wwh недель назад: 2
Родитель
Сommit
2926e32996

+ 3 - 1
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mcp/McpDraftController.java

@@ -77,7 +77,9 @@ public class McpDraftController {
77 77
 
78 78
     @PostMapping("/calculate")
79 79
     @Operation(summary = "成本测算",
80
-            description = "草稿 status=ready 后调用,返回 SCHEME_A/SCHEME_B 多套合规方案。",
80
+            description = "当前会话草稿就绪后调用。"
81
+                    + "按 fe_service_plan(status=active,排除 current)及 fe_service_plan_fee_rule 预置规则测算,"
82
+                    + "返回成本方案列表(如 SCHEME_A / SCHEME_B),并落库 fe_draft_cost_snapshot。",
81 83
             parameters = {
82 84
                     @Parameter(name = "X-Fe-Ai-Key", in = ParameterIn.HEADER)
83 85
             })

+ 3 - 1
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/DraftController.java

@@ -63,7 +63,9 @@ public class DraftController {
63 63
 
64 64
     @GetMapping("/{id}/cost-comparison")
65 65
     @Operation(summary = "成本测算(方案对比)",
66
-            description = "要求草稿 pending 且 missingFields 为空。返回 SCHEME_A / SCHEME_B 等多套合规方案,供「确认此方案」前展示。")
66
+            description = "要求草稿 pending 且 missingFields 为空。"
67
+                    + "按 fe_service_plan(status=active,排除 current)及 fe_service_plan_fee_rule 预置规则测算,"
68
+                    + "返回成本方案列表(如 SCHEME_A / SCHEME_B),并落库 fe_draft_cost_snapshot。")
67 69
     public R<McpDraftCalculateResponse> costComparison(@PathVariable("id") Long draftId) {
68 70
         LoginUser loginUser = requireEnterprise();
69 71
         return R.ok(draftService.calculateCostComparison(loginUser.getUserId(), draftId));

+ 194 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/entity/FeServicePlan.java

@@ -0,0 +1,194 @@
1
+package com.huimv.employment.dao.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.IdType;
4
+import com.baomidou.mybatisplus.annotation.TableId;
5
+import com.baomidou.mybatisplus.annotation.TableLogic;
6
+import com.baomidou.mybatisplus.annotation.TableName;
7
+
8
+import java.math.BigDecimal;
9
+import java.time.LocalDate;
10
+import java.time.LocalDateTime;
11
+
12
+/**
13
+ * 用工方案目录 {@code fe_service_plan} 实体。
14
+ */
15
+@TableName("fe_service_plan")
16
+public class FeServicePlan {
17
+
18
+    @TableId(type = IdType.AUTO)
19
+    private Long id;
20
+
21
+    private String planCode;
22
+
23
+    private String planName;
24
+
25
+    /** flexible / traditional / hybrid */
26
+    private String planType;
27
+
28
+    private String description;
29
+
30
+    private BigDecimal serviceFeeRate;
31
+
32
+    /** basic / standard / premium */
33
+    private String insuranceLevel;
34
+
35
+    private String taxMode;
36
+
37
+    /** JSON 数组字符串 */
38
+    private String applicableScenarios;
39
+
40
+    /** JSON 数组字符串 */
41
+    private String complianceTags;
42
+
43
+    private Integer sortOrder;
44
+
45
+    /** active / test_only / disabled */
46
+    private String status;
47
+
48
+    private LocalDate effectiveFrom;
49
+
50
+    private LocalDate effectiveTo;
51
+
52
+    private LocalDateTime createTime;
53
+
54
+    private LocalDateTime updateTime;
55
+
56
+    @TableLogic
57
+    private Integer delFlag;
58
+
59
+    public Long getId() {
60
+        return id;
61
+    }
62
+
63
+    public void setId(Long id) {
64
+        this.id = id;
65
+    }
66
+
67
+    public String getPlanCode() {
68
+        return planCode;
69
+    }
70
+
71
+    public void setPlanCode(String planCode) {
72
+        this.planCode = planCode;
73
+    }
74
+
75
+    public String getPlanName() {
76
+        return planName;
77
+    }
78
+
79
+    public void setPlanName(String planName) {
80
+        this.planName = planName;
81
+    }
82
+
83
+    public String getPlanType() {
84
+        return planType;
85
+    }
86
+
87
+    public void setPlanType(String planType) {
88
+        this.planType = planType;
89
+    }
90
+
91
+    public String getDescription() {
92
+        return description;
93
+    }
94
+
95
+    public void setDescription(String description) {
96
+        this.description = description;
97
+    }
98
+
99
+    public BigDecimal getServiceFeeRate() {
100
+        return serviceFeeRate;
101
+    }
102
+
103
+    public void setServiceFeeRate(BigDecimal serviceFeeRate) {
104
+        this.serviceFeeRate = serviceFeeRate;
105
+    }
106
+
107
+    public String getInsuranceLevel() {
108
+        return insuranceLevel;
109
+    }
110
+
111
+    public void setInsuranceLevel(String insuranceLevel) {
112
+        this.insuranceLevel = insuranceLevel;
113
+    }
114
+
115
+    public String getTaxMode() {
116
+        return taxMode;
117
+    }
118
+
119
+    public void setTaxMode(String taxMode) {
120
+        this.taxMode = taxMode;
121
+    }
122
+
123
+    public String getApplicableScenarios() {
124
+        return applicableScenarios;
125
+    }
126
+
127
+    public void setApplicableScenarios(String applicableScenarios) {
128
+        this.applicableScenarios = applicableScenarios;
129
+    }
130
+
131
+    public String getComplianceTags() {
132
+        return complianceTags;
133
+    }
134
+
135
+    public void setComplianceTags(String complianceTags) {
136
+        this.complianceTags = complianceTags;
137
+    }
138
+
139
+    public Integer getSortOrder() {
140
+        return sortOrder;
141
+    }
142
+
143
+    public void setSortOrder(Integer sortOrder) {
144
+        this.sortOrder = sortOrder;
145
+    }
146
+
147
+    public String getStatus() {
148
+        return status;
149
+    }
150
+
151
+    public void setStatus(String status) {
152
+        this.status = status;
153
+    }
154
+
155
+    public LocalDate getEffectiveFrom() {
156
+        return effectiveFrom;
157
+    }
158
+
159
+    public void setEffectiveFrom(LocalDate effectiveFrom) {
160
+        this.effectiveFrom = effectiveFrom;
161
+    }
162
+
163
+    public LocalDate getEffectiveTo() {
164
+        return effectiveTo;
165
+    }
166
+
167
+    public void setEffectiveTo(LocalDate effectiveTo) {
168
+        this.effectiveTo = effectiveTo;
169
+    }
170
+
171
+    public LocalDateTime getCreateTime() {
172
+        return createTime;
173
+    }
174
+
175
+    public void setCreateTime(LocalDateTime createTime) {
176
+        this.createTime = createTime;
177
+    }
178
+
179
+    public LocalDateTime getUpdateTime() {
180
+        return updateTime;
181
+    }
182
+
183
+    public void setUpdateTime(LocalDateTime updateTime) {
184
+        this.updateTime = updateTime;
185
+    }
186
+
187
+    public Integer getDelFlag() {
188
+        return delFlag;
189
+    }
190
+
191
+    public void setDelFlag(Integer delFlag) {
192
+        this.delFlag = delFlag;
193
+    }
194
+}

+ 118 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/entity/FeServicePlanFeeRule.java

@@ -0,0 +1,118 @@
1
+package com.huimv.employment.dao.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.IdType;
4
+import com.baomidou.mybatisplus.annotation.TableId;
5
+import com.baomidou.mybatisplus.annotation.TableName;
6
+
7
+import java.math.BigDecimal;
8
+import java.time.LocalDateTime;
9
+
10
+/**
11
+ * 方案费用项规则 {@code fe_service_plan_fee_rule} 实体。
12
+ */
13
+@TableName("fe_service_plan_fee_rule")
14
+public class FeServicePlanFeeRule {
15
+
16
+    @TableId(type = IdType.AUTO)
17
+    private Long id;
18
+
19
+    private Long planId;
20
+
21
+    /** labor_fee / service_fee / insurance / tax_withhold */
22
+    private String feeCode;
23
+
24
+    private String feeName;
25
+
26
+    /** fixed / rate / formula */
27
+    private String calcType;
28
+
29
+    private String calcExpression;
30
+
31
+    private BigDecimal defaultRate;
32
+
33
+    private Integer sortOrder;
34
+
35
+    private LocalDateTime createTime;
36
+
37
+    private LocalDateTime updateTime;
38
+
39
+    public Long getId() {
40
+        return id;
41
+    }
42
+
43
+    public void setId(Long id) {
44
+        this.id = id;
45
+    }
46
+
47
+    public Long getPlanId() {
48
+        return planId;
49
+    }
50
+
51
+    public void setPlanId(Long planId) {
52
+        this.planId = planId;
53
+    }
54
+
55
+    public String getFeeCode() {
56
+        return feeCode;
57
+    }
58
+
59
+    public void setFeeCode(String feeCode) {
60
+        this.feeCode = feeCode;
61
+    }
62
+
63
+    public String getFeeName() {
64
+        return feeName;
65
+    }
66
+
67
+    public void setFeeName(String feeName) {
68
+        this.feeName = feeName;
69
+    }
70
+
71
+    public String getCalcType() {
72
+        return calcType;
73
+    }
74
+
75
+    public void setCalcType(String calcType) {
76
+        this.calcType = calcType;
77
+    }
78
+
79
+    public String getCalcExpression() {
80
+        return calcExpression;
81
+    }
82
+
83
+    public void setCalcExpression(String calcExpression) {
84
+        this.calcExpression = calcExpression;
85
+    }
86
+
87
+    public BigDecimal getDefaultRate() {
88
+        return defaultRate;
89
+    }
90
+
91
+    public void setDefaultRate(BigDecimal defaultRate) {
92
+        this.defaultRate = defaultRate;
93
+    }
94
+
95
+    public Integer getSortOrder() {
96
+        return sortOrder;
97
+    }
98
+
99
+    public void setSortOrder(Integer sortOrder) {
100
+        this.sortOrder = sortOrder;
101
+    }
102
+
103
+    public LocalDateTime getCreateTime() {
104
+        return createTime;
105
+    }
106
+
107
+    public void setCreateTime(LocalDateTime createTime) {
108
+        this.createTime = createTime;
109
+    }
110
+
111
+    public LocalDateTime getUpdateTime() {
112
+        return updateTime;
113
+    }
114
+
115
+    public void setUpdateTime(LocalDateTime updateTime) {
116
+        this.updateTime = updateTime;
117
+    }
118
+}

+ 9 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/mapper/FeServicePlanFeeRuleMapper.java

@@ -0,0 +1,9 @@
1
+package com.huimv.employment.dao.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.huimv.employment.dao.entity.FeServicePlanFeeRule;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface FeServicePlanFeeRuleMapper extends BaseMapper<FeServicePlanFeeRule> {
9
+}

+ 9 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/mapper/FeServicePlanMapper.java

@@ -0,0 +1,9 @@
1
+package com.huimv.employment.dao.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.huimv.employment.dao.entity.FeServicePlan;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface FeServicePlanMapper extends BaseMapper<FeServicePlan> {
9
+}

+ 30 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/ConsoleChatCollectResult.java

@@ -0,0 +1,30 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+/**
4
+ * 会话落库场景:SSE 收集结果。
5
+ */
6
+public class ConsoleChatCollectResult {
7
+
8
+    private final String assistantText;
9
+    private final boolean completed;
10
+    /** 未转发的最后一条终端 SSE 原始行,可为 null */
11
+    private final String heldTerminalLine;
12
+
13
+    public ConsoleChatCollectResult(String assistantText, boolean completed, String heldTerminalLine) {
14
+        this.assistantText = assistantText == null ? "" : assistantText;
15
+        this.completed = completed;
16
+        this.heldTerminalLine = heldTerminalLine;
17
+    }
18
+
19
+    public String getAssistantText() {
20
+        return assistantText;
21
+    }
22
+
23
+    public boolean isCompleted() {
24
+        return completed;
25
+    }
26
+
27
+    public String getHeldTerminalLine() {
28
+        return heldTerminalLine;
29
+    }
30
+}

+ 240 - 36
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/DraftCostCalculationEngine.java

@@ -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
         }

+ 5 - 7
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/DraftService.java

@@ -57,9 +57,6 @@ public class DraftService {
57 57
             new HashSet<>(Arrays.asList("daily", "piece", "monthly")));
58 58
     private static final Set<String> ALLOWED_INSURANCE_LEVELS = Collections.unmodifiableSet(
59 59
             new HashSet<>(Arrays.asList("basic", "standard", "premium")));
60
-    private static final Set<String> ALLOWED_SCHEME_CODES = Collections.unmodifiableSet(
61
-            new HashSet<>(Arrays.asList(DraftCostCalculationEngine.SCHEME_A, DraftCostCalculationEngine.SCHEME_B)));
62
-
63 60
     private static final Map<String, String> FIELD_LABELS;
64 61
 
65 62
     static {
@@ -145,7 +142,7 @@ public class DraftService {
145 142
         }
146 143
         FeEmploymentDraft draft = requireOwnedReady(userId, draftId);
147 144
         String schemeCode = request.getSelectedSchemeCode().trim().toUpperCase();
148
-        if (!ALLOWED_SCHEME_CODES.contains(schemeCode)) {
145
+        if (!draftCostCalculationEngine.isAllowedSchemeCode(schemeCode)) {
149 146
             throw new BizException(ErrorCode.BAD_REQUEST, "不支持的方案编码:" + request.getSelectedSchemeCode());
150 147
         }
151 148
 
@@ -179,7 +176,7 @@ public class DraftService {
179 176
                 .set(FeEmploymentDraft::getStatus, STATUS_CONFIRMED)
180 177
                 .set(FeEmploymentDraft::getConfirmedAt, now)
181 178
                 .set(FeEmploymentDraft::getConfirmedBy, userId)
182
-                .set(FeEmploymentDraft::getSelectedPlanId, snapshot.getId())
179
+                .set(FeEmploymentDraft::getSelectedPlanId, snapshot.getPlanId())
183 180
                 .set(FeEmploymentDraft::getEstimatedTotal, snapshot.getEmployerTotalOutflow())
184 181
                 .set(FeEmploymentDraft::getEstimatedPerCapita, snapshot.getEmployerAvgCostPerWorker())
185 182
                 .set(FeEmploymentDraft::getUpdateTime, now));
@@ -215,6 +212,7 @@ public class DraftService {
215 212
         for (DraftCostCalculationEngine.CalculatedScheme scheme : schemes) {
216 213
             FeDraftCostSnapshot snapshot = new FeDraftCostSnapshot();
217 214
             snapshot.setDraftId(draft.getId());
215
+            snapshot.setPlanId(scheme.getPlanId());
218 216
             snapshot.setPlanCode(scheme.getCode());
219 217
             snapshot.setPlanName(scheme.getTitle());
220 218
             snapshot.setEmployerTotalOutflow(scheme.getEmployerTotalOutflow());
@@ -235,9 +233,9 @@ public class DraftService {
235 233
                 .filter(DraftCostCalculationEngine.CalculatedScheme::isRecommended)
236 234
                 .findFirst()
237 235
                 .orElse(schemes.get(0));
236
+        // 仅刷新人均预估;保留 estimated_total 作为劳务费基数(total_amount),避免二次测算被污染
238 237
         FeEmploymentDraft update = new FeEmploymentDraft();
239 238
         update.setId(draft.getId());
240
-        update.setEstimatedTotal(recommended.getEmployerTotalOutflow());
241 239
         update.setEstimatedPerCapita(recommended.getEmployerAvgCostPerWorker());
242 240
         update.setUpdateTime(now);
243 241
         feEmploymentDraftMapper.updateById(update);
@@ -259,7 +257,7 @@ public class DraftService {
259 257
         order.setEnterpriseId(draft.getEnterpriseId());
260 258
         order.setUserId(draft.getUserId() != null ? draft.getUserId() : userId);
261 259
         order.setDraftId(draft.getId());
262
-        order.setSelectedPlanId(snapshot.getId());
260
+        order.setSelectedPlanId(snapshot.getPlanId());
263 261
         order.setTitle(StringUtils.hasText(draft.getTitle()) ? draft.getTitle() : "用工订单");
264 262
         order.setWorkerCount(draft.getWorkerCount());
265 263
         order.setWorkDays(draft.getWorkDays());

+ 5 - 5
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/McpDraftService.java

@@ -74,9 +74,6 @@ public class McpDraftService {
74 74
     private static final Set<String> ALLOWED_SETTLEMENT_MODES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
75 75
             "daily", "piece", "monthly")));
76 76
 
77
-    private static final Set<String> ALLOWED_SCHEME_CODES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
78
-            DraftCostCalculationEngine.SCHEME_A, DraftCostCalculationEngine.SCHEME_B)));
79
-
80 77
     private final FeEmploymentDraftMapper feEmploymentDraftMapper;
81 78
     private final FeDraftFieldMapper feDraftFieldMapper;
82 79
     private final FeDraftCostSnapshotMapper feDraftCostSnapshotMapper;
@@ -176,7 +173,7 @@ public class McpDraftService {
176 173
         FeConversation conversation = conversationService.requireBySessionId(sessionId);
177 174
         FeEmploymentDraft draft = requireReadyDraft(conversation.getId());
178 175
         String schemeCode = request.getSelectedSchemeCode().trim().toUpperCase();
179
-        if (!ALLOWED_SCHEME_CODES.contains(schemeCode)) {
176
+        if (!draftCostCalculationEngine.isAllowedSchemeCode(schemeCode)) {
180 177
             throw new BizException(ErrorCode.BAD_REQUEST, "不支持的方案编码:" + request.getSelectedSchemeCode());
181 178
         }
182 179
         FeDraftCostSnapshot snapshot = findLatestSnapshot(draft.getId(), schemeCode);
@@ -199,6 +196,7 @@ public class McpDraftService {
199 196
         draftUpdate.setId(draft.getId());
200 197
         draftUpdate.setStatus(DB_STATUS_CONFIRMED);
201 198
         draftUpdate.setConfirmedAt(now);
199
+        draftUpdate.setSelectedPlanId(snapshot.getPlanId());
202 200
         draftUpdate.setEstimatedTotal(snapshot.getEmployerTotalOutflow());
203 201
         draftUpdate.setEstimatedPerCapita(snapshot.getEmployerAvgCostPerWorker());
204 202
         draftUpdate.setUpdateTime(now);
@@ -228,6 +226,7 @@ public class McpDraftService {
228 226
         for (DraftCostCalculationEngine.CalculatedScheme scheme : schemes) {
229 227
             FeDraftCostSnapshot snapshot = new FeDraftCostSnapshot();
230 228
             snapshot.setDraftId(draft.getId());
229
+            snapshot.setPlanId(scheme.getPlanId());
231 230
             snapshot.setPlanCode(scheme.getCode());
232 231
             snapshot.setPlanName(scheme.getTitle());
233 232
             snapshot.setEmployerTotalOutflow(scheme.getEmployerTotalOutflow());
@@ -248,9 +247,9 @@ public class McpDraftService {
248 247
                 .filter(DraftCostCalculationEngine.CalculatedScheme::isRecommended)
249 248
                 .findFirst()
250 249
                 .orElse(schemes.get(0));
250
+        // 仅刷新人均预估;保留 estimated_total 作为劳务费基数(total_amount),避免二次测算被污染
251 251
         FeEmploymentDraft update = new FeEmploymentDraft();
252 252
         update.setId(draft.getId());
253
-        update.setEstimatedTotal(recommended.getEmployerTotalOutflow());
254 253
         update.setEstimatedPerCapita(recommended.getEmployerAvgCostPerWorker());
255 254
         update.setUpdateTime(now);
256 255
         feEmploymentDraftMapper.updateById(update);
@@ -271,6 +270,7 @@ public class McpDraftService {
271 270
         order.setEnterpriseId(draft.getEnterpriseId());
272 271
         order.setUserId(draft.getUserId());
273 272
         order.setDraftId(draft.getId());
273
+        order.setSelectedPlanId(snapshot.getPlanId());
274 274
         order.setTitle(StringUtils.hasText(draft.getTitle()) ? draft.getTitle() : "用工订单");
275 275
         order.setWorkerCount(draft.getWorkerCount());
276 276
         order.setWorkDays(draft.getWorkDays());

+ 25 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/DraftConfirmRequest.java

@@ -0,0 +1,25 @@
1
+package com.huimv.employment.service.draft.dto;
2
+
3
+import com.fasterxml.jackson.annotation.JsonAlias;
4
+import com.fasterxml.jackson.annotation.JsonProperty;
5
+import io.swagger.v3.oas.annotations.media.Schema;
6
+
7
+import javax.validation.constraints.NotBlank;
8
+
9
+@Schema(description = "小程序确认用工方案请求")
10
+public class DraftConfirmRequest {
11
+
12
+    @NotBlank(message = "selected_scheme_code 不能为空")
13
+    @JsonProperty("selected_scheme_code")
14
+    @JsonAlias({"selected_plan_id", "selectedPlanId", "selectedSchemeCode"})
15
+    @Schema(description = "选定的方案编码", example = "SCHEME_A", requiredMode = Schema.RequiredMode.REQUIRED)
16
+    private String selectedSchemeCode;
17
+
18
+    public String getSelectedSchemeCode() {
19
+        return selectedSchemeCode;
20
+    }
21
+
22
+    public void setSelectedSchemeCode(String selectedSchemeCode) {
23
+        this.selectedSchemeCode = selectedSchemeCode;
24
+    }
25
+}

+ 67 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/DraftConfirmResponse.java

@@ -0,0 +1,67 @@
1
+package com.huimv.employment.service.draft.dto;
2
+
3
+import com.fasterxml.jackson.annotation.JsonProperty;
4
+import io.swagger.v3.oas.annotations.media.Schema;
5
+
6
+@Schema(description = "小程序确认用工方案响应")
7
+public class DraftConfirmResponse {
8
+
9
+    @Schema(description = "接口处理结果", example = "success")
10
+    private String status;
11
+
12
+    @JsonProperty("draft_status")
13
+    @Schema(description = "草稿状态,确认成功后为 confirmed", example = "confirmed")
14
+    private String draftStatus;
15
+
16
+    @JsonProperty("order_id")
17
+    @Schema(description = "生成的订单编号", example = "ord_123")
18
+    private String orderId;
19
+
20
+    @JsonProperty("draft_id")
21
+    @Schema(description = "草稿 ID")
22
+    private Long draftId;
23
+
24
+    @JsonProperty("selected_scheme_code")
25
+    @Schema(description = "确认的方案编码", example = "SCHEME_A")
26
+    private String selectedSchemeCode;
27
+
28
+    public String getStatus() {
29
+        return status;
30
+    }
31
+
32
+    public void setStatus(String status) {
33
+        this.status = status;
34
+    }
35
+
36
+    public String getDraftStatus() {
37
+        return draftStatus;
38
+    }
39
+
40
+    public void setDraftStatus(String draftStatus) {
41
+        this.draftStatus = draftStatus;
42
+    }
43
+
44
+    public String getOrderId() {
45
+        return orderId;
46
+    }
47
+
48
+    public void setOrderId(String orderId) {
49
+        this.orderId = orderId;
50
+    }
51
+
52
+    public Long getDraftId() {
53
+        return draftId;
54
+    }
55
+
56
+    public void setDraftId(Long draftId) {
57
+        this.draftId = draftId;
58
+    }
59
+
60
+    public String getSelectedSchemeCode() {
61
+        return selectedSchemeCode;
62
+    }
63
+
64
+    public void setSelectedSchemeCode(String selectedSchemeCode) {
65
+        this.selectedSchemeCode = selectedSchemeCode;
66
+    }
67
+}