package com.malk.service.workhours; import com.alibaba.fastjson.JSON; import com.malk.server.aliwork.YDConf; import com.malk.server.aliwork.YDParam; import com.malk.server.common.McException; import com.malk.server.dingtalk.DDR_New; import com.malk.server.workhours.MonthClosingCheckRequest; import com.malk.server.workhours.MonthClosingConf; import com.malk.server.workhours.MonthClosingStatusResult; import com.malk.server.workhours.MonthClosingValidationResult; import com.malk.server.workhours.WHConf; import com.malk.service.aliwork.YDClient; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.time.Instant; import java.time.YearMonth; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.format.ResolverStyle; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; import java.util.function.LongSupplier; /** * 月度封账统一校验服务。 * *

TimeCard、补填、变更与封账流程均调用本服务,避免各业务维护不同口径。

*

prd 2026-10-23:页面准入状态改为宜搭原生查询;本服务仍承担后端保存、 * 提交、审批补发及封账校验,不能随旧页面查询接口作废而停用。

*/ @Service public class MonthClosingService { private static final int PAGE_SIZE = 100; private static final int MAX_PAGES = 1000; private static final String APPROVAL_MONTH_FIELD = "textField_mmd2lv0y"; private static final String SUMMARY_MONTH_FIELD = "textField_mmbffvda"; private static final String SUMMARY_STATUS_IN_APPROVAL = "审批中"; private static final String REPAIR_SUMMARY_STATUS_PARTIAL = "部分成功"; private static final String WRITE_BACK_SUCCESS = "全部成功"; private static final String STATUS_CLOSED = "封账"; private static final String STATUS_REOPENED = "启封"; private static final long CLOSING_STATUS_CACHE_MILLIS = 5 * 60 * 1000L; private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("uuuuMM").withResolverStyle(ResolverStyle.STRICT); private final ConcurrentMap> closingStatusRequests = new ConcurrentHashMap<>(); private final ConcurrentMap closingStatusCache = new ConcurrentHashMap<>(); private LongSupplier currentTimeMillis = System::currentTimeMillis; @Autowired private YDClient ydClient; @Autowired private WHConf whConf; @Autowired private MonthClosingConf closingConf; /** * 查询目标月份是否已封账。 * * @param request 月份请求 * @return 已审批封账与进行中封账数量 */ public MonthClosingStatusResult status(MonthClosingCheckRequest request) { String monthText = validateRequest(request); return queryClosingStatusCached(monthText); } /** * 一次读取全部封账流程,预热实际封账月份、当前月和上月的五分钟缓存。 * * @deprecated prd 2026-10-23:旧页面查询预热功能作废,对应定时任务已关闭。 * 仅保留历史方法兼容,不再自动调用;业务按需缓存及并发合并保持原样。 * @return 本次写入缓存的月份数量 */ @Deprecated public int warmUpClosingStatusCache() { List processes = queryProcesses(closingConf.getFormUuid(), null, null, null); Set months = new LinkedHashSet<>(); for (Map item : processes) { String monthText = monthFromDateValue( extractFormData(item).get(closingConf.getMonthDateField())); if (StringUtils.isNotBlank(monthText)) { months.add(monthText); } } YearMonth currentMonth = YearMonth.now(BUSINESS_ZONE); months.add(currentMonth.format(MONTH_FORMATTER)); months.add(currentMonth.minusMonths(1).format(MONTH_FORMATTER)); long expiresAt = currentTimeMillis.getAsLong() + CLOSING_STATUS_CACHE_MILLIS; for (String monthText : months) { closingStatusCache.put(monthText, new CachedClosingStatus( summarizeClosingStatus( monthText, filterClosingMonth(processes, monthText)), expiresAt)); } return months.size(); } /** * 查询目标月份是否已封账;同月查询结果在五分钟内由后端复用。 * * @param monthText 月份,格式 yyyyMM * @return true 表示最新已审批动作是封账 */ public boolean isClosed(String monthText) { MonthClosingCheckRequest request = MonthClosingCheckRequest.builder() .monthText(monthText) .build(); return status(request).isClosed(); } /** * 正常工时写入锁:封账流程中与已审批封账均禁止新增或追加审批。 * * @param monthText 工时所属月份,格式 yyyyMM * @return true 表示当月已经锁定 */ public boolean isLocked(String monthText) { MonthClosingStatusResult result = status(MonthClosingCheckRequest.builder() .monthText(monthText).build()); return result.isClosed() || result.getRunningClosingCount() > 0; } /** * 校验目标月份是否允许发起封账。 * * @param request 月份请求 * @return 封账许可、阻塞数量及提示 */ public MonthClosingValidationResult validate(MonthClosingCheckRequest request) { String monthText = validateRequest(request); String requestedStatus = validateClosingStatus(request.getClosingStatus()); MonthClosingStatusResult closingStatus = queryClosingStatusCached(monthText); if (STATUS_REOPENED.equals(requestedStatus)) { return validateReopening(monthText, closingStatus); } return validateClosing(monthText, closingStatus); } private MonthClosingValidationResult validateClosing( String monthText, MonthClosingStatusResult closingStatus) { if (closingStatus.isClosed() || closingStatus.getRunningClosingCount() > 0) { return buildValidationResult(monthText, STATUS_CLOSED, closingStatus, 0, 0, 0, 0); } List projectRunning = normalApprovalProcesses(queryProcesses( whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING")); List otherRunning = normalApprovalProcesses(queryProcesses( whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING")); List projectCompleted = normalApprovalProcesses(queryProcesses( whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED")); List otherCompleted = normalApprovalProcesses(queryProcesses( whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED")); projectRunning.addAll(adjustmentApprovalProcesses(whConf.getFormUuidApproval(), whConf.getApprovalRepairSubmitDateField(), monthText, "RUNNING")); otherRunning.addAll(adjustmentApprovalProcesses(whConf.getFormUuidOtherApproval(), whConf.getOtherApprovalRepairSubmitDateField(), monthText, "RUNNING")); projectCompleted.addAll(adjustmentApprovalProcesses(whConf.getFormUuidApproval(), whConf.getApprovalRepairSubmitDateField(), monthText, "COMPLETED")); otherCompleted.addAll(adjustmentApprovalProcesses(whConf.getFormUuidOtherApproval(), whConf.getOtherApprovalRepairSubmitDateField(), monthText, "COMPLETED")); int writeBackPendingCount = countWriteBackPending( projectCompleted, whConf.getApprovalSyncStatusField()) + countWriteBackPending( otherCompleted, whConf.getOtherApprovalSyncStatusField()); int summaryPendingCount = querySummaryPendingCount(monthText) + queryRepairSummaryPendingCount(monthText); return buildValidationResult( monthText, STATUS_CLOSED, closingStatus, projectRunning.size(), otherRunning.size(), writeBackPendingCount, summaryPendingCount); } /** 正常填报沿用填报月份口径;历史空类型按正常填报处理。 */ private List normalApprovalProcesses(List processes) { List result = new ArrayList<>(); String fillTypeField = whConf.getApprovalFillTypeField(); for (Map item : processes) { Map data = extractFormData(item); String fillType = text(data.get(fillTypeField)); if (StringUtils.isBlank(fillTypeField) || (!"补填".equals(fillType) && !"变更".equals(fillType))) { result.add(item); } } return result; } /** 补填和变更审批按提交日期归属封账月份,目标月份只用于定位被调整的工时。 */ private List adjustmentApprovalProcesses(String formUuid, String submitDateField, String monthText, String instanceStatus) { McException.assertAccessException(StringUtils.isAnyBlank( whConf.getApprovalFillTypeField(), submitDateField), "补填/变更审批查询配置不完整"); List result = new ArrayList<>(); for (Map item : queryProcesses(formUuid, null, null, instanceStatus)) { Map data = extractFormData(item); String fillType = text(data.get(whConf.getApprovalFillTypeField())); if (("补填".equals(fillType) || "变更".equals(fillType)) && monthText.equals(monthFromDateValue(data.get(submitDateField)))) { result.add(item); } } return result; } private MonthClosingValidationResult validateReopening( String monthText, MonthClosingStatusResult closingStatus) { return buildValidationResult(monthText, STATUS_REOPENED, closingStatus, 0, 0, 0, 0); } private static MonthClosingValidationResult buildValidationResult( String monthText, String requestedStatus, MonthClosingStatusResult closingStatus, int projectRunningCount, int otherRunningCount, int writeBackPendingCount, int summaryPendingCount) { boolean requestedClosing = STATUS_CLOSED.equals(requestedStatus); boolean stateAllowed = requestedClosing ? !closingStatus.isClosed() : closingStatus.isClosed(); boolean allowed = stateAllowed && closingStatus.getRunningClosingCount() == 0 && projectRunningCount == 0 && otherRunningCount == 0 && writeBackPendingCount == 0 && summaryPendingCount == 0; return MonthClosingValidationResult.builder() .monthText(monthText) .allowed(allowed) .requestedStatus(requestedStatus) .currentStatus(closingStatus.getCurrentStatus()) .alreadyClosed(closingStatus.isClosed()) .closingRunningCount(closingStatus.getRunningClosingCount()) .projectRunningCount(projectRunningCount) .otherRunningCount(otherRunningCount) .writeBackPendingCount(writeBackPendingCount) .summaryPendingCount(summaryPendingCount) .message(buildMessage( monthText, requestedStatus, closingStatus, projectRunningCount, otherRunningCount, writeBackPendingCount, summaryPendingCount)) .build(); } private MonthClosingStatusResult queryClosingStatusCached(String monthText) { MonthClosingStatusResult cached = getCachedClosingStatus(monthText); if (cached != null) { return cached; } return queryClosingStatusCoalesced(monthText); } private MonthClosingStatusResult queryClosingStatusCoalesced(String monthText) { // fixme 缓存未命中时,同月并发请求共享一次宜搭查询。 FutureTask newRequest = new FutureTask<>(() -> { MonthClosingStatusResult cached = getCachedClosingStatus(monthText); if (cached != null) { return cached; } MonthClosingStatusResult result = queryClosingStatus(monthText); closingStatusCache.put(monthText, new CachedClosingStatus( result, currentTimeMillis.getAsLong() + CLOSING_STATUS_CACHE_MILLIS)); return result; }); FutureTask activeRequest = closingStatusRequests.putIfAbsent(monthText, newRequest); boolean ownsRequest = activeRequest == null; if (ownsRequest) { activeRequest = newRequest; activeRequest.run(); } try { return activeRequest.get(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); throw new IllegalStateException("月度封账状态查询被中断", ex); } catch (ExecutionException ex) { Throwable cause = ex.getCause(); if (cause instanceof RuntimeException) { throw (RuntimeException) cause; } if (cause instanceof Error) { throw (Error) cause; } throw new IllegalStateException("月度封账状态查询失败", cause); } finally { if (ownsRequest) { closingStatusRequests.remove(monthText, newRequest); } } } private MonthClosingStatusResult getCachedClosingStatus(String monthText) { while (true) { CachedClosingStatus cached = closingStatusCache.get(monthText); if (cached == null) { return null; } if (currentTimeMillis.getAsLong() < cached.expiresAtMillis) { return cached.result; } if (closingStatusCache.remove(monthText, cached)) { return null; } } } private static final class CachedClosingStatus { private final MonthClosingStatusResult result; private final long expiresAtMillis; private CachedClosingStatus(MonthClosingStatusResult result, long expiresAtMillis) { this.result = result; this.expiresAtMillis = expiresAtMillis; } } private MonthClosingStatusResult queryClosingStatus(String monthText) { List monthRange = closingMonthRange(monthText); List processes = filterClosingMonth( queryProcesses(closingConf.getFormUuid(), closingConf.getMonthDateField(), monthRange, null), monthText); return summarizeClosingStatus(monthText, processes); } private MonthClosingStatusResult summarizeClosingStatus(String monthText, List processes) { int approvedClosingCount = 0; int approvedReopeningCount = 0; int runningClosingCount = 0; String currentStatus = ""; Long latestTime = null; for (Map item : processes) { String instanceStatus = processInstanceStatus(item); if ("RUNNING".equals(instanceStatus)) { runningClosingCount++; continue; } if (!"COMPLETED".equals(instanceStatus)) { continue; } if (!isApproved(item)) { continue; } String itemStatus = text(extractFormData(item).get(closingConf.getStatusField())); if (STATUS_CLOSED.equals(itemStatus)) { approvedClosingCount++; } else if (STATUS_REOPENED.equals(itemStatus)) { approvedReopeningCount++; } else { continue; } Long itemTime = processEventTime(item); if (StringUtils.isBlank(currentStatus) || (latestTime == null && itemTime != null) || (latestTime != null && itemTime != null && itemTime > latestTime)) { currentStatus = itemStatus; latestTime = itemTime; } } return MonthClosingStatusResult.builder() .monthText(monthText) .closed(STATUS_CLOSED.equals(currentStatus)) .currentStatus(currentStatus) .approvedClosingCount(approvedClosingCount) .approvedReopeningCount(approvedReopeningCount) .runningClosingCount(runningClosingCount) .build(); } private List filterClosingMonth(List processes, String monthText) { List result = new ArrayList<>(); for (Map item : processes) { Object monthValue = extractFormData(item).get(closingConf.getMonthDateField()); if (monthText.equals(monthFromDateValue(monthValue))) { result.add(item); } } return result; } private int countWriteBackPending(List completed, String syncStatusField) { int pending = 0; for (Map item : completed) { if (!isApproved(item)) { continue; } Map formData = extractFormData(item); String syncStatus = text(formData.get(syncStatusField)); if (!WRITE_BACK_SUCCESS.equals(syncStatus)) { pending++; } } return pending; } private int querySummaryPendingCount(String monthText) { Map search = new HashMap<>(); search.put(SUMMARY_MONTH_FIELD, monthText); search.put(whConf.getSummaryApprovalStatusField(), SUMMARY_STATUS_IN_APPROVAL); List rows = queryForms(whConf.getFormUuidWorkHoursSummary(), search); int count = 0; for (Map item : rows) { Map formData = extractFormData(item); if (monthText.equals(text(formData.get(SUMMARY_MONTH_FIELD))) && SUMMARY_STATUS_IN_APPROVAL.equals( text(formData.get(whConf.getSummaryApprovalStatusField())))) { count++; } } return count; } private int queryRepairSummaryPendingCount(String monthText) { McException.assertAccessException(StringUtils.isAnyBlank( whConf.getFormUuidRepairWorkHoursSummary(), whConf.getRepairSummarySubmitDateField(), whConf.getRepairSummarySyncStatusField()), "补填汇总查询配置不完整"); int count = 0; // fixme 补填提交日期是复制表新增字段;完整分页后本地匹配月份,避免查询索引漏掉待回写记录。 for (Map item : queryForms(whConf.getFormUuidRepairWorkHoursSummary(), new HashMap<>())) { Map data = extractFormData(item); String status = text(data.get(whConf.getRepairSummarySyncStatusField())); if (monthText.equals(monthFromDateValue(data.get(whConf.getRepairSummarySubmitDateField()))) && (SUMMARY_STATUS_IN_APPROVAL.equals(status) || REPAIR_SUMMARY_STATUS_PARTIAL.equals(status))) { count++; } } return count; } private List queryProcesses( String formUuid, String monthField, Object monthValue, String instanceStatus) { McException.assertAccessException( StringUtils.isBlank(formUuid), "月度封账流程查询配置不完整"); Map search = new HashMap<>(); if (StringUtils.isNotBlank(monthField) && monthValue != null) { search.put(monthField, monthValue); } List result = new ArrayList<>(); int page = 1; while (page <= MAX_PAGES) { DDR_New response = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(formUuid) .searchFieldJson(search.isEmpty() ? null : JSON.toJSONString(search)) .instanceStatus(instanceStatus) .pageNumber(page) .pageSize(PAGE_SIZE) .build(), YDConf.FORM_QUERY.retrieve_search_process); List data = responseData(response); result.addAll(data); if (!hasNextPage(page, response.getTotalCount(), data.size(), result.size())) { break; } page++; } McException.assertAccessException(page > MAX_PAGES, "月度封账流程查询超过分页上限"); return result; } private List queryForms(String formUuid, Map search) { McException.assertAccessException(StringUtils.isBlank(formUuid), "工时汇总表配置为空"); List result = new ArrayList<>(); int page = 1; while (page <= MAX_PAGES) { DDR_New response = ydClient.queryData(YDParam.builder() .appType(whConf.getYidaAppType()) .systemToken(whConf.getYidaSystemToken()) .formUuid(formUuid) .searchFieldJson(JSON.toJSONString(search)) .currentPage(page) .pageSize(PAGE_SIZE) .build(), YDConf.FORM_QUERY.retrieve_search_form); List data = responseData(response); result.addAll(data); if (!hasNextPage(page, response.getTotalCount(), data.size(), result.size())) { break; } page++; } McException.assertAccessException(page > MAX_PAGES, "工时汇总查询超过分页上限"); return result; } private static boolean hasNextPage( int page, long totalCount, int returnedSize, int collectedSize) { if (returnedSize < PAGE_SIZE) { return false; } return totalCount <= 0 || collectedSize < totalCount || (long) page * PAGE_SIZE < totalCount; } @SuppressWarnings("unchecked") private static List responseData(DDR_New response) { McException.assertAccessException(response == null, "宜搭查询未返回结果"); Object data = response.getData(); if (data == null) { return new ArrayList<>(); } McException.assertAccessException(!(data instanceof List), "宜搭查询返回数据格式异常"); return (List) data; } @SuppressWarnings("unchecked") static Map extractFormData(Map item) { if (item == null) { return new HashMap(); } Object formData = item.get("formData"); if (formData instanceof Map) { return (Map) formData; } Object data = item.get("data"); if (!(data instanceof Map)) { return new HashMap(); } Map dataMap = (Map) data; Object nested = dataMap.get("formData"); return nested instanceof Map ? (Map) nested : dataMap; } static String validateMonthText(String monthText) { String value = StringUtils.trimToEmpty(monthText); McException.assertParamException(!value.matches("\\d{6}"), "月份必须为yyyyMM格式"); try { YearMonth.parse(value, MONTH_FORMATTER); } catch (DateTimeParseException ex) { throw McException.builder() .code("validated_param") .message("月份必须为有效的yyyyMM格式") .build(); } return value; } static List closingMonthRange(String monthText) { YearMonth month = YearMonth.parse(validateMonthText(monthText), MONTH_FORMATTER); long start = month.atDay(1).atStartOfDay(BUSINESS_ZONE).toInstant().toEpochMilli(); long end = month.plusMonths(1).atDay(1).atStartOfDay(BUSINESS_ZONE) .toInstant().toEpochMilli() - 1L; return Arrays.asList(start, end); } static String monthFromDateValue(Object value) { if (value == null) { return ""; } if (value instanceof Number) { return monthFromEpoch(((Number) value).longValue()); } String raw = text(value); if (raw.matches("\\d{6}")) { try { return validateMonthText(raw); } catch (McException ignored) { return ""; } } if (raw.matches("\\d{8}")) { return normalizeYearMonth(raw.substring(0, 4), raw.substring(4, 6)); } if (raw.matches("\\d{10}|\\d{13}")) { try { return monthFromEpoch(Long.parseLong(raw)); } catch (NumberFormatException ignored) { return ""; } } try { return YearMonth.from(Instant.parse(raw).atZone(BUSINESS_ZONE)).format(MONTH_FORMATTER); } catch (DateTimeParseException ignored) { // 普通日期字符串继续按 yyyy-MM 前缀处理。 } if (raw.matches("^\\d{4}[-/]\\d{2}.*")) { return normalizeYearMonth(raw.substring(0, 4), raw.substring(5, 7)); } return ""; } private static String monthFromEpoch(long rawTimestamp) { String digits = String.valueOf(Math.abs(rawTimestamp)); if (digits.length() == 8) { return normalizeYearMonth(digits.substring(0, 4), digits.substring(4, 6)); } long milliseconds = digits.length() <= 10 ? rawTimestamp * 1000L : rawTimestamp; try { return YearMonth.from(Instant.ofEpochMilli(milliseconds).atZone(BUSINESS_ZONE)) .format(MONTH_FORMATTER); } catch (RuntimeException ignored) { return ""; } } private static String normalizeYearMonth(String year, String month) { try { return YearMonth.of(Integer.parseInt(year), Integer.parseInt(month)) .format(MONTH_FORMATTER); } catch (RuntimeException ignored) { return ""; } } private static Long processEventTime(Map item) { if (item == null) { return null; } String[] fields = { "modifiedTimeGMT", "gmtModified", "finishTimeGMT", "finishTime", "modifiedTime", "createTimeGMT", "createTime" }; for (String field : fields) { Long parsed = parseTimestamp(item.get(field)); if (parsed != null) { return parsed; } } return null; } private static Long parseTimestamp(Object value) { if (value instanceof Number) { long number = ((Number) value).longValue(); return String.valueOf(Math.abs(number)).length() <= 10 ? number * 1000L : number; } String raw = text(value); if (raw.matches("\\d{10}|\\d{13}")) { try { long number = Long.parseLong(raw); return raw.length() == 10 ? number * 1000L : number; } catch (NumberFormatException ignored) { return null; } } try { return Instant.parse(raw).toEpochMilli(); } catch (DateTimeParseException ignored) { return null; } } private static String validateClosingStatus(String closingStatus) { String value = text(closingStatus); McException.assertParamException( !STATUS_CLOSED.equals(value) && !STATUS_REOPENED.equals(value), "封账状态必须为封账或启封"); return value; } private String validateRequest(MonthClosingCheckRequest request) { McException.assertParamException(request == null, "请求不能为空"); McException.assertAccessException( StringUtils.isAnyBlank( whConf.getYidaAppType(), whConf.getYidaSystemToken(), closingConf.getFormUuid(), closingConf.getMonthDateField(), closingConf.getStatusField()), "月度封账配置不完整"); return validateMonthText(request.getMonthText()); } private static boolean isApproved(Map item) { return "agree".equals(text(item.get("approvedResult")).toLowerCase(Locale.ROOT)); } private static String processInstanceStatus(Map item) { String status = text(item == null ? null : item.get("instanceStatus")); if (StringUtils.isBlank(status)) { status = text(item == null ? null : item.get("processInstanceStatus")); } return status.toUpperCase(Locale.ROOT); } private static String buildMessage( String monthText, String requestedStatus, MonthClosingStatusResult closingStatus, int projectRunningCount, int otherRunningCount, int writeBackPendingCount, int summaryPendingCount) { if (closingStatus.getRunningClosingCount() > 0) { return monthText + " 已有封账或启封流程审批中,不可重复提交"; } if (STATUS_REOPENED.equals(requestedStatus)) { return closingStatus.isClosed() ? monthText + " 当前已封账,可以提交启封" : monthText + " 当前未封账,不可重复启封"; } if (closingStatus.isClosed()) { return monthText + " 已完成封账,不可重复提交"; } if (projectRunningCount > 0 || otherRunningCount > 0) { return monthText + " 仍有工时审批单处理中:项目工时 " + projectRunningCount + " 张,其他工时 " + otherRunningCount + " 张"; } if (writeBackPendingCount > 0) { return monthText + " 有 " + writeBackPendingCount + " 张审批单尚未成功回写工时汇总"; } if (summaryPendingCount > 0) { return monthText + " 仍有 " + summaryPendingCount + " 条工时汇总尚未完成审批回写"; } return monthText + " 工时审批已完成,可以提交封账"; } private static String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); } }