|
|
@@ -7,6 +7,7 @@ import com.huimv.employment.dao.entity.FeDraftCostSnapshot;
|
|
7
|
7
|
import com.huimv.employment.dao.entity.FeEmploymentDraft;
|
|
8
|
8
|
import com.huimv.employment.dao.entity.FeEmploymentOrder;
|
|
9
|
9
|
import com.huimv.employment.dao.entity.FeEnterprise;
|
|
|
10
|
+import com.huimv.employment.dao.entity.FePaymentRecord;
|
|
10
|
11
|
import com.huimv.employment.dao.entity.FeServicePlan;
|
|
11
|
12
|
import com.huimv.employment.dao.entity.FeSettlementFee;
|
|
12
|
13
|
import com.huimv.employment.dao.entity.FeSettlementItem;
|
|
|
@@ -15,6 +16,7 @@ import com.huimv.employment.dao.entity.FeWorkerRegistration;
|
|
15
|
16
|
import com.huimv.employment.dao.mapper.FeDraftCostSnapshotMapper;
|
|
16
|
17
|
import com.huimv.employment.dao.mapper.FeEmploymentDraftMapper;
|
|
17
|
18
|
import com.huimv.employment.dao.mapper.FeEmploymentOrderMapper;
|
|
|
19
|
+import com.huimv.employment.dao.mapper.FePaymentRecordMapper;
|
|
18
|
20
|
import com.huimv.employment.dao.mapper.FeServicePlanMapper;
|
|
19
|
21
|
import com.huimv.employment.dao.mapper.FeSettlementFeeMapper;
|
|
20
|
22
|
import com.huimv.employment.dao.mapper.FeSettlementItemMapper;
|
|
|
@@ -22,6 +24,7 @@ import com.huimv.employment.dao.mapper.FeSettlementOrderMapper;
|
|
22
|
24
|
import com.huimv.employment.dao.mapper.FeWorkerRegistrationMapper;
|
|
23
|
25
|
import com.huimv.employment.service.enterprise.EnterpriseService;
|
|
24
|
26
|
import com.huimv.employment.service.settlement.dto.OrderStartSettlementRequest;
|
|
|
27
|
+import com.huimv.employment.service.settlement.dto.SettlementConfirmPayRequest;
|
|
25
|
28
|
import com.huimv.employment.service.settlement.dto.SettlementFeeResponse;
|
|
26
|
29
|
import com.huimv.employment.service.settlement.dto.SettlementItemResponse;
|
|
27
|
30
|
import com.huimv.employment.service.settlement.dto.SettlementSummaryResponse;
|
|
|
@@ -44,11 +47,10 @@ import java.util.concurrent.ThreadLocalRandom;
|
|
44
|
47
|
/**
|
|
45
|
48
|
* 结算服务(方案 A:企业主动发起)。
|
|
46
|
49
|
* <p>
|
|
47
|
|
- * 流程:订单处于 {@code work_started} → 企业调用发起结算 →
|
|
48
|
|
- * 生成 {@code fe_settlement_order}(含按人明细、费用分项)→
|
|
49
|
|
- * 订单推进至 {@code settlement_pending}(待确认结算)。
|
|
|
50
|
+ * 流程:{@code work_started} → 发起结算 → {@code settlement_pending} →
|
|
|
51
|
+ * 确认并支付(Mock)→ {@code settlement_paid}。
|
|
50
|
52
|
* </p>
|
|
51
|
|
- * <p>MVP 无考勤:金额按日薪×天数或方案成本快照估算;确认支付另接口处理。</p>
|
|
|
53
|
+ * <p>MVP 无考勤、无真实支付通道:金额按日薪/方案快照估算,支付即时成功。</p>
|
|
52
|
54
|
*/
|
|
53
|
55
|
@Service
|
|
54
|
56
|
public class SettlementService {
|
|
|
@@ -59,19 +61,37 @@ public class SettlementService {
|
|
59
|
61
|
private static final String STEP_WORK_STARTED = "work_started";
|
|
60
|
62
|
/** 订单步骤:待结算(发起结算成功后的目标步骤) */
|
|
61
|
63
|
private static final String STEP_SETTLEMENT_PENDING = "settlement_pending";
|
|
|
64
|
+ /** 订单步骤:已支付 */
|
|
|
65
|
+ private static final String STEP_SETTLEMENT_PAID = "settlement_paid";
|
|
62
|
66
|
/** 登记状态:已签完合同,可纳入结算 */
|
|
63
|
67
|
private static final String REG_COMPLETED = "completed";
|
|
64
|
68
|
/** 结算单状态:待企业确认 */
|
|
65
|
69
|
private static final String STATUS_PENDING = "pending";
|
|
|
70
|
+ /** 结算单状态:已确认待支付 */
|
|
|
71
|
+ private static final String STATUS_CONFIRMED = "confirmed";
|
|
|
72
|
+ /** 结算单状态:支付中 */
|
|
|
73
|
+ private static final String STATUS_PAYING = "paying";
|
|
|
74
|
+ /** 结算单状态:已支付 */
|
|
|
75
|
+ private static final String STATUS_PAID = "paid";
|
|
66
|
76
|
/** 结算单状态:已取消(不视为有效结算单) */
|
|
67
|
77
|
private static final String STATUS_CANCELLED = "cancelled";
|
|
|
78
|
+ /** 明细状态:已支付 */
|
|
|
79
|
+ private static final String ITEM_STATUS_PAID = "paid";
|
|
|
80
|
+ /** 支付状态:成功 */
|
|
|
81
|
+ private static final String PAY_STATUS_SUCCESS = "success";
|
|
68
|
82
|
/** 订单业务状态:结算中 */
|
|
69
|
83
|
private static final String ORDER_STATUS_SETTLING = "settling";
|
|
|
84
|
+ /** 订单业务状态:已支付 */
|
|
|
85
|
+ private static final String ORDER_STATUS_PAID = "paid";
|
|
|
86
|
+ private static final String CHANNEL_WECHAT = "wechat";
|
|
|
87
|
+ private static final String CHANNEL_ALIPAY = "alipay";
|
|
|
88
|
+ private static final String CHANNEL_BANK = "bank";
|
|
70
|
89
|
private static final DateTimeFormatter SETTLEMENT_NO_DAY = DateTimeFormatter.ofPattern("yyyyMMdd");
|
|
71
|
90
|
|
|
72
|
91
|
private final FeSettlementOrderMapper feSettlementOrderMapper;
|
|
73
|
92
|
private final FeSettlementItemMapper feSettlementItemMapper;
|
|
74
|
93
|
private final FeSettlementFeeMapper feSettlementFeeMapper;
|
|
|
94
|
+ private final FePaymentRecordMapper fePaymentRecordMapper;
|
|
75
|
95
|
private final FeEmploymentOrderMapper feEmploymentOrderMapper;
|
|
76
|
96
|
private final FeEmploymentDraftMapper feEmploymentDraftMapper;
|
|
77
|
97
|
private final FeWorkerRegistrationMapper feWorkerRegistrationMapper;
|
|
|
@@ -82,6 +102,7 @@ public class SettlementService {
|
|
82
|
102
|
public SettlementService(FeSettlementOrderMapper feSettlementOrderMapper,
|
|
83
|
103
|
FeSettlementItemMapper feSettlementItemMapper,
|
|
84
|
104
|
FeSettlementFeeMapper feSettlementFeeMapper,
|
|
|
105
|
+ FePaymentRecordMapper fePaymentRecordMapper,
|
|
85
|
106
|
FeEmploymentOrderMapper feEmploymentOrderMapper,
|
|
86
|
107
|
FeEmploymentDraftMapper feEmploymentDraftMapper,
|
|
87
|
108
|
FeWorkerRegistrationMapper feWorkerRegistrationMapper,
|
|
|
@@ -91,6 +112,7 @@ public class SettlementService {
|
|
91
|
112
|
this.feSettlementOrderMapper = feSettlementOrderMapper;
|
|
92
|
113
|
this.feSettlementItemMapper = feSettlementItemMapper;
|
|
93
|
114
|
this.feSettlementFeeMapper = feSettlementFeeMapper;
|
|
|
115
|
+ this.fePaymentRecordMapper = fePaymentRecordMapper;
|
|
94
|
116
|
this.feEmploymentOrderMapper = feEmploymentOrderMapper;
|
|
95
|
117
|
this.feEmploymentDraftMapper = feEmploymentDraftMapper;
|
|
96
|
118
|
this.feWorkerRegistrationMapper = feWorkerRegistrationMapper;
|
|
|
@@ -227,6 +249,114 @@ public class SettlementService {
|
|
227
|
249
|
return response;
|
|
228
|
250
|
}
|
|
229
|
251
|
|
|
|
252
|
+ /**
|
|
|
253
|
+ * 企业确认结算并支付(MVP 一次完成)。
|
|
|
254
|
+ * <ul>
|
|
|
255
|
+ * <li>结算单 {@code pending → paid},明细同步 paid</li>
|
|
|
256
|
+ * <li>写入 {@code fe_payment_record}(Mock 即时 success)</li>
|
|
|
257
|
+ * <li>订单 {@code settlement_pending → settlement_paid}</li>
|
|
|
258
|
+ * <li>幂等:已支付则返回现有结算/支付信息</li>
|
|
|
259
|
+ * </ul>
|
|
|
260
|
+ */
|
|
|
261
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
262
|
+ public SettlementSummaryResponse confirmAndPay(Long userId, Long orderId, SettlementConfirmPayRequest request) {
|
|
|
263
|
+ FeEmploymentOrder order = requireOwnedOrder(userId, orderId);
|
|
|
264
|
+ FeSettlementOrder settlement = findActiveByOrderId(orderId);
|
|
|
265
|
+ if (settlement == null) {
|
|
|
266
|
+ throw new BizException(ErrorCode.BAD_REQUEST, "尚未发起结算,请先调用发起结算");
|
|
|
267
|
+ }
|
|
|
268
|
+
|
|
|
269
|
+ // 幂等:已支付
|
|
|
270
|
+ if (STATUS_PAID.equals(settlement.getSettlementStatus())) {
|
|
|
271
|
+ ensureOrderAtSettlementPaid(order, LocalDateTime.now());
|
|
|
272
|
+ return toSummary(order, settlement, false, "结算已支付");
|
|
|
273
|
+ }
|
|
|
274
|
+
|
|
|
275
|
+ String step = order.getCurrentStepCode();
|
|
|
276
|
+ if (STEP_SETTLEMENT_PAID.equals(step) || isStepAfter(step, STEP_SETTLEMENT_PAID)) {
|
|
|
277
|
+ ensureOrderAtSettlementPaid(order, LocalDateTime.now());
|
|
|
278
|
+ return toSummary(order, settlement, false, "订单已进入已支付阶段");
|
|
|
279
|
+ }
|
|
|
280
|
+ if (!STEP_SETTLEMENT_PENDING.equals(step)) {
|
|
|
281
|
+ throw new BizException(ErrorCode.BAD_REQUEST,
|
|
|
282
|
+ "仅待结算阶段可确认支付,当前步骤:" + (step == null ? "未知" : step));
|
|
|
283
|
+ }
|
|
|
284
|
+ if (!STATUS_PENDING.equals(settlement.getSettlementStatus())
|
|
|
285
|
+ && !STATUS_CONFIRMED.equals(settlement.getSettlementStatus())
|
|
|
286
|
+ && !STATUS_PAYING.equals(settlement.getSettlementStatus())) {
|
|
|
287
|
+ throw new BizException(ErrorCode.BAD_REQUEST,
|
|
|
288
|
+ "当前结算单状态不可支付:" + settlement.getSettlementStatus());
|
|
|
289
|
+ }
|
|
|
290
|
+
|
|
|
291
|
+ String channel = resolvePaymentChannel(request != null ? request.getPaymentChannel() : null);
|
|
|
292
|
+ LocalDateTime now = LocalDateTime.now();
|
|
|
293
|
+ BigDecimal payAmount = settlement.getTotalOutflow() != null
|
|
|
294
|
+ ? settlement.getTotalOutflow()
|
|
|
295
|
+ : (settlement.getNetAmount() != null ? settlement.getNetAmount() : BigDecimal.ZERO);
|
|
|
296
|
+
|
|
|
297
|
+ // 1) 结算单确认并置为已支付(MVP 跳过真实渠道回调)
|
|
|
298
|
+ FeSettlementOrder settlementUpdate = new FeSettlementOrder();
|
|
|
299
|
+ settlementUpdate.setId(settlement.getId());
|
|
|
300
|
+ settlementUpdate.setSettlementStatus(STATUS_PAID);
|
|
|
301
|
+ settlementUpdate.setConfirmedAt(settlement.getConfirmedAt() != null ? settlement.getConfirmedAt() : now);
|
|
|
302
|
+ settlementUpdate.setConfirmedBy(userId);
|
|
|
303
|
+ settlementUpdate.setPaidAt(now);
|
|
|
304
|
+ settlementUpdate.setComplianceTags("[\"合同已签署\",\"结算已确认\",\"支付已完成\"]");
|
|
|
305
|
+ settlementUpdate.setComplianceRemark("MVP Mock 支付成功,后续可对接真实支付通道");
|
|
|
306
|
+ settlementUpdate.setUpdateTime(now);
|
|
|
307
|
+ feSettlementOrderMapper.updateById(settlementUpdate);
|
|
|
308
|
+ settlement.setSettlementStatus(STATUS_PAID);
|
|
|
309
|
+ settlement.setConfirmedAt(settlementUpdate.getConfirmedAt());
|
|
|
310
|
+ settlement.setConfirmedBy(userId);
|
|
|
311
|
+ settlement.setPaidAt(now);
|
|
|
312
|
+
|
|
|
313
|
+ // 2) 明细全部标记已支付
|
|
|
314
|
+ List<FeSettlementItem> items = feSettlementItemMapper.selectList(
|
|
|
315
|
+ new LambdaQueryWrapper<FeSettlementItem>()
|
|
|
316
|
+ .eq(FeSettlementItem::getSettlementId, settlement.getId()));
|
|
|
317
|
+ if (items != null) {
|
|
|
318
|
+ for (FeSettlementItem item : items) {
|
|
|
319
|
+ FeSettlementItem itemUpdate = new FeSettlementItem();
|
|
|
320
|
+ itemUpdate.setId(item.getId());
|
|
|
321
|
+ itemUpdate.setItemStatus(ITEM_STATUS_PAID);
|
|
|
322
|
+ itemUpdate.setUpdateTime(now);
|
|
|
323
|
+ feSettlementItemMapper.updateById(itemUpdate);
|
|
|
324
|
+ }
|
|
|
325
|
+ }
|
|
|
326
|
+
|
|
|
327
|
+ // 3) 支付记录(Mock 即时成功;已有成功记录则复用)
|
|
|
328
|
+ FePaymentRecord payment = findSuccessPayment(settlement.getId());
|
|
|
329
|
+ if (payment == null) {
|
|
|
330
|
+ payment = new FePaymentRecord();
|
|
|
331
|
+ payment.setPaymentNo(nextPaymentNo());
|
|
|
332
|
+ payment.setSettlementId(settlement.getId());
|
|
|
333
|
+ payment.setEnterpriseId(order.getEnterpriseId());
|
|
|
334
|
+ payment.setPaymentChannel(channel);
|
|
|
335
|
+ payment.setPaymentAmount(payAmount);
|
|
|
336
|
+ payment.setPaymentStatus(PAY_STATUS_SUCCESS);
|
|
|
337
|
+ payment.setExternalTradeNo("MOCK-" + payment.getPaymentNo());
|
|
|
338
|
+ payment.setPayerUserId(userId);
|
|
|
339
|
+ payment.setPaidAt(now);
|
|
|
340
|
+ payment.setCreateTime(now);
|
|
|
341
|
+ payment.setUpdateTime(now);
|
|
|
342
|
+ fePaymentRecordMapper.insert(payment);
|
|
|
343
|
+ }
|
|
|
344
|
+
|
|
|
345
|
+ // 4) 订单推进至已支付
|
|
|
346
|
+ FeEmploymentOrder orderUpdate = new FeEmploymentOrder();
|
|
|
347
|
+ orderUpdate.setId(order.getId());
|
|
|
348
|
+ orderUpdate.setCurrentStepCode(STEP_SETTLEMENT_PAID);
|
|
|
349
|
+ orderUpdate.setOrderStatus(ORDER_STATUS_PAID);
|
|
|
350
|
+ orderUpdate.setUpdateTime(now);
|
|
|
351
|
+ feEmploymentOrderMapper.updateById(orderUpdate);
|
|
|
352
|
+ order.setCurrentStepCode(STEP_SETTLEMENT_PAID);
|
|
|
353
|
+ order.setOrderStatus(ORDER_STATUS_PAID);
|
|
|
354
|
+
|
|
|
355
|
+ log.info("结算确认支付成功 orderId={} settlementId={} paymentNo={} channel={} amount={}",
|
|
|
356
|
+ orderId, settlement.getId(), payment.getPaymentNo(), channel, payAmount);
|
|
|
357
|
+ return toSummary(order, settlement, false, "结算已确认并支付成功");
|
|
|
358
|
+ }
|
|
|
359
|
+
|
|
230
|
360
|
/**
|
|
231
|
361
|
* 幂等路径:结算单已存在但订单步骤未跟上时,校正到 settlement_pending。
|
|
232
|
362
|
*/
|
|
|
@@ -245,6 +375,22 @@ public class SettlementService {
|
|
245
|
375
|
order.setOrderStatus(ORDER_STATUS_SETTLING);
|
|
246
|
376
|
}
|
|
247
|
377
|
|
|
|
378
|
+ /** 幂等路径:已支付但订单步骤未跟上时,校正到 settlement_paid。 */
|
|
|
379
|
+ private void ensureOrderAtSettlementPaid(FeEmploymentOrder order, LocalDateTime now) {
|
|
|
380
|
+ if (STEP_SETTLEMENT_PAID.equals(order.getCurrentStepCode())
|
|
|
381
|
+ || isStepAfter(order.getCurrentStepCode(), STEP_SETTLEMENT_PAID)) {
|
|
|
382
|
+ return;
|
|
|
383
|
+ }
|
|
|
384
|
+ FeEmploymentOrder update = new FeEmploymentOrder();
|
|
|
385
|
+ update.setId(order.getId());
|
|
|
386
|
+ update.setCurrentStepCode(STEP_SETTLEMENT_PAID);
|
|
|
387
|
+ update.setOrderStatus(ORDER_STATUS_PAID);
|
|
|
388
|
+ update.setUpdateTime(now);
|
|
|
389
|
+ feEmploymentOrderMapper.updateById(update);
|
|
|
390
|
+ order.setCurrentStepCode(STEP_SETTLEMENT_PAID);
|
|
|
391
|
+ order.setOrderStatus(ORDER_STATUS_PAID);
|
|
|
392
|
+ }
|
|
|
393
|
+
|
|
248
|
394
|
/** 本单最新一条非 cancelled 结算单。 */
|
|
249
|
395
|
private FeSettlementOrder findActiveByOrderId(Long orderId) {
|
|
250
|
396
|
return feSettlementOrderMapper.selectOne(new LambdaQueryWrapper<FeSettlementOrder>()
|
|
|
@@ -392,6 +538,18 @@ public class SettlementService {
|
|
392
|
538
|
response.setCreated(created);
|
|
393
|
539
|
response.setMessage(message);
|
|
394
|
540
|
response.setCreateTime(settlement.getCreateTime());
|
|
|
541
|
+ response.setConfirmedAt(settlement.getConfirmedAt());
|
|
|
542
|
+ response.setPaidAt(settlement.getPaidAt());
|
|
|
543
|
+
|
|
|
544
|
+ FePaymentRecord payment = findLatestPayment(settlement.getId());
|
|
|
545
|
+ if (payment != null) {
|
|
|
546
|
+ response.setPaymentId(payment.getId());
|
|
|
547
|
+ response.setPaymentNo(payment.getPaymentNo());
|
|
|
548
|
+ response.setPaymentChannel(payment.getPaymentChannel());
|
|
|
549
|
+ response.setPaymentStatus(payment.getPaymentStatus());
|
|
|
550
|
+ response.setPaymentAmount(payment.getPaymentAmount());
|
|
|
551
|
+ response.setExternalTradeNo(payment.getExternalTradeNo());
|
|
|
552
|
+ }
|
|
395
|
553
|
|
|
396
|
554
|
List<FeSettlementItem> items = feSettlementItemMapper.selectList(
|
|
397
|
555
|
new LambdaQueryWrapper<FeSettlementItem>()
|
|
|
@@ -484,6 +642,40 @@ public class SettlementService {
|
|
484
|
642
|
return "STL" + day + rand;
|
|
485
|
643
|
}
|
|
486
|
644
|
|
|
|
645
|
+ /** 支付单号:PAY + yyyyMMdd + 4 位随机数。 */
|
|
|
646
|
+ private String nextPaymentNo() {
|
|
|
647
|
+ String day = LocalDateTime.now().format(SETTLEMENT_NO_DAY);
|
|
|
648
|
+ int rand = ThreadLocalRandom.current().nextInt(1000, 10000);
|
|
|
649
|
+ return "PAY" + day + rand;
|
|
|
650
|
+ }
|
|
|
651
|
+
|
|
|
652
|
+ /** 解析并校验支付渠道,非法则默认 wechat。 */
|
|
|
653
|
+ private static String resolvePaymentChannel(String raw) {
|
|
|
654
|
+ if (!StringUtils.hasText(raw)) {
|
|
|
655
|
+ return CHANNEL_WECHAT;
|
|
|
656
|
+ }
|
|
|
657
|
+ String channel = raw.trim().toLowerCase();
|
|
|
658
|
+ if (CHANNEL_WECHAT.equals(channel) || CHANNEL_ALIPAY.equals(channel) || CHANNEL_BANK.equals(channel)) {
|
|
|
659
|
+ return channel;
|
|
|
660
|
+ }
|
|
|
661
|
+ throw new BizException(ErrorCode.BAD_REQUEST, "不支持的支付渠道:" + raw + ",可选 wechat/alipay/bank");
|
|
|
662
|
+ }
|
|
|
663
|
+
|
|
|
664
|
+ private FePaymentRecord findSuccessPayment(Long settlementId) {
|
|
|
665
|
+ return fePaymentRecordMapper.selectOne(new LambdaQueryWrapper<FePaymentRecord>()
|
|
|
666
|
+ .eq(FePaymentRecord::getSettlementId, settlementId)
|
|
|
667
|
+ .eq(FePaymentRecord::getPaymentStatus, PAY_STATUS_SUCCESS)
|
|
|
668
|
+ .orderByDesc(FePaymentRecord::getId)
|
|
|
669
|
+ .last("LIMIT 1"));
|
|
|
670
|
+ }
|
|
|
671
|
+
|
|
|
672
|
+ private FePaymentRecord findLatestPayment(Long settlementId) {
|
|
|
673
|
+ return fePaymentRecordMapper.selectOne(new LambdaQueryWrapper<FePaymentRecord>()
|
|
|
674
|
+ .eq(FePaymentRecord::getSettlementId, settlementId)
|
|
|
675
|
+ .orderByDesc(FePaymentRecord::getId)
|
|
|
676
|
+ .last("LIMIT 1"));
|
|
|
677
|
+ }
|
|
|
678
|
+
|
|
487
|
679
|
/** 判断 current 是否在合规流程中严格位于 target 之后。 */
|
|
488
|
680
|
private static boolean isStepAfter(String current, String target) {
|
|
489
|
681
|
if (!StringUtils.hasText(current) || !StringUtils.hasText(target)) {
|