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.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.ConcurrentHashMap; /** 补填 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 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 ConcurrentHashMap SUBMIT_GUARDS = 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; public Map context(TimeCardRepairContextRequest request) { validateMonthRequest(request == null ? null : request.getSubmitterUserId(), request == null ? null : request.getSourceMonthText()); Map result = new LinkedHashMap<>(); result.put("closed", monthClosingService.isClosed(request.getSourceMonthText())); result.put("originalRecords", query(whConf.getFormUuidWorkHoursSummary(), mapOf("textField_mmbffvd9", request.getSubmitterUserId(), "textField_mmbffvda", request.getSourceMonthText()))); List repairRecords = query(whConf.getFormUuidRepairWorkHoursSummary(), mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_MONTH, request.getSourceMonthText())); result.put("repairRecords", repairRecords); // 兼容页面状态合并接口的 records 约定;原始双表结果仍通过 repairRecords 返回。 result.put("records", repairRecords); return result; } 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.isClosed(monthText), "补填只能保存已封账月份"); McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidRepairWorkHoursSummary()), "补填汇总表未配置"); McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySyncStatusField()), "补填汇总状态字段未配置"); McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空"); assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText()); Map data = new LinkedHashMap<>(request.getFormData()); data.put(R_SUBMITTER, request.getSubmitterUserId()); data.put(R_DAY, request.getDayText()); data.put(R_MONTH, request.getDayText().substring(0, 6)); data.put(whConf.getRepairSummarySyncStatusField(), "暂存"); ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()).formUuid(whConf.getFormUuidRepairWorkHoursSummary()) .searchCondition(JSON.toJSONString(mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText()))) .formDataJson(JSON.toJSONString(data)).noExecuteExpression(false).build(), YDConf.FORM_OPERATION.upsert); return true; } public Map submit(TimeCardRepairSubmitRequest request) { McException.assertAccessException(request == null, "请求不能为空"); validateMonthRequest(request.getSubmitterUserId(), request.getSourceMonthText()); McException.assertAccessException(!monthClosingService.isClosed(request.getSourceMonthText()), "补填只能提交已封账月份"); 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 = LocalDate.now(ZONE).atStartOfDay(ZONE).toInstant().toEpochMilli(); int rowCount = validateSubmitRows(request, projectGroups, other); McException.assertAccessException(rowCount == 0, "没有需要提交的补填工时"); 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); } } /** * 补填审批补发起:只扫描近期修改的补填汇总,按员工、月份和补填提交日期补齐缺失明细。 * 正常 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 = query(whConf.getFormUuidRepairWorkHoursSummary(), 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) { if (StringUtils.isBlank(id)) return new HashMap(); com.malk.server.dingtalk.DDR_New result = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()).systemToken(whConf.getYidaSystemToken()).formInstanceId(id).build(), YDConf.FORM_QUERY.retrieve_id); return result == null || result.getFormData() == null ? new HashMap() : result.getFormData(); } 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<>(); Object data = 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).getData(); return data instanceof List ? (List) data : new ArrayList<>(); } 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) { 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); assertRepairRowMutable(request.getSubmitterUserId(), day, row, category); } private int validateSubmitRows(TimeCardRepairSubmitRequest request, Map projectGroups, Map otherGroup) { int count = 0; 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; validateRepairRow(request, (Map) rawRow, category); count++; } } } for (Object rawRow : list(otherGroup.get("otherRows"))) { if (!(rawRow instanceof Map)) continue; validateRepairRow(request, (Map) rawRow, "otherRows"); count++; } return count; } private void assertSourceDateAvailable(String userId, String day) { List original = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day)); boolean hasApprovedOrChangedHours = original.stream() .anyMatch(TimeCardRepairService::hasApprovedOrChangedHours); McException.assertAccessException(hasApprovedOrChangedHours, "来源日期已有已审批或变更工时,不能补填 userId=" + userId + ", day=" + day); } /** 仅原汇总当天已审批与变更工时的实际合计大于 0 时禁止补填;暂存草稿不占用补填资格。 */ static boolean hasApprovedOrChangedHours(Map record) { Object nested = record == null ? null : record.get("formData"); Map formData = nested instanceof Map ? (Map) nested : record; if (formData == null) return false; return num(formData.get(R_DAILY_APPROVED)) + num(formData.get(R_DAILY_CHANGE)) > 1e-6; } private void assertRepairRowMutable(String userId, String day, Map row, String category) { List existing = query(whConf.getFormUuidRepairWorkHoursSummary(), 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 = formData(item); 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; } Map fd = 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 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)}; } private 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 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; } }