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.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.stereotype.Service; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; /** * 审批结果回写工时汇总表 *

* 审批结束或撤销后调用,入参为流程实例ID + 审批结果(0=拒绝/撤销,1=同意)。 * 工时审批含两个明细子表(项目/非项目工时),其他工时审批含一个明细子表。 * 明细按「填报人 + 工时日期(天) + 项目编号/Activity」1:1 对应工时汇总表(人+天一条)里的子表行: * - 拒绝 → 对应子表行「已提交工时」置 0 * - 同意 → 对应子表行「已审批工时」= 该明细工时 * 主表合计按子表行重算(幂等,撤销/重发不翻倍)。 *

* 设计文档:后端/阿科德斯/审批结果回写工时汇总表.md */ @Slf4j @Service public class ApprovalWriteBackService { @Autowired private YDClient ydClient; @Autowired private YDService ydService; @Autowired private WHConf whConf; private static final int THREAD_POOL_SIZE = 10; private static final int MAX_RETRY = 2; /** * 明细审批意见「同意」取值(单选,已确认) */ 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=同意 * @return 回写结果统计 */ public ApprovalWriteBackResult writeBack(String formInstanceId, int result) { McException.assertAccessException(StringUtils.isBlank(formInstanceId), "实例ID不能为空"); // 1. 按实例ID查审批单表单详情(表单接口 retrieve_id),含主表 + 内联子表(子表内联上限 50) Map approvalFormData = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(formInstanceId) .build(), YDConf.FORM_QUERY.retrieve_id).getFormData(); log.info("[审批回写] 审批单详情 formInstanceId={} keys={}", formInstanceId, approvalFormData == null ? null : approvalFormData.keySet()); McException.assertAccessException(approvalFormData == null || approvalFormData.isEmpty(), "未查询到审批单数据,实例ID=" + formInstanceId); // 2. 类型判定:工时审批含「项目工时明细」子表 tableField_mmae8t99,其他工时审批没有 boolean isOther = !approvalFormData.containsKey(AP_BIL_TABLE); boolean approveWhole = (result == 1); // 3. 取审批单明细(内联子表 <50 直接用,==50 才 queryDetails 递归取全,避免无效请求) List rows = new ArrayList<>(); if (isOther) { rows.addAll(loadDetailRows(formInstanceId, approvalFormData, Cat.OTHER, approveWhole)); } else { rows.addAll(loadDetailRows(formInstanceId, approvalFormData, Cat.BILLABLE, approveWhole)); rows.addAll(loadDetailRows(formInstanceId, approvalFormData, Cat.NON_BILLABLE, approveWhole)); } // 4. 按汇总表记录键(填报人 + 工时日期文本)分组 Map> groups = new LinkedHashMap<>(); for (DetailRow r : rows) { if (StringUtils.isBlank(r.userId) || StringUtils.isBlank(r.dayText)) { 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(() -> { for (int retry = 0; retry <= MAX_RETRY; retry++) { try { int[] r = applyToRecord(userId, dayText, groupRows, limiter); if (r == null) { // 未命中汇总表记录 break; } hitRecords.incrementAndGet(); updatedRows.addAndGet(r[0]); missRows.addAndGet(r[1]); return; } catch (Exception ex) { if (retry < MAX_RETRY) { sleep(1000L * (retry + 1)); } else { 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(); } 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(failRecords.get()) .build(); log.info("[审批回写] 完成 {}", stats); return stats; } /** * 处理单条汇总表日记录 * * @return {updatedRows, missRows};汇总表无此记录返回 null */ private int[] applyToRecord(String userId, String dayText, List groupRows, RateLimiter limiter) { // a. 定位汇总表记录(填报人ID + 工时日期文本,代码内精确过滤) limiter.acquire(); List found = (List) ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(whConf.getFormUuidWorkHoursSummary()) .searchFieldJson(JSON.toJSONString(UtilMap.map( SUMMARY_SUBMITTER_ID + ", " + SUMMARY_DAY_TEXT, userId, dayText))) .build(), YDConf.FORM_QUERY.retrieve_search_form).getData(); String summaryInstId = null; for (Map item : found) { Map fd = (Map) item.get("formData"); if (fd == null) continue; if (userId.equals(String.valueOf(fd.get(SUMMARY_SUBMITTER_ID))) && dayText.equals(String.valueOf(fd.get(SUMMARY_DAY_TEXT)))) { summaryInstId = String.valueOf(item.get("formInstanceId")); break; } } if (StringUtils.isBlank(summaryInstId)) { log.warn("[审批回写] 未命中汇总表记录 userId={} day={}", userId, dayText); return null; } // b. 取汇总表记录完整数据(含三子表) limiter.acquire(); Map formData = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(summaryInstId) .build(), YDConf.FORM_QUERY.retrieve_id).getFormData(); Map updateData = new HashMap<>(); int updated = 0; int miss = 0; // c. 按类别分组本记录的明细,逐类别重建子表 + 置值 Map> byCat = new EnumMap<>(Cat.class); for (DetailRow r : groupRows) { byCat.computeIfAbsent(r.cat, k -> new ArrayList<>()).add(r); } // 各类别已提交/已审批合计(受影响类别从重建行重算,未受影响沿用主表现值) double bilSubmitted = num(formData, M_BIL_SUBMITTED), bilApproved = num(formData, M_BIL_APPROVED); double nonSubmitted = num(formData, M_NON_SUBMITTED), nonApproved = num(formData, M_NON_APPROVED); double othSubmitted = num(formData, M_OTH_SUBMITTED), othApproved = num(formData, M_OTH_APPROVED); for (Map.Entry> e : byCat.entrySet()) { Cat cat = e.getKey(); String subTable = subTable(cat); String keyField = subKey(cat); String submittedField = subSubmitted(cat); String approvedField = subApproved(cat); // 子表取数控制:内联 <50 直接用,==50 才递归取全,避免全量覆盖时漏行被删 List rebuilt = rebuildRows( resolveDetailRows(summaryInstId, subTable, (List) formData.get(subTable))); for (DetailRow dr : e.getValue()) { Map target = null; for (Map row : rebuilt) { if (dr.key != null && dr.key.equals(String.valueOf(row.get(keyField)))) { target = row; break; } } if (target == null) { miss++; log.warn("[审批回写] 子表行未匹配 cat={} key={} userId={} day={}", cat, dr.key, userId, dayText); continue; } if (dr.approve) { target.put(approvedField, dr.hours); } else { target.put(submittedField, 0); } updated++; } updateData.put(subTable, rebuilt); double sumSubmitted = sumRows(rebuilt, submittedField); double sumApproved = sumRows(rebuilt, approvedField); switch (cat) { case BILLABLE: bilSubmitted = sumSubmitted; bilApproved = sumApproved; break; case NON_BILLABLE: nonSubmitted = sumSubmitted; nonApproved = sumApproved; break; case OTHER: othSubmitted = sumSubmitted; othApproved = sumApproved; break; } } // d. 主表合计重算 updateData.put(M_BIL_SUBMITTED, bilSubmitted); updateData.put(M_BIL_APPROVED, bilApproved); updateData.put(M_NON_SUBMITTED, nonSubmitted); updateData.put(M_NON_APPROVED, nonApproved); updateData.put(M_OTH_SUBMITTED, othSubmitted); updateData.put(M_OTH_APPROVED, othApproved); updateData.put(M_DAY_SUBMITTED, bilSubmitted + nonSubmitted + othSubmitted); updateData.put(M_DAY_APPROVED, bilApproved + nonApproved + othApproved); // e. 全量覆盖更新(子表整组替换 + 主表合计) limiter.acquire(); ydClient.operateData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(summaryInstId) .updateFormDataJson(JSON.toJSONString(updateData)) .ignoreEmpty(false) .useLatestVersion(true) .build(), YDConf.FORM_OPERATION.update); return new int[]{updated, miss}; } /** * 加载审批单某类别明细(内联子表优先,必要时递归取全) */ private List loadDetailRows(String formInstanceId, Map approvalFormData, Cat cat, boolean approveWhole) { String tableId = apTable(cat); List raw = resolveDetailRows(formInstanceId, tableId, (List) approvalFormData.get(tableId)); List list = new ArrayList<>(); for (Map row : raw) { DetailRow dr = new DetailRow(); dr.cat = cat; dr.userId = str(row, apUserId(cat)); dr.dayText = str(row, apDayText(cat)); dr.key = str(row, apKey(cat)); dr.hours = num(row, apHours(cat)); String opinion = str(row, apOpinion(cat)); // 有效意见:整单拒绝/撤销 → 全拒绝;整单同意 → 看单条明细意见 dr.approve = approveWhole && OPINION_APPROVE.equals(opinion); list.add(dr); } return list; } /** * 子表取数控制:主表详情已内联返回子表时,行数 <50 视为完整直接用; * ==50(可能被宜搭截断)或内联缺失,才调 {@link YDService#queryDetails} 递归取全,避免无效请求。 * 复用 ydService 封装,不重复造分页轮子。 */ private List resolveDetailRows(String formInstanceId, String tableId, List inlineRows) { if (inlineRows != null && inlineRows.size() < 50) { return inlineRows; } return ydService.queryDetails(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(formInstanceId) .tableFieldId(tableId) .pageNumber(1) .build()); } /** * 重建子表行:保留全部字段,成员/关联字段用 _id 取真实ID(全量覆盖写回前置) */ private List rebuildRows(List rows) { List out = new ArrayList<>(); if (rows == null) { return out; } for (Map row : rows) { Map newRow = new HashMap(); for (Object keyObj : row.keySet()) { String key = String.valueOf(keyObj); if (key.endsWith("_value") || (key.startsWith("employeeField_") && !key.endsWith("_id"))) { continue; } String k = key; if (key.endsWith("_id")) { if (key.startsWith("employeeField_") || key.startsWith("associationFormField_")) { k = key.substring(0, key.length() - 3); } else { continue; } } newRow.put(k, YDConf.getDataByCompId(row, k)); } out.add(newRow); } return out; } // ===== 字段映射辅助 ===== private String apTable(Cat c) { return c == Cat.BILLABLE ? AP_BIL_TABLE : (c == Cat.NON_BILLABLE ? AP_NON_TABLE : AP_OTH_TABLE); } private String apUserId(Cat c) { return c == Cat.BILLABLE ? AP_BIL_USERID : (c == Cat.NON_BILLABLE ? AP_NON_USERID : AP_OTH_USERID); } private String apDayText(Cat c) { return c == Cat.BILLABLE ? AP_BIL_DAYTEXT : (c == Cat.NON_BILLABLE ? AP_NON_DAYTEXT : AP_OTH_DAYTEXT); } private String apKey(Cat c) { return c == Cat.BILLABLE ? AP_BIL_KEY : (c == Cat.NON_BILLABLE ? AP_NON_KEY : AP_OTH_KEY); } private String apHours(Cat c) { return c == Cat.BILLABLE ? AP_BIL_HOURS : (c == Cat.NON_BILLABLE ? AP_NON_HOURS : AP_OTH_HOURS); } private String apOpinion(Cat c) { return c == Cat.BILLABLE ? AP_BIL_OPINION : (c == Cat.NON_BILLABLE ? AP_NON_OPINION : AP_OTH_OPINION); } private String subTable(Cat c) { return c == Cat.BILLABLE ? SUB_BIL_TABLE : (c == Cat.NON_BILLABLE ? SUB_NON_TABLE : SUB_OTH_TABLE); } private String subKey(Cat c) { return c == Cat.BILLABLE ? SUB_BIL_KEY : (c == Cat.NON_BILLABLE ? SUB_NON_KEY : SUB_OTH_KEY); } private String subSubmitted(Cat c) { return c == Cat.BILLABLE ? SUB_BIL_SUBMITTED : (c == Cat.NON_BILLABLE ? SUB_NON_SUBMITTED : SUB_OTH_SUBMITTED); } private String subApproved(Cat c) { return c == Cat.BILLABLE ? SUB_BIL_APPROVED : (c == Cat.NON_BILLABLE ? SUB_NON_APPROVED : SUB_OTH_APPROVED); } // ===== 通用工具 ===== private double num(Map m, String k) { Object v = m == null ? null : m.get(k); if (v == null) { return 0d; } try { return Double.parseDouble(String.valueOf(v)); } catch (Exception e) { return 0d; } } private double sumRows(List rows, String field) { double sum = 0d; for (Map r : rows) { sum += num(r, field); } return sum; } private String str(Map m, String k) { Object v = m == null ? null : m.get(k); return v == null ? "" : String.valueOf(v); } private void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } }