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.workhours.MonthClosingCheckRequest; import com.malk.server.workhours.MonthClosingStatusResult; import com.malk.server.workhours.TimeCardApprovalStartRequest; import com.malk.server.workhours.TimeCardRepairContextRequest; import com.malk.server.workhours.TimeCardRepairSubmitRequest; import com.malk.server.workhours.TimeCardRepairSummaryUpsertRequest; import com.malk.server.workhours.WHConf; import com.malk.service.aliwork.YDClient; 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.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.YearMonth; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; /** 补填 TimeCard 专用服务;不复用正常汇总表写入目标。 */ @Slf4j @Service public class TimeCardRepairService { private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyyMM"); private static final String REPAIR_TYPE = "补填"; private static final String DEFAULT_MANAGER_ID = "634735129"; // 宜搭查询接口对复制表新增的 mokwy 字段返回 field is null;沿用复制表中可检索的原字段。 private static final String R_SUBMITTER = "textField_mmbffvd9"; private static final String R_DAY = "textField_mmbffvdb"; private static final String R_MONTH = "textField_mmbffvda"; private static final String R_SUBMITTER_EMP = "employeeField_mmacxew4"; private static final String R_MONTH_DATE = "dateField_mmacxewf"; private static final String R_DAY_DATE = "dateField_mmbffvd8"; private static final String R_BIL_TABLE = "tableField_mmczo634"; private static final String R_NON_TABLE = "tableField_mmczo63h"; private static final String R_OTHER_TABLE = "tableField_mmeakgid"; private static final String R_DAILY_DRAFT = "numberField_mmd1smel"; private static final String R_DAILY_SUBMITTED = "numberField_mmd1smem"; private static final String R_DAILY_APPROVED = "numberField_mmeakgi4"; private static final String R_DAILY_CHANGE = "numberField_mmeakgi5"; private static final long SUBMIT_GUARD_TTL_MILLIS = 30_000L; private static final long CONTEXT_CACHE_TTL_MILLIS = 60_000L; private static final ConcurrentHashMap SUBMIT_GUARDS = new ConcurrentHashMap<>(); private static final AtomicInteger CONTEXT_THREAD_SEQUENCE = new AtomicInteger(); private static final ExecutorService CONTEXT_QUERY_EXECUTOR = Executors.newFixedThreadPool(8, runnable -> { Thread thread = new Thread(runnable, "timecard-repair-context-" + CONTEXT_THREAD_SEQUENCE.incrementAndGet()); thread.setDaemon(true); return thread; }); private final ConcurrentHashMap>> contextRequests = new ConcurrentHashMap<>(); private final ConcurrentHashMap contextCache = new ConcurrentHashMap<>(); private static final List R_TOTAL_FIELDS = Arrays.asList( "numberField_mmacxew9", "numberField_mmacxewa", "numberField_mmacxewg", "numberField_mmad6jbc", "numberField_mmad6jbx", "numberField_mmad6jbz", "numberField_mmad6jc1", "numberField_mmeakgia", "numberField_mmd1smeo", "numberField_mmd1smeq", "numberField_mmd1smes", "numberField_mmeakgic", R_DAILY_DRAFT, R_DAILY_SUBMITTED, R_DAILY_APPROVED, R_DAILY_CHANGE); @Autowired private YDClient ydClient; @Autowired private WHConf whConf; @Autowired private MonthClosingService monthClosingService; @Autowired private ReSubmitApprovalService reSubmitApprovalService; @Autowired private TimeCardPersonnelPolicy personnelPolicy; public Map context(TimeCardRepairContextRequest request) { validateMonthRequest(request == null ? null : request.getSubmitterUserId(), request == null ? null : request.getSourceMonthText()); String userId = request.getSubmitterUserId(); String monthText = request.getSourceMonthText(); String staffType = text(request.getStaffType()); McException.assertAccessException(!staffType.isEmpty() && !"内部".equals(staffType) && !"外部".equals(staffType), "人员属性无效"); String requestKey = userId + "|" + monthText + "|" + staffType; Map cachedResult = getCachedContext(requestKey); if (cachedResult != null) { CompletableFuture> completedRequest = contextRequests.get(requestKey); if (completedRequest != null && completedRequest.isDone()) { contextRequests.remove(requestKey, completedRequest); } return cachedResult; } CompletableFuture> contextFuture = contextRequests.computeIfAbsent( requestKey, ignored -> CompletableFuture.supplyAsync( () -> personnelPolicy.getStaffType(userId), CONTEXT_QUERY_EXECUTOR) .thenCompose(actualType -> { McException.assertAccessException(!staffType.isEmpty() && !staffType.equals(actualType), "人员属性已变化,请刷新后重试"); return loadContextAsync(requestKey, userId, monthText, actualType); })); if (!contextFuture.isDone()) return pendingContext(); contextRequests.remove(requestKey, contextFuture); return completedContext(contextFuture, userId, monthText); } private CompletableFuture> loadContextAsync(String requestKey, String userId, String monthText, String staffType) { // prd 外部补填独立于已冲销但保留的原汇总;不发起原表查询。 CompletableFuture> originalFuture = "外部".equals(staffType) ? CompletableFuture.completedFuture(Collections.emptyList()) : CompletableFuture.supplyAsync( () -> query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_MONTH, monthText)), CONTEXT_QUERY_EXECUTOR); CompletableFuture> repairFuture = CompletableFuture.supplyAsync( () -> queryRepairSummaries(mapOf(R_SUBMITTER, userId, R_MONTH, monthText)), CONTEXT_QUERY_EXECUTOR); return originalFuture.thenCombine(repairFuture, (originalRecords, repairRecords) -> { Map result = new LinkedHashMap<>(); result.put("ready", true); result.put("staffType", staffType); result.put("summaryReadMode", "外部".equals(staffType) ? "repair-only" : "original-and-repair"); result.put("originalRecords", compactContextRecords(originalRecords)); result.put("repairRecords", compactContextRecords(repairRecords)); // 兼容页面状态合并接口的 records 约定;原始双表结果仍通过 repairRecords 返回。 result.put("records", compactContextRecords(repairRecords)); contextCache.put(requestKey, new ContextCacheEntry(result, System.currentTimeMillis() + CONTEXT_CACHE_TTL_MILLIS)); return result; }); } private Map completedContext(CompletableFuture> contextFuture, String userId, String monthText) { try { return contextFuture.join(); } catch (CompletionException error) { Throwable cause = error.getCause(); if (cause instanceof RuntimeException) throw (RuntimeException) cause; log.error("补填上下文查询失败: userId={}, month={}", userId, monthText, cause); throw new McException("REPAIR_CONTEXT_QUERY_FAILED", "补填工时上下文查询失败"); } } private static Map pendingContext() { Map result = new LinkedHashMap<>(); result.put("ready", false); result.put("retryAfterMillis", 2_000L); return result; } private Map getCachedContext(String requestKey) { long now = System.currentTimeMillis(); ContextCacheEntry cached = contextCache.get(requestKey); if (cached == null) return null; if (cached.expiresAt > now) return cached.result; contextCache.remove(requestKey, cached); return null; } private void invalidateContext(String userId, String monthText) { String prefix = userId + "|" + monthText + "|"; contextCache.keySet().removeIf(key -> key.startsWith(prefix)); } private static List> compactContextRecords(List records) { List> compact = new ArrayList<>(); for (Map record : records) { Map item = new LinkedHashMap<>(); item.put("formInstId", instanceId(record)); item.put("formData", formData(record)); compact.add(item); } return compact; } public boolean upsert(TimeCardRepairSummaryUpsertRequest request) { McException.assertAccessException(request == null, "请求不能为空"); McException.assertAccessException(StringUtils.isBlank(request.getDayText()) || !request.getDayText().matches("\\d{8}"), "日期必须为yyyyMMdd"); try { LocalDate.parse(request.getDayText(), DateTimeFormatter.ofPattern("yyyyMMdd")); } catch (RuntimeException ex) { McException.assertAccessException(true, "日期必须为有效的yyyyMMdd"); } String monthText = request.getDayText().substring(0, 6); validateMonthRequest(request.getSubmitterUserId(), monthText); McException.assertAccessException(!monthClosingService.isLocked(monthText), "补填只能保存已发起封账或已完成封账的月份"); assertCurrentMonthOpen("保存"); McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidWorkHoursSummary()), "原工时汇总表未配置"); McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidRepairWorkHoursSummary()), "补填汇总表未配置"); McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummaryFillTypeField()), "补填汇总类型字段未配置"); McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySyncStatusField()), "补填汇总状态字段未配置"); McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空"); assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText(), "外部".equals(personnelPolicy.getStaffType(request.getSubmitterUserId()))); List existing = queryRepairSummaries( mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText())); McException.assertAccessException(existing.size() > 1, "补填汇总记录重复,已停止保存 userId=" + request.getSubmitterUserId() + ", day=" + request.getDayText()); McException.assertAccessException(existing.stream().anyMatch(record -> TimeCardSummaryService.isRepairRecordLocked(record, whConf.getRepairSummarySyncStatusField())), "该日期补填工时已提交或已审批,不能再填写 userId=" + request.getSubmitterUserId() + ", day=" + request.getDayText()); Map data = new LinkedHashMap<>(request.getFormData()); // fixme 草稿仅接收明细暂存工时;汇总由服务端重算,不能信任页面传入的审批合计。 for (String table : Arrays.asList(R_BIL_TABLE, R_NON_TABLE, R_OTHER_TABLE)) { for (Map child : list(data.get(table))) { McException.assertAccessException( TimeCardSummaryService.hasSubmittedOrApprovedHours(mapOf(table, Collections.singletonList(child))), "补填草稿不能包含已提交、已审批或变更工时"); } } TimeCardRepairService.applySummaryTotals(data, data); data.put(R_SUBMITTER, request.getSubmitterUserId()); data.put(R_DAY, request.getDayText()); data.put(R_MONTH, request.getDayText().substring(0, 6)); data.put(whConf.getRepairSummaryFillTypeField(), REPAIR_TYPE); data.put(whConf.getRepairSummarySyncStatusField(), "暂存"); // prd 补填与变更草稿均记录保存当天日期;实际提交时更新日期并生成批次。 McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySubmitDateField()), "补填/变更提交日期字段未配置"); data.put(whConf.getRepairSummarySubmitDateField(), LocalDate.now(ZONE).atStartOfDay(ZONE).toInstant().toEpochMilli()); if (StringUtils.isNotBlank(whConf.getRepairSummarySourceMonthField())) { data.put(whConf.getRepairSummarySourceMonthField(), monthText); } saveRepairDraft(existing, data, request.getSubmitterUserId(), request.getDayText()); invalidateContext(request.getSubmitterUserId(), monthText); verifyRepairDraftDate(request.getSubmitterUserId(), request.getDayText(), data); return true; } /** 保存和提交均禁止当前月进入封账后的补填写入。 */ private void assertCurrentMonthOpen(String action) { String monthText = LocalDate.now(ZONE).format(MONTH); MonthClosingStatusResult status = monthClosingService.status( MonthClosingCheckRequest.builder().monthText(monthText).build()); McException.assertAccessException(status == null || status.isClosed() || status.getRunningClosingCount() > 0, "当前月份已封账或封账流程审批中,不可" + action + "补填"); } /** 以实际实例回读确认草稿的提交日期已落库。 */ private void verifyRepairDraftDate(String userId, String dayText, Map expected) { List found = queryRepairSummaries(mapOf(R_SUBMITTER, userId, R_DAY, dayText)); McException.assertAccessException(found.size() != 1, "补填草稿未落库或记录重复,请勿提交"); Map actual = detail(instanceId(found.get(0))); String field = whConf.getRepairSummarySubmitDateField(); McException.assertAccessException(num(expected.get(field)) != num(actual.get(field)), "补填/变更提交日期回读不一致,请勿提交"); } public Map submit(TimeCardRepairSubmitRequest request) { McException.assertAccessException(request == null, "请求不能为空"); validateMonthRequest(request.getSubmitterUserId(), request.getSourceMonthText()); McException.assertAccessException(!monthClosingService.isLocked(request.getSourceMonthText()), "补填只能提交已发起封账或已完成封账的月份"); LocalDate submitDay = LocalDate.now(ZONE); // prd 补填写入当前月变更工时;当前月进入封账流程或已封账后禁止新增提交。 assertCurrentMonthOpen("提交"); Map projectGroups = request.getProjectGroups() == null ? Collections.emptyMap() : request.getProjectGroups(); Map other = request.getOtherGroup() == null ? Collections.emptyMap() : request.getOtherGroup(); String guardKey = buildSubmitGuardKey(request, projectGroups, other); SubmitGuard guard = acquireSubmitGuard(guardKey); Map cachedResult = guard.cachedResult(); if (cachedResult != null) return cachedResult; try { String batch = UUID.randomUUID().toString(); long submitDate = submitDay.atStartOfDay(ZONE).toInstant().toEpochMilli(); int rowCount = validateSubmitRows(request, projectGroups, other); McException.assertAccessException(rowCount == 0, "没有需要提交的补填工时"); invalidateContext(request.getSubmitterUserId(), request.getSourceMonthText()); for (Object rawGroup : projectGroups.values()) { if (!(rawGroup instanceof Map)) continue; Map group = (Map) rawGroup; String managerId = normalizeManagerId(text(group.get("managerId"))); for (String category : new String[]{"billableRows", "nonBillableRows"}) { for (Object rawRow : list(group.get(category))) { if (rawRow instanceof Map) { upsertRepairRow(request, (Map) rawRow, category, managerId, batch, submitDate); } } } } String otherManagerId = normalizeManagerId(text(other.get("managerId"))); for (Object rawRow : list(other.get("otherRows"))) { if (rawRow instanceof Map) { upsertRepairRow(request, (Map) rawRow, "otherRows", otherManagerId, batch, submitDate); } } startApprovals(request, projectGroups, other, batch, submitDate); Map result = mapOf("batch", batch, "submitDate", submitDate, "status", "审批中"); guard.complete(result); return result; } catch (RuntimeException ex) { SUBMIT_GUARDS.remove(guardKey, guard); throw ex; } } private String buildSubmitGuardKey(TimeCardRepairSubmitRequest request, Map projectGroups, Map other) { String payload = request.getSubmitterUserId() + "|" + request.getSourceMonthText() + "|" + JSON.toJSONString(projectGroups) + "|" + JSON.toJSONString(other); try { byte[] digest = MessageDigest.getInstance("SHA-256") .digest(payload.getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(digest.length * 2); for (byte value : digest) hex.append(String.format("%02x", value)); return hex.toString(); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("提交幂等键计算失败", ex); } } private SubmitGuard acquireSubmitGuard(String key) { long now = System.currentTimeMillis(); synchronized (SUBMIT_GUARDS) { SUBMIT_GUARDS.entrySet().removeIf(entry -> entry.getValue().expiresAt <= now); SubmitGuard existing = SUBMIT_GUARDS.get(key); if (existing != null && existing.expiresAt > now) { if (existing.result != null) return existing; McException.assertAccessException(true, "补填提交正在处理中,请勿重复提交"); } SubmitGuard guard = new SubmitGuard(now + SUBMIT_GUARD_TTL_MILLIS); SUBMIT_GUARDS.put(key, guard); return guard; } } private static final class SubmitGuard { private final long expiresAt; private Map result; private SubmitGuard(long expiresAt) { this.expiresAt = expiresAt; } private synchronized void complete(Map value) { this.result = new LinkedHashMap<>(value); } private synchronized Map cachedResult() { return result == null ? null : new LinkedHashMap<>(result); } } private static final class ContextCacheEntry { private final Map result; private final long expiresAt; private ContextCacheEntry(Map result, long expiresAt) { this.result = result; this.expiresAt = expiresAt; } } /** * 补填审批补发起:只扫描近期修改的补填汇总,按员工、月份和补填提交日期补齐缺失明细。 * 正常 TimeCard 补发逻辑仍由 ReSubmitApprovalService 独立处理。 * * @param lookbackDays 最近修改自然日数量 * @return 本次补发的审批分组数量及补试后失败批次数 */ public RepairResubmitResult resubmitRecentlyModifiedPending(int lookbackDays) { String[] window = modifiedDateWindow(lookbackDays); List records = queryModified( whConf.getFormUuidRepairWorkHoursSummary(), window[0], window[1]); Map batches = buildPendingBatches(records); Map> coverageByMonth = new HashMap<>(); int started = 0; int failed = 0; List retryQueue = new ArrayList<>(); for (RepairBatch batch : batches.values()) { try { Set covered = coverageByMonth.computeIfAbsent( batch.monthText, this::loadRepairApprovalDetailCoverage); started += submitMissingRepairApprovals(batch, covered); } catch (Exception ex) { retryQueue.add(batch); log.warn("[补填审批补发] 首次处理失败,待本轮扫描结束补试 batch={}", batch.batchId, ex); } } for (RepairBatch batch : retryQueue) { try { started += submitMissingRepairApprovals( batch, loadRepairApprovalDetailCoverage(batch.monthText)); } catch (Exception ex) { failed++; log.error("[补填审批补发] 本轮补试失败 batch={}", batch.batchId, ex); } } log.info("[补填审批补发] from={} to={} records={} batches={} started={} failed={}", window[0], window[1], records.size(), batches.size(), started, failed); return new RepairResubmitResult(started, failed); } public static final class RepairResubmitResult { private final int started; private final int failed; public RepairResubmitResult(int started, int failed) { this.started = started; this.failed = failed; } public int getStarted() { return started; } public int getFailed() { return failed; } } private int submitMissingRepairApprovals(RepairBatch batch, Set covered) { removeCoveredRows(batch, covered); Map missingProjects = batch.projectGroups; Map missingOther = batch.otherGroup; if (missingProjects.isEmpty() && missingOther.isEmpty()) return 0; TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest(); request.setSourceMonthText(batch.monthText); request.setSubmitterUserId(batch.userId); request.setSubmitterName(batch.submitterName); request.setProjectGroups(missingProjects); request.setOtherGroup(missingOther); startApprovals(request, missingProjects, missingOther, batch.batchId, batch.submitDate); return missingProjects.size() + (missingOther.isEmpty() ? 0 : 1); } private void upsertRepairRow(TimeCardRepairSubmitRequest request, Map row, String category, String managerId, String batch, long submitDate) { String day = text(row.get("dateStr")); if (StringUtils.isBlank(day)) day = text(row.get("dayText")); String key = day; List found = queryRepairSummaries( mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, key)); McException.assertAccessException(found.size() != 1, "补填汇总完整记录不存在或重复,请先保存后提交 userId=" + request.getSubmitterUserId() + ", day=" + key); String instanceId = text(found.get(0).get("formInstId")); if (StringUtils.isBlank(instanceId)) instanceId = text(found.get(0).get("formInstanceId")); McException.assertAccessException(StringUtils.isBlank(instanceId), "补填汇总实例ID为空"); Map existing = detail(instanceId); Map data = new LinkedHashMap<>(); data.put(R_SUBMITTER, request.getSubmitterUserId()); data.put(R_DAY, day); data.put(R_MONTH, request.getSourceMonthText()); data.put(whConf.getRepairSummarySyncStatusField(), "审批中"); data.put(whConf.getRepairSummaryFillTypeField(), "补填"); data.put(whConf.getRepairSummarySubmitDateField(), submitDate); data.put(whConf.getRepairSummaryBatchField(), batch); data.put(whConf.getRepairSummarySourceMonthField(), request.getSourceMonthText()); data.put(R_SUBMITTER_EMP, Collections.singletonList(request.getSubmitterUserId())); data.put(R_MONTH_DATE, LocalDate.parse(request.getSourceMonthText() + "01", DateTimeFormatter.ofPattern("yyyyMMdd")) .atStartOfDay(ZONE).toInstant().toEpochMilli()); data.put(R_DAY_DATE, LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd")) .atStartOfDay(ZONE).toInstant().toEpochMilli()); Map child = new LinkedHashMap<>(); double hours = num(row.get("hours")); if ("billableRows".equals(category)) { child.put("numberField_mmczo636", hours); child.put("numberField_mmczo637", 0d); } else if ("nonBillableRows".equals(category)) { child.put("numberField_mmczo63c", hours); child.put("numberField_mmczo63d", 0d); } else { child.put("numberField_mmeakgij", hours); child.put("numberField_mmeakgii", 0d); } String tableField; String keyField; if ("billableRows".equals(category)) { child.put("textField_mmacxew3", text(row.get("projectCode"))); child.put("textField_mmad6jcj", text(row.get("projectName"))); tableField = R_BIL_TABLE; keyField = "textField_mmacxew3"; } else if ("nonBillableRows".equals(category)) { child.put("textField_mmczo639", text(row.get("projectCode"))); child.put("textField_mmczo63g", text(row.get("projectName"))); tableField = R_NON_TABLE; keyField = "textField_mmczo639"; } else { child.put("selectField_mmeakgie", text(row.get("activityName"))); tableField = R_OTHER_TABLE; keyField = "selectField_mmeakgie"; } if (StringUtils.isNotBlank(managerId)) { if ("billableRows".equals(category)) { child.put("employeeField_mkoxpswf", Collections.singletonList(managerId)); } else if ("nonBillableRows".equals(category)) { child.put("employeeField_mmczo63a", Collections.singletonList(managerId)); } else { data.put("employeeField_mh8xhqc3", Collections.singletonList(managerId)); } } List current = copyRows(existing.get(tableField)); String submittedField = "billableRows".equals(category) ? "numberField_mmczo636" : ("nonBillableRows".equals(category) ? "numberField_mmczo63c" : "numberField_mmeakgij"); // prd 与原 TimeCard 一致:提交值覆盖 submitted,draft 保留供拒绝后恢复编辑。 mergeSubmittedRow(current, child, keyField, submittedField, hours); data.put(tableField, current); Map snapshot = new LinkedHashMap(existing); snapshot.put(tableField, current); applySummaryTotals(data, snapshot); final String targetInstanceId = instanceId; operateAndVerify( () -> ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()).formInstanceId(targetInstanceId) .updateFormDataJson(JSON.toJSONString(data)).ignoreEmpty(false).useLatestVersion(true).build(), YDConf.FORM_OPERATION.update), () -> verifySubmittedRepairRow(targetInstanceId, tableField, keyField, text(child.get(keyField)), submittedField, hours, data)); } private void verifySubmittedRepairRow(String instanceId, String tableField, String keyField, String rowKey, String submittedField, double hours, Map expected) { Map actual = detail(instanceId); Map actualRow = null; for (Map row : list(actual.get(tableField))) { if (rowKey.equals(text(row.get(keyField)))) { actualRow = row; break; } } McException.assertAccessException(actualRow == null || !sameHours(num(actualRow.get(submittedField)), hours), "补填汇总提交子表回读不一致 instanceId=" + instanceId + ", key=" + rowKey); for (String field : R_TOTAL_FIELDS) { McException.assertAccessException(!sameHours(num(actual.get(field)), num(expected.get(field))), "补填汇总主表合计回读不一致 instanceId=" + instanceId + ", field=" + field); } String statusField = whConf.getRepairSummarySyncStatusField(); McException.assertAccessException(!text(expected.get(statusField)).equals(text(actual.get(statusField))), "补填汇总审批状态回读不一致 instanceId=" + instanceId); } private void operateAndVerify(Runnable operation, Runnable verification) { try { operation.run(); } catch (RuntimeException operationException) { try { verification.run(); return; } catch (RuntimeException verificationException) { operationException.addSuppressed(verificationException); throw operationException; } } verification.run(); } private static boolean sameHours(double actual, double expected) { return Math.abs(actual - expected) <= 1e-6; } private Map detail(String id) { return new WorkHoursFormDataReader(ydClient, whConf).form(id, whConf.getFormUuidRepairWorkHoursSummary()); } private void startApprovals(TimeCardRepairSubmitRequest request, Map groups, Map other, String batch, long submitDate) { for (Object raw : groups.values()) { if (!(raw instanceof Map)) continue; Map group = (Map) raw; Map fd = new LinkedHashMap<>(); fd.put("dateField_mmacxewf", parseMonth(request.getSourceMonthText())); fd.put("employeeField_moi9pvt1", Collections.singletonList(request.getSubmitterUserId())); fd.put(whConf.getApprovalFillTypeField(), "补填"); fd.put(whConf.getApprovalRepairSubmitDateField(), submitDate); fd.put(whConf.getApprovalRepairBatchField(), batch); fd.put(whConf.getApprovalRepairSourceMonthField(), request.getSourceMonthText()); fd.put("textField_mmd2lv0y", request.getSourceMonthText()); fd.put("numberField_mmd2z155", sumRows(group.get("billableRows"))); fd.put("numberField_mmd2z156", sumRows(group.get("nonBillableRows"))); fd.put("numberField_mmd5b5gl", sumRows(group.get("billableRows")) + sumRows(group.get("nonBillableRows"))); fd.put("employeeField_mkoxpswf", Collections.singletonList( normalizeManagerId(text(group.get("managerId"))))); fd.put("tableField_mmae8t99", approvalRows(group.get("billableRows"), true, request)); fd.put("tableField_mmd1wu9h", approvalRows(group.get("nonBillableRows"), false, request)); start(request.getSubmitterUserId(), whConf.getFormUuidApproval(), whConf.getApprovalProcessCode(), fd); } if (other != null && !list(other.get("otherRows")).isEmpty()) { Map fd = new LinkedHashMap<>(); fd.put("dateField_mmacxewf", parseMonth(request.getSourceMonthText())); fd.put("employeeField_moi9pvt1", Collections.singletonList(request.getSubmitterUserId())); fd.put(whConf.getApprovalFillTypeField(), "补填"); fd.put(whConf.getOtherApprovalRepairSubmitDateField(), submitDate); fd.put(whConf.getOtherApprovalRepairBatchField(), batch); fd.put(whConf.getOtherApprovalRepairSourceMonthField(), request.getSourceMonthText()); fd.put("textField_mmd2lv0y", request.getSourceMonthText()); fd.put("numberField_mmd5b5gl", sumRows(other.get("otherRows"))); fd.put("employeeField_mh8xhqc3", Collections.singletonList( normalizeManagerId(text(other.get("managerId"))))); fd.put("tableField_mmd1wu9h", approvalRows(other.get("otherRows"), false, request)); start(request.getSubmitterUserId(), whConf.getFormUuidOtherApproval(), whConf.getOtherApprovalProcessCode(), fd); } } private void start(String userId, String formUuid, String processCode, Map data) { reSubmitApprovalService.startApproval(new TimeCardApprovalStartRequest() {{ setFormUuid(formUuid); setProcessCode(processCode); setFormDataJson(JSON.toJSONString(data)); }}); } private List> approvalRows(Object raw, boolean billable, TimeCardRepairSubmitRequest request) { List> result = new ArrayList<>(); for (Object item : list(raw)) { if (!(item instanceof Map)) continue; Map row = (Map) item; String day = text(row.get("dateStr")); Map out = new LinkedHashMap<>(); out.put(billable ? "dateField_mmbffvd8" : "dateField_mmd2z150", parseDay(day)); out.put(billable ? "textField_mmd8g654" : "textField_mmd8g655", day); out.put(billable ? "textField_mmd1ozk4" : "textField_mmd1wu9f", request.getSubmitterUserId()); out.put(billable ? "employeeField_mmacxew4" : "employeeField_mmd1wu9b", Collections.singletonList(request.getSubmitterUserId())); out.put(billable ? "textField_mmdbi2n2" : "textField_mmdbi2n0", request.getSourceMonthText()); out.put(billable ? "numberField_mmacxew9" : "numberField_mmd1wu9e", num(row.get("hours"))); out.put(billable ? "textField_mmacxew3" : "textField_mmd1wu9d", text(row.get("projectCode"))); if (billable) out.put("textField_mkov5ozd", text(row.get("projectName"))); else if (!text(row.get("activityName")).isEmpty()) { out.put("selectField_mmeakgie", text(row.get("activityName"))); out.remove("textField_mmd1wu9d"); } else out.put("textField_mmd1wu9c", text(row.get("projectName"))); // 其他工时审批沿用 radioField_mpwhk294;只有 Non Billable 使用 295。 String opinionField = billable || out.containsKey("selectField_mmeakgie") ? "radioField_mpwhk294" : "radioField_mpwhk295"; out.put(opinionField, "同意"); result.add(out); } return result; } private long parseDay(String day) { return LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd")).atStartOfDay(ZONE).toInstant().toEpochMilli(); } private List query(String formUuid, Map search) { if (StringUtils.isBlank(formUuid)) return new ArrayList<>(); com.malk.server.dingtalk.DDR_New result = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()).systemToken(whConf.getYidaSystemToken()) .formUuid(formUuid).searchFieldJson(JSON.toJSONString(search)) .currentPage(1).pageSize(100).build(), YDConf.FORM_QUERY.retrieve_search_form); McException.assertAccessException(result == null || !(result.getData() instanceof List), "查询现有工时失败,已停止保存"); List records = (List) result.getData(); McException.assertAccessException(records.size() >= 100, "现有工时记录过多,已停止保存"); return records; } private void validateMonthRequest(String userId, String month) { McException.assertAccessException(StringUtils.isAnyBlank(userId, month) || !month.matches("\\d{6}"), "员工和月份不能为空且月份必须为yyyyMM"); try { YearMonth.parse(month, MONTH); } catch (RuntimeException ex) { McException.assertAccessException(true, "月份必须为有效的yyyyMM"); } } private void validateRepairRow(TimeCardRepairSubmitRequest request, Map row, String category, boolean external) { String day = text(row.get("dateStr")); if (StringUtils.isBlank(day)) day = text(row.get("dayText")); McException.assertAccessException(!day.matches("\\d{8}") || !day.startsWith(request.getSourceMonthText()), "补填日期必须属于来源月份"); try { LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd")); } catch (RuntimeException ex) { McException.assertAccessException(true, "补填日期必须为有效的yyyyMMdd"); } double hours = num(row.get("hours")); McException.assertAccessException(hours <= 0d || hours > 24d, "补填工时必须大于0且不超过24小时"); if (!"otherRows".equals(category)) { McException.assertAccessException(StringUtils.isBlank(text(row.get("projectCode"))), ("billableRows".equals(category) ? "Billable" : "Non Billable") + "工时必须填写项目编号"); } assertSourceDateAvailable(request.getSubmitterUserId(), day, external); assertRepairRowMutable(request.getSubmitterUserId(), day, row, category); } private int validateSubmitRows(TimeCardRepairSubmitRequest request, Map projectGroups, Map otherGroup) { int count = 0; String staffType = null; for (Object rawGroup : projectGroups.values()) { if (!(rawGroup instanceof Map)) continue; Map group = (Map) rawGroup; for (String category : new String[]{"billableRows", "nonBillableRows"}) { for (Object rawRow : list(group.get(category))) { if (!(rawRow instanceof Map)) continue; if (staffType == null) staffType = personnelPolicy.getStaffType(request.getSubmitterUserId()); validateRepairRow(request, (Map) rawRow, category, "外部".equals(staffType)); count++; } } } for (Object rawRow : list(otherGroup.get("otherRows"))) { if (!(rawRow instanceof Map)) continue; if (staffType == null) staffType = personnelPolicy.getStaffType(request.getSubmitterUserId()); validateRepairRow(request, (Map) rawRow, "otherRows", "外部".equals(staffType)); count++; } return count; } private void assertSourceDateAvailable(String userId, String day, boolean external) { // prd 外部来源日由补填记录保护;内部继续核实原表锁定。 if (external) return; List original = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day)); boolean hasApprovedOrChangedHours = original.stream() .anyMatch(TimeCardSummaryService::isFormalRecordLocked); McException.assertAccessException(hasApprovedOrChangedHours, "来源日期已有已提交、已审批或变更工时,不能补填 userId=" + userId + ", day=" + day); } /** 原汇总当天任一已审批或变更工时均禁止补填;暂存草稿不占用补填资格。 */ static boolean hasApprovedOrChangedHours(Map record) { return TimeCardSummaryService.hasApprovedOrChangedHours(record); } private void assertRepairRowMutable(String userId, String day, Map row, String category) { List existing = queryRepairSummaries(mapOf(R_SUBMITTER, userId, R_DAY, day)); McException.assertAccessException(existing.size() > 1, "补填汇总记录重复 userId=" + userId + ", day=" + day); if (existing.size() != 1) return; String id = text(existing.get(0).get("formInstId")); if (StringUtils.isBlank(id)) id = text(existing.get(0).get("formInstanceId")); Map fd = detail(id); String projectCode = text(row.get("projectCode")); String activityName = text(row.get("activityName")); boolean other = "otherRows".equals(category); String table = other ? R_OTHER_TABLE : ("billableRows".equals(category) ? R_BIL_TABLE : R_NON_TABLE); String keyField = other ? "selectField_mmeakgie" : (R_BIL_TABLE.equals(table) ? "textField_mmacxew3" : "textField_mmczo639"); String submittedField = other ? "numberField_mmeakgij" : (R_BIL_TABLE.equals(table) ? "numberField_mmczo636" : "numberField_mmczo63c"); String approvedField = other ? "numberField_mmeakgii" : (R_BIL_TABLE.equals(table) ? "numberField_mmczo637" : "numberField_mmczo63d"); String key = other ? activityName : projectCode; for (Map child : list(fd.get(table))) { if (key.equals(text(child.get(keyField)))) { McException.assertAccessException(num(child.get(submittedField)) > 1e-6 || num(child.get(approvedField)) > 1e-6, "该补填明细已提交或审批完成,不能再次补填 day=" + day + ", key=" + key); } } } private long parseMonth(String month) { return LocalDate.parse(month + "01", DateTimeFormatter.ofPattern("yyyyMMdd")) .atStartOfDay(ZONE).toInstant().toEpochMilli(); } private Map buildPendingBatches(List records) { Map batches = new LinkedHashMap<>(); for (Map item : records) { Map fd = new WorkHoursFormDataReader(ydClient, whConf).complete( instanceId(item), whConf.getFormUuidRepairWorkHoursSummary(), formData(item)); if (!isRepairSummaryRecord(fd, whConf.getRepairSummaryFillTypeField())) continue; if (!"审批中".equals(text(fd.get(whConf.getRepairSummarySyncStatusField())))) continue; String userId = text(fd.get(R_SUBMITTER)); String monthText = text(fd.get(R_MONTH)); String batchId = text(fd.get(whConf.getRepairSummaryBatchField())); long submitDate = (long) num(fd.get(whConf.getRepairSummarySubmitDateField())); if (StringUtils.isAnyBlank(userId, monthText, batchId) || submitDate <= 0) continue; String submissionKey = userId + "|" + monthText + "|" + batchId; RepairBatch batch = batches.computeIfAbsent(submissionKey, key -> { RepairBatch value = new RepairBatch(); value.batchId = batchId; value.userId = userId; value.monthText = monthText; value.submitDate = submitDate; value.submitterName = text(fd.get("textField_mmoazo1b")); return value; }); String dayText = text(fd.get(R_DAY)); collectPendingProjectRows(batch, fd, dayText, true); collectPendingProjectRows(batch, fd, dayText, false); collectPendingOtherRows(batch, fd, dayText); } return batches; } private void collectPendingProjectRows(RepairBatch batch, Map fd, String dayText, boolean billable) { String tableField = billable ? R_BIL_TABLE : R_NON_TABLE; String managerField = billable ? "employeeField_mkoxpswf" : "employeeField_mmczo63a"; String submittedField = billable ? "numberField_mmczo636" : "numberField_mmczo63c"; String approvedField = billable ? "numberField_mmczo637" : "numberField_mmczo63d"; String projectCodeField = billable ? "textField_mmacxew3" : "textField_mmczo639"; String projectNameField = billable ? "textField_mmad6jcj" : "textField_mmczo63g"; for (Map child : list(fd.get(tableField))) { double hours = num(child.get(submittedField)) - num(child.get(approvedField)); if (hours <= 1e-6) continue; String managerId = firstUserId(child, managerField); if (StringUtils.isBlank(managerId)) managerId = DEFAULT_MANAGER_ID; final String resolvedManagerId = managerId; Map group = (Map) batch.projectGroups.computeIfAbsent(resolvedManagerId, key -> { Map value = new LinkedHashMap<>(); value.put("managerId", resolvedManagerId); value.put("billableRows", new ArrayList()); value.put("nonBillableRows", new ArrayList()); return value; }); Map row = mapOf( "dateStr", dayText, "projectCode", text(child.get(projectCodeField)), "projectName", text(child.get(projectNameField)), "hours", hours); ((List>) group.get(billable ? "billableRows" : "nonBillableRows")).add(row); } } private void collectPendingOtherRows(RepairBatch batch, Map fd, String dayText) { for (Map child : list(fd.get(R_OTHER_TABLE))) { double hours = num(child.get("numberField_mmeakgij")) - num(child.get("numberField_mmeakgii")); if (hours <= 1e-6) continue; String managerId = firstUserId(fd, "employeeField_mh8xhqc3"); if (StringUtils.isBlank(managerId)) managerId = DEFAULT_MANAGER_ID; batch.otherGroup.put("managerId", managerId); List> rows = (List>) batch.otherGroup.computeIfAbsent( "otherRows", key -> new ArrayList>()); rows.add(mapOf("dateStr", dayText, "activityName", text(child.get("selectField_mmeakgie")), "hours", hours)); } } private Set loadRepairApprovalDetailCoverage(String monthText) { Set covered = new HashSet<>(); Map search = mapOf( whConf.getApprovalFillTypeField(), "补填", "textField_mmd2lv0y", monthText); for (Map item : queryProcesses(whConf.getFormUuidApproval(), search)) { collectRepairApprovalCoverage(item, false, covered); } for (Map item : queryProcesses(whConf.getFormUuidOtherApproval(), search)) { collectRepairApprovalCoverage(item, true, covered); } return covered; } private void collectRepairApprovalCoverage(Map item, boolean other, Set covered) { String status = text(item.get("instanceStatus")).toUpperCase(Locale.ROOT); String approvedResult = text(item.get("approvedResult")).toLowerCase(Locale.ROOT); if (!"RUNNING".equals(status) && !("COMPLETED".equals(status) && "agree".equals(approvedResult))) { return; } String id = text(item.get("processInstanceId")); if (StringUtils.isBlank(id)) id = instanceId(item); Map fd = new WorkHoursFormDataReader(ydClient, whConf).complete(id, other ? whConf.getFormUuidOtherApproval() : whConf.getFormUuidApproval(), formData(item)); if (other) { addCoverageRows(covered, list(fd.get("tableField_mmd1wu9h")), "other", "textField_mmd1wu9f", "textField_mmd8g655", "selectField_mmeakgie", "radioField_mpwhk294", status); return; } addCoverageRows(covered, list(fd.get("tableField_mmae8t99")), "billable", "textField_mmd1ozk4", "textField_mmd8g654", "textField_mmacxew3", "radioField_mpwhk294", status); addCoverageRows(covered, list(fd.get("tableField_mmd1wu9h")), "nonBillable", "textField_mmd1wu9f", "textField_mmd8g655", "textField_mmd1wu9d", "radioField_mpwhk295", status); } private void addCoverageRows(Set covered, List rows, String category, String userField, String dayField, String keyField, String opinionField, String status) { for (Map row : rows) { if ("COMPLETED".equals(status) && row.containsKey(opinionField) && !"同意".equals(text(row.get(opinionField)))) { continue; } covered.add(coverageKey(category, text(row.get(userField)), text(row.get(dayField)), text(row.get(keyField)))); } } private void removeCoveredRows(RepairBatch batch, Set covered) { Iterator> iterator = batch.projectGroups.entrySet().iterator(); while (iterator.hasNext()) { Map group = (Map) iterator.next().getValue(); removeCoveredProjectRows(batch.userId, list(group.get("billableRows")), "billable", covered); removeCoveredProjectRows(batch.userId, list(group.get("nonBillableRows")), "nonBillable", covered); if (list(group.get("billableRows")).isEmpty() && list(group.get("nonBillableRows")).isEmpty()) { iterator.remove(); } } List otherRows = list(batch.otherGroup.get("otherRows")); otherRows.removeIf(row -> covered.contains(coverageKey("other", batch.userId, text(row.get("dateStr")), text(row.get("activityName"))))); if (otherRows.isEmpty()) batch.otherGroup.clear(); } private void removeCoveredProjectRows(String userId, List rows, String category, Set covered) { rows.removeIf(row -> covered.contains(coverageKey(category, userId, text(row.get("dateStr")), text(row.get("projectCode"))))); } static String coverageKey(String category, String userId, String day, String detailKey) { return category + "|" + userId + "|" + day + "|" + detailKey; } private List queryModified(String formUuid, String fromDate, String toDate) { List all = new ArrayList<>(); for (int page = 1; page <= 200; page++) { Object raw = 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).getData(); List data = raw instanceof List ? (List) raw : Collections.emptyList(); all.addAll(data); if (data.size() < 100) break; } return all; } private List queryRepairSummaries(Map search) { List records = new ArrayList<>(query(whConf.getFormUuidRepairWorkHoursSummary(), search)); records.removeIf(record -> !isRepairSummaryRecord(record, whConf.getRepairSummaryFillTypeField())); return records; } private void saveRepairDraft(List existing, Map data, String userId, String dayText) { YDParam.YDParamBuilder builder = YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()); if (existing.isEmpty()) { Map search = mapOf(R_SUBMITTER, userId, R_DAY, dayText, whConf.getRepairSummaryFillTypeField(), REPAIR_TYPE); ydClient.operateData(builder.formUuid(whConf.getFormUuidRepairWorkHoursSummary()) .searchCondition(JSON.toJSONString(search)).formDataJson(JSON.toJSONString(data)) .noExecuteExpression(false).build(), YDConf.FORM_OPERATION.upsert); return; } String instanceId = instanceId(existing.get(0)); McException.assertAccessException(StringUtils.isBlank(instanceId), "补填汇总实例ID为空"); ydClient.operateData(builder.formInstanceId(instanceId) .updateFormDataJson(JSON.toJSONString(data)).ignoreEmpty(false).useLatestVersion(true).build(), YDConf.FORM_OPERATION.update); } private List queryProcesses(String formUuid, Map search) { List all = new ArrayList<>(); for (int page = 1; page <= 200; page++) { Object raw = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()).formUuid(formUuid) .searchFieldJson(JSON.toJSONString(search)).pageNumber(page).pageSize(100) .build(), YDConf.FORM_QUERY.retrieve_search_process).getData(); List data = raw instanceof List ? (List) raw : Collections.emptyList(); all.addAll(data); if (data.size() < 100) break; } return all; } private String[] modifiedDateWindow(int lookbackDays) { int safeDays = Math.max(1, lookbackDays); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); return new String[]{LocalDate.now(ZONE).minusDays(safeDays - 1L).format(formatter), LocalDate.now(ZONE).plusDays(1L).format(formatter)}; } static boolean isRepairSummaryRecord(Map item, String fillTypeField) { if (StringUtils.isBlank(fillTypeField)) return true; Map data = formData(item); String fillType = text(data.get(fillTypeField)); return StringUtils.isBlank(fillType) || REPAIR_TYPE.equals(fillType); } private static Map formData(Map item) { Object fd = item == null ? null : item.get("formData"); if (!(fd instanceof Map)) fd = item == null ? null : item.get("data"); return fd instanceof Map ? (Map) fd : (item == null ? Collections.emptyMap() : item); } private static String instanceId(Map item) { String id = text(item == null ? null : item.get("formInstId")); return StringUtils.isBlank(id) ? text(item == null ? null : item.get("formInstanceId")) : id; } private String firstUserId(Map data, String field) { Object value = data == null ? null : data.get(field + "_id"); if (!(value instanceof List) || ((List) value).isEmpty()) value = data == null ? null : data.get(field); if (value instanceof List && !((List) value).isEmpty()) return text(((List) value).get(0)); return text(value); } private String normalizeManagerId(String managerId) { return StringUtils.isBlank(managerId) ? DEFAULT_MANAGER_ID : managerId; } /** * 提交请求是页面当前值的完整快照;已有草稿应覆盖而不是再次累加。 */ static void mergeSubmittedRow(List current, Map child, String keyField, String submittedField, double hours) { for (Map existing : current) { if (text(existing.get(keyField)).equals(text(child.get(keyField)))) { existing.put(submittedField, hours); return; } } current.add(child); } static void applySummaryTotals(Map update, Map snapshot) { putCategoryTotals(update, snapshot, R_BIL_TABLE, "numberField_mmczo635", "numberField_mmczo636", "numberField_mmczo637", "numberField_mmd80zae", "numberField_mmacxew9", "numberField_mmacxewa", "numberField_mmacxewg", "numberField_mmad6jbc"); putCategoryTotals(update, snapshot, R_NON_TABLE, "numberField_mmczo63b", "numberField_mmczo63c", "numberField_mmczo63d", "numberField_mmeakgi9", "numberField_mmad6jbx", "numberField_mmad6jbz", "numberField_mmad6jc1", "numberField_mmeakgia"); putCategoryTotals(update, snapshot, R_OTHER_TABLE, "numberField_mmeakgik", "numberField_mmeakgij", "numberField_mmeakgii", "numberField_mmeakgim", "numberField_mmd1smeo", "numberField_mmd1smeq", "numberField_mmd1smes", "numberField_mmeakgic"); update.put(R_DAILY_DRAFT, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmczo635") + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmczo63b") + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgik")); update.put(R_DAILY_SUBMITTED, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmczo636") + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmczo63c") + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgij")); update.put(R_DAILY_APPROVED, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmczo637") + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmczo63d") + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgii")); update.put(R_DAILY_CHANGE, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmd80zae") + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmeakgi9") + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgim")); } private static void putCategoryTotals(Map update, Map snapshot, String table, String draftChild, String submittedChild, String approvedChild, String changeChild, String draftMain, String submittedMain, String approvedMain, String changeMain) { update.put(draftMain, categoryTotal(snapshot, table, draftChild)); update.put(submittedMain, categoryTotal(snapshot, table, submittedChild)); update.put(approvedMain, categoryTotal(snapshot, table, approvedChild)); update.put(changeMain, categoryTotal(snapshot, table, changeChild)); } private static double categoryTotal(Map snapshot, String table, String field) { double total = 0d; for (Map row : list(snapshot.get(table))) total += num(row.get(field)); return total; } private List copyRows(Object raw) { List rows = new ArrayList<>(); for (Map row : list(raw)) rows.add(new LinkedHashMap(row)); return rows; } private static class RepairBatch { private String batchId; private String userId; private String submitterName; private String monthText; private long submitDate; private final Map projectGroups = new LinkedHashMap<>(); private final Map otherGroup = new LinkedHashMap<>(); } private static Map mapOf(Object... values) { Map m = new LinkedHashMap<>(); for (int i=0;i list(Object raw) { return raw instanceof List ? (List) raw : new ArrayList<>(); } private static String text(Object value) { return value == null ? "" : String.valueOf(value); } private static double num(Object value) { try { return value == null ? 0d : Double.parseDouble(String.valueOf(value)); } catch (NumberFormatException e) { return 0d; } } private static double sumRows(Object raw) { double total=0d; for (Object item:list(raw)) if (item instanceof Map) total += num(((Map)item).get("hours")); return total; } }