package com.malk.service.workhours;
import com.alibaba.fastjson.JSON;
import com.google.common.util.concurrent.RateLimiter;
import com.malk.server.aliwork.YDConf;
import com.malk.server.aliwork.YDParam;
import com.malk.server.common.McException;
import com.malk.server.dingtalk.DDR_New;
import com.malk.server.workhours.ApprovalWriteBackResult;
import com.malk.server.workhours.WHConf;
import com.malk.service.aliwork.YDClient;
import com.malk.service.aliwork.YDService;
import com.malk.utils.UtilMap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 审批结果回写工时汇总表
*
* 审批结束或撤销后调用,入参为流程实例ID + 审批结果(0=拒绝/撤销,1=同意)。
* 工时审批含两个明细子表(项目/非项目工时),其他工时审批含一个明细子表。
* 明细按「填报人 + 工时日期(天) + 项目编号/Activity」1:1 对应工时汇总表(人+天一条)里的子表行:
* - 拒绝 → 对应子表行「已提交工时」置 0
* - 同意 → 对应子表行「已审批工时」= 该明细工时,且「已提交工时」不得低于已审批工时
* 主表合计按子表行重算(幂等,撤销/重发不翻倍)。
*
* 设计文档:后端/阿科德斯/审批结果回写工时汇总表/02-程序实现与接口.md
*/
@Slf4j
@Service
public class ApprovalWriteBackService {
@Autowired
private YDClient ydClient;
@Autowired
private YDService ydService;
@Autowired
private WHConf whConf;
@Autowired
private RepairApprovalWriteBackService repairApprovalWriteBackService;
private static final int THREAD_POOL_SIZE = 10;
/**
* 明细审批意见「同意」取值(单选,已确认)
*/
private static final String OPINION_APPROVE = "同意";
// ===== 工时汇总表:记录定位键 + 主表合计字段 =====
private static final String SUMMARY_SUBMITTER_ID = "textField_mmbffvd9"; // 填报人ID(文本)
private static final String SUMMARY_DAY_TEXT = "textField_mmbffvdb"; // 工时日期(文本) yyyyMMdd
private static final String M_DAY_SUBMITTED = "numberField_mmd1smem"; // 已提交工时(日)
private static final String M_DAY_APPROVED = "numberField_mmeakgi4"; // 已审批工时(日)
private static final String M_BIL_SUBMITTED = "numberField_mmacxewa"; // Billable已提交
private static final String M_BIL_APPROVED = "numberField_mmacxewg"; // Billable已审批
private static final String M_NON_SUBMITTED = "numberField_mmad6jbz"; // Non Billable已提交
private static final String M_NON_APPROVED = "numberField_mmad6jc1"; // Non Billable已审批
private static final String M_OTH_SUBMITTED = "numberField_mmd1smeq"; // 已提交(其他)
private static final String M_OTH_APPROVED = "numberField_mmd1smes"; // 已审批(其他)
// ===== 工时汇总表:三个子表 =====
private static final String SUB_BIL_TABLE = "tableField_mmczo634";
private static final String SUB_BIL_KEY = "textField_mmacxew3"; // 项目编号
private static final String SUB_BIL_SUBMITTED = "numberField_mmczo636"; // 已提交工时
private static final String SUB_BIL_APPROVED = "numberField_mmczo637"; // 已审批工时
private static final String SUB_NON_TABLE = "tableField_mmczo63h";
private static final String SUB_NON_KEY = "textField_mmczo639"; // 项目编号
private static final String SUB_NON_SUBMITTED = "numberField_mmczo63c";
private static final String SUB_NON_APPROVED = "numberField_mmczo63d";
private static final String SUB_OTH_TABLE = "tableField_mmeakgid";
private static final String SUB_OTH_KEY = "selectField_mmeakgie"; // Activity
private static final String SUB_OTH_SUBMITTED = "numberField_mmeakgij";
private static final String SUB_OTH_APPROVED = "numberField_mmeakgii";
// ===== 审批单明细子表字段(按类别)=====
// 工时审批·项目工时明细(Billable)
private static final String AP_BIL_TABLE = "tableField_mmae8t99";
private static final String AP_BIL_USERID = "textField_mmd1ozk4";
private static final String AP_BIL_KEY = "textField_mmacxew3"; // 项目编号
private static final String AP_BIL_DAYTEXT = "textField_mmd8g654";
private static final String AP_BIL_HOURS = "numberField_mmacxew9";
private static final String AP_BIL_OPINION = "radioField_mpwhk294";
// 工时审批·非项目工时明细(Non Billable)
private static final String AP_NON_TABLE = "tableField_mmd1wu9h";
private static final String AP_NON_USERID = "textField_mmd1wu9f";
private static final String AP_NON_KEY = "textField_mmd1wu9d"; // 项目编号
private static final String AP_NON_DAYTEXT = "textField_mmd8g655";
private static final String AP_NON_HOURS = "numberField_mmd1wu9e";
private static final String AP_NON_OPINION = "radioField_mpwhk295";
// 其他工时审批·明细(与非项目同子表ID,但语义为 Activity)
private static final String AP_OTH_TABLE = "tableField_mmd1wu9h";
private static final String AP_OTH_USERID = "textField_mmd1wu9f";
private static final String AP_OTH_KEY = "selectField_mmeakgie"; // Activity
private static final String AP_OTH_DAYTEXT = "textField_mmd8g655";
private static final String AP_OTH_HOURS = "numberField_mmd1wu9e";
private static final String AP_OTH_OPINION = "radioField_mpwhk294";
private enum Cat {BILLABLE, NON_BILLABLE, OTHER}
/**
* 单条审批明细(归一化)
*/
private static class DetailRow {
Cat cat;
String userId;
String dayText;
String key; // 项目编号 / Activity
double hours;
boolean approve; // 有效意见:true=同意, false=拒绝
}
/**
* 异步触发审批回写,供流程结束自动化调用。
*
* @param formInstanceId 审批表单实例 ID
* @param result 审批结果:0=拒绝/撤销,1=同意
*/
@Async
public void writeBackAsync(String formInstanceId, int result) {
writeBack(formInstanceId, result);
}
/**
* 审批结果回写工时汇总表
*
* @param formInstanceId 流程实例ID
* @param result 审批结果:0=拒绝/撤销,1=同意
* @return 回写结果统计
*/
public ApprovalWriteBackResult writeBack(String formInstanceId, int result) {
McException.assertAccessException(StringUtils.isBlank(formInstanceId), "实例ID不能为空");
// 1. 流程详情接口优先,流程表单可直接返回完整子表;旧回调传表单实例ID时回退表单详情接口。
Map approvalFormData = queryApprovalFormData(formInstanceId);
log.info("[审批回写] 审批单详情 formInstanceId={} keys={}", formInstanceId,
approvalFormData == null ? null : approvalFormData.keySet());
McException.assertAccessException(approvalFormData == null || approvalFormData.isEmpty(),
"未查询到审批单数据,实例ID=" + formInstanceId);
// prd 补填审批只走独立回写:来源日写补填汇总,提交日写原汇总变更工时。
// 正常审批(工时类型为空/正常)继续执行下方原有逻辑。
String fillType = whConf.getApprovalFillTypeField() == null
? "" : String.valueOf(approvalFormData.get(whConf.getApprovalFillTypeField()));
if ("补填".equals(fillType) && repairApprovalWriteBackService != null) {
return repairApprovalWriteBackService.writeBack(formInstanceId, result);
}
// 2. 类型判定:不能只看项目工时子表;仅填非项目工时的工时审批也可能没有 AP_BIL_TABLE。
boolean isOther = isOtherApprovalForm(approvalFormData);
String approvalFormUuid = isOther
? whConf.getFormUuidOtherApproval()
: whConf.getFormUuidApproval();
boolean approveWhole = (result == 1);
// 3. 取审批单明细(内联子表 <50 直接用,==50 才 queryDetails 递归取全,避免无效请求)
List rows = new ArrayList<>();
if (isOther) {
rows.addAll(loadDetailRows(
formInstanceId, approvalFormUuid, approvalFormData, Cat.OTHER, approveWhole));
} else {
rows.addAll(loadDetailRows(
formInstanceId, approvalFormUuid, approvalFormData, Cat.BILLABLE, approveWhole));
rows.addAll(loadDetailRows(
formInstanceId, approvalFormUuid, approvalFormData, Cat.NON_BILLABLE, approveWhole));
}
// 4. 按汇总表记录键(填报人 + 工时日期文本)分组
Map> groups = new LinkedHashMap<>();
int invalidRows = 0;
for (DetailRow r : rows) {
if (StringUtils.isBlank(r.userId) || StringUtils.isBlank(r.dayText)) {
invalidRows++;
continue;
}
groups.computeIfAbsent(r.userId + "" + r.dayText, k -> new ArrayList<>()).add(r);
}
// 5. 并发逐「日记录」处理
AtomicInteger hitRecords = new AtomicInteger(0);
AtomicInteger updatedRows = new AtomicInteger(0);
AtomicInteger missRows = new AtomicInteger(0);
AtomicInteger failRecords = new AtomicInteger(0);
RateLimiter limiter = RateLimiter.create(20.0);
ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
try {
List> futures = new ArrayList<>();
for (Map.Entry> e : groups.entrySet()) {
List groupRows = e.getValue();
String userId = groupRows.get(0).userId;
String dayText = groupRows.get(0).dayText;
futures.add(executor.submit(() -> {
try {
int[] dayResult = applyToRecord(userId, dayText, groupRows, limiter);
if (dayResult == null) {
// fixme 审批明细存在但汇总表日记录缺失属于失败,不能把审批单标记为「全部成功」。
failRecords.incrementAndGet();
return;
}
hitRecords.incrementAndGet();
updatedRows.addAndGet(dayResult[0]);
missRows.addAndGet(dayResult[1]);
if (dayResult[1] > 0) {
failRecords.incrementAndGet();
}
} catch (Exception ex) {
failRecords.incrementAndGet();
log.error("[审批回写] 记录处理失败,交由定时器本轮结束后补试 userId={} day={}",
userId, dayText, ex);
}
}));
}
for (Future> f : futures) {
try {
f.get();
} catch (Exception ex) {
log.error("[审批回写] 线程执行异常", ex);
}
}
} finally {
executor.shutdown();
}
// 6. 同步状态汇总(syncTotal=分组数;failCount=失败组数)
// fixme 状态字面值对齐宜搭审批表 selectField_mq58cd5p 选项:"全部成功" / "部分失败" / "全部失败"
int syncTotal = groups.size() + invalidRows;
int failCount = failRecords.get() + invalidRows;
// fixme 审批明细为空同样不能标记为成功,否则兜底定时会永久跳过该异常实例。
if (syncTotal == 0) {
syncTotal = 1;
failCount = 1;
}
String syncStatus = determineSyncStatus(syncTotal, failCount);
ApprovalWriteBackResult stats = ApprovalWriteBackResult.builder()
.formType(isOther ? "其他工时审批" : "工时审批")
.formInstanceId(formInstanceId)
.result(result)
.detailRows(rows.size())
.groups(groups.size())
.hitRecords(hitRecords.get())
.updatedRows(updatedRows.get())
.missRows(missRows.get())
.failRecords(failCount)
.syncStatus(syncStatus)
.syncTotal(syncTotal)
.failCount(failCount)
.build();
log.info("[审批回写] 完成 {}", stats);
// 7. 延迟 2s 等宜搭审批结束的内部同步索引/锁释放,再回写审批单同步字段,避免触发同步锁失败
sleep(2000);
writebackSyncFields(isOther, formInstanceId, syncStatus, syncTotal, failCount);
return stats;
}
static String determineSyncStatus(int syncTotal, int failCount) {
if (syncTotal <= 0 || failCount >= syncTotal) {
return "全部失败";
}
return failCount > 0 ? "部分失败" : "全部成功";
}
private Map queryApprovalFormData(String instanceId) {
try {
DDR_New processDetail = ydClient.queryData(YDParam.builder()
.appType(whConf.getYidaAppType())
.systemToken(whConf.getYidaSystemToken())
.processInstanceId(instanceId)
.build(), YDConf.FORM_QUERY.retrieve_search_process_id);
Map processFormData = extractProcessFormData(processDetail);
if (processFormData != null && !processFormData.isEmpty()) {
return processFormData;
}
} catch (Exception ex) {
log.warn("[审批回写] 流程详情查询失败,回退表单详情 instanceId={}", instanceId, ex);
}
try {
DDR_New legacyProcessDetail = ydClient.queryData(YDParam.builder()
.appType(whConf.getYidaAppType())
.systemToken(whConf.getYidaSystemToken())
.processInstanceId(instanceId)
.build(), YDConf.FORM_QUERY.retrieve_process_legacy_id);
Map legacyFormData = extractProcessFormData(legacyProcessDetail);
if (legacyFormData != null && !legacyFormData.isEmpty()) {
return legacyFormData;
}
} catch (Exception ex) {
log.warn("[审批回写] 旧版流程详情查询失败,回退表单详情 instanceId={}", instanceId, ex);
}
DDR_New formDetail = ydClient.queryData(YDParam.builder()
.appType(whConf.getYidaAppType())
.systemToken(whConf.getYidaSystemToken())
.formInstanceId(instanceId)
.build(), YDConf.FORM_QUERY.retrieve_id);
return formDetail == null ? null : formDetail.getFormData();
}
@SuppressWarnings("unchecked")
static Map extractProcessFormData(DDR_New processDetail) {
if (processDetail == null) {
return null;
}
if (processDetail.getFormData() != null && !processDetail.getFormData().isEmpty()) {
return processDetail.getFormData();
}
if (processDetail.getResult() instanceof Map) {
Map result = (Map) processDetail.getResult();
Object nested = result.get("formData");
if (!(nested instanceof Map)) {
nested = result.get("data");
}
if (nested instanceof Map) {
return (Map) nested;
}
if (result.containsKey(AP_BIL_TABLE)
|| result.containsKey(AP_NON_TABLE)
|| result.containsKey(AP_OTH_TABLE)) {
return result;
}
}
if (!(processDetail.getData() instanceof Map)) {
return null;
}
Map data = (Map) processDetail.getData();
Object nested = data.get("formData");
if (!(nested instanceof Map)) {
nested = data.get("data");
}
if (nested instanceof Map) {
return (Map) nested;
}
if (data.containsKey(AP_BIL_TABLE)
|| data.containsKey(AP_NON_TABLE)
|| data.containsKey(AP_OTH_TABLE)) {
return data;
}
return null;
}
/**
* 区分「工时审批」与「其他工时审批」。
* 两者共用 tableField_mmd1wu9h,但明细键分别为项目编号和 Activity。
*/
@SuppressWarnings("unchecked")
static boolean isOtherApprovalForm(Map approvalFormData) {
if (approvalFormData == null) {
return false;
}
if (approvalFormData.containsKey(AP_BIL_TABLE)) {
return false;
}
Object sharedRows = approvalFormData.get(AP_NON_TABLE);
if (sharedRows instanceof List) {
for (Object item : (List>) sharedRows) {
if (!(item instanceof Map)) {
continue;
}
Map row = (Map) item;
if (row.containsKey(AP_NON_KEY)) {
return false;
}
if (row.containsKey(AP_OTH_KEY)) {
return true;
}
}
}
return true;
}
/**
* 把同步状态/同步总数/失败数量回写到原审批单(fieldId 走 WHConf 配置)
* fixme 三个 fieldId 任一为空则跳过该类别回写,允许业务方分阶段在宜搭后台建字段
*/
private void writebackSyncFields(boolean isOther, String formInstanceId,
String syncStatus, int syncTotal, int failCount) {
String statusField = isOther ? whConf.getOtherApprovalSyncStatusField() : whConf.getApprovalSyncStatusField();
String totalField = isOther ? whConf.getOtherApprovalSyncTotalField() : whConf.getApprovalSyncTotalField();
String failField = isOther ? whConf.getOtherApprovalSyncFailField() : whConf.getApprovalSyncFailField();
if (StringUtils.isAnyBlank(statusField, totalField, failField)) {
log.info("[审批回写] 跳过审批单同步字段回写(配置未启用) isOther={} formInstanceId={}", isOther, formInstanceId);
return;
}
try {
Map sync = new HashMap<>();
sync.put(statusField, syncStatus);
sync.put(totalField, syncTotal);
sync.put(failField, failCount);
ydClient.operateData(YDParam.builder()
.appType(whConf.getYidaAppType())
.systemToken(whConf.getYidaSystemToken())
.formInstanceId(formInstanceId)
.updateFormDataJson(JSON.toJSONString(sync))
.ignoreEmpty(false)
.useLatestVersion(true)
.build(), YDConf.FORM_OPERATION.update);
log.info("[审批回写] 审批单同步字段已回写 isOther={} formInstanceId={} status={} total={} fail={}",
isOther, formInstanceId, syncStatus, syncTotal, failCount);
} catch (Exception ex) {
log.error("[审批回写] 审批单同步字段回写失败 isOther={} formInstanceId={}", isOther, formInstanceId, ex);
throw new IllegalStateException("审批单同步字段回写失败 formInstanceId=" + formInstanceId, ex);
}
}
/**
* 处理单条汇总表日记录
*
* @return {updatedRows, missRows};汇总表无此记录返回 null
*/
private int[] applyToRecord(String userId, String dayText, List groupRows, RateLimiter limiter) {
// a. 定位汇总表记录(填报人ID + 工时日期文本,代码内精确过滤)
limiter.acquire();
List