# Conflicts:
#	pom.xml
#	yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java
#	yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductServiceImpl.java
#	yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/in/ErpPurchaseInSaveReqVO.java
#	yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/product/ErpProductServiceImpl.java
#	yudao-module-mall/yudao-module-product/src/main/java/cn/iocoder/yudao/module/product/controller/admin/spu/vo/ProductSkuSaveReqVO.java
#	yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptDetailServiceImpl.java
#	yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImplTest.java
This commit is contained in:
YunaiV
2026-09-26 23:03:46 +08:00
67 changed files with 1663 additions and 252 deletions
@@ -0,0 +1,142 @@
INSERT IGNORE INTO `fms_report_template` (`id`, `name`, `row_no`, `formula`, `remark`, `editable`, `sort`, `row_id`, `type`, `category`, `create_time`, `level`) VALUES
(1, '流动资产:', 0, '[]', NULL, 0, 0, 0, 1, NULL, '2021-09-18 16:39:40', 1),
(2, '流动负债:', 0, '[]', NULL, 0, 32, 0, 1, NULL, '2021-09-18 16:39:40', 1),
(3, '货币资金', 1, '[{"operator":"+","rules":0,"subjectId":1331,"subjectName":"库存现金","subjectNumber":"1001"},{"operator":"+","rules":0,"subjectId":1332,"subjectName":"银行存款","subjectNumber":"1002"},{"operator":"+","rules":0,"subjectId":1333,"subjectName":"其他货币资金","subjectNumber":"1012"}]', NULL, 1, 1, 1, 1, NULL, '2021-09-18 16:39:40', 2),
(4, '短期借款', 31, '[{"operator":"+","rules":0,"subjectId":1375,"subjectName":"短期借款","subjectNumber":"2001"}]', NULL, 1, 33, 1, 1, NULL, '2021-09-18 16:39:40', 2),
(5, '短期投资', 2, '[{"operator":"+","rules":0,"subjectId":1334,"subjectName":"短期投资","subjectNumber":"1101"}]', NULL, 1, 2, 2, 1, NULL, '2021-09-18 16:39:40', 2),
(6, '应付票据', 32, '[{"operator":"+","rules":0,"subjectId":1376,"subjectName":"应付票据","subjectNumber":"2201"}]', NULL, 1, 34, 2, 1, NULL, '2021-09-18 16:39:40', 2),
(7, '应收票据', 3, '[{"operator":"+","rules":0,"subjectId":1339,"subjectName":"应收票据","subjectNumber":"1121"}]', NULL, 1, 3, 3, 1, NULL, '2021-09-18 16:39:40', 2),
(8, '应付账款', 33, '[{"operator":"+","rules":4,"subjectId":1341,"subjectName":"预付账款","subjectNumber":"1123"},{"operator":"+","rules":4,"subjectId":1377,"subjectName":"应付账款","subjectNumber":"2202"}]', NULL, 1, 35, 3, 1, NULL, '2021-09-18 16:39:40', 2),
(9, '应收账款', 4, '[{"operator":"+","rules":3,"subjectId":1340,"subjectName":"应收账款","subjectNumber":"1122"},{"operator":"+","rules":3,"subjectId":1378,"subjectName":"预收账款","subjectNumber":"2203"}]', NULL, 1, 4, 4, 1, NULL, '2021-09-18 16:39:40', 2),
(10, '预收账款', 34, '[{"operator":"+","rules":4,"subjectId":1340,"subjectName":"应收账款","subjectNumber":"1122"},{"operator":"+","rules":4,"subjectId":1378,"subjectName":"预收账款","subjectNumber":"2203"}]', NULL, 1, 36, 4, 1, NULL, '2021-09-18 16:39:40', 2),
(11, '预付账款', 5, '[{"operator":"+","rules":3,"subjectId":1341,"subjectName":"预付账款","subjectNumber":"1123"},{"operator":"+","rules":3,"subjectId":1377,"subjectName":"应付账款","subjectNumber":"2202"}]', NULL, 1, 5, 5, 1, NULL, '2021-09-18 16:39:40', 2),
(12, '应付职工薪酬', 35, '[{"operator":"+","rules":0,"subjectId":1379,"subjectName":"应付职工薪酬","subjectNumber":"2211"}]', NULL, 1, 37, 5, 1, NULL, '2021-09-18 16:39:40', 2),
(13, '应收股利', 6, '[{"operator":"+","rules":0,"subjectId":1342,"subjectName":"应收股利","subjectNumber":"1131"}]', NULL, 1, 6, 6, 1, NULL, '2021-09-18 16:39:40', 2),
(14, '应交税费', 36, '[{"operator":"+","rules":0,"subjectId":1393,"subjectName":" 应交税费","subjectNumber":"2221"}]', NULL, 1, 38, 6, 1, NULL, '2021-09-18 16:39:40', 2),
(15, '应收利息', 7, '[{"operator":"+","rules":0,"subjectId":1343,"subjectName":"应收利息","subjectNumber":"1132"}]', NULL, 1, 7, 7, 1, NULL, '2021-09-18 16:39:40', 2),
(16, '应付利息', 37, '[{"operator":"+","rules":0,"subjectId":1412,"subjectName":"应付利息","subjectNumber":"2231"}]', NULL, 1, 39, 7, 1, NULL, '2021-09-18 16:39:40', 2),
(17, '其他应收款', 8, '[{"operator":"+","rules":0,"subjectId":1344,"subjectName":"其他应收款","subjectNumber":"1221"}]', NULL, 1, 8, 8, 1, NULL, '2021-09-18 16:39:40', 2),
(18, '应付利润', 38, '[{"operator":"+","rules":0,"subjectId":1413,"subjectName":"应付利润","subjectNumber":"2232"}]', NULL, 1, 40, 8, 1, NULL, '2021-09-18 16:39:41', 2),
(19, '存货', 9, '[{"operator":"+","rules":0,"subjectId":1345,"subjectName":"材料采购","subjectNumber":"1401"},{"operator":"+","rules":0,"subjectId":1346,"subjectName":"在途物资","subjectNumber":"1402"},{"operator":"+","rules":0,"subjectId":1347,"subjectName":"原材料","subjectNumber":"1403"},{"operator":"+","rules":0,"subjectId":1348,"subjectName":"材料成本差异","subjectNumber":"1404"},{"operator":"+","rules":0,"subjectId":1349,"subjectName":"库存商品","subjectNumber":"1405"},{"operator":"-","rules":0,"subjectId":1350,"subjectName":"商品进销差价","subjectNumber":"1407"},{"operator":"+","rules":0,"subjectId":1351,"subjectName":"委托加工物资","subjectNumber":"1408"},{"operator":"+","rules":0,"subjectId":1352,"subjectName":"周转材料","subjectNumber":"1411"},{"operator":"+","rules":0,"subjectId":1353,"subjectName":"消耗性生物资产","subjectNumber":"1421"},{"operator":"+","rules":0,"subjectId":1437,"subjectName":"生产成本","subjectNumber":"4001"},{"operator":"+","rules":0,"subjectId":1440,"subjectName":"制造费用","subjectNumber":"4101"},{"operator":"+","rules":0,"subjectId":1442,"subjectName":"工程施工","subjectNumber":"4401"},{"operator":"+","rules":0,"subjectId":1443,"subjectName":"机械作业","subjectNumber":"4403"}]', NULL, 1, 9, 9, 1, NULL, '2021-09-18 16:39:41', 2),(20, '其他应付款', 39, '[{"operator":"+","rules":0,"subjectId":1414,"subjectName":"其他应付款","subjectNumber":"2241"}]', NULL, 1, 41, 9, 1, NULL, '2021-09-18 16:39:41', 2),
(21, '其中:原材料', 10, '[{"operator":"+","rules":0,"subjectId":1347,"subjectName":"原材料","subjectNumber":"1403"}]', NULL, 1, 10, 10, 1, NULL, '2021-09-18 16:39:41', 2),
(22, '其他流动负债', 40, '[{"operator":"+","rules":0,"subjectId":1396,"subjectName":"待转销项税额","subjectNumber":"222106"}]', NULL, 1, 42, 10, 1, NULL, '2021-09-18 16:39:41', 2),
(23, '在产品', 11, '[{"operator":"+","rules":0,"subjectId":1437,"subjectName":"生产成本","subjectNumber":"4001"},{"operator":"+","rules":0,"subjectId":1440,"subjectName":"制造费用","subjectNumber":"4101"},{"operator":"+","rules":0,"subjectId":1442,"subjectName":"工程施工","subjectNumber":"4401"},{"operator":"+","rules":0,"subjectId":1443,"subjectName":"机械作业","subjectNumber":"4403"}]', NULL, 1, 11, 11, 1, NULL, '2021-09-18 16:39:41', 2),
(24, '流动负债合计', 41, '["L31+L32+L33+L34+L35+L36+L37+L38+L39+L40"]', NULL, 0, 43, 11, 1, NULL, '2021-09-18 16:39:41', 1),
(25, '库存商品', 12, '[{"operator":"+","rules":0,"subjectId":1349,"subjectName":"库存商品","subjectNumber":"1405"}]', NULL, 1, 12, 12, 1, NULL, '2021-09-18 16:39:41', 2),
(26, '非流动负债:', 0, '[]', NULL, 0, 44, 12, 1, NULL, '2021-09-18 16:39:41', 1),
(27, '周转材料', 13, '[{"operator":"+","rules":0,"subjectId":1352,"subjectName":"周转材料","subjectNumber":"1411"}]', NULL, 1, 13, 13, 1, NULL, '2021-09-18 16:39:41', 2),
(28, '长期借款', 42, '[{"operator":"+","rules":0,"subjectId":1416,"subjectName":"长期借款","subjectNumber":"2501"}]', NULL, 1, 45, 13, 1, NULL, '2021-09-18 16:39:41', 2),
(29, '其他流动资产', 14, '[{"operator":"+","rules":0,"subjectId":1394,"subjectName":"待抵扣进项税额","subjectNumber":"222104"},{"operator":"+","rules":0,"subjectId":1395,"subjectName":"待认证进项税额","subjectNumber":"222105"},{"operator":"+","rules":1,"subjectId":1392,"subjectName":"未交增值税","subjectNumber":"222102"},{"operator":"+","rules":0,"subjectId":1397,"subjectName":"增值税留抵税额","subjectNumber":"222107"}]', NULL, 1, 14, 14, 1, NULL, '2021-09-18 16:39:41', 2),
(30, '长期应付款', 43, '[{"operator":"+","rules":0,"subjectId":1417,"subjectName":"长期应付款","subjectNumber":"2701"}]', NULL, 1, 46, 14, 1, NULL, '2021-09-18 16:39:41', 2),
(31, '流动资产合计', 15, '["L1+L2+L3+L4+L5+L6+L7+L8+L9+L14"]', NULL, 0, 15, 15, 1, NULL, '2021-09-18 16:39:41', 1),
(32, '递延收益', 44, '[{"operator":"+","rules":0,"subjectId":1415,"subjectName":"递延收益","subjectNumber":"2401"}]', NULL, 1, 47, 15, 1, NULL, '2021-09-18 16:39:41', 2),
(33, '非流动资产:', 0, '[]', NULL, 0, 16, 16, 1, NULL, '2021-09-18 16:39:41', 1),
(34, '其他非流动负债', 45, '[]', NULL, 0, 48, 16, 1, NULL, '2021-09-18 16:39:41', 2),
(35, '长期债券投资', 16, '[{"operator":"+","rules":0,"subjectId":1354,"subjectName":"长期债券投资","subjectNumber":"1501"}]', NULL, 1, 17, 17, 1, NULL, '2021-09-18 16:39:41', 2),
(36, '非流动负债合计', 46, '["L42+L43+L44+L45"]', NULL, 0, 49, 17, 1, NULL, '2021-09-18 16:39:41', 1),
(37, '长期股权投资', 17, '[{"operator":"+","rules":0,"subjectId":1357,"subjectName":"长期股权投资","subjectNumber":"1511"}]', NULL, 1, 18, 18, 1, NULL, '2021-09-18 16:39:41', 2),
(38, '负债合计', 47, '["L41+L46"]', NULL, 0, 50, 18, 1, NULL, '2021-09-18 16:39:41', 1),
(39, '固定资产原价', 18, '[{"operator":"+","rules":0,"subjectId":1360,"subjectName":"固定资产","subjectNumber":"1601"}]', NULL, 1, 19, 19, 1, NULL, '2021-09-18 16:39:41', 2),
(40, '减:累计折旧', 19, '[{"operator":"+","rules":0,"subjectId":1361,"subjectName":"累计折旧","subjectNumber":"1602"}]', NULL, 1, 20, 20, 1, NULL, '2021-09-18 16:39:41', 2),
(41, '固定资产账面价值', 20, '["L18-L19"]', NULL, 0, 21, 21, 1, NULL, '2021-09-18 16:39:41', 2),
(42, '在建工程', 21, '[{"operator":"+","rules":0,"subjectId":1362,"subjectName":"在建工程","subjectNumber":"1604"}]', NULL, 1, 22, 22, 1, NULL, '2021-09-18 16:39:41', 2),
(43, '工程物资', 22, '[{"operator":"+","rules":0,"subjectId":1367,"subjectName":"工程物资","subjectNumber":"1605"}]', NULL, 1, 23, 23, 1, NULL, '2021-09-18 16:39:41', 2),
(44, '固定资产清理', 23, '[{"operator":"+","rules":0,"subjectId":1368,"subjectName":"固定资产清理","subjectNumber":"1606"}]', NULL, 1, 24, 24, 1, NULL, '2021-09-18 16:39:41', 2),
(45, '生产性生物资产', 24, '[{"operator":"+","rules":0,"subjectId":1369,"subjectName":"生产性生物资产","subjectNumber":"1621"},{"operator":"-","rules":0,"subjectId":1370,"subjectName":"生产性生物资产累计折旧","subjectNumber":"1622"}]', NULL, 1, 25, 25, 1, NULL, '2021-09-18 16:39:41', 2),(46, '所有者权益(或股东权益):', 0, '[]', NULL, 0, 51, 25, 1, NULL, '2021-09-18 16:39:41', 1),
(47, '无形资产', 25, '[{"operator":"+","rules":0,"subjectId":1371,"subjectName":"无形资产","subjectNumber":"1701"},{"operator":"-","rules":0,"subjectId":1372,"subjectName":"累计摊销","subjectNumber":"1702"}]', NULL, 1, 26, 26, 1, NULL, '2021-09-18 16:39:41', 2),(48, '实收资本(或股本)', 48, '[{"operator":"+","rules":0,"subjectId":1418,"subjectName":"实收资本","subjectNumber":"3001"}]', NULL, 1, 52, 26, 1, NULL, '2021-09-18 16:39:41', 2),
(49, '开发支出', 26, '[{"operator":"+","rules":0,"subjectId":1441,"subjectName":"研发支出","subjectNumber":"4301"}]', NULL, 1, 27, 27, 1, NULL, '2021-09-18 16:39:41', 2),
(50, '资本公积', 49, '[{"operator":"+","rules":0,"subjectId":1419,"subjectName":"资本公积","subjectNumber":"3002"}]', NULL, 1, 53, 27, 1, NULL, '2021-09-18 16:39:41', 2),
(51, '长期待摊费用', 27, '[{"operator":"+","rules":0,"subjectId":1373,"subjectName":"长期待摊费用","subjectNumber":"1801"}]', NULL, 1, 28, 28, 1, NULL, '2021-09-18 16:39:41', 2),
(52, '盈余公积', 50, '[{"operator":"+","rules":0,"subjectId":1424,"subjectName":"盈余公积","subjectNumber":"3101"}]', NULL, 1, 54, 28, 1, NULL, '2021-09-18 16:39:41', 2),
(53, '其他非流动资产', 28, '[{"operator":"+","rules":0,"subjectId":1374,"subjectName":"待处理财产损溢","subjectNumber":"1901"}]', NULL, 1, 29, 29, 1, NULL, '2021-09-18 16:39:41', 2),
(54, '未分配利润', 51, '[{"operator":"+","rules":0,"subjectId":1428,"subjectName":"本年利润","subjectNumber":"3103"},{"operator":"+","rules":0,"subjectId":1429,"subjectName":"利润分配","subjectNumber":"3104"}]', NULL, 1, 55, 29, 1, NULL, '2021-09-18 16:39:41', 2),
(55, '非流动资产合计', 29, '["L16+L17+L20+L21+L22+L23+L24+L25+L26+L27+L28"]', NULL, 0, 30, 30, 1, NULL, '2021-09-18 16:39:41', 1),
(56, '所有者权益(或股东权益)合计', 52, '["L48+L49+L50+L51"]', NULL, 0, 56, 30, 1, NULL, '2021-09-18 16:39:41', 1),
(57, '资产总计', 30, '["L15+L29"]', NULL, 0, 31, 31, 1, NULL, '2021-09-18 16:39:41', 1),
(58, '负债和所有者权益(或股东权益)总计', 53, '["L47+L52"]', NULL, 0, 57, 31, 1, NULL, '2021-09-18 16:39:41', 1),
(59, '一、营业收入', 1, '[{"operator":"+","rules":6,"subjectId":1444,"subjectName":"主营业务收入","subjectNumber":"5001"},{"operator":"+","rules":6,"subjectId":1445,"subjectName":"其他业务收入","subjectNumber":"5051"}]', NULL, 1, 0, NULL, 2, NULL, '2021-09-18 16:39:56', 1),
(60, '减:营业成本', 2, '[{"operator":"+","rules":5,"subjectId":1453,"subjectName":"主营业务成本","subjectNumber":"5401"},{"operator":"+","rules":5,"subjectId":1454,"subjectName":"其他业务成本","subjectNumber":"5402"}]', NULL, 1, 1, NULL, 2, NULL, '2021-09-18 16:39:56', 2),
(61, '税金及附加', 3, '[{"operator":"+","rules":5,"subjectId":1455,"subjectName":"税金及附加","subjectNumber":"5403"}]', NULL, 1, 2, NULL, 2, NULL, '2021-09-18 16:39:56', 2),
(62, '其中:消费税', 4, '[{"operator":"+","rules":5,"subjectId":1456,"subjectName":"消费税","subjectNumber":"540301"}]', NULL, 1, 3, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(63, '城市维护建设税', 6, '[{"operator":"+","rules":5,"subjectId":1457,"subjectName":"城市维护建设税","subjectNumber":"540303"}]', NULL, 1, 4, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(64, '资源税', 7, '[{"operator":"+","rules":5,"subjectId":1458,"subjectName":"资源税","subjectNumber":"540304"}]', NULL, 1, 5, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(65, '土地增值税', 8, '[{"operator":"+","rules":5,"subjectId":1459,"subjectName":"土地增值税","subjectNumber":"540305"}]', NULL, 1, 6, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(66, '城镇土地使用税、房产税、车船税、印花税', 9, '[{"operator":"+","rules":5,"subjectId":1460,"subjectName":"城镇土地使用税","subjectNumber":"540306"},{"operator":"+","rules":5,"subjectId":1462,"subjectName":"车船税","subjectNumber":"540308"},{"operator":"+","rules":5,"subjectId":1461,"subjectName":"房产税","subjectNumber":"540307"},{"operator":"+","rules":5,"subjectId":1463,"subjectName":"印花税","subjectNumber":"540309"}]', NULL, 1, 7, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(67, '教育费附加、矿产资源补偿费、排污费', 10, '[{"operator":"+","rules":5,"subjectId":1466,"subjectName":"排污费","subjectNumber":"540312"},{"operator":"+","rules":5,"subjectId":1465,"subjectName":"矿产资源补偿费","subjectNumber":"540311"},{"operator":"+","rules":5,"subjectId":1464,"subjectName":"教育费附加","subjectNumber":"540310"}]', NULL, 1, 8, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(68, '销售费用', 11, '[{"operator":"+","rules":5,"subjectId":1468,"subjectName":"销售费用","subjectNumber":"5601"}]', NULL, 1, 9, NULL, 2, NULL, '2021-09-18 16:39:56', 2),
(69, '其中:商品维修费', 12, '[{"operator":"+","rules":5,"subjectId":1483,"subjectName":"商品维修费","subjectNumber":"560115"}]', NULL, 1, 10, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(70, '广告费和业务宣传费', 13, '[{"operator":"+","rules":5,"subjectId":1484,"subjectName":"广告和业务宣传费","subjectNumber":"560116"}]', NULL, 1, 11, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(71, '管理费用', 14, '[{"operator":"+","rules":5,"subjectId":1486,"subjectName":"管理费用","subjectNumber":"5602"}]', NULL, 1, 12, NULL, 2, NULL, '2021-09-18 16:39:56', 2),
(72, '其中:开办费', 15, '[{"operator":"+","rules":5,"subjectId":1499,"subjectName":"开办费","subjectNumber":"560213"}]', NULL, 1, 13, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(73, '业务招待费', 16, '[{"operator":"+","rules":5,"subjectId":1491,"subjectName":"交际应酬费","subjectNumber":"560205"}]', NULL, 1, 14, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(74, '研究费用', 17, '[{"operator":"+","rules":5,"subjectId":1501,"subjectName":"研究费用","subjectNumber":"560215"}]', NULL, 1, 15, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(75, '财务费用', 18, '[{"operator":"+","rules":5,"subjectId":1503,"subjectName":"财务费用","subjectNumber":"5603"}]', NULL, 1, 16, NULL, 2, NULL, '2021-09-18 16:39:56', 2),
(76, '其中:利息费用(收入以“-”号填列)', 19, '[{"operator":"+","rules":5,"subjectId":1505,"subjectName":"利息","subjectNumber":"560302"}]', NULL, 1, 17, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(77, '加:投资收益(损失以“-”号填列)', 20, '[{"operator":"+","rules":6,"subjectId":1446,"subjectName":"投资收益","subjectNumber":"5111"}]', NULL, 1, 18, NULL, 2, NULL, '2021-09-18 16:39:56', 2),
(78, '二、营业利润(亏损以“-”号填列)', 21, '["L1-L2-L3-L11-L14-L18+L20"]', NULL, 0, 19, NULL, 2, NULL, '2021-09-18 16:39:56', 1),(79, '加:营业外收入', 22, '[{"operator":"+","rules":6,"subjectId":1447,"subjectName":"营业外收入","subjectNumber":"5301"}]', NULL, 1, 20, NULL, 2, NULL, '2021-09-18 16:39:56', 2),
(80, '其中:政府补助', 23, '[{"operator":"+","rules":6,"subjectId":1449,"subjectName":"政府补助","subjectNumber":"530102"}]', NULL, 1, 21, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(81, '减:营业外支出', 24, '[{"operator":"+","rules":5,"subjectId":1508,"subjectName":"营业外支出","subjectNumber":"5711"}]', NULL, 1, 22, NULL, 2, NULL, '2021-09-18 16:39:56', 2),
(82, '其中:坏账损失', 25, '[{"operator":"+","rules":5,"subjectId":1511,"subjectName":"坏账损失","subjectNumber":"571103"}]', NULL, 1, 23, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(83, '无法收回的长期债券投资损失', 26, '[{"operator":"+","rules":5,"subjectId":1512,"subjectName":"无法收回的长期债券投资损失","subjectNumber":"571104"}]', NULL, 1, 24, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(84, '无法收回的长期股权投资损失', 27, '[{"operator":"+","rules":5,"subjectId":1513,"subjectName":"无法收回的长期股权投资损失","subjectNumber":"571105"}]', NULL, 1, 25, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(85, '自然灾害等不可抗力因素造成的损失', 28, '[{"operator":"+","rules":5,"subjectId":1514,"subjectName":"自然灾害等不可抗力造成的损失","subjectNumber":"571106"}]', NULL, 1, 26, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(86, '税收滞纳金', 29, '[{"operator":"+","rules":5,"subjectId":1515,"subjectName":"税收滞纳金","subjectNumber":"571107"}]', NULL, 1, 27, NULL, 2, NULL, '2021-09-18 16:39:56', 3),
(87, '三、利润总额(亏损总额以“-”号填列)', 30, '["L21+L22-L24"]', NULL, 0, 28, NULL, 2, NULL, '2021-09-18 16:39:56', 1),
(88, '减:所得税费用', 31, '[{"operator":"+","rules":5,"subjectId":1519,"subjectName":"所得税费用","subjectNumber":"5801"}]', NULL, 1, 29, NULL, 2, NULL, '2021-09-18 16:39:56', 2),
(89, '四、净利润(净亏损以“-”号填列)', 32, '["L30-L31"]', NULL, 0, 30, NULL, 2, NULL, '2021-09-18 16:39:56', 1);
INSERT IGNORE INTO `fms_report_template` (`id`, `name`, `row_no`, `formula`, `remark`, `editable`, `sort`, `row_id`, `type`, `category`, `create_time`, `level`) VALUES
(90, '一、经营活动产生的现金流量:', 0, '[]', NULL, 0, 0, 0, 3, 1, '2021-09-18 16:39:59', 1),
(91, '销售产成品、商品、提供劳务收到的现金', 1, '["IN1-((BA[3,2]-BA[3,1])+(BA[4,2]-BA[4,1])-(BA[34,2]-BA[34,1]))+EX5+EX11"]', NULL, 0, 1, 1, 3, 1, '2021-09-18 16:39:59', 2),
(92, '收到其他与经营活动有关的现金', 2, '["((IN22-(BA[8,2]-BA[8,1])) < 0 ? 0 : (IN22-(BA[8,2]-BA[8,1])))"]', NULL, 0, 2, 2, 3, 1, '2021-09-18 16:39:59', 2),
(93, '购买原材料、商品、接受劳务支付的现金', 3, '["IN2+(BA[9,2]-BA[9,1])-(BA[32,2]-BA[32,1])-(BA[33,2]-BA[33,1])+(BA[5,2]-BA[5,1])+EX6"]', NULL, 0, 3, 3, 3, 1, '2021-09-18 16:39:59', 2),
(94, '支付的职工薪酬', 4, '["EX4"]', NULL, 0, 4, 4, 3, 1, '2021-09-18 16:39:59', 2),
(95, '支付的税费', 5, '["EX12-(BA[36,2]-BA[36,1])"]', NULL, 0, 5, 5, 3, 1, '2021-09-18 16:39:59', 2),
(96, '支付其他与经营活动有关的现金', 6, '["(((IN24+IN14+IN11+IN18+IN3+IN19)-(EX15+EX16+EX4+EX18+EX14+EX17+EX13+EX10+EX9+EX8-EX19)+(BA[14,2]-BA[14,1])-(BA[39,2]-BA[39,1]-(BA[35,2]-BA[35,1])-(BA[40,2]-BA[40,1]))) < 0 ? 0 : ((IN24+IN14+IN11+IN18+IN3+IN19)-(EX15+EX16+EX4+EX18+EX14+EX17+EX13+EX10+EX9+EX8-EX19)+(BA[14,2]-BA[14,1])-(BA[39,2]-BA[39,1]-(BA[35,2]-BA[35,1])-(BA[40,2]-BA[40,1]))))"]', NULL, 0, 6, 6, 3, 1, '2021-09-18 16:39:59', 2),
(97, '经营活动产生的现金流量净额', 7, '["L1+L2-L3-L4-L5-L6"]', NULL, 0, 7, 7, 3, 1, '2021-09-18 16:39:59', 2),
(98, '二、投资活动产生的现金流量:', 0, '[]', NULL, 0, 8, 8, 3, 1, '2021-09-18 16:39:59', 1),
(99, '收回短期投资、长期债券投资和长期股权投资收到的现金', 8, '["((BA[2,2]-BA[2,1])+(BA[16,2]-BA[16,1])+(BA[17,2]-BA[17,1]))*(-1) < 0 ? 0 : ((BA[2,2]-BA[2,1])+(BA[16,2]-BA[16,1])+(BA[17,2]-BA[17,1]))*(-1)"]', NULL, 0, 9, 9, 3, 1, '2021-09-18 16:39:59', 2),
(100, '取得投资收益收到的现金', 9, '["IN20-((BA[7,2]-BA[7,1])+(BA[6,2]-BA[6,1]))"]', NULL, 0, 10, 10, 3, 1, '2021-09-18 16:39:59', 2),
(101, '处置固定资产、无形资产和其他非流动资产收回的现金净额', 10, '[]', NULL, 0, 11, 11, 3, 1, '2021-09-18 16:39:59', 2),
(102, '短期投资、长期债券投资和长期股权投资支付的现金', 11, '["(((BA[2,2]-BA[2,1])+(BA[16,2]-BA[16,1])+(BA[17,2]-BA[17,1]))*(-1)) < 0 ? (((BA[2,2]-BA[2,1])+(BA[16,2]-BA[16,1])+(BA[17,2]-BA[17,1]))*(-1))*(-1) : 0"]', NULL, 0, 12, 12, 3, 1, '2021-09-18 16:39:59', 2),
(103, '购建固定资产、无形资产和其他非流动资产支付的现金', 12, '["((BA[21,2]-BA[21,1])+(BA[20,2]-BA[20,1])+(BA[25,2]-BA[25,1])+(BA[28,2]-BA[28,1])+(BA[23,2]-BA[23,1])+(BA[22,2]-BA[22,1])+(BA[26,2]-BA[26,1])+(BA[27,2]-BA[27,1])+(BA[24,2]-BA[24,1]))+(EX15+EX16+EX18)+(EX13+EX17)"]', NULL, 0, 13, 13, 3, 1, '2021-09-18 16:39:59', 2),
(104, '投资活动产生的现金流量净额', 13, '["L8+L9+L10-L11-L12"]', NULL, 0, 14, 14, 3, 1, '2021-09-18 16:39:59', 2),
(105, '三、筹资活动产生的现金流量:', 0, '[]', NULL, 0, 15, 15, 3, 1, '2021-09-18 16:39:59', 1),
(106, '取得借款收到的现金', 14, '["((BA[31,2]-BA[31,1])+(BA[42,2]-BA[42,1])+(BA[43,2]-BA[43,1])+(BA[44,2]-BA[44,1])+(BA[45,2]-BA[45,1])) < 0 ? 0 : ((BA[31,2]-BA[31,1])+(BA[42,2]-BA[42,1])+(BA[43,2]-BA[43,1])+(BA[44,2]-BA[44,1])+(BA[45,2]-BA[45,1]))"]', NULL, 0, 16, 16, 3, 1, '2021-09-18 16:39:59', 2),
(107, '吸收投资者投资收到的现金', 15, '["(BA[48,2]-BA[48,1])+(BA[49,2]-BA[49,1])+(BA[50,2]-BA[50,1])"]', NULL, 0, 17, 17, 3, 1, '2021-09-18 16:39:59', 2),
(108, '偿还借款本金支付的现金', 16, '["((BA[31,2]-BA[31,1])+(BA[42,2]-BA[42,1])+(BA[43,2]-BA[43,1])+(BA[44,2]-BA[44,1])+(BA[45,2]-BA[45,1])) < 0 ? ((BA[31,2]-BA[31,1])+(BA[42,2]-BA[42,1])+(BA[43,2]-BA[43,1])+(BA[44,2]-BA[44,1])+(BA[45,2]-BA[45,1]))*(-1) : 0"]', NULL, 0, 18, 18, 3, 1, '2021-09-18 16:39:59', 2),
(109, '偿还借款利息支付的现金', 17, '["IN19-(BA[37,2]-BA[37,1])"]', NULL, 0, 19, 19, 3, 1, '2021-09-18 16:39:59', 2),
(110, '分配利润支付的现金', 18, '["IN32-((BA[38,2]-BA[38,1])+(BA[51,2]-BA[51,1]))"]', NULL, 0, 20, 20, 3, 1, '2021-09-18 16:39:59', 2),
(111, '筹资活动产生的现金流量净额', 19, '["L14+L15-L16-L17-L18"]', NULL, 0, 21, 21, 3, 1, '2021-09-18 16:39:59', 2),(112, '四、现金净增加额', 20, '["L7+L13+L19"]', NULL, 0, 22, 22, 3, 1, '2021-09-18 16:39:59', 1),
(113, '加:期初现金余额', 21, '["BA[1,1]"]', NULL, 0, 23, 23, 3, 1, '2021-09-18 16:39:59', 2),
(114, '五、期末现金余额', 22, '["L20+L21"]', NULL, 0, 24, 24, 3, 1, '2021-09-18 16:39:59', 1);
INSERT IGNORE INTO `fms_report_template` (`id`, `name`, `row_no`, `formula`, `remark`, `editable`, `sort`, `row_id`, `type`, `category`, `create_time`, `level`) VALUES
(115, '支付给职工的工资', 1, '[{"operator":"+","rules":5,"subjectId":1379,"subjectName":"应付职工薪酬","subjectNumber":"2211"}]', NULL, 1, 0, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(116, '支付给职工的四金', 2, '[]', NULL, 1, 1, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(117, '支付给职工的其他福利费', 3, '[]', NULL, 1, 2, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(118, '支付给职工以及为职工支付的现金', 4, '["L1+L2+L3"]', '行 1、2、3 之和,参与现金流量表行 4 计算', 0, 3, NULL, 4, 1, '2021-09-18 16:40:02', 2),
(119, '销项税额(小规模纳税人填本年累计应交增值税)', 5, '[{"operator":"+","rules":6,"subjectId":1388,"subjectName":"销项税额","subjectNumber":"22210107"}]', '参与现金流量表行 1 计算', 1, 4, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(120, '进项税额(小规模纳税人不填此项)', 6, '[{"operator":"+","rules":5,"subjectId":1382,"subjectName":"进项税额","subjectNumber":"22210101"}]', '参与现金流量表行 3 计算', 1, 5, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(121, '应交增值税', 7, '["L5-L6"]', '行 5、6 之差', 0, 6, NULL, 4, 1, '2021-09-18 16:40:02', 2),
(122, '主营业务税金及附加', 8, '[{"operator":"+","rules":7,"subjectId":1455,"subjectName":"税金及附加","subjectNumber":"5403"}]', '参与现金流量表行 6 计算', 1, 7, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(123, '所得税', 9, '[{"operator":"+","rules":7,"subjectId":1519,"subjectName":"所得税费用","subjectNumber":"5801"}]', '参与现金流量表行 6 计算', 1, 8, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(124, '管理费用中列支的税金', 10, '[]', '参与现金流量表行 6 计算', 1, 9, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(125, '在其他业务支出中列支的税金', 11, '[]', '参与现金流量表行 1 计算', 1, 10, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(126, '应缴纳各项税金合计', 12, '["L7+L8+L9+L10+L11"]', '行 7、8、9、10、11 之和,参与现金流量表行 5 计算', 0, 11, NULL, 4, 1, '2021-09-18 16:40:02', 2),
(127, '计提固定资产折旧', 13, '[{"operator":"+","rules":6,"subjectId":1370,"subjectName":"生产性生物资产累计折旧","subjectNumber":"1622"},{"operator":"+","rules":6,"subjectId":1361,"subjectName":"累计折旧","subjectNumber":"1602"}]', '参与现金流量表行 6、12 计算', 1, 12, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(128, '利息支出', 14, '[{"operator":"+","rules":7,"subjectId":1505,"subjectName":"利息","subjectNumber":"560302"}]', '参与现金流量表行 6 计算', 1, 13, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(129, '无形资产摊销', 15, '[]', '参与现金流量表行 6、12 计算', 1, 14, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(130, '长期待摊费用摊销', 16, '[]', '参与现金流量表行 6、12 计算', 1, 15, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(131, '计提的各项减值准备(仅反映本年与上年计提的差额)', 17, '[]', '参与现金流量表行 12 计算', 1, 16, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(132, '处置固定资产、无形资产和其他长期资产的损失(减:收益)', 18, '[]', '参与现金流量表行 6、12 计算', 1, 17, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(133, '汇兑收益(减:汇兑损失)', 19, '[]', '参与现金流量表行 6 计算', 1, 18, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(134, '不涉及现金收支的投资活动', 20, '[]', '参与现金流量表行 8 计算', 1, 19, NULL, 4, 1, '2021-09-18 16:40:02', 1),
(135, '不涉及现金收支的筹资活动', 21, '[]', '参与现金流量表行 15 计算', 1, 20, NULL, 4, 1, '2021-09-18 16:40:02', 1);
-- 修正旧版默认公式;仅匹配原始公式,保留用户自定义内容
UPDATE `fms_report_template` SET `formula` = '[{"operator":"+","rules":0,"subjectId":1345,"subjectName":"材料采购","subjectNumber":"1401"},{"operator":"+","rules":0,"subjectId":1346,"subjectName":"在途物资","subjectNumber":"1402"},{"operator":"+","rules":0,"subjectId":1347,"subjectName":"原材料","subjectNumber":"1403"},{"operator":"+","rules":0,"subjectId":1348,"subjectName":"材料成本差异","subjectNumber":"1404"},{"operator":"+","rules":0,"subjectId":1349,"subjectName":"库存商品","subjectNumber":"1405"},{"operator":"-","rules":0,"subjectId":1350,"subjectName":"商品进销差价","subjectNumber":"1407"},{"operator":"+","rules":0,"subjectId":1351,"subjectName":"委托加工物资","subjectNumber":"1408"},{"operator":"+","rules":0,"subjectId":1352,"subjectName":"周转材料","subjectNumber":"1411"},{"operator":"+","rules":0,"subjectId":1353,"subjectName":"消耗性生物资产","subjectNumber":"1421"},{"operator":"+","rules":0,"subjectId":1437,"subjectName":"生产成本","subjectNumber":"4001"},{"operator":"+","rules":0,"subjectId":1440,"subjectName":"制造费用","subjectNumber":"4101"},{"operator":"+","rules":0,"subjectId":1442,"subjectName":"工程施工","subjectNumber":"4401"},{"operator":"+","rules":0,"subjectId":1443,"subjectName":"机械作业","subjectNumber":"4403"}]' WHERE `id` = 19 AND `formula` = '[{"operator":"+","rules":0,"subjectId":1345,"subjectName":"材料采购","subjectNumber":"1401"},{"operator":"+","rules":0,"subjectId":1346,"subjectName":"在途物资","subjectNumber":"1402"},{"operator":"+","rules":0,"subjectId":1347,"subjectName":"原材料","subjectNumber":"1403"},{"operator":"+","rules":0,"subjectId":1348,"subjectName":"材料成本差异","subjectNumber":"1404"},{"operator":"+","rules":0,"subjectId":1349,"subjectName":"库存商品","subjectNumber":"1405"},{"operator":"+","rules":0,"subjectId":1350,"subjectName":"商品进销差价","subjectNumber":"1407"},{"operator":"+","rules":0,"subjectId":1351,"subjectName":"委托加工物资","subjectNumber":"1408"},{"operator":"+","rules":0,"subjectId":1352,"subjectName":"周转材料","subjectNumber":"1411"},{"operator":"+","rules":0,"subjectId":1353,"subjectName":"消耗性生物资产","subjectNumber":"1421"},{"operator":"+","rules":0,"subjectId":1437,"subjectName":"生产成本","subjectNumber":"4001"},{"operator":"+","rules":0,"subjectId":1440,"subjectName":"制造费用","subjectNumber":"4101"},{"operator":"+","rules":0,"subjectId":1442,"subjectName":"工程施工","subjectNumber":"4401"},{"operator":"+","rules":0,"subjectId":1443,"subjectName":"机械作业","subjectNumber":"4403"}]';
UPDATE `fms_report_template` SET `formula` = '[{"operator":"+","rules":0,"subjectId":1369,"subjectName":"生产性生物资产","subjectNumber":"1621"},{"operator":"-","rules":0,"subjectId":1370,"subjectName":"生产性生物资产累计折旧","subjectNumber":"1622"}]' WHERE `id` = 45 AND `formula` = '[{"operator":"+","rules":0,"subjectId":1369,"subjectName":"生产性生物资产","subjectNumber":"1621"},{"operator":"+","rules":0,"subjectId":1370,"subjectName":"生产性生物资产累计折旧","subjectNumber":"1622"}]';
UPDATE `fms_report_template` SET `formula` = '[{"operator":"+","rules":0,"subjectId":1371,"subjectName":"无形资产","subjectNumber":"1701"},{"operator":"-","rules":0,"subjectId":1372,"subjectName":"累计摊销","subjectNumber":"1702"}]' WHERE `id` = 47 AND `formula` = '[{"operator":"+","rules":0,"subjectId":1371,"subjectName":"无形资产","subjectNumber":"1701"},{"operator":"+","rules":0,"subjectId":1372,"subjectName":"累计摊销","subjectNumber":"1702"}]';
UPDATE `fms_report_template` SET `formula` = '["L1-L2-L3-L11-L14-L18+L20"]' WHERE `id` = 78 AND `formula` = '["L1-L2-L3-L11-L14-L18"]';
UPDATE `fms_report_template` SET `formula` = '["L14+L15-L16-L17-L18"]' WHERE `id` = 111 AND `formula` = '["L14+L15-L17-L18"]';
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ import cn.idev.excel.converters.Converter;
import cn.idev.excel.enums.CellDataTypeEnum;
import cn.idev.excel.metadata.GlobalConfiguration;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty;
import lombok.extern.slf4j.Slf4j;
@@ -43,4 +44,21 @@ public class AreaConvert implements Converter<Object> {
return Convert.convert(fieldClazz, area.getId());
}
@Override
public WriteCellData<String> convertToExcelData(Object object, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
// 空时,返回空
if (object == null) {
return new WriteCellData<>("");
}
// 将地区编号转换成地区全路径(与 convertToJavaData 的 parseArea 对应,使用 "/" 分隔)
Integer id = Convert.convert(Integer.class, object);
String path = AreaUtils.format(id, "/");
if (path == null) {
log.error("[convertToExcelData][id({}) 转换不了]", id);
return new WriteCellData<>("");
}
return new WriteCellData<>(path);
}
}
@@ -133,6 +133,12 @@ public class SelectSheetWriteHandler implements SheetWriteHandler {
DataValidationHelper helper = writeSheetHolder.getSheet().getDataValidationHelper(); // 需要设置下拉框的 sheet 页的数据验证助手
Workbook workbook = writeWorkbookHolder.getWorkbook(); // 获得工作簿
List<KeyValue<Integer, List<String>>> keyValues = convertList(selectMap.entrySet(), entry -> new KeyValue<>(entry.getKey(), entry.getValue()));
// 对应 issue:https://github.com/YunaiV/ruoyi-vue-pro/pull/1217
// 空字典/空选项源:跳过该列下拉,避免拼出 $F$1:$F$0 非法区间致 POI FormulaParseException(该列退化为普通文本列)
keyValues.removeIf(item -> CollUtil.isEmpty(item.getValue()));
if (CollUtil.isEmpty(keyValues)) {
return; // 全部为空时连「字典sheet」都不创建
}
keyValues.sort(Comparator.comparing(item -> item.getValue().size())); // 升序不然创建下拉会报错
// 2. 创建数据字典的 sheet 页
@@ -13,7 +13,7 @@ import org.springframework.core.task.SimpleAsyncTaskExecutor;
* 异步任务 Configuration
*/
@AutoConfiguration
@EnableAsync
@EnableAsync(proxyTargetClass = true) // proxyTargetClass=true:与 @EnableTransactionManagement(proxyTargetClass = true) 保持一致,否则会有 https://github.com/YunaiV/ruoyi-vue-pro/pull/1223 问题
public class YudaoAsyncAutoConfiguration {
@Bean
@@ -13,7 +13,7 @@ import lombok.Data;
* @author lesan
*/
@TableName(value = "ai_workflow", autoResultMap = true)
@KeySequence("ai_workflow") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@KeySequence("ai_workflow_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@Data
public class AiWorkflowDO extends BaseDO {
@@ -44,6 +44,7 @@ public interface ErrorCodeConstants {
ErrorCode PROCESS_INSTANCE_HTTP_CALL_ERROR = new ErrorCode(1_009_004_006, "流程 Http 请求调用失败");
ErrorCode PROCESS_INSTANCE_APPROVE_USER_SELECT_ASSIGNEES_NOT_CONFIG = new ErrorCode(1_009_004_007, "下一个任务({})的审批人未配置");
ErrorCode PROCESS_INSTANCE_CANCEL_CHILD_FAIL_NOT_ALLOW = new ErrorCode(1_009_004_008, "子流程取消失败,子流程不允许取消");
ErrorCode PROCESS_INSTANCE_START_FORM_FIELD_REQUIRED = new ErrorCode(1_009_004_009, "发起流程失败,表单字段({})为必填项,不能为空");
// ========== 流程任务 1-009-005-000 ==========
ErrorCode TASK_OPERATE_FAIL_ASSIGN_NOT_SELF = new ErrorCode(1_009_005_001, "操作失败,原因:该任务的审批人不是你");
@@ -22,6 +22,7 @@ import cn.iocoder.yudao.module.bpm.convert.task.BpmProcessInstanceConvert;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.dal.redis.BpmProcessIdRedisDAO;
import cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants;
import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelFormTypeEnum;
import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelTypeEnum;
import cn.iocoder.yudao.module.bpm.enums.definition.BpmSimpleModelNodeTypeEnum;
import cn.iocoder.yudao.module.bpm.enums.task.BpmAttachmentTypeEnum;
@@ -763,11 +764,52 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
// 获得流程定义
ProcessDefinition definition = processDefinitionService
.getProcessDefinition(createReqVO.getProcessDefinitionId());
// 校验动态表单必填字段(前端校验可被接口直调绕过,后端必须兜底,否则必填缺失也会建成脏实例)
validateDynamicFormRequiredFields(definition, createReqVO.getVariables());
// 发起流程
return createProcessInstance0(userId, definition, createReqVO.getVariables(), null,
createReqVO.getStartUserSelectAssignees());
}
/**
* 校验动态表单的必填字段
*
* @see <a href="https://github.com/YunaiV/ruoyi-vue-pro/pull/1245">相关 issue</a>
*/
private void validateDynamicFormRequiredFields(ProcessDefinition definition, Map<String, Object> variables) {
BpmProcessDefinitionInfoDO processDefinitionInfo = processDefinitionService
.getProcessDefinitionInfo(definition.getId());
if (processDefinitionInfo == null
|| !BpmModelFormTypeEnum.NORMAL.getType().equals(processDefinitionInfo.getFormType())
|| CollUtil.isEmpty(processDefinitionInfo.getFormFields())) {
return;
}
List<String> fieldStrs = processDefinitionInfo.getFormFields();
// formFields 是 JSON 数组,每个元素本身又是 JSON 字符串;两种表单设计器并存:
// 字段标识可能是 field 或 key,必填标记可能是数组 ["required"] 或对象 {"required": true}
for (String fieldStr : fieldStrs) {
Map<String, Object> field = JsonUtils.parseMap(fieldStr);
boolean required = false;
Object validate = field.get("validate");
if (validate instanceof List) {
required = ((List<?>) validate).contains("required");
} else if (validate instanceof Map) {
required = Boolean.TRUE.equals(((Map<?, ?>) validate).get("required"));
}
if (!required) {
continue;
}
String fieldId = Convert.toStr(field.get("field"), Convert.toStr(field.get("key")));
if (StrUtil.isEmpty(fieldId)) {
continue;
}
Object value = variables == null ? null : variables.get(fieldId);
if (value == null || (value instanceof String && StrUtil.isBlank((String) value))) {
throw exception(PROCESS_INSTANCE_START_FORM_FIELD_REQUIRED, Convert.toStr(field.get("title"), fieldId));
}
}
}
@Override
@DataPermission(enable = false) // 关闭数据权限,避免查询不到用户数据。相关案例:https://gitee.com/zhijiantianya/yudao-cloud/issues/ID1UYA
public String createProcessInstance(Long userId, @Valid BpmProcessInstanceCreateReqDTO createReqDTO) {
@@ -125,7 +125,7 @@ public class CrmBusinessRespVO {
@Schema(description = "产品条码", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
private String productNo;
@Schema(description = "产品单位", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
private Integer productUnit;
private int productUnit;
@Schema(description = "产品单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00")
private BigDecimal productPrice;
@@ -143,7 +143,7 @@ public class CrmContractRespVO {
@Schema(description = "产品条码", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
private String productNo;
@Schema(description = "产品单位", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
private Integer productUnit;
private int productUnit;
@Schema(description = "产品单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00")
private BigDecimal productPrice;
@@ -6,12 +6,13 @@ import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmCustomerParseFun
import cn.iocoder.yudao.module.crm.framework.operatelog.core.SysAdminUserParseFunction;
import com.mzt.logapi.starter.annotation.DiffLogField;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@@ -67,6 +68,7 @@ public class CrmContractSaveReqVO {
@Schema(description = "合同金额", example = "5617")
@DiffLogField(name = "合同金额")
@DecimalMin(value = "0", message = "合同金额不能小于 0")
private BigDecimal totalPrice;
@Schema(description = "客户签约人编号", example = "18546")
@@ -35,7 +35,7 @@ public class CrmProductRespVO implements VO {
@Schema(description = "单位", example = "2")
@ExcelProperty(value = "单位", converter = DictConvert.class)
@DictFormat(DictTypeConstants.CRM_PRODUCT_UNIT)
private Integer unit;
private int unit;
@Schema(description = "价格, 单位:分", requiredMode = Schema.RequiredMode.REQUIRED, example = "8911")
@ExcelProperty("价格,单位:分")
@@ -28,7 +28,7 @@ public class CrmProductSaveReqVO {
@Schema(description = "单位", example = "2")
@DiffLogField(name = "单位", function = CrmProductUnitParseFunction.NAME)
private Integer unit;
private int unit;
@Schema(description = "价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "8911")
@NotNull(message = "价格不能为空")
@@ -43,7 +43,7 @@ public class CrmProductDO extends BaseDO {
*
* 字典 {@link DictTypeConstants#CRM_PRODUCT_UNIT}
*/
private Integer unit;
private int unit;
/**
* 价格,单位:元
*/
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.crm.dal.mysql.receivable;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanPageReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
@@ -24,7 +25,7 @@ import java.util.Objects;
public interface CrmReceivablePlanMapper extends BaseMapperX<CrmReceivablePlanDO> {
default CrmReceivablePlanDO selectMaxPeriodByContractId(Long contractId) {
return selectOne(new MPJLambdaWrapperX<CrmReceivablePlanDO>()
return selectOne(new LambdaQueryWrapperX<CrmReceivablePlanDO>()
.eq(CrmReceivablePlanDO::getContractId, contractId)
.orderByDesc(CrmReceivablePlanDO::getPeriod)
.last("LIMIT 1"));
@@ -81,6 +81,8 @@ public interface ErrorCodeConstants {
ErrorCode PRODUCT_NOT_EXISTS = new ErrorCode(1_020_008_000, "产品不存在");
ErrorCode PRODUCT_NO_EXISTS = new ErrorCode(1_020_008_001, "产品编号已存在");
ErrorCode PRODUCT_NOT_ENABLE = new ErrorCode(1_020_008_002, "产品【{}】已禁用");
ErrorCode PRODUCT_DELETE_FAIL_CONTRACT_EXISTS = new ErrorCode(1_020_008_003, "产品已被合同引用,无法删除");
ErrorCode PRODUCT_DELETE_FAIL_BUSINESS_EXISTS = new ErrorCode(1_020_008_004, "产品已被商机引用,无法删除");
// ========== 产品分类 1_020_009_000 ==========
ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_020_009_000, "产品分类不存在");
@@ -144,7 +144,7 @@ public interface LogRecordConstants {
String CRM_RECEIVABLE_CREATE_SUB_TYPE = "创建回款";
String CRM_RECEIVABLE_CREATE_SUCCESS = "创建了合同【{getContractById{#receivable.contractId}}】的{{#period != null ? '【第'+ #period +'期】' : '编号为【'+ #receivable.no +'】的'}}回款";
String CRM_RECEIVABLE_UPDATE_SUB_TYPE = "更新回款";
String CRM_RECEIVABLE_UPDATE_SUCCESS = "更新了合同【{getContractById{#receivable.contractId}}】的{{#period != null ? '【第'+ #period +'期】' : '编号为【'+ #receivable.no +'】的'}}回款: {_DIFF{#updateReqVO}}";
String CRM_RECEIVABLE_UPDATE_SUCCESS = "更新了合同【{getContractById{#oldReceivable.contractId}}】的{{#period != null ? '【第'+ #period +'期】' : '编号为【'+ #oldReceivable.no +'】的'}}回款: {_DIFF{#updateReqVO}}";
String CRM_RECEIVABLE_DELETE_SUB_TYPE = "删除回款";
String CRM_RECEIVABLE_DELETE_SUCCESS = "删除了合同【{getContractById{#receivable.contractId}}】的{{#period != null ? '【第'+ #period +'期】' : '编号为【'+ #receivable.no +'】的'}}回款";
String CRM_RECEIVABLE_SUBMIT_SUB_TYPE = "提交回款审批";
@@ -25,7 +25,8 @@ public class CrmContractParseFunction implements IParseFunction {
@Override
public boolean executeBefore() {
return true; // 先转换值后对比
// 可见 https://github.com/YunaiV/ruoyi-vue-pro/pull/1247 描述
return false;
}
@Override
@@ -45,6 +45,14 @@ public class CrmPermissionAspect {
@Before("@annotation(crmPermission)")
public void doBefore(JoinPoint joinPoint, CrmPermission crmPermission) {
// 对应 issue:https://github.com/YunaiV/ruoyi-vue-pro/pull/1221
// 0. 系统调用(定时任务/异步线程等,无登录用户)不走数据权限校验,直接放行。
// 数据权限是“按登录用户”的访问控制;/admin-api 外部请求必然有登录用户,
// 故登录用户为 null 只可能是系统内部调用(如公海自动掉落定时任务),属可信进程。
// 若在此校验,isCrmAdmin() 会以 null 用户查缓存,抛 Null key 异常导致业务失败。
if (getUserId() == null) {
return;
}
// 1.1 获取相关属性值
Map<String, Object> expressionValues = parseExpressions(joinPoint, crmPermission);
Integer bizType = StrUtil.isEmpty(crmPermission.bizTypeValue()) ?
@@ -164,6 +164,14 @@ public interface CrmBusinessService {
*/
Long getBusinessCountByCustomerId(Long customerId);
/**
* 获得引用指定产品的商机产品数量
*
* @param productId 产品编号
* @return 数量
*/
Long getBusinessProductCountByProductId(Long productId);
/**
* 获得使用指定商机状态组的商机数量
*
@@ -375,6 +375,11 @@ public class CrmBusinessServiceImpl implements CrmBusinessService {
return businessMapper.selectCount(CrmBusinessDO::getCustomerId, customerId);
}
@Override
public Long getBusinessProductCountByProductId(Long productId) {
return businessProductMapper.selectCount(CrmBusinessProductDO::getProductId, productId);
}
@Override
public Long getBusinessCountByStatusTypeId(Long statusTypeId) {
return businessMapper.selectCountByStatusTypeId(statusTypeId);
@@ -177,6 +177,14 @@ public interface CrmContractService {
*/
List<CrmContractProductDO> getContractProductListByContractId(Long contactId);
/**
* 获得引用指定产品的合同产品数量
*
* @param productId 产品编号
* @return 数量
*/
Long getContractProductCountByProductId(Long productId);
/**
* 获得待审核合同数量
*
@@ -217,6 +217,10 @@ public class CrmContractServiceImpl implements CrmContractService {
private void calculateTotalPrice(CrmContractDO contract, List<CrmContractProductDO> contractProducts) {
contract.setTotalProductPrice(getSumValue(contractProducts, CrmContractProductDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO));
// 无产品明细的合同,合同金额以传入值为准(支持手工填写合同金额),不做重算覆盖
if (CollUtil.isEmpty(contractProducts)) {
return;
}
BigDecimal discountPrice = MoneyUtils.priceMultiplyPercent(contract.getTotalProductPrice(), contract.getDiscountPercent());
contract.setTotalPrice(contract.getTotalProductPrice().subtract(discountPrice));
}
@@ -394,6 +398,11 @@ public class CrmContractServiceImpl implements CrmContractService {
return contractProductMapper.selectListByContractId(contactId);
}
@Override
public Long getContractProductCountByProductId(Long productId) {
return contractProductMapper.selectCount(CrmContractProductDO::getProductId, productId);
}
@Override
public Long getAuditContractCount(Long userId) {
return contractMapper.selectCountByAudit(userId);
@@ -12,17 +12,20 @@ import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
import cn.iocoder.yudao.module.crm.enums.product.CrmProductStatusEnum;
import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission;
import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService;
import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@@ -49,6 +52,12 @@ public class CrmProductServiceImpl implements CrmProductService {
@Resource
private CrmProductCategoryService productCategoryService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private CrmContractService contractService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private CrmBusinessService businessService;
@Resource
private CrmPermissionService permissionService;
@Resource
@@ -128,6 +137,14 @@ public class CrmProductServiceImpl implements CrmProductService {
public void deleteProduct(Long id) {
// 校验存在
validateProductExists(id);
// 校验未被合同/商机引用,避免删除后产生悬空引用
if (contractService.getContractProductCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_CONTRACT_EXISTS);
}
if (businessService.getBusinessProductCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_BUSINESS_EXISTS);
}
// 删除
productMapper.deleteById(id);
}
@@ -116,7 +116,7 @@ public class CrmReceivablePlanServiceImpl implements CrmReceivablePlanService {
}
// 校验合同存在
if (reqVO.getContractId() != null) {
CrmContractDO contract = contractService.getContract(reqVO.getContractId());
CrmContractDO contract = contractService.validateContract(reqVO.getContractId());
reqVO.setCustomerId(contract.getCustomerId());
}
}
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.crm.service.receivable;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivablePlanMapper;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.CONTRACT_NOT_EXISTS;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.*;
public class CrmReceivablePlanServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private CrmReceivablePlanServiceImpl receivablePlanService;
@Mock
private CrmReceivablePlanMapper receivablePlanMapper;
@Mock
private CrmContractService contractService;
@Mock
private CrmPermissionService permissionService;
@Mock
private AdminUserApi adminUserApi;
@Test
public void testCreateReceivablePlan_contractNotExists() {
// 准备参数
CrmReceivablePlanSaveReqVO reqVO = new CrmReceivablePlanSaveReqVO().setContractId(9L);
// mock 合同不存在
when(contractService.validateContract(9L)).thenThrow(exception(CONTRACT_NOT_EXISTS));
// 调用,并断言异常
assertServiceException(() -> receivablePlanService.createReceivablePlan(reqVO), CONTRACT_NOT_EXISTS);
// 断言未写入回款计划和数据权限
verifyNoInteractions(receivablePlanMapper, permissionService);
}
@Test
public void testCreateReceivablePlan_useContractCustomerId() {
// 准备参数:入参的客户编号应以合同为准
CrmReceivablePlanSaveReqVO reqVO = new CrmReceivablePlanSaveReqVO()
.setContractId(9L).setCustomerId(999L);
// mock 合同
when(contractService.validateContract(9L)).thenReturn(new CrmContractDO().setId(9L).setCustomerId(20L));
// 调用
receivablePlanService.createReceivablePlan(reqVO);
// 断言
verify(receivablePlanMapper).insert(argThat((CrmReceivablePlanDO row) ->
Objects.equals(row.getCustomerId(), 20L) && Objects.equals(row.getPeriod(), 1)));
}
}
@@ -1,9 +1,11 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.in;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@@ -39,6 +41,7 @@ public class ErpPurchaseInSaveReqVO {
private String remark;
@Schema(description = "入库清单列表")
@Valid
private List<Item> items;
@Data
@@ -68,6 +71,7 @@ public class ErpPurchaseInSaveReqVO {
@Schema(description = "产品数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "100.00")
@NotNull(message = "产品数量不能为空")
@DecimalMin(value = "0", inclusive = false, message = "产品数量必须大于 0")
private BigDecimal count;
@Schema(description = "税率,百分比", example = "99.88")
@@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.in.ErpPurchaseIn
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInItemDO;
import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
@@ -67,4 +68,10 @@ public interface ErpPurchaseInMapper extends BaseMapperX<ErpPurchaseInDO> {
return selectList(ErpPurchaseInDO::getOrderId, orderId);
}
}
default List<ErpPurchaseInDO> selectListByOrderIdAndStatus(Long orderId, Integer status) {
return selectList(new LambdaQueryWrapper<ErpPurchaseInDO>()
.eq(ErpPurchaseInDO::getOrderId, orderId)
.eq(ErpPurchaseInDO::getStatus, status));
}
}
@@ -130,6 +130,12 @@ ErrorCode PURCHASE_ORDER_ITEM_RETURN_FAIL_IN_EXCEED = new ErrorCode(1_030_101_00
// ========== ERP 产品 1-030-500-000 ==========
ErrorCode PRODUCT_NOT_EXISTS = new ErrorCode(1_030_500_000, "产品不存在");
ErrorCode PRODUCT_NOT_ENABLE = new ErrorCode(1_030_500_001, "产品({})未启用");
ErrorCode PRODUCT_DELETE_FAIL_PURCHASE_ORDER_EXISTS = new ErrorCode(1_030_500_002, "产品已被采购订单引用,无法删除");
ErrorCode PRODUCT_DELETE_FAIL_PURCHASE_IN_EXISTS = new ErrorCode(1_030_500_003, "产品已被采购入库单引用,无法删除");
ErrorCode PRODUCT_DELETE_FAIL_PURCHASE_RETURN_EXISTS = new ErrorCode(1_030_500_004, "产品已被采购退货单引用,无法删除");
ErrorCode PRODUCT_DELETE_FAIL_SALE_ORDER_EXISTS = new ErrorCode(1_030_500_005, "产品已被销售订单引用,无法删除");
ErrorCode PRODUCT_DELETE_FAIL_SALE_OUT_EXISTS = new ErrorCode(1_030_500_006, "产品已被销售出库单引用,无法删除");
ErrorCode PRODUCT_DELETE_FAIL_SALE_RETURN_EXISTS = new ErrorCode(1_030_500_007, "产品已被销售退货单引用,无法删除");
// ========== ERP 产品分类 1-030-501-000 ==========
ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_030_501_000, "产品分类不存在");
@@ -12,20 +12,22 @@ import cn.iocoder.yudao.module.erp.dal.dataobject.product.ErpProductCategoryDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.product.ErpProductDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.product.ErpProductUnitDO;
import cn.iocoder.yudao.module.erp.dal.mysql.product.ErpProductMapper;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseInService;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseOrderService;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseReturnService;
import cn.iocoder.yudao.module.erp.service.sale.ErpSaleOrderService;
import cn.iocoder.yudao.module.erp.service.sale.ErpSaleOutService;
import cn.iocoder.yudao.module.erp.service.sale.ErpSaleReturnService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.PRODUCT_NOT_ENABLE;
import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.PRODUCT_NOT_EXISTS;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*;
/**
* ERP 产品 Service 实现类
@@ -43,6 +45,24 @@ public class ErpProductServiceImpl implements ErpProductService {
private ErpProductCategoryService productCategoryService;
@Resource
private ErpProductUnitService productUnitService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private ErpPurchaseOrderService purchaseOrderService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private ErpPurchaseInService purchaseInService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private ErpPurchaseReturnService purchaseReturnService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private ErpSaleOrderService saleOrderService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private ErpSaleOutService saleOutService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private ErpSaleReturnService saleReturnService;
@Override
public Long createProduct(ProductSaveReqVO createReqVO) {
@@ -68,6 +88,25 @@ public class ErpProductServiceImpl implements ErpProductService {
public void deleteProduct(Long id) {
// 校验存在
validateProductExists(id);
// 校验未被任何业务单据明细引用,避免删除后产生悬空引用
if (purchaseOrderService.getPurchaseOrderItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_PURCHASE_ORDER_EXISTS);
}
if (purchaseInService.getPurchaseInItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_PURCHASE_IN_EXISTS);
}
if (purchaseReturnService.getPurchaseReturnItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_PURCHASE_RETURN_EXISTS);
}
if (saleOrderService.getSaleOrderItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_SALE_ORDER_EXISTS);
}
if (saleOutService.getSaleOutItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_SALE_OUT_EXISTS);
}
if (saleReturnService.getSaleReturnItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_SALE_RETURN_EXISTS);
}
// 删除
productMapper.deleteById(id);
}
@@ -98,4 +98,12 @@ public interface ErpPurchaseInService {
*/
List<ErpPurchaseInItemDO> getPurchaseInItemListByInIds(Collection<Long> inIds);
}
/**
* 获得引用指定产品的采购入库项数量
*
* @param productId 产品编号
* @return 数量
*/
Long getPurchaseInItemCountByProductId(Long productId);
}
@@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.erp.dal.dataobject.product.ErpProductDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInItemDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderItemDO;
import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseInItemMapper;
import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseInMapper;
import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO;
@@ -144,13 +145,27 @@ public class ErpPurchaseInServiceImpl implements ErpPurchaseInService {
}
private void updatePurchaseOrderInCount(Long orderId) {
// 1.1 查询采购订单对应的采购入库单列表
List<ErpPurchaseInDO> purchaseIns = purchaseInMapper.selectListByOrderId(orderId);
// 1.2 查询对应的采购订单项的入库数量
Map<Long, BigDecimal> returnCountMap = purchaseInItemMapper.selectOrderItemCountSumMapByInIds(
convertList(purchaseIns, ErpPurchaseInDO::getId));
// 1.1 查询已审核入库单,计算入库进度
List<ErpPurchaseInDO> approvedIns = purchaseInMapper.selectListByOrderIdAndStatus(
orderId, ErpAuditStatus.APPROVE.getStatus());
Map<Long, BigDecimal> inCountMap = purchaseInItemMapper.selectOrderItemCountSumMapByInIds(
convertList(approvedIns, ErpPurchaseInDO::getId));
// 1.2 查询全部入库单,计算占用数量
List<ErpPurchaseInDO> allIns = purchaseInMapper.selectListByOrderId(orderId);
Map<Long, BigDecimal> allCountMap = purchaseInItemMapper.selectOrderItemCountSumMapByInIds(
convertList(allIns, ErpPurchaseInDO::getId));
// 1.3 校验全部入库数量未超过采购订单项数量
List<ErpPurchaseOrderItemDO> orderItems = purchaseOrderService.getPurchaseOrderItemListByOrderId(orderId);
for (ErpPurchaseOrderItemDO item : orderItems) {
BigDecimal allCount = allCountMap.getOrDefault(item.getId(), BigDecimal.ZERO);
if (allCount.compareTo(item.getCount()) > 0) {
throw exception(PURCHASE_ORDER_ITEM_IN_FAIL_PRODUCT_EXCEED,
productService.getProduct(item.getProductId()).getName(), item.getCount());
}
}
// 2. 更新采购订单的入库数量
purchaseOrderService.updatePurchaseOrderInCount(orderId, returnCountMap);
purchaseOrderService.updatePurchaseOrderInCount(orderId, inCountMap);
}
@Override
@@ -185,6 +200,9 @@ public class ErpPurchaseInServiceImpl implements ErpPurchaseInService {
purchaseInItem.getProductId(), purchaseInItem.getWarehouseId(), count,
bizType, purchaseInItem.getInId(), purchaseInItem.getId(), purchaseIn.getNo()));
});
// 4. 更新采购订单的入库数量
updatePurchaseOrderInCount(purchaseIn.getOrderId());
}
@Override
@@ -305,4 +323,9 @@ public class ErpPurchaseInServiceImpl implements ErpPurchaseInService {
return purchaseInItemMapper.selectListByInIds(inIds);
}
@Override
public Long getPurchaseInItemCountByProductId(Long productId) {
return purchaseInItemMapper.selectCount(ErpPurchaseInItemDO::getProductId, productId);
}
}
@@ -107,4 +107,12 @@ public interface ErpPurchaseOrderService {
*/
List<ErpPurchaseOrderItemDO> getPurchaseOrderItemListByOrderIds(Collection<Long> orderIds);
}
/**
* 获得引用指定产品的采购订单项数量
*
* @param productId 产品编号
* @return 数量
*/
Long getPurchaseOrderItemCountByProductId(Long productId);
}
@@ -292,4 +292,9 @@ public class ErpPurchaseOrderServiceImpl implements ErpPurchaseOrderService {
return purchaseOrderItemMapper.selectListByOrderIds(orderIds);
}
@Override
public Long getPurchaseOrderItemCountByProductId(Long productId) {
return purchaseOrderItemMapper.selectCount(ErpPurchaseOrderItemDO::getProductId, productId);
}
}
@@ -98,4 +98,12 @@ public interface ErpPurchaseReturnService {
*/
List<ErpPurchaseReturnItemDO> getPurchaseReturnItemListByReturnIds(Collection<Long> returnIds);
}
/**
* 获得引用指定产品的采购退货项数量
*
* @param productId 产品编号
* @return 数量
*/
Long getPurchaseReturnItemCountByProductId(Long productId);
}
@@ -301,4 +301,9 @@ public class ErpPurchaseReturnServiceImpl implements ErpPurchaseReturnService {
return purchaseReturnItemMapper.selectListByReturnIds(returnIds);
}
@Override
public Long getPurchaseReturnItemCountByProductId(Long productId) {
return purchaseReturnItemMapper.selectCount(ErpPurchaseReturnItemDO::getProductId, productId);
}
}
@@ -107,4 +107,12 @@ public interface ErpSaleOrderService {
*/
List<ErpSaleOrderItemDO> getSaleOrderItemListByOrderIds(Collection<Long> orderIds);
}
/**
* 获得引用指定产品的销售订单项数量
*
* @param productId 产品编号
* @return 数量
*/
Long getSaleOrderItemCountByProductId(Long productId);
}
@@ -304,4 +304,9 @@ public class ErpSaleOrderServiceImpl implements ErpSaleOrderService {
return saleOrderItemMapper.selectListByOrderIds(orderIds);
}
@Override
public Long getSaleOrderItemCountByProductId(Long productId) {
return saleOrderItemMapper.selectCount(ErpSaleOrderItemDO::getProductId, productId);
}
}
@@ -99,4 +99,12 @@ public interface ErpSaleOutService {
List<ErpSaleOutItemDO> getSaleOutItemListByOutIds(Collection<Long> outIds);
}
/**
* 获得引用指定产品的销售出库项数量
*
* @param productId 产品编号
* @return 数量
*/
Long getSaleOutItemCountByProductId(Long productId);
}
@@ -313,4 +313,9 @@ public class ErpSaleOutServiceImpl implements ErpSaleOutService {
return saleOutItemMapper.selectListByOutIds(outIds);
}
@Override
public Long getSaleOutItemCountByProductId(Long productId) {
return saleOutItemMapper.selectCount(ErpSaleOutItemDO::getProductId, productId);
}
}
@@ -98,4 +98,12 @@ public interface ErpSaleReturnService {
*/
List<ErpSaleReturnItemDO> getSaleReturnItemListByReturnIds(Collection<Long> returnIds);
}
/**
* 获得引用指定产品的销售退货项数量
*
* @param productId 产品编号
* @return 数量
*/
Long getSaleReturnItemCountByProductId(Long productId);
}
@@ -313,4 +313,9 @@ public class ErpSaleReturnServiceImpl implements ErpSaleReturnService {
return saleReturnItemMapper.selectListByReturnIds(returnIds);
}
@Override
public Long getSaleReturnItemCountByProductId(Long productId) {
return saleReturnItemMapper.selectCount(ErpSaleReturnItemDO::getProductId, productId);
}
}
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.fms.service.closing;
import cn.iocoder.yudao.module.fms.controller.admin.closing.vo.FmsClosingOverviewRespVO;
import cn.iocoder.yudao.module.fms.controller.admin.closing.vo.FmsClosingQueryReqVO;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.YearMonth;
@@ -57,6 +58,16 @@ public interface FmsClosingPeriodService {
*/
FmsClosingOverviewRespVO getClosingOverview(FmsClosingQueryReqVO queryReqVO, Long userId);
/**
* 获得指定会计期间的损益结转余额(末级损益科目本期借贷差额绝对值之和)
*
* @param accountSetId 账套编号
* @param month 会计期间
* @param userId 用户编号
* @return 损益余额
*/
BigDecimal getProfitLossBalance(Long accountSetId, String month, Long userId);
/**
* 结账
*
@@ -198,6 +198,11 @@ public class FmsClosingPeriodServiceImpl implements FmsClosingPeriodService {
.setCanClose(canClose);
}
@Override
public BigDecimal getProfitLossBalance(Long accountSetId, String month, Long userId) {
return calculateProfitLossBalance(accountSetId, LocalDateTimeUtils.parseYearMonth(month), userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
@LogRecord(type = FMS_CLOSING_TYPE, subType = FMS_CLOSING_PERIOD_CLOSE_SUB_TYPE,
@@ -53,6 +53,7 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList;
import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.ACCOUNT_SET_NOT_INITIALIZED;
import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.SUBJECT_CODE_DUPLICATE;
import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.SUBJECT_CODE_RULE_INVALID;
import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.SUBJECT_CURRENCY_INVALID;
@@ -161,6 +162,9 @@ public class FmsSubjectServiceImpl implements FmsSubjectService {
accountSetService.validateAccountSetWritePermission(createReqVO.getAccountSetId(), userId);
// 1.2 查询财务参数
FmsFinanceParameterDO financeParameter = financeParameterService.getFinanceParameter(createReqVO.getAccountSetId());
if (financeParameter == null) {
throw exception(ACCOUNT_SET_NOT_INITIALIZED);
}
// 1.3 校验科目编码唯一
validateSubjectCodeUnique(null, createReqVO.getAccountSetId(), createReqVO.getCode());
// 1.4 校验上级科目和科目编码规则
@@ -209,6 +213,9 @@ public class FmsSubjectServiceImpl implements FmsSubjectService {
// 1.2 查询财务参数
FmsFinanceParameterDO financeParameter = financeParameterService.getFinanceParameter(
updateReqVO.getAccountSetId());
if (financeParameter == null) {
throw exception(ACCOUNT_SET_NOT_INITIALIZED);
}
// 1.3 校验科目存在
FmsSubjectDO subject = validateSubjectExists(updateReqVO.getAccountSetId(), updateReqVO.getId());
if (ObjUtil.notEqual(subject.getParentId(), updateReqVO.getParentId())) {
@@ -416,6 +423,9 @@ public class FmsSubjectServiceImpl implements FmsSubjectService {
accountSetService.validateAccountSetWritePermission(accountSetId, userId);
// 1.3 加载导入依赖
FmsFinanceParameterDO financeParameter = financeParameterService.getFinanceParameter(accountSetId);
if (financeParameter == null) {
throw exception(ACCOUNT_SET_NOT_INITIALIZED);
}
Map<String, FmsSubjectDO> subjectMap = convertMap(
subjectMapper.selectListByAccountSetIdAndType(accountSetId, null), FmsSubjectDO::getCode);
Map<String, Long> auxiliaryTypeIdMap = auxiliaryTypeService.getAuxiliaryTypeIdMap(accountSetId);
@@ -7,8 +7,6 @@ import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.fms.controller.admin.closing.vo.FmsClosingOverviewRespVO;
import cn.iocoder.yudao.module.fms.controller.admin.closing.vo.FmsClosingQueryReqVO;
import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.subjectbalance.FmsLedgerSubjectBalanceRespVO;
import cn.iocoder.yudao.module.fms.controller.admin.report.vo.balance.FmsBalanceSheetCheckRespVO;
import cn.iocoder.yudao.module.fms.controller.admin.report.vo.balance.FmsBalanceSheetRowRespVO;
@@ -292,7 +290,7 @@ public class FmsBalanceSheetServiceImpl implements FmsBalanceSheetService {
}
/**
* 判断查询期间内各月的损益是否均已结转,逐月检查结账概览的损益余额
* 判断查询期间内各月的损益是否均已结转
*
* @param listReqVO 查询条件
* @param userId 用户编号
@@ -302,12 +300,11 @@ public class FmsBalanceSheetServiceImpl implements FmsBalanceSheetService {
YearMonth endMonth = LocalDateTimeUtils.parseYearMonth(listReqVO.getEndMonth());
for (YearMonth currentMonth = LocalDateTimeUtils.parseYearMonth(listReqVO.getStartMonth());
!currentMonth.isAfter(endMonth); currentMonth = currentMonth.plusMonths(1)) {
FmsClosingQueryReqVO closingQueryReqVO = new FmsClosingQueryReqVO();
closingQueryReqVO.setAccountSetId(listReqVO.getAccountSetId());
closingQueryReqVO.setMonth(currentMonth.toString());
FmsClosingOverviewRespVO overview = closingPeriodService.getClosingOverview(closingQueryReqVO, userId);
// 直接查询损益余额,避免报表校验与结账概况循环调用
BigDecimal profitLossBalance = closingPeriodService.getProfitLossBalance(
listReqVO.getAccountSetId(), currentMonth.toString(), userId);
// 当月损益余额不为零时,说明存在尚未结转的损益
if (NumberUtils.zeroIfNull(overview.getProfitLossBalance()).signum() != 0) {
if (NumberUtils.zeroIfNull(profitLossBalance).signum() != 0) {
return false;
}
}
@@ -116,6 +116,41 @@ public class FmsSubjectServiceImplTest extends BaseDbUnitTest {
assertTrue(subjects.get(1).getAuxiliaryTypeIds().isEmpty());
}
@Test
public void testCreateSubject_accountSetNotInitialized() {
// 准备参数
FmsSubjectSaveReqVO reqVO = buildSubjectSaveReqVO(1L, "1001", "库存现金");
// 调用,并断言异常
assertServiceException(() -> subjectService.createSubject(reqVO, 10L), ACCOUNT_SET_NOT_INITIALIZED);
// 断言未写入科目
assertEquals(0, subjectMapper.selectCount());
}
@Test
public void testUpdateSubject_accountSetNotInitialized() {
// 准备参数
FmsSubjectSaveReqVO reqVO = buildSubjectSaveReqVO(1L, "1001", "库存现金");
reqVO.setId(1L);
// 调用,并断言异常
assertServiceException(() -> subjectService.updateSubject(reqVO, 10L), ACCOUNT_SET_NOT_INITIALIZED);
}
@Test
public void testImportSubjectList_accountSetNotInitialized() {
// 准备参数
FmsSubjectImportExcelVO importVO = new FmsSubjectImportExcelVO();
importVO.setCode("1001");
importVO.setName("库存现金");
// 调用,并断言异常
assertServiceException(() -> subjectService.importSubjectList(1L, Collections.singletonList(importVO), 10L),
ACCOUNT_SET_NOT_INITIALIZED);
// 断言未写入科目
assertEquals(0, subjectMapper.selectCount());
}
@Test
public void testCreateSubject_success() {
// 准备参数
@@ -21,7 +21,6 @@ import cn.iocoder.yudao.module.fms.dal.mysql.report.balance.FmsBalanceSheetConfi
import cn.iocoder.yudao.module.fms.dal.mysql.report.balance.FmsBalanceSheetReportMapper;
import cn.iocoder.yudao.module.fms.enums.config.FmsSubjectTypeEnum;
import cn.iocoder.yudao.module.fms.enums.report.FmsReportTypeEnum;
import cn.iocoder.yudao.module.fms.controller.admin.closing.vo.FmsClosingOverviewRespVO;
import cn.iocoder.yudao.module.fms.service.closing.FmsClosingPeriodService;
import cn.iocoder.yudao.module.fms.service.closing.FmsClosingSchemeService;
import cn.iocoder.yudao.module.fms.service.closing.FmsClosingVoucherService;
@@ -46,6 +45,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@@ -206,8 +206,8 @@ public class FmsBalanceSheetServiceImplTest extends BaseDbUnitTest {
FmsTrialBalanceRespVO trialBalance = new FmsTrialBalanceRespVO();
trialBalance.setBalanced(false);
when(initialBalanceService.getTrialBalance(1L, 10L)).thenReturn(trialBalance);
when(closingPeriodService.getClosingOverview(any(), eq(10L)))
.thenReturn(new FmsClosingOverviewRespVO().setProfitLossBalance(new BigDecimal("20.00")));
when(closingPeriodService.getProfitLossBalance(1L, "2026-08", 10L))
.thenReturn(new BigDecimal("20.00"));
// 调用
FmsBalanceSheetCheckRespVO result = balanceSheetService.checkBalanceSheet(buildQueryReqVO(), 10L);
@@ -241,14 +241,15 @@ public class FmsBalanceSheetServiceImplTest extends BaseDbUnitTest {
.setChildren(Collections.emptyList())));
when(initialBalanceService.getTrialBalance(1L, 10L))
.thenReturn(new FmsTrialBalanceRespVO().setBalanced(true));
when(closingPeriodService.getClosingOverview(any(), eq(10L)))
.thenReturn(new FmsClosingOverviewRespVO().setProfitLossBalance(BigDecimal.ZERO));
when(closingPeriodService.getProfitLossBalance(1L, "2026-08", 10L))
.thenReturn(BigDecimal.ZERO);
// 调用
FmsBalanceSheetCheckRespVO result = balanceSheetService.checkBalanceSheet(buildQueryReqVO(), 10L);
// 断言
assertEquals(0, result.getUnmappedSubjects().size());
assertTrue(result.getProfitLossTransferred());
}
// ========== 随机对象 ==========
@@ -0,0 +1,381 @@
package cn.iocoder.yudao.module.fms.service.report;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.subjectbalance.FmsLedgerSubjectBalanceRespVO;
import cn.iocoder.yudao.module.fms.controller.admin.report.vo.FmsReportItemRespVO;
import cn.iocoder.yudao.module.fms.controller.admin.report.vo.FmsReportListReqVO;
import cn.iocoder.yudao.module.fms.controller.admin.report.vo.balance.FmsBalanceSheetRowRespVO;
import cn.iocoder.yudao.module.fms.dal.dataobject.config.FmsAccountSetDO;
import cn.iocoder.yudao.module.fms.dal.dataobject.config.FmsSubjectDO;
import cn.iocoder.yudao.module.fms.dal.dataobject.report.FmsReportTemplateDO;
import cn.iocoder.yudao.module.fms.dal.mysql.report.FmsReportTemplateMapper;
import cn.iocoder.yudao.module.fms.enums.report.FmsReportTypeEnum;
import cn.iocoder.yudao.module.fms.service.closing.FmsClosingPeriodService;
import cn.iocoder.yudao.module.fms.service.closing.FmsClosingSchemeService;
import cn.iocoder.yudao.module.fms.service.closing.FmsClosingVoucherService;
import cn.iocoder.yudao.module.fms.service.config.FmsAccountSetService;
import cn.iocoder.yudao.module.fms.service.config.FmsFinanceParameterService;
import cn.iocoder.yudao.module.fms.service.config.FmsInitialBalanceService;
import cn.iocoder.yudao.module.fms.service.config.FmsSubjectService;
import cn.iocoder.yudao.module.fms.service.ledger.FmsLedgerService;
import cn.iocoder.yudao.module.fms.service.voucher.FmsVoucherService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.core.io.FileSystemResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import javax.sql.DataSource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@Import({FmsBalanceSheetServiceImpl.class, FmsIncomeStatementServiceImpl.class,
FmsCashFlowStatementServiceImpl.class, FmsReportCommonServiceImpl.class})
public class FmsReportTemplateServiceTest extends BaseDbUnitTest {
@Resource
private DataSource dataSource;
@Resource
private FmsReportTemplateMapper reportTemplateMapper;
@Resource
private FmsBalanceSheetServiceImpl balanceSheetService;
@Resource
private FmsIncomeStatementServiceImpl incomeStatementService;
@Resource
private FmsCashFlowStatementServiceImpl cashFlowStatementService;
@MockitoBean
private FmsAccountSetService accountSetService;
@MockitoBean
private FmsSubjectService subjectService;
@MockitoBean
private FmsLedgerService ledgerService;
@MockitoBean
private FmsFinanceParameterService financeParameterService;
@MockitoBean
private FmsInitialBalanceService initialBalanceService;
@MockitoBean
private FmsClosingPeriodService closingPeriodService;
@MockitoBean
private FmsClosingSchemeService closingSchemeService;
@MockitoBean
private FmsClosingVoucherService closingVoucherService;
@MockitoBean
private FmsVoucherService voucherService;
private final List<FmsSubjectDO> subjects = new ArrayList<>();
private final List<FmsLedgerSubjectBalanceRespVO> balances = new ArrayList<>();
@BeforeEach
public void before() {
// 初始化报表模板
initializeTemplates();
// mock 账套、科目和账簿余额
FmsAccountSetDO accountSet = new FmsAccountSetDO().setId(1L).setInitialized(true)
.setStartTime(LocalDateTime.of(2026, 1, 1, 0, 0));
when(accountSetService.validateAccountSetReadPermission(1L, 10L)).thenReturn(accountSet);
when(subjectService.getSubjectList(1L, null, 10L)).thenReturn(subjects);
when(ledgerService.getSubjectBalanceList(any(), eq(10L))).thenReturn(balances);
}
@Test
public void testInitializeTemplates_repeatExecution() {
// mock 数据:模拟用户修改已有报表公式
FmsReportTemplateDO template = CollUtil.findOne(
reportTemplateMapper.selectListByType(FmsReportTypeEnum.INCOME_STATEMENT.getType()),
item -> Integer.valueOf(21).equals(item.getRowNo()));
reportTemplateMapper.updateById(new FmsReportTemplateDO().setId(template.getId()).setFormula("[\"L1\"]"));
// 调用
initializeTemplates();
// 断言
assertEquals(58, reportTemplateMapper.selectListByType(FmsReportTypeEnum.BALANCE_SHEET.getType()).size());
assertEquals(31, reportTemplateMapper.selectListByType(FmsReportTypeEnum.INCOME_STATEMENT.getType()).size());
assertEquals(25, reportTemplateMapper.selectListByType(FmsReportTypeEnum.CASH_FLOW_STATEMENT.getType()).size());
assertEquals(21, reportTemplateMapper.selectListByType(FmsReportTypeEnum.CASH_FLOW_ADJUSTMENT.getType()).size());
assertEquals(135, reportTemplateMapper.selectCount());
assertEquals("[\"L1\"]", reportTemplateMapper.selectById(template.getId()).getFormula());
}
@Test
public void testInitializeTemplates_preserveExistingFormulas() {
// mock 数据:模拟旧版报表公式
List<FmsReportTemplateDO> templates = new ArrayList<>();
for (FmsReportTemplateDO template : reportTemplateMapper.selectList()) {
String oldFormula;
if (FmsReportTypeEnum.BALANCE_SHEET.getType().equals(template.getType()) && (template.getRowNo() == 9
|| template.getRowNo() == 24 || template.getRowNo() == 25)) {
oldFormula = template.getFormula().replace("\"operator\":\"-\"", "\"operator\":\"+\"");
} else if (FmsReportTypeEnum.INCOME_STATEMENT.getType().equals(template.getType())
&& template.getRowNo() == 21) {
oldFormula = "[\"L1-L2-L3-L11-L14-L18\"]";
} else if (FmsReportTypeEnum.CASH_FLOW_STATEMENT.getType().equals(template.getType())
&& template.getRowNo() == 19) {
oldFormula = "[\"L14+L15-L17-L18\"]";
} else {
continue;
}
templates.add(template);
reportTemplateMapper.updateById(new FmsReportTemplateDO().setId(template.getId()).setFormula(oldFormula));
}
// 调用
initializeTemplates();
// 断言
assertEquals(5, templates.size());
for (FmsReportTemplateDO template : templates) {
assertEquals(template.getFormula(), reportTemplateMapper.selectById(template.getId()).getFormula());
}
assertEquals(135, reportTemplateMapper.selectCount());
}
@Test
public void testInitializeTemplates_preserveLegacySubjectIds() {
// mock 数据:模拟使用历史科目编号的公式
FmsReportTemplateDO template = CollUtil.findOne(
reportTemplateMapper.selectListByType(FmsReportTypeEnum.BALANCE_SHEET.getType()),
item -> Integer.valueOf(25).equals(item.getRowNo()));
String oldFormula = "[{\"operator\":\"+\",\"rules\":0,\"subjectId\":1371,"
+ "\"subjectName\":\"无形资产\",\"subjectNumber\":\"1701\"},"
+ "{\"operator\":\"+\",\"rules\":0,\"subjectId\":1372,"
+ "\"subjectName\":\"累计摊销\",\"subjectNumber\":\"1702\"}]";
reportTemplateMapper.updateById(new FmsReportTemplateDO().setId(template.getId()).setFormula(oldFormula));
// 调用
initializeTemplates();
// 断言
assertEquals(template.getFormula(), reportTemplateMapper.selectById(template.getId()).getFormula());
}
@Test
public void testGetBalanceSheet_capitalContribution() {
// mock 数据:投入资本 100 元
mockSubjectBalance("1001", 1, 0, 100, 100, 0);
mockSubjectBalance("3001", 2, 0, -100, 0, 100);
// 准备参数
FmsReportListReqVO reqVO = buildQueryReqVO();
// 调用
List<FmsBalanceSheetRowRespVO> balanceSheet = balanceSheetService.getBalanceSheet(reqVO, 10L);
List<FmsReportItemRespVO> cashFlow = cashFlowStatementService.getCashFlowStatement(reqVO, 10L);
// 断言
assertAssetAmount(balanceSheet, 30, 100);
FmsBalanceSheetRowRespVO total = CollUtil.findOne(balanceSheet,
row -> Integer.valueOf(53).equals(row.getLiabilityRowNo()));
assertNotNull(total);
assertAmount(100, total.getLiabilityClosingAmount());
assertItemAmount(cashFlow, 15, 100);
assertItemAmount(cashFlow, 22, 100);
}
@Test
public void testGetBalanceSheet_inventoryAndContraAccounts() {
// mock 数据:存货、固定资产、无形资产及其备抵科目
mockSubjectBalance("1403", 1, 0, 30, 30, 0);
mockSubjectBalance("1407", 2, 0, -5, 0, 5);
mockSubjectBalance("1621", 1, 0, 100, 100, 0);
mockSubjectBalance("1622", 2, 0, -20, 0, 20);
mockSubjectBalance("1701", 1, 0, 100, 100, 0);
mockSubjectBalance("1702", 2, 0, -10, 0, 10);
// 准备参数
FmsReportListReqVO reqVO = buildQueryReqVO();
// 调用
List<FmsBalanceSheetRowRespVO> result = balanceSheetService.getBalanceSheet(reqVO, 10L);
// 断言
assertAssetAmount(result, 9, 25);
assertAssetAmount(result, 24, 80);
assertAssetAmount(result, 25, 90);
assertAssetAmount(result, 30, 195);
}
@Test
public void testGetCashFlowStatement_creditSale() {
// mock 数据:赊销 100 元并结转利润,尚未收款
mockSubjectBalance("1001", 1, 0, 0, 0, 0);
mockSubjectBalance("1122", 1, 0, 100, 100, 0);
mockSubjectBalance("5001", 2, 0, 0, 0, 100);
mockSubjectBalance("3103", 2, 0, -100, 0, 100);
// 准备参数
FmsReportListReqVO reqVO = buildQueryReqVO();
// 调用
List<FmsReportItemRespVO> incomeItems = incomeStatementService.getIncomeStatement(reqVO, 10L);
List<FmsReportItemRespVO> result = cashFlowStatementService.getCashFlowStatement(reqVO, 10L);
// 断言
assertItemAmount(incomeItems, 1, 100);
assertItemAmount(result, 1, 0);
assertItemAmount(result, 6, 0);
assertItemAmount(result, 20, 0);
assertItemAmount(result, 22, 0);
}
@Test
public void testGetCashFlowStatement_purchaseInventory() {
// mock 数据:期初现金 100 元,支付 40 元购入原材料
mockSubjectBalance("1001", 1, 100, 60, 0, 40);
mockSubjectBalance("1403", 1, 0, 40, 40, 0);
mockSubjectBalance("3001", 2, -100, -100, 0, 0);
// 准备参数
FmsReportListReqVO reqVO = buildQueryReqVO();
// 调用
List<FmsReportItemRespVO> result = cashFlowStatementService.getCashFlowStatement(reqVO, 10L);
// 断言
assertItemAmount(result, 3, 40);
assertItemAmount(result, 6, 0);
assertItemAmount(result, 20, -40);
assertItemAmount(result, 22, 60);
}
@Test
public void testGetCashFlowStatement_payWagesAndIncomeTax() {
// mock 数据:计提并支付工资 30 元、所得税 10 元,期末应付款为零
mockSubjectBalance("1001", 1, 100, 60, 0, 40);
mockSubjectBalance("3001", 2, -100, -100, 0, 0);
mockSubjectBalance("3103", 2, 0, 40, 40, 0);
mockSubjectBalance("5602", 1, 0, 0, 30, 0);
mockSubjectBalance("5801", 1, 0, 0, 10, 0);
mockSubjectBalance("2211", 2, 0, 0, 30, 30);
mockSubjectBalance("2221", 2, 0, 0, 10, 10);
// 准备参数
FmsReportListReqVO reqVO = buildQueryReqVO();
// 调用
List<FmsReportItemRespVO> result = cashFlowStatementService.getCashFlowStatement(reqVO, 10L);
// 断言
assertItemAmount(result, 4, 30);
assertItemAmount(result, 5, 10);
assertItemAmount(result, 6, 0);
assertItemAmount(result, 20, -40);
assertItemAmount(result, 22, 60);
}
@Test
public void testGetCashFlowStatement_repayLoan() {
// mock 数据:期初现金 100 元,偿还借款 30 元
mockSubjectBalance("1001", 1, 100, 70, 0, 30);
mockSubjectBalance("2001", 2, -100, -70, 30, 0);
// 准备参数
FmsReportListReqVO reqVO = buildQueryReqVO();
// 调用
List<FmsReportItemRespVO> result = cashFlowStatementService.getCashFlowStatement(reqVO, 10L);
// 断言
assertItemAmount(result, 16, 30);
assertItemAmount(result, 19, -30);
assertItemAmount(result, 6, 0);
assertItemAmount(result, 22, 70);
}
@Test
public void testGetIncomeStatement_investmentIncomeAndNonOperatingExpense() {
// mock 数据:投资收益 20 元,营业外支出 5 元
mockSubjectBalance("5111", 2, 0, -20, 0, 20);
mockSubjectBalance("5711", 1, 0, 5, 5, 0);
// 准备参数
FmsReportListReqVO reqVO = buildQueryReqVO();
// 调用
List<FmsReportItemRespVO> result = incomeStatementService.getIncomeStatement(reqVO, 10L);
// 断言
assertItemAmount(result, 20, 20);
assertItemAmount(result, 21, 20);
assertItemAmount(result, 24, 5);
assertItemAmount(result, 32, 15);
}
/**
* 执行报表模板升级脚本
*/
private void initializeTemplates() {
new ResourceDatabasePopulator(new FileSystemResource("../sql/mysql/upgrade/20260926-fms-report-template.sql"))
.execute(dataSource);
}
/**
* 模拟科目余额和发生额:余额借方为正、贷方为负,发生额已扣除损益结转数据
*/
private void mockSubjectBalance(String code, int direction, int opening, int closing, int debit, int credit) {
Long id = Long.valueOf(code);
subjects.add(new FmsSubjectDO().setId(id).setAccountSetId(1L).setCode(code).setName(code)
.setLevel(1).setBalanceDirection(direction));
balances.add(new FmsLedgerSubjectBalanceRespVO().setSubjectId(id).setSubjectCode(code)
.setOpeningDebitAmount(BigDecimal.valueOf(Math.max(opening, 0)))
.setOpeningCreditAmount(BigDecimal.valueOf(Math.max(-opening, 0)))
.setEndingDebitAmount(BigDecimal.valueOf(Math.max(closing, 0)))
.setEndingCreditAmount(BigDecimal.valueOf(Math.max(-closing, 0)))
.setPeriodDebitAmount(BigDecimal.valueOf(debit)).setPeriodCreditAmount(BigDecimal.valueOf(credit))
.setYearDebitAmount(BigDecimal.valueOf(debit)).setYearCreditAmount(BigDecimal.valueOf(credit))
.setChildren(Collections.emptyList()));
}
/**
* 构造 2026 年 8 月的报表查询参数
*/
private FmsReportListReqVO buildQueryReqVO() {
FmsReportListReqVO reqVO = new FmsReportListReqVO();
reqVO.setAccountSetId(1L);
reqVO.setStartMonth("2026-08");
reqVO.setEndMonth("2026-08");
return reqVO;
}
/**
* 断言报表指定行的本期和本年金额
*/
private void assertItemAmount(List<FmsReportItemRespVO> rows, int rowNo, int expected) {
FmsReportItemRespVO item = CollUtil.findOne(rows, row -> Integer.valueOf(rowNo).equals(row.getRowNo()));
assertNotNull(item);
assertAmount(expected, item.getCurrentAmount());
assertAmount(expected, item.getYearAmount());
}
/**
* 断言资产负债表指定行的资产期末余额
*/
private void assertAssetAmount(List<FmsBalanceSheetRowRespVO> rows, int rowNo, int expected) {
FmsBalanceSheetRowRespVO item = CollUtil.findOne(rows,
row -> Integer.valueOf(rowNo).equals(row.getAssetRowNo()));
assertNotNull(item);
assertAmount(expected, item.getAssetClosingAmount());
}
/**
* 按数值比较金额,忽略 BigDecimal 的小数位数差异
*/
private void assertAmount(int expected, BigDecimal actual) {
assertNotNull(actual);
assertEquals(0, BigDecimal.valueOf(expected).compareTo(actual), "实际金额:" + actual);
}
}
@@ -106,7 +106,12 @@ public class HrmEmployeeFieldConfigServiceImpl implements HrmEmployeeFieldConfig
public void validateEmployeeCreateFields(HrmEmployeeSaveReqVO reqVO, Integer entryStatus) {
for (HrmEmployeeFieldConfigRespVO field : getEmployeeCreateFieldConfigList(entryStatus)) {
if (Boolean.TRUE.equals(field.getVisible())
|| HrmEmployeeCreateFieldEnum.CANDIDATE_ID.getName().equals(field.getName())) {
|| HrmEmployeeCreateFieldEnum.CANDIDATE_ID.getName().equals(field.getName())
// fix #1211:https://github.com/YunaiV/ruoyi-vue-pro/pull/1211
// 候选人转员工(convert 来源,请求携带 candidateId):channelId 由服务端从候选人记录回填,
// 与 candidateId 同属服务端赋值字段,默认隐藏时同样放行(渠道存在性仍由 createEmployee 校验兜底)
|| (reqVO.getCandidateId() != null
&& HrmEmployeeCreateFieldEnum.CHANNEL_ID.getName().equals(field.getName()))) {
continue;
}
if (ObjectUtil.isNotEmpty(BeanUtil.getFieldValue(reqVO, field.getName()))) {
@@ -21,6 +21,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
@@ -48,6 +49,14 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD
@Slf4j
public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
/**
* 关闭多租户时的统一缓存键,仅用于缓存,不作为查询的租户编号
*/
private static final Long SINGLE_TENANT_CACHE_KEY = 0L;
@Value("${yudao.tenant.enable:true}")
private boolean tenantEnable = true;
@Resource
private ImSensitiveWordMapper sensitiveWordMapper;
@@ -70,7 +79,7 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
}
/**
* 租户 → SensitiveWordBs 实例的本地缓存
* 租户 → SensitiveWordBs 实例的本地缓存;关闭多租户时共用一个缓存
* <p>
* 每分钟触发一次异步 reload,先读 max(update_time),没变就复用旧实例(避免 trie 重建),变了才重新读词库 + 重建。
* 单实例 CRUD 后另外通过 {@link #invalidateSensitiveWordBsCaches()} 立即让本机失效,多实例靠定时刷新最长 1 分钟内收敛。
@@ -87,8 +96,8 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
@Override
public ListenableFuture<SensitiveWordBsCache> reload(Long tenantId, SensitiveWordBsCache oldValue) {
// 异步刷新线程独立于业务线程,没有租户上下文;必须显式 TenantUtils.execute 设置,否则租户拦截器会按当前线程的空上下文拼 SQL
return Futures.immediateFuture(TenantUtils.execute(tenantId, () -> {
// 异步刷新时恢复租户上下文;关闭多租户时不设置租户编号
return Futures.immediateFuture(TenantUtils.execute(tenantEnable ? tenantId : null, () -> {
LocalDateTime currentMax = sensitiveWordMapper.selectMaxUpdateTime(tenantId);
// 没变 → 复用旧实例,避免无谓地重建 trie
if (Objects.equals(oldValue.getMaxUpdateTime(), currentMax)) {
@@ -102,7 +111,7 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
});
private SensitiveWordBsCache loadFresh(Long tenantId) {
return TenantUtils.execute(tenantId, () -> {
return TenantUtils.execute(tenantEnable ? tenantId : null, () -> {
// 先取基线时间再读词库:反过来在两次查询之间出现的新插入会被漏感知
LocalDateTime maxUpdateTime = sensitiveWordMapper.selectMaxUpdateTime(tenantId);
List<ImSensitiveWordDO> words = sensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
@@ -122,9 +131,16 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
/**
* 强制让敏感词缓存失效,下次访问按最新 DB 重建
* <p>
* 有租户上下文:仅失效该租户。无租户上下文(如系统级 / 跨租户清理):兜底失效所有租户。
* 关闭多租户时失效统一缓存;开启时仅失效当前租户,无租户上下文则失效所有租户。
*/
private void invalidateSensitiveWordBsCaches() {
// 情况一:租户未开启的清理
if (!tenantEnable) {
sensitiveWordBsCaches.invalidate(SINGLE_TENANT_CACHE_KEY);
return;
}
// 情况二:租户已开启的清理
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
sensitiveWordBsCaches.invalidate(tenantId);
@@ -138,7 +154,8 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
if (StrUtil.isBlank(text)) {
return;
}
SensitiveWordBs bs = sensitiveWordBsCaches.getUnchecked(TenantContextHolder.getRequiredTenantId()).getBs();
Long cacheKey = tenantEnable ? TenantContextHolder.getRequiredTenantId() : SINGLE_TENANT_CACHE_KEY;
SensitiveWordBs bs = sensitiveWordBsCaches.getUnchecked(cacheKey).getBs();
if (bs.contains(text)) {
throw exception(MESSAGE_SENSITIVE_WORD_BLOCKED);
}
@@ -2,24 +2,28 @@ package cn.iocoder.yudao.module.im.service.sensitiveword;
import cn.hutool.core.collection.ListUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordSaveReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO;
import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper;
import com.google.common.cache.LoadingCache;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_DUPLICATED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_NOT_EXISTS;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -40,10 +44,9 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
@BeforeEach
public void setUp() {
// 设置租户上下文;validateText 与 loadFresh 都依赖 TenantContextHolder
// 设置租户上下文
TenantContextHolder.setTenantId(TENANT_ID);
// mock 启用敏感词列表;LoadingCache 在首次 validateText 时懒加载,无需主动 init
// 用 lenient 避免 null / empty 等不触发 cache load 的用例报 UnnecessaryStubbing
// mock 启用的敏感词,部分用例不触发缓存加载
lenient().when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(1L).word("badword")
@@ -55,69 +58,165 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
@AfterEach
public void tearDown() {
// 清理租户上下文,避免污染其它测试
// 清理租户上下文
TenantContextHolder.clear();
}
@Test
public void testValidateText_null() {
// null 直接返回,不抛异常
// 调用,并断言
assertDoesNotThrow(() -> sensitiveWordService.validateText(null));
}
@Test
public void testValidateText_empty() {
// 调用,并断言
assertDoesNotThrow(() -> sensitiveWordService.validateText(""));
}
@Test
public void testValidateText_clean() {
// 正常文本不应命中
// 调用,并断言
assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world"));
}
@Test
public void testValidateText_tenantDisabled() {
// 准备上下文
ReflectionTestUtils.setField(sensitiveWordService, "tenantEnable", false);
TenantContextHolder.clear();
// 调用,并断言
assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world"));
assertServiceException(() -> sensitiveWordService.validateText("this contains badword here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
assertNull(TenantContextHolder.getTenantId());
}
@Test
public void testValidateText_tenantEnabledWithoutTenantId() {
// 准备上下文
TenantContextHolder.clear();
// 调用,并断言异常
assertThrows(NullPointerException.class, () -> sensitiveWordService.validateText("hello world"));
// 断言
verifyNoInteractions(imSensitiveWordMapper);
}
@Test
public void testValidateText_tenantIsolation() {
// mock 不同租户的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenAnswer(invocation -> List.of(ImSensitiveWordDO.builder()
.word(TenantContextHolder.getRequiredTenantId().equals(TENANT_ID) ? "firstbad" : "secondbad")
.build()));
// 调用,并断言当前租户的词库
assertServiceException(() -> sensitiveWordService.validateText("firstbad"), MESSAGE_SENSITIVE_WORD_BLOCKED);
assertDoesNotThrow(() -> sensitiveWordService.validateText("secondbad"));
// 调用,并断言切换租户后的词库
TenantContextHolder.setTenantId(2L);
assertDoesNotThrow(() -> sensitiveWordService.validateText("firstbad"));
assertServiceException(() -> sensitiveWordService.validateText("secondbad"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 调用,并断言切回租户后复用缓存
TenantContextHolder.setTenantId(TENANT_ID);
assertServiceException(() -> sensitiveWordService.validateText("firstbad"), MESSAGE_SENSITIVE_WORD_BLOCKED);
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWordList_tenantDisabled() {
// 准备上下文
ReflectionTestUtils.setField(sensitiveWordService, "tenantEnable", false);
TenantContextHolder.clear();
// 准备缓存
sensitiveWordService.validateText("hello world");
// 调用,并断言携带租户编号时仍复用缓存
TenantContextHolder.setTenantId(2L);
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// mock 删除后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of());
// 调用
sensitiveWordService.deleteSensitiveWordList(ListUtil.of(1L, 2L));
// 断言缓存已刷新,上下文不变
assertDoesNotThrow(() -> sensitiveWordService.validateText("badword"));
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
assertEquals(2L, TenantContextHolder.getTenantId());
}
@Test
@SuppressWarnings("unchecked")
public void testValidateText_tenantDisabledRefresh() {
// 准备上下文
ReflectionTestUtils.setField(sensitiveWordService, "tenantEnable", false);
TenantContextHolder.clear();
// mock 初始更新时间
when(imSensitiveWordMapper.selectMaxUpdateTime(0L)).thenReturn(LocalDateTime.of(2026, 9, 1, 0, 0));
// 准备缓存
sensitiveWordService.validateText("hello world");
// mock 词库更新,并断言刷新线程无租户编号
when(imSensitiveWordMapper.selectMaxUpdateTime(0L)).thenAnswer(invocation -> {
assertNull(TenantContextHolder.getTenantId());
return LocalDateTime.of(2026, 9, 2, 0, 0);
});
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(List.of(ImSensitiveWordDO.builder().word("newbad").build()));
// 调用
LoadingCache<Long, ?> cache = (LoadingCache<Long, ?>) ReflectionTestUtils.getField(
sensitiveWordService, "sensitiveWordBsCaches");
cache.refresh(0L);
// 断言缓存异步刷新
await().atMost(Duration.ofSeconds(5)).untilAsserted(() ->
assertServiceException(() -> sensitiveWordService.validateText("newbad"), MESSAGE_SENSITIVE_WORD_BLOCKED));
assertDoesNotThrow(() -> sensitiveWordService.validateText("badword"));
}
@Test
public void testValidateText_hitEnglish() {
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("this contains badword here"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 调用,并断言异常
assertServiceException(() -> sensitiveWordService.validateText("this contains badword here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
}
@Test
public void testValidateText_hitChinese() {
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 调用,并断言异常
assertServiceException(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"), MESSAGE_SENSITIVE_WORD_BLOCKED);
}
@Test
public void testValidateText_lazyLoadsCacheOnFirstCall() {
// 调用:首次 validateText 应触发 cache load
// 调用
sensitiveWordService.validateText("hello world");
// 断言:mapper 各调用 1 次(loadFresh 里先取 maxUpdateTime 再读词库)
// 断言
verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID);
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testValidateText_reusesCachedBsAcrossCalls() {
// 调用:连续两次 validateText
// 调用
sensitiveWordService.validateText("hello world");
sensitiveWordService.validateText("another text");
// 断言:第二次复用 cache,mapper 仍只被调用 1 次
// 断言复用缓存
verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID);
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testCreateSensitiveWord_invalidatesCacheAndReloadsOnNextValidate() {
// 准备:首次 validateText 触发 cache load(旧词库)
// 准备缓存
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:mapper 返回新词库(额外多一个 newbad),并让 createSensitiveWord 走通
// mock 新增后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(1L).word("badword")
@@ -129,149 +228,145 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
));
when(imSensitiveWordMapper.selectByWord("newbad")).thenReturn(null);
// 调用:新增敏感词,触发 invalidate
// 准备参数
ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
reqVO.setWord("newbad");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
// 调用
sensitiveWordService.createSensitiveWord(reqVO);
// 调用:再次 validateText,应触发重新 load
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("contains newbad here"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 旧词依然命中
assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("contains badword here"));
// 断言缓存已刷新
assertServiceException(() -> sensitiveWordService.validateText("contains newbad here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 断言原有敏感词仍然生效
assertServiceException(() -> sensitiveWordService.validateText("contains badword here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 断言:selectListByStatus 共被调用 2 次(首次 load + invalidate 后 reload)
// 断言词库重新加载
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testCreateSensitiveWord_duplicateWord_throws() {
// 准备:mock 已存在同名敏感词
// mock 同名敏感词
when(imSensitiveWordMapper.selectByWord("dup")).thenReturn(
ImSensitiveWordDO.builder().id(99L).word("dup")
.status(CommonStatusEnum.ENABLE.getStatus()).build());
// 调用 + 断言:重复敏感词抛 SENSITIVE_WORD_DUPLICATED
// 准备参数
ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
reqVO.setWord("dup");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.createSensitiveWord(reqVO));
assertEquals(SENSITIVE_WORD_DUPLICATED.getCode(), exception.getCode());
// 断言:未走到 insert
// 调用,并断言异常
assertServiceException(() -> sensitiveWordService.createSensitiveWord(reqVO), SENSITIVE_WORD_DUPLICATED, reqVO.getWord());
// 断言
verify(imSensitiveWordMapper, never()).insert(any(ImSensitiveWordDO.class));
}
@Test
public void testUpdateSensitiveWord_invalidatesCache() {
// 准备:首次 validateText 触发 cache load
// 准备缓存
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:让 update 校验通过
// mock 原敏感词
when(imSensitiveWordMapper.selectById(1L)).thenReturn(
ImSensitiveWordDO.builder().id(1L).word("badword")
.status(CommonStatusEnum.ENABLE.getStatus()).build());
when(imSensitiveWordMapper.selectByWord("updatedbad")).thenReturn(null);
// 准备:reload 时返回新词库
// mock 修改后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(1L).word("updatedbad")
.status(CommonStatusEnum.ENABLE.getStatus()).build()
));
// 调用:更新敏感词,触发 invalidate
// 准备参数
ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
reqVO.setId(1L);
reqVO.setWord("updatedbad");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
// 调用
sensitiveWordService.updateSensitiveWord(reqVO);
// 调用 + 断言:再次 validateText 应使用新词库
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("contains updatedbad here"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 断言缓存已刷新
assertServiceException(() -> sensitiveWordService.validateText("contains updatedbad here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 断言:selectListByStatus 共被调用 2 次
// 断言词库重新加载
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWord_invalidatesCache() {
// 准备:首次 validateText 触发 cache load
// 准备缓存
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:让 delete 校验通过
// mock 原敏感词
when(imSensitiveWordMapper.selectById(1L)).thenReturn(
ImSensitiveWordDO.builder().id(1L).word("badword")
.status(CommonStatusEnum.ENABLE.getStatus()).build());
// 准备:reload 时返回剩余词库(只剩中文那条)
// mock 删除后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(2L).word("违禁词")
.status(CommonStatusEnum.ENABLE.getStatus()).build()
));
// 调用:删除 badword,触发 invalidate
// 调用
sensitiveWordService.deleteSensitiveWord(1L);
// 调用 + 断言:badword 已被删,不再命中
// 断言缓存已刷新
assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here"));
// 中文词依然在
assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
// 断言未删除的敏感词仍然生效
assertServiceException(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 断言:selectListByStatus 共被调用 2 次
// 断言词库重新加载
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWord_notExists_throws() {
// 准备:selectById 返回 null
// mock 敏感词不存在
when(imSensitiveWordMapper.selectById(999L)).thenReturn(null);
// 调用 + 断言:抛 SENSITIVE_WORD_NOT_EXISTS
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.deleteSensitiveWord(999L));
assertEquals(SENSITIVE_WORD_NOT_EXISTS.getCode(), exception.getCode());
// 调用,并断言异常
assertServiceException(() -> sensitiveWordService.deleteSensitiveWord(999L), SENSITIVE_WORD_NOT_EXISTS);
// 断言:未走到 deleteById
// 断言
verify(imSensitiveWordMapper, never()).deleteById(anyLong());
}
@Test
public void testDeleteSensitiveWordList_invalidatesCache() {
// 准备:首次 validateText 触发 cache load
// 准备缓存
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:reload 时返回空词库
// mock 删除后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of());
// 调用:批量删除,触发 invalidate
// 调用
sensitiveWordService.deleteSensitiveWordList(ListUtil.of(1L, 2L));
// 调用 + 断言:所有词都不再命中
// 断言缓存已刷新
assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here"));
assertDoesNotThrow(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
// 断言:deleteByIds 被调用 1 次;selectListByStatus 共 2 次
// 断言
verify(imSensitiveWordMapper, times(1)).deleteByIds(ListUtil.of(1L, 2L));
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWordList_emptyIds_skip() {
// 调用:空列表直接返回
// 调用
sensitiveWordService.deleteSensitiveWordList(ListUtil.of());
// 断言:mapper 不被调用
// 断言
verify(imSensitiveWordMapper, never()).deleteByIds(anyList());
}
@@ -1,12 +1,11 @@
package cn.iocoder.yudao.module.product.controller.admin.spu.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
@Schema(description = "管理后台 - 商品 SKU 创建/更新 Request VO")
@@ -19,12 +18,15 @@ public class ProductSkuSaveReqVO {
@Schema(description = "销售价格,单位:分", requiredMode = Schema.RequiredMode.REQUIRED, example = "1999")
@NotNull(message = "销售价格,单位:分不能为空")
@Min(value = 0, message = "销售价格,单位:分不能小于 0")
private Integer price;
@Schema(description = "市场价", example = "2999")
@Min(value = 0, message = "市场价不能小于 0")
private Integer marketPrice;
@Schema(description = "成本价", example = "19")
@Min(value = 0, message = "成本价不能小于 0")
private Integer costPrice;
@Schema(description = "条形码", example = "15156165456")
@@ -45,9 +47,11 @@ public class ProductSkuSaveReqVO {
private Double volume;
@Schema(description = "一级分销的佣金,单位:分", example = "199")
@Min(value = 0, message = "一级分销的佣金,单位:分不能小于 0")
private Integer firstBrokeragePrice;
@Schema(description = "二级分销的佣金,单位:分", example = "19")
@Min(value = 0, message = "二级分销的佣金,单位:分不能小于 0")
private Integer secondBrokeragePrice;
@Schema(description = "属性数组")
@@ -76,14 +76,15 @@ public interface ProductSpuMapper extends BaseMapperX<ProductSpuDO> {
query.eq(ProductSpuDO::getStatus, ProductSpuStatusEnum.ENABLE.getStatus());
// 排序逻辑
Boolean sortAsc = pageReqVO.getSortAsc() != null ? pageReqVO.getSortAsc() : Boolean.TRUE;
if (Objects.equals(pageReqVO.getSortField(), AppProductSpuPageReqVO.SORT_FIELD_SALES_COUNT)) {
query.last(String.format(" ORDER BY (sales_count + virtual_sales_count) %s, sort DESC, id DESC",
pageReqVO.getSortAsc() ? "ASC" : "DESC"));
sortAsc ? "ASC" : "DESC"));
} else if (Objects.equals(pageReqVO.getSortField(), AppProductSpuPageReqVO.SORT_FIELD_PRICE)) {
query.orderBy(true, pageReqVO.getSortAsc(), ProductSpuDO::getPrice)
query.orderBy(true, sortAsc, ProductSpuDO::getPrice)
.orderByDesc(ProductSpuDO::getSort).orderByDesc(ProductSpuDO::getId);
} else if (Objects.equals(pageReqVO.getSortField(), AppProductSpuPageReqVO.SORT_FIELD_CREATE_TIME)) {
query.orderBy(true, pageReqVO.getSortAsc(), ProductSpuDO::getCreateTime)
query.orderBy(true, sortAsc, ProductSpuDO::getCreateTime)
.orderByDesc(ProductSpuDO::getSort).orderByDesc(ProductSpuDO::getId);
} else {
query.orderByDesc(ProductSpuDO::getSort).orderByDesc(ProductSpuDO::getId);
@@ -16,6 +16,7 @@ public interface ErrorCodeConstants {
ErrorCode CATEGORY_EXISTS_CHILDREN = new ErrorCode(1_008_001_003, "存在子分类,无法删除");
ErrorCode CATEGORY_DISABLED = new ErrorCode(1_008_001_004, "商品分类({})已禁用,无法使用");
ErrorCode CATEGORY_HAVE_BIND_SPU = new ErrorCode(1_008_001_005, "类别下存在商品,无法删除");
ErrorCode CATEGORY_PARENT_IS_SELF = new ErrorCode(1_008_001_006, "父分类不能设置为分类自身");
// ========== 商品品牌相关编号 1-008-002-000 ==========
ErrorCode BRAND_NOT_EXISTS = new ErrorCode(1_008_002_000, "品牌不存在");
@@ -44,6 +45,7 @@ public interface ErrorCodeConstants {
ErrorCode SPU_ATTR_NUMBERS_MUST_BE_EQUALS = new ErrorCode(1_008_006_002, "一个 SPU 下的每个 SKU,其属性项必须一致");
ErrorCode SPU_SKU_NOT_DUPLICATE = new ErrorCode(1_008_006_003, "一个 SPU 下的每个 SKU,必须不重复");
ErrorCode SKU_STOCK_NOT_ENOUGH = new ErrorCode(1_008_006_004, "商品 SKU 库存不足");
ErrorCode SKU_PROPERTIES_EMPTY = new ErrorCode(1_008_006_005, "多规格商品必须从规格列表选择属性组合,SKU 属性不能为空");
// ========== 商品 评价 1-008-007-000 ==========
ErrorCode COMMENT_NOT_EXISTS = new ErrorCode(1_008_007_000, "商品评价不存在");
@@ -55,6 +55,10 @@ public class ProductCategoryServiceImpl implements ProductCategoryService {
public void updateCategory(ProductCategorySaveReqVO updateReqVO) {
// 校验分类是否存在
validateProductCategoryExists(updateReqVO.getId());
// 校验父分类不能是分类自身(环检测:父级设成自己会形成循环层级,后续按父链遍历将死循环/脏数据)
if (Objects.equals(updateReqVO.getParentId(), updateReqVO.getId())) {
throw exception(CATEGORY_PARENT_IS_SELF);
}
// 校验父分类存在
validateParentProductCategory(updateReqVO.getParentId());
@@ -104,11 +104,12 @@ public class ProductSkuServiceImpl implements ProductSkuService {
// 1、校验属性项存在
Set<Long> propertyIds = skus.stream().filter(p -> p.getProperties() != null)
// 遍历多个 Property 属性
.flatMap(p -> p.getProperties().stream())
// 将每个 Property 转换成对应的 propertyId,最后形成集合
.map(ProductSkuSaveReqVO.Property::getPropertyId)
.collect(Collectors.toSet());
if (CollUtil.isEmpty(propertyIds)) {
throw exception(SKU_PROPERTIES_EMPTY);
}
List<ProductPropertyDO> propertyList = productPropertyService.getPropertyList(propertyIds);
if (propertyList.size() != propertyIds.size()) {
throw exception(PROPERTY_NOT_EXISTS);
@@ -32,7 +32,8 @@ public interface CombinationActivityMapper extends BaseMapperX<CombinationActivi
default PageResult<CombinationActivityDO> selectPage(PageParam pageParam, Integer status) {
return selectPage(pageParam, new LambdaQueryWrapperX<CombinationActivityDO>()
.eq(CombinationActivityDO::getStatus, status));
.eq(CombinationActivityDO::getStatus, status)
.orderByDesc(CombinationActivityDO::getId));
}
default CombinationActivityDO selectBySpuIdAndStatusAndNow(Long spuId, Integer status) {
@@ -45,11 +45,13 @@ public interface BrokerageRecordService {
/**
* 增加佣金【多级分佣】
*
* @param userId 会员编号
* @param userId 会员编号(下单人)
* @param brokerageUserId 订单推广人
* @param bizType 业务类型
* @param list 请求参数列表
*/
void addBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, @Valid List<BrokerageAddReqBO> list);
void addBrokerage(Long userId, Long brokerageUserId, BrokerageRecordBizTypeEnum bizType,
@Valid List<BrokerageAddReqBO> list);
/**
* 增加佣金【只针对自己】
@@ -74,16 +74,18 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService {
@Override
@Transactional(rollbackFor = Exception.class)
public void addBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, List<BrokerageAddReqBO> list) {
public void addBrokerage(Long userId, Long brokerageUserId, BrokerageRecordBizTypeEnum bizType,
List<BrokerageAddReqBO> list) {
TradeConfigDO memberConfig = tradeConfigService.getTradeConfig();
// 0 未启用分销功能
if (memberConfig == null || !BooleanUtil.isTrue(memberConfig.getBrokerageEnabled())) {
log.error("[addBrokerage][增加佣金失败:brokerageEnabled 未配置,userId({}) bizType({}) list({})", userId, bizType, list);
log.error("[addBrokerage][增加佣金失败:brokerageEnabled 未配置,userId({}) brokerageUserId({}) bizType({}) list({})",
userId, brokerageUserId, bizType, list);
return;
}
// 1.1 获得一级推广人
BrokerageUserDO firstUser = brokerageUserService.getBindBrokerageUser(userId);
BrokerageUserDO firstUser = brokerageUserService.getBrokerageUser(brokerageUserId);
if (firstUser == null || !BooleanUtil.isTrue(firstUser.getBrokerageEnabled())) {
return;
}
@@ -58,7 +58,7 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler {
if (order.getBrokerageUserId() == null) {
return;
}
addBrokerage(order.getUserId(), orderItems);
addBrokerage(order.getUserId(), order.getBrokerageUserId(), orderItems);
}
@Override
@@ -97,9 +97,10 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler {
* 2. 支付 or 下单成功时,创建分销记录(冻结),确认收货解冻或者 n 天后解冻
*
* @param userId 用户编号
* @param brokerageUserId 订单推广人
* @param orderItems 订单项
*/
protected void addBrokerage(Long userId, List<TradeOrderItemDO> orderItems) {
protected void addBrokerage(Long userId, Long brokerageUserId, List<TradeOrderItemDO> orderItems) {
MemberUserRespDTO user = memberUserApi.getUser(userId);
Assert.notNull(user);
Map<Long, ProductSpuRespDTO> spusMap = productSpuApi.getSpuMap(convertList(orderItems, TradeOrderItemDO::getSpuId));
@@ -113,7 +114,7 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler {
Assert.notNull(sku);
return TradeOrderConvert.INSTANCE.convert(user, item, spu, sku);
});
brokerageRecordService.addBrokerage(userId, BrokerageRecordBizTypeEnum.ORDER, addList);
brokerageRecordService.addBrokerage(userId, brokerageUserId, BrokerageRecordBizTypeEnum.ORDER, addList);
}
}
@@ -11,10 +11,12 @@ import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO;
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.record.BrokerageRecordPageReqVO;
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.record.AppBrokerageProductPriceRespVO;
import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageRecordDO;
import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO;
import cn.iocoder.yudao.module.trade.dal.dataobject.config.TradeConfigDO;
import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageRecordMapper;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordStatusEnum;
import cn.iocoder.yudao.module.trade.service.brokerage.bo.BrokerageAddReqBO;
import cn.iocoder.yudao.module.trade.service.config.TradeConfigService;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -22,6 +24,7 @@ import org.springframework.context.annotation.Import;
import javax.annotation.Resource;
import java.math.RoundingMode;
import java.util.List;
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime;
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime;
@@ -30,6 +33,7 @@ import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEq
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -96,6 +100,26 @@ public class BrokerageRecordServiceImplTest extends BaseDbUnitTest {
assertPojoEquals(dbBrokerageRecord, pageResult.getList().get(0));
}
@Test
public void testAddBrokerage_usesOrderBrokerageUser() {
TradeConfigDO tradeConfig = new TradeConfigDO();
tradeConfig.setBrokerageEnabled(true);
tradeConfig.setBrokerageFirstPercent(10);
when(tradeConfigService.getTradeConfig()).thenReturn(tradeConfig);
BrokerageUserDO orderBrokerageUser = new BrokerageUserDO().setId(200L).setBrokerageEnabled(true)
.setBrokeragePrice(0);
when(brokerageUserService.getBrokerageUser(200L)).thenReturn(orderBrokerageUser);
brokerageRecordService.addBrokerage(100L, 200L, BrokerageRecordBizTypeEnum.ORDER,
ListUtil.of(new BrokerageAddReqBO("1", 1000, null, null, 100L, "订单商品")));
List<BrokerageRecordDO> records = brokerageRecordMapper.selectList(null);
assertEquals(1, records.size());
assertEquals(200L, records.get(0).getUserId());
assertEquals(100, records.get(0).getPrice());
verify(brokerageUserService).updateUserPrice(200L, 100);
}
@Test
public void testCalculatePrice_useFixedPrice() {
// mock 数据
@@ -10,11 +10,11 @@ import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockSe
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
@@ -96,6 +96,30 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest {
assertRecord(records.get(8), LocalDateTime.of(2026, 4, 3, 0, 0), 3, 101L, 13L);
}
@Test
public void testGenerateTeamShiftRecords_singleWithoutRotation() {
Long planId = 3002L;
LocalDateTime start = LocalDateTime.of(2026, 9, 21, 0, 0);
when(planService.getPlan(planId)).thenReturn(MesCalPlanDO.builder()
.id(planId).code("SINGLE-001").name("单白班")
.calendarType(1).startDate(start).endDate(start.plusDays(1))
.shiftType(MesCalShiftTypeEnum.SINGLE.getType())
.shiftMethod(null).shiftCount(null).build());
when(planShiftService.getPlanShiftListByPlanId(planId)).thenReturn(ListUtil.of(
MesCalPlanShiftDO.builder().id(11L).planId(planId).sort(1).name("白班")
.startTime("08:00").endTime("16:00").build()));
when(planTeamService.getPlanTeamListByPlanId(planId)).thenReturn(ListUtil.of(
MesCalPlanTeamDO.builder().id(21L).planId(planId).teamId(101L).build()));
teamShiftService.generateTeamShiftRecords(planId);
List<MesCalTeamShiftDO> records = teamShiftMapper.selectListByPlanId(planId);
records.sort(Comparator.comparing(MesCalTeamShiftDO::getDay));
assertEquals(2, records.size());
assertRecord(records.get(0), start, 1, 101L, 11L);
assertRecord(records.get(1), start.plusDays(1), 1, 101L, 11L);
}
private static void assertRecord(MesCalTeamShiftDO actual, LocalDateTime day, Integer sort,
Long teamId, Long shiftId) {
assertEquals(day, actual.getDay());
@@ -46,6 +46,8 @@ public interface ErrorCodeConstants {
ErrorCode USER_IMPORT_INIT_PASSWORD = new ErrorCode(1_002_003_009, "初始密码不能为空");
ErrorCode USER_MOBILE_NOT_EXISTS = new ErrorCode(1_002_003_010, "该手机号尚未注册");
ErrorCode USER_REGISTER_DISABLED = new ErrorCode(1_002_003_011, "注册功能已关闭");
ErrorCode USER_ADMIN_NOT_ALLOW_DISABLE = new ErrorCode(1_002_003_012, "超级管理员或租户管理员账号不能禁用");
ErrorCode USER_ADMIN_NOT_ALLOW_DELETE = new ErrorCode(1_002_003_013, "超级管理员或租户管理员账号不能删除");
// ========== 部门模块 1-002-004-000 ==========
ErrorCode DEPT_NAME_DUPLICATE = new ErrorCode(1_002_004_000, "已经存在该名字的部门");
@@ -6,10 +6,12 @@ import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
import cn.iocoder.yudao.framework.datapermission.core.util.DataPermissionUtils;
import cn.iocoder.yudao.module.infra.api.config.ConfigApi;
@@ -22,14 +24,17 @@ import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserPageReqV
import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.UserPostDO;
import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.dept.UserPostMapper;
import cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper;
import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum;
import cn.iocoder.yudao.module.system.mq.producer.user.AdminUserProducer;
import cn.iocoder.yudao.module.system.service.dept.DeptService;
import cn.iocoder.yudao.module.system.service.dept.PostService;
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
import cn.iocoder.yudao.module.system.service.permission.PermissionService;
import cn.iocoder.yudao.module.system.service.permission.RoleService;
import cn.iocoder.yudao.module.system.service.tenant.TenantService;
import com.google.common.annotations.VisibleForTesting;
import com.mzt.logapi.context.LogRecordContext;
@@ -75,6 +80,8 @@ public class AdminUserServiceImpl implements AdminUserService {
@Resource
private PermissionService permissionService;
@Resource
private RoleService roleService;
@Resource
private PasswordEncoder passwordEncoder;
@Resource
@Lazy // 延迟,避免循环依赖报错
@@ -250,15 +257,20 @@ public class AdminUserServiceImpl implements AdminUserService {
@Override
public void updateUserStatus(Long id, Integer status) {
// 校验用户存在
// 1.1 校验用户存在
validateUserExists(id);
// 更新状态
// 1.2 管理员不能禁用
if (CommonStatusEnum.isDisable(status)) {
validateUserNotAdmin(id, USER_ADMIN_NOT_ALLOW_DISABLE);
}
// 2. 更新状态
AdminUserDO updateObj = new AdminUserDO();
updateObj.setId(id);
updateObj.setStatus(status);
userMapper.updateById(updateObj);
// 如果是禁用用户,则删除其 Token 信息
// 3. 如果是禁用用户,则删除其 Token 信息
if (CommonStatusEnum.isDisable(status)) {
oauth2TokenService.removeAccessToken(id, UserTypeEnum.ADMIN.getValue());
}
@@ -269,8 +281,10 @@ public class AdminUserServiceImpl implements AdminUserService {
@LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_DELETE_SUB_TYPE, bizNo = "{{#id}}",
success = SYSTEM_USER_DELETE_SUCCESS)
public void deleteUser(Long id) {
// 1. 校验用户存在
// 1.1 校验用户存在
AdminUserDO user = validateUserExists(id);
// 1.2 管理员不能删除
validateUserNotAdmin(id, USER_ADMIN_NOT_ALLOW_DELETE);
// 2.1 删除用户
userMapper.deleteById(id);
@@ -286,16 +300,32 @@ public class AdminUserServiceImpl implements AdminUserService {
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteUserList(List<Long> ids) {
// 1. 批量删除用户
// 1. 校验不能删除管理员
ids.forEach(id -> validateUserNotAdmin(id, USER_ADMIN_NOT_ALLOW_DELETE));
// 2. 批量删除用户
userMapper.deleteByIds(ids);
// 2. 批量删除用户关联数据
// 3. 批量删除用户关联数据
ids.forEach(id -> {
permissionService.processUserDeleted(id);
userPostMapper.deleteByUserId(id);
});
}
private void validateUserNotAdmin(Long id, ErrorCode errorCode) {
Set<Long> roleIds = permissionService.getUserRoleIdListByUserId(id);
if (CollUtil.isEmpty(roleIds)) {
return;
}
for (RoleDO role : roleService.getRoleList(roleIds)) {
if (ObjectUtils.equalsAny(role.getCode(),
RoleCodeEnum.SUPER_ADMIN.getCode(), RoleCodeEnum.TENANT_ADMIN.getCode())) {
throw exception(errorCode);
}
}
}
@Override
public AdminUserDO getUserByUsername(String username) {
return userMapper.selectByUsername(username);
@@ -562,6 +592,16 @@ public class AdminUserServiceImpl implements AdminUserService {
respVO.getFailureUsernames().put(importUser.getUsername(), USER_USERNAME_EXISTS.getMsg());
return;
}
// 2.2.3 校验管理员不能禁用
if (CommonStatusEnum.isDisable(importUser.getStatus())) {
try {
validateUserNotAdmin(existUser.getId(), USER_ADMIN_NOT_ALLOW_DISABLE);
} catch (ServiceException ex) {
respVO.getFailureUsernames().put(importUser.getUsername(), ex.getMessage());
return;
}
}
// 2.2.4 更新用户
AdminUserDO updateUser = BeanUtils.toBean(importUser, AdminUserDO.class);
updateUser.setId(existUser.getId());
userMapper.updateById(updateUser);
@@ -19,25 +19,28 @@ import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserSaveReqV
import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.PostDO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.UserPostDO;
import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
import cn.iocoder.yudao.module.system.dal.dataobject.tenant.TenantDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.dept.UserPostMapper;
import cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper;
import cn.iocoder.yudao.module.system.enums.common.SexEnum;
import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum;
import cn.iocoder.yudao.module.system.mq.producer.user.AdminUserProducer;
import cn.iocoder.yudao.module.system.service.dept.DeptService;
import cn.iocoder.yudao.module.system.service.dept.PostService;
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
import cn.iocoder.yudao.module.system.service.permission.PermissionService;
import cn.iocoder.yudao.module.system.service.permission.RoleService;
import cn.iocoder.yudao.module.system.service.tenant.TenantService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.stubbing.Answer;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -71,23 +74,25 @@ public class AdminUserServiceImplTest extends BaseDbUnitTest {
@Resource
private UserPostMapper userPostMapper;
@MockBean
@MockitoBean
private DeptService deptService;
@MockBean
@MockitoBean
private PostService postService;
@MockBean
@MockitoBean
private PermissionService permissionService;
@MockBean
@MockitoBean
private RoleService roleService;
@MockitoBean
private PasswordEncoder passwordEncoder;
@MockBean
@MockitoBean
private TenantService tenantService;
@MockBean
@MockitoBean
private FileApi fileApi;
@MockBean
@MockitoBean
private ConfigApi configApi;
@MockBean
@MockitoBean
private OAuth2TokenService oauth2TokenService;
@MockBean
@MockitoBean
private AdminUserProducer adminUserProducer;
@BeforeEach
@@ -289,6 +294,35 @@ public class AdminUserServiceImplTest extends BaseDbUnitTest {
verify(oauth2TokenService).removeAccessToken(userId, UserTypeEnum.ADMIN.getValue());
}
@Test
public void testUpdateUserStatus_adminCannotDisable() {
for (RoleCodeEnum roleCode : new RoleCodeEnum[]{RoleCodeEnum.SUPER_ADMIN, RoleCodeEnum.TENANT_ADMIN}) {
// mock 数据
AdminUserDO dbUser = randomAdminUserDO(o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()));
userMapper.insert(dbUser);
mockUserRole(dbUser.getId(), roleCode);
// 调用,并断言异常
assertServiceException(() -> userService.updateUserStatus(dbUser.getId(), CommonStatusEnum.DISABLE.getStatus()),
USER_ADMIN_NOT_ALLOW_DISABLE);
assertEquals(CommonStatusEnum.ENABLE.getStatus(), userMapper.selectById(dbUser.getId()).getStatus());
}
verify(oauth2TokenService, never()).removeAccessToken(anyLong(), anyInt());
}
@Test
public void testUpdateUserStatus_adminCanEnable() {
// mock 数据
AdminUserDO dbUser = randomAdminUserDO(o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus()));
userMapper.insert(dbUser);
mockUserRole(dbUser.getId(), RoleCodeEnum.TENANT_ADMIN);
// 调用
userService.updateUserStatus(dbUser.getId(), CommonStatusEnum.ENABLE.getStatus());
// 断言
assertEquals(CommonStatusEnum.ENABLE.getStatus(), userMapper.selectById(dbUser.getId()).getStatus());
}
@Test
public void testDeleteUser_success(){
// mock 数据
@@ -305,6 +339,43 @@ public class AdminUserServiceImplTest extends BaseDbUnitTest {
verify(permissionService, times(1)).processUserDeleted(eq(userId));
}
@Test
public void testDeleteUser_adminCannotDelete() {
for (RoleCodeEnum roleCode : new RoleCodeEnum[]{RoleCodeEnum.SUPER_ADMIN, RoleCodeEnum.TENANT_ADMIN}) {
// mock 数据
AdminUserDO dbUser = randomAdminUserDO();
userMapper.insert(dbUser);
mockUserRole(dbUser.getId(), roleCode);
// 调用,并断言异常
assertServiceException(() -> userService.deleteUser(dbUser.getId()), USER_ADMIN_NOT_ALLOW_DELETE);
assertNotNull(userMapper.selectById(dbUser.getId()));
}
}
@Test
public void testDeleteUserList_adminCannotDelete() {
// mock 数据
AdminUserDO ordinaryUser = randomAdminUserDO();
userMapper.insert(ordinaryUser);
AdminUserDO adminUser = randomAdminUserDO();
userMapper.insert(adminUser);
mockUserRole(adminUser.getId(), RoleCodeEnum.TENANT_ADMIN);
// 调用,并断言整批未删除
assertServiceException(() -> userService.deleteUserList(List.of(ordinaryUser.getId(), adminUser.getId())),
USER_ADMIN_NOT_ALLOW_DELETE);
assertNotNull(userMapper.selectById(ordinaryUser.getId()));
assertNotNull(userMapper.selectById(adminUser.getId()));
}
private void mockUserRole(Long userId, RoleCodeEnum roleCode) {
Long roleId = 100L;
when(permissionService.getUserRoleIdListByUserId(userId)).thenReturn(asSet(roleId));
when(roleService.getRoleList(asSet(roleId))).thenReturn(singletonList(
randomPojo(RoleDO.class, role -> role.setCode(roleCode.getCode()))));
}
@Test
public void testGetUserByUsername() {
// mock 数据
@@ -532,6 +603,29 @@ public class AdminUserServiceImplTest extends BaseDbUnitTest {
assertEquals(0, respVO.getFailureUsernames().size());
}
@Test
public void testImportUserList_adminCannotDisable() {
// mock 数据
AdminUserDO dbUser = randomAdminUserDO(o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()));
userMapper.insert(dbUser);
mockUserRole(dbUser.getId(), RoleCodeEnum.TENANT_ADMIN);
// 准备参数
UserImportExcelVO importUser = randomPojo(UserImportExcelVO.class, o -> {
o.setUsername(dbUser.getUsername());
o.setStatus(CommonStatusEnum.DISABLE.getStatus());
o.setSex(randomEle(SexEnum.values()).getSex());
o.setEmail(randomEmail());
o.setMobile(randomMobile());
});
// 调用
UserImportRespVO respVO = userService.importUserList(singletonList(importUser), true);
// 断言
assertEquals(USER_ADMIN_NOT_ALLOW_DISABLE.getMsg(), respVO.getFailureUsernames().get(importUser.getUsername()));
assertTrue(respVO.getUpdateUsernames().isEmpty());
assertEquals(CommonStatusEnum.ENABLE.getStatus(), userMapper.selectById(dbUser.getId()).getStatus());
}
@Test
public void testValidateUserExists_notExists() {
assertServiceException(() -> userService.validateUserExists(randomLongId()), USER_NOT_EXISTS);