ApprovalWriteBackService.java 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  1. package com.malk.service.workhours;
  2. import com.alibaba.fastjson.JSON;
  3. import com.google.common.util.concurrent.RateLimiter;
  4. import com.malk.server.aliwork.YDConf;
  5. import com.malk.server.aliwork.YDParam;
  6. import com.malk.server.common.McException;
  7. import com.malk.server.dingtalk.DDR_New;
  8. import com.malk.server.workhours.ApprovalWriteBackResult;
  9. import com.malk.server.workhours.WHConf;
  10. import com.malk.service.aliwork.YDClient;
  11. import com.malk.service.aliwork.YDService;
  12. import com.malk.utils.UtilMap;
  13. import lombok.extern.slf4j.Slf4j;
  14. import org.apache.commons.lang3.StringUtils;
  15. import org.springframework.beans.factory.annotation.Autowired;
  16. import org.springframework.scheduling.annotation.Async;
  17. import org.springframework.stereotype.Service;
  18. import java.util.*;
  19. import java.util.concurrent.*;
  20. import java.util.concurrent.atomic.AtomicInteger;
  21. /**
  22. * 审批结果回写工时汇总表
  23. * <p>
  24. * 审批结束或撤销后调用,入参为流程实例ID + 审批结果(0=拒绝/撤销,1=同意)。
  25. * 工时审批含两个明细子表(项目/非项目工时),其他工时审批含一个明细子表。
  26. * 明细按「填报人 + 工时日期(天) + 项目编号/Activity」1:1 对应工时汇总表(人+天一条)里的子表行:
  27. * - 拒绝 → 对应子表行「已提交工时」置 0
  28. * - 同意 → 对应子表行「已审批工时」= 该明细工时,且「已提交工时」不得低于已审批工时
  29. * 主表合计按子表行重算(幂等,撤销/重发不翻倍)。
  30. * <p>
  31. * 设计文档:后端/阿科德斯/审批结果回写工时汇总表/02-程序实现与接口.md
  32. */
  33. @Slf4j
  34. @Service
  35. public class ApprovalWriteBackService {
  36. @Autowired
  37. private YDClient ydClient;
  38. @Autowired
  39. private YDService ydService;
  40. @Autowired
  41. private WHConf whConf;
  42. @Autowired
  43. private RepairApprovalWriteBackService repairApprovalWriteBackService;
  44. private static final int THREAD_POOL_SIZE = 10;
  45. /**
  46. * 明细审批意见「同意」取值(单选,已确认)
  47. */
  48. private static final String OPINION_APPROVE = "同意";
  49. // ===== 工时汇总表:记录定位键 + 主表合计字段 =====
  50. private static final String SUMMARY_SUBMITTER_ID = "textField_mmbffvd9"; // 填报人ID(文本)
  51. private static final String SUMMARY_DAY_TEXT = "textField_mmbffvdb"; // 工时日期(文本) yyyyMMdd
  52. private static final String M_DAY_SUBMITTED = "numberField_mmd1smem"; // 已提交工时(日)
  53. private static final String M_DAY_APPROVED = "numberField_mmeakgi4"; // 已审批工时(日)
  54. private static final String M_BIL_SUBMITTED = "numberField_mmacxewa"; // Billable已提交
  55. private static final String M_BIL_APPROVED = "numberField_mmacxewg"; // Billable已审批
  56. private static final String M_NON_SUBMITTED = "numberField_mmad6jbz"; // Non Billable已提交
  57. private static final String M_NON_APPROVED = "numberField_mmad6jc1"; // Non Billable已审批
  58. private static final String M_OTH_SUBMITTED = "numberField_mmd1smeq"; // 已提交(其他)
  59. private static final String M_OTH_APPROVED = "numberField_mmd1smes"; // 已审批(其他)
  60. // ===== 工时汇总表:三个子表 =====
  61. private static final String SUB_BIL_TABLE = "tableField_mmczo634";
  62. private static final String SUB_BIL_KEY = "textField_mmacxew3"; // 项目编号
  63. private static final String SUB_BIL_SUBMITTED = "numberField_mmczo636"; // 已提交工时
  64. private static final String SUB_BIL_APPROVED = "numberField_mmczo637"; // 已审批工时
  65. private static final String SUB_NON_TABLE = "tableField_mmczo63h";
  66. private static final String SUB_NON_KEY = "textField_mmczo639"; // 项目编号
  67. private static final String SUB_NON_SUBMITTED = "numberField_mmczo63c";
  68. private static final String SUB_NON_APPROVED = "numberField_mmczo63d";
  69. private static final String SUB_OTH_TABLE = "tableField_mmeakgid";
  70. private static final String SUB_OTH_KEY = "selectField_mmeakgie"; // Activity
  71. private static final String SUB_OTH_SUBMITTED = "numberField_mmeakgij";
  72. private static final String SUB_OTH_APPROVED = "numberField_mmeakgii";
  73. // ===== 审批单明细子表字段(按类别)=====
  74. // 工时审批·项目工时明细(Billable)
  75. private static final String AP_BIL_TABLE = "tableField_mmae8t99";
  76. private static final String AP_BIL_USERID = "textField_mmd1ozk4";
  77. private static final String AP_BIL_KEY = "textField_mmacxew3"; // 项目编号
  78. private static final String AP_BIL_DAYTEXT = "textField_mmd8g654";
  79. private static final String AP_BIL_HOURS = "numberField_mmacxew9";
  80. private static final String AP_BIL_OPINION = "radioField_mpwhk294";
  81. // 工时审批·非项目工时明细(Non Billable)
  82. private static final String AP_NON_TABLE = "tableField_mmd1wu9h";
  83. private static final String AP_NON_USERID = "textField_mmd1wu9f";
  84. private static final String AP_NON_KEY = "textField_mmd1wu9d"; // 项目编号
  85. private static final String AP_NON_DAYTEXT = "textField_mmd8g655";
  86. private static final String AP_NON_HOURS = "numberField_mmd1wu9e";
  87. private static final String AP_NON_OPINION = "radioField_mpwhk295";
  88. // 其他工时审批·明细(与非项目同子表ID,但语义为 Activity)
  89. private static final String AP_OTH_TABLE = "tableField_mmd1wu9h";
  90. private static final String AP_OTH_USERID = "textField_mmd1wu9f";
  91. private static final String AP_OTH_KEY = "selectField_mmeakgie"; // Activity
  92. private static final String AP_OTH_DAYTEXT = "textField_mmd8g655";
  93. private static final String AP_OTH_HOURS = "numberField_mmd1wu9e";
  94. private static final String AP_OTH_OPINION = "radioField_mpwhk294";
  95. private enum Cat {BILLABLE, NON_BILLABLE, OTHER}
  96. /**
  97. * 单条审批明细(归一化)
  98. */
  99. private static class DetailRow {
  100. Cat cat;
  101. String userId;
  102. String dayText;
  103. String key; // 项目编号 / Activity
  104. double hours;
  105. boolean approve; // 有效意见:true=同意, false=拒绝
  106. }
  107. /**
  108. * 异步触发审批回写,供流程结束自动化调用。
  109. *
  110. * @param formInstanceId 审批表单实例 ID
  111. * @param result 审批结果:0=拒绝/撤销,1=同意
  112. */
  113. @Async
  114. public void writeBackAsync(String formInstanceId, int result) {
  115. writeBack(formInstanceId, result);
  116. }
  117. /**
  118. * 审批结果回写工时汇总表
  119. *
  120. * @param formInstanceId 流程实例ID
  121. * @param result 审批结果:0=拒绝/撤销,1=同意
  122. * @return 回写结果统计
  123. */
  124. public ApprovalWriteBackResult writeBack(String formInstanceId, int result) {
  125. McException.assertAccessException(StringUtils.isBlank(formInstanceId), "实例ID不能为空");
  126. // 1. 流程详情接口优先,流程表单可直接返回完整子表;旧回调传表单实例ID时回退表单详情接口。
  127. Map approvalFormData = queryApprovalFormData(formInstanceId);
  128. log.info("[审批回写] 审批单详情 formInstanceId={} keys={}", formInstanceId,
  129. approvalFormData == null ? null : approvalFormData.keySet());
  130. McException.assertAccessException(approvalFormData == null || approvalFormData.isEmpty(),
  131. "未查询到审批单数据,实例ID=" + formInstanceId);
  132. // prd 补填审批只走独立回写:来源日写补填汇总,提交日写原汇总变更工时。
  133. // 正常审批(工时类型为空/正常)继续执行下方原有逻辑。
  134. String fillType = whConf.getApprovalFillTypeField() == null
  135. ? "" : String.valueOf(approvalFormData.get(whConf.getApprovalFillTypeField()));
  136. if ("补填".equals(fillType) && repairApprovalWriteBackService != null) {
  137. return repairApprovalWriteBackService.writeBack(formInstanceId, result);
  138. }
  139. // 2. 类型判定:不能只看项目工时子表;仅填非项目工时的工时审批也可能没有 AP_BIL_TABLE。
  140. boolean isOther = isOtherApprovalForm(approvalFormData);
  141. String approvalFormUuid = isOther
  142. ? whConf.getFormUuidOtherApproval()
  143. : whConf.getFormUuidApproval();
  144. boolean approveWhole = (result == 1);
  145. // 3. 取审批单明细(内联子表 <50 直接用,==50 才 queryDetails 递归取全,避免无效请求)
  146. List<DetailRow> rows = new ArrayList<>();
  147. if (isOther) {
  148. rows.addAll(loadDetailRows(
  149. formInstanceId, approvalFormUuid, approvalFormData, Cat.OTHER, approveWhole));
  150. } else {
  151. rows.addAll(loadDetailRows(
  152. formInstanceId, approvalFormUuid, approvalFormData, Cat.BILLABLE, approveWhole));
  153. rows.addAll(loadDetailRows(
  154. formInstanceId, approvalFormUuid, approvalFormData, Cat.NON_BILLABLE, approveWhole));
  155. }
  156. // 4. 按汇总表记录键(填报人 + 工时日期文本)分组
  157. Map<String, List<DetailRow>> groups = new LinkedHashMap<>();
  158. int invalidRows = 0;
  159. for (DetailRow r : rows) {
  160. if (StringUtils.isBlank(r.userId) || StringUtils.isBlank(r.dayText)) {
  161. invalidRows++;
  162. continue;
  163. }
  164. groups.computeIfAbsent(r.userId + "" + r.dayText, k -> new ArrayList<>()).add(r);
  165. }
  166. // 5. 并发逐「日记录」处理
  167. AtomicInteger hitRecords = new AtomicInteger(0);
  168. AtomicInteger updatedRows = new AtomicInteger(0);
  169. AtomicInteger missRows = new AtomicInteger(0);
  170. AtomicInteger failRecords = new AtomicInteger(0);
  171. RateLimiter limiter = RateLimiter.create(20.0);
  172. ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
  173. try {
  174. List<Future<?>> futures = new ArrayList<>();
  175. for (Map.Entry<String, List<DetailRow>> e : groups.entrySet()) {
  176. List<DetailRow> groupRows = e.getValue();
  177. String userId = groupRows.get(0).userId;
  178. String dayText = groupRows.get(0).dayText;
  179. futures.add(executor.submit(() -> {
  180. try {
  181. int[] dayResult = applyToRecord(userId, dayText, groupRows, limiter);
  182. if (dayResult == null) {
  183. // fixme 审批明细存在但汇总表日记录缺失属于失败,不能把审批单标记为「全部成功」。
  184. failRecords.incrementAndGet();
  185. return;
  186. }
  187. hitRecords.incrementAndGet();
  188. updatedRows.addAndGet(dayResult[0]);
  189. missRows.addAndGet(dayResult[1]);
  190. if (dayResult[1] > 0) {
  191. failRecords.incrementAndGet();
  192. }
  193. } catch (Exception ex) {
  194. failRecords.incrementAndGet();
  195. log.error("[审批回写] 记录处理失败,交由定时器本轮结束后补试 userId={} day={}",
  196. userId, dayText, ex);
  197. }
  198. }));
  199. }
  200. for (Future<?> f : futures) {
  201. try {
  202. f.get();
  203. } catch (Exception ex) {
  204. log.error("[审批回写] 线程执行异常", ex);
  205. }
  206. }
  207. } finally {
  208. executor.shutdown();
  209. }
  210. // 6. 同步状态汇总(syncTotal=分组数;failCount=失败组数)
  211. // fixme 状态字面值对齐宜搭审批表 selectField_mq58cd5p 选项:"全部成功" / "部分失败" / "全部失败"
  212. int syncTotal = groups.size() + invalidRows;
  213. int failCount = failRecords.get() + invalidRows;
  214. // fixme 审批明细为空同样不能标记为成功,否则兜底定时会永久跳过该异常实例。
  215. if (syncTotal == 0) {
  216. syncTotal = 1;
  217. failCount = 1;
  218. }
  219. String syncStatus = determineSyncStatus(syncTotal, failCount);
  220. ApprovalWriteBackResult stats = ApprovalWriteBackResult.builder()
  221. .formType(isOther ? "其他工时审批" : "工时审批")
  222. .formInstanceId(formInstanceId)
  223. .result(result)
  224. .detailRows(rows.size())
  225. .groups(groups.size())
  226. .hitRecords(hitRecords.get())
  227. .updatedRows(updatedRows.get())
  228. .missRows(missRows.get())
  229. .failRecords(failCount)
  230. .syncStatus(syncStatus)
  231. .syncTotal(syncTotal)
  232. .failCount(failCount)
  233. .build();
  234. log.info("[审批回写] 完成 {}", stats);
  235. // 7. 延迟 2s 等宜搭审批结束的内部同步索引/锁释放,再回写审批单同步字段,避免触发同步锁失败
  236. sleep(2000);
  237. writebackSyncFields(isOther, formInstanceId, syncStatus, syncTotal, failCount);
  238. return stats;
  239. }
  240. static String determineSyncStatus(int syncTotal, int failCount) {
  241. if (syncTotal <= 0 || failCount >= syncTotal) {
  242. return "全部失败";
  243. }
  244. return failCount > 0 ? "部分失败" : "全部成功";
  245. }
  246. private Map queryApprovalFormData(String instanceId) {
  247. try {
  248. DDR_New processDetail = ydClient.queryData(YDParam.builder()
  249. .appType(whConf.getYidaAppType())
  250. .systemToken(whConf.getYidaSystemToken())
  251. .processInstanceId(instanceId)
  252. .build(), YDConf.FORM_QUERY.retrieve_search_process_id);
  253. Map processFormData = extractProcessFormData(processDetail);
  254. if (processFormData != null && !processFormData.isEmpty()) {
  255. return processFormData;
  256. }
  257. } catch (Exception ex) {
  258. log.warn("[审批回写] 流程详情查询失败,回退表单详情 instanceId={}", instanceId, ex);
  259. }
  260. try {
  261. DDR_New legacyProcessDetail = ydClient.queryData(YDParam.builder()
  262. .appType(whConf.getYidaAppType())
  263. .systemToken(whConf.getYidaSystemToken())
  264. .processInstanceId(instanceId)
  265. .build(), YDConf.FORM_QUERY.retrieve_process_legacy_id);
  266. Map legacyFormData = extractProcessFormData(legacyProcessDetail);
  267. if (legacyFormData != null && !legacyFormData.isEmpty()) {
  268. return legacyFormData;
  269. }
  270. } catch (Exception ex) {
  271. log.warn("[审批回写] 旧版流程详情查询失败,回退表单详情 instanceId={}", instanceId, ex);
  272. }
  273. DDR_New formDetail = ydClient.queryData(YDParam.builder()
  274. .appType(whConf.getYidaAppType())
  275. .systemToken(whConf.getYidaSystemToken())
  276. .formInstanceId(instanceId)
  277. .build(), YDConf.FORM_QUERY.retrieve_id);
  278. return formDetail == null ? null : formDetail.getFormData();
  279. }
  280. @SuppressWarnings("unchecked")
  281. static Map extractProcessFormData(DDR_New processDetail) {
  282. if (processDetail == null) {
  283. return null;
  284. }
  285. if (processDetail.getFormData() != null && !processDetail.getFormData().isEmpty()) {
  286. return processDetail.getFormData();
  287. }
  288. if (processDetail.getResult() instanceof Map) {
  289. Map result = (Map) processDetail.getResult();
  290. Object nested = result.get("formData");
  291. if (!(nested instanceof Map)) {
  292. nested = result.get("data");
  293. }
  294. if (nested instanceof Map) {
  295. return (Map) nested;
  296. }
  297. if (result.containsKey(AP_BIL_TABLE)
  298. || result.containsKey(AP_NON_TABLE)
  299. || result.containsKey(AP_OTH_TABLE)) {
  300. return result;
  301. }
  302. }
  303. if (!(processDetail.getData() instanceof Map)) {
  304. return null;
  305. }
  306. Map data = (Map) processDetail.getData();
  307. Object nested = data.get("formData");
  308. if (!(nested instanceof Map)) {
  309. nested = data.get("data");
  310. }
  311. if (nested instanceof Map) {
  312. return (Map) nested;
  313. }
  314. if (data.containsKey(AP_BIL_TABLE)
  315. || data.containsKey(AP_NON_TABLE)
  316. || data.containsKey(AP_OTH_TABLE)) {
  317. return data;
  318. }
  319. return null;
  320. }
  321. /**
  322. * 区分「工时审批」与「其他工时审批」。
  323. * 两者共用 tableField_mmd1wu9h,但明细键分别为项目编号和 Activity。
  324. */
  325. @SuppressWarnings("unchecked")
  326. static boolean isOtherApprovalForm(Map approvalFormData) {
  327. if (approvalFormData == null) {
  328. return false;
  329. }
  330. if (approvalFormData.containsKey(AP_BIL_TABLE)) {
  331. return false;
  332. }
  333. Object sharedRows = approvalFormData.get(AP_NON_TABLE);
  334. if (sharedRows instanceof List) {
  335. for (Object item : (List<?>) sharedRows) {
  336. if (!(item instanceof Map)) {
  337. continue;
  338. }
  339. Map row = (Map) item;
  340. if (row.containsKey(AP_NON_KEY)) {
  341. return false;
  342. }
  343. if (row.containsKey(AP_OTH_KEY)) {
  344. return true;
  345. }
  346. }
  347. }
  348. return true;
  349. }
  350. /**
  351. * 把同步状态/同步总数/失败数量回写到原审批单(fieldId 走 WHConf 配置)
  352. * fixme 三个 fieldId 任一为空则跳过该类别回写,允许业务方分阶段在宜搭后台建字段
  353. */
  354. private void writebackSyncFields(boolean isOther, String formInstanceId,
  355. String syncStatus, int syncTotal, int failCount) {
  356. String statusField = isOther ? whConf.getOtherApprovalSyncStatusField() : whConf.getApprovalSyncStatusField();
  357. String totalField = isOther ? whConf.getOtherApprovalSyncTotalField() : whConf.getApprovalSyncTotalField();
  358. String failField = isOther ? whConf.getOtherApprovalSyncFailField() : whConf.getApprovalSyncFailField();
  359. if (StringUtils.isAnyBlank(statusField, totalField, failField)) {
  360. log.info("[审批回写] 跳过审批单同步字段回写(配置未启用) isOther={} formInstanceId={}", isOther, formInstanceId);
  361. return;
  362. }
  363. try {
  364. Map<String, Object> sync = new HashMap<>();
  365. sync.put(statusField, syncStatus);
  366. sync.put(totalField, syncTotal);
  367. sync.put(failField, failCount);
  368. ydClient.operateData(YDParam.builder()
  369. .appType(whConf.getYidaAppType())
  370. .systemToken(whConf.getYidaSystemToken())
  371. .formInstanceId(formInstanceId)
  372. .updateFormDataJson(JSON.toJSONString(sync))
  373. .ignoreEmpty(false)
  374. .useLatestVersion(true)
  375. .build(), YDConf.FORM_OPERATION.update);
  376. log.info("[审批回写] 审批单同步字段已回写 isOther={} formInstanceId={} status={} total={} fail={}",
  377. isOther, formInstanceId, syncStatus, syncTotal, failCount);
  378. } catch (Exception ex) {
  379. log.error("[审批回写] 审批单同步字段回写失败 isOther={} formInstanceId={}", isOther, formInstanceId, ex);
  380. throw new IllegalStateException("审批单同步字段回写失败 formInstanceId=" + formInstanceId, ex);
  381. }
  382. }
  383. /**
  384. * 处理单条汇总表日记录
  385. *
  386. * @return {updatedRows, missRows};汇总表无此记录返回 null
  387. */
  388. private int[] applyToRecord(String userId, String dayText, List<DetailRow> groupRows, RateLimiter limiter) {
  389. // a. 定位汇总表记录(填报人ID + 工时日期文本,代码内精确过滤)
  390. limiter.acquire();
  391. List<Map> found = (List<Map>) ydClient.queryData(YDParam.builder()
  392. .appType(whConf.getYidaAppType())
  393. .systemToken(whConf.getYidaSystemToken())
  394. .formUuid(whConf.getFormUuidWorkHoursSummary())
  395. .searchFieldJson(JSON.toJSONString(UtilMap.map(
  396. SUMMARY_SUBMITTER_ID + ", " + SUMMARY_DAY_TEXT, userId, dayText)))
  397. .build(), YDConf.FORM_QUERY.retrieve_search_form).getData();
  398. List<String> matchingIds = new ArrayList<>();
  399. for (Map item : found == null ? Collections.<Map>emptyList() : found) {
  400. Map fd = (Map) item.get("formData");
  401. if (fd == null) continue;
  402. if (userId.equals(String.valueOf(fd.get(SUMMARY_SUBMITTER_ID)))
  403. && dayText.equals(String.valueOf(fd.get(SUMMARY_DAY_TEXT)))) {
  404. Object id = item.get("formInstanceId");
  405. if (id == null) {
  406. id = item.get("formInstId");
  407. }
  408. if (id != null && StringUtils.isNotBlank(String.valueOf(id))) {
  409. matchingIds.add(String.valueOf(id));
  410. }
  411. }
  412. }
  413. McException.assertAccessException(
  414. matchingIds.size() > 1,
  415. "工时汇总表唯一键重复,拒绝回写 userId=" + userId + ", day=" + dayText);
  416. String summaryInstId = matchingIds.isEmpty() ? null : matchingIds.get(0);
  417. if (StringUtils.isBlank(summaryInstId)) {
  418. log.warn("[审批回写] 未命中汇总表记录 userId={} day={}", userId, dayText);
  419. return null;
  420. }
  421. // b. 取汇总表记录完整数据(含三子表)
  422. limiter.acquire();
  423. Map formData = ydClient.queryData(YDParam.builder()
  424. .appType(whConf.getYidaAppType())
  425. .systemToken(whConf.getYidaSystemToken())
  426. .formInstanceId(summaryInstId)
  427. .build(), YDConf.FORM_QUERY.retrieve_id).getFormData();
  428. Map<String, Object> updateData = new HashMap<>();
  429. int updated = 0;
  430. int miss = 0;
  431. // c. 按类别分组本记录的明细,逐类别重建子表 + 置值
  432. Map<Cat, List<DetailRow>> byCat = new EnumMap<>(Cat.class);
  433. for (DetailRow r : groupRows) {
  434. byCat.computeIfAbsent(r.cat, k -> new ArrayList<>()).add(r);
  435. }
  436. // 各类别已提交/已审批合计(受影响类别从重建行重算,未受影响沿用主表现值)
  437. double bilSubmitted = num(formData, M_BIL_SUBMITTED), bilApproved = num(formData, M_BIL_APPROVED);
  438. double nonSubmitted = num(formData, M_NON_SUBMITTED), nonApproved = num(formData, M_NON_APPROVED);
  439. double othSubmitted = num(formData, M_OTH_SUBMITTED), othApproved = num(formData, M_OTH_APPROVED);
  440. for (Map.Entry<Cat, List<DetailRow>> e : byCat.entrySet()) {
  441. Cat cat = e.getKey();
  442. String subTable = subTable(cat);
  443. String keyField = subKey(cat);
  444. String submittedField = subSubmitted(cat);
  445. String approvedField = subApproved(cat);
  446. // 子表取数控制:内联 <50 直接用,==50 才递归取全,避免全量覆盖时漏行被删
  447. List<Map> rebuilt = rebuildRows(
  448. resolveDetailRows(
  449. summaryInstId,
  450. whConf.getFormUuidWorkHoursSummary(),
  451. subTable,
  452. (List<Map>) formData.get(subTable)));
  453. for (DetailRow dr : e.getValue()) {
  454. Map target = null;
  455. for (Map row : rebuilt) {
  456. if (dr.key != null && dr.key.equals(String.valueOf(row.get(keyField)))) {
  457. target = row;
  458. break;
  459. }
  460. }
  461. if (target == null) {
  462. miss++;
  463. log.warn("[审批回写] 子表行未匹配 cat={} key={} userId={} day={}", cat, dr.key, userId, dayText);
  464. continue;
  465. }
  466. double submittedBefore = num(target, submittedField);
  467. if (dr.approve) {
  468. target.put(approvedField, dr.hours);
  469. }
  470. // fixme 拒绝仍清零;同意时兜底恢复已提交,避免补发审批只写 approved 后形成 submitted=0。
  471. target.put(submittedField, resolveSubmittedAfterWriteBack(
  472. dr.approve, submittedBefore, dr.hours));
  473. updated++;
  474. }
  475. updateData.put(subTable, rebuilt);
  476. double sumSubmitted = sumRows(rebuilt, submittedField);
  477. double sumApproved = sumRows(rebuilt, approvedField);
  478. switch (cat) {
  479. case BILLABLE:
  480. bilSubmitted = sumSubmitted;
  481. bilApproved = sumApproved;
  482. break;
  483. case NON_BILLABLE:
  484. nonSubmitted = sumSubmitted;
  485. nonApproved = sumApproved;
  486. break;
  487. case OTHER:
  488. othSubmitted = sumSubmitted;
  489. othApproved = sumApproved;
  490. break;
  491. }
  492. }
  493. // d. 主表合计重算
  494. updateData.put(M_BIL_SUBMITTED, bilSubmitted);
  495. updateData.put(M_BIL_APPROVED, bilApproved);
  496. updateData.put(M_NON_SUBMITTED, nonSubmitted);
  497. updateData.put(M_NON_APPROVED, nonApproved);
  498. updateData.put(M_OTH_SUBMITTED, othSubmitted);
  499. updateData.put(M_OTH_APPROVED, othApproved);
  500. updateData.put(M_DAY_SUBMITTED, bilSubmitted + nonSubmitted + othSubmitted);
  501. updateData.put(M_DAY_APPROVED, bilApproved + nonApproved + othApproved);
  502. // prd 审批回写完成后同步记录级审批状态,避免数字已回写但汇总状态仍停留在“审批中”
  503. String approvalStatusField = whConf.getSummaryApprovalStatusField();
  504. if (StringUtils.isNotBlank(approvalStatusField)) {
  505. updateData.put(
  506. approvalStatusField,
  507. resolveSummaryApprovalStatus(
  508. bilSubmitted + nonSubmitted + othSubmitted,
  509. bilApproved + nonApproved + othApproved));
  510. }
  511. // e. 全量覆盖更新(子表整组替换 + 主表合计)
  512. limiter.acquire();
  513. ydClient.operateData(YDParam.builder()
  514. .appType(whConf.getYidaAppType())
  515. .systemToken(whConf.getYidaSystemToken())
  516. .formInstanceId(summaryInstId)
  517. .updateFormDataJson(JSON.toJSONString(updateData))
  518. .ignoreEmpty(false)
  519. .useLatestVersion(true)
  520. .build(), YDConf.FORM_OPERATION.update);
  521. return new int[]{updated, miss};
  522. }
  523. /**
  524. * 计算工时汇总记录的审批状态。
  525. *
  526. * @param submitted 已提交合计
  527. * @param approved 已审批合计
  528. * @return 已审批 / 审批中 / 暂存
  529. */
  530. static String resolveSummaryApprovalStatus(double submitted, double approved) {
  531. if (submitted > approved + 1e-6) {
  532. return "审批中";
  533. }
  534. if (approved > 0) {
  535. return "已审批";
  536. }
  537. if (submitted > 0) {
  538. return "审批中";
  539. }
  540. return "暂存";
  541. }
  542. /**
  543. * 计算审批回写后的已提交工时。
  544. *
  545. * @param approve 是否审批同意
  546. * @param currentSubmitted 当前已提交工时
  547. * @param approvedHours 本次审批明细工时
  548. * @return 拒绝时为 0;同意时不低于本次审批工时且不降低当前已提交值
  549. */
  550. static double resolveSubmittedAfterWriteBack(
  551. boolean approve,
  552. double currentSubmitted,
  553. double approvedHours) {
  554. if (!approve) {
  555. return 0d;
  556. }
  557. return Math.max(currentSubmitted, approvedHours);
  558. }
  559. /**
  560. * 加载审批单某类别明细(内联子表优先,必要时递归取全)
  561. */
  562. private List<DetailRow> loadDetailRows(String formInstanceId,
  563. String formUuid,
  564. Map approvalFormData,
  565. Cat cat,
  566. boolean approveWhole) {
  567. String tableId = apTable(cat);
  568. List<Map> raw = resolveDetailRows(
  569. formInstanceId,
  570. formUuid,
  571. tableId,
  572. (List<Map>) approvalFormData.get(tableId));
  573. List<DetailRow> list = new ArrayList<>();
  574. for (Map row : raw) {
  575. DetailRow dr = new DetailRow();
  576. dr.cat = cat;
  577. dr.userId = str(row, apUserId(cat));
  578. dr.dayText = str(row, apDayText(cat));
  579. dr.key = str(row, apKey(cat));
  580. dr.hours = num(row, apHours(cat));
  581. String opinion = str(row, apOpinion(cat));
  582. // fixme 历史审批单可能没有逐行意见:整单同意时空意见按同意处理,仅显式非同意值按拒绝处理。
  583. dr.approve = isDetailApproved(approveWhole, opinion);
  584. list.add(dr);
  585. }
  586. return list;
  587. }
  588. static boolean isDetailApproved(boolean approveWhole, String opinion) {
  589. return approveWhole && (StringUtils.isBlank(opinion) || OPINION_APPROVE.equals(opinion));
  590. }
  591. /**
  592. * 子表取数控制:主表详情已内联返回子表时,行数 &lt;50 视为完整直接用;
  593. * ==50(可能被宜搭截断)才调 {@link YDService#queryDetails} 递归取全,避免无效请求。
  594. * 复用 ydService 封装,不重复造分页轮子。
  595. * <p>
  596. * fixme inlineRows==null 表示审批单不含该子表(如其他工时审批单无 BILLABLE/NON_BILLABLE,或工时审批单某一子表未填):
  597. * 直接返回空 list,不再调 queryDetails——基座 _queryDetails 对宜搭返回 data=null 的空子表查询会触发
  598. * ArrayList.addAll(null) NPE。queryDetails 返回 null 同样兜底空 list。
  599. */
  600. private List<Map> resolveDetailRows(String formInstanceId,
  601. String formUuid,
  602. String tableId,
  603. List<Map> inlineRows) {
  604. if (inlineRows == null) {
  605. return Collections.emptyList();
  606. }
  607. if (inlineRows.size() < 50) {
  608. return inlineRows;
  609. }
  610. List<Map> full = ydService.queryDetails(YDParam.builder()
  611. .appType(whConf.getYidaAppType())
  612. .systemToken(whConf.getYidaSystemToken())
  613. .formUuid(formUuid)
  614. .formInstanceId(formInstanceId)
  615. .tableFieldId(tableId)
  616. .pageNumber(1)
  617. .build());
  618. McException.assertAccessException(
  619. full == null || full.size() < inlineRows.size(),
  620. "审批单子表完整数据获取失败 instanceId=" + formInstanceId
  621. + ", tableFieldId=" + tableId);
  622. return full;
  623. }
  624. /**
  625. * 重建子表行:保留全部字段,成员/关联字段用 _id 取真实ID(全量覆盖写回前置)
  626. */
  627. private List<Map> rebuildRows(List<Map> rows) {
  628. List<Map> out = new ArrayList<>();
  629. if (rows == null) {
  630. return out;
  631. }
  632. for (Map row : rows) {
  633. Map newRow = new HashMap();
  634. for (Object keyObj : row.keySet()) {
  635. String key = String.valueOf(keyObj);
  636. if (key.endsWith("_value") || (key.startsWith("employeeField_") && !key.endsWith("_id"))) {
  637. continue;
  638. }
  639. String k = key;
  640. if (key.endsWith("_id")) {
  641. if (key.startsWith("employeeField_") || key.startsWith("associationFormField_")) {
  642. k = key.substring(0, key.length() - 3);
  643. } else {
  644. continue;
  645. }
  646. }
  647. newRow.put(k, YDConf.getDataByCompId(row, k));
  648. }
  649. out.add(newRow);
  650. }
  651. return out;
  652. }
  653. // ===== 字段映射辅助 =====
  654. private String apTable(Cat c) {
  655. return c == Cat.BILLABLE ? AP_BIL_TABLE : (c == Cat.NON_BILLABLE ? AP_NON_TABLE : AP_OTH_TABLE);
  656. }
  657. private String apUserId(Cat c) {
  658. return c == Cat.BILLABLE ? AP_BIL_USERID : (c == Cat.NON_BILLABLE ? AP_NON_USERID : AP_OTH_USERID);
  659. }
  660. private String apDayText(Cat c) {
  661. return c == Cat.BILLABLE ? AP_BIL_DAYTEXT : (c == Cat.NON_BILLABLE ? AP_NON_DAYTEXT : AP_OTH_DAYTEXT);
  662. }
  663. private String apKey(Cat c) {
  664. return c == Cat.BILLABLE ? AP_BIL_KEY : (c == Cat.NON_BILLABLE ? AP_NON_KEY : AP_OTH_KEY);
  665. }
  666. private String apHours(Cat c) {
  667. return c == Cat.BILLABLE ? AP_BIL_HOURS : (c == Cat.NON_BILLABLE ? AP_NON_HOURS : AP_OTH_HOURS);
  668. }
  669. private String apOpinion(Cat c) {
  670. return c == Cat.BILLABLE ? AP_BIL_OPINION : (c == Cat.NON_BILLABLE ? AP_NON_OPINION : AP_OTH_OPINION);
  671. }
  672. private String subTable(Cat c) {
  673. return c == Cat.BILLABLE ? SUB_BIL_TABLE : (c == Cat.NON_BILLABLE ? SUB_NON_TABLE : SUB_OTH_TABLE);
  674. }
  675. private String subKey(Cat c) {
  676. return c == Cat.BILLABLE ? SUB_BIL_KEY : (c == Cat.NON_BILLABLE ? SUB_NON_KEY : SUB_OTH_KEY);
  677. }
  678. private String subSubmitted(Cat c) {
  679. return c == Cat.BILLABLE ? SUB_BIL_SUBMITTED : (c == Cat.NON_BILLABLE ? SUB_NON_SUBMITTED : SUB_OTH_SUBMITTED);
  680. }
  681. private String subApproved(Cat c) {
  682. return c == Cat.BILLABLE ? SUB_BIL_APPROVED : (c == Cat.NON_BILLABLE ? SUB_NON_APPROVED : SUB_OTH_APPROVED);
  683. }
  684. // ===== 通用工具 =====
  685. private double num(Map m, String k) {
  686. Object v = m == null ? null : m.get(k);
  687. if (v == null) {
  688. return 0d;
  689. }
  690. try {
  691. return Double.parseDouble(String.valueOf(v));
  692. } catch (Exception e) {
  693. return 0d;
  694. }
  695. }
  696. private double sumRows(List<Map> rows, String field) {
  697. double sum = 0d;
  698. for (Map r : rows) {
  699. sum += num(r, field);
  700. }
  701. return sum;
  702. }
  703. private String str(Map m, String k) {
  704. Object v = m == null ? null : m.get(k);
  705. return v == null ? "" : String.valueOf(v);
  706. }
  707. private void sleep(long ms) {
  708. try {
  709. Thread.sleep(ms);
  710. } catch (InterruptedException ie) {
  711. Thread.currentThread().interrupt();
  712. }
  713. }
  714. }