MonthClosingService.java 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. package com.malk.service.workhours;
  2. import com.alibaba.fastjson.JSON;
  3. import com.malk.server.aliwork.YDConf;
  4. import com.malk.server.aliwork.YDParam;
  5. import com.malk.server.common.McException;
  6. import com.malk.server.dingtalk.DDR_New;
  7. import com.malk.server.workhours.MonthClosingCheckRequest;
  8. import com.malk.server.workhours.MonthClosingConf;
  9. import com.malk.server.workhours.MonthClosingStatusResult;
  10. import com.malk.server.workhours.MonthClosingValidationResult;
  11. import com.malk.server.workhours.WHConf;
  12. import com.malk.service.aliwork.YDClient;
  13. import org.apache.commons.lang3.StringUtils;
  14. import org.springframework.beans.factory.annotation.Autowired;
  15. import org.springframework.stereotype.Service;
  16. import java.time.YearMonth;
  17. import java.time.Instant;
  18. import java.time.ZoneId;
  19. import java.time.format.DateTimeFormatter;
  20. import java.time.format.DateTimeParseException;
  21. import java.time.format.ResolverStyle;
  22. import java.util.ArrayList;
  23. import java.util.HashMap;
  24. import java.util.List;
  25. import java.util.Locale;
  26. import java.util.Map;
  27. /**
  28. * 月度封账统一校验服务。
  29. *
  30. * <p>TimeCard 与封账流程提交前均调用本服务,避免页面各自维护不同口径。</p>
  31. */
  32. @Service
  33. public class MonthClosingService {
  34. private static final int PAGE_SIZE = 100;
  35. private static final int MAX_PAGES = 1000;
  36. private static final String APPROVAL_MONTH_FIELD = "textField_mmd2lv0y";
  37. private static final String SUMMARY_MONTH_FIELD = "textField_mmbffvda";
  38. private static final String SUMMARY_STATUS_IN_APPROVAL = "审批中";
  39. private static final String WRITE_BACK_SUCCESS = "全部成功";
  40. private static final String STATUS_CLOSED = "封账";
  41. private static final String STATUS_REOPENED = "启封";
  42. private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
  43. private static final DateTimeFormatter MONTH_FORMATTER =
  44. DateTimeFormatter.ofPattern("uuuuMM").withResolverStyle(ResolverStyle.STRICT);
  45. @Autowired
  46. private YDClient ydClient;
  47. @Autowired
  48. private WHConf whConf;
  49. @Autowired
  50. private MonthClosingConf closingConf;
  51. /**
  52. * 查询目标月份是否已封账。
  53. *
  54. * @param request 月份请求
  55. * @return 已审批封账与进行中封账数量
  56. */
  57. public MonthClosingStatusResult status(MonthClosingCheckRequest request) {
  58. String monthText = validateRequest(request);
  59. return queryClosingStatus(monthText);
  60. }
  61. /**
  62. * 校验目标月份是否允许发起封账。
  63. *
  64. * @param request 月份请求
  65. * @return 封账许可、阻塞数量及提示
  66. */
  67. public MonthClosingValidationResult validate(MonthClosingCheckRequest request) {
  68. String monthText = validateRequest(request);
  69. String requestedStatus = validateClosingStatus(request.getClosingStatus());
  70. MonthClosingStatusResult closingStatus = queryClosingStatus(monthText);
  71. if (STATUS_REOPENED.equals(requestedStatus)) {
  72. return validateReopening(monthText, closingStatus);
  73. }
  74. return validateClosing(monthText, closingStatus);
  75. }
  76. private MonthClosingValidationResult validateClosing(
  77. String monthText,
  78. MonthClosingStatusResult closingStatus) {
  79. if (closingStatus.isClosed() || closingStatus.getRunningClosingCount() > 0) {
  80. return buildValidationResult(monthText, STATUS_CLOSED, closingStatus, 0, 0, 0, 0);
  81. }
  82. List<Map> projectRunning = queryProcesses(
  83. whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING");
  84. List<Map> otherRunning = queryProcesses(
  85. whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING");
  86. List<Map> projectCompleted = queryProcesses(
  87. whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED");
  88. List<Map> otherCompleted = queryProcesses(
  89. whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED");
  90. int writeBackPendingCount = countWriteBackPending(
  91. projectCompleted, whConf.getApprovalSyncStatusField())
  92. + countWriteBackPending(
  93. otherCompleted, whConf.getOtherApprovalSyncStatusField());
  94. int summaryPendingCount = querySummaryPendingCount(monthText);
  95. return buildValidationResult(
  96. monthText,
  97. STATUS_CLOSED,
  98. closingStatus,
  99. projectRunning.size(),
  100. otherRunning.size(),
  101. writeBackPendingCount,
  102. summaryPendingCount);
  103. }
  104. private MonthClosingValidationResult validateReopening(
  105. String monthText,
  106. MonthClosingStatusResult closingStatus) {
  107. return buildValidationResult(monthText, STATUS_REOPENED, closingStatus, 0, 0, 0, 0);
  108. }
  109. private static MonthClosingValidationResult buildValidationResult(
  110. String monthText,
  111. String requestedStatus,
  112. MonthClosingStatusResult closingStatus,
  113. int projectRunningCount,
  114. int otherRunningCount,
  115. int writeBackPendingCount,
  116. int summaryPendingCount) {
  117. boolean requestedClosing = STATUS_CLOSED.equals(requestedStatus);
  118. boolean stateAllowed = requestedClosing ? !closingStatus.isClosed() : closingStatus.isClosed();
  119. boolean allowed = stateAllowed
  120. && closingStatus.getRunningClosingCount() == 0
  121. && projectRunningCount == 0
  122. && otherRunningCount == 0
  123. && writeBackPendingCount == 0
  124. && summaryPendingCount == 0;
  125. return MonthClosingValidationResult.builder()
  126. .monthText(monthText)
  127. .allowed(allowed)
  128. .requestedStatus(requestedStatus)
  129. .currentStatus(closingStatus.getCurrentStatus())
  130. .alreadyClosed(closingStatus.isClosed())
  131. .closingRunningCount(closingStatus.getRunningClosingCount())
  132. .projectRunningCount(projectRunningCount)
  133. .otherRunningCount(otherRunningCount)
  134. .writeBackPendingCount(writeBackPendingCount)
  135. .summaryPendingCount(summaryPendingCount)
  136. .message(buildMessage(
  137. monthText,
  138. requestedStatus,
  139. closingStatus,
  140. projectRunningCount,
  141. otherRunningCount,
  142. writeBackPendingCount,
  143. summaryPendingCount))
  144. .build();
  145. }
  146. private MonthClosingStatusResult queryClosingStatus(String monthText) {
  147. List<Map> completed = filterClosingMonth(
  148. queryProcesses(closingConf.getFormUuid(), null, null, "COMPLETED"), monthText);
  149. List<Map> running = filterClosingMonth(
  150. queryProcesses(closingConf.getFormUuid(), null, null, "RUNNING"), monthText);
  151. int approvedClosingCount = 0;
  152. int approvedReopeningCount = 0;
  153. String currentStatus = "";
  154. Long latestTime = null;
  155. for (Map item : completed) {
  156. if (!isApproved(item)) {
  157. continue;
  158. }
  159. String itemStatus = text(extractFormData(item).get(closingConf.getStatusField()));
  160. if (STATUS_CLOSED.equals(itemStatus)) {
  161. approvedClosingCount++;
  162. } else if (STATUS_REOPENED.equals(itemStatus)) {
  163. approvedReopeningCount++;
  164. } else {
  165. continue;
  166. }
  167. Long itemTime = processEventTime(item);
  168. if (StringUtils.isBlank(currentStatus)
  169. || (latestTime == null && itemTime != null)
  170. || (latestTime != null && itemTime != null && itemTime > latestTime)) {
  171. currentStatus = itemStatus;
  172. latestTime = itemTime;
  173. }
  174. }
  175. return MonthClosingStatusResult.builder()
  176. .monthText(monthText)
  177. .closed(STATUS_CLOSED.equals(currentStatus))
  178. .currentStatus(currentStatus)
  179. .approvedClosingCount(approvedClosingCount)
  180. .approvedReopeningCount(approvedReopeningCount)
  181. .runningClosingCount(running.size())
  182. .build();
  183. }
  184. private List<Map> filterClosingMonth(List<Map> processes, String monthText) {
  185. List<Map> result = new ArrayList<>();
  186. for (Map item : processes) {
  187. Object monthValue = extractFormData(item).get(closingConf.getMonthDateField());
  188. if (monthText.equals(monthFromDateValue(monthValue))) {
  189. result.add(item);
  190. }
  191. }
  192. return result;
  193. }
  194. private int countWriteBackPending(List<Map> completed, String syncStatusField) {
  195. int pending = 0;
  196. for (Map item : completed) {
  197. if (!isApproved(item)) {
  198. continue;
  199. }
  200. Map formData = extractFormData(item);
  201. String syncStatus = text(formData.get(syncStatusField));
  202. if (!WRITE_BACK_SUCCESS.equals(syncStatus)) {
  203. pending++;
  204. }
  205. }
  206. return pending;
  207. }
  208. private int querySummaryPendingCount(String monthText) {
  209. Map<String, Object> search = new HashMap<>();
  210. search.put(SUMMARY_MONTH_FIELD, monthText);
  211. search.put(whConf.getSummaryApprovalStatusField(), SUMMARY_STATUS_IN_APPROVAL);
  212. List<Map> rows = queryForms(whConf.getFormUuidWorkHoursSummary(), search);
  213. int count = 0;
  214. for (Map item : rows) {
  215. Map formData = extractFormData(item);
  216. if (monthText.equals(text(formData.get(SUMMARY_MONTH_FIELD)))
  217. && SUMMARY_STATUS_IN_APPROVAL.equals(
  218. text(formData.get(whConf.getSummaryApprovalStatusField())))) {
  219. count++;
  220. }
  221. }
  222. return count;
  223. }
  224. private List<Map> queryProcesses(
  225. String formUuid,
  226. String monthField,
  227. String monthText,
  228. String instanceStatus) {
  229. McException.assertAccessException(
  230. StringUtils.isBlank(formUuid),
  231. "月度封账流程查询配置不完整");
  232. Map<String, Object> search = new HashMap<>();
  233. if (StringUtils.isNotBlank(monthField) && StringUtils.isNotBlank(monthText)) {
  234. search.put(monthField, monthText);
  235. }
  236. List<Map> result = new ArrayList<>();
  237. int page = 1;
  238. while (page <= MAX_PAGES) {
  239. DDR_New response = ydClient.queryData(YDParam.builder()
  240. .appType(whConf.getYidaAppType())
  241. .systemToken(whConf.getYidaSystemToken())
  242. .formUuid(formUuid)
  243. .searchFieldJson(search.isEmpty() ? null : JSON.toJSONString(search))
  244. .instanceStatus(instanceStatus)
  245. .pageNumber(page)
  246. .pageSize(PAGE_SIZE)
  247. .build(), YDConf.FORM_QUERY.retrieve_search_process);
  248. List<Map> data = responseData(response);
  249. result.addAll(data);
  250. if (!hasNextPage(page, response.getTotalCount(), data.size(), result.size())) {
  251. break;
  252. }
  253. page++;
  254. }
  255. McException.assertAccessException(page > MAX_PAGES, "月度封账流程查询超过分页上限");
  256. return result;
  257. }
  258. private List<Map> queryForms(String formUuid, Map<String, Object> search) {
  259. McException.assertAccessException(StringUtils.isBlank(formUuid), "工时汇总表配置为空");
  260. List<Map> result = new ArrayList<>();
  261. int page = 1;
  262. while (page <= MAX_PAGES) {
  263. DDR_New response = ydClient.queryData(YDParam.builder()
  264. .appType(whConf.getYidaAppType())
  265. .systemToken(whConf.getYidaSystemToken())
  266. .formUuid(formUuid)
  267. .searchFieldJson(JSON.toJSONString(search))
  268. .currentPage(page)
  269. .pageSize(PAGE_SIZE)
  270. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  271. List<Map> data = responseData(response);
  272. result.addAll(data);
  273. if (!hasNextPage(page, response.getTotalCount(), data.size(), result.size())) {
  274. break;
  275. }
  276. page++;
  277. }
  278. McException.assertAccessException(page > MAX_PAGES, "工时汇总查询超过分页上限");
  279. return result;
  280. }
  281. private static boolean hasNextPage(
  282. int page,
  283. long totalCount,
  284. int returnedSize,
  285. int collectedSize) {
  286. if (returnedSize < PAGE_SIZE) {
  287. return false;
  288. }
  289. return totalCount <= 0 || collectedSize < totalCount || (long) page * PAGE_SIZE < totalCount;
  290. }
  291. @SuppressWarnings("unchecked")
  292. private static List<Map> responseData(DDR_New response) {
  293. McException.assertAccessException(response == null, "宜搭查询未返回结果");
  294. Object data = response.getData();
  295. if (data == null) {
  296. return new ArrayList<>();
  297. }
  298. McException.assertAccessException(!(data instanceof List), "宜搭查询返回数据格式异常");
  299. return (List<Map>) data;
  300. }
  301. @SuppressWarnings("unchecked")
  302. static Map extractFormData(Map item) {
  303. if (item == null) {
  304. return new HashMap();
  305. }
  306. Object formData = item.get("formData");
  307. if (formData instanceof Map) {
  308. return (Map) formData;
  309. }
  310. Object data = item.get("data");
  311. if (!(data instanceof Map)) {
  312. return new HashMap();
  313. }
  314. Map dataMap = (Map) data;
  315. Object nested = dataMap.get("formData");
  316. return nested instanceof Map ? (Map) nested : dataMap;
  317. }
  318. static String validateMonthText(String monthText) {
  319. String value = StringUtils.trimToEmpty(monthText);
  320. McException.assertParamException(!value.matches("\\d{6}"), "月份必须为yyyyMM格式");
  321. try {
  322. YearMonth.parse(value, MONTH_FORMATTER);
  323. } catch (DateTimeParseException ex) {
  324. throw McException.builder()
  325. .code("validated_param")
  326. .message("月份必须为有效的yyyyMM格式")
  327. .build();
  328. }
  329. return value;
  330. }
  331. static String monthFromDateValue(Object value) {
  332. if (value == null) {
  333. return "";
  334. }
  335. if (value instanceof Number) {
  336. return monthFromEpoch(((Number) value).longValue());
  337. }
  338. String raw = text(value);
  339. if (raw.matches("\\d{6}")) {
  340. try {
  341. return validateMonthText(raw);
  342. } catch (McException ignored) {
  343. return "";
  344. }
  345. }
  346. if (raw.matches("\\d{8}")) {
  347. return normalizeYearMonth(raw.substring(0, 4), raw.substring(4, 6));
  348. }
  349. if (raw.matches("\\d{10}|\\d{13}")) {
  350. try {
  351. return monthFromEpoch(Long.parseLong(raw));
  352. } catch (NumberFormatException ignored) {
  353. return "";
  354. }
  355. }
  356. try {
  357. return YearMonth.from(Instant.parse(raw).atZone(BUSINESS_ZONE)).format(MONTH_FORMATTER);
  358. } catch (DateTimeParseException ignored) {
  359. // 普通日期字符串继续按 yyyy-MM 前缀处理。
  360. }
  361. if (raw.matches("^\\d{4}[-/]\\d{2}.*")) {
  362. return normalizeYearMonth(raw.substring(0, 4), raw.substring(5, 7));
  363. }
  364. return "";
  365. }
  366. private static String monthFromEpoch(long rawTimestamp) {
  367. String digits = String.valueOf(Math.abs(rawTimestamp));
  368. if (digits.length() == 8) {
  369. return normalizeYearMonth(digits.substring(0, 4), digits.substring(4, 6));
  370. }
  371. long milliseconds = digits.length() <= 10 ? rawTimestamp * 1000L : rawTimestamp;
  372. try {
  373. return YearMonth.from(Instant.ofEpochMilli(milliseconds).atZone(BUSINESS_ZONE))
  374. .format(MONTH_FORMATTER);
  375. } catch (RuntimeException ignored) {
  376. return "";
  377. }
  378. }
  379. private static String normalizeYearMonth(String year, String month) {
  380. try {
  381. return YearMonth.of(Integer.parseInt(year), Integer.parseInt(month))
  382. .format(MONTH_FORMATTER);
  383. } catch (RuntimeException ignored) {
  384. return "";
  385. }
  386. }
  387. private static Long processEventTime(Map item) {
  388. if (item == null) {
  389. return null;
  390. }
  391. String[] fields = {
  392. "modifiedTimeGMT", "gmtModified", "finishTimeGMT", "finishTime",
  393. "modifiedTime", "createTimeGMT", "createTime"
  394. };
  395. for (String field : fields) {
  396. Long parsed = parseTimestamp(item.get(field));
  397. if (parsed != null) {
  398. return parsed;
  399. }
  400. }
  401. return null;
  402. }
  403. private static Long parseTimestamp(Object value) {
  404. if (value instanceof Number) {
  405. long number = ((Number) value).longValue();
  406. return String.valueOf(Math.abs(number)).length() <= 10 ? number * 1000L : number;
  407. }
  408. String raw = text(value);
  409. if (raw.matches("\\d{10}|\\d{13}")) {
  410. try {
  411. long number = Long.parseLong(raw);
  412. return raw.length() == 10 ? number * 1000L : number;
  413. } catch (NumberFormatException ignored) {
  414. return null;
  415. }
  416. }
  417. try {
  418. return Instant.parse(raw).toEpochMilli();
  419. } catch (DateTimeParseException ignored) {
  420. return null;
  421. }
  422. }
  423. private static String validateClosingStatus(String closingStatus) {
  424. String value = text(closingStatus);
  425. McException.assertParamException(
  426. !STATUS_CLOSED.equals(value) && !STATUS_REOPENED.equals(value),
  427. "封账状态必须为封账或启封");
  428. return value;
  429. }
  430. private String validateRequest(MonthClosingCheckRequest request) {
  431. McException.assertParamException(request == null, "请求不能为空");
  432. McException.assertAccessException(
  433. StringUtils.isAnyBlank(
  434. whConf.getYidaAppType(),
  435. whConf.getYidaSystemToken(),
  436. closingConf.getFormUuid(),
  437. closingConf.getMonthDateField(),
  438. closingConf.getStatusField()),
  439. "月度封账配置不完整");
  440. return validateMonthText(request.getMonthText());
  441. }
  442. private static boolean isApproved(Map item) {
  443. return "agree".equals(text(item.get("approvedResult")).toLowerCase(Locale.ROOT));
  444. }
  445. private static String buildMessage(
  446. String monthText,
  447. String requestedStatus,
  448. MonthClosingStatusResult closingStatus,
  449. int projectRunningCount,
  450. int otherRunningCount,
  451. int writeBackPendingCount,
  452. int summaryPendingCount) {
  453. if (closingStatus.getRunningClosingCount() > 0) {
  454. return monthText + " 已有封账或启封流程审批中,不可重复提交";
  455. }
  456. if (STATUS_REOPENED.equals(requestedStatus)) {
  457. return closingStatus.isClosed()
  458. ? monthText + " 当前已封账,可以提交启封"
  459. : monthText + " 当前未封账,不可重复启封";
  460. }
  461. if (closingStatus.isClosed()) {
  462. return monthText + " 已完成封账,不可重复提交";
  463. }
  464. if (projectRunningCount > 0 || otherRunningCount > 0) {
  465. return monthText + " 仍有工时审批单处理中:项目工时 "
  466. + projectRunningCount + " 张,其他工时 " + otherRunningCount + " 张";
  467. }
  468. if (writeBackPendingCount > 0) {
  469. return monthText + " 有 " + writeBackPendingCount + " 张审批单尚未成功回写工时汇总";
  470. }
  471. if (summaryPendingCount > 0) {
  472. return monthText + " 仍有 " + summaryPendingCount + " 条工时汇总处于审批中";
  473. }
  474. return monthText + " 工时审批已完成,可以提交封账";
  475. }
  476. private static String text(Object value) {
  477. return value == null ? "" : String.valueOf(value).trim();
  478. }
  479. }