package com.malk.service.workhours; import com.alibaba.fastjson.JSON; import com.google.common.util.concurrent.RateLimiter; import com.malk.server.aliwork.YDConf; import com.malk.server.aliwork.YDParam; import com.malk.server.common.McException; import com.malk.server.dingtalk.DDR_New; import com.malk.server.workhours.ApprovalWriteBackResult; import com.malk.server.workhours.WHConf; import com.malk.service.aliwork.YDClient; import com.malk.service.aliwork.YDService; import com.malk.utils.UtilMap; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; /** * 审批结果回写工时汇总表 *

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

* 设计文档:后端/阿科德斯/审批结果回写工时汇总表/02-程序实现与接口.md */ @Slf4j @Service public class ApprovalWriteBackService { @Autowired private YDClient ydClient; @Autowired private YDService ydService; @Autowired private WHConf whConf; @Autowired private RepairApprovalWriteBackService repairApprovalWriteBackService; private static final int THREAD_POOL_SIZE = 10; /** * 明细审批意见「同意」取值(单选,已确认) */ private static final String OPINION_APPROVE = "同意"; // ===== 工时汇总表:记录定位键 + 主表合计字段 ===== private static final String SUMMARY_SUBMITTER_ID = "textField_mmbffvd9"; // 填报人ID(文本) private static final String SUMMARY_DAY_TEXT = "textField_mmbffvdb"; // 工时日期(文本) yyyyMMdd private static final String M_DAY_SUBMITTED = "numberField_mmd1smem"; // 已提交工时(日) private static final String M_DAY_APPROVED = "numberField_mmeakgi4"; // 已审批工时(日) private static final String M_BIL_SUBMITTED = "numberField_mmacxewa"; // Billable已提交 private static final String M_BIL_APPROVED = "numberField_mmacxewg"; // Billable已审批 private static final String M_NON_SUBMITTED = "numberField_mmad6jbz"; // Non Billable已提交 private static final String M_NON_APPROVED = "numberField_mmad6jc1"; // Non Billable已审批 private static final String M_OTH_SUBMITTED = "numberField_mmd1smeq"; // 已提交(其他) private static final String M_OTH_APPROVED = "numberField_mmd1smes"; // 已审批(其他) // ===== 工时汇总表:三个子表 ===== private static final String SUB_BIL_TABLE = "tableField_mmczo634"; private static final String SUB_BIL_KEY = "textField_mmacxew3"; // 项目编号 private static final String SUB_BIL_SUBMITTED = "numberField_mmczo636"; // 已提交工时 private static final String SUB_BIL_APPROVED = "numberField_mmczo637"; // 已审批工时 private static final String SUB_NON_TABLE = "tableField_mmczo63h"; private static final String SUB_NON_KEY = "textField_mmczo639"; // 项目编号 private static final String SUB_NON_SUBMITTED = "numberField_mmczo63c"; private static final String SUB_NON_APPROVED = "numberField_mmczo63d"; private static final String SUB_OTH_TABLE = "tableField_mmeakgid"; private static final String SUB_OTH_KEY = "selectField_mmeakgie"; // Activity private static final String SUB_OTH_SUBMITTED = "numberField_mmeakgij"; private static final String SUB_OTH_APPROVED = "numberField_mmeakgii"; // ===== 审批单明细子表字段(按类别)===== // 工时审批·项目工时明细(Billable) private static final String AP_BIL_TABLE = "tableField_mmae8t99"; private static final String AP_BIL_USERID = "textField_mmd1ozk4"; private static final String AP_BIL_KEY = "textField_mmacxew3"; // 项目编号 private static final String AP_BIL_DAYTEXT = "textField_mmd8g654"; private static final String AP_BIL_HOURS = "numberField_mmacxew9"; private static final String AP_BIL_OPINION = "radioField_mpwhk294"; // 工时审批·非项目工时明细(Non Billable) private static final String AP_NON_TABLE = "tableField_mmd1wu9h"; private static final String AP_NON_USERID = "textField_mmd1wu9f"; private static final String AP_NON_KEY = "textField_mmd1wu9d"; // 项目编号 private static final String AP_NON_DAYTEXT = "textField_mmd8g655"; private static final String AP_NON_HOURS = "numberField_mmd1wu9e"; private static final String AP_NON_OPINION = "radioField_mpwhk295"; // 其他工时审批·明细(与非项目同子表ID,但语义为 Activity) private static final String AP_OTH_TABLE = "tableField_mmd1wu9h"; private static final String AP_OTH_USERID = "textField_mmd1wu9f"; private static final String AP_OTH_KEY = "selectField_mmeakgie"; // Activity private static final String AP_OTH_DAYTEXT = "textField_mmd8g655"; private static final String AP_OTH_HOURS = "numberField_mmd1wu9e"; private static final String AP_OTH_OPINION = "radioField_mpwhk294"; private enum Cat {BILLABLE, NON_BILLABLE, OTHER} /** * 单条审批明细(归一化) */ private static class DetailRow { Cat cat; String userId; String dayText; String key; // 项目编号 / Activity double hours; boolean approve; // 有效意见:true=同意, false=拒绝 } /** * 异步触发审批回写,供流程结束自动化调用。 * * @param formInstanceId 审批表单实例 ID * @param result 审批结果:0=拒绝/撤销,1=同意 */ @Async public void writeBackAsync(String formInstanceId, int result) { writeBack(formInstanceId, result); } /** * 审批结果回写工时汇总表 * * @param formInstanceId 流程实例ID * @param result 审批结果:0=拒绝/撤销,1=同意 * @return 回写结果统计 */ public ApprovalWriteBackResult writeBack(String formInstanceId, int result) { McException.assertAccessException(StringUtils.isBlank(formInstanceId), "实例ID不能为空"); // 1. 流程详情接口优先,流程表单可直接返回完整子表;旧回调传表单实例ID时回退表单详情接口。 Map approvalFormData = queryApprovalFormData(formInstanceId); log.info("[审批回写] 审批单详情 formInstanceId={} keys={}", formInstanceId, approvalFormData == null ? null : approvalFormData.keySet()); McException.assertAccessException(approvalFormData == null || approvalFormData.isEmpty(), "未查询到审批单数据,实例ID=" + formInstanceId); // prd 补填审批只走独立回写:来源日写补填汇总,提交日写原汇总变更工时。 // 正常审批(工时类型为空/正常)继续执行下方原有逻辑。 String fillType = whConf.getApprovalFillTypeField() == null ? "" : String.valueOf(approvalFormData.get(whConf.getApprovalFillTypeField())); if ("补填".equals(fillType) && repairApprovalWriteBackService != null) { return repairApprovalWriteBackService.writeBack(formInstanceId, result); } // 2. 类型判定:不能只看项目工时子表;仅填非项目工时的工时审批也可能没有 AP_BIL_TABLE。 boolean isOther = isOtherApprovalForm(approvalFormData); String approvalFormUuid = isOther ? whConf.getFormUuidOtherApproval() : whConf.getFormUuidApproval(); boolean approveWhole = (result == 1); // 3. 取审批单明细(内联子表 <50 直接用,==50 才 queryDetails 递归取全,避免无效请求) List rows = new ArrayList<>(); if (isOther) { rows.addAll(loadDetailRows( formInstanceId, approvalFormUuid, approvalFormData, Cat.OTHER, approveWhole)); } else { rows.addAll(loadDetailRows( formInstanceId, approvalFormUuid, approvalFormData, Cat.BILLABLE, approveWhole)); rows.addAll(loadDetailRows( formInstanceId, approvalFormUuid, approvalFormData, Cat.NON_BILLABLE, approveWhole)); } // 4. 按汇总表记录键(填报人 + 工时日期文本)分组 Map> groups = new LinkedHashMap<>(); int invalidRows = 0; for (DetailRow r : rows) { if (StringUtils.isBlank(r.userId) || StringUtils.isBlank(r.dayText)) { invalidRows++; continue; } groups.computeIfAbsent(r.userId + "" + r.dayText, k -> new ArrayList<>()).add(r); } // 5. 并发逐「日记录」处理 AtomicInteger hitRecords = new AtomicInteger(0); AtomicInteger updatedRows = new AtomicInteger(0); AtomicInteger missRows = new AtomicInteger(0); AtomicInteger failRecords = new AtomicInteger(0); RateLimiter limiter = RateLimiter.create(20.0); ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE); try { List> futures = new ArrayList<>(); for (Map.Entry> e : groups.entrySet()) { List groupRows = e.getValue(); String userId = groupRows.get(0).userId; String dayText = groupRows.get(0).dayText; futures.add(executor.submit(() -> { try { int[] dayResult = applyToRecord(userId, dayText, groupRows, limiter); if (dayResult == null) { // fixme 审批明细存在但汇总表日记录缺失属于失败,不能把审批单标记为「全部成功」。 failRecords.incrementAndGet(); return; } hitRecords.incrementAndGet(); updatedRows.addAndGet(dayResult[0]); missRows.addAndGet(dayResult[1]); if (dayResult[1] > 0) { failRecords.incrementAndGet(); } } catch (Exception ex) { failRecords.incrementAndGet(); log.error("[审批回写] 记录处理失败,交由定时器本轮结束后补试 userId={} day={}", userId, dayText, ex); } })); } for (Future f : futures) { try { f.get(); } catch (Exception ex) { log.error("[审批回写] 线程执行异常", ex); } } } finally { executor.shutdown(); } // 6. 同步状态汇总(syncTotal=分组数;failCount=失败组数) // fixme 状态字面值对齐宜搭审批表 selectField_mq58cd5p 选项:"全部成功" / "部分失败" / "全部失败" int syncTotal = groups.size() + invalidRows; int failCount = failRecords.get() + invalidRows; // fixme 审批明细为空同样不能标记为成功,否则兜底定时会永久跳过该异常实例。 if (syncTotal == 0) { syncTotal = 1; failCount = 1; } String syncStatus = determineSyncStatus(syncTotal, failCount); ApprovalWriteBackResult stats = ApprovalWriteBackResult.builder() .formType(isOther ? "其他工时审批" : "工时审批") .formInstanceId(formInstanceId) .result(result) .detailRows(rows.size()) .groups(groups.size()) .hitRecords(hitRecords.get()) .updatedRows(updatedRows.get()) .missRows(missRows.get()) .failRecords(failCount) .syncStatus(syncStatus) .syncTotal(syncTotal) .failCount(failCount) .build(); log.info("[审批回写] 完成 {}", stats); // 7. 延迟 2s 等宜搭审批结束的内部同步索引/锁释放,再回写审批单同步字段,避免触发同步锁失败 sleep(2000); writebackSyncFields(isOther, formInstanceId, syncStatus, syncTotal, failCount); return stats; } static String determineSyncStatus(int syncTotal, int failCount) { if (syncTotal <= 0 || failCount >= syncTotal) { return "全部失败"; } return failCount > 0 ? "部分失败" : "全部成功"; } private Map queryApprovalFormData(String instanceId) { try { DDR_New processDetail = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .processInstanceId(instanceId) .build(), YDConf.FORM_QUERY.retrieve_search_process_id); Map processFormData = extractProcessFormData(processDetail); if (processFormData != null && !processFormData.isEmpty()) { return processFormData; } } catch (Exception ex) { log.warn("[审批回写] 流程详情查询失败,回退表单详情 instanceId={}", instanceId, ex); } try { DDR_New legacyProcessDetail = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .processInstanceId(instanceId) .build(), YDConf.FORM_QUERY.retrieve_process_legacy_id); Map legacyFormData = extractProcessFormData(legacyProcessDetail); if (legacyFormData != null && !legacyFormData.isEmpty()) { return legacyFormData; } } catch (Exception ex) { log.warn("[审批回写] 旧版流程详情查询失败,回退表单详情 instanceId={}", instanceId, ex); } DDR_New formDetail = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(instanceId) .build(), YDConf.FORM_QUERY.retrieve_id); return formDetail == null ? null : formDetail.getFormData(); } @SuppressWarnings("unchecked") static Map extractProcessFormData(DDR_New processDetail) { if (processDetail == null) { return null; } if (processDetail.getFormData() != null && !processDetail.getFormData().isEmpty()) { return processDetail.getFormData(); } if (processDetail.getResult() instanceof Map) { Map result = (Map) processDetail.getResult(); Object nested = result.get("formData"); if (!(nested instanceof Map)) { nested = result.get("data"); } if (nested instanceof Map) { return (Map) nested; } if (result.containsKey(AP_BIL_TABLE) || result.containsKey(AP_NON_TABLE) || result.containsKey(AP_OTH_TABLE)) { return result; } } if (!(processDetail.getData() instanceof Map)) { return null; } Map data = (Map) processDetail.getData(); Object nested = data.get("formData"); if (!(nested instanceof Map)) { nested = data.get("data"); } if (nested instanceof Map) { return (Map) nested; } if (data.containsKey(AP_BIL_TABLE) || data.containsKey(AP_NON_TABLE) || data.containsKey(AP_OTH_TABLE)) { return data; } return null; } /** * 区分「工时审批」与「其他工时审批」。 * 两者共用 tableField_mmd1wu9h,但明细键分别为项目编号和 Activity。 */ @SuppressWarnings("unchecked") static boolean isOtherApprovalForm(Map approvalFormData) { if (approvalFormData == null) { return false; } if (approvalFormData.containsKey(AP_BIL_TABLE)) { return false; } Object sharedRows = approvalFormData.get(AP_NON_TABLE); if (sharedRows instanceof List) { for (Object item : (List) sharedRows) { if (!(item instanceof Map)) { continue; } Map row = (Map) item; if (row.containsKey(AP_NON_KEY)) { return false; } if (row.containsKey(AP_OTH_KEY)) { return true; } } } return true; } /** * 把同步状态/同步总数/失败数量回写到原审批单(fieldId 走 WHConf 配置) * fixme 三个 fieldId 任一为空则跳过该类别回写,允许业务方分阶段在宜搭后台建字段 */ private void writebackSyncFields(boolean isOther, String formInstanceId, String syncStatus, int syncTotal, int failCount) { String statusField = isOther ? whConf.getOtherApprovalSyncStatusField() : whConf.getApprovalSyncStatusField(); String totalField = isOther ? whConf.getOtherApprovalSyncTotalField() : whConf.getApprovalSyncTotalField(); String failField = isOther ? whConf.getOtherApprovalSyncFailField() : whConf.getApprovalSyncFailField(); if (StringUtils.isAnyBlank(statusField, totalField, failField)) { log.info("[审批回写] 跳过审批单同步字段回写(配置未启用) isOther={} formInstanceId={}", isOther, formInstanceId); return; } try { Map sync = new HashMap<>(); sync.put(statusField, syncStatus); sync.put(totalField, syncTotal); sync.put(failField, failCount); ydClient.operateData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(formInstanceId) .updateFormDataJson(JSON.toJSONString(sync)) .ignoreEmpty(false) .useLatestVersion(true) .build(), YDConf.FORM_OPERATION.update); log.info("[审批回写] 审批单同步字段已回写 isOther={} formInstanceId={} status={} total={} fail={}", isOther, formInstanceId, syncStatus, syncTotal, failCount); } catch (Exception ex) { log.error("[审批回写] 审批单同步字段回写失败 isOther={} formInstanceId={}", isOther, formInstanceId, ex); throw new IllegalStateException("审批单同步字段回写失败 formInstanceId=" + formInstanceId, ex); } } /** * 处理单条汇总表日记录 * * @return {updatedRows, missRows};汇总表无此记录返回 null */ private int[] applyToRecord(String userId, String dayText, List groupRows, RateLimiter limiter) { // a. 定位汇总表记录(填报人ID + 工时日期文本,代码内精确过滤) limiter.acquire(); List found = (List) ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(whConf.getFormUuidWorkHoursSummary()) .searchFieldJson(JSON.toJSONString(UtilMap.map( SUMMARY_SUBMITTER_ID + ", " + SUMMARY_DAY_TEXT, userId, dayText))) .build(), YDConf.FORM_QUERY.retrieve_search_form).getData(); List matchingIds = new ArrayList<>(); for (Map item : found == null ? Collections.emptyList() : found) { Map fd = (Map) item.get("formData"); if (fd == null) continue; if (userId.equals(String.valueOf(fd.get(SUMMARY_SUBMITTER_ID))) && dayText.equals(String.valueOf(fd.get(SUMMARY_DAY_TEXT)))) { Object id = item.get("formInstanceId"); if (id == null) { id = item.get("formInstId"); } if (id != null && StringUtils.isNotBlank(String.valueOf(id))) { matchingIds.add(String.valueOf(id)); } } } McException.assertAccessException( matchingIds.size() > 1, "工时汇总表唯一键重复,拒绝回写 userId=" + userId + ", day=" + dayText); String summaryInstId = matchingIds.isEmpty() ? null : matchingIds.get(0); if (StringUtils.isBlank(summaryInstId)) { log.warn("[审批回写] 未命中汇总表记录 userId={} day={}", userId, dayText); return null; } // b. 取汇总表记录完整数据(含三子表) limiter.acquire(); Map formData = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(summaryInstId) .build(), YDConf.FORM_QUERY.retrieve_id).getFormData(); Map updateData = new HashMap<>(); int updated = 0; int miss = 0; // c. 按类别分组本记录的明细,逐类别重建子表 + 置值 Map> byCat = new EnumMap<>(Cat.class); for (DetailRow r : groupRows) { byCat.computeIfAbsent(r.cat, k -> new ArrayList<>()).add(r); } // 各类别已提交/已审批合计(受影响类别从重建行重算,未受影响沿用主表现值) double bilSubmitted = num(formData, M_BIL_SUBMITTED), bilApproved = num(formData, M_BIL_APPROVED); double nonSubmitted = num(formData, M_NON_SUBMITTED), nonApproved = num(formData, M_NON_APPROVED); double othSubmitted = num(formData, M_OTH_SUBMITTED), othApproved = num(formData, M_OTH_APPROVED); for (Map.Entry> e : byCat.entrySet()) { Cat cat = e.getKey(); String subTable = subTable(cat); String keyField = subKey(cat); String submittedField = subSubmitted(cat); String approvedField = subApproved(cat); // 子表取数控制:内联 <50 直接用,==50 才递归取全,避免全量覆盖时漏行被删 List rebuilt = rebuildRows( resolveDetailRows( summaryInstId, whConf.getFormUuidWorkHoursSummary(), subTable, (List) formData.get(subTable))); for (DetailRow dr : e.getValue()) { Map target = null; for (Map row : rebuilt) { if (dr.key != null && dr.key.equals(String.valueOf(row.get(keyField)))) { target = row; break; } } if (target == null) { miss++; log.warn("[审批回写] 子表行未匹配 cat={} key={} userId={} day={}", cat, dr.key, userId, dayText); continue; } double submittedBefore = num(target, submittedField); if (dr.approve) { target.put(approvedField, dr.hours); } // fixme 拒绝仍清零;同意时兜底恢复已提交,避免补发审批只写 approved 后形成 submitted=0。 target.put(submittedField, resolveSubmittedAfterWriteBack( dr.approve, submittedBefore, dr.hours)); updated++; } updateData.put(subTable, rebuilt); double sumSubmitted = sumRows(rebuilt, submittedField); double sumApproved = sumRows(rebuilt, approvedField); switch (cat) { case BILLABLE: bilSubmitted = sumSubmitted; bilApproved = sumApproved; break; case NON_BILLABLE: nonSubmitted = sumSubmitted; nonApproved = sumApproved; break; case OTHER: othSubmitted = sumSubmitted; othApproved = sumApproved; break; } } // d. 主表合计重算 updateData.put(M_BIL_SUBMITTED, bilSubmitted); updateData.put(M_BIL_APPROVED, bilApproved); updateData.put(M_NON_SUBMITTED, nonSubmitted); updateData.put(M_NON_APPROVED, nonApproved); updateData.put(M_OTH_SUBMITTED, othSubmitted); updateData.put(M_OTH_APPROVED, othApproved); updateData.put(M_DAY_SUBMITTED, bilSubmitted + nonSubmitted + othSubmitted); updateData.put(M_DAY_APPROVED, bilApproved + nonApproved + othApproved); // prd 审批回写完成后同步记录级审批状态,避免数字已回写但汇总状态仍停留在“审批中” String approvalStatusField = whConf.getSummaryApprovalStatusField(); if (StringUtils.isNotBlank(approvalStatusField)) { updateData.put( approvalStatusField, resolveSummaryApprovalStatus( bilSubmitted + nonSubmitted + othSubmitted, bilApproved + nonApproved + othApproved)); } // e. 全量覆盖更新(子表整组替换 + 主表合计) limiter.acquire(); ydClient.operateData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formInstanceId(summaryInstId) .updateFormDataJson(JSON.toJSONString(updateData)) .ignoreEmpty(false) .useLatestVersion(true) .build(), YDConf.FORM_OPERATION.update); return new int[]{updated, miss}; } /** * 计算工时汇总记录的审批状态。 * * @param submitted 已提交合计 * @param approved 已审批合计 * @return 已审批 / 审批中 / 暂存 */ static String resolveSummaryApprovalStatus(double submitted, double approved) { if (submitted > approved + 1e-6) { return "审批中"; } if (approved > 0) { return "已审批"; } if (submitted > 0) { return "审批中"; } return "暂存"; } /** * 计算审批回写后的已提交工时。 * * @param approve 是否审批同意 * @param currentSubmitted 当前已提交工时 * @param approvedHours 本次审批明细工时 * @return 拒绝时为 0;同意时不低于本次审批工时且不降低当前已提交值 */ static double resolveSubmittedAfterWriteBack( boolean approve, double currentSubmitted, double approvedHours) { if (!approve) { return 0d; } return Math.max(currentSubmitted, approvedHours); } /** * 加载审批单某类别明细(内联子表优先,必要时递归取全) */ private List loadDetailRows(String formInstanceId, String formUuid, Map approvalFormData, Cat cat, boolean approveWhole) { String tableId = apTable(cat); List raw = resolveDetailRows( formInstanceId, formUuid, tableId, (List) approvalFormData.get(tableId)); List list = new ArrayList<>(); for (Map row : raw) { DetailRow dr = new DetailRow(); dr.cat = cat; dr.userId = str(row, apUserId(cat)); dr.dayText = str(row, apDayText(cat)); dr.key = str(row, apKey(cat)); dr.hours = num(row, apHours(cat)); String opinion = str(row, apOpinion(cat)); // fixme 历史审批单可能没有逐行意见:整单同意时空意见按同意处理,仅显式非同意值按拒绝处理。 dr.approve = isDetailApproved(approveWhole, opinion); list.add(dr); } return list; } static boolean isDetailApproved(boolean approveWhole, String opinion) { return approveWhole && (StringUtils.isBlank(opinion) || OPINION_APPROVE.equals(opinion)); } /** * 子表取数控制:主表详情已内联返回子表时,行数 <50 视为完整直接用; * ==50(可能被宜搭截断)才调 {@link YDService#queryDetails} 递归取全,避免无效请求。 * 复用 ydService 封装,不重复造分页轮子。 *

* fixme inlineRows==null 表示审批单不含该子表(如其他工时审批单无 BILLABLE/NON_BILLABLE,或工时审批单某一子表未填): * 直接返回空 list,不再调 queryDetails——基座 _queryDetails 对宜搭返回 data=null 的空子表查询会触发 * ArrayList.addAll(null) NPE。queryDetails 返回 null 同样兜底空 list。 */ private List resolveDetailRows(String formInstanceId, String formUuid, String tableId, List inlineRows) { if (inlineRows == null) { return Collections.emptyList(); } if (inlineRows.size() < 50) { return inlineRows; } List full = ydService.queryDetails(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(formUuid) .formInstanceId(formInstanceId) .tableFieldId(tableId) .pageNumber(1) .build()); McException.assertAccessException( full == null || full.size() < inlineRows.size(), "审批单子表完整数据获取失败 instanceId=" + formInstanceId + ", tableFieldId=" + tableId); return full; } /** * 重建子表行:保留全部字段,成员/关联字段用 _id 取真实ID(全量覆盖写回前置) */ private List rebuildRows(List rows) { List out = new ArrayList<>(); if (rows == null) { return out; } for (Map row : rows) { Map newRow = new HashMap(); for (Object keyObj : row.keySet()) { String key = String.valueOf(keyObj); if (key.endsWith("_value") || (key.startsWith("employeeField_") && !key.endsWith("_id"))) { continue; } String k = key; if (key.endsWith("_id")) { if (key.startsWith("employeeField_") || key.startsWith("associationFormField_")) { k = key.substring(0, key.length() - 3); } else { continue; } } newRow.put(k, YDConf.getDataByCompId(row, k)); } out.add(newRow); } return out; } // ===== 字段映射辅助 ===== private String apTable(Cat c) { return c == Cat.BILLABLE ? AP_BIL_TABLE : (c == Cat.NON_BILLABLE ? AP_NON_TABLE : AP_OTH_TABLE); } private String apUserId(Cat c) { return c == Cat.BILLABLE ? AP_BIL_USERID : (c == Cat.NON_BILLABLE ? AP_NON_USERID : AP_OTH_USERID); } private String apDayText(Cat c) { return c == Cat.BILLABLE ? AP_BIL_DAYTEXT : (c == Cat.NON_BILLABLE ? AP_NON_DAYTEXT : AP_OTH_DAYTEXT); } private String apKey(Cat c) { return c == Cat.BILLABLE ? AP_BIL_KEY : (c == Cat.NON_BILLABLE ? AP_NON_KEY : AP_OTH_KEY); } private String apHours(Cat c) { return c == Cat.BILLABLE ? AP_BIL_HOURS : (c == Cat.NON_BILLABLE ? AP_NON_HOURS : AP_OTH_HOURS); } private String apOpinion(Cat c) { return c == Cat.BILLABLE ? AP_BIL_OPINION : (c == Cat.NON_BILLABLE ? AP_NON_OPINION : AP_OTH_OPINION); } private String subTable(Cat c) { return c == Cat.BILLABLE ? SUB_BIL_TABLE : (c == Cat.NON_BILLABLE ? SUB_NON_TABLE : SUB_OTH_TABLE); } private String subKey(Cat c) { return c == Cat.BILLABLE ? SUB_BIL_KEY : (c == Cat.NON_BILLABLE ? SUB_NON_KEY : SUB_OTH_KEY); } private String subSubmitted(Cat c) { return c == Cat.BILLABLE ? SUB_BIL_SUBMITTED : (c == Cat.NON_BILLABLE ? SUB_NON_SUBMITTED : SUB_OTH_SUBMITTED); } private String subApproved(Cat c) { return c == Cat.BILLABLE ? SUB_BIL_APPROVED : (c == Cat.NON_BILLABLE ? SUB_NON_APPROVED : SUB_OTH_APPROVED); } // ===== 通用工具 ===== private double num(Map m, String k) { Object v = m == null ? null : m.get(k); if (v == null) { return 0d; } try { return Double.parseDouble(String.valueOf(v)); } catch (Exception e) { return 0d; } } private double sumRows(List rows, String field) { double sum = 0d; for (Map r : rows) { sum += num(r, field); } return sum; } private String str(Map m, String k) { Object v = m == null ? null : m.get(k); return v == null ? "" : String.valueOf(v); } private void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } }