TimeCardRepairService.java 48 KB

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