ReSubmitApprovalService.java 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305
  1. package com.malk.service.workhours;
  2. import com.alibaba.fastjson.JSON;
  3. import com.malk.server.aliwork.YDConf;
  4. import com.malk.server.aliwork.YDParam;
  5. import com.malk.server.common.McException;
  6. import com.malk.server.dingtalk.DDR_New;
  7. import com.malk.server.workhours.ReSubmitApprovalResult;
  8. import com.malk.server.workhours.TimeCardApprovalStartRequest;
  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.stereotype.Service;
  17. import java.util.*;
  18. /**
  19. * 补发起工时审批(bug 1 兜底)
  20. * <p>
  21. * 场景:汇总表 submitted > 0 但对应 (userId, day) 未出现在任何该月工时审批 / 其他工时审批的子表明细中,
  22. * 即前端 submitApprovalProcess 链路失败(startInstance 挂了/静默丢),导致部分员工工时从未进入审批。
  23. * <p>
  24. * 逻辑完全参考前端 TimeCard 的 submitApprovalProcess:
  25. * 1. collectApprovalData — 遍历汇总表每天子表,delta = submitted - approved - change, delta>0 才 push,按 PM 分组
  26. * 2. processManagerApproval — queryProcess(month + PM + RUNNING) 有则 update / 无则 create
  27. * COMPLETED 明细先进入覆盖索引;汇总尚未落值时归回写处理,不重复发起
  28. * 3. collectOtherApprovalData / processOtherApproval — 其他工时按当前员工的部门主管作 approver,同样 query→update/create
  29. * <p>
  30. * 发起人 userId 必须用真人钉钉账号(建议 System),不能用 yida_pub_account(不触发钉钉待办通知)
  31. * <p>
  32. * 设计文档:后端/阿科德斯/审批链路补发起.md
  33. */
  34. @Slf4j
  35. @Service
  36. public class ReSubmitApprovalService {
  37. @Autowired
  38. private YDClient ydClient;
  39. @Autowired
  40. private YDService ydService;
  41. @Autowired
  42. private WHConf whConf;
  43. /**
  44. * 前端 v2.35 常量:发起时明细「审批意见」默认「同意」
  45. */
  46. private static final String APPROVAL_OPINION_AGREE = "同意";
  47. /**
  48. * 宜搭单个子表组件可稳定查询和覆盖更新的最大行数。
  49. */
  50. private static final int MAX_APPROVAL_SUBTABLE_ROWS = 500;
  51. /**
  52. * 批量历史补偿的目标分片大小;以员工整组为边界,允许单个员工整组略超目标值。
  53. */
  54. private static final int TARGET_APPROVAL_SUBTABLE_ROWS = 100;
  55. /**
  56. * 兜底部门主管 userId(Raymond,前端 v2.42 保持一致)
  57. */
  58. private static final String DEFAULT_MANAGER_ID = "634735129";
  59. // ===== 汇总表字段(与 ApprovalWriteBackService 一致) =====
  60. private static final String S_SUBMITTER = "employeeField_mmacxew4";
  61. private static final String S_SUBMITTER_ID = "textField_mmbffvd9";
  62. private static final String S_MONTH_TEXT = "textField_mmbffvda";
  63. private static final String S_DAY_TEXT = "textField_mmbffvdb";
  64. private static final String S_DAY_DATE = "dateField_mmbffvd8";
  65. private static final String S_DEPT_MANAGER = "employeeField_mh8xhqc3";
  66. // 汇总表 · Billable 子表
  67. private static final String S_BIL_TABLE = "tableField_mmczo634";
  68. private static final String S_BIL_PROJECT_NAME = "selectField_mmad6jci";
  69. private static final String S_BIL_PROJECT_NAME_TEXT = "textField_mmad6jcj";
  70. private static final String S_BIL_PROJECT_CODE = "textField_mmacxew3";
  71. private static final String S_BIL_MANAGER = "employeeField_mkoxpswf";
  72. private static final String S_BIL_SUBMITTED = "numberField_mmczo636";
  73. private static final String S_BIL_APPROVED = "numberField_mmczo637";
  74. private static final String S_BIL_CHANGE = "numberField_mmd80zae";
  75. // 汇总表 · Non Billable 子表
  76. private static final String S_NON_TABLE = "tableField_mmczo63h";
  77. private static final String S_NON_PROJECT_NAME = "selectField_mmczo638";
  78. private static final String S_NON_PROJECT_NAME_TEXT = "textField_mmczo63g";
  79. private static final String S_NON_PROJECT_CODE = "textField_mmczo639";
  80. private static final String S_NON_MANAGER = "employeeField_mmczo63a";
  81. private static final String S_NON_SUBMITTED = "numberField_mmczo63c";
  82. private static final String S_NON_APPROVED = "numberField_mmczo63d";
  83. private static final String S_NON_CHANGE = "numberField_mmeakgi9";
  84. // 汇总表 · 其他工时子表
  85. private static final String S_OTH_TABLE = "tableField_mmeakgid";
  86. private static final String S_OTH_ACTIVITY = "selectField_mmeakgie";
  87. private static final String S_OTH_SUBMITTED = "numberField_mmeakgij";
  88. private static final String S_OTH_APPROVED = "numberField_mmeakgii";
  89. private static final String S_OTH_CHANGE = "numberField_mmeakgim";
  90. // ===== 工时审批(APPROVAL) 主表字段 =====
  91. private static final String A_MONTH_DATE = "dateField_mmacxewf";
  92. private static final String A_MONTH_TEXT = "textField_mmd2lv0y";
  93. private static final String A_MANAGER = "employeeField_mkoxpswf";
  94. private static final String A_PROJECT_HOURS = "numberField_mmd2z155";
  95. private static final String A_NON_PROJECT_HOURS = "numberField_mmd2z156";
  96. private static final String A_TOTAL_HOURS = "numberField_mmd5b5gl";
  97. private static final String A_VIEWERS = "employeeField_moi9pvt1";
  98. // 工时审批 · Billable 明细子表
  99. private static final String A_BIL_TABLE = "tableField_mmae8t99";
  100. private static final String A_BIL_WORK_DATE = "dateField_mmbffvd8";
  101. private static final String A_BIL_SUBMITTER = "employeeField_mmacxew4";
  102. private static final String A_BIL_SUBMITTER_UID = "textField_mmd1ozk4";
  103. private static final String A_BIL_PROJECT_NAME = "textField_mkov5ozd";
  104. private static final String A_BIL_PROJECT_CODE = "textField_mmacxew3";
  105. private static final String A_BIL_HOURS = "numberField_mmacxew9";
  106. private static final String A_BIL_MONTH_TEXT = "textField_mmdbi2n2";
  107. private static final String A_BIL_DAY_TEXT = "textField_mmd8g654";
  108. private static final String A_BIL_OPINION = "radioField_mpwhk294";
  109. // 工时审批 · Non Billable 明细子表
  110. private static final String A_NON_TABLE = "tableField_mmd1wu9h";
  111. private static final String A_NON_WORK_DATE = "dateField_mmd2z150";
  112. private static final String A_NON_SUBMITTER = "employeeField_mmd1wu9b";
  113. private static final String A_NON_SUBMITTER_UID = "textField_mmd1wu9f";
  114. private static final String A_NON_PROJECT_NAME = "textField_mmd1wu9c";
  115. private static final String A_NON_PROJECT_CODE = "textField_mmd1wu9d";
  116. private static final String A_NON_HOURS = "numberField_mmd1wu9e";
  117. private static final String A_NON_MONTH_TEXT = "textField_mmdbi2n0";
  118. private static final String A_NON_DAY_TEXT = "textField_mmd8g655";
  119. private static final String A_NON_OPINION = "radioField_mpwhk295";
  120. // ===== 其他工时审批(OTHER_APPROVAL) 主表字段 =====
  121. private static final String O_MONTH_DATE = "dateField_mmacxewf";
  122. private static final String O_MONTH_TEXT = "textField_mmd2lv0y";
  123. private static final String O_DEPT_MANAGER = "employeeField_mh8xhqc3";
  124. private static final String O_TOTAL_HOURS = "numberField_mmd5b5gl";
  125. private static final String O_VIEWERS = "employeeField_moi9pvt1";
  126. // 其他工时审批 · 明细子表(与 Non Billable 同 tableField,但 activity 语义)
  127. private static final String O_SUB_TABLE = "tableField_mmd1wu9h";
  128. private static final String O_SUB_WORK_DATE = "dateField_mmd2z150";
  129. private static final String O_SUB_SUBMITTER = "employeeField_mmd1wu9b";
  130. private static final String O_SUB_SUBMITTER_UID = "textField_mmd1wu9f";
  131. private static final String O_SUB_ACTIVITY = "selectField_mmeakgie";
  132. private static final String O_SUB_HOURS = "numberField_mmd1wu9e";
  133. private static final String O_SUB_MONTH_TEXT = "textField_mmdbi2n0";
  134. private static final String O_SUB_DAY_TEXT = "textField_mmd8g655";
  135. private static final String O_SUB_OPINION = "radioField_mpwhk294";
  136. /**
  137. * 一条待发起的项目工时行(前端 collectApprovalData 的等价)
  138. */
  139. private static class BillRow {
  140. String dayText; // yyyyMMdd
  141. long dayTs; // epoch ms
  142. String projectCode;
  143. String projectName;
  144. double hours; // delta = submitted - approved - change
  145. String submitterUid;
  146. String monthText; // yyyyMM
  147. }
  148. /**
  149. * 一条待发起的其他工时行
  150. */
  151. private static class OtherRow {
  152. String dayText;
  153. long dayTs;
  154. String activity; // Annual Leave / Sick Leave / ...
  155. double hours;
  156. String submitterUid;
  157. String monthText;
  158. }
  159. /**
  160. * 审批覆盖索引:RUNNING 明细直接阻止重复补发;COMPLETED 同意明细在汇总尚未计入时归 Bug2。
  161. */
  162. static class ApprovalCoverage {
  163. Set<String> runningKeys = new HashSet<>();
  164. Map<String, Double> completedAgreeHours = new HashMap<>();
  165. boolean shouldSkip(String key, double accountedHours) {
  166. if (runningKeys.contains(key)) {
  167. return true;
  168. }
  169. return completedAgreeHours.getOrDefault(key, 0d) > accountedHours + 1e-6;
  170. }
  171. }
  172. /**
  173. * 某员工某月按 PM 聚合的项目工时组
  174. */
  175. private static class ManagerGroup {
  176. String managerId;
  177. List<BillRow> billableRows = new ArrayList<>();
  178. List<BillRow> nonBillableRows = new ArrayList<>();
  179. double billableTotal = 0;
  180. double nonBillableTotal = 0;
  181. }
  182. /**
  183. * 对指定月份、指定员工(可空)执行补发起
  184. *
  185. * @param monthText yyyyMM
  186. * @param submitterUserId 可空; 空则处理该月所有员工
  187. * @return 结果统计
  188. */
  189. public ReSubmitApprovalResult resubmit(String monthText, String submitterUserId) {
  190. McException.assertAccessException(StringUtils.isBlank(monthText), "月份不能为空");
  191. McException.assertAccessException(StringUtils.isBlank(whConf.getApprovalProcessCode()),
  192. "工时审批 processCode 未配置(workhours.approvalProcessCode)");
  193. McException.assertAccessException(StringUtils.isBlank(whConf.getOtherApprovalProcessCode()),
  194. "其他工时审批 processCode 未配置(workhours.otherApprovalProcessCode)");
  195. McException.assertAccessException(StringUtils.isBlank(whConf.getApprovalOriginatorUserId()),
  196. "审批补发起真人账号未配置(workhours.approvalOriginatorUserId)");
  197. // 1) 查汇总表:按月份 + 可选员工取全,是否待审批由子表 submitted-approved-change 实际差额判定。
  198. Map<String, Object> search = new HashMap<>();
  199. search.put(S_MONTH_TEXT, monthText);
  200. if (StringUtils.isNotBlank(submitterUserId)) {
  201. search.put(S_SUBMITTER_ID, submitterUserId);
  202. }
  203. List<Map> allRecords = queryAll(whConf.getFormUuidWorkHoursSummary(), search);
  204. ApprovalCoverage approvalCoverage = loadApprovalCoverage(monthText);
  205. // 2) 按 (submitterUid) 分组员工月度记录
  206. Map<String, List<Map>> perSubmitter = new LinkedHashMap<>();
  207. for (Map rec : allRecords) {
  208. Map fd = (Map) rec.get("formData");
  209. if (fd == null) {
  210. continue;
  211. }
  212. String uid = str(fd, S_SUBMITTER_ID);
  213. if (StringUtils.isBlank(uid)) {
  214. continue;
  215. }
  216. perSubmitter.computeIfAbsent(uid, k -> new ArrayList<>()).add(rec);
  217. }
  218. log.info("[补发起] 待处理员工数 month={} submitter={} count={}", monthText, submitterUserId, perSubmitter.size());
  219. int billableCreated = 0, billableUpdated = 0;
  220. int otherCreated = 0, otherUpdated = 0;
  221. int skipped = 0, failed = 0;
  222. // 3) 每个员工:构造 managerGroups + otherRows,处理 processManagerApproval / processOtherApproval
  223. for (Map.Entry<String, List<Map>> e : perSubmitter.entrySet()) {
  224. String uid = e.getKey();
  225. List<Map> records = e.getValue();
  226. try {
  227. CollectResult cr = collectApprovalData(uid, monthText, records, approvalCoverage);
  228. if (cr.managerGroups.isEmpty() && cr.otherRows.isEmpty()) {
  229. skipped++;
  230. continue;
  231. }
  232. // 3a) 项目工时:每个 PM 一张单
  233. for (ManagerGroup mg : cr.managerGroups.values()) {
  234. try {
  235. String action = processManagerApproval(mg, monthText, uid);
  236. if ("created".equals(action)) {
  237. billableCreated++;
  238. } else if ("updated".equals(action)) {
  239. billableUpdated++;
  240. }
  241. } catch (Exception ex) {
  242. failed++;
  243. log.error("[补发起] 项目工时发起失败 uid={} pm={} month={}", uid, mg.managerId, monthText, ex);
  244. }
  245. }
  246. // 3b) 其他工时:一人一张单,approver 用该员工部门主管(记录中带),否则兜底 Raymond
  247. if (!cr.otherRows.isEmpty()) {
  248. try {
  249. String action = processOtherApproval(cr.otherRows, cr.deptManagerId, monthText, uid);
  250. if ("created".equals(action)) {
  251. otherCreated++;
  252. } else if ("updated".equals(action)) {
  253. otherUpdated++;
  254. }
  255. } catch (Exception ex) {
  256. failed++;
  257. log.error("[补发起] 其他工时发起失败 uid={} deptMgr={} month={}", uid, cr.deptManagerId, monthText, ex);
  258. }
  259. }
  260. } catch (Exception ex) {
  261. failed++;
  262. log.error("[补发起] 员工整月处理失败 uid={} month={}", uid, monthText, ex);
  263. }
  264. }
  265. ReSubmitApprovalResult r = ReSubmitApprovalResult.builder()
  266. .monthText(monthText)
  267. .submitterCount(perSubmitter.size())
  268. .billableCreated(billableCreated)
  269. .billableUpdated(billableUpdated)
  270. .otherCreated(otherCreated)
  271. .otherUpdated(otherUpdated)
  272. .skipped(skipped)
  273. .failed(failed)
  274. .build();
  275. log.info("[补发起] 完成 {}", r);
  276. return r;
  277. }
  278. /**
  279. * 使用真人 System 账号发起 TimeCard 工时审批。
  280. *
  281. * @param request 发起审批请求
  282. * @return true 表示宜搭发起接口调用成功
  283. */
  284. public boolean startApproval(TimeCardApprovalStartRequest request) {
  285. McException.assertAccessException(request == null, "请求不能为空");
  286. McException.assertAccessException(
  287. StringUtils.isAnyBlank(
  288. request.getFormUuid(),
  289. request.getProcessCode(),
  290. request.getFormDataJson()),
  291. "审批表单、流程编码和表单数据不能为空");
  292. boolean billable = StringUtils.equals(whConf.getFormUuidApproval(), request.getFormUuid())
  293. && StringUtils.equals(whConf.getApprovalProcessCode(), request.getProcessCode());
  294. boolean other = StringUtils.equals(whConf.getFormUuidOtherApproval(), request.getFormUuid())
  295. && StringUtils.equals(whConf.getOtherApprovalProcessCode(), request.getProcessCode());
  296. McException.assertAccessException(!billable && !other, "不允许发起非TimeCard审批流程");
  297. McException.assertAccessException(
  298. StringUtils.isBlank(whConf.getApprovalOriginatorUserId()),
  299. "审批发起真人账号未配置(workhours.approvalOriginatorUserId)");
  300. Map<String, Object> formData = JSON.parseObject(
  301. request.getFormDataJson(), Map.class);
  302. startProcessInstance(
  303. request.getFormUuid(),
  304. request.getProcessCode(),
  305. formData,
  306. request.getDeptId());
  307. return true;
  308. }
  309. /**
  310. * collectApprovalData + collectOtherApprovalData 的等价:
  311. * 遍历该员工该月每天记录,按项目工时 / 其他工时反构造 delta 行,按 PM 分组
  312. */
  313. private static class CollectResult {
  314. Map<String, ManagerGroup> managerGroups = new LinkedHashMap<>();
  315. List<OtherRow> otherRows = new ArrayList<>();
  316. String deptManagerId;
  317. }
  318. private CollectResult collectApprovalData(String uid, String monthText, List<Map> records,
  319. ApprovalCoverage approvalCoverage) {
  320. CollectResult cr = new CollectResult();
  321. Set<String> seenBillableKeys = new HashSet<>();
  322. Set<String> seenNonBillableKeys = new HashSet<>();
  323. Set<String> seenOtherKeys = new HashSet<>();
  324. for (Map rec : records) {
  325. Map fd = (Map) rec.get("formData");
  326. if (fd == null) {
  327. continue;
  328. }
  329. String dayText = str(fd, S_DAY_TEXT);
  330. if (StringUtils.isBlank(dayText)) {
  331. continue;
  332. }
  333. long dayTs = numLong(fd, S_DAY_DATE);
  334. // 部门主管兜底
  335. if (StringUtils.isBlank(cr.deptManagerId)) {
  336. cr.deptManagerId = firstIdOrText(fd, S_DEPT_MANAGER);
  337. }
  338. // Billable 子表
  339. for (Map row : listOrEmpty(fd, S_BIL_TABLE)) {
  340. double submitted = num(row, S_BIL_SUBMITTED);
  341. double approved = num(row, S_BIL_APPROVED);
  342. double change = num(row, S_BIL_CHANGE);
  343. double delta = submitted - approved - change;
  344. if (delta <= 0) {
  345. continue;
  346. }
  347. String projectCode = str(row, S_BIL_PROJECT_CODE);
  348. String coverageKey = approvalKey("billable", uid, dayText, projectCode);
  349. if (!seenBillableKeys.add(coverageKey)
  350. || approvalCoverage.shouldSkip(coverageKey, approved + change)) {
  351. continue;
  352. }
  353. String pmRaw = firstIdOrText(row, S_BIL_MANAGER);
  354. final String pm = StringUtils.isBlank(pmRaw) ? DEFAULT_MANAGER_ID : pmRaw;
  355. ManagerGroup mg = cr.managerGroups.computeIfAbsent(pm, k -> {
  356. ManagerGroup m = new ManagerGroup();
  357. m.managerId = pm;
  358. return m;
  359. });
  360. BillRow br = new BillRow();
  361. br.dayText = dayText;
  362. br.dayTs = dayTs;
  363. br.projectCode = projectCode;
  364. br.projectName = firstNonBlank(str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME));
  365. br.hours = delta;
  366. br.submitterUid = uid;
  367. br.monthText = monthText;
  368. mg.billableRows.add(br);
  369. mg.billableTotal += delta;
  370. }
  371. // Non Billable 子表
  372. for (Map row : listOrEmpty(fd, S_NON_TABLE)) {
  373. double submitted = num(row, S_NON_SUBMITTED);
  374. double approved = num(row, S_NON_APPROVED);
  375. double change = num(row, S_NON_CHANGE);
  376. double delta = submitted - approved - change;
  377. if (delta <= 0) {
  378. continue;
  379. }
  380. String projectCode = str(row, S_NON_PROJECT_CODE);
  381. String coverageKey = approvalKey("nonBillable", uid, dayText, projectCode);
  382. if (!seenNonBillableKeys.add(coverageKey)
  383. || approvalCoverage.shouldSkip(coverageKey, approved + change)) {
  384. continue;
  385. }
  386. String pmRaw = firstIdOrText(row, S_NON_MANAGER);
  387. final String pm = StringUtils.isBlank(pmRaw) ? DEFAULT_MANAGER_ID : pmRaw;
  388. ManagerGroup mg = cr.managerGroups.computeIfAbsent(pm, k -> {
  389. ManagerGroup m = new ManagerGroup();
  390. m.managerId = pm;
  391. return m;
  392. });
  393. BillRow br = new BillRow();
  394. br.dayText = dayText;
  395. br.dayTs = dayTs;
  396. br.projectCode = projectCode;
  397. br.projectName = firstNonBlank(str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME));
  398. br.hours = delta;
  399. br.submitterUid = uid;
  400. br.monthText = monthText;
  401. mg.nonBillableRows.add(br);
  402. mg.nonBillableTotal += delta;
  403. }
  404. // 其他工时子表
  405. for (Map row : listOrEmpty(fd, S_OTH_TABLE)) {
  406. double submitted = num(row, S_OTH_SUBMITTED);
  407. double approved = num(row, S_OTH_APPROVED);
  408. double change = num(row, S_OTH_CHANGE);
  409. double delta = submitted - approved - change;
  410. if (delta <= 0) {
  411. continue;
  412. }
  413. String activity = str(row, S_OTH_ACTIVITY);
  414. String coverageKey = approvalKey("other", uid, dayText, activity);
  415. if (!seenOtherKeys.add(coverageKey)
  416. || approvalCoverage.shouldSkip(coverageKey, approved + change)) {
  417. continue;
  418. }
  419. OtherRow or = new OtherRow();
  420. or.dayText = dayText;
  421. or.dayTs = dayTs;
  422. or.activity = activity;
  423. or.hours = delta;
  424. or.submitterUid = uid;
  425. or.monthText = monthText;
  426. cr.otherRows.add(or);
  427. }
  428. }
  429. if (StringUtils.isBlank(cr.deptManagerId)) {
  430. cr.deptManagerId = DEFAULT_MANAGER_ID;
  431. }
  432. return cr;
  433. }
  434. /**
  435. * 加载指定月份两类审批单的完整明细覆盖索引。
  436. * fixme 流程列表内联子表恰好 50 行时必须 queryDetails 拉全,否则会把第 51 行以后误判为漏单。
  437. */
  438. private ApprovalCoverage loadApprovalCoverage(String monthText) {
  439. ApprovalCoverage coverage = new ApprovalCoverage();
  440. Map<String, Object> search = new HashMap<>();
  441. search.put(A_MONTH_TEXT, monthText);
  442. for (Map item : queryProcessAll(whConf.getFormUuidApproval(), search)) {
  443. collectProcessCoverage(item, false, coverage);
  444. }
  445. for (Map item : queryProcessAll(whConf.getFormUuidOtherApproval(), search)) {
  446. collectProcessCoverage(item, true, coverage);
  447. }
  448. log.info("[补发起] 审批覆盖索引 month={} runningKeys={} completedAgreeKeys={}",
  449. monthText, coverage.runningKeys.size(), coverage.completedAgreeHours.size());
  450. return coverage;
  451. }
  452. /**
  453. * 把单张审批单明细加入覆盖索引。
  454. */
  455. private void collectProcessCoverage(Map item, boolean isOther, ApprovalCoverage coverage) {
  456. String status = str(item, "instanceStatus").toUpperCase(Locale.ROOT);
  457. if (!"RUNNING".equals(status) && !"COMPLETED".equals(status)) {
  458. return;
  459. }
  460. String approvedResult = str(item, "approvedResult").toLowerCase(Locale.ROOT);
  461. String formInstanceId = firstNonBlank(
  462. str(item, "formInstanceId"),
  463. str(item, "processInstanceId"));
  464. Map formData = processFormData(item, formInstanceId);
  465. if (formData.isEmpty()) {
  466. return;
  467. }
  468. if (isOther) {
  469. collectCoverageRows(
  470. coverage,
  471. status,
  472. approvedResult,
  473. resolveFullRows(
  474. formInstanceId,
  475. whConf.getFormUuidOtherApproval(),
  476. O_SUB_TABLE,
  477. (List<Map>) formData.get(O_SUB_TABLE)),
  478. "other",
  479. O_SUB_SUBMITTER_UID,
  480. O_SUB_DAY_TEXT,
  481. O_SUB_ACTIVITY,
  482. O_SUB_HOURS,
  483. O_SUB_OPINION);
  484. return;
  485. }
  486. collectCoverageRows(
  487. coverage,
  488. status,
  489. approvedResult,
  490. resolveFullRows(
  491. formInstanceId,
  492. whConf.getFormUuidApproval(),
  493. A_BIL_TABLE,
  494. (List<Map>) formData.get(A_BIL_TABLE)),
  495. "billable",
  496. A_BIL_SUBMITTER_UID,
  497. A_BIL_DAY_TEXT,
  498. A_BIL_PROJECT_CODE,
  499. A_BIL_HOURS,
  500. A_BIL_OPINION);
  501. collectCoverageRows(
  502. coverage,
  503. status,
  504. approvedResult,
  505. resolveFullRows(
  506. formInstanceId,
  507. whConf.getFormUuidApproval(),
  508. A_NON_TABLE,
  509. (List<Map>) formData.get(A_NON_TABLE)),
  510. "nonBillable",
  511. A_NON_SUBMITTER_UID,
  512. A_NON_DAY_TEXT,
  513. A_NON_PROJECT_CODE,
  514. A_NON_HOURS,
  515. A_NON_OPINION);
  516. }
  517. /**
  518. * 归一化一类审批子表行并写入覆盖索引。
  519. */
  520. private void collectCoverageRows(ApprovalCoverage coverage,
  521. String status,
  522. String approvedResult,
  523. List<Map> rows,
  524. String category,
  525. String uidField,
  526. String dayField,
  527. String detailKeyField,
  528. String hoursField,
  529. String opinionField) {
  530. for (Map row : rows) {
  531. String uid = str(row, uidField);
  532. String day = str(row, dayField);
  533. String detailKey = str(row, detailKeyField);
  534. if (StringUtils.isAnyBlank(uid, day, detailKey)) {
  535. continue;
  536. }
  537. String key = approvalKey(category, uid, day, detailKey);
  538. if ("RUNNING".equals(status)) {
  539. coverage.runningKeys.add(key);
  540. continue;
  541. }
  542. if (isCompletedApprovedRow(
  543. status,
  544. approvedResult,
  545. str(row, opinionField))) {
  546. coverage.completedAgreeHours.merge(key, num(row, hoursField), Math::max);
  547. }
  548. }
  549. }
  550. static boolean isCompletedApprovedRow(String status,
  551. String approvedResult,
  552. String opinion) {
  553. return "COMPLETED".equals(status)
  554. && "agree".equals(approvedResult)
  555. && (StringUtils.isBlank(opinion) || APPROVAL_OPINION_AGREE.equals(opinion));
  556. }
  557. /**
  558. * 获取流程列表项中的表单数据;列表不带数据时回查表单详情。
  559. */
  560. private Map processFormData(Map item, String formInstanceId) {
  561. Object formData = item.get("formData");
  562. if (!(formData instanceof Map)) {
  563. formData = item.get("data");
  564. }
  565. if (formData instanceof Map && !((Map) formData).isEmpty()) {
  566. return (Map) formData;
  567. }
  568. if (StringUtils.isBlank(formInstanceId)) {
  569. return Collections.emptyMap();
  570. }
  571. Map detail = ydClient.queryData(YDParam.builder()
  572. .appType(whConf.getYidaAppType())
  573. .systemToken(whConf.getYidaSystemToken())
  574. .formInstanceId(formInstanceId)
  575. .build(), YDConf.FORM_QUERY.retrieve_id).getFormData();
  576. return detail != null ? detail : Collections.emptyMap();
  577. }
  578. /**
  579. * 子表内联小于 50 行直接使用,恰好 50 行时递归拉全。
  580. */
  581. private List<Map> resolveFullRows(String formInstanceId,
  582. String formUuid,
  583. String tableFieldId,
  584. List<Map> inlineRows) {
  585. if (inlineRows == null) {
  586. return Collections.emptyList();
  587. }
  588. if (inlineRows.size() < 50) {
  589. return inlineRows;
  590. }
  591. List<Map> fullRows = ydService.queryDetails(YDParam.builder()
  592. .appType(whConf.getYidaAppType())
  593. .systemToken(whConf.getYidaSystemToken())
  594. .formUuid(formUuid)
  595. .formInstanceId(formInstanceId)
  596. .tableFieldId(tableFieldId)
  597. .pageNumber(1)
  598. .build());
  599. return fullRows != null ? fullRows : Collections.emptyList();
  600. }
  601. /**
  602. * 审批明细唯一键。
  603. */
  604. static String approvalKey(String category, String uid, String day, String detailKey) {
  605. return category + "|" + uid + "|" + day + "|" + detailKey;
  606. }
  607. /**
  608. * processManagerApproval 等价 — queryProcess(RUNNING) 有则 update / 无则 create。
  609. * COMPLETED 覆盖已在 collectApprovalData 前过滤,避免历史已完成单被重复发起。
  610. */
  611. private String processManagerApproval(ManagerGroup mg, String monthText, String submitterUid) {
  612. Map<String, Object> searchFieldJson = new HashMap<>();
  613. searchFieldJson.put(A_MONTH_TEXT, monthText);
  614. // fixme employeeField 查询用数组: 单值 [uid]
  615. searchFieldJson.put(A_MANAGER, Collections.singletonList(mg.managerId));
  616. List<Map> running = queryProcessRunning(whConf.getFormUuidApproval(), searchFieldJson);
  617. // 优先追加到最新分片,避免每名员工都先扫描已满 500 行的旧审批单。
  618. running.sort(Comparator.comparingLong(
  619. (Map item) -> numLong(item, "gmtModified")).reversed());
  620. for (Map item : running) {
  621. String pid = str(item, "processInstanceId");
  622. String finstId = firstNonBlank(str(item, "formInstanceId"), pid);
  623. Map existingFormData = processFormData(item, finstId);
  624. if (!managerApprovalHasCapacity(finstId, mg, submitterUid, monthText, existingFormData)) {
  625. continue;
  626. }
  627. updateApprovalInstance(finstId, mg, monthText, submitterUid, existingFormData);
  628. return "updated";
  629. }
  630. // fixme: 单张审批子表达到 500 行后必须新建分片,继续覆盖会丢失第 501 行后的明细。
  631. createApprovalInstance(mg, monthText, submitterUid);
  632. return "created";
  633. }
  634. private boolean managerApprovalHasCapacity(String formInstanceId,
  635. ManagerGroup mg,
  636. String submitterUid,
  637. String monthText,
  638. Map existingFormData) {
  639. List<Map> existBil = resolveFullRows(
  640. formInstanceId,
  641. whConf.getFormUuidApproval(),
  642. A_BIL_TABLE,
  643. (List<Map>) (existingFormData == null ? null : existingFormData.get(A_BIL_TABLE)));
  644. List<Map> existNon = resolveFullRows(
  645. formInstanceId,
  646. whConf.getFormUuidApproval(),
  647. A_NON_TABLE,
  648. (List<Map>) (existingFormData == null ? null : existingFormData.get(A_NON_TABLE)));
  649. MergeResult billable = mergeSubRows(
  650. existBil, mg.billableRows, submitterUid, monthText,
  651. A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE,
  652. br -> buildBillableApprovalRow(br, submitterUid));
  653. MergeResult nonBillable = mergeSubRows(
  654. existNon, mg.nonBillableRows, submitterUid, monthText,
  655. A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE,
  656. br -> buildNonBillableApprovalRow(br, submitterUid));
  657. int existingBillableCount = countUniqueRows(
  658. existBil, A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE);
  659. int existingNonBillableCount = countUniqueRows(
  660. existNon, A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE);
  661. return canAppendToApprovalShard(existingBillableCount, billable.rows.size())
  662. && canAppendToApprovalShard(existingNonBillableCount, nonBillable.rows.size());
  663. }
  664. /**
  665. * 创建新的工时审批实例(startProcessInstance)
  666. */
  667. private void createApprovalInstance(ManagerGroup mg, String monthText, String submitterUid) {
  668. McException.assertAccessException(
  669. !hasSubtableCapacity(mg.billableRows.size())
  670. || !hasSubtableCapacity(mg.nonBillableRows.size()),
  671. "单个员工的审批明细超过宜搭子表 500 行限制");
  672. Map<String, Object> formData = new HashMap<>();
  673. formData.put(A_MONTH_DATE, monthToTs(monthText));
  674. formData.put(A_MONTH_TEXT, monthText);
  675. formData.put(A_MANAGER, Collections.singletonList(mg.managerId));
  676. formData.put(A_PROJECT_HOURS, mg.billableTotal);
  677. formData.put(A_NON_PROJECT_HOURS, mg.nonBillableTotal);
  678. formData.put(A_TOTAL_HOURS, mg.billableTotal + mg.nonBillableTotal);
  679. formData.put(A_VIEWERS, Collections.singletonList(submitterUid));
  680. List<Map<String, Object>> billableSub = new ArrayList<>();
  681. for (BillRow br : mg.billableRows) {
  682. billableSub.add(buildBillableApprovalRow(br, submitterUid));
  683. }
  684. List<Map<String, Object>> nonBillableSub = new ArrayList<>();
  685. for (BillRow br : mg.nonBillableRows) {
  686. nonBillableSub.add(buildNonBillableApprovalRow(br, submitterUid));
  687. }
  688. if (!billableSub.isEmpty()) {
  689. formData.put(A_BIL_TABLE, billableSub);
  690. }
  691. if (!nonBillableSub.isEmpty()) {
  692. formData.put(A_NON_TABLE, nonBillableSub);
  693. }
  694. startProcessInstance(
  695. whConf.getFormUuidApproval(),
  696. whConf.getApprovalProcessCode(),
  697. formData,
  698. whConf.getApprovalOriginatorDeptId());
  699. log.info("[补发起] 新建工时审批单 submitterUid={} pm={} month={} bilTotal={} nonBilTotal={} rows={}+{}",
  700. submitterUid, mg.managerId, monthText,
  701. mg.billableTotal, mg.nonBillableTotal, billableSub.size(), nonBillableSub.size());
  702. }
  703. /**
  704. * 累计更新已有 RUNNING 工时审批单(按唯一键 userId + dayText + projectCode 去重合并)
  705. */
  706. private void updateApprovalInstance(String formInstanceId, ManagerGroup mg,
  707. String monthText, String submitterUid, Map existingFormData) {
  708. List<Map> existBil = resolveFullRows(
  709. formInstanceId,
  710. whConf.getFormUuidApproval(),
  711. A_BIL_TABLE,
  712. (List<Map>) (existingFormData == null ? null : existingFormData.get(A_BIL_TABLE)));
  713. List<Map> existNon = resolveFullRows(
  714. formInstanceId,
  715. whConf.getFormUuidApproval(),
  716. A_NON_TABLE,
  717. (List<Map>) (existingFormData == null ? null : existingFormData.get(A_NON_TABLE)));
  718. MergeResult mrBil = mergeSubRows(existBil, mg.billableRows, submitterUid, monthText,
  719. A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE,
  720. (br) -> buildBillableApprovalRow(br, submitterUid));
  721. MergeResult mrNon = mergeSubRows(existNon, mg.nonBillableRows, submitterUid, monthText,
  722. A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE,
  723. (br) -> buildNonBillableApprovalRow(br, submitterUid));
  724. if (mrBil.addedHours == 0 && mrNon.addedHours == 0) {
  725. log.info("[补发起] 已存在等效明细,跳过更新 formInstanceId={}", formInstanceId);
  726. return;
  727. }
  728. double existProject = num(existingFormData, A_PROJECT_HOURS);
  729. double existNonProject = num(existingFormData, A_NON_PROJECT_HOURS);
  730. double newProject = existProject + mrBil.addedHours;
  731. double newNonProject = existNonProject + mrNon.addedHours;
  732. Map<String, Object> updateData = new HashMap<>();
  733. updateData.put(A_PROJECT_HOURS, newProject);
  734. updateData.put(A_NON_PROJECT_HOURS, newNonProject);
  735. updateData.put(A_TOTAL_HOURS, newProject + newNonProject);
  736. if (!mrBil.rows.isEmpty()) {
  737. updateData.put(A_BIL_TABLE, mrBil.rows);
  738. }
  739. if (!mrNon.rows.isEmpty()) {
  740. updateData.put(A_NON_TABLE, mrNon.rows);
  741. }
  742. // Viewers 去重(原有 + 本次申请人)
  743. Set<String> viewerSet = new LinkedHashSet<>();
  744. Object existViewersRaw = existingFormData == null ? null : existingFormData.get(A_VIEWERS + "_id");
  745. if (existViewersRaw instanceof List) {
  746. for (Object v : (List<?>) existViewersRaw) {
  747. if (v != null) {
  748. viewerSet.add(String.valueOf(v));
  749. }
  750. }
  751. }
  752. // 从原有子表行提取填报人
  753. for (Map r : new ArrayList<Map>(existBil == null ? Collections.emptyList() : existBil)) {
  754. String u = str(r, A_BIL_SUBMITTER_UID);
  755. if (StringUtils.isNotBlank(u)) {
  756. viewerSet.add(u);
  757. }
  758. }
  759. for (Map r : new ArrayList<Map>(existNon == null ? Collections.emptyList() : existNon)) {
  760. String u = str(r, A_NON_SUBMITTER_UID);
  761. if (StringUtils.isNotBlank(u)) {
  762. viewerSet.add(u);
  763. }
  764. }
  765. if (StringUtils.isNotBlank(submitterUid)) {
  766. viewerSet.add(submitterUid);
  767. }
  768. updateData.put(A_VIEWERS, new ArrayList<>(viewerSet));
  769. ydClient.operateData(YDParam.builder()
  770. .appType(whConf.getYidaAppType())
  771. .systemToken(whConf.getYidaSystemToken())
  772. .formInstanceId(formInstanceId)
  773. .updateFormDataJson(JSON.toJSONString(updateData))
  774. .ignoreEmpty(false)
  775. .useLatestVersion(true)
  776. .build(), YDConf.FORM_OPERATION.update);
  777. log.info("[补发起] 累计更新工时审批单 formInstanceId={} pm={} addedBil={} addedNon={}",
  778. formInstanceId, mg.managerId, mrBil.addedHours, mrNon.addedHours);
  779. }
  780. /**
  781. * 其他工时审批处理(与 processManagerApproval 同构,但 approver 为员工的部门主管)
  782. */
  783. private String processOtherApproval(List<OtherRow> otherRows, String deptManagerId,
  784. String monthText, String submitterUid) {
  785. Map<String, Object> searchFieldJson = new HashMap<>();
  786. searchFieldJson.put(O_MONTH_TEXT, monthText);
  787. searchFieldJson.put(O_DEPT_MANAGER, Collections.singletonList(deptManagerId));
  788. List<Map> running = queryProcessRunning(whConf.getFormUuidOtherApproval(), searchFieldJson);
  789. running.sort(Comparator.comparingLong(
  790. (Map item) -> numLong(item, "gmtModified")).reversed());
  791. for (Map item : running) {
  792. String pid = str(item, "processInstanceId");
  793. String finstId = firstNonBlank(str(item, "formInstanceId"), pid);
  794. Map existingFormData = processFormData(item, finstId);
  795. if (!otherApprovalHasCapacity(finstId, otherRows, submitterUid, existingFormData)) {
  796. continue;
  797. }
  798. updateOtherApprovalInstance(finstId, otherRows, monthText, submitterUid, existingFormData);
  799. return "updated";
  800. }
  801. createOtherApprovalInstance(otherRows, deptManagerId, monthText, submitterUid);
  802. return "created";
  803. }
  804. private boolean otherApprovalHasCapacity(String formInstanceId,
  805. List<OtherRow> otherRows,
  806. String submitterUid,
  807. Map existingFormData) {
  808. List<Map> existing = resolveFullRows(
  809. formInstanceId,
  810. whConf.getFormUuidOtherApproval(),
  811. O_SUB_TABLE,
  812. (List<Map>) (existingFormData == null ? null : existingFormData.get(O_SUB_TABLE)));
  813. Set<String> keys = new HashSet<>();
  814. for (Map row : existing) {
  815. keys.add(str(row, O_SUB_SUBMITTER_UID) + "|"
  816. + str(row, O_SUB_DAY_TEXT) + "|"
  817. + str(row, O_SUB_ACTIVITY));
  818. }
  819. int existingCount = keys.size();
  820. for (OtherRow row : otherRows) {
  821. keys.add(submitterUid + "|" + row.dayText + "|" + row.activity);
  822. }
  823. return canAppendToApprovalShard(existingCount, keys.size());
  824. }
  825. private void createOtherApprovalInstance(List<OtherRow> otherRows, String deptManagerId,
  826. String monthText, String submitterUid) {
  827. McException.assertAccessException(
  828. !hasSubtableCapacity(otherRows.size()),
  829. "单个员工的其他工时审批明细超过宜搭子表 500 行限制");
  830. double total = 0;
  831. List<Map<String, Object>> sub = new ArrayList<>();
  832. for (OtherRow or : otherRows) {
  833. sub.add(buildOtherApprovalRow(or, submitterUid));
  834. total += or.hours;
  835. }
  836. Map<String, Object> formData = new HashMap<>();
  837. formData.put(O_MONTH_DATE, monthToTs(monthText));
  838. formData.put(O_MONTH_TEXT, monthText);
  839. formData.put(O_DEPT_MANAGER, Collections.singletonList(deptManagerId));
  840. formData.put(O_TOTAL_HOURS, total);
  841. formData.put(O_VIEWERS, Collections.singletonList(submitterUid));
  842. if (!sub.isEmpty()) {
  843. formData.put(O_SUB_TABLE, sub);
  844. }
  845. startProcessInstance(
  846. whConf.getFormUuidOtherApproval(),
  847. whConf.getOtherApprovalProcessCode(),
  848. formData,
  849. whConf.getApprovalOriginatorDeptId());
  850. log.info("[补发起] 新建其他工时审批单 submitterUid={} deptMgr={} month={} total={} rows={}",
  851. submitterUid, deptManagerId, monthText, total, sub.size());
  852. }
  853. private void updateOtherApprovalInstance(String formInstanceId, List<OtherRow> otherRows,
  854. String monthText, String submitterUid, Map existingFormData) {
  855. List<Map> existRows = resolveFullRows(
  856. formInstanceId,
  857. whConf.getFormUuidOtherApproval(),
  858. O_SUB_TABLE,
  859. (List<Map>) (existingFormData == null ? null : existingFormData.get(O_SUB_TABLE)));
  860. // 唯一键: submitterUid + dayText + activity
  861. Set<String> existingKeys = new HashSet<>();
  862. List<Map<String, Object>> merged = new ArrayList<>();
  863. if (existRows != null) {
  864. for (Map row : existRows) {
  865. String uid = str(row, O_SUB_SUBMITTER_UID);
  866. String day = str(row, O_SUB_DAY_TEXT);
  867. String act = str(row, O_SUB_ACTIVITY);
  868. existingKeys.add(uid + "|" + day + "|" + act);
  869. merged.add(rebuildRow(row, O_SUB_SUBMITTER));
  870. }
  871. }
  872. double addedHours = 0;
  873. for (OtherRow or : otherRows) {
  874. String key = submitterUid + "|" + or.dayText + "|" + or.activity;
  875. if (existingKeys.contains(key)) {
  876. continue;
  877. }
  878. merged.add(buildOtherApprovalRow(or, submitterUid));
  879. addedHours += or.hours;
  880. }
  881. if (addedHours == 0) {
  882. log.info("[补发起] 其他工时无新增明细 formInstanceId={}", formInstanceId);
  883. return;
  884. }
  885. double existTotal = num(existingFormData, O_TOTAL_HOURS);
  886. Map<String, Object> updateData = new HashMap<>();
  887. updateData.put(O_TOTAL_HOURS, existTotal + addedHours);
  888. updateData.put(O_SUB_TABLE, merged);
  889. Set<String> viewerSet = new LinkedHashSet<>();
  890. Object existOViewersRaw = existingFormData == null ? null : existingFormData.get(O_VIEWERS + "_id");
  891. if (existOViewersRaw instanceof List) {
  892. for (Object v : (List<?>) existOViewersRaw) {
  893. if (v != null) {
  894. viewerSet.add(String.valueOf(v));
  895. }
  896. }
  897. }
  898. if (existRows != null) {
  899. for (Map r : existRows) {
  900. String u = str(r, O_SUB_SUBMITTER_UID);
  901. if (StringUtils.isNotBlank(u)) {
  902. viewerSet.add(u);
  903. }
  904. }
  905. }
  906. if (StringUtils.isNotBlank(submitterUid)) {
  907. viewerSet.add(submitterUid);
  908. }
  909. updateData.put(O_VIEWERS, new ArrayList<>(viewerSet));
  910. ydClient.operateData(YDParam.builder()
  911. .appType(whConf.getYidaAppType())
  912. .systemToken(whConf.getYidaSystemToken())
  913. .formInstanceId(formInstanceId)
  914. .updateFormDataJson(JSON.toJSONString(updateData))
  915. .ignoreEmpty(false)
  916. .useLatestVersion(true)
  917. .build(), YDConf.FORM_OPERATION.update);
  918. log.info("[补发起] 累计更新其他工时审批单 formInstanceId={} addedHours={}", formInstanceId, addedHours);
  919. }
  920. // ============================================================
  921. // 子表行构造 + 合并 helper
  922. // ============================================================
  923. private Map<String, Object> buildBillableApprovalRow(BillRow br, String submitterUid) {
  924. Map<String, Object> r = new HashMap<>();
  925. r.put(A_BIL_WORK_DATE, br.dayTs);
  926. r.put(A_BIL_SUBMITTER, Collections.singletonList(submitterUid));
  927. r.put(A_BIL_SUBMITTER_UID, submitterUid);
  928. r.put(A_BIL_PROJECT_CODE, br.projectCode);
  929. r.put(A_BIL_PROJECT_NAME, br.projectName);
  930. r.put(A_BIL_HOURS, br.hours);
  931. r.put(A_BIL_MONTH_TEXT, br.monthText);
  932. r.put(A_BIL_DAY_TEXT, br.dayText);
  933. r.put(A_BIL_OPINION, APPROVAL_OPINION_AGREE);
  934. return r;
  935. }
  936. private Map<String, Object> buildNonBillableApprovalRow(BillRow br, String submitterUid) {
  937. Map<String, Object> r = new HashMap<>();
  938. r.put(A_NON_WORK_DATE, br.dayTs);
  939. r.put(A_NON_SUBMITTER, Collections.singletonList(submitterUid));
  940. r.put(A_NON_SUBMITTER_UID, submitterUid);
  941. r.put(A_NON_PROJECT_CODE, br.projectCode);
  942. r.put(A_NON_PROJECT_NAME, br.projectName);
  943. r.put(A_NON_HOURS, br.hours);
  944. r.put(A_NON_MONTH_TEXT, br.monthText);
  945. r.put(A_NON_DAY_TEXT, br.dayText);
  946. r.put(A_NON_OPINION, APPROVAL_OPINION_AGREE);
  947. return r;
  948. }
  949. private Map<String, Object> buildOtherApprovalRow(OtherRow or, String submitterUid) {
  950. Map<String, Object> r = new HashMap<>();
  951. r.put(O_SUB_WORK_DATE, or.dayTs);
  952. r.put(O_SUB_SUBMITTER, Collections.singletonList(submitterUid));
  953. r.put(O_SUB_SUBMITTER_UID, submitterUid);
  954. r.put(O_SUB_ACTIVITY, or.activity);
  955. r.put(O_SUB_HOURS, or.hours);
  956. r.put(O_SUB_MONTH_TEXT, or.monthText);
  957. r.put(O_SUB_DAY_TEXT, or.dayText);
  958. r.put(O_SUB_OPINION, APPROVAL_OPINION_AGREE);
  959. return r;
  960. }
  961. private interface RowBuilder<T> {
  962. Map<String, Object> build(T row);
  963. }
  964. private static class MergeResult {
  965. List<Map<String, Object>> rows = new ArrayList<>();
  966. double addedHours = 0;
  967. }
  968. static boolean hasSubtableCapacity(int rowCount) {
  969. return rowCount <= MAX_APPROVAL_SUBTABLE_ROWS;
  970. }
  971. static boolean canAppendToApprovalShard(int existingCount, int mergedCount) {
  972. if (!hasSubtableCapacity(mergedCount)) {
  973. return false;
  974. }
  975. // 已有等效明细允许幂等命中;空分片允许承载一个员工的完整明细组。
  976. return mergedCount == existingCount
  977. || existingCount == 0
  978. || mergedCount <= TARGET_APPROVAL_SUBTABLE_ROWS;
  979. }
  980. private int countUniqueRows(List<Map> rows,
  981. String uidField,
  982. String dayField,
  983. String keyField) {
  984. Set<String> keys = new HashSet<>();
  985. for (Map row : rows) {
  986. keys.add(str(row, uidField) + "|"
  987. + str(row, dayField) + "|"
  988. + str(row, keyField));
  989. }
  990. return keys.size();
  991. }
  992. /**
  993. * mergeApprovalSubRows 等价:唯一键 uid + dayText + projectCode/activity,已存在跳过
  994. * 保留原行时用 rebuildRow 修正 EmployeeField 主键(_id 后缀 → 主键)
  995. */
  996. private MergeResult mergeSubRows(List<Map> existing, List<BillRow> newRows, String submitterUid, String monthText,
  997. String uidField, String dayField, String keyField, RowBuilder<BillRow> builder) {
  998. MergeResult r = new MergeResult();
  999. Set<String> keys = new HashSet<>();
  1000. if (existing != null) {
  1001. for (Map row : existing) {
  1002. String uid = str(row, uidField);
  1003. String day = str(row, dayField);
  1004. String key = str(row, keyField);
  1005. if (!keys.add(uid + "|" + day + "|" + key)) {
  1006. continue;
  1007. }
  1008. r.rows.add(rebuildRow(row, uidField.equals(A_BIL_SUBMITTER_UID) ? A_BIL_SUBMITTER : A_NON_SUBMITTER));
  1009. }
  1010. }
  1011. for (BillRow br : newRows) {
  1012. String key = submitterUid + "|" + br.dayText + "|" + br.projectCode;
  1013. if (!keys.add(key)) {
  1014. continue;
  1015. }
  1016. r.rows.add(builder.build(br));
  1017. r.addedHours += br.hours;
  1018. }
  1019. return r;
  1020. }
  1021. /**
  1022. * 子表行"重建":成员字段用 _id 后缀真实 userId 替换显示名(否则宜搭静默清空)
  1023. * fixme 前端 v2.32 已确认这个坑 — 后端全量覆盖写子表时同规则
  1024. */
  1025. private Map<String, Object> rebuildRow(Map row, String submitterField) {
  1026. Map<String, Object> nr = new HashMap<>();
  1027. for (Object k : row.keySet()) {
  1028. String key = String.valueOf(k);
  1029. if (key.endsWith("_value")) {
  1030. continue;
  1031. }
  1032. if (key.startsWith("employeeField_") && !key.endsWith("_id")) {
  1033. continue;
  1034. }
  1035. if (key.endsWith("_id") && (key.startsWith("employeeField_") || key.startsWith("associationFormField_"))) {
  1036. nr.put(key.substring(0, key.length() - 3), row.get(k));
  1037. continue;
  1038. }
  1039. nr.put(key, row.get(k));
  1040. }
  1041. return nr;
  1042. }
  1043. // ============================================================
  1044. // 宜搭 API 封装
  1045. // ============================================================
  1046. private void startProcessInstance(String formUuid,
  1047. String processCode,
  1048. Map<String, Object> formData,
  1049. String deptId) {
  1050. // fixme 必须用真人 System 账号发起,yida_pub_account 不触发钉钉待办通知。
  1051. ydClient.operateData(YDParam.builder()
  1052. .appType(whConf.getYidaAppType())
  1053. .systemToken(whConf.getYidaSystemToken())
  1054. .userId(whConf.getApprovalOriginatorUserId())
  1055. .deptId(StringUtils.defaultIfBlank(deptId, whConf.getApprovalOriginatorDeptId()))
  1056. .formUuid(formUuid)
  1057. .processCode(processCode)
  1058. .formDataJson(JSON.toJSONString(formData))
  1059. .build(), YDConf.FORM_OPERATION.start);
  1060. }
  1061. /**
  1062. * 查该月 + PM/deptManager + RUNNING 的审批单;分页取全
  1063. */
  1064. private List<Map> queryProcessRunning(String formUuid, Map<String, Object> searchFieldJson) {
  1065. List<Map> all = new ArrayList<>();
  1066. int page = 1;
  1067. while (page <= 20) {
  1068. DDR_New r = ydClient.queryData(YDParam.builder()
  1069. .appType(whConf.getYidaAppType())
  1070. .systemToken(whConf.getYidaSystemToken())
  1071. .formUuid(formUuid)
  1072. .searchFieldJson(JSON.toJSONString(searchFieldJson))
  1073. .instanceStatus("RUNNING")
  1074. .pageNumber(page)
  1075. .pageSize(100)
  1076. .build(), YDConf.FORM_QUERY.retrieve_search_process);
  1077. List<Map> data = (List<Map>) r.getData();
  1078. if (data == null || data.isEmpty()) {
  1079. break;
  1080. }
  1081. all.addAll(data);
  1082. if (data.size() < 100) {
  1083. break;
  1084. }
  1085. page++;
  1086. }
  1087. return all;
  1088. }
  1089. /**
  1090. * 查指定条件下全部状态的审批单,供补发起前构建覆盖索引。
  1091. */
  1092. private List<Map> queryProcessAll(String formUuid, Map<String, Object> searchFieldJson) {
  1093. List<Map> all = new ArrayList<>();
  1094. int page = 1;
  1095. while (page <= 100) {
  1096. DDR_New r = ydClient.queryData(YDParam.builder()
  1097. .appType(whConf.getYidaAppType())
  1098. .systemToken(whConf.getYidaSystemToken())
  1099. .formUuid(formUuid)
  1100. .searchFieldJson(JSON.toJSONString(searchFieldJson))
  1101. .pageNumber(page)
  1102. .pageSize(100)
  1103. .build(), YDConf.FORM_QUERY.retrieve_search_process);
  1104. List<Map> data = (List<Map>) r.getData();
  1105. if (data == null || data.isEmpty()) {
  1106. break;
  1107. }
  1108. all.addAll(data);
  1109. if (data.size() < 100) {
  1110. break;
  1111. }
  1112. page++;
  1113. }
  1114. return all;
  1115. }
  1116. /**
  1117. * 汇总表分页取全
  1118. */
  1119. List<Map> queryAll(String formUuid, Map<String, Object> searchFieldJson) {
  1120. List<Map> all = new ArrayList<>();
  1121. int page = 1;
  1122. while (page <= 200) {
  1123. DDR_New r = ydClient.queryData(YDParam.builder()
  1124. .appType(whConf.getYidaAppType())
  1125. .systemToken(whConf.getYidaSystemToken())
  1126. .formUuid(formUuid)
  1127. .searchFieldJson(JSON.toJSONString(searchFieldJson))
  1128. .currentPage(page)
  1129. .pageSize(100)
  1130. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  1131. List<Map> data = (List<Map>) r.getData();
  1132. if (data == null || data.isEmpty()) {
  1133. break;
  1134. }
  1135. all.addAll(data);
  1136. if (data.size() < 100) {
  1137. break;
  1138. }
  1139. page++;
  1140. }
  1141. return all;
  1142. }
  1143. // ============================================================
  1144. // 通用工具
  1145. // ============================================================
  1146. @SuppressWarnings("unchecked")
  1147. private List<Map> listOrEmpty(Map m, String k) {
  1148. if (m == null) {
  1149. return Collections.emptyList();
  1150. }
  1151. Object v = m.get(k);
  1152. if (v instanceof List) {
  1153. return (List<Map>) v;
  1154. }
  1155. return Collections.emptyList();
  1156. }
  1157. private double num(Map m, String k) {
  1158. Object v = m == null ? null : m.get(k);
  1159. if (v == null || "".equals(v)) {
  1160. return 0d;
  1161. }
  1162. try {
  1163. return Double.parseDouble(String.valueOf(v));
  1164. } catch (NumberFormatException e) {
  1165. return 0d;
  1166. }
  1167. }
  1168. private long numLong(Map m, String k) {
  1169. return (long) num(m, k);
  1170. }
  1171. private String str(Map m, String k) {
  1172. Object v = m == null ? null : m.get(k);
  1173. return v == null ? "" : String.valueOf(v);
  1174. }
  1175. /**
  1176. * 从 EmployeeField 值(数组)取第一个 userId;取不到时兜底返回文本
  1177. */
  1178. private String firstIdOrText(Map m, String fieldPrefix) {
  1179. Object idArr = m == null ? null : m.get(fieldPrefix + "_id");
  1180. if (idArr instanceof List && !((List) idArr).isEmpty()) {
  1181. Object first = ((List) idArr).get(0);
  1182. if (first != null) {
  1183. return String.valueOf(first);
  1184. }
  1185. }
  1186. Object nameArr = m == null ? null : m.get(fieldPrefix);
  1187. if (nameArr instanceof List && !((List) nameArr).isEmpty()) {
  1188. Object first = ((List) nameArr).get(0);
  1189. if (first != null) {
  1190. return String.valueOf(first);
  1191. }
  1192. }
  1193. return "";
  1194. }
  1195. private String firstNonBlank(String... vals) {
  1196. for (String v : vals) {
  1197. if (StringUtils.isNotBlank(v)) {
  1198. return v;
  1199. }
  1200. }
  1201. return "";
  1202. }
  1203. /**
  1204. * yyyyMM → epoch ms(该月 1 号 00:00)
  1205. */
  1206. private long monthToTs(String monthText) {
  1207. int y = Integer.parseInt(monthText.substring(0, 4));
  1208. int m = Integer.parseInt(monthText.substring(4, 6));
  1209. Calendar c = Calendar.getInstance();
  1210. c.set(y, m - 1, 1, 0, 0, 0);
  1211. c.set(Calendar.MILLISECOND, 0);
  1212. return c.getTimeInMillis();
  1213. }
  1214. }