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.service.dingtalk.DDClient; import com.malk.service.dingtalk.DDClient_Contacts; 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.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.*; /** * 补发起工时审批(bug 1 兜底) *

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

* 逻辑完全参考前端 TimeCard 的 submitApprovalProcess: * 1. collectApprovalData — 遍历汇总表每天子表,delta = submitted - approved - change, delta>0 才 push; * 普通 PM 按审批人分组,Raymond 兜底按项目拆组 * 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; @Autowired private DDClient ddClient; @Autowired private DDClient_Contacts ddClient_contacts; /** * 前端 v2.35 常量:发起时明细「审批意见」默认「同意」 */ private static final String APPROVAL_OPINION_AGREE = "同意"; private static final String SUMMARY_STATUS_IN_APPROVAL = "审批中"; private static final String SUMMARY_STATUS_DRAFT = "暂存"; private static final String SUMMARY_STATUS_APPROVED = "已审批"; private static final String S_BIL_SUBMITTED_TOTAL = "numberField_mmacxewa"; private static final String S_BIL_APPROVED_TOTAL = "numberField_mmacxewg"; private static final String S_NON_SUBMITTED_TOTAL = "numberField_mmad6jbz"; private static final String S_NON_APPROVED_TOTAL = "numberField_mmad6jc1"; private static final String S_OTHER_SUBMITTED_TOTAL = "numberField_mmd1smeq"; private static final String S_OTHER_APPROVED_TOTAL = "numberField_mmd1smes"; /** * 宜搭单个子表组件可稳定查询和覆盖更新的最大行数。 */ private static final int MAX_APPROVAL_SUBTABLE_ROWS = 500; /** * 批量历史补偿的目标分片大小;以员工整组为边界,允许单个员工整组略超目标值。 */ private static final int TARGET_APPROVAL_SUBTABLE_ROWS = 300; /** * 兜底部门主管 userId(Raymond,前端 v2.42 保持一致) */ private static final String DEFAULT_MANAGER_ID = "634735129"; private static final String PERSONNEL_EMPLOYEE = "employeeField_mkow4ydp"; private static final String PERSONNEL_MANAGER = "employeeField_mh8xhqc3"; // ===== 汇总表字段(与 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; } } /** * 某员工某月的项目工时组;Raymond 兜底组带项目标识做项目隔离 */ private static class ManagerGroup { String managerId; String projectCode; String projectName; 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) { return resubmit(monthText, submitterUserId, false); } /** * 对指定月份、指定员工执行补发起或只读预演。 * * @param monthText yyyyMM * @param submitterUserId 可空;空则处理该月所有员工 * @param dryRun true 时只统计候选,不创建或更新审批 * @return 执行或预演统计 */ public ReSubmitApprovalResult resubmit(String monthText, String submitterUserId, boolean dryRun) { 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); Map projectManagerCache = new HashMap<>(); // 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; int billableCandidates = 0, otherCandidates = 0; double billableCandidateHours = 0, otherCandidateHours = 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, projectManagerCache); if (cr.managerGroups.isEmpty() && cr.otherRows.isEmpty()) { skipped++; continue; } // 3a) 项目工时:普通 PM 按人合单,Raymond 兜底按项目拆单 for (ManagerGroup mg : cr.managerGroups.values()) { billableCandidates += mg.billableRows.size() + mg.nonBillableRows.size(); billableCandidateHours += mg.billableTotal + mg.nonBillableTotal; if (dryRun) { continue; } try { List actions = processManagerApproval(mg, monthText, uid); for (String action : actions) { if ("created".equals(action)) { billableCreated++; } else if ("updated".equals(action)) { billableUpdated++; } } } catch (Exception ex) { failed++; log.error("[补发起] 项目工时发起失败 uid={} pm={} project={} month={}", uid, mg.managerId, mg.projectCode, monthText, ex); } } // 3b) 其他工时:一人一张单,approver 用该员工部门主管(记录中带),否则兜底 Raymond if (!cr.otherRows.isEmpty()) { otherCandidates += cr.otherRows.size(); for (OtherRow row : cr.otherRows) { otherCandidateHours += row.hours; } if (dryRun) { continue; } try { List actions = processOtherApproval( cr.otherRows, cr.deptManagerId, monthText, uid); for (String action : actions) { 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); } } // 4) 三档状态按汇总总额统一校正;覆盖无缺单但状态为空/错误的历史记录。 StatusSyncResult statusSync = reconcileSummaryApprovalStatuses(allRecords, dryRun); ReSubmitApprovalResult r = ReSubmitApprovalResult.builder() .monthText(monthText) .submitterCount(perSubmitter.size()) .dryRun(dryRun) .billableCandidates(billableCandidates) .billableCandidateHours(billableCandidateHours) .otherCandidates(otherCandidates) .otherCandidateHours(otherCandidateHours) .billableCreated(billableCreated) .billableUpdated(billableUpdated) .otherCreated(otherCreated) .otherUpdated(otherUpdated) .skipped(skipped) .failed(failed) .summaryStatusCandidates(statusSync.candidates) .summaryStatusUpdated(statusSync.updated) .summaryStatusFailed(statusSync.failed) .build(); log.info("[补发起] 完成 {}", r); return r; } /** * 查询最近若干自然日发生修改的汇总记录,并按实际业务月份归集员工。 *

* prd 定时兜底只跟踪增量修改,不按最近若干业务月份全量回扫;因此历史月份记录只要近期有变化也会命中。 * * @param lookbackDays 最近自然日数量,最小按 1 天处理 * @return monthText -> submitterUserId 集合 */ @SuppressWarnings("unchecked") public Map> findRecentlyModifiedTargets(int lookbackDays) { LocalDate today = LocalDate.now(); String[] window = modifiedDateWindow(lookbackDays, today); List records = queryAllModified( whConf.getFormUuidWorkHoursSummary(), window[0], window[1]); Map> targets = new TreeMap<>(); for (Map record : records) { Object rawFormData = record.get("formData"); if (!(rawFormData instanceof Map)) { continue; } Map formData = (Map) rawFormData; String monthText = str(formData, S_MONTH_TEXT); String submitterUserId = str(formData, S_SUBMITTER_ID); if (!monthText.matches("\\d{6}") || StringUtils.isBlank(submitterUserId)) { continue; } targets.computeIfAbsent(monthText, key -> new LinkedHashSet<>()).add(submitterUserId); } log.info("[补发起兜底] 增量扫描 from={} to={} records={} months={} targets={}", window[0], window[1], records.size(), targets.size(), targets.values().stream().mapToInt(Set::size).sum()); return targets; } /** * 计算最近 N 个自然日的宜搭修改时间查询窗口,结束日加 1 以覆盖当天全天。 */ static String[] modifiedDateWindow(int lookbackDays, LocalDate today) { int safeDays = Math.max(1, lookbackDays); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); LocalDate fromDate = today.minusDays(safeDays - 1L); LocalDate toDate = today.plusDays(1L); return new String[]{fromDate.format(formatter), toDate.format(formatter)}; } /** * 使用真人 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); List> shards = billable ? splitApprovalStartFormData(formData) : splitOtherApprovalStartFormData(formData); for (Map shard : shards) { startProcessInstance( request.getFormUuid(), request.getProcessCode(), shard, request.getDeptId()); } return true; } private List> splitApprovalStartFormData(Map formData) { List billableRows = listOrEmpty(formData, A_BIL_TABLE); List nonBillableRows = listOrEmpty(formData, A_NON_TABLE); List rowShards = splitRowsByHardLimit(billableRows, nonBillableRows); List> result = new ArrayList<>(); for (RowShard rowShard : rowShards) { Map shard = new HashMap<>(formData); shard.remove(A_BIL_TABLE); shard.remove(A_NON_TABLE); putRowsIfPresent(shard, A_BIL_TABLE, rowShard.firstRows); putRowsIfPresent(shard, A_NON_TABLE, rowShard.secondRows); double billableHours = sumHours(rowShard.firstRows, A_BIL_HOURS); double nonBillableHours = sumHours(rowShard.secondRows, A_NON_HOURS); shard.put(A_PROJECT_HOURS, billableHours); shard.put(A_NON_PROJECT_HOURS, nonBillableHours); shard.put(A_TOTAL_HOURS, billableHours + nonBillableHours); result.add(shard); } return result; } private List> splitOtherApprovalStartFormData(Map formData) { List rows = listOrEmpty(formData, O_SUB_TABLE); List rowShards = splitRowsByHardLimit(rows, Collections.emptyList()); List> result = new ArrayList<>(); for (RowShard rowShard : rowShards) { Map shard = new HashMap<>(formData); shard.remove(O_SUB_TABLE); putRowsIfPresent(shard, O_SUB_TABLE, rowShard.firstRows); shard.put(O_TOTAL_HOURS, sumHours(rowShard.firstRows, O_SUB_HOURS)); result.add(shard); } return result; } private void putRowsIfPresent(Map formData, String tableFieldId, List rows) { if (!rows.isEmpty()) { formData.put(tableFieldId, rows); } } private double sumHours(List rows, String hoursFieldId) { double total = 0; for (Map row : rows) { total += num(row, hoursFieldId); } return total; } /** * collectApprovalData + collectOtherApprovalData 的等价: * 遍历该员工该月每天记录,按项目工时 / 其他工时反构造 delta 行。 * 普通 PM 按审批人分组,Raymond 兜底按项目拆组;项目编号为空时使用项目名称。 */ private static class CollectResult { Map managerGroups = new LinkedHashMap<>(); List otherRows = new ArrayList<>(); String deptManagerId; } private static class StatusSyncResult { int candidates; int updated; int failed; } /** * 按主表提交/审批总额把状态统一校正为暂存、审批中或已审批。 */ @SuppressWarnings("unchecked") private StatusSyncResult reconcileSummaryApprovalStatuses(List records, boolean dryRun) { StatusSyncResult result = new StatusSyncResult(); String fieldId = whConf.getSummaryApprovalStatusField(); if (StringUtils.isBlank(fieldId)) { return result; } for (Map record : records) { Object rawFormData = record.get("formData"); if (!(rawFormData instanceof Map)) { continue; } Map formData = (Map) rawFormData; String targetStatus = expectedSummaryApprovalStatus(formData); String currentStatus = str(formData, fieldId); String formInstanceId = firstNonBlank( str(record, "formInstanceId"), str(record, "formInstId")); if (StringUtils.isBlank(formInstanceId) || targetStatus.equals(currentStatus)) { continue; } result.candidates++; if (dryRun) { continue; } try { Map updateData = new HashMap<>(); updateData.put(fieldId, targetStatus); 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); result.updated++; } catch (Exception ex) { result.failed++; log.error("[补发起] 汇总三档状态校正失败 formInstanceId={} target={}", formInstanceId, targetStatus, ex); } } return result; } /** * 根据汇总主表三类已提交/已审批总额计算状态。 */ static String expectedSummaryApprovalStatus(Map formData) { double submitted = num(formData, S_BIL_SUBMITTED_TOTAL) + num(formData, S_NON_SUBMITTED_TOTAL) + num(formData, S_OTHER_SUBMITTED_TOTAL); double approved = num(formData, S_BIL_APPROVED_TOTAL) + num(formData, S_NON_APPROVED_TOTAL) + num(formData, S_OTHER_APPROVED_TOTAL); if (submitted > approved + 1e-6) { return SUMMARY_STATUS_IN_APPROVAL; } if (approved > 1e-6) { return SUMMARY_STATUS_APPROVED; } if (submitted > 1e-6) { return SUMMARY_STATUS_IN_APPROVAL; } return SUMMARY_STATUS_DRAFT; } private CollectResult collectApprovalData(String uid, String monthText, List records, ApprovalCoverage approvalCoverage, Map projectManagerCache) { 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 projectName = firstNonBlank( str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME)); String coverageKey = approvalKey("billable", uid, dayText, approvalDetailKey(projectCode, projectName)); if (!seenBillableKeys.add(coverageKey) || approvalCoverage.shouldSkip(coverageKey, approved + change)) { continue; } String pmRaw = firstIdOrText(row, S_BIL_MANAGER); final String pm = projectManagerCache.computeIfAbsent( StringUtils.defaultString(pmRaw), this::resolveProjectManager); String groupKey = approvalGroupKey(pm, projectCode, projectName); ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> { ManagerGroup m = new ManagerGroup(); m.managerId = pm; m.projectCode = DEFAULT_MANAGER_ID.equals(pm) ? projectCode : ""; m.projectName = DEFAULT_MANAGER_ID.equals(pm) ? projectName : ""; return m; }); BillRow br = new BillRow(); br.dayText = dayText; br.dayTs = dayTs; br.projectCode = projectCode; br.projectName = projectName; 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 projectName = firstNonBlank( str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME)); String coverageKey = approvalKey("nonBillable", uid, dayText, approvalDetailKey(projectCode, projectName)); if (!seenNonBillableKeys.add(coverageKey) || approvalCoverage.shouldSkip(coverageKey, approved + change)) { continue; } String pmRaw = firstIdOrText(row, S_NON_MANAGER); final String pm = projectManagerCache.computeIfAbsent( StringUtils.defaultString(pmRaw), this::resolveProjectManager); String groupKey = approvalGroupKey(pm, projectCode, projectName); ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> { ManagerGroup m = new ManagerGroup(); m.managerId = pm; m.projectCode = DEFAULT_MANAGER_ID.equals(pm) ? projectCode : ""; m.projectName = DEFAULT_MANAGER_ID.equals(pm) ? projectName : ""; return m; }); BillRow br = new BillRow(); br.dayText = dayText; br.dayTs = dayTs; br.projectCode = projectCode; br.projectName = projectName; 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, approvalDetailKey(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; } /** * 生成项目工时审批分组键。 * * @param managerId 审批人 userId * @param projectCode 项目编号 * @param projectName 项目名称 * @return 普通 PM 返回 managerId;Raymond 返回项目隔离键,编号为空时用名称 */ static String approvalGroupKey(String managerId, String projectCode, String projectName) { if (DEFAULT_MANAGER_ID.equals(managerId)) { String projectIdentity = StringUtils.isNotBlank(projectCode) ? projectCode : "name:" + StringUtils.defaultString(projectName); return managerId + "|project|" + projectIdentity; } return managerId; } /** * prd 项目经理离职时,改由人员档案中的在职主管审批;仅在整条替补链路不可用时兜底 Raymond。 */ String resolveProjectManager(String managerId) { if (StringUtils.isBlank(managerId) || DEFAULT_MANAGER_ID.equals(managerId)) { return DEFAULT_MANAGER_ID; } String accessToken; try { accessToken = ddClient.getAccessToken(); } catch (Exception ex) { log.warn("[补发起] 获取钉钉 accessToken 失败,项目经理兜底 Raymond pm={} err={}", managerId, ex.getMessage()); return DEFAULT_MANAGER_ID; } if (isUserInService(accessToken, managerId)) { return managerId; } String supervisorId; try { supervisorId = queryPersonnelManagerId(managerId); } catch (Exception ex) { log.warn("[补发起] 查询离职 PM 人员档案主管失败,兜底 Raymond pm={} err={}", managerId, ex.getMessage()); return DEFAULT_MANAGER_ID; } if (StringUtils.isBlank(supervisorId)) { log.warn("[补发起] 离职 PM 未配置人员档案主管,兜底 Raymond pm={}", managerId); return DEFAULT_MANAGER_ID; } if (DEFAULT_MANAGER_ID.equals(supervisorId) || isUserInService(accessToken, supervisorId)) { log.info("[补发起] 离职 PM 改由人员档案主管审批 pm={} supervisor={}", managerId, supervisorId); return supervisorId; } log.warn("[补发起] 离职 PM 的人员档案主管也不在职,兜底 Raymond pm={} supervisor={}", managerId, supervisorId); return DEFAULT_MANAGER_ID; } private boolean isUserInService(String accessToken, String userId) { try { Map user = ddClient_contacts.getUserInfoById(accessToken, userId); return user != null && StringUtils.isNotBlank(str(user, "userid")); } catch (Exception ex) { log.info("[补发起] 钉钉用户不在职或查询失败 userId={} err={}", userId, ex.getMessage()); return false; } } private String queryPersonnelManagerId(String userId) { Map search = new HashMap<>(); search.put(PERSONNEL_EMPLOYEE, Collections.singletonList(userId)); DDR_New response = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(whConf.getFormUuidPersonnel()) .searchFieldJson(JSON.toJSONString(search)) .currentPage(1) .pageSize(1) .build(), YDConf.FORM_QUERY.retrieve_search_form); List records = response == null ? null : (List) response.getData(); if (records == null || records.isEmpty()) { return ""; } Object formData = records.get(0).get("formData"); if (!(formData instanceof Map)) { return ""; } return firstEmployeeId((Map) formData, PERSONNEL_MANAGER); } /** * 加载指定月份两类审批单的完整明细覆盖索引。 * 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, null, 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_PROJECT_NAME, 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_PROJECT_NAME, 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 detailFallbackField, String hoursField, String opinionField) { for (Map row : rows) { String uid = str(row, uidField); String day = str(row, dayField); String detailKey = approvalDetailKey( str(row, detailKeyField), detailFallbackField == null ? "" : str(row, detailFallbackField)); 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; } /** * 审批明细业务标识优先使用编号;历史数据编号为空时退回项目名称。 * * @param primary 编号或活动类型 * @param fallback 项目名称兜底 * @return 带来源前缀的稳定业务标识 */ static String approvalDetailKey(String primary, String fallback) { if (StringUtils.isNotBlank(primary)) { return "id:" + primary; } return "name:" + StringUtils.defaultString(fallback); } /** * processManagerApproval 等价 — queryProcess(RUNNING) 有则 update / 无则 create。 * COMPLETED 覆盖已在 collectApprovalData 前过滤,避免历史已完成单被重复发起。 */ private List processManagerApproval(ManagerGroup mg, String monthText, String submitterUid) { List actions = new ArrayList<>(); for (ManagerGroup shard : splitManagerGroupByHardLimit(mg)) { actions.add(processManagerApprovalShard(shard, monthText, submitterUid)); } return actions; } private String processManagerApprovalShard(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; } if (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); if (!approvalMatchesProject(existBil, existNon, mg.projectCode, mg.projectName)) { return false; } int existingCount = existingBillableCount + existingNonBillableCount; int mergedCount = billable.rows.size() + nonBillable.rows.size(); return canAppendToApprovalShard(existingCount, mergedCount); } /** * 创建新的工时审批实例(startProcessInstance) */ private void createApprovalInstance(ManagerGroup mg, String monthText, String submitterUid) { McException.assertAccessException( !hasSubtableCapacity( mg.billableRows.size() + 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={} project={} month={} bilTotal={} nonBilTotal={} rows={}+{}", submitterUid, mg.managerId, mg.projectCode, monthText, mg.billableTotal, mg.nonBillableTotal, billableSub.size(), nonBillableSub.size()); } /** * 累计更新已有 RUNNING 工时审批单(按唯一键 userId + dayText + projectCode 去重合并) */ private boolean 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 (!hasSubtableCapacity(mrBil.rows.size() + mrNon.rows.size())) { return false; } if (mrBil.addedHours == 0 && mrNon.addedHours == 0) { log.info("[补发起] 已存在等效明细,跳过更新 formInstanceId={}", formInstanceId); return true; } 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={} project={} addedBil={} addedNon={}", formInstanceId, mg.managerId, mg.projectCode, mrBil.addedHours, mrNon.addedHours); return true; } /** * 判断已有 Raymond 审批单是否只包含目标项目。 * * @param billableRows Billable 明细 * @param nonBillableRows Non Billable 明细 * @param projectCode Raymond 组的项目编号 * @param projectName Raymond 组的项目名称;编号为空时用于项目隔离,两者均空表示普通 PM 组 * @return 可否追加到该审批单 */ static boolean approvalMatchesProject(List billableRows, List nonBillableRows, String projectCode, String projectName) { if (StringUtils.isBlank(projectCode) && StringUtils.isBlank(projectName)) { return true; } int rowCount = 0; for (Map row : billableRows) { rowCount++; if (!matchesProject(row, A_BIL_PROJECT_CODE, A_BIL_PROJECT_NAME, projectCode, projectName)) { return false; } } for (Map row : nonBillableRows) { rowCount++; if (!matchesProject(row, A_NON_PROJECT_CODE, A_NON_PROJECT_NAME, projectCode, projectName)) { return false; } } return rowCount > 0; } private static boolean matchesProject(Map row, String codeField, String nameField, String projectCode, String projectName) { String rowProjectCode = str(row, codeField); if (StringUtils.isNotBlank(projectCode)) { return projectCode.equals(rowProjectCode); } return StringUtils.isBlank(rowProjectCode) && StringUtils.equals(projectName, str(row, nameField)); } /** * 其他工时审批处理(与 processManagerApproval 同构,但 approver 为员工的部门主管) */ private List processOtherApproval(List otherRows, String deptManagerId, String monthText, String submitterUid) { List actions = new ArrayList<>(); for (List shard : splitListByHardLimit(otherRows)) { actions.add(processOtherApprovalShard( shard, deptManagerId, monthText, submitterUid)); } return actions; } private String processOtherApprovalShard(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; } if (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 boolean 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 (!hasSubtableCapacity(merged.size())) { return false; } if (addedHours == 0) { log.info("[补发起] 其他工时无新增明细 formInstanceId={}", formInstanceId); return true; } 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); return true; } // ============================================================ // 子表行构造 + 合并 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; } private static class RowShard { List firstRows = new ArrayList<>(); List secondRows = new ArrayList<>(); int size() { return firstRows.size() + secondRows.size(); } } private List splitRowsByHardLimit(List firstRows, List secondRows) { List shards = new ArrayList<>(); shards.add(new RowShard()); for (Map row : firstRows) { appendRowToShard(shards, row, true); } for (Map row : secondRows) { appendRowToShard(shards, row, false); } return shards; } private void appendRowToShard(List shards, Map row, boolean firstTable) { RowShard current = shards.get(shards.size() - 1); if (current.size() >= MAX_APPROVAL_SUBTABLE_ROWS) { current = new RowShard(); shards.add(current); } (firstTable ? current.firstRows : current.secondRows).add(row); } private List splitManagerGroupByHardLimit(ManagerGroup source) { if (source.billableRows.size() + source.nonBillableRows.size() <= MAX_APPROVAL_SUBTABLE_ROWS) { return Collections.singletonList(source); } List shards = new ArrayList<>(); shards.add(newManagerGroup(source.managerId, source.projectCode, source.projectName)); for (BillRow row : source.billableRows) { appendManagerRow(shards, row, true); } for (BillRow row : source.nonBillableRows) { appendManagerRow(shards, row, false); } return shards; } private ManagerGroup newManagerGroup(String managerId, String projectCode, String projectName) { ManagerGroup group = new ManagerGroup(); group.managerId = managerId; group.projectCode = projectCode; group.projectName = projectName; return group; } private void appendManagerRow(List shards, BillRow row, boolean billable) { ManagerGroup current = shards.get(shards.size() - 1); if (current.billableRows.size() + current.nonBillableRows.size() >= MAX_APPROVAL_SUBTABLE_ROWS) { current = newManagerGroup(current.managerId, current.projectCode, current.projectName); shards.add(current); } if (billable) { current.billableRows.add(row); current.billableTotal += row.hours; } else { current.nonBillableRows.add(row); current.nonBillableTotal += row.hours; } } private List> splitListByHardLimit(List rows) { List> shards = new ArrayList<>(); for (int from = 0; from < rows.size(); from += MAX_APPROVAL_SUBTABLE_ROWS) { int to = Math.min(from + MAX_APPROVAL_SUBTABLE_ROWS, rows.size()); shards.add(new ArrayList<>(rows.subList(from, to))); } return shards; } 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; } /** * 按宜搭 gmtModified 时间窗口分页查询表单实例。 */ @SuppressWarnings("unchecked") private List queryAllModified(String formUuid, String fromDate, String toDate) { List all = new ArrayList<>(); int page = 1; while (page <= 200) { DDR_New result = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(formUuid) .modifiedFromTimeGMT(fromDate) .modifiedToTimeGMT(toDate) .currentPage(page) .pageSize(100) .build(), YDConf.FORM_QUERY.retrieve_search_form); List data = result == null ? null : (List) result.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 static 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 static 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 firstEmployeeId(Map m, String fieldPrefix) { Object idArr = m == null ? null : m.get(fieldPrefix + "_id"); if (!(idArr instanceof List) || ((List) idArr).isEmpty()) { return ""; } Object first = ((List) idArr).get(0); return first == null ? "" : String.valueOf(first); } 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(); } }