|
|
@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
4
|
4
|
import com.huimv.employment.common.exception.BizException;
|
|
5
|
5
|
import com.huimv.employment.common.exception.ErrorCode;
|
|
6
|
6
|
import com.huimv.employment.dao.entity.FeDraftCostSnapshot;
|
|
7
|
|
-import com.huimv.employment.dao.entity.FeDraftWorkItem;
|
|
8
|
7
|
import com.huimv.employment.dao.entity.FeEmploymentDraft;
|
|
9
|
8
|
import com.huimv.employment.dao.entity.FeEmploymentOrder;
|
|
10
|
9
|
import com.huimv.employment.dao.entity.FeEnterprise;
|
|
|
@@ -24,7 +23,6 @@ import com.huimv.employment.dao.mapper.FeSettlementFeeMapper;
|
|
24
|
23
|
import com.huimv.employment.dao.mapper.FeSettlementItemMapper;
|
|
25
|
24
|
import com.huimv.employment.dao.mapper.FeSettlementOrderMapper;
|
|
26
|
25
|
import com.huimv.employment.dao.mapper.FeWorkerRegistrationMapper;
|
|
27
|
|
-import com.huimv.employment.service.draft.DraftWorkItemService;
|
|
28
|
26
|
import com.huimv.employment.service.enterprise.EnterpriseService;
|
|
29
|
27
|
import com.huimv.employment.service.notification.NotificationService;
|
|
30
|
28
|
import com.huimv.employment.service.order.OrderWorkItemService;
|
|
|
@@ -48,7 +46,9 @@ import java.time.LocalDateTime;
|
|
48
|
46
|
import java.time.format.DateTimeFormatter;
|
|
49
|
47
|
import java.util.ArrayList;
|
|
50
|
48
|
import java.util.Arrays;
|
|
|
49
|
+import java.util.HashMap;
|
|
51
|
50
|
import java.util.List;
|
|
|
51
|
+import java.util.Map;
|
|
52
|
52
|
import java.util.concurrent.ThreadLocalRandom;
|
|
53
|
53
|
|
|
54
|
54
|
/**
|
|
|
@@ -57,7 +57,7 @@ import java.util.concurrent.ThreadLocalRandom;
|
|
57
|
57
|
* 流程:{@code task_publish} → 发起结算 → {@code settlement_pending} →
|
|
58
|
58
|
* 确认并支付(Mock)→ {@code settlement_paid} → 完税/开票办结 → {@code completed}。
|
|
59
|
59
|
* </p>
|
|
60
|
|
- * <p>MVP 无考勤、无真实支付/税票通道:金额按日薪/方案快照估算,支付与办结均为 Mock。</p>
|
|
|
60
|
+ * <p>MVP 无考勤、无真实支付/税票通道:明细按订单工种 unit_amount×work_days 计算,支付与办结均为 Mock。</p>
|
|
61
|
61
|
*/
|
|
62
|
62
|
@Service
|
|
63
|
63
|
public class SettlementService {
|
|
|
@@ -112,7 +112,6 @@ public class SettlementService {
|
|
112
|
112
|
private final FeServicePlanMapper feServicePlanMapper;
|
|
113
|
113
|
private final EnterpriseService enterpriseService;
|
|
114
|
114
|
private final NotificationService notificationService;
|
|
115
|
|
- private final DraftWorkItemService draftWorkItemService;
|
|
116
|
115
|
private final OrderWorkItemService orderWorkItemService;
|
|
117
|
116
|
private final OrderInvoiceService orderInvoiceService;
|
|
118
|
117
|
|
|
|
@@ -127,7 +126,6 @@ public class SettlementService {
|
|
127
|
126
|
FeServicePlanMapper feServicePlanMapper,
|
|
128
|
127
|
EnterpriseService enterpriseService,
|
|
129
|
128
|
NotificationService notificationService,
|
|
130
|
|
- DraftWorkItemService draftWorkItemService,
|
|
131
|
129
|
OrderWorkItemService orderWorkItemService,
|
|
132
|
130
|
OrderInvoiceService orderInvoiceService) {
|
|
133
|
131
|
this.feSettlementOrderMapper = feSettlementOrderMapper;
|
|
|
@@ -141,7 +139,6 @@ public class SettlementService {
|
|
141
|
139
|
this.feServicePlanMapper = feServicePlanMapper;
|
|
142
|
140
|
this.enterpriseService = enterpriseService;
|
|
143
|
141
|
this.notificationService = notificationService;
|
|
144
|
|
- this.draftWorkItemService = draftWorkItemService;
|
|
145
|
142
|
this.orderWorkItemService = orderWorkItemService;
|
|
146
|
143
|
this.orderInvoiceService = orderInvoiceService;
|
|
147
|
144
|
}
|
|
|
@@ -205,7 +202,11 @@ public class SettlementService {
|
|
205
|
202
|
throw new BizException(ErrorCode.BAD_REQUEST, "无可结算工人(须登记已完成签署)");
|
|
206
|
203
|
}
|
|
207
|
204
|
|
|
208
|
|
- AmountBreakdown amounts = calculateAmounts(order, workers.size());
|
|
|
205
|
+ List<FeOrderWorkItem> orderItems = orderWorkItemService.listByOrderId(order.getId());
|
|
|
206
|
+ if (orderItems == null || orderItems.isEmpty()) {
|
|
|
207
|
+ throw new BizException(ErrorCode.BAD_REQUEST, "订单无用工明细,无法按工种结算");
|
|
|
208
|
+ }
|
|
|
209
|
+ AmountBreakdown amounts = calculateAmountsByJobType(order, workers, orderItems);
|
|
209
|
210
|
LocalDateTime now = LocalDateTime.now();
|
|
210
|
211
|
// 多批次时取首个可结算工人的批次作为主批次引用
|
|
211
|
212
|
Long batchId = workers.get(0).getBatchId();
|
|
|
@@ -225,28 +226,24 @@ public class SettlementService {
|
|
225
|
226
|
settlement.setTotalOutflow(amounts.totalOutflow);
|
|
226
|
227
|
settlement.setSettlementStatus(STATUS_PENDING);
|
|
227
|
228
|
settlement.setComplianceTags("[\"合同已签署\",\"待企业确认结算\"]");
|
|
228
|
|
- settlement.setComplianceRemark("MVP 按约定天数与日薪/方案快照生成,待企业确认后支付");
|
|
|
229
|
+ settlement.setComplianceRemark("按订单工种 unit_amount×work_days 生成明细,待企业确认后支付");
|
|
229
|
230
|
settlement.setCreateTime(now);
|
|
230
|
231
|
settlement.setUpdateTime(now);
|
|
231
|
232
|
settlement.setDelFlag(0);
|
|
232
|
233
|
feSettlementOrderMapper.insert(settlement);
|
|
233
|
234
|
|
|
234
|
|
- // 2) 按人明细(姓名脱敏;MVP 个税/其他扣款为 0)
|
|
235
|
|
- BigDecimal workDays = orderWorkItemService.primaryWorkDays(orderWorkItemService.listByOrderId(order.getId()));
|
|
236
|
|
- if (workDays == null) {
|
|
237
|
|
- workDays = BigDecimal.ONE;
|
|
238
|
|
- }
|
|
239
|
|
- for (FeWorkerRegistration reg : workers) {
|
|
|
235
|
+ // 2) 按人明细:匹配登记工种 → 订单用工明细计算应发
|
|
|
236
|
+ for (WorkerLine line : amounts.lines) {
|
|
240
|
237
|
FeSettlementItem item = new FeSettlementItem();
|
|
241
|
238
|
item.setSettlementId(settlement.getId());
|
|
242
|
|
- item.setRegistrationId(reg.getId());
|
|
243
|
|
- item.setWorkerId(reg.getWorkerId());
|
|
244
|
|
- item.setWorkerNameMask(maskName(reg.getRealName()));
|
|
245
|
|
- item.setWorkDays(workDays);
|
|
246
|
|
- item.setGrossAmount(amounts.perWorkerGross);
|
|
|
239
|
+ item.setRegistrationId(line.registration.getId());
|
|
|
240
|
+ item.setWorkerId(line.registration.getWorkerId());
|
|
|
241
|
+ item.setWorkerNameMask(maskName(line.registration.getRealName()));
|
|
|
242
|
+ item.setWorkDays(line.workDays);
|
|
|
243
|
+ item.setGrossAmount(line.gross);
|
|
247
|
244
|
item.setTaxAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
|
|
248
|
245
|
item.setOtherDeduction(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
|
|
249
|
|
- item.setNetAmount(amounts.perWorkerNet);
|
|
|
246
|
+ item.setNetAmount(line.net);
|
|
250
|
247
|
item.setItemStatus(STATUS_PENDING);
|
|
251
|
248
|
item.setCreateTime(now);
|
|
252
|
249
|
item.setUpdateTime(now);
|
|
|
@@ -561,45 +558,55 @@ public class SettlementService {
|
|
561
|
558
|
}
|
|
562
|
559
|
|
|
563
|
560
|
/**
|
|
564
|
|
- * MVP 金额估算(无考勤):
|
|
|
561
|
+ * 按工人确认工种匹配订单用工明细计算结算金额:
|
|
565
|
562
|
* <ol>
|
|
566
|
|
- * <li>人均应发:优先方案成本快照人均到手,否则草稿日薪 × 工作天数</li>
|
|
567
|
|
- * <li>服务费:方案 {@code serviceFeeRate × 应发总额}</li>
|
|
568
|
|
- * <li>企业总支出:按快照/订单总支出 × 实际人数/计划人数折算;差额记 other_fee</li>
|
|
|
563
|
+ * <li>每人应发 = 对应工种 {@code unit_amount × work_days}</li>
|
|
|
564
|
+ * <li>应发总额 = 各人应发合计</li>
|
|
|
565
|
+ * <li>服务费 = 方案费率 × 应发总额(若有)</li>
|
|
|
566
|
+ * <li>企业总支出:优先按工种明细 total_amount 折算;否则 应发+服务费</li>
|
|
569
|
567
|
* </ol>
|
|
570
|
568
|
*/
|
|
571
|
|
- private AmountBreakdown calculateAmounts(FeEmploymentOrder order, int workerCount) {
|
|
572
|
|
- List<FeOrderWorkItem> orderItems = orderWorkItemService.listByOrderId(order.getId());
|
|
573
|
|
- int plannedCount = orderWorkItemService.sumWorkerCount(orderItems);
|
|
574
|
|
- BigDecimal workDays = orderWorkItemService.primaryWorkDays(orderItems);
|
|
575
|
|
- if (workDays == null || workDays.compareTo(BigDecimal.ZERO) <= 0) {
|
|
576
|
|
- workDays = BigDecimal.ONE;
|
|
577
|
|
- }
|
|
578
|
|
- BigDecimal dailyWage = BigDecimal.ZERO;
|
|
579
|
|
- FeOrderWorkItem firstOrderItem = orderWorkItemService.firstItem(orderItems);
|
|
580
|
|
- if (firstOrderItem != null && firstOrderItem.getUnitAmount() != null) {
|
|
581
|
|
- dailyWage = firstOrderItem.getUnitAmount();
|
|
582
|
|
- } else if (order.getDraftId() != null) {
|
|
583
|
|
- List<FeDraftWorkItem> items = draftWorkItemService.listByDraftId(order.getDraftId());
|
|
584
|
|
- if (!items.isEmpty() && items.get(0).getUnitAmount() != null) {
|
|
585
|
|
- dailyWage = items.get(0).getUnitAmount();
|
|
|
569
|
+ private AmountBreakdown calculateAmountsByJobType(FeEmploymentOrder order,
|
|
|
570
|
+ List<FeWorkerRegistration> workers,
|
|
|
571
|
+ List<FeOrderWorkItem> orderItems) {
|
|
|
572
|
+ Map<String, FeOrderWorkItem> jobTypeMap = new HashMap<>();
|
|
|
573
|
+ for (FeOrderWorkItem wi : orderItems) {
|
|
|
574
|
+ if (wi != null && StringUtils.hasText(wi.getJobType())) {
|
|
|
575
|
+ jobTypeMap.putIfAbsent(wi.getJobType().trim(), wi);
|
|
586
|
576
|
}
|
|
587
|
577
|
}
|
|
|
578
|
+ FeOrderWorkItem fallback = orderWorkItemService.firstItem(orderItems);
|
|
588
|
579
|
|
|
589
|
|
- // 默认:日薪 × 天数
|
|
590
|
|
- BigDecimal perGross = dailyWage.multiply(workDays).setScale(2, RoundingMode.HALF_UP);
|
|
591
|
|
- BigDecimal serviceFeeRate = null;
|
|
592
|
|
- FeDraftCostSnapshot snapshot = findSelectedSnapshot(order);
|
|
593
|
|
- // 有快照则覆盖为方案测算的人均到手
|
|
594
|
|
- if (snapshot != null && snapshot.getWorkerTakeHomePerWorker() != null
|
|
595
|
|
- && snapshot.getWorkerTakeHomePerWorker().compareTo(BigDecimal.ZERO) > 0) {
|
|
596
|
|
- perGross = snapshot.getWorkerTakeHomePerWorker().setScale(2, RoundingMode.HALF_UP);
|
|
|
580
|
+ List<WorkerLine> lines = new ArrayList<>();
|
|
|
581
|
+ BigDecimal gross = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
|
|
582
|
+ for (FeWorkerRegistration reg : workers) {
|
|
|
583
|
+ FeOrderWorkItem wi = resolveWorkItemForRegistration(reg, jobTypeMap, fallback);
|
|
|
584
|
+ BigDecimal workDays = wi.getWorkDays() != null && wi.getWorkDays().compareTo(BigDecimal.ZERO) > 0
|
|
|
585
|
+ ? wi.getWorkDays() : BigDecimal.ONE;
|
|
|
586
|
+ BigDecimal unitAmount = wi.getUnitAmount() != null ? wi.getUnitAmount() : BigDecimal.ZERO;
|
|
|
587
|
+ BigDecimal perGross = unitAmount.multiply(workDays).setScale(2, RoundingMode.HALF_UP);
|
|
|
588
|
+ // 若明细已有 total_amount 且配置了人数,优先用「组内人均」作为该工种单价口径兜底为 0 时的回退
|
|
|
589
|
+ if (perGross.compareTo(BigDecimal.ZERO) <= 0
|
|
|
590
|
+ && wi.getTotalAmount() != null
|
|
|
591
|
+ && wi.getWorkerCount() != null
|
|
|
592
|
+ && wi.getWorkerCount() > 0) {
|
|
|
593
|
+ perGross = wi.getTotalAmount()
|
|
|
594
|
+ .divide(BigDecimal.valueOf(wi.getWorkerCount()), 2, RoundingMode.HALF_UP);
|
|
|
595
|
+ }
|
|
|
596
|
+
|
|
|
597
|
+ WorkerLine line = new WorkerLine();
|
|
|
598
|
+ line.registration = reg;
|
|
|
599
|
+ line.jobType = wi.getJobType();
|
|
|
600
|
+ line.workDays = workDays;
|
|
|
601
|
+ line.unitAmount = unitAmount;
|
|
|
602
|
+ line.gross = perGross;
|
|
|
603
|
+ line.net = perGross; // MVP:实发=应发
|
|
|
604
|
+ lines.add(line);
|
|
|
605
|
+ gross = gross.add(perGross);
|
|
597
|
606
|
}
|
|
598
|
607
|
|
|
599
|
|
- BigDecimal gross = perGross.multiply(BigDecimal.valueOf(workerCount)).setScale(2, RoundingMode.HALF_UP);
|
|
|
608
|
+ BigDecimal serviceFeeRate = null;
|
|
600
|
609
|
BigDecimal serviceFee = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
|
601
|
|
- BigDecimal otherFee = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
|
602
|
|
-
|
|
603
|
610
|
if (order.getSelectedPlanId() != null) {
|
|
604
|
611
|
FeServicePlan plan = feServicePlanMapper.selectById(order.getSelectedPlanId());
|
|
605
|
612
|
if (plan != null && plan.getServiceFeeRate() != null
|
|
|
@@ -609,8 +616,11 @@ public class SettlementService {
|
|
609
|
616
|
}
|
|
610
|
617
|
}
|
|
611
|
618
|
|
|
612
|
|
- // 总支出:按实际结算人数相对计划人数比例折算(缺编开工场景)
|
|
|
619
|
+ int workerCount = workers.size();
|
|
|
620
|
+ int plannedCount = orderWorkItemService.sumWorkerCount(orderItems);
|
|
|
621
|
+ BigDecimal otherFee = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
|
613
|
622
|
BigDecimal totalOutflow;
|
|
|
623
|
+ FeDraftCostSnapshot snapshot = findSelectedSnapshot(order);
|
|
614
|
624
|
BigDecimal plannedTotal = orderWorkItemService.sumTotalAmount(orderItems);
|
|
615
|
625
|
if (snapshot != null && snapshot.getEmployerTotalOutflow() != null && plannedCount > 0) {
|
|
616
|
626
|
totalOutflow = snapshot.getEmployerTotalOutflow()
|
|
|
@@ -631,8 +641,7 @@ public class SettlementService {
|
|
631
|
641
|
}
|
|
632
|
642
|
|
|
633
|
643
|
AmountBreakdown breakdown = new AmountBreakdown();
|
|
634
|
|
- breakdown.perWorkerGross = perGross;
|
|
635
|
|
- breakdown.perWorkerNet = perGross; // MVP:实发=应发,暂不计个税
|
|
|
644
|
+ breakdown.lines = lines;
|
|
636
|
645
|
breakdown.gross = gross;
|
|
637
|
646
|
breakdown.deduction = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
|
638
|
647
|
breakdown.net = gross;
|
|
|
@@ -644,6 +653,26 @@ public class SettlementService {
|
|
644
|
653
|
return breakdown;
|
|
645
|
654
|
}
|
|
646
|
655
|
|
|
|
656
|
+ /** 按登记 confirmed_work_type 精确匹配订单工种;无工种时回退首条明细。 */
|
|
|
657
|
+ private static FeOrderWorkItem resolveWorkItemForRegistration(FeWorkerRegistration reg,
|
|
|
658
|
+ Map<String, FeOrderWorkItem> jobTypeMap,
|
|
|
659
|
+ FeOrderWorkItem fallback) {
|
|
|
660
|
+ if (reg != null && StringUtils.hasText(reg.getConfirmedWorkType())) {
|
|
|
661
|
+ FeOrderWorkItem matched = jobTypeMap.get(reg.getConfirmedWorkType().trim());
|
|
|
662
|
+ if (matched != null) {
|
|
|
663
|
+ return matched;
|
|
|
664
|
+ }
|
|
|
665
|
+ throw new BizException(ErrorCode.BAD_REQUEST,
|
|
|
666
|
+ "工人「" + (reg.getRealName() != null ? reg.getRealName() : reg.getId())
|
|
|
667
|
+ + "」确认工种「" + reg.getConfirmedWorkType().trim()
|
|
|
668
|
+ + "」不在订单用工明细中,无法结算");
|
|
|
669
|
+ }
|
|
|
670
|
+ if (fallback == null) {
|
|
|
671
|
+ throw new BizException(ErrorCode.BAD_REQUEST, "订单无用工明细,无法结算");
|
|
|
672
|
+ }
|
|
|
673
|
+ return fallback;
|
|
|
674
|
+ }
|
|
|
675
|
+
|
|
647
|
676
|
/** 取订单所选方案对应的草稿成本快照。 */
|
|
648
|
677
|
private FeDraftCostSnapshot findSelectedSnapshot(FeEmploymentOrder order) {
|
|
649
|
678
|
if (order.getDraftId() == null || order.getSelectedPlanId() == null) {
|
|
|
@@ -905,12 +934,20 @@ public class SettlementService {
|
|
905
|
934
|
return curIdx >= 0 && targetIdx >= 0 && curIdx > targetIdx;
|
|
906
|
935
|
}
|
|
907
|
936
|
|
|
|
937
|
+ /** 单人按工种计算的结算行。 */
|
|
|
938
|
+ private static final class WorkerLine {
|
|
|
939
|
+ private FeWorkerRegistration registration;
|
|
|
940
|
+ private String jobType;
|
|
|
941
|
+ private BigDecimal workDays;
|
|
|
942
|
+ private BigDecimal unitAmount;
|
|
|
943
|
+ private BigDecimal gross;
|
|
|
944
|
+ private BigDecimal net;
|
|
|
945
|
+ }
|
|
|
946
|
+
|
|
908
|
947
|
/** 结算金额中间结果。 */
|
|
909
|
948
|
private static final class AmountBreakdown {
|
|
910
|
|
- /** 人均应发 */
|
|
911
|
|
- private BigDecimal perWorkerGross;
|
|
912
|
|
- /** 人均实发 */
|
|
913
|
|
- private BigDecimal perWorkerNet;
|
|
|
949
|
+ /** 按人明细行 */
|
|
|
950
|
+ private List<WorkerLine> lines;
|
|
914
|
951
|
/** 应发总额 */
|
|
915
|
952
|
private BigDecimal gross;
|
|
916
|
953
|
/** 扣款合计 */
|