TimeCardRepairService.java 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081
  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.workhours.MonthClosingCheckRequest;
  7. import com.malk.server.workhours.MonthClosingStatusResult;
  8. import com.malk.server.workhours.TimeCardApprovalStartRequest;
  9. import com.malk.server.workhours.TimeCardRepairContextRequest;
  10. import com.malk.server.workhours.TimeCardRepairSubmitRequest;
  11. import com.malk.server.workhours.TimeCardRepairSummaryUpsertRequest;
  12. import com.malk.server.workhours.WHConf;
  13. import com.malk.service.aliwork.YDClient;
  14. import lombok.extern.slf4j.Slf4j;
  15. import org.apache.commons.lang3.StringUtils;
  16. import org.springframework.beans.factory.annotation.Autowired;
  17. import org.springframework.stereotype.Service;
  18. import java.nio.charset.StandardCharsets;
  19. import java.security.MessageDigest;
  20. import java.security.NoSuchAlgorithmException;
  21. import java.time.LocalDate;
  22. import java.time.ZoneId;
  23. import java.time.format.DateTimeFormatter;
  24. import java.time.YearMonth;
  25. import java.util.*;
  26. import java.util.concurrent.CompletableFuture;
  27. import java.util.concurrent.CompletionException;
  28. import java.util.concurrent.ConcurrentHashMap;
  29. import java.util.concurrent.ExecutorService;
  30. import java.util.concurrent.Executors;
  31. import java.util.concurrent.atomic.AtomicInteger;
  32. /** 补填 TimeCard 专用服务;不复用正常汇总表写入目标。 */
  33. @Slf4j
  34. @Service
  35. public class TimeCardRepairService {
  36. private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
  37. private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyyMM");
  38. private static final String REPAIR_TYPE = "补填";
  39. private static final String DEFAULT_MANAGER_ID = "634735129";
  40. // 宜搭查询接口对复制表新增的 mokwy 字段返回 field is null;沿用复制表中可检索的原字段。
  41. private static final String R_SUBMITTER = "textField_mmbffvd9";
  42. private static final String R_DAY = "textField_mmbffvdb";
  43. private static final String R_MONTH = "textField_mmbffvda";
  44. private static final String R_SUBMITTER_EMP = "employeeField_mmacxew4";
  45. private static final String R_MONTH_DATE = "dateField_mmacxewf";
  46. private static final String R_DAY_DATE = "dateField_mmbffvd8";
  47. private static final String R_BIL_TABLE = "tableField_mmczo634";
  48. private static final String R_NON_TABLE = "tableField_mmczo63h";
  49. private static final String R_OTHER_TABLE = "tableField_mmeakgid";
  50. private static final String R_DAILY_DRAFT = "numberField_mmd1smel";
  51. private static final String R_DAILY_SUBMITTED = "numberField_mmd1smem";
  52. private static final String R_DAILY_APPROVED = "numberField_mmeakgi4";
  53. private static final String R_DAILY_CHANGE = "numberField_mmeakgi5";
  54. private static final long SUBMIT_GUARD_TTL_MILLIS = 30_000L;
  55. private static final long CONTEXT_CACHE_TTL_MILLIS = 60_000L;
  56. private static final ConcurrentHashMap<String, SubmitGuard> SUBMIT_GUARDS = new ConcurrentHashMap<>();
  57. private static final AtomicInteger CONTEXT_THREAD_SEQUENCE = new AtomicInteger();
  58. private static final ExecutorService CONTEXT_QUERY_EXECUTOR = Executors.newFixedThreadPool(8, runnable -> {
  59. Thread thread = new Thread(runnable,
  60. "timecard-repair-context-" + CONTEXT_THREAD_SEQUENCE.incrementAndGet());
  61. thread.setDaemon(true);
  62. return thread;
  63. });
  64. private final ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>> contextRequests =
  65. new ConcurrentHashMap<>();
  66. private final ConcurrentHashMap<String, ContextCacheEntry> contextCache = new ConcurrentHashMap<>();
  67. private static final List<String> R_TOTAL_FIELDS = Arrays.asList(
  68. "numberField_mmacxew9", "numberField_mmacxewa", "numberField_mmacxewg", "numberField_mmad6jbc",
  69. "numberField_mmad6jbx", "numberField_mmad6jbz", "numberField_mmad6jc1", "numberField_mmeakgia",
  70. "numberField_mmd1smeo", "numberField_mmd1smeq", "numberField_mmd1smes", "numberField_mmeakgic",
  71. R_DAILY_DRAFT, R_DAILY_SUBMITTED, R_DAILY_APPROVED, R_DAILY_CHANGE);
  72. @Autowired private YDClient ydClient;
  73. @Autowired private WHConf whConf;
  74. @Autowired private MonthClosingService monthClosingService;
  75. @Autowired private ReSubmitApprovalService reSubmitApprovalService;
  76. @Autowired private TimeCardPersonnelPolicy personnelPolicy;
  77. public Map<String, Object> context(TimeCardRepairContextRequest request) {
  78. validateMonthRequest(request == null ? null : request.getSubmitterUserId(),
  79. request == null ? null : request.getSourceMonthText());
  80. String userId = request.getSubmitterUserId();
  81. String monthText = request.getSourceMonthText();
  82. String staffType = text(request.getStaffType());
  83. McException.assertAccessException(!staffType.isEmpty()
  84. && !"内部".equals(staffType) && !"外部".equals(staffType), "人员属性无效");
  85. String requestKey = userId + "|" + monthText + "|" + staffType;
  86. Map<String, Object> cachedResult = getCachedContext(requestKey);
  87. if (cachedResult != null) {
  88. CompletableFuture<Map<String, Object>> completedRequest = contextRequests.get(requestKey);
  89. if (completedRequest != null && completedRequest.isDone()) {
  90. contextRequests.remove(requestKey, completedRequest);
  91. }
  92. return cachedResult;
  93. }
  94. CompletableFuture<Map<String, Object>> contextFuture = contextRequests.computeIfAbsent(
  95. requestKey, ignored -> CompletableFuture.supplyAsync(
  96. () -> personnelPolicy.getStaffType(userId), CONTEXT_QUERY_EXECUTOR)
  97. .thenCompose(actualType -> {
  98. McException.assertAccessException(!staffType.isEmpty() && !staffType.equals(actualType),
  99. "人员属性已变化,请刷新后重试");
  100. return loadContextAsync(requestKey, userId, monthText, actualType);
  101. }));
  102. if (!contextFuture.isDone()) return pendingContext();
  103. contextRequests.remove(requestKey, contextFuture);
  104. return completedContext(contextFuture, userId, monthText);
  105. }
  106. private CompletableFuture<Map<String, Object>> loadContextAsync(String requestKey,
  107. String userId,
  108. String monthText,
  109. String staffType) {
  110. // prd 外部补填独立于已冲销但保留的原汇总;不发起原表查询。
  111. CompletableFuture<List<Map>> originalFuture = "外部".equals(staffType)
  112. ? CompletableFuture.completedFuture(Collections.emptyList())
  113. : CompletableFuture.supplyAsync(
  114. () -> query(whConf.getFormUuidWorkHoursSummary(),
  115. mapOf(R_SUBMITTER, userId, R_MONTH, monthText)), CONTEXT_QUERY_EXECUTOR);
  116. CompletableFuture<List<Map>> repairFuture = CompletableFuture.supplyAsync(
  117. () -> queryRepairSummaries(mapOf(R_SUBMITTER, userId, R_MONTH, monthText)),
  118. CONTEXT_QUERY_EXECUTOR);
  119. return originalFuture.thenCombine(repairFuture, (originalRecords, repairRecords) -> {
  120. Map<String, Object> result = new LinkedHashMap<>();
  121. result.put("ready", true);
  122. result.put("staffType", staffType);
  123. result.put("summaryReadMode", "外部".equals(staffType) ? "repair-only" : "original-and-repair");
  124. result.put("originalRecords", compactContextRecords(originalRecords));
  125. result.put("repairRecords", compactContextRecords(repairRecords));
  126. // 兼容页面状态合并接口的 records 约定;原始双表结果仍通过 repairRecords 返回。
  127. result.put("records", compactContextRecords(repairRecords));
  128. contextCache.put(requestKey,
  129. new ContextCacheEntry(result, System.currentTimeMillis() + CONTEXT_CACHE_TTL_MILLIS));
  130. return result;
  131. });
  132. }
  133. private Map<String, Object> completedContext(CompletableFuture<Map<String, Object>> contextFuture,
  134. String userId,
  135. String monthText) {
  136. try {
  137. return contextFuture.join();
  138. } catch (CompletionException error) {
  139. Throwable cause = error.getCause();
  140. if (cause instanceof RuntimeException) throw (RuntimeException) cause;
  141. log.error("补填上下文查询失败: userId={}, month={}", userId, monthText, cause);
  142. throw new McException("REPAIR_CONTEXT_QUERY_FAILED", "补填工时上下文查询失败");
  143. }
  144. }
  145. private static Map<String, Object> pendingContext() {
  146. Map<String, Object> result = new LinkedHashMap<>();
  147. result.put("ready", false);
  148. result.put("retryAfterMillis", 2_000L);
  149. return result;
  150. }
  151. private Map<String, Object> getCachedContext(String requestKey) {
  152. long now = System.currentTimeMillis();
  153. ContextCacheEntry cached = contextCache.get(requestKey);
  154. if (cached == null) return null;
  155. if (cached.expiresAt > now) return cached.result;
  156. contextCache.remove(requestKey, cached);
  157. return null;
  158. }
  159. private void invalidateContext(String userId, String monthText) {
  160. String prefix = userId + "|" + monthText + "|";
  161. contextCache.keySet().removeIf(key -> key.startsWith(prefix));
  162. }
  163. private static List<Map<String, Object>> compactContextRecords(List<Map> records) {
  164. List<Map<String, Object>> compact = new ArrayList<>();
  165. for (Map record : records) {
  166. Map<String, Object> item = new LinkedHashMap<>();
  167. item.put("formInstId", instanceId(record));
  168. item.put("formData", formData(record));
  169. compact.add(item);
  170. }
  171. return compact;
  172. }
  173. public boolean upsert(TimeCardRepairSummaryUpsertRequest request) {
  174. McException.assertAccessException(request == null, "请求不能为空");
  175. McException.assertAccessException(StringUtils.isBlank(request.getDayText())
  176. || !request.getDayText().matches("\\d{8}"), "日期必须为yyyyMMdd");
  177. try {
  178. LocalDate.parse(request.getDayText(), DateTimeFormatter.ofPattern("yyyyMMdd"));
  179. } catch (RuntimeException ex) {
  180. McException.assertAccessException(true, "日期必须为有效的yyyyMMdd");
  181. }
  182. String monthText = request.getDayText().substring(0, 6);
  183. validateMonthRequest(request.getSubmitterUserId(), monthText);
  184. McException.assertAccessException(!monthClosingService.isLocked(monthText), "补填只能保存已发起封账或已完成封账的月份");
  185. assertCurrentMonthOpen("保存");
  186. McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidWorkHoursSummary()), "原工时汇总表未配置");
  187. McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidRepairWorkHoursSummary()), "补填汇总表未配置");
  188. McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummaryFillTypeField()), "补填汇总类型字段未配置");
  189. McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySyncStatusField()), "补填汇总状态字段未配置");
  190. McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空");
  191. assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText(),
  192. "外部".equals(personnelPolicy.getStaffType(request.getSubmitterUserId())));
  193. List<Map> existing = queryRepairSummaries(
  194. mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText()));
  195. McException.assertAccessException(existing.size() > 1,
  196. "补填汇总记录重复,已停止保存 userId=" + request.getSubmitterUserId() + ", day=" + request.getDayText());
  197. McException.assertAccessException(existing.stream().anyMatch(record ->
  198. TimeCardSummaryService.isRepairRecordLocked(record, whConf.getRepairSummarySyncStatusField())),
  199. "该日期补填工时已提交或已审批,不能再填写 userId=" + request.getSubmitterUserId()
  200. + ", day=" + request.getDayText());
  201. Map<String, Object> data = new LinkedHashMap<>(request.getFormData());
  202. // fixme 草稿仅接收明细暂存工时;汇总由服务端重算,不能信任页面传入的审批合计。
  203. for (String table : Arrays.asList(R_BIL_TABLE, R_NON_TABLE, R_OTHER_TABLE)) {
  204. for (Map child : list(data.get(table))) {
  205. McException.assertAccessException(
  206. TimeCardSummaryService.hasSubmittedOrApprovedHours(mapOf(table,
  207. Collections.singletonList(child))),
  208. "补填草稿不能包含已提交、已审批或变更工时");
  209. }
  210. }
  211. TimeCardRepairService.applySummaryTotals(data, data);
  212. data.put(R_SUBMITTER, request.getSubmitterUserId());
  213. data.put(R_DAY, request.getDayText());
  214. data.put(R_MONTH, request.getDayText().substring(0, 6));
  215. data.put(whConf.getRepairSummaryFillTypeField(), REPAIR_TYPE);
  216. data.put(whConf.getRepairSummarySyncStatusField(), "暂存");
  217. // prd 补填与变更草稿均记录保存当天日期;实际提交时更新日期并生成批次。
  218. McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySubmitDateField()),
  219. "补填/变更提交日期字段未配置");
  220. data.put(whConf.getRepairSummarySubmitDateField(),
  221. LocalDate.now(ZONE).atStartOfDay(ZONE).toInstant().toEpochMilli());
  222. if (StringUtils.isNotBlank(whConf.getRepairSummarySourceMonthField())) {
  223. data.put(whConf.getRepairSummarySourceMonthField(), monthText);
  224. }
  225. saveRepairDraft(existing, data, request.getSubmitterUserId(), request.getDayText());
  226. invalidateContext(request.getSubmitterUserId(), monthText);
  227. verifyRepairDraftDate(request.getSubmitterUserId(), request.getDayText(), data);
  228. return true;
  229. }
  230. /** 保存和提交均禁止当前月进入封账后的补填写入。 */
  231. private void assertCurrentMonthOpen(String action) {
  232. String monthText = LocalDate.now(ZONE).format(MONTH);
  233. MonthClosingStatusResult status = monthClosingService.status(
  234. MonthClosingCheckRequest.builder().monthText(monthText).build());
  235. McException.assertAccessException(status == null || status.isClosed()
  236. || status.getRunningClosingCount() > 0,
  237. "当前月份已封账或封账流程审批中,不可" + action + "补填");
  238. }
  239. /** 以实际实例回读确认草稿的提交日期已落库。 */
  240. private void verifyRepairDraftDate(String userId, String dayText, Map<String, Object> expected) {
  241. List<Map> found = queryRepairSummaries(mapOf(R_SUBMITTER, userId, R_DAY, dayText));
  242. McException.assertAccessException(found.size() != 1, "补填草稿未落库或记录重复,请勿提交");
  243. Map actual = detail(instanceId(found.get(0)));
  244. String field = whConf.getRepairSummarySubmitDateField();
  245. McException.assertAccessException(num(expected.get(field)) != num(actual.get(field)),
  246. "补填/变更提交日期回读不一致,请勿提交");
  247. }
  248. public Map<String, Object> submit(TimeCardRepairSubmitRequest request) {
  249. McException.assertAccessException(request == null, "请求不能为空");
  250. validateMonthRequest(request.getSubmitterUserId(), request.getSourceMonthText());
  251. McException.assertAccessException(!monthClosingService.isLocked(request.getSourceMonthText()), "补填只能提交已发起封账或已完成封账的月份");
  252. LocalDate submitDay = LocalDate.now(ZONE);
  253. // prd 补填写入当前月变更工时;当前月进入封账流程或已封账后禁止新增提交。
  254. assertCurrentMonthOpen("提交");
  255. Map<String, Object> projectGroups = request.getProjectGroups() == null ? Collections.emptyMap() : request.getProjectGroups();
  256. Map other = request.getOtherGroup() == null ? Collections.emptyMap() : request.getOtherGroup();
  257. String guardKey = buildSubmitGuardKey(request, projectGroups, other);
  258. SubmitGuard guard = acquireSubmitGuard(guardKey);
  259. Map<String, Object> cachedResult = guard.cachedResult();
  260. if (cachedResult != null) return cachedResult;
  261. try {
  262. String batch = UUID.randomUUID().toString();
  263. long submitDate = submitDay.atStartOfDay(ZONE).toInstant().toEpochMilli();
  264. int rowCount = validateSubmitRows(request, projectGroups, other);
  265. McException.assertAccessException(rowCount == 0, "没有需要提交的补填工时");
  266. invalidateContext(request.getSubmitterUserId(), request.getSourceMonthText());
  267. for (Object rawGroup : projectGroups.values()) {
  268. if (!(rawGroup instanceof Map)) continue;
  269. Map group = (Map) rawGroup;
  270. String managerId = normalizeManagerId(text(group.get("managerId")));
  271. for (String category : new String[]{"billableRows", "nonBillableRows"}) {
  272. for (Object rawRow : list(group.get(category))) {
  273. if (rawRow instanceof Map) {
  274. upsertRepairRow(request, (Map) rawRow, category, managerId, batch, submitDate);
  275. }
  276. }
  277. }
  278. }
  279. String otherManagerId = normalizeManagerId(text(other.get("managerId")));
  280. for (Object rawRow : list(other.get("otherRows"))) {
  281. if (rawRow instanceof Map) {
  282. upsertRepairRow(request, (Map) rawRow, "otherRows", otherManagerId, batch, submitDate);
  283. }
  284. }
  285. startApprovals(request, projectGroups, other, batch, submitDate);
  286. Map<String, Object> result = mapOf("batch", batch, "submitDate", submitDate, "status", "审批中");
  287. guard.complete(result);
  288. return result;
  289. } catch (RuntimeException ex) {
  290. SUBMIT_GUARDS.remove(guardKey, guard);
  291. throw ex;
  292. }
  293. }
  294. private String buildSubmitGuardKey(TimeCardRepairSubmitRequest request,
  295. Map<String, Object> projectGroups, Map other) {
  296. String payload = request.getSubmitterUserId() + "|" + request.getSourceMonthText()
  297. + "|" + JSON.toJSONString(projectGroups) + "|" + JSON.toJSONString(other);
  298. try {
  299. byte[] digest = MessageDigest.getInstance("SHA-256")
  300. .digest(payload.getBytes(StandardCharsets.UTF_8));
  301. StringBuilder hex = new StringBuilder(digest.length * 2);
  302. for (byte value : digest) hex.append(String.format("%02x", value));
  303. return hex.toString();
  304. } catch (NoSuchAlgorithmException ex) {
  305. throw new IllegalStateException("提交幂等键计算失败", ex);
  306. }
  307. }
  308. private SubmitGuard acquireSubmitGuard(String key) {
  309. long now = System.currentTimeMillis();
  310. synchronized (SUBMIT_GUARDS) {
  311. SUBMIT_GUARDS.entrySet().removeIf(entry -> entry.getValue().expiresAt <= now);
  312. SubmitGuard existing = SUBMIT_GUARDS.get(key);
  313. if (existing != null && existing.expiresAt > now) {
  314. if (existing.result != null) return existing;
  315. McException.assertAccessException(true, "补填提交正在处理中,请勿重复提交");
  316. }
  317. SubmitGuard guard = new SubmitGuard(now + SUBMIT_GUARD_TTL_MILLIS);
  318. SUBMIT_GUARDS.put(key, guard);
  319. return guard;
  320. }
  321. }
  322. private static final class SubmitGuard {
  323. private final long expiresAt;
  324. private Map<String, Object> result;
  325. private SubmitGuard(long expiresAt) {
  326. this.expiresAt = expiresAt;
  327. }
  328. private synchronized void complete(Map<String, Object> value) {
  329. this.result = new LinkedHashMap<>(value);
  330. }
  331. private synchronized Map<String, Object> cachedResult() {
  332. return result == null ? null : new LinkedHashMap<>(result);
  333. }
  334. }
  335. private static final class ContextCacheEntry {
  336. private final Map<String, Object> result;
  337. private final long expiresAt;
  338. private ContextCacheEntry(Map<String, Object> result, long expiresAt) {
  339. this.result = result;
  340. this.expiresAt = expiresAt;
  341. }
  342. }
  343. /**
  344. * 补填审批补发起:只扫描近期修改的补填汇总,按员工、月份和补填提交日期补齐缺失明细。
  345. * 正常 TimeCard 补发逻辑仍由 ReSubmitApprovalService 独立处理。
  346. *
  347. * @param lookbackDays 最近修改自然日数量
  348. * @return 本次补发的审批分组数量及补试后失败批次数
  349. */
  350. public RepairResubmitResult resubmitRecentlyModifiedPending(int lookbackDays) {
  351. String[] window = modifiedDateWindow(lookbackDays);
  352. List<Map> records = queryModified(
  353. whConf.getFormUuidRepairWorkHoursSummary(), window[0], window[1]);
  354. Map<String, RepairBatch> batches = buildPendingBatches(records);
  355. Map<String, Set<String>> coverageByMonth = new HashMap<>();
  356. int started = 0;
  357. int failed = 0;
  358. List<RepairBatch> retryQueue = new ArrayList<>();
  359. for (RepairBatch batch : batches.values()) {
  360. try {
  361. Set<String> covered = coverageByMonth.computeIfAbsent(
  362. batch.monthText, this::loadRepairApprovalDetailCoverage);
  363. started += submitMissingRepairApprovals(batch, covered);
  364. } catch (Exception ex) {
  365. retryQueue.add(batch);
  366. log.warn("[补填审批补发] 首次处理失败,待本轮扫描结束补试 batch={}", batch.batchId, ex);
  367. }
  368. }
  369. for (RepairBatch batch : retryQueue) {
  370. try {
  371. started += submitMissingRepairApprovals(
  372. batch, loadRepairApprovalDetailCoverage(batch.monthText));
  373. } catch (Exception ex) {
  374. failed++;
  375. log.error("[补填审批补发] 本轮补试失败 batch={}", batch.batchId, ex);
  376. }
  377. }
  378. log.info("[补填审批补发] from={} to={} records={} batches={} started={} failed={}",
  379. window[0], window[1], records.size(), batches.size(), started, failed);
  380. return new RepairResubmitResult(started, failed);
  381. }
  382. public static final class RepairResubmitResult {
  383. private final int started;
  384. private final int failed;
  385. public RepairResubmitResult(int started, int failed) {
  386. this.started = started;
  387. this.failed = failed;
  388. }
  389. public int getStarted() {
  390. return started;
  391. }
  392. public int getFailed() {
  393. return failed;
  394. }
  395. }
  396. private int submitMissingRepairApprovals(RepairBatch batch, Set<String> covered) {
  397. removeCoveredRows(batch, covered);
  398. Map<String, Object> missingProjects = batch.projectGroups;
  399. Map<String, Object> missingOther = batch.otherGroup;
  400. if (missingProjects.isEmpty() && missingOther.isEmpty()) return 0;
  401. TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
  402. request.setSourceMonthText(batch.monthText);
  403. request.setSubmitterUserId(batch.userId);
  404. request.setSubmitterName(batch.submitterName);
  405. request.setProjectGroups(missingProjects);
  406. request.setOtherGroup(missingOther);
  407. startApprovals(request, missingProjects, missingOther, batch.batchId, batch.submitDate);
  408. return missingProjects.size() + (missingOther.isEmpty() ? 0 : 1);
  409. }
  410. private void upsertRepairRow(TimeCardRepairSubmitRequest request, Map row, String category,
  411. String managerId, String batch, long submitDate) {
  412. String day = text(row.get("dateStr"));
  413. if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
  414. String key = day;
  415. List<Map> found = queryRepairSummaries(
  416. mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, key));
  417. McException.assertAccessException(found.size() != 1, "补填汇总完整记录不存在或重复,请先保存后提交 userId="
  418. + request.getSubmitterUserId() + ", day=" + key);
  419. String instanceId = text(found.get(0).get("formInstId"));
  420. if (StringUtils.isBlank(instanceId)) instanceId = text(found.get(0).get("formInstanceId"));
  421. McException.assertAccessException(StringUtils.isBlank(instanceId), "补填汇总实例ID为空");
  422. Map existing = detail(instanceId);
  423. Map<String, Object> data = new LinkedHashMap<>();
  424. data.put(R_SUBMITTER, request.getSubmitterUserId());
  425. data.put(R_DAY, day);
  426. data.put(R_MONTH, request.getSourceMonthText());
  427. data.put(whConf.getRepairSummarySyncStatusField(), "审批中");
  428. data.put(whConf.getRepairSummaryFillTypeField(), "补填");
  429. data.put(whConf.getRepairSummarySubmitDateField(), submitDate);
  430. data.put(whConf.getRepairSummaryBatchField(), batch);
  431. data.put(whConf.getRepairSummarySourceMonthField(), request.getSourceMonthText());
  432. data.put(R_SUBMITTER_EMP, Collections.singletonList(request.getSubmitterUserId()));
  433. data.put(R_MONTH_DATE, LocalDate.parse(request.getSourceMonthText() + "01", DateTimeFormatter.ofPattern("yyyyMMdd"))
  434. .atStartOfDay(ZONE).toInstant().toEpochMilli());
  435. data.put(R_DAY_DATE, LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd"))
  436. .atStartOfDay(ZONE).toInstant().toEpochMilli());
  437. Map<String, Object> child = new LinkedHashMap<>();
  438. double hours = num(row.get("hours"));
  439. if ("billableRows".equals(category)) {
  440. child.put("numberField_mmczo636", hours);
  441. child.put("numberField_mmczo637", 0d);
  442. } else if ("nonBillableRows".equals(category)) {
  443. child.put("numberField_mmczo63c", hours);
  444. child.put("numberField_mmczo63d", 0d);
  445. } else {
  446. child.put("numberField_mmeakgij", hours);
  447. child.put("numberField_mmeakgii", 0d);
  448. }
  449. String tableField;
  450. String keyField;
  451. if ("billableRows".equals(category)) {
  452. child.put("textField_mmacxew3", text(row.get("projectCode")));
  453. child.put("textField_mmad6jcj", text(row.get("projectName")));
  454. tableField = R_BIL_TABLE; keyField = "textField_mmacxew3";
  455. } else if ("nonBillableRows".equals(category)) {
  456. child.put("textField_mmczo639", text(row.get("projectCode")));
  457. child.put("textField_mmczo63g", text(row.get("projectName")));
  458. tableField = R_NON_TABLE; keyField = "textField_mmczo639";
  459. } else {
  460. child.put("selectField_mmeakgie", text(row.get("activityName")));
  461. tableField = R_OTHER_TABLE; keyField = "selectField_mmeakgie";
  462. }
  463. if (StringUtils.isNotBlank(managerId)) {
  464. if ("billableRows".equals(category)) {
  465. child.put("employeeField_mkoxpswf", Collections.singletonList(managerId));
  466. } else if ("nonBillableRows".equals(category)) {
  467. child.put("employeeField_mmczo63a", Collections.singletonList(managerId));
  468. } else {
  469. data.put("employeeField_mh8xhqc3", Collections.singletonList(managerId));
  470. }
  471. }
  472. List<Map> current = copyRows(existing.get(tableField));
  473. String submittedField = "billableRows".equals(category)
  474. ? "numberField_mmczo636"
  475. : ("nonBillableRows".equals(category) ? "numberField_mmczo63c" : "numberField_mmeakgij");
  476. // prd 与原 TimeCard 一致:提交值覆盖 submitted,draft 保留供拒绝后恢复编辑。
  477. mergeSubmittedRow(current, child, keyField, submittedField, hours);
  478. data.put(tableField, current);
  479. Map snapshot = new LinkedHashMap(existing);
  480. snapshot.put(tableField, current);
  481. applySummaryTotals(data, snapshot);
  482. final String targetInstanceId = instanceId;
  483. operateAndVerify(
  484. () -> ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
  485. .systemToken(whConf.getYidaSystemToken()).formInstanceId(targetInstanceId)
  486. .updateFormDataJson(JSON.toJSONString(data)).ignoreEmpty(false).useLatestVersion(true).build(),
  487. YDConf.FORM_OPERATION.update),
  488. () -> verifySubmittedRepairRow(targetInstanceId, tableField, keyField,
  489. text(child.get(keyField)), submittedField, hours, data));
  490. }
  491. private void verifySubmittedRepairRow(String instanceId, String tableField, String keyField,
  492. String rowKey, String submittedField, double hours,
  493. Map<String, Object> expected) {
  494. Map actual = detail(instanceId);
  495. Map actualRow = null;
  496. for (Map row : list(actual.get(tableField))) {
  497. if (rowKey.equals(text(row.get(keyField)))) {
  498. actualRow = row;
  499. break;
  500. }
  501. }
  502. McException.assertAccessException(actualRow == null
  503. || !sameHours(num(actualRow.get(submittedField)), hours),
  504. "补填汇总提交子表回读不一致 instanceId=" + instanceId + ", key=" + rowKey);
  505. for (String field : R_TOTAL_FIELDS) {
  506. McException.assertAccessException(!sameHours(num(actual.get(field)), num(expected.get(field))),
  507. "补填汇总主表合计回读不一致 instanceId=" + instanceId + ", field=" + field);
  508. }
  509. String statusField = whConf.getRepairSummarySyncStatusField();
  510. McException.assertAccessException(!text(expected.get(statusField)).equals(text(actual.get(statusField))),
  511. "补填汇总审批状态回读不一致 instanceId=" + instanceId);
  512. }
  513. private void operateAndVerify(Runnable operation, Runnable verification) {
  514. try {
  515. operation.run();
  516. } catch (RuntimeException operationException) {
  517. try {
  518. verification.run();
  519. return;
  520. } catch (RuntimeException verificationException) {
  521. operationException.addSuppressed(verificationException);
  522. throw operationException;
  523. }
  524. }
  525. verification.run();
  526. }
  527. private static boolean sameHours(double actual, double expected) {
  528. return Math.abs(actual - expected) <= 1e-6;
  529. }
  530. private Map detail(String id) {
  531. return new WorkHoursFormDataReader(ydClient, whConf).form(id, whConf.getFormUuidRepairWorkHoursSummary());
  532. }
  533. private void startApprovals(TimeCardRepairSubmitRequest request, Map<String, Object> groups,
  534. Map<String, Object> other, String batch, long submitDate) {
  535. for (Object raw : groups.values()) {
  536. if (!(raw instanceof Map)) continue;
  537. Map group = (Map) raw;
  538. Map<String, Object> fd = new LinkedHashMap<>();
  539. fd.put("dateField_mmacxewf", parseMonth(request.getSourceMonthText()));
  540. fd.put("employeeField_moi9pvt1", Collections.singletonList(request.getSubmitterUserId()));
  541. fd.put(whConf.getApprovalFillTypeField(), "补填");
  542. fd.put(whConf.getApprovalRepairSubmitDateField(), submitDate);
  543. fd.put(whConf.getApprovalRepairBatchField(), batch);
  544. fd.put(whConf.getApprovalRepairSourceMonthField(), request.getSourceMonthText());
  545. fd.put("textField_mmd2lv0y", request.getSourceMonthText());
  546. fd.put("numberField_mmd2z155", sumRows(group.get("billableRows")));
  547. fd.put("numberField_mmd2z156", sumRows(group.get("nonBillableRows")));
  548. fd.put("numberField_mmd5b5gl", sumRows(group.get("billableRows")) + sumRows(group.get("nonBillableRows")));
  549. fd.put("employeeField_mkoxpswf", Collections.singletonList(
  550. normalizeManagerId(text(group.get("managerId")))));
  551. fd.put("tableField_mmae8t99", approvalRows(group.get("billableRows"), true, request));
  552. fd.put("tableField_mmd1wu9h", approvalRows(group.get("nonBillableRows"), false, request));
  553. start(request.getSubmitterUserId(), whConf.getFormUuidApproval(), whConf.getApprovalProcessCode(), fd);
  554. }
  555. if (other != null && !list(other.get("otherRows")).isEmpty()) {
  556. Map<String, Object> fd = new LinkedHashMap<>();
  557. fd.put("dateField_mmacxewf", parseMonth(request.getSourceMonthText()));
  558. fd.put("employeeField_moi9pvt1", Collections.singletonList(request.getSubmitterUserId()));
  559. fd.put(whConf.getApprovalFillTypeField(), "补填");
  560. fd.put(whConf.getOtherApprovalRepairSubmitDateField(), submitDate);
  561. fd.put(whConf.getOtherApprovalRepairBatchField(), batch);
  562. fd.put(whConf.getOtherApprovalRepairSourceMonthField(), request.getSourceMonthText());
  563. fd.put("textField_mmd2lv0y", request.getSourceMonthText());
  564. fd.put("numberField_mmd5b5gl", sumRows(other.get("otherRows")));
  565. fd.put("employeeField_mh8xhqc3", Collections.singletonList(
  566. normalizeManagerId(text(other.get("managerId")))));
  567. fd.put("tableField_mmd1wu9h", approvalRows(other.get("otherRows"), false, request));
  568. start(request.getSubmitterUserId(), whConf.getFormUuidOtherApproval(), whConf.getOtherApprovalProcessCode(), fd);
  569. }
  570. }
  571. private void start(String userId, String formUuid, String processCode, Map<String, Object> data) {
  572. reSubmitApprovalService.startApproval(new TimeCardApprovalStartRequest() {{
  573. setFormUuid(formUuid); setProcessCode(processCode); setFormDataJson(JSON.toJSONString(data));
  574. }});
  575. }
  576. private List<Map<String, Object>> approvalRows(Object raw, boolean billable, TimeCardRepairSubmitRequest request) {
  577. List<Map<String, Object>> result = new ArrayList<>();
  578. for (Object item : list(raw)) {
  579. if (!(item instanceof Map)) continue;
  580. Map row = (Map) item;
  581. String day = text(row.get("dateStr"));
  582. Map<String, Object> out = new LinkedHashMap<>();
  583. out.put(billable ? "dateField_mmbffvd8" : "dateField_mmd2z150", parseDay(day));
  584. out.put(billable ? "textField_mmd8g654" : "textField_mmd8g655", day);
  585. out.put(billable ? "textField_mmd1ozk4" : "textField_mmd1wu9f", request.getSubmitterUserId());
  586. out.put(billable ? "employeeField_mmacxew4" : "employeeField_mmd1wu9b",
  587. Collections.singletonList(request.getSubmitterUserId()));
  588. out.put(billable ? "textField_mmdbi2n2" : "textField_mmdbi2n0", request.getSourceMonthText());
  589. out.put(billable ? "numberField_mmacxew9" : "numberField_mmd1wu9e", num(row.get("hours")));
  590. out.put(billable ? "textField_mmacxew3" : "textField_mmd1wu9d", text(row.get("projectCode")));
  591. if (billable) out.put("textField_mkov5ozd", text(row.get("projectName")));
  592. else if (!text(row.get("activityName")).isEmpty()) {
  593. out.put("selectField_mmeakgie", text(row.get("activityName")));
  594. out.remove("textField_mmd1wu9d");
  595. } else out.put("textField_mmd1wu9c", text(row.get("projectName")));
  596. // 其他工时审批沿用 radioField_mpwhk294;只有 Non Billable 使用 295。
  597. String opinionField = billable || out.containsKey("selectField_mmeakgie")
  598. ? "radioField_mpwhk294" : "radioField_mpwhk295";
  599. out.put(opinionField, "同意");
  600. result.add(out);
  601. }
  602. return result;
  603. }
  604. private long parseDay(String day) { return LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd")).atStartOfDay(ZONE).toInstant().toEpochMilli(); }
  605. private List<Map> query(String formUuid, Map<String, Object> search) {
  606. if (StringUtils.isBlank(formUuid)) return new ArrayList<>();
  607. com.malk.server.dingtalk.DDR_New result = ydClient.queryData(YDParam.builder()
  608. .appType(whConf.getYidaAppType()).systemToken(whConf.getYidaSystemToken())
  609. .formUuid(formUuid).searchFieldJson(JSON.toJSONString(search))
  610. .currentPage(1).pageSize(100).build(), YDConf.FORM_QUERY.retrieve_search_form);
  611. McException.assertAccessException(result == null || !(result.getData() instanceof List),
  612. "查询现有工时失败,已停止保存");
  613. List<Map> records = (List<Map>) result.getData();
  614. McException.assertAccessException(records.size() >= 100, "现有工时记录过多,已停止保存");
  615. return records;
  616. }
  617. private void validateMonthRequest(String userId, String month) {
  618. McException.assertAccessException(StringUtils.isAnyBlank(userId, month)
  619. || !month.matches("\\d{6}"), "员工和月份不能为空且月份必须为yyyyMM");
  620. try {
  621. YearMonth.parse(month, MONTH);
  622. } catch (RuntimeException ex) {
  623. McException.assertAccessException(true, "月份必须为有效的yyyyMM");
  624. }
  625. }
  626. private void validateRepairRow(TimeCardRepairSubmitRequest request, Map row, String category,
  627. boolean external) {
  628. String day = text(row.get("dateStr"));
  629. if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
  630. McException.assertAccessException(!day.matches("\\d{8}")
  631. || !day.startsWith(request.getSourceMonthText()), "补填日期必须属于来源月份");
  632. try {
  633. LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd"));
  634. } catch (RuntimeException ex) {
  635. McException.assertAccessException(true, "补填日期必须为有效的yyyyMMdd");
  636. }
  637. double hours = num(row.get("hours"));
  638. McException.assertAccessException(hours <= 0d || hours > 24d, "补填工时必须大于0且不超过24小时");
  639. if (!"otherRows".equals(category)) {
  640. McException.assertAccessException(StringUtils.isBlank(text(row.get("projectCode"))),
  641. ("billableRows".equals(category) ? "Billable" : "Non Billable") + "工时必须填写项目编号");
  642. }
  643. assertSourceDateAvailable(request.getSubmitterUserId(), day, external);
  644. assertRepairRowMutable(request.getSubmitterUserId(), day, row, category);
  645. }
  646. private int validateSubmitRows(TimeCardRepairSubmitRequest request,
  647. Map<String, Object> projectGroups,
  648. Map<String, Object> otherGroup) {
  649. int count = 0;
  650. String staffType = null;
  651. for (Object rawGroup : projectGroups.values()) {
  652. if (!(rawGroup instanceof Map)) continue;
  653. Map group = (Map) rawGroup;
  654. for (String category : new String[]{"billableRows", "nonBillableRows"}) {
  655. for (Object rawRow : list(group.get(category))) {
  656. if (!(rawRow instanceof Map)) continue;
  657. if (staffType == null) staffType = personnelPolicy.getStaffType(request.getSubmitterUserId());
  658. validateRepairRow(request, (Map) rawRow, category, "外部".equals(staffType));
  659. count++;
  660. }
  661. }
  662. }
  663. for (Object rawRow : list(otherGroup.get("otherRows"))) {
  664. if (!(rawRow instanceof Map)) continue;
  665. if (staffType == null) staffType = personnelPolicy.getStaffType(request.getSubmitterUserId());
  666. validateRepairRow(request, (Map) rawRow, "otherRows", "外部".equals(staffType));
  667. count++;
  668. }
  669. return count;
  670. }
  671. private void assertSourceDateAvailable(String userId, String day, boolean external) {
  672. // prd 外部来源日由补填记录保护;内部继续核实原表锁定。
  673. if (external) return;
  674. List<Map> original = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day));
  675. boolean hasApprovedOrChangedHours = original.stream()
  676. .anyMatch(TimeCardSummaryService::isFormalRecordLocked);
  677. McException.assertAccessException(hasApprovedOrChangedHours,
  678. "来源日期已有已提交、已审批或变更工时,不能补填 userId=" + userId + ", day=" + day);
  679. }
  680. /** 原汇总当天任一已审批或变更工时均禁止补填;暂存草稿不占用补填资格。 */
  681. static boolean hasApprovedOrChangedHours(Map record) {
  682. return TimeCardSummaryService.hasApprovedOrChangedHours(record);
  683. }
  684. private void assertRepairRowMutable(String userId, String day, Map row, String category) {
  685. List<Map> existing = queryRepairSummaries(mapOf(R_SUBMITTER, userId, R_DAY, day));
  686. McException.assertAccessException(existing.size() > 1, "补填汇总记录重复 userId=" + userId + ", day=" + day);
  687. if (existing.size() != 1) return;
  688. String id = text(existing.get(0).get("formInstId"));
  689. if (StringUtils.isBlank(id)) id = text(existing.get(0).get("formInstanceId"));
  690. Map fd = detail(id);
  691. String projectCode = text(row.get("projectCode"));
  692. String activityName = text(row.get("activityName"));
  693. boolean other = "otherRows".equals(category);
  694. String table = other ? R_OTHER_TABLE
  695. : ("billableRows".equals(category) ? R_BIL_TABLE : R_NON_TABLE);
  696. String keyField = other ? "selectField_mmeakgie"
  697. : (R_BIL_TABLE.equals(table) ? "textField_mmacxew3" : "textField_mmczo639");
  698. String submittedField = other ? "numberField_mmeakgij"
  699. : (R_BIL_TABLE.equals(table) ? "numberField_mmczo636" : "numberField_mmczo63c");
  700. String approvedField = other ? "numberField_mmeakgii"
  701. : (R_BIL_TABLE.equals(table) ? "numberField_mmczo637" : "numberField_mmczo63d");
  702. String key = other ? activityName : projectCode;
  703. for (Map child : list(fd.get(table))) {
  704. if (key.equals(text(child.get(keyField)))) {
  705. McException.assertAccessException(num(child.get(submittedField)) > 1e-6
  706. || num(child.get(approvedField)) > 1e-6,
  707. "该补填明细已提交或审批完成,不能再次补填 day=" + day + ", key=" + key);
  708. }
  709. }
  710. }
  711. private long parseMonth(String month) {
  712. return LocalDate.parse(month + "01", DateTimeFormatter.ofPattern("yyyyMMdd"))
  713. .atStartOfDay(ZONE).toInstant().toEpochMilli();
  714. }
  715. private Map<String, RepairBatch> buildPendingBatches(List<Map> records) {
  716. Map<String, RepairBatch> batches = new LinkedHashMap<>();
  717. for (Map item : records) {
  718. Map fd = new WorkHoursFormDataReader(ydClient, whConf).complete(
  719. instanceId(item),
  720. whConf.getFormUuidRepairWorkHoursSummary(), formData(item));
  721. if (!isRepairSummaryRecord(fd, whConf.getRepairSummaryFillTypeField())) continue;
  722. if (!"审批中".equals(text(fd.get(whConf.getRepairSummarySyncStatusField())))) continue;
  723. String userId = text(fd.get(R_SUBMITTER));
  724. String monthText = text(fd.get(R_MONTH));
  725. String batchId = text(fd.get(whConf.getRepairSummaryBatchField()));
  726. long submitDate = (long) num(fd.get(whConf.getRepairSummarySubmitDateField()));
  727. if (StringUtils.isAnyBlank(userId, monthText, batchId) || submitDate <= 0) continue;
  728. String submissionKey = userId + "|" + monthText + "|" + batchId;
  729. RepairBatch batch = batches.computeIfAbsent(submissionKey, key -> {
  730. RepairBatch value = new RepairBatch();
  731. value.batchId = batchId;
  732. value.userId = userId;
  733. value.monthText = monthText;
  734. value.submitDate = submitDate;
  735. value.submitterName = text(fd.get("textField_mmoazo1b"));
  736. return value;
  737. });
  738. String dayText = text(fd.get(R_DAY));
  739. collectPendingProjectRows(batch, fd, dayText, true);
  740. collectPendingProjectRows(batch, fd, dayText, false);
  741. collectPendingOtherRows(batch, fd, dayText);
  742. }
  743. return batches;
  744. }
  745. private void collectPendingProjectRows(RepairBatch batch, Map fd, String dayText, boolean billable) {
  746. String tableField = billable ? R_BIL_TABLE : R_NON_TABLE;
  747. String managerField = billable ? "employeeField_mkoxpswf" : "employeeField_mmczo63a";
  748. String submittedField = billable ? "numberField_mmczo636" : "numberField_mmczo63c";
  749. String approvedField = billable ? "numberField_mmczo637" : "numberField_mmczo63d";
  750. String projectCodeField = billable ? "textField_mmacxew3" : "textField_mmczo639";
  751. String projectNameField = billable ? "textField_mmad6jcj" : "textField_mmczo63g";
  752. for (Map child : list(fd.get(tableField))) {
  753. double hours = num(child.get(submittedField)) - num(child.get(approvedField));
  754. if (hours <= 1e-6) continue;
  755. String managerId = firstUserId(child, managerField);
  756. if (StringUtils.isBlank(managerId)) managerId = DEFAULT_MANAGER_ID;
  757. final String resolvedManagerId = managerId;
  758. Map<String, Object> group = (Map<String, Object>) batch.projectGroups.computeIfAbsent(resolvedManagerId, key -> {
  759. Map<String, Object> value = new LinkedHashMap<>();
  760. value.put("managerId", resolvedManagerId);
  761. value.put("billableRows", new ArrayList<Map>());
  762. value.put("nonBillableRows", new ArrayList<Map>());
  763. return value;
  764. });
  765. Map<String, Object> row = mapOf(
  766. "dateStr", dayText,
  767. "projectCode", text(child.get(projectCodeField)),
  768. "projectName", text(child.get(projectNameField)),
  769. "hours", hours);
  770. ((List<Map<String, Object>>) group.get(billable ? "billableRows" : "nonBillableRows")).add(row);
  771. }
  772. }
  773. private void collectPendingOtherRows(RepairBatch batch, Map fd, String dayText) {
  774. for (Map child : list(fd.get(R_OTHER_TABLE))) {
  775. double hours = num(child.get("numberField_mmeakgij")) - num(child.get("numberField_mmeakgii"));
  776. if (hours <= 1e-6) continue;
  777. String managerId = firstUserId(fd, "employeeField_mh8xhqc3");
  778. if (StringUtils.isBlank(managerId)) managerId = DEFAULT_MANAGER_ID;
  779. batch.otherGroup.put("managerId", managerId);
  780. List<Map<String, Object>> rows = (List<Map<String, Object>>) batch.otherGroup.computeIfAbsent(
  781. "otherRows", key -> new ArrayList<Map<String, Object>>());
  782. rows.add(mapOf("dateStr", dayText,
  783. "activityName", text(child.get("selectField_mmeakgie")), "hours", hours));
  784. }
  785. }
  786. private Set<String> loadRepairApprovalDetailCoverage(String monthText) {
  787. Set<String> covered = new HashSet<>();
  788. Map<String, Object> search = mapOf(
  789. whConf.getApprovalFillTypeField(), "补填",
  790. "textField_mmd2lv0y", monthText);
  791. for (Map item : queryProcesses(whConf.getFormUuidApproval(), search)) {
  792. collectRepairApprovalCoverage(item, false, covered);
  793. }
  794. for (Map item : queryProcesses(whConf.getFormUuidOtherApproval(), search)) {
  795. collectRepairApprovalCoverage(item, true, covered);
  796. }
  797. return covered;
  798. }
  799. private void collectRepairApprovalCoverage(Map item, boolean other, Set<String> covered) {
  800. String status = text(item.get("instanceStatus")).toUpperCase(Locale.ROOT);
  801. String approvedResult = text(item.get("approvedResult")).toLowerCase(Locale.ROOT);
  802. if (!"RUNNING".equals(status)
  803. && !("COMPLETED".equals(status) && "agree".equals(approvedResult))) {
  804. return;
  805. }
  806. String id = text(item.get("processInstanceId"));
  807. if (StringUtils.isBlank(id)) id = instanceId(item);
  808. Map fd = new WorkHoursFormDataReader(ydClient, whConf).complete(id,
  809. other ? whConf.getFormUuidOtherApproval() : whConf.getFormUuidApproval(), formData(item));
  810. if (other) {
  811. addCoverageRows(covered, list(fd.get("tableField_mmd1wu9h")), "other",
  812. "textField_mmd1wu9f", "textField_mmd8g655", "selectField_mmeakgie",
  813. "radioField_mpwhk294", status);
  814. return;
  815. }
  816. addCoverageRows(covered, list(fd.get("tableField_mmae8t99")), "billable",
  817. "textField_mmd1ozk4", "textField_mmd8g654", "textField_mmacxew3",
  818. "radioField_mpwhk294", status);
  819. addCoverageRows(covered, list(fd.get("tableField_mmd1wu9h")), "nonBillable",
  820. "textField_mmd1wu9f", "textField_mmd8g655", "textField_mmd1wu9d",
  821. "radioField_mpwhk295", status);
  822. }
  823. private void addCoverageRows(Set<String> covered, List<Map> rows, String category,
  824. String userField, String dayField, String keyField,
  825. String opinionField, String status) {
  826. for (Map row : rows) {
  827. if ("COMPLETED".equals(status)
  828. && row.containsKey(opinionField)
  829. && !"同意".equals(text(row.get(opinionField)))) {
  830. continue;
  831. }
  832. covered.add(coverageKey(category, text(row.get(userField)),
  833. text(row.get(dayField)), text(row.get(keyField))));
  834. }
  835. }
  836. private void removeCoveredRows(RepairBatch batch, Set<String> covered) {
  837. Iterator<Map.Entry<String, Object>> iterator = batch.projectGroups.entrySet().iterator();
  838. while (iterator.hasNext()) {
  839. Map group = (Map) iterator.next().getValue();
  840. removeCoveredProjectRows(batch.userId, list(group.get("billableRows")),
  841. "billable", covered);
  842. removeCoveredProjectRows(batch.userId, list(group.get("nonBillableRows")),
  843. "nonBillable", covered);
  844. if (list(group.get("billableRows")).isEmpty()
  845. && list(group.get("nonBillableRows")).isEmpty()) {
  846. iterator.remove();
  847. }
  848. }
  849. List<Map> otherRows = list(batch.otherGroup.get("otherRows"));
  850. otherRows.removeIf(row -> covered.contains(coverageKey("other", batch.userId,
  851. text(row.get("dateStr")), text(row.get("activityName")))));
  852. if (otherRows.isEmpty()) batch.otherGroup.clear();
  853. }
  854. private void removeCoveredProjectRows(String userId, List<Map> rows,
  855. String category, Set<String> covered) {
  856. rows.removeIf(row -> covered.contains(coverageKey(category, userId,
  857. text(row.get("dateStr")), text(row.get("projectCode")))));
  858. }
  859. static String coverageKey(String category, String userId, String day, String detailKey) {
  860. return category + "|" + userId + "|" + day + "|" + detailKey;
  861. }
  862. private List<Map> queryModified(String formUuid, String fromDate, String toDate) {
  863. List<Map> all = new ArrayList<>();
  864. for (int page = 1; page <= 200; page++) {
  865. Object raw = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType())
  866. .systemToken(whConf.getYidaSystemToken()).formUuid(formUuid)
  867. .modifiedFromTimeGMT(fromDate).modifiedToTimeGMT(toDate)
  868. .currentPage(page).pageSize(100).build(), YDConf.FORM_QUERY.retrieve_search_form).getData();
  869. List<Map> data = raw instanceof List ? (List<Map>) raw : Collections.emptyList();
  870. all.addAll(data);
  871. if (data.size() < 100) break;
  872. }
  873. return all;
  874. }
  875. private List<Map> queryRepairSummaries(Map<String, Object> search) {
  876. List<Map> records = new ArrayList<>(query(whConf.getFormUuidRepairWorkHoursSummary(), search));
  877. records.removeIf(record -> !isRepairSummaryRecord(record, whConf.getRepairSummaryFillTypeField()));
  878. return records;
  879. }
  880. private void saveRepairDraft(List<Map> existing, Map<String, Object> data,
  881. String userId, String dayText) {
  882. YDParam.YDParamBuilder builder = YDParam.builder()
  883. .appType(whConf.getYidaAppType())
  884. .systemToken(whConf.getYidaSystemToken());
  885. if (existing.isEmpty()) {
  886. Map<String, Object> search = mapOf(R_SUBMITTER, userId, R_DAY, dayText,
  887. whConf.getRepairSummaryFillTypeField(), REPAIR_TYPE);
  888. ydClient.operateData(builder.formUuid(whConf.getFormUuidRepairWorkHoursSummary())
  889. .searchCondition(JSON.toJSONString(search)).formDataJson(JSON.toJSONString(data))
  890. .noExecuteExpression(false).build(), YDConf.FORM_OPERATION.upsert);
  891. return;
  892. }
  893. String instanceId = instanceId(existing.get(0));
  894. McException.assertAccessException(StringUtils.isBlank(instanceId), "补填汇总实例ID为空");
  895. ydClient.operateData(builder.formInstanceId(instanceId)
  896. .updateFormDataJson(JSON.toJSONString(data)).ignoreEmpty(false).useLatestVersion(true).build(),
  897. YDConf.FORM_OPERATION.update);
  898. }
  899. private List<Map> queryProcesses(String formUuid, Map<String, Object> search) {
  900. List<Map> all = new ArrayList<>();
  901. for (int page = 1; page <= 200; page++) {
  902. Object raw = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType())
  903. .systemToken(whConf.getYidaSystemToken()).formUuid(formUuid)
  904. .searchFieldJson(JSON.toJSONString(search)).pageNumber(page).pageSize(100)
  905. .build(), YDConf.FORM_QUERY.retrieve_search_process).getData();
  906. List<Map> data = raw instanceof List ? (List<Map>) raw : Collections.emptyList();
  907. all.addAll(data);
  908. if (data.size() < 100) break;
  909. }
  910. return all;
  911. }
  912. private String[] modifiedDateWindow(int lookbackDays) {
  913. int safeDays = Math.max(1, lookbackDays);
  914. DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
  915. return new String[]{LocalDate.now(ZONE).minusDays(safeDays - 1L).format(formatter),
  916. LocalDate.now(ZONE).plusDays(1L).format(formatter)};
  917. }
  918. static boolean isRepairSummaryRecord(Map item, String fillTypeField) {
  919. if (StringUtils.isBlank(fillTypeField)) return true;
  920. Map data = formData(item);
  921. String fillType = text(data.get(fillTypeField));
  922. return StringUtils.isBlank(fillType) || REPAIR_TYPE.equals(fillType);
  923. }
  924. private static Map formData(Map item) {
  925. Object fd = item == null ? null : item.get("formData");
  926. if (!(fd instanceof Map)) fd = item == null ? null : item.get("data");
  927. return fd instanceof Map ? (Map) fd : (item == null ? Collections.emptyMap() : item);
  928. }
  929. private static String instanceId(Map item) {
  930. String id = text(item == null ? null : item.get("formInstId"));
  931. return StringUtils.isBlank(id) ? text(item == null ? null : item.get("formInstanceId")) : id;
  932. }
  933. private String firstUserId(Map data, String field) {
  934. Object value = data == null ? null : data.get(field + "_id");
  935. if (!(value instanceof List) || ((List) value).isEmpty()) value = data == null ? null : data.get(field);
  936. if (value instanceof List && !((List) value).isEmpty()) return text(((List) value).get(0));
  937. return text(value);
  938. }
  939. private String normalizeManagerId(String managerId) {
  940. return StringUtils.isBlank(managerId) ? DEFAULT_MANAGER_ID : managerId;
  941. }
  942. /**
  943. * 提交请求是页面当前值的完整快照;已有草稿应覆盖而不是再次累加。
  944. */
  945. static void mergeSubmittedRow(List<Map> current, Map child, String keyField,
  946. String submittedField, double hours) {
  947. for (Map existing : current) {
  948. if (text(existing.get(keyField)).equals(text(child.get(keyField)))) {
  949. existing.put(submittedField, hours);
  950. return;
  951. }
  952. }
  953. current.add(child);
  954. }
  955. static void applySummaryTotals(Map<String, Object> update, Map snapshot) {
  956. putCategoryTotals(update, snapshot, R_BIL_TABLE,
  957. "numberField_mmczo635", "numberField_mmczo636", "numberField_mmczo637", "numberField_mmd80zae",
  958. "numberField_mmacxew9", "numberField_mmacxewa", "numberField_mmacxewg", "numberField_mmad6jbc");
  959. putCategoryTotals(update, snapshot, R_NON_TABLE,
  960. "numberField_mmczo63b", "numberField_mmczo63c", "numberField_mmczo63d", "numberField_mmeakgi9",
  961. "numberField_mmad6jbx", "numberField_mmad6jbz", "numberField_mmad6jc1", "numberField_mmeakgia");
  962. putCategoryTotals(update, snapshot, R_OTHER_TABLE,
  963. "numberField_mmeakgik", "numberField_mmeakgij", "numberField_mmeakgii", "numberField_mmeakgim",
  964. "numberField_mmd1smeo", "numberField_mmd1smeq", "numberField_mmd1smes", "numberField_mmeakgic");
  965. update.put(R_DAILY_DRAFT, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmczo635")
  966. + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmczo63b")
  967. + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgik"));
  968. update.put(R_DAILY_SUBMITTED, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmczo636")
  969. + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmczo63c")
  970. + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgij"));
  971. update.put(R_DAILY_APPROVED, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmczo637")
  972. + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmczo63d")
  973. + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgii"));
  974. update.put(R_DAILY_CHANGE, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmd80zae")
  975. + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmeakgi9")
  976. + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgim"));
  977. }
  978. private static void putCategoryTotals(Map<String, Object> update, Map snapshot, String table,
  979. String draftChild, String submittedChild, String approvedChild, String changeChild,
  980. String draftMain, String submittedMain, String approvedMain, String changeMain) {
  981. update.put(draftMain, categoryTotal(snapshot, table, draftChild));
  982. update.put(submittedMain, categoryTotal(snapshot, table, submittedChild));
  983. update.put(approvedMain, categoryTotal(snapshot, table, approvedChild));
  984. update.put(changeMain, categoryTotal(snapshot, table, changeChild));
  985. }
  986. private static double categoryTotal(Map snapshot, String table, String field) {
  987. double total = 0d;
  988. for (Map row : list(snapshot.get(table))) total += num(row.get(field));
  989. return total;
  990. }
  991. private List<Map> copyRows(Object raw) {
  992. List<Map> rows = new ArrayList<>();
  993. for (Map row : list(raw)) rows.add(new LinkedHashMap(row));
  994. return rows;
  995. }
  996. private static class RepairBatch {
  997. private String batchId;
  998. private String userId;
  999. private String submitterName;
  1000. private String monthText;
  1001. private long submitDate;
  1002. private final Map<String, Object> projectGroups = new LinkedHashMap<>();
  1003. private final Map<String, Object> otherGroup = new LinkedHashMap<>();
  1004. }
  1005. private static Map<String, Object> mapOf(Object... values) { Map<String, Object> m = new LinkedHashMap<>(); for (int i=0;i<values.length;i+=2) m.put(String.valueOf(values[i]), values[i+1]); return m; }
  1006. private static List<Map> list(Object raw) { return raw instanceof List ? (List<Map>) raw : new ArrayList<>(); }
  1007. private static String text(Object value) { return value == null ? "" : String.valueOf(value); }
  1008. private static double num(Object value) { try { return value == null ? 0d : Double.parseDouble(String.valueOf(value)); } catch (NumberFormatException e) { return 0d; } }
  1009. 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; }
  1010. }