package com.malk.service.workhours; import com.alibaba.fastjson.JSON; 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.ReSubmitApprovalResult; import com.malk.server.workhours.TimeCardApprovalStartRequest; 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.*; /** * 补发起工时审批(bug 1 兜底) *

* 场景:汇总表 submitted > 0 但对应 (userId, day) 未出现在任何该月工时审批 / 其他工时审批的子表明细中, * 即前端 submitApprovalProcess 链路失败(startInstance 挂了/静默丢),导致部分员工工时从未进入审批。 *

* 逻辑完全参考前端 TimeCard 的 submitApprovalProcess: * 1. collectApprovalData — 遍历汇总表每天子表,delta = submitted - approved - change, delta>0 才 push,按 PM 分组 * 2. processManagerApproval — queryProcess(month + PM + RUNNING) 有则 update / 无则 create * COMPLETED 明细先进入覆盖索引;汇总尚未落值时归回写处理,不重复发起 * 3. collectOtherApprovalData / processOtherApproval — 其他工时按当前员工的部门主管作 approver,同样 query→update/create *

* 发起人 userId 必须用真人钉钉账号(建议 System),不能用 yida_pub_account(不触发钉钉待办通知) *

* 设计文档:后端/阿科德斯/审批链路补发起.md */ @Slf4j @Service public class ReSubmitApprovalService { @Autowired private YDClient ydClient; @Autowired private YDService ydService; @Autowired private WHConf whConf; /** * 前端 v2.35 常量:发起时明细「审批意见」默认「同意」 */ private static final String APPROVAL_OPINION_AGREE = "同意"; /** * 宜搭单个子表组件可稳定查询和覆盖更新的最大行数。 */ private static final int MAX_APPROVAL_SUBTABLE_ROWS = 500; /** * 批量历史补偿的目标分片大小;以员工整组为边界,允许单个员工整组略超目标值。 */ private static final int TARGET_APPROVAL_SUBTABLE_ROWS = 100; /** * 兜底部门主管 userId(Raymond,前端 v2.42 保持一致) */ private static final String DEFAULT_MANAGER_ID = "634735129"; // ===== 汇总表字段(与 ApprovalWriteBackService 一致) ===== private static final String S_SUBMITTER = "employeeField_mmacxew4"; private static final String S_SUBMITTER_ID = "textField_mmbffvd9"; private static final String S_MONTH_TEXT = "textField_mmbffvda"; private static final String S_DAY_TEXT = "textField_mmbffvdb"; private static final String S_DAY_DATE = "dateField_mmbffvd8"; private static final String S_DEPT_MANAGER = "employeeField_mh8xhqc3"; // 汇总表 · Billable 子表 private static final String S_BIL_TABLE = "tableField_mmczo634"; private static final String S_BIL_PROJECT_NAME = "selectField_mmad6jci"; private static final String S_BIL_PROJECT_NAME_TEXT = "textField_mmad6jcj"; private static final String S_BIL_PROJECT_CODE = "textField_mmacxew3"; private static final String S_BIL_MANAGER = "employeeField_mkoxpswf"; private static final String S_BIL_SUBMITTED = "numberField_mmczo636"; private static final String S_BIL_APPROVED = "numberField_mmczo637"; private static final String S_BIL_CHANGE = "numberField_mmd80zae"; // 汇总表 · Non Billable 子表 private static final String S_NON_TABLE = "tableField_mmczo63h"; private static final String S_NON_PROJECT_NAME = "selectField_mmczo638"; private static final String S_NON_PROJECT_NAME_TEXT = "textField_mmczo63g"; private static final String S_NON_PROJECT_CODE = "textField_mmczo639"; private static final String S_NON_MANAGER = "employeeField_mmczo63a"; private static final String S_NON_SUBMITTED = "numberField_mmczo63c"; private static final String S_NON_APPROVED = "numberField_mmczo63d"; private static final String S_NON_CHANGE = "numberField_mmeakgi9"; // 汇总表 · 其他工时子表 private static final String S_OTH_TABLE = "tableField_mmeakgid"; private static final String S_OTH_ACTIVITY = "selectField_mmeakgie"; private static final String S_OTH_SUBMITTED = "numberField_mmeakgij"; private static final String S_OTH_APPROVED = "numberField_mmeakgii"; private static final String S_OTH_CHANGE = "numberField_mmeakgim"; // ===== 工时审批(APPROVAL) 主表字段 ===== private static final String A_MONTH_DATE = "dateField_mmacxewf"; private static final String A_MONTH_TEXT = "textField_mmd2lv0y"; private static final String A_MANAGER = "employeeField_mkoxpswf"; private static final String A_PROJECT_HOURS = "numberField_mmd2z155"; private static final String A_NON_PROJECT_HOURS = "numberField_mmd2z156"; private static final String A_TOTAL_HOURS = "numberField_mmd5b5gl"; private static final String A_VIEWERS = "employeeField_moi9pvt1"; // 工时审批 · Billable 明细子表 private static final String A_BIL_TABLE = "tableField_mmae8t99"; private static final String A_BIL_WORK_DATE = "dateField_mmbffvd8"; private static final String A_BIL_SUBMITTER = "employeeField_mmacxew4"; private static final String A_BIL_SUBMITTER_UID = "textField_mmd1ozk4"; private static final String A_BIL_PROJECT_NAME = "textField_mkov5ozd"; private static final String A_BIL_PROJECT_CODE = "textField_mmacxew3"; private static final String A_BIL_HOURS = "numberField_mmacxew9"; private static final String A_BIL_MONTH_TEXT = "textField_mmdbi2n2"; private static final String A_BIL_DAY_TEXT = "textField_mmd8g654"; private static final String A_BIL_OPINION = "radioField_mpwhk294"; // 工时审批 · Non Billable 明细子表 private static final String A_NON_TABLE = "tableField_mmd1wu9h"; private static final String A_NON_WORK_DATE = "dateField_mmd2z150"; private static final String A_NON_SUBMITTER = "employeeField_mmd1wu9b"; private static final String A_NON_SUBMITTER_UID = "textField_mmd1wu9f"; private static final String A_NON_PROJECT_NAME = "textField_mmd1wu9c"; private static final String A_NON_PROJECT_CODE = "textField_mmd1wu9d"; private static final String A_NON_HOURS = "numberField_mmd1wu9e"; private static final String A_NON_MONTH_TEXT = "textField_mmdbi2n0"; private static final String A_NON_DAY_TEXT = "textField_mmd8g655"; private static final String A_NON_OPINION = "radioField_mpwhk295"; // ===== 其他工时审批(OTHER_APPROVAL) 主表字段 ===== private static final String O_MONTH_DATE = "dateField_mmacxewf"; private static final String O_MONTH_TEXT = "textField_mmd2lv0y"; private static final String O_DEPT_MANAGER = "employeeField_mh8xhqc3"; private static final String O_TOTAL_HOURS = "numberField_mmd5b5gl"; private static final String O_VIEWERS = "employeeField_moi9pvt1"; // 其他工时审批 · 明细子表(与 Non Billable 同 tableField,但 activity 语义) private static final String O_SUB_TABLE = "tableField_mmd1wu9h"; private static final String O_SUB_WORK_DATE = "dateField_mmd2z150"; private static final String O_SUB_SUBMITTER = "employeeField_mmd1wu9b"; private static final String O_SUB_SUBMITTER_UID = "textField_mmd1wu9f"; private static final String O_SUB_ACTIVITY = "selectField_mmeakgie"; private static final String O_SUB_HOURS = "numberField_mmd1wu9e"; private static final String O_SUB_MONTH_TEXT = "textField_mmdbi2n0"; private static final String O_SUB_DAY_TEXT = "textField_mmd8g655"; private static final String O_SUB_OPINION = "radioField_mpwhk294"; /** * 一条待发起的项目工时行(前端 collectApprovalData 的等价) */ private static class BillRow { String dayText; // yyyyMMdd long dayTs; // epoch ms String projectCode; String projectName; double hours; // delta = submitted - approved - change String submitterUid; String monthText; // yyyyMM } /** * 一条待发起的其他工时行 */ private static class OtherRow { String dayText; long dayTs; String activity; // Annual Leave / Sick Leave / ... double hours; String submitterUid; String monthText; } /** * 审批覆盖索引:RUNNING 明细直接阻止重复补发;COMPLETED 同意明细在汇总尚未计入时归 Bug2。 */ static class ApprovalCoverage { Set runningKeys = new HashSet<>(); Map completedAgreeHours = new HashMap<>(); boolean shouldSkip(String key, double accountedHours) { if (runningKeys.contains(key)) { return true; } return completedAgreeHours.getOrDefault(key, 0d) > accountedHours + 1e-6; } } /** * 某员工某月按 PM 聚合的项目工时组 */ private static class ManagerGroup { String managerId; List billableRows = new ArrayList<>(); List nonBillableRows = new ArrayList<>(); double billableTotal = 0; double nonBillableTotal = 0; } /** * 对指定月份、指定员工(可空)执行补发起 * * @param monthText yyyyMM * @param submitterUserId 可空; 空则处理该月所有员工 * @return 结果统计 */ public ReSubmitApprovalResult resubmit(String monthText, String submitterUserId) { McException.assertAccessException(StringUtils.isBlank(monthText), "月份不能为空"); McException.assertAccessException(StringUtils.isBlank(whConf.getApprovalProcessCode()), "工时审批 processCode 未配置(workhours.approvalProcessCode)"); McException.assertAccessException(StringUtils.isBlank(whConf.getOtherApprovalProcessCode()), "其他工时审批 processCode 未配置(workhours.otherApprovalProcessCode)"); McException.assertAccessException(StringUtils.isBlank(whConf.getApprovalOriginatorUserId()), "审批补发起真人账号未配置(workhours.approvalOriginatorUserId)"); // 1) 查汇总表:按月份 + 可选员工取全,是否待审批由子表 submitted-approved-change 实际差额判定。 Map search = new HashMap<>(); search.put(S_MONTH_TEXT, monthText); if (StringUtils.isNotBlank(submitterUserId)) { search.put(S_SUBMITTER_ID, submitterUserId); } List allRecords = queryAll(whConf.getFormUuidWorkHoursSummary(), search); ApprovalCoverage approvalCoverage = loadApprovalCoverage(monthText); // 2) 按 (submitterUid) 分组员工月度记录 Map> perSubmitter = new LinkedHashMap<>(); for (Map rec : allRecords) { Map fd = (Map) rec.get("formData"); if (fd == null) { continue; } String uid = str(fd, S_SUBMITTER_ID); if (StringUtils.isBlank(uid)) { continue; } perSubmitter.computeIfAbsent(uid, k -> new ArrayList<>()).add(rec); } log.info("[补发起] 待处理员工数 month={} submitter={} count={}", monthText, submitterUserId, perSubmitter.size()); int billableCreated = 0, billableUpdated = 0; int otherCreated = 0, otherUpdated = 0; int skipped = 0, failed = 0; // 3) 每个员工:构造 managerGroups + otherRows,处理 processManagerApproval / processOtherApproval for (Map.Entry> e : perSubmitter.entrySet()) { String uid = e.getKey(); List records = e.getValue(); try { CollectResult cr = collectApprovalData(uid, monthText, records, approvalCoverage); if (cr.managerGroups.isEmpty() && cr.otherRows.isEmpty()) { skipped++; continue; } // 3a) 项目工时:每个 PM 一张单 for (ManagerGroup mg : cr.managerGroups.values()) { try { String action = processManagerApproval(mg, monthText, uid); if ("created".equals(action)) { billableCreated++; } else if ("updated".equals(action)) { billableUpdated++; } } catch (Exception ex) { failed++; log.error("[补发起] 项目工时发起失败 uid={} pm={} month={}", uid, mg.managerId, monthText, ex); } } // 3b) 其他工时:一人一张单,approver 用该员工部门主管(记录中带),否则兜底 Raymond if (!cr.otherRows.isEmpty()) { try { String action = processOtherApproval(cr.otherRows, cr.deptManagerId, monthText, uid); if ("created".equals(action)) { otherCreated++; } else if ("updated".equals(action)) { otherUpdated++; } } catch (Exception ex) { failed++; log.error("[补发起] 其他工时发起失败 uid={} deptMgr={} month={}", uid, cr.deptManagerId, monthText, ex); } } } catch (Exception ex) { failed++; log.error("[补发起] 员工整月处理失败 uid={} month={}", uid, monthText, ex); } } ReSubmitApprovalResult r = ReSubmitApprovalResult.builder() .monthText(monthText) .submitterCount(perSubmitter.size()) .billableCreated(billableCreated) .billableUpdated(billableUpdated) .otherCreated(otherCreated) .otherUpdated(otherUpdated) .skipped(skipped) .failed(failed) .build(); log.info("[补发起] 完成 {}", r); return r; } /** * 使用真人 System 账号发起 TimeCard 工时审批。 * * @param request 发起审批请求 * @return true 表示宜搭发起接口调用成功 */ public boolean startApproval(TimeCardApprovalStartRequest request) { McException.assertAccessException(request == null, "请求不能为空"); McException.assertAccessException( StringUtils.isAnyBlank( request.getFormUuid(), request.getProcessCode(), request.getFormDataJson()), "审批表单、流程编码和表单数据不能为空"); boolean billable = StringUtils.equals(whConf.getFormUuidApproval(), request.getFormUuid()) && StringUtils.equals(whConf.getApprovalProcessCode(), request.getProcessCode()); boolean other = StringUtils.equals(whConf.getFormUuidOtherApproval(), request.getFormUuid()) && StringUtils.equals(whConf.getOtherApprovalProcessCode(), request.getProcessCode()); McException.assertAccessException(!billable && !other, "不允许发起非TimeCard审批流程"); McException.assertAccessException( StringUtils.isBlank(whConf.getApprovalOriginatorUserId()), "审批发起真人账号未配置(workhours.approvalOriginatorUserId)"); Map formData = JSON.parseObject( request.getFormDataJson(), Map.class); startProcessInstance( request.getFormUuid(), request.getProcessCode(), formData, request.getDeptId()); return true; } /** * collectApprovalData + collectOtherApprovalData 的等价: * 遍历该员工该月每天记录,按项目工时 / 其他工时反构造 delta 行,按 PM 分组 */ private static class CollectResult { Map managerGroups = new LinkedHashMap<>(); List otherRows = new ArrayList<>(); String deptManagerId; } private CollectResult collectApprovalData(String uid, String monthText, List records, ApprovalCoverage approvalCoverage) { CollectResult cr = new CollectResult(); Set seenBillableKeys = new HashSet<>(); Set seenNonBillableKeys = new HashSet<>(); Set seenOtherKeys = new HashSet<>(); for (Map rec : records) { Map fd = (Map) rec.get("formData"); if (fd == null) { continue; } String dayText = str(fd, S_DAY_TEXT); if (StringUtils.isBlank(dayText)) { continue; } long dayTs = numLong(fd, S_DAY_DATE); // 部门主管兜底 if (StringUtils.isBlank(cr.deptManagerId)) { cr.deptManagerId = firstIdOrText(fd, S_DEPT_MANAGER); } // Billable 子表 for (Map row : listOrEmpty(fd, S_BIL_TABLE)) { double submitted = num(row, S_BIL_SUBMITTED); double approved = num(row, S_BIL_APPROVED); double change = num(row, S_BIL_CHANGE); double delta = submitted - approved - change; if (delta <= 0) { continue; } String projectCode = str(row, S_BIL_PROJECT_CODE); String coverageKey = approvalKey("billable", uid, dayText, projectCode); if (!seenBillableKeys.add(coverageKey) || approvalCoverage.shouldSkip(coverageKey, approved + change)) { continue; } String pmRaw = firstIdOrText(row, S_BIL_MANAGER); final String pm = StringUtils.isBlank(pmRaw) ? DEFAULT_MANAGER_ID : pmRaw; ManagerGroup mg = cr.managerGroups.computeIfAbsent(pm, k -> { ManagerGroup m = new ManagerGroup(); m.managerId = pm; return m; }); BillRow br = new BillRow(); br.dayText = dayText; br.dayTs = dayTs; br.projectCode = projectCode; br.projectName = firstNonBlank(str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME)); br.hours = delta; br.submitterUid = uid; br.monthText = monthText; mg.billableRows.add(br); mg.billableTotal += delta; } // Non Billable 子表 for (Map row : listOrEmpty(fd, S_NON_TABLE)) { double submitted = num(row, S_NON_SUBMITTED); double approved = num(row, S_NON_APPROVED); double change = num(row, S_NON_CHANGE); double delta = submitted - approved - change; if (delta <= 0) { continue; } String projectCode = str(row, S_NON_PROJECT_CODE); String coverageKey = approvalKey("nonBillable", uid, dayText, projectCode); if (!seenNonBillableKeys.add(coverageKey) || approvalCoverage.shouldSkip(coverageKey, approved + change)) { continue; } String pmRaw = firstIdOrText(row, S_NON_MANAGER); final String pm = StringUtils.isBlank(pmRaw) ? DEFAULT_MANAGER_ID : pmRaw; ManagerGroup mg = cr.managerGroups.computeIfAbsent(pm, k -> { ManagerGroup m = new ManagerGroup(); m.managerId = pm; return m; }); BillRow br = new BillRow(); br.dayText = dayText; br.dayTs = dayTs; br.projectCode = projectCode; br.projectName = firstNonBlank(str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME)); br.hours = delta; br.submitterUid = uid; br.monthText = monthText; mg.nonBillableRows.add(br); mg.nonBillableTotal += delta; } // 其他工时子表 for (Map row : listOrEmpty(fd, S_OTH_TABLE)) { double submitted = num(row, S_OTH_SUBMITTED); double approved = num(row, S_OTH_APPROVED); double change = num(row, S_OTH_CHANGE); double delta = submitted - approved - change; if (delta <= 0) { continue; } String activity = str(row, S_OTH_ACTIVITY); String coverageKey = approvalKey("other", uid, dayText, activity); if (!seenOtherKeys.add(coverageKey) || approvalCoverage.shouldSkip(coverageKey, approved + change)) { continue; } OtherRow or = new OtherRow(); or.dayText = dayText; or.dayTs = dayTs; or.activity = activity; or.hours = delta; or.submitterUid = uid; or.monthText = monthText; cr.otherRows.add(or); } } if (StringUtils.isBlank(cr.deptManagerId)) { cr.deptManagerId = DEFAULT_MANAGER_ID; } return cr; } /** * 加载指定月份两类审批单的完整明细覆盖索引。 * fixme 流程列表内联子表恰好 50 行时必须 queryDetails 拉全,否则会把第 51 行以后误判为漏单。 */ private ApprovalCoverage loadApprovalCoverage(String monthText) { ApprovalCoverage coverage = new ApprovalCoverage(); Map search = new HashMap<>(); search.put(A_MONTH_TEXT, monthText); for (Map item : queryProcessAll(whConf.getFormUuidApproval(), search)) { collectProcessCoverage(item, false, coverage); } for (Map item : queryProcessAll(whConf.getFormUuidOtherApproval(), search)) { collectProcessCoverage(item, true, coverage); } log.info("[补发起] 审批覆盖索引 month={} runningKeys={} completedAgreeKeys={}", monthText, coverage.runningKeys.size(), coverage.completedAgreeHours.size()); return coverage; } /** * 把单张审批单明细加入覆盖索引。 */ private void collectProcessCoverage(Map item, boolean isOther, ApprovalCoverage coverage) { String status = str(item, "instanceStatus").toUpperCase(Locale.ROOT); if (!"RUNNING".equals(status) && !"COMPLETED".equals(status)) { return; } String approvedResult = str(item, "approvedResult").toLowerCase(Locale.ROOT); String formInstanceId = firstNonBlank( str(item, "formInstanceId"), str(item, "processInstanceId")); Map formData = processFormData(item, formInstanceId); if (formData.isEmpty()) { return; } if (isOther) { collectCoverageRows( coverage, status, approvedResult, resolveFullRows( formInstanceId, whConf.getFormUuidOtherApproval(), O_SUB_TABLE, (List) formData.get(O_SUB_TABLE)), "other", O_SUB_SUBMITTER_UID, O_SUB_DAY_TEXT, O_SUB_ACTIVITY, O_SUB_HOURS, O_SUB_OPINION); return; } collectCoverageRows( coverage, status, approvedResult, resolveFullRows( formInstanceId, whConf.getFormUuidApproval(), A_BIL_TABLE, (List) formData.get(A_BIL_TABLE)), "billable", A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE, A_BIL_HOURS, A_BIL_OPINION); collectCoverageRows( coverage, status, approvedResult, resolveFullRows( formInstanceId, whConf.getFormUuidApproval(), A_NON_TABLE, (List) formData.get(A_NON_TABLE)), "nonBillable", A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE, A_NON_HOURS, A_NON_OPINION); } /** * 归一化一类审批子表行并写入覆盖索引。 */ private void collectCoverageRows(ApprovalCoverage coverage, String status, String approvedResult, List rows, String category, String uidField, String dayField, String detailKeyField, String hoursField, String opinionField) { for (Map row : rows) { String uid = str(row, uidField); String day = str(row, dayField); String detailKey = str(row, detailKeyField); if (StringUtils.isAnyBlank(uid, day, detailKey)) { continue; } String key = approvalKey(category, uid, day, detailKey); if ("RUNNING".equals(status)) { coverage.runningKeys.add(key); continue; } if (isCompletedApprovedRow( status, approvedResult, str(row, opinionField))) { coverage.completedAgreeHours.merge(key, num(row, hoursField), Math::max); } } } static boolean isCompletedApprovedRow(String status, String approvedResult, String opinion) { return "COMPLETED".equals(status) && "agree".equals(approvedResult) && (StringUtils.isBlank(opinion) || APPROVAL_OPINION_AGREE.equals(opinion)); } /** * 获取流程列表项中的表单数据;列表不带数据时回查表单详情。 */ private Map processFormData(Map item, String formInstanceId) { Object formData = item.get("formData"); if (!(formData instanceof Map)) { formData = item.get("data"); } if (formData instanceof Map && !((Map) formData).isEmpty()) { return (Map) formData; } if (StringUtils.isBlank(formInstanceId)) { return Collections.emptyMap(); } Map detail = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(formInstanceId) .build(), YDConf.FORM_QUERY.retrieve_id).getFormData(); return detail != null ? detail : Collections.emptyMap(); } /** * 子表内联小于 50 行直接使用,恰好 50 行时递归拉全。 */ private List resolveFullRows(String formInstanceId, String formUuid, String tableFieldId, List inlineRows) { if (inlineRows == null) { return Collections.emptyList(); } if (inlineRows.size() < 50) { return inlineRows; } List fullRows = ydService.queryDetails(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(formUuid) .formInstanceId(formInstanceId) .tableFieldId(tableFieldId) .pageNumber(1) .build()); return fullRows != null ? fullRows : Collections.emptyList(); } /** * 审批明细唯一键。 */ static String approvalKey(String category, String uid, String day, String detailKey) { return category + "|" + uid + "|" + day + "|" + detailKey; } /** * processManagerApproval 等价 — queryProcess(RUNNING) 有则 update / 无则 create。 * COMPLETED 覆盖已在 collectApprovalData 前过滤,避免历史已完成单被重复发起。 */ private String processManagerApproval(ManagerGroup mg, String monthText, String submitterUid) { Map searchFieldJson = new HashMap<>(); searchFieldJson.put(A_MONTH_TEXT, monthText); // fixme employeeField 查询用数组: 单值 [uid] searchFieldJson.put(A_MANAGER, Collections.singletonList(mg.managerId)); List running = queryProcessRunning(whConf.getFormUuidApproval(), searchFieldJson); // 优先追加到最新分片,避免每名员工都先扫描已满 500 行的旧审批单。 running.sort(Comparator.comparingLong( (Map item) -> numLong(item, "gmtModified")).reversed()); for (Map item : running) { String pid = str(item, "processInstanceId"); String finstId = firstNonBlank(str(item, "formInstanceId"), pid); Map existingFormData = processFormData(item, finstId); if (!managerApprovalHasCapacity(finstId, mg, submitterUid, monthText, existingFormData)) { continue; } updateApprovalInstance(finstId, mg, monthText, submitterUid, existingFormData); return "updated"; } // fixme: 单张审批子表达到 500 行后必须新建分片,继续覆盖会丢失第 501 行后的明细。 createApprovalInstance(mg, monthText, submitterUid); return "created"; } private boolean managerApprovalHasCapacity(String formInstanceId, ManagerGroup mg, String submitterUid, String monthText, Map existingFormData) { List existBil = resolveFullRows( formInstanceId, whConf.getFormUuidApproval(), A_BIL_TABLE, (List) (existingFormData == null ? null : existingFormData.get(A_BIL_TABLE))); List existNon = resolveFullRows( formInstanceId, whConf.getFormUuidApproval(), A_NON_TABLE, (List) (existingFormData == null ? null : existingFormData.get(A_NON_TABLE))); MergeResult billable = mergeSubRows( existBil, mg.billableRows, submitterUid, monthText, A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE, br -> buildBillableApprovalRow(br, submitterUid)); MergeResult nonBillable = mergeSubRows( existNon, mg.nonBillableRows, submitterUid, monthText, A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE, br -> buildNonBillableApprovalRow(br, submitterUid)); int existingBillableCount = countUniqueRows( existBil, A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE); int existingNonBillableCount = countUniqueRows( existNon, A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE); return canAppendToApprovalShard(existingBillableCount, billable.rows.size()) && canAppendToApprovalShard(existingNonBillableCount, nonBillable.rows.size()); } /** * 创建新的工时审批实例(startProcessInstance) */ private void createApprovalInstance(ManagerGroup mg, String monthText, String submitterUid) { McException.assertAccessException( !hasSubtableCapacity(mg.billableRows.size()) || !hasSubtableCapacity(mg.nonBillableRows.size()), "单个员工的审批明细超过宜搭子表 500 行限制"); Map formData = new HashMap<>(); formData.put(A_MONTH_DATE, monthToTs(monthText)); formData.put(A_MONTH_TEXT, monthText); formData.put(A_MANAGER, Collections.singletonList(mg.managerId)); formData.put(A_PROJECT_HOURS, mg.billableTotal); formData.put(A_NON_PROJECT_HOURS, mg.nonBillableTotal); formData.put(A_TOTAL_HOURS, mg.billableTotal + mg.nonBillableTotal); formData.put(A_VIEWERS, Collections.singletonList(submitterUid)); List> billableSub = new ArrayList<>(); for (BillRow br : mg.billableRows) { billableSub.add(buildBillableApprovalRow(br, submitterUid)); } List> nonBillableSub = new ArrayList<>(); for (BillRow br : mg.nonBillableRows) { nonBillableSub.add(buildNonBillableApprovalRow(br, submitterUid)); } if (!billableSub.isEmpty()) { formData.put(A_BIL_TABLE, billableSub); } if (!nonBillableSub.isEmpty()) { formData.put(A_NON_TABLE, nonBillableSub); } startProcessInstance( whConf.getFormUuidApproval(), whConf.getApprovalProcessCode(), formData, whConf.getApprovalOriginatorDeptId()); log.info("[补发起] 新建工时审批单 submitterUid={} pm={} month={} bilTotal={} nonBilTotal={} rows={}+{}", submitterUid, mg.managerId, monthText, mg.billableTotal, mg.nonBillableTotal, billableSub.size(), nonBillableSub.size()); } /** * 累计更新已有 RUNNING 工时审批单(按唯一键 userId + dayText + projectCode 去重合并) */ private void updateApprovalInstance(String formInstanceId, ManagerGroup mg, String monthText, String submitterUid, Map existingFormData) { List existBil = resolveFullRows( formInstanceId, whConf.getFormUuidApproval(), A_BIL_TABLE, (List) (existingFormData == null ? null : existingFormData.get(A_BIL_TABLE))); List existNon = resolveFullRows( formInstanceId, whConf.getFormUuidApproval(), A_NON_TABLE, (List) (existingFormData == null ? null : existingFormData.get(A_NON_TABLE))); MergeResult mrBil = mergeSubRows(existBil, mg.billableRows, submitterUid, monthText, A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE, (br) -> buildBillableApprovalRow(br, submitterUid)); MergeResult mrNon = mergeSubRows(existNon, mg.nonBillableRows, submitterUid, monthText, A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE, (br) -> buildNonBillableApprovalRow(br, submitterUid)); if (mrBil.addedHours == 0 && mrNon.addedHours == 0) { log.info("[补发起] 已存在等效明细,跳过更新 formInstanceId={}", formInstanceId); return; } double existProject = num(existingFormData, A_PROJECT_HOURS); double existNonProject = num(existingFormData, A_NON_PROJECT_HOURS); double newProject = existProject + mrBil.addedHours; double newNonProject = existNonProject + mrNon.addedHours; Map updateData = new HashMap<>(); updateData.put(A_PROJECT_HOURS, newProject); updateData.put(A_NON_PROJECT_HOURS, newNonProject); updateData.put(A_TOTAL_HOURS, newProject + newNonProject); if (!mrBil.rows.isEmpty()) { updateData.put(A_BIL_TABLE, mrBil.rows); } if (!mrNon.rows.isEmpty()) { updateData.put(A_NON_TABLE, mrNon.rows); } // Viewers 去重(原有 + 本次申请人) Set viewerSet = new LinkedHashSet<>(); Object existViewersRaw = existingFormData == null ? null : existingFormData.get(A_VIEWERS + "_id"); if (existViewersRaw instanceof List) { for (Object v : (List) existViewersRaw) { if (v != null) { viewerSet.add(String.valueOf(v)); } } } // 从原有子表行提取填报人 for (Map r : new ArrayList(existBil == null ? Collections.emptyList() : existBil)) { String u = str(r, A_BIL_SUBMITTER_UID); if (StringUtils.isNotBlank(u)) { viewerSet.add(u); } } for (Map r : new ArrayList(existNon == null ? Collections.emptyList() : existNon)) { String u = str(r, A_NON_SUBMITTER_UID); if (StringUtils.isNotBlank(u)) { viewerSet.add(u); } } if (StringUtils.isNotBlank(submitterUid)) { viewerSet.add(submitterUid); } updateData.put(A_VIEWERS, new ArrayList<>(viewerSet)); ydClient.operateData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(formInstanceId) .updateFormDataJson(JSON.toJSONString(updateData)) .ignoreEmpty(false) .useLatestVersion(true) .build(), YDConf.FORM_OPERATION.update); log.info("[补发起] 累计更新工时审批单 formInstanceId={} pm={} addedBil={} addedNon={}", formInstanceId, mg.managerId, mrBil.addedHours, mrNon.addedHours); } /** * 其他工时审批处理(与 processManagerApproval 同构,但 approver 为员工的部门主管) */ private String processOtherApproval(List otherRows, String deptManagerId, String monthText, String submitterUid) { Map searchFieldJson = new HashMap<>(); searchFieldJson.put(O_MONTH_TEXT, monthText); searchFieldJson.put(O_DEPT_MANAGER, Collections.singletonList(deptManagerId)); List running = queryProcessRunning(whConf.getFormUuidOtherApproval(), searchFieldJson); running.sort(Comparator.comparingLong( (Map item) -> numLong(item, "gmtModified")).reversed()); for (Map item : running) { String pid = str(item, "processInstanceId"); String finstId = firstNonBlank(str(item, "formInstanceId"), pid); Map existingFormData = processFormData(item, finstId); if (!otherApprovalHasCapacity(finstId, otherRows, submitterUid, existingFormData)) { continue; } updateOtherApprovalInstance(finstId, otherRows, monthText, submitterUid, existingFormData); return "updated"; } createOtherApprovalInstance(otherRows, deptManagerId, monthText, submitterUid); return "created"; } private boolean otherApprovalHasCapacity(String formInstanceId, List otherRows, String submitterUid, Map existingFormData) { List existing = resolveFullRows( formInstanceId, whConf.getFormUuidOtherApproval(), O_SUB_TABLE, (List) (existingFormData == null ? null : existingFormData.get(O_SUB_TABLE))); Set keys = new HashSet<>(); for (Map row : existing) { keys.add(str(row, O_SUB_SUBMITTER_UID) + "|" + str(row, O_SUB_DAY_TEXT) + "|" + str(row, O_SUB_ACTIVITY)); } int existingCount = keys.size(); for (OtherRow row : otherRows) { keys.add(submitterUid + "|" + row.dayText + "|" + row.activity); } return canAppendToApprovalShard(existingCount, keys.size()); } private void createOtherApprovalInstance(List otherRows, String deptManagerId, String monthText, String submitterUid) { McException.assertAccessException( !hasSubtableCapacity(otherRows.size()), "单个员工的其他工时审批明细超过宜搭子表 500 行限制"); double total = 0; List> sub = new ArrayList<>(); for (OtherRow or : otherRows) { sub.add(buildOtherApprovalRow(or, submitterUid)); total += or.hours; } Map formData = new HashMap<>(); formData.put(O_MONTH_DATE, monthToTs(monthText)); formData.put(O_MONTH_TEXT, monthText); formData.put(O_DEPT_MANAGER, Collections.singletonList(deptManagerId)); formData.put(O_TOTAL_HOURS, total); formData.put(O_VIEWERS, Collections.singletonList(submitterUid)); if (!sub.isEmpty()) { formData.put(O_SUB_TABLE, sub); } startProcessInstance( whConf.getFormUuidOtherApproval(), whConf.getOtherApprovalProcessCode(), formData, whConf.getApprovalOriginatorDeptId()); log.info("[补发起] 新建其他工时审批单 submitterUid={} deptMgr={} month={} total={} rows={}", submitterUid, deptManagerId, monthText, total, sub.size()); } private void updateOtherApprovalInstance(String formInstanceId, List otherRows, String monthText, String submitterUid, Map existingFormData) { List existRows = resolveFullRows( formInstanceId, whConf.getFormUuidOtherApproval(), O_SUB_TABLE, (List) (existingFormData == null ? null : existingFormData.get(O_SUB_TABLE))); // 唯一键: submitterUid + dayText + activity Set existingKeys = new HashSet<>(); List> merged = new ArrayList<>(); if (existRows != null) { for (Map row : existRows) { String uid = str(row, O_SUB_SUBMITTER_UID); String day = str(row, O_SUB_DAY_TEXT); String act = str(row, O_SUB_ACTIVITY); existingKeys.add(uid + "|" + day + "|" + act); merged.add(rebuildRow(row, O_SUB_SUBMITTER)); } } double addedHours = 0; for (OtherRow or : otherRows) { String key = submitterUid + "|" + or.dayText + "|" + or.activity; if (existingKeys.contains(key)) { continue; } merged.add(buildOtherApprovalRow(or, submitterUid)); addedHours += or.hours; } if (addedHours == 0) { log.info("[补发起] 其他工时无新增明细 formInstanceId={}", formInstanceId); return; } double existTotal = num(existingFormData, O_TOTAL_HOURS); Map updateData = new HashMap<>(); updateData.put(O_TOTAL_HOURS, existTotal + addedHours); updateData.put(O_SUB_TABLE, merged); Set viewerSet = new LinkedHashSet<>(); Object existOViewersRaw = existingFormData == null ? null : existingFormData.get(O_VIEWERS + "_id"); if (existOViewersRaw instanceof List) { for (Object v : (List) existOViewersRaw) { if (v != null) { viewerSet.add(String.valueOf(v)); } } } if (existRows != null) { for (Map r : existRows) { String u = str(r, O_SUB_SUBMITTER_UID); if (StringUtils.isNotBlank(u)) { viewerSet.add(u); } } } if (StringUtils.isNotBlank(submitterUid)) { viewerSet.add(submitterUid); } updateData.put(O_VIEWERS, new ArrayList<>(viewerSet)); ydClient.operateData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(formInstanceId) .updateFormDataJson(JSON.toJSONString(updateData)) .ignoreEmpty(false) .useLatestVersion(true) .build(), YDConf.FORM_OPERATION.update); log.info("[补发起] 累计更新其他工时审批单 formInstanceId={} addedHours={}", formInstanceId, addedHours); } // ============================================================ // 子表行构造 + 合并 helper // ============================================================ private Map buildBillableApprovalRow(BillRow br, String submitterUid) { Map r = new HashMap<>(); r.put(A_BIL_WORK_DATE, br.dayTs); r.put(A_BIL_SUBMITTER, Collections.singletonList(submitterUid)); r.put(A_BIL_SUBMITTER_UID, submitterUid); r.put(A_BIL_PROJECT_CODE, br.projectCode); r.put(A_BIL_PROJECT_NAME, br.projectName); r.put(A_BIL_HOURS, br.hours); r.put(A_BIL_MONTH_TEXT, br.monthText); r.put(A_BIL_DAY_TEXT, br.dayText); r.put(A_BIL_OPINION, APPROVAL_OPINION_AGREE); return r; } private Map buildNonBillableApprovalRow(BillRow br, String submitterUid) { Map r = new HashMap<>(); r.put(A_NON_WORK_DATE, br.dayTs); r.put(A_NON_SUBMITTER, Collections.singletonList(submitterUid)); r.put(A_NON_SUBMITTER_UID, submitterUid); r.put(A_NON_PROJECT_CODE, br.projectCode); r.put(A_NON_PROJECT_NAME, br.projectName); r.put(A_NON_HOURS, br.hours); r.put(A_NON_MONTH_TEXT, br.monthText); r.put(A_NON_DAY_TEXT, br.dayText); r.put(A_NON_OPINION, APPROVAL_OPINION_AGREE); return r; } private Map buildOtherApprovalRow(OtherRow or, String submitterUid) { Map r = new HashMap<>(); r.put(O_SUB_WORK_DATE, or.dayTs); r.put(O_SUB_SUBMITTER, Collections.singletonList(submitterUid)); r.put(O_SUB_SUBMITTER_UID, submitterUid); r.put(O_SUB_ACTIVITY, or.activity); r.put(O_SUB_HOURS, or.hours); r.put(O_SUB_MONTH_TEXT, or.monthText); r.put(O_SUB_DAY_TEXT, or.dayText); r.put(O_SUB_OPINION, APPROVAL_OPINION_AGREE); return r; } private interface RowBuilder { Map build(T row); } private static class MergeResult { List> rows = new ArrayList<>(); double addedHours = 0; } static boolean hasSubtableCapacity(int rowCount) { return rowCount <= MAX_APPROVAL_SUBTABLE_ROWS; } static boolean canAppendToApprovalShard(int existingCount, int mergedCount) { if (!hasSubtableCapacity(mergedCount)) { return false; } // 已有等效明细允许幂等命中;空分片允许承载一个员工的完整明细组。 return mergedCount == existingCount || existingCount == 0 || mergedCount <= TARGET_APPROVAL_SUBTABLE_ROWS; } private int countUniqueRows(List rows, String uidField, String dayField, String keyField) { Set keys = new HashSet<>(); for (Map row : rows) { keys.add(str(row, uidField) + "|" + str(row, dayField) + "|" + str(row, keyField)); } return keys.size(); } /** * mergeApprovalSubRows 等价:唯一键 uid + dayText + projectCode/activity,已存在跳过 * 保留原行时用 rebuildRow 修正 EmployeeField 主键(_id 后缀 → 主键) */ private MergeResult mergeSubRows(List existing, List newRows, String submitterUid, String monthText, String uidField, String dayField, String keyField, RowBuilder builder) { MergeResult r = new MergeResult(); Set keys = new HashSet<>(); if (existing != null) { for (Map row : existing) { String uid = str(row, uidField); String day = str(row, dayField); String key = str(row, keyField); if (!keys.add(uid + "|" + day + "|" + key)) { continue; } r.rows.add(rebuildRow(row, uidField.equals(A_BIL_SUBMITTER_UID) ? A_BIL_SUBMITTER : A_NON_SUBMITTER)); } } for (BillRow br : newRows) { String key = submitterUid + "|" + br.dayText + "|" + br.projectCode; if (!keys.add(key)) { continue; } r.rows.add(builder.build(br)); r.addedHours += br.hours; } return r; } /** * 子表行"重建":成员字段用 _id 后缀真实 userId 替换显示名(否则宜搭静默清空) * fixme 前端 v2.32 已确认这个坑 — 后端全量覆盖写子表时同规则 */ private Map rebuildRow(Map row, String submitterField) { Map nr = new HashMap<>(); for (Object k : row.keySet()) { String key = String.valueOf(k); if (key.endsWith("_value")) { continue; } if (key.startsWith("employeeField_") && !key.endsWith("_id")) { continue; } if (key.endsWith("_id") && (key.startsWith("employeeField_") || key.startsWith("associationFormField_"))) { nr.put(key.substring(0, key.length() - 3), row.get(k)); continue; } nr.put(key, row.get(k)); } return nr; } // ============================================================ // 宜搭 API 封装 // ============================================================ private void startProcessInstance(String formUuid, String processCode, Map formData, String deptId) { // fixme 必须用真人 System 账号发起,yida_pub_account 不触发钉钉待办通知。 ydClient.operateData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .userId(whConf.getApprovalOriginatorUserId()) .deptId(StringUtils.defaultIfBlank(deptId, whConf.getApprovalOriginatorDeptId())) .formUuid(formUuid) .processCode(processCode) .formDataJson(JSON.toJSONString(formData)) .build(), YDConf.FORM_OPERATION.start); } /** * 查该月 + PM/deptManager + RUNNING 的审批单;分页取全 */ private List queryProcessRunning(String formUuid, Map searchFieldJson) { List all = new ArrayList<>(); int page = 1; while (page <= 20) { DDR_New r = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(formUuid) .searchFieldJson(JSON.toJSONString(searchFieldJson)) .instanceStatus("RUNNING") .pageNumber(page) .pageSize(100) .build(), YDConf.FORM_QUERY.retrieve_search_process); List data = (List) r.getData(); if (data == null || data.isEmpty()) { break; } all.addAll(data); if (data.size() < 100) { break; } page++; } return all; } /** * 查指定条件下全部状态的审批单,供补发起前构建覆盖索引。 */ private List queryProcessAll(String formUuid, Map searchFieldJson) { List all = new ArrayList<>(); int page = 1; while (page <= 100) { DDR_New r = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(formUuid) .searchFieldJson(JSON.toJSONString(searchFieldJson)) .pageNumber(page) .pageSize(100) .build(), YDConf.FORM_QUERY.retrieve_search_process); List data = (List) r.getData(); if (data == null || data.isEmpty()) { break; } all.addAll(data); if (data.size() < 100) { break; } page++; } return all; } /** * 汇总表分页取全 */ List queryAll(String formUuid, Map searchFieldJson) { List all = new ArrayList<>(); int page = 1; while (page <= 200) { DDR_New r = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(formUuid) .searchFieldJson(JSON.toJSONString(searchFieldJson)) .currentPage(page) .pageSize(100) .build(), YDConf.FORM_QUERY.retrieve_search_form); List data = (List) r.getData(); if (data == null || data.isEmpty()) { break; } all.addAll(data); if (data.size() < 100) { break; } page++; } return all; } // ============================================================ // 通用工具 // ============================================================ @SuppressWarnings("unchecked") private List listOrEmpty(Map m, String k) { if (m == null) { return Collections.emptyList(); } Object v = m.get(k); if (v instanceof List) { return (List) v; } return Collections.emptyList(); } private double num(Map m, String k) { Object v = m == null ? null : m.get(k); if (v == null || "".equals(v)) { return 0d; } try { return Double.parseDouble(String.valueOf(v)); } catch (NumberFormatException e) { return 0d; } } private long numLong(Map m, String k) { return (long) num(m, k); } private String str(Map m, String k) { Object v = m == null ? null : m.get(k); return v == null ? "" : String.valueOf(v); } /** * 从 EmployeeField 值(数组)取第一个 userId;取不到时兜底返回文本 */ private String firstIdOrText(Map m, String fieldPrefix) { Object idArr = m == null ? null : m.get(fieldPrefix + "_id"); if (idArr instanceof List && !((List) idArr).isEmpty()) { Object first = ((List) idArr).get(0); if (first != null) { return String.valueOf(first); } } Object nameArr = m == null ? null : m.get(fieldPrefix); if (nameArr instanceof List && !((List) nameArr).isEmpty()) { Object first = ((List) nameArr).get(0); if (first != null) { return String.valueOf(first); } } return ""; } private String firstNonBlank(String... vals) { for (String v : vals) { if (StringUtils.isNotBlank(v)) { return v; } } return ""; } /** * yyyyMM → epoch ms(该月 1 号 00:00) */ private long monthToTs(String monthText) { int y = Integer.parseInt(monthText.substring(0, 4)); int m = Integer.parseInt(monthText.substring(4, 6)); Calendar c = Calendar.getInstance(); c.set(y, m - 1, 1, 0, 0, 0); c.set(Calendar.MILLISECOND, 0); return c.getTimeInMillis(); } }