|
|
@@ -1,40 +1,115 @@
|
|
1
|
1
|
package com.huimv.employment.service.draft;
|
|
2
|
2
|
|
|
|
3
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
4
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
5
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
6
|
+import com.huimv.employment.common.exception.BizException;
|
|
|
7
|
+import com.huimv.employment.common.exception.ErrorCode;
|
|
3
|
8
|
import com.huimv.employment.dao.entity.FeEmploymentDraft;
|
|
|
9
|
+import com.huimv.employment.dao.entity.FeServicePlan;
|
|
|
10
|
+import com.huimv.employment.dao.entity.FeServicePlanFeeRule;
|
|
|
11
|
+import com.huimv.employment.dao.mapper.FeServicePlanFeeRuleMapper;
|
|
|
12
|
+import com.huimv.employment.dao.mapper.FeServicePlanMapper;
|
|
4
|
13
|
import com.huimv.employment.service.draft.dto.McpDraftCalculateResponse;
|
|
5
|
14
|
import com.huimv.employment.service.draft.dto.McpDraftSchemeCostResponse;
|
|
6
|
15
|
import com.huimv.employment.service.draft.dto.McpDraftSchemeResponse;
|
|
7
|
16
|
import com.huimv.employment.service.draft.dto.McpDraftSchemeWorkerIncomeResponse;
|
|
8
|
17
|
import org.springframework.stereotype.Component;
|
|
|
18
|
+import org.springframework.util.StringUtils;
|
|
9
|
19
|
|
|
10
|
20
|
import java.math.BigDecimal;
|
|
11
|
21
|
import java.math.RoundingMode;
|
|
12
|
|
-import java.util.Arrays;
|
|
|
22
|
+import java.time.LocalDate;
|
|
|
23
|
+import java.util.ArrayList;
|
|
|
24
|
+import java.util.Collections;
|
|
|
25
|
+import java.util.HashMap;
|
|
13
|
26
|
import java.util.List;
|
|
|
27
|
+import java.util.Map;
|
|
|
28
|
+import java.util.regex.Matcher;
|
|
|
29
|
+import java.util.regex.Pattern;
|
|
14
|
30
|
|
|
15
|
31
|
/**
|
|
16
|
|
- * 草稿成本测算引擎(v2 §6.3.4 黑盒)。
|
|
17
|
|
- * <p>一期内置 SCHEME_A(灵活用工)与 SCHEME_B(劳务报酬)两套规则,后续可接入 fe_service_plan。</p>
|
|
|
32
|
+ * 草稿成本测算引擎(v2 §6.3.4)。
|
|
|
33
|
+ * <p>从 {@code fe_service_plan} + {@code fe_service_plan_fee_rule} 读取方案与费率,不在代码中写死业务费率。</p>
|
|
18
|
34
|
*/
|
|
19
|
35
|
@Component
|
|
20
|
36
|
public class DraftCostCalculationEngine {
|
|
21
|
37
|
|
|
22
|
|
- public static final String SCHEME_A = "SCHEME_A";
|
|
23
|
|
- public static final String SCHEME_B = "SCHEME_B";
|
|
24
|
38
|
private static final String CALC_VERSION = "v1";
|
|
|
39
|
+ private static final String STATUS_ACTIVE = "active";
|
|
|
40
|
+ private static final String PLAN_CURRENT = "current";
|
|
|
41
|
+ private static final String FEE_SERVICE = "service_fee";
|
|
|
42
|
+ private static final String FEE_TAX = "tax_withhold";
|
|
|
43
|
+ private static final String CALC_RATE = "rate";
|
|
|
44
|
+ private static final String CALC_FORMULA = "formula";
|
|
|
45
|
+ private static final String CALC_FIXED = "fixed";
|
|
25
|
46
|
|
|
26
|
|
- /** 灵活用工平台服务费率 2% */
|
|
27
|
|
- private static final BigDecimal SCHEME_A_SERVICE_FEE_RATE = new BigDecimal("0.02");
|
|
28
|
|
- /** 劳务报酬预扣税率(示例:3000 元对应 440 元) */
|
|
29
|
|
- private static final BigDecimal SCHEME_B_TAX_RATE = new BigDecimal("440").divide(new BigDecimal("3000"), 8, RoundingMode.HALF_UP);
|
|
|
47
|
+ /** gross*440/3000 或 gross * 0.02 */
|
|
|
48
|
+ private static final Pattern FORMULA_MUL_DIV = Pattern.compile(
|
|
|
49
|
+ "(?i)^(gross|labor|company_pay)\\s*\\*\\s*(\\d+(?:\\.\\d+)?)\\s*/\\s*(\\d+(?:\\.\\d+)?)$");
|
|
|
50
|
+ private static final Pattern FORMULA_MUL = Pattern.compile(
|
|
|
51
|
+ "(?i)^(gross|labor|company_pay)\\s*\\*\\s*(\\d+(?:\\.\\d+)?)$");
|
|
|
52
|
+
|
|
|
53
|
+ private final FeServicePlanMapper feServicePlanMapper;
|
|
|
54
|
+ private final FeServicePlanFeeRuleMapper feServicePlanFeeRuleMapper;
|
|
|
55
|
+ private final ObjectMapper objectMapper;
|
|
|
56
|
+
|
|
|
57
|
+ public DraftCostCalculationEngine(FeServicePlanMapper feServicePlanMapper,
|
|
|
58
|
+ FeServicePlanFeeRuleMapper feServicePlanFeeRuleMapper,
|
|
|
59
|
+ ObjectMapper objectMapper) {
|
|
|
60
|
+ this.feServicePlanMapper = feServicePlanMapper;
|
|
|
61
|
+ this.feServicePlanFeeRuleMapper = feServicePlanFeeRuleMapper;
|
|
|
62
|
+ this.objectMapper = objectMapper;
|
|
|
63
|
+ }
|
|
30
|
64
|
|
|
31
|
65
|
public List<CalculatedScheme> calculate(FeEmploymentDraft draft) {
|
|
32
|
|
- BigDecimal laborTotal = draft.getEstimatedTotal();
|
|
|
66
|
+ BigDecimal laborTotal = resolveLaborTotal(draft);
|
|
33
|
67
|
int workerCount = draft.getWorkerCount() != null && draft.getWorkerCount() > 0
|
|
34
|
68
|
? draft.getWorkerCount() : 1;
|
|
35
|
|
- return Arrays.asList(
|
|
36
|
|
- buildSchemeA(laborTotal, workerCount),
|
|
37
|
|
- buildSchemeB(laborTotal, workerCount));
|
|
|
69
|
+
|
|
|
70
|
+ List<FeServicePlan> plans = listActiveComparisonPlans();
|
|
|
71
|
+ if (plans.isEmpty()) {
|
|
|
72
|
+ throw new BizException(ErrorCode.BAD_REQUEST, "未配置可用用工方案,请先初始化 fe_service_plan");
|
|
|
73
|
+ }
|
|
|
74
|
+
|
|
|
75
|
+ Map<Long, List<FeServicePlanFeeRule>> rulesByPlan = loadRulesByPlanIds(plans);
|
|
|
76
|
+ List<CalculatedScheme> result = new ArrayList<>(plans.size());
|
|
|
77
|
+ boolean recommendedAssigned = false;
|
|
|
78
|
+ for (FeServicePlan plan : plans) {
|
|
|
79
|
+ boolean recommended = !recommendedAssigned;
|
|
|
80
|
+ recommendedAssigned = true;
|
|
|
81
|
+ result.add(buildScheme(plan, rulesByPlan.getOrDefault(plan.getId(), Collections.emptyList()),
|
|
|
82
|
+ laborTotal, workerCount, recommended));
|
|
|
83
|
+ }
|
|
|
84
|
+ return result;
|
|
|
85
|
+ }
|
|
|
86
|
+
|
|
|
87
|
+ /**
|
|
|
88
|
+ * 劳务费基数:优先人数×天数×日薪;否则用草稿 total_amount(estimated_total)。
|
|
|
89
|
+ */
|
|
|
90
|
+ private static BigDecimal resolveLaborTotal(FeEmploymentDraft draft) {
|
|
|
91
|
+ if (draft.getDailyWage() != null
|
|
|
92
|
+ && draft.getWorkerCount() != null && draft.getWorkerCount() > 0
|
|
|
93
|
+ && draft.getWorkDays() != null && draft.getWorkDays() > 0) {
|
|
|
94
|
+ return draft.getDailyWage()
|
|
|
95
|
+ .multiply(BigDecimal.valueOf(draft.getWorkerCount()))
|
|
|
96
|
+ .multiply(BigDecimal.valueOf(draft.getWorkDays()));
|
|
|
97
|
+ }
|
|
|
98
|
+ if (draft.getEstimatedTotal() != null
|
|
|
99
|
+ && draft.getEstimatedTotal().compareTo(BigDecimal.ZERO) > 0) {
|
|
|
100
|
+ return draft.getEstimatedTotal();
|
|
|
101
|
+ }
|
|
|
102
|
+ throw new BizException(ErrorCode.BAD_REQUEST, "草稿劳务费总额为空,无法测算(需 total_amount 或 人数×天数×日薪)");
|
|
|
103
|
+ }
|
|
|
104
|
+
|
|
|
105
|
+ /** 方案编码是否为库中 active 对比方案(非 current)。 */
|
|
|
106
|
+ public boolean isAllowedSchemeCode(String schemeCode) {
|
|
|
107
|
+ if (!StringUtils.hasText(schemeCode)) {
|
|
|
108
|
+ return false;
|
|
|
109
|
+ }
|
|
|
110
|
+ String code = schemeCode.trim();
|
|
|
111
|
+ return listActiveComparisonPlans().stream()
|
|
|
112
|
+ .anyMatch(p -> code.equalsIgnoreCase(p.getPlanCode()));
|
|
38
|
113
|
}
|
|
39
|
114
|
|
|
40
|
115
|
public McpDraftCalculateResponse toApiResponse(List<CalculatedScheme> schemes) {
|
|
|
@@ -49,37 +124,159 @@ public class DraftCostCalculationEngine {
|
|
49
|
124
|
return CALC_VERSION;
|
|
50
|
125
|
}
|
|
51
|
126
|
|
|
52
|
|
- private CalculatedScheme buildSchemeA(BigDecimal laborTotal, int workerCount) {
|
|
|
127
|
+ private List<FeServicePlan> listActiveComparisonPlans() {
|
|
|
128
|
+ LocalDate today = LocalDate.now();
|
|
|
129
|
+ List<FeServicePlan> plans = feServicePlanMapper.selectList(new LambdaQueryWrapper<FeServicePlan>()
|
|
|
130
|
+ .eq(FeServicePlan::getStatus, STATUS_ACTIVE)
|
|
|
131
|
+ .ne(FeServicePlan::getPlanCode, PLAN_CURRENT)
|
|
|
132
|
+ .orderByAsc(FeServicePlan::getSortOrder)
|
|
|
133
|
+ .orderByAsc(FeServicePlan::getId));
|
|
|
134
|
+ List<FeServicePlan> effective = new ArrayList<>();
|
|
|
135
|
+ for (FeServicePlan plan : plans) {
|
|
|
136
|
+ if (plan.getEffectiveFrom() != null && today.isBefore(plan.getEffectiveFrom())) {
|
|
|
137
|
+ continue;
|
|
|
138
|
+ }
|
|
|
139
|
+ if (plan.getEffectiveTo() != null && today.isAfter(plan.getEffectiveTo())) {
|
|
|
140
|
+ continue;
|
|
|
141
|
+ }
|
|
|
142
|
+ effective.add(plan);
|
|
|
143
|
+ }
|
|
|
144
|
+ return effective;
|
|
|
145
|
+ }
|
|
|
146
|
+
|
|
|
147
|
+ private Map<Long, List<FeServicePlanFeeRule>> loadRulesByPlanIds(List<FeServicePlan> plans) {
|
|
|
148
|
+ List<Long> planIds = new ArrayList<>(plans.size());
|
|
|
149
|
+ for (FeServicePlan plan : plans) {
|
|
|
150
|
+ planIds.add(plan.getId());
|
|
|
151
|
+ }
|
|
|
152
|
+ List<FeServicePlanFeeRule> rules = feServicePlanFeeRuleMapper.selectList(
|
|
|
153
|
+ new LambdaQueryWrapper<FeServicePlanFeeRule>()
|
|
|
154
|
+ .in(FeServicePlanFeeRule::getPlanId, planIds)
|
|
|
155
|
+ .orderByAsc(FeServicePlanFeeRule::getSortOrder));
|
|
|
156
|
+ Map<Long, List<FeServicePlanFeeRule>> map = new HashMap<>();
|
|
|
157
|
+ for (FeServicePlanFeeRule rule : rules) {
|
|
|
158
|
+ map.computeIfAbsent(rule.getPlanId(), k -> new ArrayList<>()).add(rule);
|
|
|
159
|
+ }
|
|
|
160
|
+ return map;
|
|
|
161
|
+ }
|
|
|
162
|
+
|
|
|
163
|
+ private CalculatedScheme buildScheme(FeServicePlan plan,
|
|
|
164
|
+ List<FeServicePlanFeeRule> rules,
|
|
|
165
|
+ BigDecimal laborTotal,
|
|
|
166
|
+ int workerCount,
|
|
|
167
|
+ boolean recommended) {
|
|
53
|
168
|
BigDecimal companyPay = scaleMoney(laborTotal);
|
|
54
|
|
- BigDecimal serviceFee = scaleMoney(companyPay.multiply(SCHEME_A_SERVICE_FEE_RATE));
|
|
55
|
|
- BigDecimal totalCost = companyPay.add(serviceFee);
|
|
56
|
169
|
BigDecimal gross = companyPay;
|
|
|
170
|
+
|
|
|
171
|
+ FeServicePlanFeeRule serviceRule = findRule(rules, FEE_SERVICE);
|
|
|
172
|
+ BigDecimal serviceFee = null;
|
|
|
173
|
+ if (serviceRule != null) {
|
|
|
174
|
+ serviceFee = scaleMoney(calcFeeAmount(gross, serviceRule, plan));
|
|
|
175
|
+ } else if (plan.getServiceFeeRate() != null) {
|
|
|
176
|
+ serviceFee = scaleMoney(gross.multiply(plan.getServiceFeeRate()));
|
|
|
177
|
+ }
|
|
|
178
|
+
|
|
|
179
|
+ FeServicePlanFeeRule taxRule = findRule(rules, FEE_TAX);
|
|
57
|
180
|
BigDecimal tax = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
|
58
|
|
- BigDecimal net = gross;
|
|
|
181
|
+ if (taxRule != null) {
|
|
|
182
|
+ tax = scaleMoney(calcFeeAmount(gross, taxRule, plan));
|
|
|
183
|
+ }
|
|
|
184
|
+
|
|
|
185
|
+ BigDecimal net = scaleMoney(gross.subtract(tax));
|
|
|
186
|
+ BigDecimal totalCost = serviceFee != null ? companyPay.add(serviceFee) : companyPay;
|
|
|
187
|
+
|
|
59
|
188
|
return new CalculatedScheme(
|
|
60
|
|
- SCHEME_A,
|
|
61
|
|
- "灵活用工模式(推荐)",
|
|
62
|
|
- Arrays.asList("合规", "低税负"),
|
|
63
|
|
- true,
|
|
|
189
|
+ plan.getId(),
|
|
|
190
|
+ plan.getPlanCode(),
|
|
|
191
|
+ plan.getPlanName(),
|
|
|
192
|
+ parseTags(plan.getComplianceTags()),
|
|
|
193
|
+ recommended,
|
|
64
|
194
|
companyPay, serviceFee, totalCost,
|
|
65
|
195
|
gross, tax, net,
|
|
66
|
196
|
totalCost, net, workerCount);
|
|
67
|
197
|
}
|
|
68
|
198
|
|
|
69
|
|
- private CalculatedScheme buildSchemeB(BigDecimal laborTotal, int workerCount) {
|
|
70
|
|
- BigDecimal companyPay = scaleMoney(laborTotal);
|
|
71
|
|
- BigDecimal totalCost = companyPay;
|
|
72
|
|
- BigDecimal gross = companyPay;
|
|
73
|
|
- BigDecimal tax = scaleMoney(gross.multiply(SCHEME_B_TAX_RATE));
|
|
74
|
|
- BigDecimal net = gross.subtract(tax);
|
|
75
|
|
- return new CalculatedScheme(
|
|
76
|
|
- SCHEME_B,
|
|
77
|
|
- "劳务报酬模式",
|
|
78
|
|
- Arrays.asList("传统"),
|
|
79
|
|
- false,
|
|
80
|
|
- companyPay, null, totalCost,
|
|
81
|
|
- gross, tax, net,
|
|
82
|
|
- totalCost, net, workerCount);
|
|
|
199
|
+ private static FeServicePlanFeeRule findRule(List<FeServicePlanFeeRule> rules, String feeCode) {
|
|
|
200
|
+ for (FeServicePlanFeeRule rule : rules) {
|
|
|
201
|
+ if (feeCode.equalsIgnoreCase(rule.getFeeCode())) {
|
|
|
202
|
+ return rule;
|
|
|
203
|
+ }
|
|
|
204
|
+ }
|
|
|
205
|
+ return null;
|
|
|
206
|
+ }
|
|
|
207
|
+
|
|
|
208
|
+ private BigDecimal calcFeeAmount(BigDecimal base, FeServicePlanFeeRule rule, FeServicePlan plan) {
|
|
|
209
|
+ String calcType = rule.getCalcType() == null ? "" : rule.getCalcType().trim().toLowerCase();
|
|
|
210
|
+ if (CALC_RATE.equals(calcType)) {
|
|
|
211
|
+ BigDecimal rate = rule.getDefaultRate();
|
|
|
212
|
+ if (rate == null && plan.getServiceFeeRate() != null
|
|
|
213
|
+ && FEE_SERVICE.equalsIgnoreCase(rule.getFeeCode())) {
|
|
|
214
|
+ rate = plan.getServiceFeeRate();
|
|
|
215
|
+ }
|
|
|
216
|
+ if (rate == null) {
|
|
|
217
|
+ rate = parsePlainRate(rule.getCalcExpression());
|
|
|
218
|
+ }
|
|
|
219
|
+ if (rate == null) {
|
|
|
220
|
+ return BigDecimal.ZERO;
|
|
|
221
|
+ }
|
|
|
222
|
+ return base.multiply(rate);
|
|
|
223
|
+ }
|
|
|
224
|
+ if (CALC_FIXED.equals(calcType)) {
|
|
|
225
|
+ return rule.getDefaultRate() != null ? rule.getDefaultRate() : BigDecimal.ZERO;
|
|
|
226
|
+ }
|
|
|
227
|
+ if (CALC_FORMULA.equals(calcType)) {
|
|
|
228
|
+ return evalFormula(base, rule.getCalcExpression(), plan);
|
|
|
229
|
+ }
|
|
|
230
|
+ return BigDecimal.ZERO;
|
|
|
231
|
+ }
|
|
|
232
|
+
|
|
|
233
|
+ private static BigDecimal evalFormula(BigDecimal base, String expression, FeServicePlan plan) {
|
|
|
234
|
+ if (!StringUtils.hasText(expression)) {
|
|
|
235
|
+ return BigDecimal.ZERO;
|
|
|
236
|
+ }
|
|
|
237
|
+ String expr = expression.trim().replace(" ", "");
|
|
|
238
|
+ if ("gross".equalsIgnoreCase(expr) || "labor".equalsIgnoreCase(expr)
|
|
|
239
|
+ || "company_pay".equalsIgnoreCase(expr)) {
|
|
|
240
|
+ return base;
|
|
|
241
|
+ }
|
|
|
242
|
+ if (expr.toLowerCase().contains("service_fee_rate") && plan.getServiceFeeRate() != null) {
|
|
|
243
|
+ return base.multiply(plan.getServiceFeeRate());
|
|
|
244
|
+ }
|
|
|
245
|
+ Matcher mulDiv = FORMULA_MUL_DIV.matcher(expr);
|
|
|
246
|
+ if (mulDiv.matches()) {
|
|
|
247
|
+ return base.multiply(new BigDecimal(mulDiv.group(2)))
|
|
|
248
|
+ .divide(new BigDecimal(mulDiv.group(3)), 8, RoundingMode.HALF_UP);
|
|
|
249
|
+ }
|
|
|
250
|
+ Matcher mul = FORMULA_MUL.matcher(expr);
|
|
|
251
|
+ if (mul.matches()) {
|
|
|
252
|
+ return base.multiply(new BigDecimal(mul.group(2)));
|
|
|
253
|
+ }
|
|
|
254
|
+ BigDecimal plain = parsePlainRate(expression);
|
|
|
255
|
+ return plain != null ? base.multiply(plain) : BigDecimal.ZERO;
|
|
|
256
|
+ }
|
|
|
257
|
+
|
|
|
258
|
+ private static BigDecimal parsePlainRate(String text) {
|
|
|
259
|
+ if (!StringUtils.hasText(text)) {
|
|
|
260
|
+ return null;
|
|
|
261
|
+ }
|
|
|
262
|
+ try {
|
|
|
263
|
+ return new BigDecimal(text.trim());
|
|
|
264
|
+ } catch (NumberFormatException ex) {
|
|
|
265
|
+ return null;
|
|
|
266
|
+ }
|
|
|
267
|
+ }
|
|
|
268
|
+
|
|
|
269
|
+ private List<String> parseTags(String complianceTagsJson) {
|
|
|
270
|
+ if (!StringUtils.hasText(complianceTagsJson)) {
|
|
|
271
|
+ return Collections.emptyList();
|
|
|
272
|
+ }
|
|
|
273
|
+ try {
|
|
|
274
|
+ List<String> tags = objectMapper.readValue(complianceTagsJson, new TypeReference<List<String>>() {
|
|
|
275
|
+ });
|
|
|
276
|
+ return tags != null ? tags : Collections.emptyList();
|
|
|
277
|
+ } catch (Exception ex) {
|
|
|
278
|
+ return Collections.emptyList();
|
|
|
279
|
+ }
|
|
83
|
280
|
}
|
|
84
|
281
|
|
|
85
|
282
|
private McpDraftSchemeResponse toSchemeResponse(CalculatedScheme scheme) {
|
|
|
@@ -117,6 +314,7 @@ public class DraftCostCalculationEngine {
|
|
117
|
314
|
*/
|
|
118
|
315
|
public static final class CalculatedScheme {
|
|
119
|
316
|
|
|
|
317
|
+ private final Long planId;
|
|
120
|
318
|
private final String code;
|
|
121
|
319
|
private final String title;
|
|
122
|
320
|
private final List<String> tags;
|
|
|
@@ -131,7 +329,8 @@ public class DraftCostCalculationEngine {
|
|
131
|
329
|
private final BigDecimal workerTakeHomePerWorker;
|
|
132
|
330
|
private final int workerCount;
|
|
133
|
331
|
|
|
134
|
|
- public CalculatedScheme(String code,
|
|
|
332
|
+ public CalculatedScheme(Long planId,
|
|
|
333
|
+ String code,
|
|
135
|
334
|
String title,
|
|
136
|
335
|
List<String> tags,
|
|
137
|
336
|
boolean recommended,
|
|
|
@@ -144,6 +343,7 @@ public class DraftCostCalculationEngine {
|
|
144
|
343
|
BigDecimal employerTotalOutflow,
|
|
145
|
344
|
BigDecimal workerTakeHomePerWorker,
|
|
146
|
345
|
int workerCount) {
|
|
|
346
|
+ this.planId = planId;
|
|
147
|
347
|
this.code = code;
|
|
148
|
348
|
this.title = title;
|
|
149
|
349
|
this.tags = tags;
|
|
|
@@ -161,6 +361,10 @@ public class DraftCostCalculationEngine {
|
|
161
|
361
|
this.workerCount = workerCount;
|
|
162
|
362
|
}
|
|
163
|
363
|
|
|
|
364
|
+ public Long getPlanId() {
|
|
|
365
|
+ return planId;
|
|
|
366
|
+ }
|
|
|
367
|
+
|
|
164
|
368
|
public String getCode() {
|
|
165
|
369
|
return code;
|
|
166
|
370
|
}
|