Преглед изворни кода

增加与ai大模型对话

wwh пре 1 недеља
родитељ
комит
29887853c0

+ 2 - 1
doc/灵活用工-mysql.sql

@@ -457,7 +457,7 @@ CREATE TABLE `fe_worker_registration` (
457 457
   `id_card_no_enc` TEXT COMMENT '登记身份证密文。可空;高度敏感。',
458 458
   `bank_account_enc` TEXT COMMENT '登记银行卡密文。可空;高度敏感。',
459 459
   `confirmed_work_type` VARCHAR(100) COMMENT '临时工确认的工种或具体任务描述。提交时勾选或填写。可空。',
460
-  `reg_status` VARCHAR(20)     NOT NULL DEFAULT 'pending' COMMENT '登记流程状态。枚举:pending=待登记(占位);registering=填写中;completed=已完成;verify_failed=实名核验失败;under_review=待企业审核。非空,默认 pending。',
460
+  `reg_status` VARCHAR(20)     NOT NULL DEFAULT 'pending' COMMENT '登记流程状态。枚举:pending=待登记;registering=填写中;under_review=待企业审核;contract_signing=待签约(审核通过);completed=已完成;verify_failed=实名核验失败;rejected=企业驳回。非空,默认 pending。',
461 461
   `fail_reason` TEXT COMMENT '失败或企业驳回原因。可空。',
462 462
   `submitted_at` datetime COMMENT '临时工点击提交时间。可空。',
463 463
   `reviewed_at` datetime COMMENT '企业审核完成时间。可空。',
@@ -510,6 +510,7 @@ CREATE TABLE `fe_contract` (
510 510
   `del_flag` tinyint        NOT NULL DEFAULT 0 COMMENT '逻辑删除标记。枚举:0=正常;1=已删除。非空,默认 0。',
511 511
   PRIMARY KEY (`id`),
512 512
   UNIQUE KEY `uk_fe_contract_no` (`contract_no`),
513
+  UNIQUE KEY `uk_fe_contract_registration` (`registration_id`),
513 514
   KEY `idx_fe_contract_order` (`order_id`, `sign_status`)
514 515
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电子劳动合同表,合规流程「合同签署」节点核心数据。一人一登记一条合同;MVP 阶段 sign_provider 可用 mock,对接电子签平台后替换。关联 fe_employment_order、fe_worker_registration、fe_worker。';
515 516
 

+ 3 - 2
doc/灵活用工.sql

@@ -500,7 +500,7 @@ CREATE TABLE fe_worker_registration (
500 500
     id_card_no_enc      TEXT,
501 501
     bank_account_enc    TEXT,
502 502
     confirmed_work_type VARCHAR(100),                               -- 工种或任务确认
503
-    reg_status          VARCHAR(20)     NOT NULL DEFAULT 'pending', -- pending/registering/completed/verify_failed/under_review
503
+    reg_status          VARCHAR(20)     NOT NULL DEFAULT 'pending', -- pending/registering/under_review/contract_signing/completed/verify_failed/rejected
504 504
     fail_reason         TEXT,
505 505
     submitted_at        TIMESTAMPTZ,
506 506
     reviewed_at         TIMESTAMPTZ,
@@ -557,6 +557,7 @@ CREATE TABLE fe_contract (
557 557
     del_flag        SMALLINT        NOT NULL DEFAULT 0
558 558
 );
559 559
 CREATE UNIQUE INDEX uk_fe_contract_no ON fe_contract (contract_no);
560
+CREATE UNIQUE INDEX uk_fe_contract_registration ON fe_contract (registration_id);
560 561
 CREATE INDEX idx_fe_contract_order ON fe_contract (order_id, sign_status);
561 562
 
562 563
 
@@ -1138,7 +1139,7 @@ COMMENT ON COLUMN fe_worker_registration.mobile_mask IS '登记手机号脱敏
1138 1139
 COMMENT ON COLUMN fe_worker_registration.id_card_no_enc IS '登记身份证密文。可空;高度敏感。';
1139 1140
 COMMENT ON COLUMN fe_worker_registration.bank_account_enc IS '登记银行卡密文。可空;高度敏感。';
1140 1141
 COMMENT ON COLUMN fe_worker_registration.confirmed_work_type IS '临时工确认的工种或具体任务描述。提交时勾选或填写。可空。';
1141
-COMMENT ON COLUMN fe_worker_registration.reg_status IS '登记流程状态。枚举:pending=待登记(占位);registering=填写中;completed=已完成;verify_failed=实名核验失败;under_review=待企业审核。非空,默认 pending。';
1142
+COMMENT ON COLUMN fe_worker_registration.reg_status IS '登记流程状态。枚举:pending=待登记;registering=填写中;under_review=待企业审核;contract_signing=待签约(审核通过);completed=已完成;verify_failed=实名核验失败;rejected=企业驳回。非空,默认 pending。';
1142 1143
 COMMENT ON COLUMN fe_worker_registration.fail_reason IS '失败或企业驳回原因。可空。';
1143 1144
 COMMENT ON COLUMN fe_worker_registration.submitted_at IS '临时工点击提交时间。可空。';
1144 1145
 COMMENT ON COLUMN fe_worker_registration.reviewed_at IS '企业审核完成时间。可空。';

+ 56 - 0
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/ContractController.java

@@ -0,0 +1,56 @@
1
+package com.huimv.employment.controller.mp;
2
+
3
+import com.huimv.employment.common.exception.BizException;
4
+import com.huimv.employment.common.exception.ErrorCode;
5
+import com.huimv.employment.common.web.R;
6
+import com.huimv.employment.security.LoginUser;
7
+import com.huimv.employment.security.LoginUserHolder;
8
+import com.huimv.employment.service.contract.ContractService;
9
+import com.huimv.employment.service.contract.dto.ContractSummaryResponse;
10
+import io.swagger.v3.oas.annotations.Operation;
11
+import io.swagger.v3.oas.annotations.Parameter;
12
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
13
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
14
+import io.swagger.v3.oas.annotations.tags.Tag;
15
+import org.springframework.web.bind.annotation.PathVariable;
16
+import org.springframework.web.bind.annotation.PostMapping;
17
+import org.springframework.web.bind.annotation.RequestMapping;
18
+import org.springframework.web.bind.annotation.RestController;
19
+
20
+/**
21
+ * 电子合同(企业端):重新生成待签合同。
22
+ */
23
+@RestController
24
+@RequestMapping("/api/v1/mp/contracts")
25
+@Tag(name = "电子合同", description = "审核通过后自动生成待签合同;失败可重试")
26
+@SecurityRequirement(name = "Authorization")
27
+public class ContractController {
28
+
29
+    private static final String USER_TYPE_ENTERPRISE = "enterprise";
30
+
31
+    private final ContractService contractService;
32
+
33
+    public ContractController(ContractService contractService) {
34
+        this.contractService = contractService;
35
+    }
36
+
37
+    @PostMapping("/by-registration/{registrationId}/regenerate")
38
+    @Operation(summary = "重新生成待签合同",
39
+            description = "电子签失败或签署链接缺失时调用。同一 registration 幂等;已签署不可重生成。",
40
+            parameters = {
41
+                    @Parameter(name = "registrationId", in = ParameterIn.PATH, required = true,
42
+                            description = "fe_worker_registration.id")
43
+            })
44
+    public R<ContractSummaryResponse> regenerate(@PathVariable("registrationId") Long registrationId) {
45
+        LoginUser loginUser = requireEnterprise();
46
+        return R.ok(contractService.regeneratePendingContract(loginUser.getUserId(), registrationId));
47
+    }
48
+
49
+    private static LoginUser requireEnterprise() {
50
+        LoginUser loginUser = LoginUserHolder.require();
51
+        if (!USER_TYPE_ENTERPRISE.equals(loginUser.getUserType())) {
52
+            throw new BizException(ErrorCode.ENTERPRISE_ONLY);
53
+        }
54
+        return loginUser;
55
+    }
56
+}

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

@@ -51,9 +51,9 @@ public class WorkerRegistrationController {
51 51
 
52 52
     @PostMapping("/{id}/approve")
53 53
     @Operation(summary = "企业审核通过用工登记",
54
-            description = "企业主审核 under_review 登记:该人 reg_status→contract_signing(进入签合同);"
55
-                    + "写 reviewed_at/reviewed_by;批次 completed_count+1;"
56
-                    + "订单若尚未签约则推进到 contract_signing(不要求整批审完)。",
54
+            description = "企业主审核 under_review 登记:该人 reg_status→contract_signing;"
55
+                    + "自动生成待签电子合同(fe_contract,幂等);"
56
+                    + "订单推进到 contract_signing。合同生成失败不影响审核结果,可调用重新生成接口。",
57 57
             parameters = {
58 58
                     @Parameter(name = "id", in = ParameterIn.PATH, required = true,
59 59
                             description = "fe_worker_registration.id(进度人员列表 registration_id)")

+ 190 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/entity/FeContract.java

@@ -0,0 +1,190 @@
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.time.LocalDateTime;
9
+
10
+/**
11
+ * 电子合同 {@code fe_contract} 实体。一人一登记一条主合同。
12
+ */
13
+@TableName("fe_contract")
14
+public class FeContract {
15
+
16
+    @TableId(type = IdType.AUTO)
17
+    private Long id;
18
+
19
+    private String contractNo;
20
+
21
+    private Long orderId;
22
+
23
+    private Long registrationId;
24
+
25
+    private Long workerId;
26
+
27
+    private Long enterpriseId;
28
+
29
+    /** labor / labor_flexible / labor_dispatch 等模板编码 */
30
+    private String contractType;
31
+
32
+    private String contractTitle;
33
+
34
+    /** pending / signing / signed / rejected / expired / voided */
35
+    private String signStatus;
36
+
37
+    /** mock / esign_partner */
38
+    private String signProvider;
39
+
40
+    private String externalContractId;
41
+
42
+    private String signUrl;
43
+
44
+    private LocalDateTime signedAt;
45
+
46
+    private String fileUrl;
47
+
48
+    private LocalDateTime createTime;
49
+
50
+    private LocalDateTime updateTime;
51
+
52
+    @TableLogic
53
+    private Integer delFlag;
54
+
55
+    public Long getId() {
56
+        return id;
57
+    }
58
+
59
+    public void setId(Long id) {
60
+        this.id = id;
61
+    }
62
+
63
+    public String getContractNo() {
64
+        return contractNo;
65
+    }
66
+
67
+    public void setContractNo(String contractNo) {
68
+        this.contractNo = contractNo;
69
+    }
70
+
71
+    public Long getOrderId() {
72
+        return orderId;
73
+    }
74
+
75
+    public void setOrderId(Long orderId) {
76
+        this.orderId = orderId;
77
+    }
78
+
79
+    public Long getRegistrationId() {
80
+        return registrationId;
81
+    }
82
+
83
+    public void setRegistrationId(Long registrationId) {
84
+        this.registrationId = registrationId;
85
+    }
86
+
87
+    public Long getWorkerId() {
88
+        return workerId;
89
+    }
90
+
91
+    public void setWorkerId(Long workerId) {
92
+        this.workerId = workerId;
93
+    }
94
+
95
+    public Long getEnterpriseId() {
96
+        return enterpriseId;
97
+    }
98
+
99
+    public void setEnterpriseId(Long enterpriseId) {
100
+        this.enterpriseId = enterpriseId;
101
+    }
102
+
103
+    public String getContractType() {
104
+        return contractType;
105
+    }
106
+
107
+    public void setContractType(String contractType) {
108
+        this.contractType = contractType;
109
+    }
110
+
111
+    public String getContractTitle() {
112
+        return contractTitle;
113
+    }
114
+
115
+    public void setContractTitle(String contractTitle) {
116
+        this.contractTitle = contractTitle;
117
+    }
118
+
119
+    public String getSignStatus() {
120
+        return signStatus;
121
+    }
122
+
123
+    public void setSignStatus(String signStatus) {
124
+        this.signStatus = signStatus;
125
+    }
126
+
127
+    public String getSignProvider() {
128
+        return signProvider;
129
+    }
130
+
131
+    public void setSignProvider(String signProvider) {
132
+        this.signProvider = signProvider;
133
+    }
134
+
135
+    public String getExternalContractId() {
136
+        return externalContractId;
137
+    }
138
+
139
+    public void setExternalContractId(String externalContractId) {
140
+        this.externalContractId = externalContractId;
141
+    }
142
+
143
+    public String getSignUrl() {
144
+        return signUrl;
145
+    }
146
+
147
+    public void setSignUrl(String signUrl) {
148
+        this.signUrl = signUrl;
149
+    }
150
+
151
+    public LocalDateTime getSignedAt() {
152
+        return signedAt;
153
+    }
154
+
155
+    public void setSignedAt(LocalDateTime signedAt) {
156
+        this.signedAt = signedAt;
157
+    }
158
+
159
+    public String getFileUrl() {
160
+        return fileUrl;
161
+    }
162
+
163
+    public void setFileUrl(String fileUrl) {
164
+        this.fileUrl = fileUrl;
165
+    }
166
+
167
+    public LocalDateTime getCreateTime() {
168
+        return createTime;
169
+    }
170
+
171
+    public void setCreateTime(LocalDateTime createTime) {
172
+        this.createTime = createTime;
173
+    }
174
+
175
+    public LocalDateTime getUpdateTime() {
176
+        return updateTime;
177
+    }
178
+
179
+    public void setUpdateTime(LocalDateTime updateTime) {
180
+        this.updateTime = updateTime;
181
+    }
182
+
183
+    public Integer getDelFlag() {
184
+        return delFlag;
185
+    }
186
+
187
+    public void setDelFlag(Integer delFlag) {
188
+        this.delFlag = delFlag;
189
+    }
190
+}

+ 9 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/mapper/FeContractMapper.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.FeContract;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface FeContractMapper extends BaseMapper<FeContract> {
9
+}

+ 285 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/contract/ContractService.java

@@ -0,0 +1,285 @@
1
+package com.huimv.employment.service.contract;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.huimv.employment.common.exception.BizException;
5
+import com.huimv.employment.common.exception.ErrorCode;
6
+import com.huimv.employment.dao.entity.FeContract;
7
+import com.huimv.employment.dao.entity.FeEmploymentOrder;
8
+import com.huimv.employment.dao.entity.FeEnterprise;
9
+import com.huimv.employment.dao.entity.FeServicePlan;
10
+import com.huimv.employment.dao.entity.FeWorkerRegistration;
11
+import com.huimv.employment.dao.mapper.FeContractMapper;
12
+import com.huimv.employment.dao.mapper.FeEmploymentOrderMapper;
13
+import com.huimv.employment.dao.mapper.FeServicePlanMapper;
14
+import com.huimv.employment.dao.mapper.FeWorkerRegistrationMapper;
15
+import com.huimv.employment.service.contract.dto.ContractSummaryResponse;
16
+import com.huimv.employment.service.enterprise.EnterpriseService;
17
+import org.slf4j.Logger;
18
+import org.slf4j.LoggerFactory;
19
+import org.springframework.dao.DuplicateKeyException;
20
+import org.springframework.stereotype.Service;
21
+import org.springframework.transaction.annotation.Transactional;
22
+import org.springframework.util.StringUtils;
23
+
24
+import java.time.LocalDateTime;
25
+import java.time.format.DateTimeFormatter;
26
+import java.util.concurrent.ThreadLocalRandom;
27
+
28
+/**
29
+ * 电子合同:审核通过后生成待签合同;支持幂等与重新生成。
30
+ */
31
+@Service
32
+public class ContractService {
33
+
34
+    private static final Logger log = LoggerFactory.getLogger(ContractService.class);
35
+
36
+    private static final String SIGN_PENDING = "pending";
37
+    private static final String SIGN_SIGNING = "signing";
38
+    private static final String SIGN_SIGNED = "signed";
39
+    private static final String SIGN_VOIDED = "voided";
40
+    private static final String PROVIDER_MOCK = "mock";
41
+    private static final String REG_CONTRACT_SIGNING = "contract_signing";
42
+    private static final String REG_COMPLETED = "completed";
43
+
44
+    private static final DateTimeFormatter CONTRACT_NO_DAY = DateTimeFormatter.ofPattern("yyyyMMdd");
45
+
46
+    private final FeContractMapper feContractMapper;
47
+    private final FeEmploymentOrderMapper feEmploymentOrderMapper;
48
+    private final FeWorkerRegistrationMapper feWorkerRegistrationMapper;
49
+    private final FeServicePlanMapper feServicePlanMapper;
50
+    private final EnterpriseService enterpriseService;
51
+    private final ESignClient eSignClient;
52
+
53
+    public ContractService(FeContractMapper feContractMapper,
54
+                           FeEmploymentOrderMapper feEmploymentOrderMapper,
55
+                           FeWorkerRegistrationMapper feWorkerRegistrationMapper,
56
+                           FeServicePlanMapper feServicePlanMapper,
57
+                           EnterpriseService enterpriseService,
58
+                           ESignClient eSignClient) {
59
+        this.feContractMapper = feContractMapper;
60
+        this.feEmploymentOrderMapper = feEmploymentOrderMapper;
61
+        this.feWorkerRegistrationMapper = feWorkerRegistrationMapper;
62
+        this.feServicePlanMapper = feServicePlanMapper;
63
+        this.enterpriseService = enterpriseService;
64
+        this.eSignClient = eSignClient;
65
+    }
66
+
67
+    /**
68
+     * 审核通过后创建或返回已有待签合同(同一 registration_id 幂等)。
69
+     * <p>电子签调用失败时仍保留 pending 合同记录,便于后续 {@link #regeneratePendingContract}。</p>
70
+     */
71
+    @Transactional(rollbackFor = Exception.class)
72
+    public ContractSummaryResponse createOrGetPendingForRegistration(FeWorkerRegistration reg, FeEmploymentOrder order) {
73
+        if (reg == null || reg.getId() == null) {
74
+            throw new BizException(ErrorCode.BAD_REQUEST, "登记记录无效");
75
+        }
76
+        if (order == null || order.getId() == null) {
77
+            throw new BizException(ErrorCode.BAD_REQUEST, "订单无效");
78
+        }
79
+
80
+        FeContract existing = findActiveByRegistrationId(reg.getId());
81
+        if (existing != null) {
82
+            if (needsESignRetry(existing)) {
83
+                tryFillESign(existing, reg);
84
+                existing = feContractMapper.selectById(existing.getId());
85
+            }
86
+            return toSummary(existing, false, "已存在待签合同");
87
+        }
88
+
89
+        LocalDateTime now = LocalDateTime.now();
90
+        String contractType = resolveContractType(order);
91
+        String title = buildContractTitle(contractType, reg.getRealName(), order.getTitle());
92
+        String contractNo = nextContractNo();
93
+
94
+        FeContract contract = new FeContract();
95
+        contract.setContractNo(contractNo);
96
+        contract.setOrderId(order.getId());
97
+        contract.setRegistrationId(reg.getId());
98
+        contract.setWorkerId(reg.getWorkerId());
99
+        contract.setEnterpriseId(order.getEnterpriseId() != null ? order.getEnterpriseId() : reg.getEnterpriseId());
100
+        contract.setContractType(contractType);
101
+        contract.setContractTitle(title);
102
+        contract.setSignStatus(SIGN_PENDING);
103
+        contract.setSignProvider(PROVIDER_MOCK);
104
+        contract.setCreateTime(now);
105
+        contract.setUpdateTime(now);
106
+        contract.setDelFlag(0);
107
+
108
+        try {
109
+            feContractMapper.insert(contract);
110
+        } catch (DuplicateKeyException ex) {
111
+            FeContract raced = findActiveByRegistrationId(reg.getId());
112
+            if (raced != null) {
113
+                return toSummary(raced, false, "已存在待签合同");
114
+            }
115
+            throw new BizException(ErrorCode.BAD_REQUEST, "合同编号冲突,请重试");
116
+        }
117
+
118
+        try {
119
+            tryFillESign(contract, reg);
120
+        } catch (Exception ex) {
121
+            log.error("登记 registrationId={} 电子签创建失败,合同已落库待重试: {}", reg.getId(), ex.getMessage());
122
+            FeContract saved = feContractMapper.selectById(contract.getId());
123
+            ContractSummaryResponse response = toSummary(saved, true, "待签合同已创建,电子签通道暂不可用,请稍后重新生成");
124
+            return response;
125
+        }
126
+
127
+        FeContract saved = feContractMapper.selectById(contract.getId());
128
+        return toSummary(saved, true, "待签合同已生成");
129
+    }
130
+
131
+    /**
132
+     * 重新生成签署链接(电子签失败或链接失效时)。
133
+     * <p>仅允许 pending/signing;已签署不可重生成。无合同记录时按登记补建。</p>
134
+     */
135
+    @Transactional(rollbackFor = Exception.class)
136
+    public ContractSummaryResponse regeneratePendingContract(Long userId, Long registrationId) {
137
+        enterpriseService.requireEnterpriseUser(userId);
138
+        FeEnterprise enterprise = enterpriseService.findEnterpriseByUserId(userId);
139
+        if (enterprise == null) {
140
+            throw new BizException(ErrorCode.NOT_FOUND, "企业信息不存在");
141
+        }
142
+        if (registrationId == null) {
143
+            throw new BizException(ErrorCode.BAD_REQUEST, "registration_id 不能为空");
144
+        }
145
+
146
+        FeWorkerRegistration reg = feWorkerRegistrationMapper.selectById(registrationId);
147
+        if (reg == null) {
148
+            throw new BizException(ErrorCode.NOT_FOUND, "登记记录不存在");
149
+        }
150
+        if (reg.getEnterpriseId() == null || !reg.getEnterpriseId().equals(enterprise.getId())) {
151
+            throw new BizException(ErrorCode.FORBIDDEN, "无权操作该登记合同");
152
+        }
153
+        if (!REG_CONTRACT_SIGNING.equals(reg.getRegStatus()) && !REG_COMPLETED.equals(reg.getRegStatus())) {
154
+            throw new BizException(ErrorCode.BAD_REQUEST, "仅审核通过待签约的登记可重新生成合同");
155
+        }
156
+
157
+        FeEmploymentOrder order = feEmploymentOrderMapper.selectById(reg.getOrderId());
158
+        if (order == null) {
159
+            throw new BizException(ErrorCode.NOT_FOUND, "用工订单不存在");
160
+        }
161
+
162
+        FeContract contract = findActiveByRegistrationId(registrationId);
163
+        if (contract == null) {
164
+            return createOrGetPendingForRegistration(reg, order);
165
+        }
166
+        if (SIGN_SIGNED.equals(contract.getSignStatus())) {
167
+            throw new BizException(ErrorCode.BAD_REQUEST, "合同已签署,无法重新生成");
168
+        }
169
+        if (SIGN_VOIDED.equals(contract.getSignStatus())) {
170
+            throw new BizException(ErrorCode.BAD_REQUEST, "合同已作废,无法重新生成");
171
+        }
172
+        if (!SIGN_PENDING.equals(contract.getSignStatus()) && !SIGN_SIGNING.equals(contract.getSignStatus())) {
173
+            throw new BizException(ErrorCode.BAD_REQUEST, "当前签署状态不可重新生成:" + contract.getSignStatus());
174
+        }
175
+
176
+        tryFillESign(contract, reg);
177
+        FeContract saved = feContractMapper.selectById(contract.getId());
178
+        return toSummary(saved, false, "签署链接已重新生成");
179
+    }
180
+
181
+    public FeContract findActiveByRegistrationId(Long registrationId) {
182
+        if (registrationId == null) {
183
+            return null;
184
+        }
185
+        return feContractMapper.selectOne(new LambdaQueryWrapper<FeContract>()
186
+                .eq(FeContract::getRegistrationId, registrationId)
187
+                .ne(FeContract::getSignStatus, SIGN_VOIDED)
188
+                .orderByDesc(FeContract::getId)
189
+                .last("LIMIT 1"));
190
+    }
191
+
192
+    private void tryFillESign(FeContract contract, FeWorkerRegistration reg) {
193
+        ESignClient.ESignCreateResult result = eSignClient.createPendingContract(
194
+                contract.getContractNo(),
195
+                contract.getContractType(),
196
+                contract.getContractTitle(),
197
+                reg != null ? reg.getRealName() : null);
198
+        FeContract update = new FeContract();
199
+        update.setId(contract.getId());
200
+        update.setExternalContractId(result.getExternalContractId());
201
+        update.setSignUrl(result.getSignUrl());
202
+        update.setSignProvider(PROVIDER_MOCK);
203
+        update.setSignStatus(SIGN_PENDING);
204
+        update.setUpdateTime(LocalDateTime.now());
205
+        feContractMapper.updateById(update);
206
+        contract.setExternalContractId(result.getExternalContractId());
207
+        contract.setSignUrl(result.getSignUrl());
208
+    }
209
+
210
+    private static boolean needsESignRetry(FeContract contract) {
211
+        if (contract == null) {
212
+            return false;
213
+        }
214
+        if (SIGN_SIGNED.equals(contract.getSignStatus()) || SIGN_VOIDED.equals(contract.getSignStatus())) {
215
+            return false;
216
+        }
217
+        return !StringUtils.hasText(contract.getSignUrl()) || !StringUtils.hasText(contract.getExternalContractId());
218
+    }
219
+
220
+    /**
221
+     * 按订单所选方案类型选择合同模板编码。
222
+     */
223
+    private String resolveContractType(FeEmploymentOrder order) {
224
+        if (order.getSelectedPlanId() == null) {
225
+            return "labor";
226
+        }
227
+        FeServicePlan plan = feServicePlanMapper.selectById(order.getSelectedPlanId());
228
+        if (plan == null || !StringUtils.hasText(plan.getPlanType())) {
229
+            return "labor";
230
+        }
231
+        String planType = plan.getPlanType().trim().toLowerCase();
232
+        if ("flexible".equals(planType)) {
233
+            return "labor_flexible";
234
+        }
235
+        if ("traditional".equals(planType)) {
236
+            return "labor_dispatch";
237
+        }
238
+        if ("hybrid".equals(planType)) {
239
+            return "labor_hybrid";
240
+        }
241
+        return "labor";
242
+    }
243
+
244
+    private static String buildContractTitle(String contractType, String workerName, String orderTitle) {
245
+        String typeLabel;
246
+        if ("labor_flexible".equals(contractType)) {
247
+            typeLabel = "灵活用工协议";
248
+        } else if ("labor_dispatch".equals(contractType)) {
249
+            typeLabel = "劳务派遣协议";
250
+        } else if ("labor_hybrid".equals(contractType)) {
251
+            typeLabel = "混合用工协议";
252
+        } else {
253
+            typeLabel = "劳动合同";
254
+        }
255
+        String name = StringUtils.hasText(workerName) ? workerName.trim() : "临时工";
256
+        if (StringUtils.hasText(orderTitle)) {
257
+            return typeLabel + "-" + name + "-" + orderTitle.trim();
258
+        }
259
+        return typeLabel + "-" + name;
260
+    }
261
+
262
+    private String nextContractNo() {
263
+        String day = LocalDateTime.now().format(CONTRACT_NO_DAY);
264
+        int rand = ThreadLocalRandom.current().nextInt(1000, 10000);
265
+        return "CT" + day + rand;
266
+    }
267
+
268
+    private static ContractSummaryResponse toSummary(FeContract contract, boolean created, String message) {
269
+        ContractSummaryResponse response = new ContractSummaryResponse();
270
+        if (contract != null) {
271
+            response.setContractId(contract.getId());
272
+            response.setContractNo(contract.getContractNo());
273
+            response.setRegistrationId(contract.getRegistrationId());
274
+            response.setContractType(contract.getContractType());
275
+            response.setContractTitle(contract.getContractTitle());
276
+            response.setSignStatus(contract.getSignStatus());
277
+            response.setSignProvider(contract.getSignProvider());
278
+            response.setSignUrl(contract.getSignUrl());
279
+            response.setExternalContractId(contract.getExternalContractId());
280
+        }
281
+        response.setCreated(created);
282
+        response.setMessage(message);
283
+        return response;
284
+    }
285
+}

+ 36 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/contract/ESignClient.java

@@ -0,0 +1,36 @@
1
+package com.huimv.employment.service.contract;
2
+
3
+/**
4
+ * 电子签平台客户端。生产替换为厂商实现;开发态用 Mock。
5
+ */
6
+public interface ESignClient {
7
+
8
+    /**
9
+     * 创建待签合同。
10
+     *
11
+     * @param contractNo   业务合同号
12
+     * @param contractType 模板/类型编码
13
+     * @param title        合同标题
14
+     * @param workerName   工人姓名
15
+     * @return 平台返回的外部合同 ID 与签署链接
16
+     */
17
+    ESignCreateResult createPendingContract(String contractNo, String contractType, String title, String workerName);
18
+
19
+    class ESignCreateResult {
20
+        private final String externalContractId;
21
+        private final String signUrl;
22
+
23
+        public ESignCreateResult(String externalContractId, String signUrl) {
24
+            this.externalContractId = externalContractId;
25
+            this.signUrl = signUrl;
26
+        }
27
+
28
+        public String getExternalContractId() {
29
+            return externalContractId;
30
+        }
31
+
32
+        public String getSignUrl() {
33
+            return signUrl;
34
+        }
35
+    }
36
+}

+ 20 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/contract/MockESignClient.java

@@ -0,0 +1,20 @@
1
+package com.huimv.employment.service.contract;
2
+
3
+import org.springframework.stereotype.Component;
4
+import org.springframework.util.StringUtils;
5
+
6
+/**
7
+ * 开发态电子签 Stub:不调用外部平台,生成可联调的 mock 签署链接。
8
+ */
9
+@Component
10
+public class MockESignClient implements ESignClient {
11
+
12
+    @Override
13
+    public ESignCreateResult createPendingContract(String contractNo, String contractType, String title, String workerName) {
14
+        String no = StringUtils.hasText(contractNo) ? contractNo.trim() : "UNKNOWN";
15
+        String externalId = "MOCK-" + no;
16
+        String signUrl = "https://mock-esign.local/sign?contractNo=" + no
17
+                + "&type=" + (StringUtils.hasText(contractType) ? contractType : "labor");
18
+        return new ESignCreateResult(externalId, signUrl);
19
+    }
20
+}

+ 129 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/contract/dto/ContractSummaryResponse.java

@@ -0,0 +1,129 @@
1
+package com.huimv.employment.service.contract.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 ContractSummaryResponse {
8
+
9
+    @JsonProperty("contract_id")
10
+    private Long contractId;
11
+
12
+    @JsonProperty("contract_no")
13
+    private String contractNo;
14
+
15
+    @JsonProperty("registration_id")
16
+    private Long registrationId;
17
+
18
+    @JsonProperty("contract_type")
19
+    private String contractType;
20
+
21
+    @JsonProperty("contract_title")
22
+    private String contractTitle;
23
+
24
+    @JsonProperty("sign_status")
25
+    private String signStatus;
26
+
27
+    @JsonProperty("sign_provider")
28
+    private String signProvider;
29
+
30
+    @JsonProperty("sign_url")
31
+    private String signUrl;
32
+
33
+    @JsonProperty("external_contract_id")
34
+    private String externalContractId;
35
+
36
+    @Schema(description = "是否本次新创建")
37
+    private Boolean created;
38
+
39
+    @Schema(description = "提示")
40
+    private String message;
41
+
42
+    public Long getContractId() {
43
+        return contractId;
44
+    }
45
+
46
+    public void setContractId(Long contractId) {
47
+        this.contractId = contractId;
48
+    }
49
+
50
+    public String getContractNo() {
51
+        return contractNo;
52
+    }
53
+
54
+    public void setContractNo(String contractNo) {
55
+        this.contractNo = contractNo;
56
+    }
57
+
58
+    public Long getRegistrationId() {
59
+        return registrationId;
60
+    }
61
+
62
+    public void setRegistrationId(Long registrationId) {
63
+        this.registrationId = registrationId;
64
+    }
65
+
66
+    public String getContractType() {
67
+        return contractType;
68
+    }
69
+
70
+    public void setContractType(String contractType) {
71
+        this.contractType = contractType;
72
+    }
73
+
74
+    public String getContractTitle() {
75
+        return contractTitle;
76
+    }
77
+
78
+    public void setContractTitle(String contractTitle) {
79
+        this.contractTitle = contractTitle;
80
+    }
81
+
82
+    public String getSignStatus() {
83
+        return signStatus;
84
+    }
85
+
86
+    public void setSignStatus(String signStatus) {
87
+        this.signStatus = signStatus;
88
+    }
89
+
90
+    public String getSignProvider() {
91
+        return signProvider;
92
+    }
93
+
94
+    public void setSignProvider(String signProvider) {
95
+        this.signProvider = signProvider;
96
+    }
97
+
98
+    public String getSignUrl() {
99
+        return signUrl;
100
+    }
101
+
102
+    public void setSignUrl(String signUrl) {
103
+        this.signUrl = signUrl;
104
+    }
105
+
106
+    public String getExternalContractId() {
107
+        return externalContractId;
108
+    }
109
+
110
+    public void setExternalContractId(String externalContractId) {
111
+        this.externalContractId = externalContractId;
112
+    }
113
+
114
+    public Boolean getCreated() {
115
+        return created;
116
+    }
117
+
118
+    public void setCreated(Boolean created) {
119
+        this.created = created;
120
+    }
121
+
122
+    public String getMessage() {
123
+        return message;
124
+    }
125
+
126
+    public void setMessage(String message) {
127
+        this.message = message;
128
+    }
129
+}

+ 58 - 3
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/registration/WorkerRegistrationService.java

@@ -5,18 +5,23 @@ import com.huimv.employment.common.exception.BizException;
5 5
 import com.huimv.employment.common.exception.ErrorCode;
6 6
 import com.huimv.employment.dao.entity.FeEmploymentOrder;
7 7
 import com.huimv.employment.dao.entity.FeEnterprise;
8
+import com.huimv.employment.dao.entity.FeContract;
8 9
 import com.huimv.employment.dao.entity.FeRegistrationBatch;
9 10
 import com.huimv.employment.dao.entity.FeWorker;
10 11
 import com.huimv.employment.dao.entity.FeWorkerRegistration;
11 12
 import com.huimv.employment.dao.mapper.FeEmploymentOrderMapper;
12 13
 import com.huimv.employment.dao.mapper.FeRegistrationBatchMapper;
13 14
 import com.huimv.employment.dao.mapper.FeWorkerRegistrationMapper;
15
+import com.huimv.employment.service.contract.ContractService;
16
+import com.huimv.employment.service.contract.dto.ContractSummaryResponse;
14 17
 import com.huimv.employment.service.enterprise.EnterpriseService;
15 18
 import com.huimv.employment.service.registration.dto.WorkerRegistrationApplyRequest;
16 19
 import com.huimv.employment.service.registration.dto.WorkerRegistrationApplyResponse;
17 20
 import com.huimv.employment.service.registration.dto.WorkerRegistrationRejectRequest;
18 21
 import com.huimv.employment.service.registration.dto.WorkerRegistrationReviewResponse;
19 22
 import com.huimv.employment.service.worker.WorkerService;
23
+import org.slf4j.Logger;
24
+import org.slf4j.LoggerFactory;
20 25
 import org.springframework.stereotype.Service;
21 26
 import org.springframework.transaction.annotation.Transactional;
22 27
 import org.springframework.util.StringUtils;
@@ -29,6 +34,8 @@ import java.time.LocalDateTime;
29 34
 @Service
30 35
 public class WorkerRegistrationService {
31 36
 
37
+    private static final Logger log = LoggerFactory.getLogger(WorkerRegistrationService.class);
38
+
32 39
     private static final String BATCH_ACTIVE = "active";
33 40
     private static final String REG_UNDER_REVIEW = "under_review";
34 41
     /** 审核通过后进入个人签合同阶段 */
@@ -52,17 +59,20 @@ public class WorkerRegistrationService {
52 59
     private final FeWorkerRegistrationMapper feWorkerRegistrationMapper;
53 60
     private final WorkerService workerService;
54 61
     private final EnterpriseService enterpriseService;
62
+    private final ContractService contractService;
55 63
 
56 64
     public WorkerRegistrationService(FeRegistrationBatchMapper feRegistrationBatchMapper,
57 65
                                      FeEmploymentOrderMapper feEmploymentOrderMapper,
58 66
                                      FeWorkerRegistrationMapper feWorkerRegistrationMapper,
59 67
                                      WorkerService workerService,
60
-                                     EnterpriseService enterpriseService) {
68
+                                     EnterpriseService enterpriseService,
69
+                                     ContractService contractService) {
61 70
         this.feRegistrationBatchMapper = feRegistrationBatchMapper;
62 71
         this.feEmploymentOrderMapper = feEmploymentOrderMapper;
63 72
         this.feWorkerRegistrationMapper = feWorkerRegistrationMapper;
64 73
         this.workerService = workerService;
65 74
         this.enterpriseService = enterpriseService;
75
+        this.contractService = contractService;
66 76
     }
67 77
 
68 78
     /**
@@ -142,7 +152,10 @@ public class WorkerRegistrationService {
142 152
         FeEmploymentOrder order = requireOrder(reg.getOrderId());
143 153
 
144 154
         if (REG_CONTRACT_SIGNING.equals(reg.getRegStatus()) || REG_COMPLETED.equals(reg.getRegStatus())) {
145
-            return toReviewResponse(reg, batch, order, REVIEW_ALREADY, "该登记已审核通过");
155
+            order = feEmploymentOrderMapper.selectById(order.getId());
156
+            WorkerRegistrationReviewResponse already = toReviewResponse(reg, batch, order, REVIEW_ALREADY, "该登记已审核通过");
157
+            attachContract(already, reg.getId());
158
+            return already;
146 159
         }
147 160
         if (REG_REJECTED.equals(reg.getRegStatus())) {
148 161
             throw new BizException(ErrorCode.BAD_REQUEST, "该登记已被驳回,无法再通过");
@@ -170,7 +183,23 @@ public class WorkerRegistrationService {
170 183
         maybeAdvanceOrderToContractSigning(order, now);
171 184
 
172 185
         order = feEmploymentOrderMapper.selectById(order.getId());
173
-        return toReviewResponse(reg, batch, order, REVIEW_APPROVED, "审核通过,已进入合同签署");
186
+        WorkerRegistrationReviewResponse response = toReviewResponse(reg, batch, order, REVIEW_APPROVED, "审核通过,已进入合同签署");
187
+        try {
188
+            ContractSummaryResponse contract = contractService.createOrGetPendingForRegistration(reg, order);
189
+            fillContractFields(response, contract);
190
+            if (contract != null && Boolean.TRUE.equals(contract.getCreated())
191
+                    && StringUtils.hasText(contract.getSignUrl())) {
192
+                response.setMessage("审核通过,待签合同已生成");
193
+            } else if (contract != null && !StringUtils.hasText(contract.getSignUrl())) {
194
+                response.setMessage("审核通过,待签合同已创建但签署链接未就绪,请重新生成");
195
+                response.setContractCreateFailed(Boolean.TRUE);
196
+            }
197
+        } catch (Exception ex) {
198
+            log.error("登记 registrationId={} 自动生成待签合同失败", reg.getId(), ex);
199
+            response.setMessage("审核通过,待签合同生成失败,请重新生成");
200
+            response.setContractCreateFailed(Boolean.TRUE);
201
+        }
202
+        return response;
174 203
     }
175 204
 
176 205
     /**
@@ -451,4 +480,30 @@ public class WorkerRegistrationService {
451 480
         response.setMessage(message);
452 481
         return response;
453 482
     }
483
+
484
+    private void attachContract(WorkerRegistrationReviewResponse response, Long registrationId) {
485
+        try {
486
+            FeContract existing = contractService.findActiveByRegistrationId(registrationId);
487
+            if (existing != null) {
488
+                response.setContractId(existing.getId());
489
+                response.setContractNo(existing.getContractNo());
490
+                response.setSignStatus(existing.getSignStatus());
491
+                response.setSignUrl(existing.getSignUrl());
492
+                response.setContractType(existing.getContractType());
493
+            }
494
+        } catch (Exception ignored) {
495
+            // ignore attach failure
496
+        }
497
+    }
498
+
499
+    private static void fillContractFields(WorkerRegistrationReviewResponse response, ContractSummaryResponse contract) {
500
+        if (response == null || contract == null) {
501
+            return;
502
+        }
503
+        response.setContractId(contract.getContractId());
504
+        response.setContractNo(contract.getContractNo());
505
+        response.setSignStatus(contract.getSignStatus());
506
+        response.setSignUrl(contract.getSignUrl());
507
+        response.setContractType(contract.getContractType());
508
+    }
454 509
 }

+ 73 - 1
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/registration/dto/WorkerRegistrationReviewResponse.java

@@ -22,7 +22,7 @@ public class WorkerRegistrationReviewResponse {
22 22
     private String orderNo;
23 23
 
24 24
     @JsonProperty("reg_status")
25
-    @Schema(description = "登记状态", example = "completed")
25
+    @Schema(description = "登记状态", example = "contract_signing")
26 26
     private String regStatus;
27 27
 
28 28
     @JsonProperty("fail_reason")
@@ -33,6 +33,30 @@ public class WorkerRegistrationReviewResponse {
33 33
     @Schema(description = "订单当前办理步骤")
34 34
     private String currentStepCode;
35 35
 
36
+    @JsonProperty("contract_id")
37
+    @Schema(description = "待签合同 ID")
38
+    private Long contractId;
39
+
40
+    @JsonProperty("contract_no")
41
+    @Schema(description = "合同编号")
42
+    private String contractNo;
43
+
44
+    @JsonProperty("contract_type")
45
+    @Schema(description = "合同模板类型")
46
+    private String contractType;
47
+
48
+    @JsonProperty("sign_status")
49
+    @Schema(description = "签署状态", example = "pending")
50
+    private String signStatus;
51
+
52
+    @JsonProperty("sign_url")
53
+    @Schema(description = "签署链接")
54
+    private String signUrl;
55
+
56
+    @JsonProperty("contract_create_failed")
57
+    @Schema(description = "待签合同生成是否失败(可重新生成)")
58
+    private Boolean contractCreateFailed;
59
+
36 60
     @Schema(description = "提示文案")
37 61
     private String message;
38 62
 
@@ -92,6 +116,54 @@ public class WorkerRegistrationReviewResponse {
92 116
         this.currentStepCode = currentStepCode;
93 117
     }
94 118
 
119
+    public Long getContractId() {
120
+        return contractId;
121
+    }
122
+
123
+    public void setContractId(Long contractId) {
124
+        this.contractId = contractId;
125
+    }
126
+
127
+    public String getContractNo() {
128
+        return contractNo;
129
+    }
130
+
131
+    public void setContractNo(String contractNo) {
132
+        this.contractNo = contractNo;
133
+    }
134
+
135
+    public String getContractType() {
136
+        return contractType;
137
+    }
138
+
139
+    public void setContractType(String contractType) {
140
+        this.contractType = contractType;
141
+    }
142
+
143
+    public String getSignStatus() {
144
+        return signStatus;
145
+    }
146
+
147
+    public void setSignStatus(String signStatus) {
148
+        this.signStatus = signStatus;
149
+    }
150
+
151
+    public String getSignUrl() {
152
+        return signUrl;
153
+    }
154
+
155
+    public void setSignUrl(String signUrl) {
156
+        this.signUrl = signUrl;
157
+    }
158
+
159
+    public Boolean getContractCreateFailed() {
160
+        return contractCreateFailed;
161
+    }
162
+
163
+    public void setContractCreateFailed(Boolean contractCreateFailed) {
164
+        this.contractCreateFailed = contractCreateFailed;
165
+    }
166
+
95 167
     public String getMessage() {
96 168
         return message;
97 169
     }