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.YearMonth; import java.time.Instant; 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.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; /** * 月度封账统一校验服务。 * *

TimeCard 与封账流程提交前均调用本服务,避免页面各自维护不同口径。

*/ @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 WRITE_BACK_SUCCESS = "全部成功"; private static final String STATUS_CLOSED = "封账"; private static final String STATUS_REOPENED = "启封"; private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("uuuuMM").withResolverStyle(ResolverStyle.STRICT); @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 queryClosingStatus(monthText); } /** * 校验目标月份是否允许发起封账。 * * @param request 月份请求 * @return 封账许可、阻塞数量及提示 */ public MonthClosingValidationResult validate(MonthClosingCheckRequest request) { String monthText = validateRequest(request); String requestedStatus = validateClosingStatus(request.getClosingStatus()); MonthClosingStatusResult closingStatus = queryClosingStatus(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 = queryProcesses( whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING"); List otherRunning = queryProcesses( whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING"); List projectCompleted = queryProcesses( whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED"); List otherCompleted = queryProcesses( whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED"); int writeBackPendingCount = countWriteBackPending( projectCompleted, whConf.getApprovalSyncStatusField()) + countWriteBackPending( otherCompleted, whConf.getOtherApprovalSyncStatusField()); int summaryPendingCount = querySummaryPendingCount(monthText); return buildValidationResult( monthText, STATUS_CLOSED, closingStatus, projectRunning.size(), otherRunning.size(), writeBackPendingCount, summaryPendingCount); } 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 queryClosingStatus(String monthText) { List completed = filterClosingMonth( queryProcesses(closingConf.getFormUuid(), null, null, "COMPLETED"), monthText); List running = filterClosingMonth( queryProcesses(closingConf.getFormUuid(), null, null, "RUNNING"), monthText); int approvedClosingCount = 0; int approvedReopeningCount = 0; String currentStatus = ""; Long latestTime = null; for (Map item : completed) { 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(running.size()) .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 List queryProcesses( String formUuid, String monthField, String monthText, String instanceStatus) { McException.assertAccessException( StringUtils.isBlank(formUuid), "月度封账流程查询配置不完整"); Map search = new HashMap<>(); if (StringUtils.isNotBlank(monthField) && StringUtils.isNotBlank(monthText)) { search.put(monthField, monthText); } 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 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 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(); } }