package cn.iocoder.yudao.module.mes.service.pro.wage;
|
|
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.util.ObjectUtil;
|
import cn.hutool.core.util.StrUtil;
|
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
import cn.iocoder.yudao.module.mes.controller.admin.pro.wage.vo.MesProProcessWageRuleResolveRespVO;
|
import cn.iocoder.yudao.module.mes.controller.admin.pro.wage.vo.MesProWageQualityRuleResolveRespVO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackAuxDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.process.MesProProcessDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.wage.MesProProcessWageFieldDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.wage.MesProWageQualityRuleDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.worktime.MesProWorktimeAuxDO;
|
import cn.iocoder.yudao.module.mes.enums.pro.MesProProcessBillingTypeEnum;
|
import cn.iocoder.yudao.module.mes.service.pro.worktime.MesProWorktimeAuxService;
|
import jakarta.annotation.Resource;
|
import org.springframework.stereotype.Component;
|
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
import java.time.LocalDate;
|
import java.util.ArrayList;
|
import java.util.Collections;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
|
|
/**
|
* MES 报工工资计算器
|
*
|
* <p>口径:
|
* <ul>
|
* <li>主工资:计时 = 工时 × 单价;计件 = 合格数量(无合格数量时取报工数量)× 单价</li>
|
* <li>辅助工资:辅助明细分钟数 ÷ 60 × 辅助小时单价</li>
|
* <li>字段调整:系数字段连乘后作用于(主工资 + 辅助工资),再叠加加减金额与计价数量</li>
|
* <li>质量调整:按过程检验合格率命中质量规则,系数连乘或按不合格数量扣款</li>
|
* </ul>
|
* 本类只做计算,不写库;报工单落库由 Service 负责。
|
*
|
* @author 超级管理员
|
*/
|
@Component
|
public class MesProFeedbackWageCalculator {
|
|
private static final BigDecimal SIXTY = BigDecimal.valueOf(60);
|
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
|
@Resource
|
private MesProProcessWageRuleService wageRuleService;
|
@Resource
|
private MesProProcessWageFieldService wageFieldService;
|
@Resource
|
private MesProWageQualityRuleService qualityRuleService;
|
@Resource
|
private MesProWorktimeAuxService worktimeAuxService;
|
|
/**
|
* 按工序当前配置计算工资
|
*
|
* @param process 工序
|
* @param routeId 工艺路线(生产任务绑定)
|
* @param machineryId 本次报工主计价设备
|
* @param workHours 报工工时
|
* @param feedbackQuantity 报工数量
|
* @param qualifiedQuantity 合格数量,空或 0 时计件按报工数量
|
* @param auxList 辅助工时明细(由标准工时服务计算)
|
* @param date 计价日期,空取当天
|
* @return 工资拆分
|
*/
|
public MesProFeedbackWageBreakdown calculate(MesProProcessDO process, Long routeId, Long machineryId,
|
BigDecimal workHours, BigDecimal feedbackQuantity,
|
BigDecimal qualifiedQuantity,
|
List<MesProFeedbackAuxDO> auxList, LocalDate date) {
|
return calculate(process, routeId, machineryId, workHours, feedbackQuantity, qualifiedQuantity,
|
auxList, date, Collections.emptyMap());
|
}
|
|
/**
|
* 按工序当前配置计算工资,并用过程检验单上质检填写的值覆盖工资字段
|
*
|
* @param fieldValues 工资字段编码 → 质检填写的值;空值回退工序上的固定值
|
*/
|
public MesProFeedbackWageBreakdown calculate(MesProProcessDO process, Long routeId, Long machineryId,
|
BigDecimal workHours, BigDecimal feedbackQuantity,
|
BigDecimal qualifiedQuantity,
|
List<MesProFeedbackAuxDO> auxList, LocalDate date,
|
Map<String, String> fieldValues) {
|
MesProProcessBillingTypeEnum billingType = MesProProcessBillingTypeEnum.valueOfType(process.getBillingType());
|
if (billingType == null) {
|
throw exception(PRO_PROCESS_BILLING_TYPE_INVALID, process.getName(), process.getBillingType());
|
}
|
MesProProcessWageRuleResolveRespVO resolved = wageRuleService.resolve(
|
process.getId(), routeId, machineryId, process.getBillingType(), date);
|
BigDecimal wageRate = ObjectUtil.defaultIfNull(resolved.getWageRate(), BigDecimal.ZERO);
|
|
MesProFeedbackWageBreakdown breakdown = new MesProFeedbackWageBreakdown()
|
.setBillingType(billingType.getType())
|
.setRouteId(routeId)
|
.setMachineryId(machineryId)
|
.setWageRate(wageRate)
|
.setWageRuleId(resolved.getRuleId())
|
.setMatchLevel(resolved.getMatchLevel());
|
if (wageRate.signum() <= 0) {
|
breakdown.getWarnings().add(StrUtil.format(
|
"工序[{}]未配置工资单价,本次报工工资按 0 元计算;请到「工序管理-单价规则」维护后重试",
|
process.getName()));
|
}
|
|
// 1. 主工资
|
applyMainWage(breakdown, process, billingType, workHours, feedbackQuantity, qualifiedQuantity);
|
// 2. 辅助工资
|
applyAuxWage(breakdown, process, auxList);
|
// 3. 工资字段调整(质检填写的值优先)
|
applyWageFields(breakdown, process, fieldValues);
|
// 4. 质量调整前工资
|
breakdown.setPreQualityWage(computePreQualityWage(breakdown));
|
breakdown.setFinalWage(breakdown.getPreQualityWage());
|
return breakdown;
|
}
|
|
/**
|
* 按报工单快照重建计算明细(审批重算用)
|
*
|
* @param feedback 报工单
|
* @param qualifiedQuantity 质检合格数量,空或 0 时计件按报工数量
|
* @return 工资拆分
|
*/
|
public MesProFeedbackWageBreakdown fromSnapshot(MesProFeedbackDO feedback, BigDecimal qualifiedQuantity) {
|
return fromSnapshot(feedback, qualifiedQuantity, Collections.emptyMap());
|
}
|
|
/**
|
* 按报工单快照重建计算明细,并用过程检验单上质检填写的值覆盖工资字段
|
*
|
* @param fieldValues 工资字段编码 → 质检填写的值;空值沿用快照值
|
*/
|
public MesProFeedbackWageBreakdown fromSnapshot(MesProFeedbackDO feedback, BigDecimal qualifiedQuantity,
|
Map<String, String> fieldValues) {
|
MesProFeedbackWageBreakdown breakdown = StrUtil.isNotBlank(feedback.getWageBreakdown())
|
? JsonUtils.parseObject(feedback.getWageBreakdown(), MesProFeedbackWageBreakdown.class) : null;
|
if (breakdown == null) {
|
breakdown = new MesProFeedbackWageBreakdown();
|
}
|
if (breakdown.getWarnings() == null) {
|
breakdown.setWarnings(new ArrayList<>());
|
}
|
if (breakdown.getFields() == null) {
|
breakdown.setFields(new ArrayList<>());
|
}
|
// 以报工单落库值为准,避免快照字段缺失
|
breakdown.setBillingType(feedback.getBillingType())
|
.setWageRate(ObjectUtil.defaultIfNull(feedback.getWageRate(), BigDecimal.ZERO))
|
.setWageRuleId(feedback.getWageRuleId())
|
.setRouteId(feedback.getRouteId())
|
.setMachineryId(feedback.getMachineryId());
|
|
MesProProcessBillingTypeEnum billingType = MesProProcessBillingTypeEnum.valueOfType(breakdown.getBillingType());
|
String processName = "ID=" + feedback.getProcessId();
|
applyMainWage(breakdown, processName, billingType, feedback.getWorkHours(),
|
feedback.getFeedbackQuantity(), qualifiedQuantity);
|
// 辅助工资沿用提交时快照
|
breakdown.setAuxPaidHours(ObjectUtil.defaultIfNull(feedback.getAuxPaidHours(), BigDecimal.ZERO));
|
breakdown.setAuxWage(ObjectUtil.defaultIfNull(feedback.getAuxWageAmount(), BigDecimal.ZERO));
|
applyWageFields(breakdown, null, fieldValues);
|
breakdown.setPreQualityWage(computePreQualityWage(breakdown));
|
breakdown.setQuality(null);
|
breakdown.setFinalWage(breakdown.getPreQualityWage());
|
return breakdown;
|
}
|
|
/**
|
* 应用质量调整
|
*
|
* @param breakdown 工资拆分
|
* @param processId 工序编号
|
* @param routeId 工艺路线编号
|
* @param passRate 合格率(%)
|
* @param unqualifiedQuantity 不合格数量
|
*/
|
public void applyQuality(MesProFeedbackWageBreakdown breakdown, Long processId, Long routeId,
|
BigDecimal passRate, BigDecimal unqualifiedQuantity) {
|
BigDecimal preQuality = computePreQualityWage(breakdown);
|
breakdown.setPreQualityWage(preQuality);
|
MesProFeedbackWageBreakdown.Quality quality = null;
|
BigDecimal finalWage = preQuality;
|
if (passRate != null) {
|
MesProWageQualityRuleResolveRespVO rule = qualityRuleService.resolve(processId, routeId, passRate);
|
if (rule.getRuleId() != null) {
|
quality = new MesProFeedbackWageBreakdown.Quality().setPassRate(passRate);
|
if (Objects.equals(rule.getRuleType(), MesProWageQualityRuleDO.RULE_TYPE_COEFFICIENT)
|
&& rule.getCoefficient() != null) {
|
quality.setCoefficient(rule.getCoefficient());
|
finalWage = preQuality.multiply(rule.getCoefficient());
|
} else if (Objects.equals(rule.getRuleType(), MesProWageQualityRuleDO.RULE_TYPE_DEDUCTION)
|
&& rule.getDeductionPerUnit() != null) {
|
BigDecimal unqualified = ObjectUtil.defaultIfNull(unqualifiedQuantity, BigDecimal.ZERO);
|
BigDecimal deduction = unqualified.multiply(rule.getDeductionPerUnit())
|
.setScale(2, RoundingMode.HALF_UP);
|
quality.setDeduction(deduction);
|
finalWage = preQuality.subtract(deduction);
|
}
|
quality.setPassRate(passRate);
|
} else {
|
breakdown.getWarnings().add(StrUtil.format(
|
"工序合格率[{}%]未命中质量工资规则,本次不做质量调整;如需调整请在「工序管理-质量规则」配置区间",
|
passRate.setScale(2, RoundingMode.HALF_UP)));
|
}
|
}
|
if (finalWage.signum() < 0) {
|
breakdown.getWarnings().add("质量调整后工资小于 0,已按 0 元计算;请核对质量规则或不合格数量");
|
finalWage = BigDecimal.ZERO;
|
}
|
breakdown.setQuality(quality);
|
breakdown.setFinalWage(finalWage.setScale(2, RoundingMode.HALF_UP));
|
}
|
|
/**
|
* 计算主工资
|
*/
|
private void applyMainWage(MesProFeedbackWageBreakdown breakdown, MesProProcessDO process,
|
MesProProcessBillingTypeEnum billingType, BigDecimal workHours,
|
BigDecimal feedbackQuantity, BigDecimal qualifiedQuantity) {
|
applyMainWage(breakdown, process.getName(), billingType, workHours, feedbackQuantity, qualifiedQuantity);
|
}
|
|
private void applyMainWage(MesProFeedbackWageBreakdown breakdown, String processName,
|
MesProProcessBillingTypeEnum billingType, BigDecimal workHours,
|
BigDecimal feedbackQuantity, BigDecimal qualifiedQuantity) {
|
if (billingType == null) {
|
throw exception(PRO_PROCESS_BILLING_TYPE_INVALID, processName, breakdown.getBillingType());
|
}
|
BigDecimal wageRate = ObjectUtil.defaultIfNull(breakdown.getWageRate(), BigDecimal.ZERO);
|
if (MesProProcessBillingTypeEnum.TIME == billingType) {
|
BigDecimal hours = ObjectUtil.defaultIfNull(workHours, BigDecimal.ZERO);
|
if (wageRate.signum() > 0 && hours.signum() <= 0) {
|
throw exception(PRO_FEEDBACK_WORK_HOURS_REQUIRED, processName);
|
}
|
hours = hours.setScale(2, RoundingMode.HALF_UP);
|
breakdown.setWorkHours(hours).setPieceQuantity(null)
|
.setMainWage(hours.multiply(wageRate).setScale(2, RoundingMode.HALF_UP));
|
return;
|
}
|
// 计件:工时字段不参与计薪,统一清 0;计价数量按合格数量优先
|
BigDecimal pieceQuantity = ObjectUtil.defaultIfNull(qualifiedQuantity, BigDecimal.ZERO).signum() > 0
|
? qualifiedQuantity : ObjectUtil.defaultIfNull(feedbackQuantity, BigDecimal.ZERO);
|
breakdown.setWorkHours(BigDecimal.ZERO).setPieceQuantity(pieceQuantity)
|
.setMainWage(pieceQuantity.multiply(wageRate).setScale(2, RoundingMode.HALF_UP));
|
}
|
|
/**
|
* 辅助工资:按辅助项计薪方式取单价,工序辅助小时工资兜底
|
*/
|
private void applyAuxWage(MesProFeedbackWageBreakdown breakdown, MesProProcessDO process,
|
List<MesProFeedbackAuxDO> auxList) {
|
breakdown.setAuxPaidHours(BigDecimal.ZERO).setAuxWage(BigDecimal.ZERO);
|
if (CollUtil.isEmpty(auxList)) {
|
return;
|
}
|
BigDecimal paidMinutes = BigDecimal.ZERO;
|
BigDecimal auxWage = BigDecimal.ZERO;
|
for (MesProFeedbackAuxDO aux : auxList) {
|
BigDecimal minutes = ObjectUtil.defaultIfNull(aux.getMinutes(), BigDecimal.ZERO);
|
if (minutes.signum() <= 0) {
|
continue;
|
}
|
MesProWorktimeAuxDO auxItem = aux.getAuxItemId() != null
|
? worktimeAuxService.getWorktimeAux(aux.getAuxItemId()) : null;
|
Integer wageMode = auxItem != null ? auxItem.getWageMode() : null;
|
if (wageMode == null) {
|
// 历史辅助项未配置计薪方式:不参与工资,保持原行为
|
continue;
|
}
|
if (Objects.equals(wageMode, MesProWorktimeAuxDO.WAGE_MODE_NONE)) {
|
continue;
|
}
|
BigDecimal hourlyRate = null;
|
if (Objects.equals(wageMode, MesProWorktimeAuxDO.WAGE_MODE_ITEM)) {
|
hourlyRate = auxItem.getWageRate();
|
} else if (Objects.equals(wageMode, MesProWorktimeAuxDO.WAGE_MODE_PROCESS)) {
|
hourlyRate = process.getAuxHourlyRate();
|
if ((hourlyRate == null || hourlyRate.signum() <= 0)
|
&& Objects.equals(process.getBillingType(), MesProProcessBillingTypeEnum.TIME.getType())) {
|
// 计时工序可回退到工序工资单价(同为元/小时)
|
hourlyRate = process.getWageRate();
|
}
|
}
|
if (hourlyRate == null || hourlyRate.signum() <= 0) {
|
breakdown.getWarnings().add(StrUtil.format(
|
"辅助项[{}]未配置计薪单价,工序[{}]也未配置辅助小时工资,本次不计辅助工资;"
|
+ "请到「辅助工时配置」或「工序管理」补充单价",
|
aux.getItemName() != null ? aux.getItemName() : "ID=" + aux.getAuxItemId(),
|
process.getName()));
|
continue;
|
}
|
paidMinutes = paidMinutes.add(minutes);
|
auxWage = auxWage.add(minutes.multiply(hourlyRate).divide(SIXTY, 4, RoundingMode.HALF_UP));
|
}
|
breakdown.setAuxPaidHours(paidMinutes.divide(SIXTY, 2, RoundingMode.HALF_UP))
|
.setAuxWage(auxWage.setScale(2, RoundingMode.HALF_UP));
|
}
|
|
/**
|
* 工资字段调整
|
*
|
* @param process 工序;为空时使用快照中的字段(审批重算)
|
*/
|
private void applyWageFields(MesProFeedbackWageBreakdown breakdown, MesProProcessDO process,
|
Map<String, String> fieldValues) {
|
List<MesProFeedbackWageBreakdown.FieldSnapshot> snapshots;
|
if (process != null) {
|
snapshots = new ArrayList<>();
|
for (MesProProcessWageFieldDO field : wageFieldService.getFieldList(process.getId())) {
|
if (!Objects.equals(field.getStatus(), CommonStatusEnum.ENABLE.getStatus())) {
|
continue;
|
}
|
snapshots.add(new MesProFeedbackWageBreakdown.FieldSnapshot()
|
.setCode(field.getFieldCode())
|
.setName(field.getFieldName())
|
.setRole(field.getCalcRole())
|
.setValue(field.getFieldValue())
|
.setUnitPrice(field.getUnitPrice()));
|
}
|
breakdown.setFields(snapshots);
|
} else {
|
snapshots = breakdown.getFields() != null ? breakdown.getFields() : new ArrayList<>();
|
}
|
// 质检在过程检验单上填写的值优先于工序固定值
|
if (CollUtil.isNotEmpty(fieldValues)) {
|
for (MesProFeedbackWageBreakdown.FieldSnapshot snapshot : snapshots) {
|
String value = fieldValues.get(snapshot.getCode());
|
if (StrUtil.isNotBlank(value)) {
|
snapshot.setValue(value);
|
}
|
}
|
}
|
BigDecimal base = ObjectUtil.defaultIfNull(breakdown.getMainWage(), BigDecimal.ZERO)
|
.add(ObjectUtil.defaultIfNull(breakdown.getAuxWage(), BigDecimal.ZERO));
|
BigDecimal coefficientProduct = BigDecimal.ONE;
|
BigDecimal amountSum = BigDecimal.ZERO;
|
for (MesProFeedbackWageBreakdown.FieldSnapshot snapshot : snapshots) {
|
Integer role = snapshot.getRole();
|
String value = snapshot.getValue();
|
if (Objects.equals(role, MesProProcessWageFieldDO.CALC_ROLE_COEFFICIENT)) {
|
BigDecimal coefficient = parseDecimal(value);
|
if (coefficient == null || coefficient.signum() <= 0) {
|
coefficient = BigDecimal.ONE;
|
}
|
coefficientProduct = coefficientProduct.multiply(coefficient);
|
snapshot.setAmount(base.multiply(coefficient.subtract(BigDecimal.ONE))
|
.setScale(2, RoundingMode.HALF_UP));
|
} else if (Objects.equals(role, MesProProcessWageFieldDO.CALC_ROLE_AMOUNT)) {
|
BigDecimal amount = ObjectUtil.defaultIfNull(parseDecimal(value), BigDecimal.ZERO);
|
amountSum = amountSum.add(amount);
|
snapshot.setAmount(amount.setScale(2, RoundingMode.HALF_UP));
|
} else if (Objects.equals(role, MesProProcessWageFieldDO.CALC_ROLE_QUANTITY_PRICE)) {
|
BigDecimal quantity = ObjectUtil.defaultIfNull(parseDecimal(value), BigDecimal.ZERO);
|
BigDecimal unitPrice = ObjectUtil.defaultIfNull(snapshot.getUnitPrice(), BigDecimal.ZERO);
|
BigDecimal amount = quantity.multiply(unitPrice);
|
amountSum = amountSum.add(amount);
|
snapshot.setAmount(amount.setScale(2, RoundingMode.HALF_UP));
|
} else {
|
snapshot.setAmount(BigDecimal.ZERO);
|
}
|
}
|
breakdown.setFields(snapshots);
|
breakdown.setPreQualityWage(base.multiply(coefficientProduct).add(amountSum)
|
.setScale(2, RoundingMode.HALF_UP));
|
}
|
|
private BigDecimal computePreQualityWage(MesProFeedbackWageBreakdown breakdown) {
|
BigDecimal base = ObjectUtil.defaultIfNull(breakdown.getMainWage(), BigDecimal.ZERO)
|
.add(ObjectUtil.defaultIfNull(breakdown.getAuxWage(), BigDecimal.ZERO));
|
BigDecimal coefficientProduct = BigDecimal.ONE;
|
BigDecimal extra = BigDecimal.ZERO;
|
if (breakdown.getFields() != null) {
|
for (MesProFeedbackWageBreakdown.FieldSnapshot snapshot : breakdown.getFields()) {
|
if (Objects.equals(snapshot.getRole(), MesProProcessWageFieldDO.CALC_ROLE_COEFFICIENT)) {
|
BigDecimal coefficient = parseDecimal(snapshot.getValue());
|
if (coefficient != null && coefficient.signum() > 0) {
|
coefficientProduct = coefficientProduct.multiply(coefficient);
|
}
|
} else if (Objects.equals(snapshot.getRole(), MesProProcessWageFieldDO.CALC_ROLE_AMOUNT)) {
|
extra = extra.add(ObjectUtil.defaultIfNull(parseDecimal(snapshot.getValue()), BigDecimal.ZERO));
|
} else if (Objects.equals(snapshot.getRole(), MesProProcessWageFieldDO.CALC_ROLE_QUANTITY_PRICE)) {
|
extra = extra.add(ObjectUtil.defaultIfNull(parseDecimal(snapshot.getValue()), BigDecimal.ZERO)
|
.multiply(ObjectUtil.defaultIfNull(snapshot.getUnitPrice(), BigDecimal.ZERO)));
|
}
|
}
|
}
|
return base.multiply(coefficientProduct).add(extra).setScale(2, RoundingMode.HALF_UP);
|
}
|
|
private static BigDecimal parseDecimal(String value) {
|
if (StrUtil.isBlank(value)) {
|
return null;
|
}
|
try {
|
return new BigDecimal(value.trim());
|
} catch (NumberFormatException ex) {
|
return null;
|
}
|
}
|
|
}
|