ReSubmitApprovalService.java 82 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899
  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.service.dingtalk.DDClient;
  13. import com.malk.service.dingtalk.DDClient_Contacts;
  14. import com.malk.utils.UtilMap;
  15. import lombok.extern.slf4j.Slf4j;
  16. import org.apache.commons.lang3.StringUtils;
  17. import org.springframework.beans.factory.annotation.Autowired;
  18. import org.springframework.stereotype.Service;
  19. import java.time.LocalDate;
  20. import java.time.format.DateTimeFormatter;
  21. import java.util.*;
  22. /**
  23. * 补发起工时审批(bug 1 兜底)
  24. * <p>
  25. * 场景:汇总表 submitted > 0 但对应 (userId, day) 未出现在任何该月工时审批 / 其他工时审批的子表明细中,
  26. * 即前端 submitApprovalProcess 链路失败(startInstance 挂了/静默丢),导致部分员工工时从未进入审批。
  27. * <p>
  28. * 逻辑完全参考前端 TimeCard 的 submitApprovalProcess:
  29. * 1. collectApprovalData — 遍历汇总表每天子表,delta = submitted - approved - change, delta>0 才 push;
  30. * 普通 PM 按审批人分组,Raymond 兜底按项目拆组
  31. * 2. processManagerApproval — queryProcess(month + PM + RUNNING) 有同项目可用分片则 update / 无则 create
  32. * COMPLETED 明细先进入覆盖索引;汇总尚未落值时归回写处理,不重复发起
  33. * 3. collectOtherApprovalData / processOtherApproval — 其他工时按当前员工的部门主管作 approver,同样 query→update/create
  34. * <p>
  35. * 发起人 userId 必须用真人钉钉账号(建议 System),不能用 yida_pub_account(不触发钉钉待办通知)
  36. * <p>
  37. * 设计文档:后端/阿科德斯/审批链路补发起.md
  38. */
  39. @Slf4j
  40. @Service
  41. public class ReSubmitApprovalService {
  42. @Autowired
  43. private YDClient ydClient;
  44. @Autowired
  45. private YDService ydService;
  46. @Autowired
  47. private WHConf whConf;
  48. @Autowired
  49. private DDClient ddClient;
  50. @Autowired
  51. private DDClient_Contacts ddClient_contacts;
  52. /**
  53. * 前端 v2.35 常量:发起时明细「审批意见」默认「同意」
  54. */
  55. private static final String APPROVAL_OPINION_AGREE = "同意";
  56. private static final String SUMMARY_STATUS_IN_APPROVAL = "审批中";
  57. private static final String SUMMARY_STATUS_DRAFT = "暂存";
  58. private static final String SUMMARY_STATUS_APPROVED = "已审批";
  59. private static final String S_BIL_SUBMITTED_TOTAL = "numberField_mmacxewa";
  60. private static final String S_BIL_APPROVED_TOTAL = "numberField_mmacxewg";
  61. private static final String S_NON_SUBMITTED_TOTAL = "numberField_mmad6jbz";
  62. private static final String S_NON_APPROVED_TOTAL = "numberField_mmad6jc1";
  63. private static final String S_OTHER_SUBMITTED_TOTAL = "numberField_mmd1smeq";
  64. private static final String S_OTHER_APPROVED_TOTAL = "numberField_mmd1smes";
  65. /**
  66. * 宜搭单个子表组件可稳定查询和覆盖更新的最大行数。
  67. */
  68. private static final int MAX_APPROVAL_SUBTABLE_ROWS = 500;
  69. /**
  70. * 批量历史补偿的目标分片大小;以员工整组为边界,允许单个员工整组略超目标值。
  71. */
  72. private static final int TARGET_APPROVAL_SUBTABLE_ROWS = 300;
  73. /**
  74. * 兜底部门主管 userId(Raymond,前端 v2.42 保持一致)
  75. */
  76. private static final String DEFAULT_MANAGER_ID = "634735129";
  77. private static final String PERSONNEL_EMPLOYEE = "employeeField_mkow4ydp";
  78. private static final String PERSONNEL_MANAGER = "employeeField_mh8xhqc3";
  79. // ===== 汇总表字段(与 ApprovalWriteBackService 一致) =====
  80. private static final String S_SUBMITTER = "employeeField_mmacxew4";
  81. private static final String S_SUBMITTER_ID = "textField_mmbffvd9";
  82. private static final String S_MONTH_TEXT = "textField_mmbffvda";
  83. private static final String S_DAY_TEXT = "textField_mmbffvdb";
  84. private static final String S_DAY_DATE = "dateField_mmbffvd8";
  85. private static final String S_DEPT_MANAGER = "employeeField_mh8xhqc3";
  86. // 汇总表 · Billable 子表
  87. private static final String S_BIL_TABLE = "tableField_mmczo634";
  88. private static final String S_BIL_PROJECT_NAME = "selectField_mmad6jci";
  89. private static final String S_BIL_PROJECT_NAME_TEXT = "textField_mmad6jcj";
  90. private static final String S_BIL_PROJECT_CODE = "textField_mmacxew3";
  91. private static final String S_BIL_MANAGER = "employeeField_mkoxpswf";
  92. private static final String S_BIL_SUBMITTED = "numberField_mmczo636";
  93. private static final String S_BIL_APPROVED = "numberField_mmczo637";
  94. private static final String S_BIL_CHANGE = "numberField_mmd80zae";
  95. // 汇总表 · Non Billable 子表
  96. private static final String S_NON_TABLE = "tableField_mmczo63h";
  97. private static final String S_NON_PROJECT_NAME = "selectField_mmczo638";
  98. private static final String S_NON_PROJECT_NAME_TEXT = "textField_mmczo63g";
  99. private static final String S_NON_PROJECT_CODE = "textField_mmczo639";
  100. private static final String S_NON_MANAGER = "employeeField_mmczo63a";
  101. private static final String S_NON_SUBMITTED = "numberField_mmczo63c";
  102. private static final String S_NON_APPROVED = "numberField_mmczo63d";
  103. private static final String S_NON_CHANGE = "numberField_mmeakgi9";
  104. // 汇总表 · 其他工时子表
  105. private static final String S_OTH_TABLE = "tableField_mmeakgid";
  106. private static final String S_OTH_ACTIVITY = "selectField_mmeakgie";
  107. private static final String S_OTH_SUBMITTED = "numberField_mmeakgij";
  108. private static final String S_OTH_APPROVED = "numberField_mmeakgii";
  109. private static final String S_OTH_CHANGE = "numberField_mmeakgim";
  110. // ===== 工时审批(APPROVAL) 主表字段 =====
  111. private static final String A_MONTH_DATE = "dateField_mmacxewf";
  112. private static final String A_MONTH_TEXT = "textField_mmd2lv0y";
  113. private static final String A_MANAGER = "employeeField_mkoxpswf";
  114. private static final String A_PROJECT_HOURS = "numberField_mmd2z155";
  115. private static final String A_NON_PROJECT_HOURS = "numberField_mmd2z156";
  116. private static final String A_TOTAL_HOURS = "numberField_mmd5b5gl";
  117. private static final String A_VIEWERS = "employeeField_moi9pvt1";
  118. // 工时审批 · Billable 明细子表
  119. private static final String A_BIL_TABLE = "tableField_mmae8t99";
  120. private static final String A_BIL_WORK_DATE = "dateField_mmbffvd8";
  121. private static final String A_BIL_SUBMITTER = "employeeField_mmacxew4";
  122. private static final String A_BIL_SUBMITTER_UID = "textField_mmd1ozk4";
  123. private static final String A_BIL_PROJECT_NAME = "textField_mkov5ozd";
  124. private static final String A_BIL_PROJECT_CODE = "textField_mmacxew3";
  125. private static final String A_BIL_HOURS = "numberField_mmacxew9";
  126. private static final String A_BIL_MONTH_TEXT = "textField_mmdbi2n2";
  127. private static final String A_BIL_DAY_TEXT = "textField_mmd8g654";
  128. private static final String A_BIL_OPINION = "radioField_mpwhk294";
  129. // 工时审批 · Non Billable 明细子表
  130. private static final String A_NON_TABLE = "tableField_mmd1wu9h";
  131. private static final String A_NON_WORK_DATE = "dateField_mmd2z150";
  132. private static final String A_NON_SUBMITTER = "employeeField_mmd1wu9b";
  133. private static final String A_NON_SUBMITTER_UID = "textField_mmd1wu9f";
  134. private static final String A_NON_PROJECT_NAME = "textField_mmd1wu9c";
  135. private static final String A_NON_PROJECT_CODE = "textField_mmd1wu9d";
  136. private static final String A_NON_HOURS = "numberField_mmd1wu9e";
  137. private static final String A_NON_MONTH_TEXT = "textField_mmdbi2n0";
  138. private static final String A_NON_DAY_TEXT = "textField_mmd8g655";
  139. private static final String A_NON_OPINION = "radioField_mpwhk295";
  140. // ===== 其他工时审批(OTHER_APPROVAL) 主表字段 =====
  141. private static final String O_MONTH_DATE = "dateField_mmacxewf";
  142. private static final String O_MONTH_TEXT = "textField_mmd2lv0y";
  143. private static final String O_DEPT_MANAGER = "employeeField_mh8xhqc3";
  144. private static final String O_TOTAL_HOURS = "numberField_mmd5b5gl";
  145. private static final String O_VIEWERS = "employeeField_moi9pvt1";
  146. // 其他工时审批 · 明细子表(与 Non Billable 同 tableField,但 activity 语义)
  147. private static final String O_SUB_TABLE = "tableField_mmd1wu9h";
  148. private static final String O_SUB_WORK_DATE = "dateField_mmd2z150";
  149. private static final String O_SUB_SUBMITTER = "employeeField_mmd1wu9b";
  150. private static final String O_SUB_SUBMITTER_UID = "textField_mmd1wu9f";
  151. private static final String O_SUB_ACTIVITY = "selectField_mmeakgie";
  152. private static final String O_SUB_HOURS = "numberField_mmd1wu9e";
  153. private static final String O_SUB_MONTH_TEXT = "textField_mmdbi2n0";
  154. private static final String O_SUB_DAY_TEXT = "textField_mmd8g655";
  155. private static final String O_SUB_OPINION = "radioField_mpwhk294";
  156. /**
  157. * 一条待发起的项目工时行(前端 collectApprovalData 的等价)
  158. */
  159. private static class BillRow {
  160. String dayText; // yyyyMMdd
  161. long dayTs; // epoch ms
  162. String projectCode;
  163. String projectName;
  164. double hours; // delta = submitted - approved - change
  165. String submitterUid;
  166. String monthText; // yyyyMM
  167. }
  168. /**
  169. * 一条待发起的其他工时行
  170. */
  171. private static class OtherRow {
  172. String dayText;
  173. long dayTs;
  174. String activity; // Annual Leave / Sick Leave / ...
  175. double hours;
  176. String submitterUid;
  177. String monthText;
  178. }
  179. /**
  180. * 审批覆盖索引:RUNNING 明细直接阻止重复补发;COMPLETED 同意明细在汇总尚未计入时归 Bug2。
  181. */
  182. static class ApprovalCoverage {
  183. Set<String> runningKeys = new HashSet<>();
  184. Map<String, Double> completedAgreeHours = new HashMap<>();
  185. boolean shouldSkip(String key, double accountedHours) {
  186. if (runningKeys.contains(key)) {
  187. return true;
  188. }
  189. return completedAgreeHours.getOrDefault(key, 0d) > accountedHours + 1e-6;
  190. }
  191. }
  192. /**
  193. * 某员工某月的项目工时组;Raymond 兜底组带项目标识做项目隔离
  194. */
  195. private static class ManagerGroup {
  196. String managerId;
  197. String projectCode;
  198. String projectName;
  199. List<BillRow> billableRows = new ArrayList<>();
  200. List<BillRow> nonBillableRows = new ArrayList<>();
  201. double billableTotal = 0;
  202. double nonBillableTotal = 0;
  203. }
  204. /**
  205. * 对指定月份、指定员工(可空)执行补发起
  206. *
  207. * @param monthText yyyyMM
  208. * @param submitterUserId 可空; 空则处理该月所有员工
  209. * @return 结果统计
  210. */
  211. public ReSubmitApprovalResult resubmit(String monthText, String submitterUserId) {
  212. return resubmit(monthText, submitterUserId, false);
  213. }
  214. /**
  215. * 对指定月份、指定员工执行补发起或只读预演。
  216. *
  217. * @param monthText yyyyMM
  218. * @param submitterUserId 可空;空则处理该月所有员工
  219. * @param dryRun true 时只统计候选,不创建或更新审批
  220. * @return 执行或预演统计
  221. */
  222. public ReSubmitApprovalResult resubmit(String monthText,
  223. String submitterUserId,
  224. boolean dryRun) {
  225. McException.assertAccessException(StringUtils.isBlank(monthText), "月份不能为空");
  226. McException.assertAccessException(StringUtils.isBlank(whConf.getApprovalProcessCode()),
  227. "工时审批 processCode 未配置(workhours.approvalProcessCode)");
  228. McException.assertAccessException(StringUtils.isBlank(whConf.getOtherApprovalProcessCode()),
  229. "其他工时审批 processCode 未配置(workhours.otherApprovalProcessCode)");
  230. McException.assertAccessException(StringUtils.isBlank(whConf.getApprovalOriginatorUserId()),
  231. "审批补发起真人账号未配置(workhours.approvalOriginatorUserId)");
  232. // 1) 查汇总表:按月份 + 可选员工取全,是否待审批由子表 submitted-approved-change 实际差额判定。
  233. Map<String, Object> search = new HashMap<>();
  234. search.put(S_MONTH_TEXT, monthText);
  235. if (StringUtils.isNotBlank(submitterUserId)) {
  236. search.put(S_SUBMITTER_ID, submitterUserId);
  237. }
  238. List<Map> allRecords = queryAll(whConf.getFormUuidWorkHoursSummary(), search);
  239. ApprovalCoverage approvalCoverage = loadApprovalCoverage(monthText);
  240. Map<String, String> projectManagerCache = new HashMap<>();
  241. // 2) 按 (submitterUid) 分组员工月度记录
  242. Map<String, List<Map>> perSubmitter = new LinkedHashMap<>();
  243. for (Map rec : allRecords) {
  244. Map fd = (Map) rec.get("formData");
  245. if (fd == null) {
  246. continue;
  247. }
  248. String uid = str(fd, S_SUBMITTER_ID);
  249. if (StringUtils.isBlank(uid)) {
  250. continue;
  251. }
  252. perSubmitter.computeIfAbsent(uid, k -> new ArrayList<>()).add(rec);
  253. }
  254. log.info("[补发起] 待处理员工数 month={} submitter={} count={}", monthText, submitterUserId, perSubmitter.size());
  255. int billableCreated = 0, billableUpdated = 0;
  256. int otherCreated = 0, otherUpdated = 0;
  257. int skipped = 0, failed = 0;
  258. int billableCandidates = 0, otherCandidates = 0;
  259. double billableCandidateHours = 0, otherCandidateHours = 0;
  260. // 3) 每个员工:构造 managerGroups + otherRows,处理 processManagerApproval / processOtherApproval
  261. for (Map.Entry<String, List<Map>> e : perSubmitter.entrySet()) {
  262. String uid = e.getKey();
  263. List<Map> records = e.getValue();
  264. try {
  265. CollectResult cr = collectApprovalData(
  266. uid, monthText, records, approvalCoverage, projectManagerCache);
  267. if (cr.managerGroups.isEmpty() && cr.otherRows.isEmpty()) {
  268. skipped++;
  269. continue;
  270. }
  271. // 3a) 项目工时:普通 PM 按人合单,Raymond 兜底按项目拆单
  272. for (ManagerGroup mg : cr.managerGroups.values()) {
  273. billableCandidates += mg.billableRows.size() + mg.nonBillableRows.size();
  274. billableCandidateHours += mg.billableTotal + mg.nonBillableTotal;
  275. if (dryRun) {
  276. continue;
  277. }
  278. try {
  279. List<String> actions = processManagerApproval(mg, monthText, uid);
  280. for (String action : actions) {
  281. if ("created".equals(action)) {
  282. billableCreated++;
  283. } else if ("updated".equals(action)) {
  284. billableUpdated++;
  285. }
  286. }
  287. } catch (Exception ex) {
  288. failed++;
  289. log.error("[补发起] 项目工时发起失败 uid={} pm={} project={} month={}",
  290. uid, mg.managerId, mg.projectCode, monthText, ex);
  291. }
  292. }
  293. // 3b) 其他工时:一人一张单,approver 用该员工部门主管(记录中带),否则兜底 Raymond
  294. if (!cr.otherRows.isEmpty()) {
  295. otherCandidates += cr.otherRows.size();
  296. for (OtherRow row : cr.otherRows) {
  297. otherCandidateHours += row.hours;
  298. }
  299. if (dryRun) {
  300. continue;
  301. }
  302. try {
  303. List<String> actions = processOtherApproval(
  304. cr.otherRows, cr.deptManagerId, monthText, uid);
  305. for (String action : actions) {
  306. if ("created".equals(action)) {
  307. otherCreated++;
  308. } else if ("updated".equals(action)) {
  309. otherUpdated++;
  310. }
  311. }
  312. } catch (Exception ex) {
  313. failed++;
  314. log.error("[补发起] 其他工时发起失败 uid={} deptMgr={} month={}", uid, cr.deptManagerId, monthText, ex);
  315. }
  316. }
  317. } catch (Exception ex) {
  318. failed++;
  319. log.error("[补发起] 员工整月处理失败 uid={} month={}", uid, monthText, ex);
  320. }
  321. }
  322. // 4) 三档状态按汇总总额统一校正;覆盖无缺单但状态为空/错误的历史记录。
  323. StatusSyncResult statusSync = reconcileSummaryApprovalStatuses(allRecords, dryRun);
  324. ReSubmitApprovalResult r = ReSubmitApprovalResult.builder()
  325. .monthText(monthText)
  326. .submitterCount(perSubmitter.size())
  327. .dryRun(dryRun)
  328. .billableCandidates(billableCandidates)
  329. .billableCandidateHours(billableCandidateHours)
  330. .otherCandidates(otherCandidates)
  331. .otherCandidateHours(otherCandidateHours)
  332. .billableCreated(billableCreated)
  333. .billableUpdated(billableUpdated)
  334. .otherCreated(otherCreated)
  335. .otherUpdated(otherUpdated)
  336. .skipped(skipped)
  337. .failed(failed)
  338. .summaryStatusCandidates(statusSync.candidates)
  339. .summaryStatusUpdated(statusSync.updated)
  340. .summaryStatusFailed(statusSync.failed)
  341. .build();
  342. log.info("[补发起] 完成 {}", r);
  343. return r;
  344. }
  345. /**
  346. * 查询最近若干自然日发生修改的汇总记录,并按实际业务月份归集员工。
  347. * <p>
  348. * prd 定时兜底只跟踪增量修改,不按最近若干业务月份全量回扫;因此历史月份记录只要近期有变化也会命中。
  349. *
  350. * @param lookbackDays 最近自然日数量,最小按 1 天处理
  351. * @return monthText -&gt; submitterUserId 集合
  352. */
  353. @SuppressWarnings("unchecked")
  354. public Map<String, Set<String>> findRecentlyModifiedTargets(int lookbackDays) {
  355. LocalDate today = LocalDate.now();
  356. String[] window = modifiedDateWindow(lookbackDays, today);
  357. List<Map> records = queryAllModified(
  358. whConf.getFormUuidWorkHoursSummary(), window[0], window[1]);
  359. Map<String, Set<String>> targets = new TreeMap<>();
  360. for (Map record : records) {
  361. Object rawFormData = record.get("formData");
  362. if (!(rawFormData instanceof Map)) {
  363. continue;
  364. }
  365. Map<String, Object> formData = (Map<String, Object>) rawFormData;
  366. String monthText = str(formData, S_MONTH_TEXT);
  367. String submitterUserId = str(formData, S_SUBMITTER_ID);
  368. if (!monthText.matches("\\d{6}") || StringUtils.isBlank(submitterUserId)) {
  369. continue;
  370. }
  371. targets.computeIfAbsent(monthText, key -> new LinkedHashSet<>()).add(submitterUserId);
  372. }
  373. log.info("[补发起兜底] 增量扫描 from={} to={} records={} months={} targets={}",
  374. window[0], window[1], records.size(), targets.size(),
  375. targets.values().stream().mapToInt(Set::size).sum());
  376. return targets;
  377. }
  378. /**
  379. * 计算最近 N 个自然日的宜搭修改时间查询窗口,结束日加 1 以覆盖当天全天。
  380. */
  381. static String[] modifiedDateWindow(int lookbackDays, LocalDate today) {
  382. int safeDays = Math.max(1, lookbackDays);
  383. DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
  384. LocalDate fromDate = today.minusDays(safeDays - 1L);
  385. LocalDate toDate = today.plusDays(1L);
  386. return new String[]{fromDate.format(formatter), toDate.format(formatter)};
  387. }
  388. /**
  389. * 使用真人 System 账号发起 TimeCard 工时审批。
  390. *
  391. * @param request 发起审批请求
  392. * @return true 表示宜搭发起接口调用成功
  393. */
  394. public boolean startApproval(TimeCardApprovalStartRequest request) {
  395. McException.assertAccessException(request == null, "请求不能为空");
  396. McException.assertAccessException(
  397. StringUtils.isAnyBlank(
  398. request.getFormUuid(),
  399. request.getProcessCode(),
  400. request.getFormDataJson()),
  401. "审批表单、流程编码和表单数据不能为空");
  402. boolean billable = StringUtils.equals(whConf.getFormUuidApproval(), request.getFormUuid())
  403. && StringUtils.equals(whConf.getApprovalProcessCode(), request.getProcessCode());
  404. boolean other = StringUtils.equals(whConf.getFormUuidOtherApproval(), request.getFormUuid())
  405. && StringUtils.equals(whConf.getOtherApprovalProcessCode(), request.getProcessCode());
  406. McException.assertAccessException(!billable && !other, "不允许发起非TimeCard审批流程");
  407. McException.assertAccessException(
  408. StringUtils.isBlank(whConf.getApprovalOriginatorUserId()),
  409. "审批发起真人账号未配置(workhours.approvalOriginatorUserId)");
  410. Map<String, Object> formData = JSON.parseObject(
  411. request.getFormDataJson(), Map.class);
  412. List<Map<String, Object>> shards = billable
  413. ? splitApprovalStartFormData(formData)
  414. : splitOtherApprovalStartFormData(formData);
  415. for (Map<String, Object> shard : shards) {
  416. startProcessInstance(
  417. request.getFormUuid(),
  418. request.getProcessCode(),
  419. shard,
  420. request.getDeptId());
  421. }
  422. return true;
  423. }
  424. private List<Map<String, Object>> splitApprovalStartFormData(Map<String, Object> formData) {
  425. List<Map> billableRows = listOrEmpty(formData, A_BIL_TABLE);
  426. List<Map> nonBillableRows = listOrEmpty(formData, A_NON_TABLE);
  427. List<RowShard> rowShards = splitRowsByHardLimit(billableRows, nonBillableRows);
  428. List<Map<String, Object>> result = new ArrayList<>();
  429. for (RowShard rowShard : rowShards) {
  430. Map<String, Object> shard = new HashMap<>(formData);
  431. shard.remove(A_BIL_TABLE);
  432. shard.remove(A_NON_TABLE);
  433. putRowsIfPresent(shard, A_BIL_TABLE, rowShard.firstRows);
  434. putRowsIfPresent(shard, A_NON_TABLE, rowShard.secondRows);
  435. double billableHours = sumHours(rowShard.firstRows, A_BIL_HOURS);
  436. double nonBillableHours = sumHours(rowShard.secondRows, A_NON_HOURS);
  437. shard.put(A_PROJECT_HOURS, billableHours);
  438. shard.put(A_NON_PROJECT_HOURS, nonBillableHours);
  439. shard.put(A_TOTAL_HOURS, billableHours + nonBillableHours);
  440. result.add(shard);
  441. }
  442. return result;
  443. }
  444. private List<Map<String, Object>> splitOtherApprovalStartFormData(Map<String, Object> formData) {
  445. List<Map> rows = listOrEmpty(formData, O_SUB_TABLE);
  446. List<RowShard> rowShards = splitRowsByHardLimit(rows, Collections.emptyList());
  447. List<Map<String, Object>> result = new ArrayList<>();
  448. for (RowShard rowShard : rowShards) {
  449. Map<String, Object> shard = new HashMap<>(formData);
  450. shard.remove(O_SUB_TABLE);
  451. putRowsIfPresent(shard, O_SUB_TABLE, rowShard.firstRows);
  452. shard.put(O_TOTAL_HOURS, sumHours(rowShard.firstRows, O_SUB_HOURS));
  453. result.add(shard);
  454. }
  455. return result;
  456. }
  457. private void putRowsIfPresent(Map<String, Object> formData,
  458. String tableFieldId,
  459. List<Map> rows) {
  460. if (!rows.isEmpty()) {
  461. formData.put(tableFieldId, rows);
  462. }
  463. }
  464. private double sumHours(List<Map> rows, String hoursFieldId) {
  465. double total = 0;
  466. for (Map row : rows) {
  467. total += num(row, hoursFieldId);
  468. }
  469. return total;
  470. }
  471. /**
  472. * collectApprovalData + collectOtherApprovalData 的等价:
  473. * 遍历该员工该月每天记录,按项目工时 / 其他工时反构造 delta 行。
  474. * 普通 PM 按审批人分组,Raymond 兜底按项目拆组;项目编号为空时使用项目名称。
  475. */
  476. private static class CollectResult {
  477. Map<String, ManagerGroup> managerGroups = new LinkedHashMap<>();
  478. List<OtherRow> otherRows = new ArrayList<>();
  479. String deptManagerId;
  480. }
  481. private static class StatusSyncResult {
  482. int candidates;
  483. int updated;
  484. int failed;
  485. }
  486. /**
  487. * 按主表提交/审批总额把状态统一校正为暂存、审批中或已审批。
  488. */
  489. @SuppressWarnings("unchecked")
  490. private StatusSyncResult reconcileSummaryApprovalStatuses(List<Map> records,
  491. boolean dryRun) {
  492. StatusSyncResult result = new StatusSyncResult();
  493. String fieldId = whConf.getSummaryApprovalStatusField();
  494. if (StringUtils.isBlank(fieldId)) {
  495. return result;
  496. }
  497. for (Map record : records) {
  498. Object rawFormData = record.get("formData");
  499. if (!(rawFormData instanceof Map)) {
  500. continue;
  501. }
  502. Map<String, Object> formData = (Map<String, Object>) rawFormData;
  503. String targetStatus = expectedSummaryApprovalStatus(formData);
  504. String currentStatus = str(formData, fieldId);
  505. String formInstanceId = firstNonBlank(
  506. str(record, "formInstanceId"),
  507. str(record, "formInstId"));
  508. if (StringUtils.isBlank(formInstanceId) || targetStatus.equals(currentStatus)) {
  509. continue;
  510. }
  511. result.candidates++;
  512. if (dryRun) {
  513. continue;
  514. }
  515. try {
  516. Map<String, Object> updateData = new HashMap<>();
  517. updateData.put(fieldId, targetStatus);
  518. ydClient.operateData(YDParam.builder()
  519. .appType(whConf.getYidaAppType())
  520. .systemToken(whConf.getYidaSystemToken())
  521. .formInstanceId(formInstanceId)
  522. .updateFormDataJson(JSON.toJSONString(updateData))
  523. .ignoreEmpty(false)
  524. .useLatestVersion(true)
  525. .build(), YDConf.FORM_OPERATION.update);
  526. result.updated++;
  527. } catch (Exception ex) {
  528. result.failed++;
  529. log.error("[补发起] 汇总三档状态校正失败 formInstanceId={} target={}",
  530. formInstanceId, targetStatus, ex);
  531. }
  532. }
  533. return result;
  534. }
  535. /**
  536. * 根据汇总主表三类已提交/已审批总额计算状态。
  537. */
  538. static String expectedSummaryApprovalStatus(Map<String, Object> formData) {
  539. double submitted = num(formData, S_BIL_SUBMITTED_TOTAL)
  540. + num(formData, S_NON_SUBMITTED_TOTAL)
  541. + num(formData, S_OTHER_SUBMITTED_TOTAL);
  542. double approved = num(formData, S_BIL_APPROVED_TOTAL)
  543. + num(formData, S_NON_APPROVED_TOTAL)
  544. + num(formData, S_OTHER_APPROVED_TOTAL);
  545. if (submitted > approved + 1e-6) {
  546. return SUMMARY_STATUS_IN_APPROVAL;
  547. }
  548. if (approved > 1e-6) {
  549. return SUMMARY_STATUS_APPROVED;
  550. }
  551. if (submitted > 1e-6) {
  552. return SUMMARY_STATUS_IN_APPROVAL;
  553. }
  554. return SUMMARY_STATUS_DRAFT;
  555. }
  556. private CollectResult collectApprovalData(String uid,
  557. String monthText,
  558. List<Map> records,
  559. ApprovalCoverage approvalCoverage,
  560. Map<String, String> projectManagerCache) {
  561. CollectResult cr = new CollectResult();
  562. Set<String> seenBillableKeys = new HashSet<>();
  563. Set<String> seenNonBillableKeys = new HashSet<>();
  564. Set<String> seenOtherKeys = new HashSet<>();
  565. for (Map rec : records) {
  566. Map fd = (Map) rec.get("formData");
  567. if (fd == null) {
  568. continue;
  569. }
  570. String dayText = str(fd, S_DAY_TEXT);
  571. if (StringUtils.isBlank(dayText)) {
  572. continue;
  573. }
  574. long dayTs = numLong(fd, S_DAY_DATE);
  575. // 部门主管兜底
  576. if (StringUtils.isBlank(cr.deptManagerId)) {
  577. cr.deptManagerId = firstIdOrText(fd, S_DEPT_MANAGER);
  578. }
  579. // Billable 子表
  580. for (Map row : listOrEmpty(fd, S_BIL_TABLE)) {
  581. double submitted = num(row, S_BIL_SUBMITTED);
  582. double approved = num(row, S_BIL_APPROVED);
  583. double change = num(row, S_BIL_CHANGE);
  584. double delta = submitted - approved - change;
  585. if (delta <= 0) {
  586. continue;
  587. }
  588. String projectCode = str(row, S_BIL_PROJECT_CODE);
  589. String projectName = firstNonBlank(
  590. str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME));
  591. String coverageKey = approvalKey("billable", uid, dayText,
  592. approvalDetailKey(projectCode, projectName));
  593. if (!seenBillableKeys.add(coverageKey)
  594. || approvalCoverage.shouldSkip(coverageKey, approved + change)) {
  595. continue;
  596. }
  597. String pmRaw = firstIdOrText(row, S_BIL_MANAGER);
  598. final String pm = projectManagerCache.computeIfAbsent(
  599. StringUtils.defaultString(pmRaw), this::resolveProjectManager);
  600. String groupKey = approvalGroupKey(pm, projectCode, projectName);
  601. ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> {
  602. ManagerGroup m = new ManagerGroup();
  603. m.managerId = pm;
  604. m.projectCode = DEFAULT_MANAGER_ID.equals(pm) ? projectCode : "";
  605. m.projectName = DEFAULT_MANAGER_ID.equals(pm) ? projectName : "";
  606. return m;
  607. });
  608. BillRow br = new BillRow();
  609. br.dayText = dayText;
  610. br.dayTs = dayTs;
  611. br.projectCode = projectCode;
  612. br.projectName = projectName;
  613. br.hours = delta;
  614. br.submitterUid = uid;
  615. br.monthText = monthText;
  616. mg.billableRows.add(br);
  617. mg.billableTotal += delta;
  618. }
  619. // Non Billable 子表
  620. for (Map row : listOrEmpty(fd, S_NON_TABLE)) {
  621. double submitted = num(row, S_NON_SUBMITTED);
  622. double approved = num(row, S_NON_APPROVED);
  623. double change = num(row, S_NON_CHANGE);
  624. double delta = submitted - approved - change;
  625. if (delta <= 0) {
  626. continue;
  627. }
  628. String projectCode = str(row, S_NON_PROJECT_CODE);
  629. String projectName = firstNonBlank(
  630. str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME));
  631. String coverageKey = approvalKey("nonBillable", uid, dayText,
  632. approvalDetailKey(projectCode, projectName));
  633. if (!seenNonBillableKeys.add(coverageKey)
  634. || approvalCoverage.shouldSkip(coverageKey, approved + change)) {
  635. continue;
  636. }
  637. String pmRaw = firstIdOrText(row, S_NON_MANAGER);
  638. final String pm = projectManagerCache.computeIfAbsent(
  639. StringUtils.defaultString(pmRaw), this::resolveProjectManager);
  640. String groupKey = approvalGroupKey(pm, projectCode, projectName);
  641. ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> {
  642. ManagerGroup m = new ManagerGroup();
  643. m.managerId = pm;
  644. m.projectCode = DEFAULT_MANAGER_ID.equals(pm) ? projectCode : "";
  645. m.projectName = DEFAULT_MANAGER_ID.equals(pm) ? projectName : "";
  646. return m;
  647. });
  648. BillRow br = new BillRow();
  649. br.dayText = dayText;
  650. br.dayTs = dayTs;
  651. br.projectCode = projectCode;
  652. br.projectName = projectName;
  653. br.hours = delta;
  654. br.submitterUid = uid;
  655. br.monthText = monthText;
  656. mg.nonBillableRows.add(br);
  657. mg.nonBillableTotal += delta;
  658. }
  659. // 其他工时子表
  660. for (Map row : listOrEmpty(fd, S_OTH_TABLE)) {
  661. double submitted = num(row, S_OTH_SUBMITTED);
  662. double approved = num(row, S_OTH_APPROVED);
  663. double change = num(row, S_OTH_CHANGE);
  664. double delta = submitted - approved - change;
  665. if (delta <= 0) {
  666. continue;
  667. }
  668. String activity = str(row, S_OTH_ACTIVITY);
  669. String coverageKey = approvalKey("other", uid, dayText,
  670. approvalDetailKey(activity, ""));
  671. if (!seenOtherKeys.add(coverageKey)
  672. || approvalCoverage.shouldSkip(coverageKey, approved + change)) {
  673. continue;
  674. }
  675. OtherRow or = new OtherRow();
  676. or.dayText = dayText;
  677. or.dayTs = dayTs;
  678. or.activity = activity;
  679. or.hours = delta;
  680. or.submitterUid = uid;
  681. or.monthText = monthText;
  682. cr.otherRows.add(or);
  683. }
  684. }
  685. if (StringUtils.isBlank(cr.deptManagerId)) {
  686. cr.deptManagerId = DEFAULT_MANAGER_ID;
  687. }
  688. return cr;
  689. }
  690. /**
  691. * 生成项目工时审批分组键。
  692. *
  693. * @param managerId 审批人 userId
  694. * @param projectCode 项目编号
  695. * @param projectName 项目名称
  696. * @return 普通 PM 返回 managerId;Raymond 返回项目隔离键,编号为空时用名称
  697. */
  698. static String approvalGroupKey(String managerId, String projectCode, String projectName) {
  699. if (DEFAULT_MANAGER_ID.equals(managerId)) {
  700. String projectIdentity = StringUtils.isNotBlank(projectCode)
  701. ? projectCode
  702. : "name:" + StringUtils.defaultString(projectName);
  703. return managerId + "|project|" + projectIdentity;
  704. }
  705. return managerId;
  706. }
  707. /**
  708. * prd 项目经理离职时,改由人员档案中的在职主管审批;仅在整条替补链路不可用时兜底 Raymond。
  709. */
  710. String resolveProjectManager(String managerId) {
  711. if (StringUtils.isBlank(managerId) || DEFAULT_MANAGER_ID.equals(managerId)) {
  712. return DEFAULT_MANAGER_ID;
  713. }
  714. String accessToken;
  715. try {
  716. accessToken = ddClient.getAccessToken();
  717. } catch (Exception ex) {
  718. log.warn("[补发起] 获取钉钉 accessToken 失败,项目经理兜底 Raymond pm={} err={}",
  719. managerId, ex.getMessage());
  720. return DEFAULT_MANAGER_ID;
  721. }
  722. if (isUserInService(accessToken, managerId)) {
  723. return managerId;
  724. }
  725. String supervisorId;
  726. try {
  727. supervisorId = queryPersonnelManagerId(managerId);
  728. } catch (Exception ex) {
  729. log.warn("[补发起] 查询离职 PM 人员档案主管失败,兜底 Raymond pm={} err={}",
  730. managerId, ex.getMessage());
  731. return DEFAULT_MANAGER_ID;
  732. }
  733. if (StringUtils.isBlank(supervisorId)) {
  734. log.warn("[补发起] 离职 PM 未配置人员档案主管,兜底 Raymond pm={}", managerId);
  735. return DEFAULT_MANAGER_ID;
  736. }
  737. if (DEFAULT_MANAGER_ID.equals(supervisorId)
  738. || isUserInService(accessToken, supervisorId)) {
  739. log.info("[补发起] 离职 PM 改由人员档案主管审批 pm={} supervisor={}",
  740. managerId, supervisorId);
  741. return supervisorId;
  742. }
  743. log.warn("[补发起] 离职 PM 的人员档案主管也不在职,兜底 Raymond pm={} supervisor={}",
  744. managerId, supervisorId);
  745. return DEFAULT_MANAGER_ID;
  746. }
  747. private boolean isUserInService(String accessToken, String userId) {
  748. try {
  749. Map user = ddClient_contacts.getUserInfoById(accessToken, userId);
  750. return user != null && StringUtils.isNotBlank(str(user, "userid"));
  751. } catch (Exception ex) {
  752. log.info("[补发起] 钉钉用户不在职或查询失败 userId={} err={}", userId, ex.getMessage());
  753. return false;
  754. }
  755. }
  756. private String queryPersonnelManagerId(String userId) {
  757. Map<String, Object> search = new HashMap<>();
  758. search.put(PERSONNEL_EMPLOYEE, Collections.singletonList(userId));
  759. DDR_New response = ydClient.queryData(YDParam.builder()
  760. .appType(whConf.getYidaAppType())
  761. .systemToken(whConf.getYidaSystemToken())
  762. .formUuid(whConf.getFormUuidPersonnel())
  763. .searchFieldJson(JSON.toJSONString(search))
  764. .currentPage(1)
  765. .pageSize(1)
  766. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  767. List<Map> records = response == null ? null : (List<Map>) response.getData();
  768. if (records == null || records.isEmpty()) {
  769. return "";
  770. }
  771. Object formData = records.get(0).get("formData");
  772. if (!(formData instanceof Map)) {
  773. return "";
  774. }
  775. return firstEmployeeId((Map) formData, PERSONNEL_MANAGER);
  776. }
  777. /**
  778. * 加载指定月份两类审批单的完整明细覆盖索引。
  779. * fixme 流程列表内联子表恰好 50 行时必须 queryDetails 拉全,否则会把第 51 行以后误判为漏单。
  780. */
  781. private ApprovalCoverage loadApprovalCoverage(String monthText) {
  782. ApprovalCoverage coverage = new ApprovalCoverage();
  783. Map<String, Object> search = new HashMap<>();
  784. search.put(A_MONTH_TEXT, monthText);
  785. for (Map item : queryProcessAll(whConf.getFormUuidApproval(), search)) {
  786. collectProcessCoverage(item, false, coverage);
  787. }
  788. for (Map item : queryProcessAll(whConf.getFormUuidOtherApproval(), search)) {
  789. collectProcessCoverage(item, true, coverage);
  790. }
  791. log.info("[补发起] 审批覆盖索引 month={} runningKeys={} completedAgreeKeys={}",
  792. monthText, coverage.runningKeys.size(), coverage.completedAgreeHours.size());
  793. return coverage;
  794. }
  795. /**
  796. * 把单张审批单明细加入覆盖索引。
  797. */
  798. private void collectProcessCoverage(Map item, boolean isOther, ApprovalCoverage coverage) {
  799. String status = str(item, "instanceStatus").toUpperCase(Locale.ROOT);
  800. if (!"RUNNING".equals(status) && !"COMPLETED".equals(status)) {
  801. return;
  802. }
  803. String approvedResult = str(item, "approvedResult").toLowerCase(Locale.ROOT);
  804. String formInstanceId = firstNonBlank(
  805. str(item, "formInstanceId"),
  806. str(item, "processInstanceId"));
  807. Map formData = processFormData(item, formInstanceId);
  808. if (formData.isEmpty()) {
  809. return;
  810. }
  811. if (isOther) {
  812. collectCoverageRows(
  813. coverage,
  814. status,
  815. approvedResult,
  816. resolveFullRows(
  817. formInstanceId,
  818. whConf.getFormUuidOtherApproval(),
  819. O_SUB_TABLE,
  820. (List<Map>) formData.get(O_SUB_TABLE)),
  821. "other",
  822. O_SUB_SUBMITTER_UID,
  823. O_SUB_DAY_TEXT,
  824. O_SUB_ACTIVITY,
  825. null,
  826. O_SUB_HOURS,
  827. O_SUB_OPINION);
  828. return;
  829. }
  830. collectCoverageRows(
  831. coverage,
  832. status,
  833. approvedResult,
  834. resolveFullRows(
  835. formInstanceId,
  836. whConf.getFormUuidApproval(),
  837. A_BIL_TABLE,
  838. (List<Map>) formData.get(A_BIL_TABLE)),
  839. "billable",
  840. A_BIL_SUBMITTER_UID,
  841. A_BIL_DAY_TEXT,
  842. A_BIL_PROJECT_CODE,
  843. A_BIL_PROJECT_NAME,
  844. A_BIL_HOURS,
  845. A_BIL_OPINION);
  846. collectCoverageRows(
  847. coverage,
  848. status,
  849. approvedResult,
  850. resolveFullRows(
  851. formInstanceId,
  852. whConf.getFormUuidApproval(),
  853. A_NON_TABLE,
  854. (List<Map>) formData.get(A_NON_TABLE)),
  855. "nonBillable",
  856. A_NON_SUBMITTER_UID,
  857. A_NON_DAY_TEXT,
  858. A_NON_PROJECT_CODE,
  859. A_NON_PROJECT_NAME,
  860. A_NON_HOURS,
  861. A_NON_OPINION);
  862. }
  863. /**
  864. * 归一化一类审批子表行并写入覆盖索引。
  865. */
  866. private void collectCoverageRows(ApprovalCoverage coverage,
  867. String status,
  868. String approvedResult,
  869. List<Map> rows,
  870. String category,
  871. String uidField,
  872. String dayField,
  873. String detailKeyField,
  874. String detailFallbackField,
  875. String hoursField,
  876. String opinionField) {
  877. for (Map row : rows) {
  878. String uid = str(row, uidField);
  879. String day = str(row, dayField);
  880. String detailKey = approvalDetailKey(
  881. str(row, detailKeyField),
  882. detailFallbackField == null ? "" : str(row, detailFallbackField));
  883. if (StringUtils.isAnyBlank(uid, day, detailKey)) {
  884. continue;
  885. }
  886. String key = approvalKey(category, uid, day, detailKey);
  887. if ("RUNNING".equals(status)) {
  888. coverage.runningKeys.add(key);
  889. continue;
  890. }
  891. if (isCompletedApprovedRow(
  892. status,
  893. approvedResult,
  894. str(row, opinionField))) {
  895. coverage.completedAgreeHours.merge(key, num(row, hoursField), Math::max);
  896. }
  897. }
  898. }
  899. static boolean isCompletedApprovedRow(String status,
  900. String approvedResult,
  901. String opinion) {
  902. return "COMPLETED".equals(status)
  903. && "agree".equals(approvedResult)
  904. && (StringUtils.isBlank(opinion) || APPROVAL_OPINION_AGREE.equals(opinion));
  905. }
  906. /**
  907. * 获取流程列表项中的表单数据;列表不带数据时回查表单详情。
  908. */
  909. private Map processFormData(Map item, String formInstanceId) {
  910. Object formData = item.get("formData");
  911. if (!(formData instanceof Map)) {
  912. formData = item.get("data");
  913. }
  914. if (formData instanceof Map && !((Map) formData).isEmpty()) {
  915. return (Map) formData;
  916. }
  917. if (StringUtils.isBlank(formInstanceId)) {
  918. return Collections.emptyMap();
  919. }
  920. Map detail = ydClient.queryData(YDParam.builder()
  921. .appType(whConf.getYidaAppType())
  922. .systemToken(whConf.getYidaSystemToken())
  923. .formInstanceId(formInstanceId)
  924. .build(), YDConf.FORM_QUERY.retrieve_id).getFormData();
  925. return detail != null ? detail : Collections.emptyMap();
  926. }
  927. /**
  928. * 子表内联小于 50 行直接使用,恰好 50 行时递归拉全。
  929. */
  930. private List<Map> resolveFullRows(String formInstanceId,
  931. String formUuid,
  932. String tableFieldId,
  933. List<Map> inlineRows) {
  934. if (inlineRows == null) {
  935. return Collections.emptyList();
  936. }
  937. if (inlineRows.size() < 50) {
  938. return inlineRows;
  939. }
  940. List<Map> fullRows = ydService.queryDetails(YDParam.builder()
  941. .appType(whConf.getYidaAppType())
  942. .systemToken(whConf.getYidaSystemToken())
  943. .formUuid(formUuid)
  944. .formInstanceId(formInstanceId)
  945. .tableFieldId(tableFieldId)
  946. .pageNumber(1)
  947. .build());
  948. return fullRows != null ? fullRows : Collections.emptyList();
  949. }
  950. /**
  951. * 审批明细唯一键。
  952. */
  953. static String approvalKey(String category, String uid, String day, String detailKey) {
  954. return category + "|" + uid + "|" + day + "|" + detailKey;
  955. }
  956. /**
  957. * 审批明细业务标识优先使用编号;历史数据编号为空时退回项目名称。
  958. *
  959. * @param primary 编号或活动类型
  960. * @param fallback 项目名称兜底
  961. * @return 带来源前缀的稳定业务标识
  962. */
  963. static String approvalDetailKey(String primary, String fallback) {
  964. if (StringUtils.isNotBlank(primary)) {
  965. return "id:" + primary;
  966. }
  967. return "name:" + StringUtils.defaultString(fallback);
  968. }
  969. /**
  970. * processManagerApproval 等价 — queryProcess(RUNNING) 有则 update / 无则 create。
  971. * COMPLETED 覆盖已在 collectApprovalData 前过滤,避免历史已完成单被重复发起。
  972. */
  973. private List<String> processManagerApproval(ManagerGroup mg,
  974. String monthText,
  975. String submitterUid) {
  976. List<String> actions = new ArrayList<>();
  977. for (ManagerGroup shard : splitManagerGroupByHardLimit(mg)) {
  978. actions.add(processManagerApprovalShard(shard, monthText, submitterUid));
  979. }
  980. return actions;
  981. }
  982. private String processManagerApprovalShard(ManagerGroup mg,
  983. String monthText,
  984. String submitterUid) {
  985. Map<String, Object> searchFieldJson = new HashMap<>();
  986. searchFieldJson.put(A_MONTH_TEXT, monthText);
  987. // fixme employeeField 查询用数组: 单值 [uid]
  988. searchFieldJson.put(A_MANAGER, Collections.singletonList(mg.managerId));
  989. List<Map> running = queryProcessRunning(whConf.getFormUuidApproval(), searchFieldJson);
  990. // 优先追加到最新分片,避免每名员工都先扫描已满 500 行的旧审批单。
  991. running.sort(Comparator.comparingLong(
  992. (Map item) -> numLong(item, "gmtModified")).reversed());
  993. for (Map item : running) {
  994. String pid = str(item, "processInstanceId");
  995. String finstId = firstNonBlank(str(item, "formInstanceId"), pid);
  996. Map existingFormData = processFormData(item, finstId);
  997. if (!managerApprovalHasCapacity(finstId, mg, submitterUid, monthText, existingFormData)) {
  998. continue;
  999. }
  1000. if (updateApprovalInstance(finstId, mg, monthText, submitterUid, existingFormData)) {
  1001. return "updated";
  1002. }
  1003. }
  1004. // fixme: 单张审批子表达到 500 行后必须新建分片,继续覆盖会丢失第 501 行后的明细。
  1005. createApprovalInstance(mg, monthText, submitterUid);
  1006. return "created";
  1007. }
  1008. private boolean managerApprovalHasCapacity(String formInstanceId,
  1009. ManagerGroup mg,
  1010. String submitterUid,
  1011. String monthText,
  1012. Map existingFormData) {
  1013. List<Map> existBil = resolveFullRows(
  1014. formInstanceId,
  1015. whConf.getFormUuidApproval(),
  1016. A_BIL_TABLE,
  1017. (List<Map>) (existingFormData == null ? null : existingFormData.get(A_BIL_TABLE)));
  1018. List<Map> existNon = resolveFullRows(
  1019. formInstanceId,
  1020. whConf.getFormUuidApproval(),
  1021. A_NON_TABLE,
  1022. (List<Map>) (existingFormData == null ? null : existingFormData.get(A_NON_TABLE)));
  1023. MergeResult billable = mergeSubRows(
  1024. existBil, mg.billableRows, submitterUid, monthText,
  1025. A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE,
  1026. br -> buildBillableApprovalRow(br, submitterUid));
  1027. MergeResult nonBillable = mergeSubRows(
  1028. existNon, mg.nonBillableRows, submitterUid, monthText,
  1029. A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE,
  1030. br -> buildNonBillableApprovalRow(br, submitterUid));
  1031. int existingBillableCount = countUniqueRows(
  1032. existBil, A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE);
  1033. int existingNonBillableCount = countUniqueRows(
  1034. existNon, A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE);
  1035. if (!approvalMatchesProject(existBil, existNon, mg.projectCode, mg.projectName)) {
  1036. return false;
  1037. }
  1038. int existingCount = existingBillableCount + existingNonBillableCount;
  1039. int mergedCount = billable.rows.size() + nonBillable.rows.size();
  1040. return canAppendToApprovalShard(existingCount, mergedCount);
  1041. }
  1042. /**
  1043. * 创建新的工时审批实例(startProcessInstance)
  1044. */
  1045. private void createApprovalInstance(ManagerGroup mg, String monthText, String submitterUid) {
  1046. McException.assertAccessException(
  1047. !hasSubtableCapacity(
  1048. mg.billableRows.size() + mg.nonBillableRows.size()),
  1049. "单张工时审批明细不能超过 500 行");
  1050. Map<String, Object> formData = new HashMap<>();
  1051. formData.put(A_MONTH_DATE, monthToTs(monthText));
  1052. formData.put(A_MONTH_TEXT, monthText);
  1053. formData.put(A_MANAGER, Collections.singletonList(mg.managerId));
  1054. formData.put(A_PROJECT_HOURS, mg.billableTotal);
  1055. formData.put(A_NON_PROJECT_HOURS, mg.nonBillableTotal);
  1056. formData.put(A_TOTAL_HOURS, mg.billableTotal + mg.nonBillableTotal);
  1057. formData.put(A_VIEWERS, Collections.singletonList(submitterUid));
  1058. List<Map<String, Object>> billableSub = new ArrayList<>();
  1059. for (BillRow br : mg.billableRows) {
  1060. billableSub.add(buildBillableApprovalRow(br, submitterUid));
  1061. }
  1062. List<Map<String, Object>> nonBillableSub = new ArrayList<>();
  1063. for (BillRow br : mg.nonBillableRows) {
  1064. nonBillableSub.add(buildNonBillableApprovalRow(br, submitterUid));
  1065. }
  1066. if (!billableSub.isEmpty()) {
  1067. formData.put(A_BIL_TABLE, billableSub);
  1068. }
  1069. if (!nonBillableSub.isEmpty()) {
  1070. formData.put(A_NON_TABLE, nonBillableSub);
  1071. }
  1072. startProcessInstance(
  1073. whConf.getFormUuidApproval(),
  1074. whConf.getApprovalProcessCode(),
  1075. formData,
  1076. whConf.getApprovalOriginatorDeptId());
  1077. log.info("[补发起] 新建工时审批单 submitterUid={} pm={} project={} month={} bilTotal={} nonBilTotal={} rows={}+{}",
  1078. submitterUid, mg.managerId, mg.projectCode, monthText,
  1079. mg.billableTotal, mg.nonBillableTotal, billableSub.size(), nonBillableSub.size());
  1080. }
  1081. /**
  1082. * 累计更新已有 RUNNING 工时审批单(按唯一键 userId + dayText + projectCode 去重合并)
  1083. */
  1084. private boolean updateApprovalInstance(String formInstanceId, ManagerGroup mg,
  1085. String monthText, String submitterUid,
  1086. Map existingFormData) {
  1087. List<Map> existBil = resolveFullRows(
  1088. formInstanceId,
  1089. whConf.getFormUuidApproval(),
  1090. A_BIL_TABLE,
  1091. (List<Map>) (existingFormData == null ? null : existingFormData.get(A_BIL_TABLE)));
  1092. List<Map> existNon = resolveFullRows(
  1093. formInstanceId,
  1094. whConf.getFormUuidApproval(),
  1095. A_NON_TABLE,
  1096. (List<Map>) (existingFormData == null ? null : existingFormData.get(A_NON_TABLE)));
  1097. MergeResult mrBil = mergeSubRows(existBil, mg.billableRows, submitterUid, monthText,
  1098. A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE,
  1099. (br) -> buildBillableApprovalRow(br, submitterUid));
  1100. MergeResult mrNon = mergeSubRows(existNon, mg.nonBillableRows, submitterUid, monthText,
  1101. A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE,
  1102. (br) -> buildNonBillableApprovalRow(br, submitterUid));
  1103. if (!hasSubtableCapacity(mrBil.rows.size() + mrNon.rows.size())) {
  1104. return false;
  1105. }
  1106. if (mrBil.addedHours == 0 && mrNon.addedHours == 0) {
  1107. log.info("[补发起] 已存在等效明细,跳过更新 formInstanceId={}", formInstanceId);
  1108. return true;
  1109. }
  1110. double existProject = num(existingFormData, A_PROJECT_HOURS);
  1111. double existNonProject = num(existingFormData, A_NON_PROJECT_HOURS);
  1112. double newProject = existProject + mrBil.addedHours;
  1113. double newNonProject = existNonProject + mrNon.addedHours;
  1114. Map<String, Object> updateData = new HashMap<>();
  1115. updateData.put(A_PROJECT_HOURS, newProject);
  1116. updateData.put(A_NON_PROJECT_HOURS, newNonProject);
  1117. updateData.put(A_TOTAL_HOURS, newProject + newNonProject);
  1118. if (!mrBil.rows.isEmpty()) {
  1119. updateData.put(A_BIL_TABLE, mrBil.rows);
  1120. }
  1121. if (!mrNon.rows.isEmpty()) {
  1122. updateData.put(A_NON_TABLE, mrNon.rows);
  1123. }
  1124. // Viewers 去重(原有 + 本次申请人)
  1125. Set<String> viewerSet = new LinkedHashSet<>();
  1126. Object existViewersRaw = existingFormData == null ? null : existingFormData.get(A_VIEWERS + "_id");
  1127. if (existViewersRaw instanceof List) {
  1128. for (Object v : (List<?>) existViewersRaw) {
  1129. if (v != null) {
  1130. viewerSet.add(String.valueOf(v));
  1131. }
  1132. }
  1133. }
  1134. // 从原有子表行提取填报人
  1135. for (Map r : new ArrayList<Map>(existBil == null ? Collections.emptyList() : existBil)) {
  1136. String u = str(r, A_BIL_SUBMITTER_UID);
  1137. if (StringUtils.isNotBlank(u)) {
  1138. viewerSet.add(u);
  1139. }
  1140. }
  1141. for (Map r : new ArrayList<Map>(existNon == null ? Collections.emptyList() : existNon)) {
  1142. String u = str(r, A_NON_SUBMITTER_UID);
  1143. if (StringUtils.isNotBlank(u)) {
  1144. viewerSet.add(u);
  1145. }
  1146. }
  1147. if (StringUtils.isNotBlank(submitterUid)) {
  1148. viewerSet.add(submitterUid);
  1149. }
  1150. updateData.put(A_VIEWERS, new ArrayList<>(viewerSet));
  1151. ydClient.operateData(YDParam.builder()
  1152. .appType(whConf.getYidaAppType())
  1153. .systemToken(whConf.getYidaSystemToken())
  1154. .formInstanceId(formInstanceId)
  1155. .updateFormDataJson(JSON.toJSONString(updateData))
  1156. .ignoreEmpty(false)
  1157. .useLatestVersion(true)
  1158. .build(), YDConf.FORM_OPERATION.update);
  1159. log.info("[补发起] 累计更新工时审批单 formInstanceId={} pm={} project={} addedBil={} addedNon={}",
  1160. formInstanceId, mg.managerId, mg.projectCode, mrBil.addedHours, mrNon.addedHours);
  1161. return true;
  1162. }
  1163. /**
  1164. * 判断已有 Raymond 审批单是否只包含目标项目。
  1165. *
  1166. * @param billableRows Billable 明细
  1167. * @param nonBillableRows Non Billable 明细
  1168. * @param projectCode Raymond 组的项目编号
  1169. * @param projectName Raymond 组的项目名称;编号为空时用于项目隔离,两者均空表示普通 PM 组
  1170. * @return 可否追加到该审批单
  1171. */
  1172. static boolean approvalMatchesProject(List<Map> billableRows,
  1173. List<Map> nonBillableRows,
  1174. String projectCode,
  1175. String projectName) {
  1176. if (StringUtils.isBlank(projectCode) && StringUtils.isBlank(projectName)) {
  1177. return true;
  1178. }
  1179. int rowCount = 0;
  1180. for (Map row : billableRows) {
  1181. rowCount++;
  1182. if (!matchesProject(row, A_BIL_PROJECT_CODE, A_BIL_PROJECT_NAME, projectCode, projectName)) {
  1183. return false;
  1184. }
  1185. }
  1186. for (Map row : nonBillableRows) {
  1187. rowCount++;
  1188. if (!matchesProject(row, A_NON_PROJECT_CODE, A_NON_PROJECT_NAME, projectCode, projectName)) {
  1189. return false;
  1190. }
  1191. }
  1192. return rowCount > 0;
  1193. }
  1194. private static boolean matchesProject(Map row,
  1195. String codeField,
  1196. String nameField,
  1197. String projectCode,
  1198. String projectName) {
  1199. String rowProjectCode = str(row, codeField);
  1200. if (StringUtils.isNotBlank(projectCode)) {
  1201. return projectCode.equals(rowProjectCode);
  1202. }
  1203. return StringUtils.isBlank(rowProjectCode) && StringUtils.equals(projectName, str(row, nameField));
  1204. }
  1205. /**
  1206. * 其他工时审批处理(与 processManagerApproval 同构,但 approver 为员工的部门主管)
  1207. */
  1208. private List<String> processOtherApproval(List<OtherRow> otherRows,
  1209. String deptManagerId,
  1210. String monthText,
  1211. String submitterUid) {
  1212. List<String> actions = new ArrayList<>();
  1213. for (List<OtherRow> shard : splitListByHardLimit(otherRows)) {
  1214. actions.add(processOtherApprovalShard(
  1215. shard, deptManagerId, monthText, submitterUid));
  1216. }
  1217. return actions;
  1218. }
  1219. private String processOtherApprovalShard(List<OtherRow> otherRows,
  1220. String deptManagerId,
  1221. String monthText,
  1222. String submitterUid) {
  1223. Map<String, Object> searchFieldJson = new HashMap<>();
  1224. searchFieldJson.put(O_MONTH_TEXT, monthText);
  1225. searchFieldJson.put(O_DEPT_MANAGER, Collections.singletonList(deptManagerId));
  1226. List<Map> running = queryProcessRunning(whConf.getFormUuidOtherApproval(), searchFieldJson);
  1227. running.sort(Comparator.comparingLong(
  1228. (Map item) -> numLong(item, "gmtModified")).reversed());
  1229. for (Map item : running) {
  1230. String pid = str(item, "processInstanceId");
  1231. String finstId = firstNonBlank(str(item, "formInstanceId"), pid);
  1232. Map existingFormData = processFormData(item, finstId);
  1233. if (!otherApprovalHasCapacity(finstId, otherRows, submitterUid, existingFormData)) {
  1234. continue;
  1235. }
  1236. if (updateOtherApprovalInstance(
  1237. finstId, otherRows, monthText, submitterUid, existingFormData)) {
  1238. return "updated";
  1239. }
  1240. }
  1241. createOtherApprovalInstance(otherRows, deptManagerId, monthText, submitterUid);
  1242. return "created";
  1243. }
  1244. private boolean otherApprovalHasCapacity(String formInstanceId,
  1245. List<OtherRow> otherRows,
  1246. String submitterUid,
  1247. Map existingFormData) {
  1248. List<Map> existing = resolveFullRows(
  1249. formInstanceId,
  1250. whConf.getFormUuidOtherApproval(),
  1251. O_SUB_TABLE,
  1252. (List<Map>) (existingFormData == null ? null : existingFormData.get(O_SUB_TABLE)));
  1253. Set<String> keys = new HashSet<>();
  1254. for (Map row : existing) {
  1255. keys.add(str(row, O_SUB_SUBMITTER_UID) + "|"
  1256. + str(row, O_SUB_DAY_TEXT) + "|"
  1257. + str(row, O_SUB_ACTIVITY));
  1258. }
  1259. int existingCount = keys.size();
  1260. for (OtherRow row : otherRows) {
  1261. keys.add(submitterUid + "|" + row.dayText + "|" + row.activity);
  1262. }
  1263. return canAppendToApprovalShard(existingCount, keys.size());
  1264. }
  1265. private void createOtherApprovalInstance(List<OtherRow> otherRows, String deptManagerId,
  1266. String monthText, String submitterUid) {
  1267. McException.assertAccessException(
  1268. !hasSubtableCapacity(otherRows.size()),
  1269. "单个员工的其他工时审批明细超过宜搭子表 500 行限制");
  1270. double total = 0;
  1271. List<Map<String, Object>> sub = new ArrayList<>();
  1272. for (OtherRow or : otherRows) {
  1273. sub.add(buildOtherApprovalRow(or, submitterUid));
  1274. total += or.hours;
  1275. }
  1276. Map<String, Object> formData = new HashMap<>();
  1277. formData.put(O_MONTH_DATE, monthToTs(monthText));
  1278. formData.put(O_MONTH_TEXT, monthText);
  1279. formData.put(O_DEPT_MANAGER, Collections.singletonList(deptManagerId));
  1280. formData.put(O_TOTAL_HOURS, total);
  1281. formData.put(O_VIEWERS, Collections.singletonList(submitterUid));
  1282. if (!sub.isEmpty()) {
  1283. formData.put(O_SUB_TABLE, sub);
  1284. }
  1285. startProcessInstance(
  1286. whConf.getFormUuidOtherApproval(),
  1287. whConf.getOtherApprovalProcessCode(),
  1288. formData,
  1289. whConf.getApprovalOriginatorDeptId());
  1290. log.info("[补发起] 新建其他工时审批单 submitterUid={} deptMgr={} month={} total={} rows={}",
  1291. submitterUid, deptManagerId, monthText, total, sub.size());
  1292. }
  1293. private boolean updateOtherApprovalInstance(String formInstanceId,
  1294. List<OtherRow> otherRows,
  1295. String monthText,
  1296. String submitterUid,
  1297. Map existingFormData) {
  1298. List<Map> existRows = resolveFullRows(
  1299. formInstanceId,
  1300. whConf.getFormUuidOtherApproval(),
  1301. O_SUB_TABLE,
  1302. (List<Map>) (existingFormData == null ? null : existingFormData.get(O_SUB_TABLE)));
  1303. // 唯一键: submitterUid + dayText + activity
  1304. Set<String> existingKeys = new HashSet<>();
  1305. List<Map<String, Object>> merged = new ArrayList<>();
  1306. if (existRows != null) {
  1307. for (Map row : existRows) {
  1308. String uid = str(row, O_SUB_SUBMITTER_UID);
  1309. String day = str(row, O_SUB_DAY_TEXT);
  1310. String act = str(row, O_SUB_ACTIVITY);
  1311. existingKeys.add(uid + "|" + day + "|" + act);
  1312. merged.add(rebuildRow(row, O_SUB_SUBMITTER));
  1313. }
  1314. }
  1315. double addedHours = 0;
  1316. for (OtherRow or : otherRows) {
  1317. String key = submitterUid + "|" + or.dayText + "|" + or.activity;
  1318. if (existingKeys.contains(key)) {
  1319. continue;
  1320. }
  1321. merged.add(buildOtherApprovalRow(or, submitterUid));
  1322. addedHours += or.hours;
  1323. }
  1324. if (!hasSubtableCapacity(merged.size())) {
  1325. return false;
  1326. }
  1327. if (addedHours == 0) {
  1328. log.info("[补发起] 其他工时无新增明细 formInstanceId={}", formInstanceId);
  1329. return true;
  1330. }
  1331. double existTotal = num(existingFormData, O_TOTAL_HOURS);
  1332. Map<String, Object> updateData = new HashMap<>();
  1333. updateData.put(O_TOTAL_HOURS, existTotal + addedHours);
  1334. updateData.put(O_SUB_TABLE, merged);
  1335. Set<String> viewerSet = new LinkedHashSet<>();
  1336. Object existOViewersRaw = existingFormData == null ? null : existingFormData.get(O_VIEWERS + "_id");
  1337. if (existOViewersRaw instanceof List) {
  1338. for (Object v : (List<?>) existOViewersRaw) {
  1339. if (v != null) {
  1340. viewerSet.add(String.valueOf(v));
  1341. }
  1342. }
  1343. }
  1344. if (existRows != null) {
  1345. for (Map r : existRows) {
  1346. String u = str(r, O_SUB_SUBMITTER_UID);
  1347. if (StringUtils.isNotBlank(u)) {
  1348. viewerSet.add(u);
  1349. }
  1350. }
  1351. }
  1352. if (StringUtils.isNotBlank(submitterUid)) {
  1353. viewerSet.add(submitterUid);
  1354. }
  1355. updateData.put(O_VIEWERS, new ArrayList<>(viewerSet));
  1356. ydClient.operateData(YDParam.builder()
  1357. .appType(whConf.getYidaAppType())
  1358. .systemToken(whConf.getYidaSystemToken())
  1359. .formInstanceId(formInstanceId)
  1360. .updateFormDataJson(JSON.toJSONString(updateData))
  1361. .ignoreEmpty(false)
  1362. .useLatestVersion(true)
  1363. .build(), YDConf.FORM_OPERATION.update);
  1364. log.info("[补发起] 累计更新其他工时审批单 formInstanceId={} addedHours={}", formInstanceId, addedHours);
  1365. return true;
  1366. }
  1367. // ============================================================
  1368. // 子表行构造 + 合并 helper
  1369. // ============================================================
  1370. private Map<String, Object> buildBillableApprovalRow(BillRow br, String submitterUid) {
  1371. Map<String, Object> r = new HashMap<>();
  1372. r.put(A_BIL_WORK_DATE, br.dayTs);
  1373. r.put(A_BIL_SUBMITTER, Collections.singletonList(submitterUid));
  1374. r.put(A_BIL_SUBMITTER_UID, submitterUid);
  1375. r.put(A_BIL_PROJECT_CODE, br.projectCode);
  1376. r.put(A_BIL_PROJECT_NAME, br.projectName);
  1377. r.put(A_BIL_HOURS, br.hours);
  1378. r.put(A_BIL_MONTH_TEXT, br.monthText);
  1379. r.put(A_BIL_DAY_TEXT, br.dayText);
  1380. r.put(A_BIL_OPINION, APPROVAL_OPINION_AGREE);
  1381. return r;
  1382. }
  1383. private Map<String, Object> buildNonBillableApprovalRow(BillRow br, String submitterUid) {
  1384. Map<String, Object> r = new HashMap<>();
  1385. r.put(A_NON_WORK_DATE, br.dayTs);
  1386. r.put(A_NON_SUBMITTER, Collections.singletonList(submitterUid));
  1387. r.put(A_NON_SUBMITTER_UID, submitterUid);
  1388. r.put(A_NON_PROJECT_CODE, br.projectCode);
  1389. r.put(A_NON_PROJECT_NAME, br.projectName);
  1390. r.put(A_NON_HOURS, br.hours);
  1391. r.put(A_NON_MONTH_TEXT, br.monthText);
  1392. r.put(A_NON_DAY_TEXT, br.dayText);
  1393. r.put(A_NON_OPINION, APPROVAL_OPINION_AGREE);
  1394. return r;
  1395. }
  1396. private Map<String, Object> buildOtherApprovalRow(OtherRow or, String submitterUid) {
  1397. Map<String, Object> r = new HashMap<>();
  1398. r.put(O_SUB_WORK_DATE, or.dayTs);
  1399. r.put(O_SUB_SUBMITTER, Collections.singletonList(submitterUid));
  1400. r.put(O_SUB_SUBMITTER_UID, submitterUid);
  1401. r.put(O_SUB_ACTIVITY, or.activity);
  1402. r.put(O_SUB_HOURS, or.hours);
  1403. r.put(O_SUB_MONTH_TEXT, or.monthText);
  1404. r.put(O_SUB_DAY_TEXT, or.dayText);
  1405. r.put(O_SUB_OPINION, APPROVAL_OPINION_AGREE);
  1406. return r;
  1407. }
  1408. private interface RowBuilder<T> {
  1409. Map<String, Object> build(T row);
  1410. }
  1411. private static class MergeResult {
  1412. List<Map<String, Object>> rows = new ArrayList<>();
  1413. double addedHours = 0;
  1414. }
  1415. private static class RowShard {
  1416. List<Map> firstRows = new ArrayList<>();
  1417. List<Map> secondRows = new ArrayList<>();
  1418. int size() {
  1419. return firstRows.size() + secondRows.size();
  1420. }
  1421. }
  1422. private List<RowShard> splitRowsByHardLimit(List<Map> firstRows, List<Map> secondRows) {
  1423. List<RowShard> shards = new ArrayList<>();
  1424. shards.add(new RowShard());
  1425. for (Map row : firstRows) {
  1426. appendRowToShard(shards, row, true);
  1427. }
  1428. for (Map row : secondRows) {
  1429. appendRowToShard(shards, row, false);
  1430. }
  1431. return shards;
  1432. }
  1433. private void appendRowToShard(List<RowShard> shards, Map row, boolean firstTable) {
  1434. RowShard current = shards.get(shards.size() - 1);
  1435. if (current.size() >= MAX_APPROVAL_SUBTABLE_ROWS) {
  1436. current = new RowShard();
  1437. shards.add(current);
  1438. }
  1439. (firstTable ? current.firstRows : current.secondRows).add(row);
  1440. }
  1441. private List<ManagerGroup> splitManagerGroupByHardLimit(ManagerGroup source) {
  1442. if (source.billableRows.size() + source.nonBillableRows.size()
  1443. <= MAX_APPROVAL_SUBTABLE_ROWS) {
  1444. return Collections.singletonList(source);
  1445. }
  1446. List<ManagerGroup> shards = new ArrayList<>();
  1447. shards.add(newManagerGroup(source.managerId, source.projectCode, source.projectName));
  1448. for (BillRow row : source.billableRows) {
  1449. appendManagerRow(shards, row, true);
  1450. }
  1451. for (BillRow row : source.nonBillableRows) {
  1452. appendManagerRow(shards, row, false);
  1453. }
  1454. return shards;
  1455. }
  1456. private ManagerGroup newManagerGroup(String managerId, String projectCode, String projectName) {
  1457. ManagerGroup group = new ManagerGroup();
  1458. group.managerId = managerId;
  1459. group.projectCode = projectCode;
  1460. group.projectName = projectName;
  1461. return group;
  1462. }
  1463. private void appendManagerRow(List<ManagerGroup> shards, BillRow row, boolean billable) {
  1464. ManagerGroup current = shards.get(shards.size() - 1);
  1465. if (current.billableRows.size() + current.nonBillableRows.size()
  1466. >= MAX_APPROVAL_SUBTABLE_ROWS) {
  1467. current = newManagerGroup(current.managerId, current.projectCode, current.projectName);
  1468. shards.add(current);
  1469. }
  1470. if (billable) {
  1471. current.billableRows.add(row);
  1472. current.billableTotal += row.hours;
  1473. } else {
  1474. current.nonBillableRows.add(row);
  1475. current.nonBillableTotal += row.hours;
  1476. }
  1477. }
  1478. private <T> List<List<T>> splitListByHardLimit(List<T> rows) {
  1479. List<List<T>> shards = new ArrayList<>();
  1480. for (int from = 0; from < rows.size(); from += MAX_APPROVAL_SUBTABLE_ROWS) {
  1481. int to = Math.min(from + MAX_APPROVAL_SUBTABLE_ROWS, rows.size());
  1482. shards.add(new ArrayList<>(rows.subList(from, to)));
  1483. }
  1484. return shards;
  1485. }
  1486. static boolean hasSubtableCapacity(int rowCount) {
  1487. return rowCount <= MAX_APPROVAL_SUBTABLE_ROWS;
  1488. }
  1489. static boolean canAppendToApprovalShard(int existingCount, int mergedCount) {
  1490. if (!hasSubtableCapacity(mergedCount)) {
  1491. return false;
  1492. }
  1493. // 已有等效明细允许幂等命中;空分片允许承载一个员工的完整明细组。
  1494. return mergedCount == existingCount
  1495. || existingCount == 0
  1496. || mergedCount <= TARGET_APPROVAL_SUBTABLE_ROWS;
  1497. }
  1498. private int countUniqueRows(List<Map> rows,
  1499. String uidField,
  1500. String dayField,
  1501. String keyField) {
  1502. Set<String> keys = new HashSet<>();
  1503. for (Map row : rows) {
  1504. keys.add(str(row, uidField) + "|"
  1505. + str(row, dayField) + "|"
  1506. + str(row, keyField));
  1507. }
  1508. return keys.size();
  1509. }
  1510. /**
  1511. * mergeApprovalSubRows 等价:唯一键 uid + dayText + projectCode/activity,已存在跳过
  1512. * 保留原行时用 rebuildRow 修正 EmployeeField 主键(_id 后缀 → 主键)
  1513. */
  1514. private MergeResult mergeSubRows(List<Map> existing, List<BillRow> newRows, String submitterUid, String monthText,
  1515. String uidField, String dayField, String keyField, RowBuilder<BillRow> builder) {
  1516. MergeResult r = new MergeResult();
  1517. Set<String> keys = new HashSet<>();
  1518. if (existing != null) {
  1519. for (Map row : existing) {
  1520. String uid = str(row, uidField);
  1521. String day = str(row, dayField);
  1522. String key = str(row, keyField);
  1523. if (!keys.add(uid + "|" + day + "|" + key)) {
  1524. continue;
  1525. }
  1526. r.rows.add(rebuildRow(row, uidField.equals(A_BIL_SUBMITTER_UID) ? A_BIL_SUBMITTER : A_NON_SUBMITTER));
  1527. }
  1528. }
  1529. for (BillRow br : newRows) {
  1530. String key = submitterUid + "|" + br.dayText + "|" + br.projectCode;
  1531. if (!keys.add(key)) {
  1532. continue;
  1533. }
  1534. r.rows.add(builder.build(br));
  1535. r.addedHours += br.hours;
  1536. }
  1537. return r;
  1538. }
  1539. /**
  1540. * 子表行"重建":成员字段用 _id 后缀真实 userId 替换显示名(否则宜搭静默清空)
  1541. * fixme 前端 v2.32 已确认这个坑 — 后端全量覆盖写子表时同规则
  1542. */
  1543. private Map<String, Object> rebuildRow(Map row, String submitterField) {
  1544. Map<String, Object> nr = new HashMap<>();
  1545. for (Object k : row.keySet()) {
  1546. String key = String.valueOf(k);
  1547. if (key.endsWith("_value")) {
  1548. continue;
  1549. }
  1550. if (key.startsWith("employeeField_") && !key.endsWith("_id")) {
  1551. continue;
  1552. }
  1553. if (key.endsWith("_id") && (key.startsWith("employeeField_") || key.startsWith("associationFormField_"))) {
  1554. nr.put(key.substring(0, key.length() - 3), row.get(k));
  1555. continue;
  1556. }
  1557. nr.put(key, row.get(k));
  1558. }
  1559. return nr;
  1560. }
  1561. // ============================================================
  1562. // 宜搭 API 封装
  1563. // ============================================================
  1564. private void startProcessInstance(String formUuid,
  1565. String processCode,
  1566. Map<String, Object> formData,
  1567. String deptId) {
  1568. // fixme 必须用真人 System 账号发起,yida_pub_account 不触发钉钉待办通知。
  1569. ydClient.operateData(YDParam.builder()
  1570. .appType(whConf.getYidaAppType())
  1571. .systemToken(whConf.getYidaSystemToken())
  1572. .userId(whConf.getApprovalOriginatorUserId())
  1573. .deptId(StringUtils.defaultIfBlank(deptId, whConf.getApprovalOriginatorDeptId()))
  1574. .formUuid(formUuid)
  1575. .processCode(processCode)
  1576. .formDataJson(JSON.toJSONString(formData))
  1577. .build(), YDConf.FORM_OPERATION.start);
  1578. }
  1579. /**
  1580. * 查该月 + PM/deptManager + RUNNING 的审批单;分页取全
  1581. */
  1582. private List<Map> queryProcessRunning(String formUuid, Map<String, Object> searchFieldJson) {
  1583. List<Map> all = new ArrayList<>();
  1584. int page = 1;
  1585. while (page <= 20) {
  1586. DDR_New r = ydClient.queryData(YDParam.builder()
  1587. .appType(whConf.getYidaAppType())
  1588. .systemToken(whConf.getYidaSystemToken())
  1589. .formUuid(formUuid)
  1590. .searchFieldJson(JSON.toJSONString(searchFieldJson))
  1591. .instanceStatus("RUNNING")
  1592. .pageNumber(page)
  1593. .pageSize(100)
  1594. .build(), YDConf.FORM_QUERY.retrieve_search_process);
  1595. List<Map> data = (List<Map>) r.getData();
  1596. if (data == null || data.isEmpty()) {
  1597. break;
  1598. }
  1599. all.addAll(data);
  1600. if (data.size() < 100) {
  1601. break;
  1602. }
  1603. page++;
  1604. }
  1605. return all;
  1606. }
  1607. /**
  1608. * 查指定条件下全部状态的审批单,供补发起前构建覆盖索引。
  1609. */
  1610. private List<Map> queryProcessAll(String formUuid, Map<String, Object> searchFieldJson) {
  1611. List<Map> all = new ArrayList<>();
  1612. int page = 1;
  1613. while (page <= 100) {
  1614. DDR_New r = ydClient.queryData(YDParam.builder()
  1615. .appType(whConf.getYidaAppType())
  1616. .systemToken(whConf.getYidaSystemToken())
  1617. .formUuid(formUuid)
  1618. .searchFieldJson(JSON.toJSONString(searchFieldJson))
  1619. .pageNumber(page)
  1620. .pageSize(100)
  1621. .build(), YDConf.FORM_QUERY.retrieve_search_process);
  1622. List<Map> data = (List<Map>) r.getData();
  1623. if (data == null || data.isEmpty()) {
  1624. break;
  1625. }
  1626. all.addAll(data);
  1627. if (data.size() < 100) {
  1628. break;
  1629. }
  1630. page++;
  1631. }
  1632. return all;
  1633. }
  1634. /**
  1635. * 汇总表分页取全
  1636. */
  1637. List<Map> queryAll(String formUuid, Map<String, Object> searchFieldJson) {
  1638. List<Map> all = new ArrayList<>();
  1639. int page = 1;
  1640. while (page <= 200) {
  1641. DDR_New r = ydClient.queryData(YDParam.builder()
  1642. .appType(whConf.getYidaAppType())
  1643. .systemToken(whConf.getYidaSystemToken())
  1644. .formUuid(formUuid)
  1645. .searchFieldJson(JSON.toJSONString(searchFieldJson))
  1646. .currentPage(page)
  1647. .pageSize(100)
  1648. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  1649. List<Map> data = (List<Map>) r.getData();
  1650. if (data == null || data.isEmpty()) {
  1651. break;
  1652. }
  1653. all.addAll(data);
  1654. if (data.size() < 100) {
  1655. break;
  1656. }
  1657. page++;
  1658. }
  1659. return all;
  1660. }
  1661. /**
  1662. * 按宜搭 gmtModified 时间窗口分页查询表单实例。
  1663. */
  1664. @SuppressWarnings("unchecked")
  1665. private List<Map> queryAllModified(String formUuid, String fromDate, String toDate) {
  1666. List<Map> all = new ArrayList<>();
  1667. int page = 1;
  1668. while (page <= 200) {
  1669. DDR_New result = ydClient.queryData(YDParam.builder()
  1670. .appType(whConf.getYidaAppType())
  1671. .systemToken(whConf.getYidaSystemToken())
  1672. .formUuid(formUuid)
  1673. .modifiedFromTimeGMT(fromDate)
  1674. .modifiedToTimeGMT(toDate)
  1675. .currentPage(page)
  1676. .pageSize(100)
  1677. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  1678. List<Map> data = result == null ? null : (List<Map>) result.getData();
  1679. if (data == null || data.isEmpty()) {
  1680. break;
  1681. }
  1682. all.addAll(data);
  1683. if (data.size() < 100) {
  1684. break;
  1685. }
  1686. page++;
  1687. }
  1688. return all;
  1689. }
  1690. // ============================================================
  1691. // 通用工具
  1692. // ============================================================
  1693. @SuppressWarnings("unchecked")
  1694. private List<Map> listOrEmpty(Map m, String k) {
  1695. if (m == null) {
  1696. return Collections.emptyList();
  1697. }
  1698. Object v = m.get(k);
  1699. if (v instanceof List) {
  1700. return (List<Map>) v;
  1701. }
  1702. return Collections.emptyList();
  1703. }
  1704. private static double num(Map m, String k) {
  1705. Object v = m == null ? null : m.get(k);
  1706. if (v == null || "".equals(v)) {
  1707. return 0d;
  1708. }
  1709. try {
  1710. return Double.parseDouble(String.valueOf(v));
  1711. } catch (NumberFormatException e) {
  1712. return 0d;
  1713. }
  1714. }
  1715. private long numLong(Map m, String k) {
  1716. return (long) num(m, k);
  1717. }
  1718. private static String str(Map m, String k) {
  1719. Object v = m == null ? null : m.get(k);
  1720. return v == null ? "" : String.valueOf(v);
  1721. }
  1722. /**
  1723. * 从 EmployeeField 值(数组)取第一个 userId;取不到时兜底返回文本
  1724. */
  1725. private String firstIdOrText(Map m, String fieldPrefix) {
  1726. Object idArr = m == null ? null : m.get(fieldPrefix + "_id");
  1727. if (idArr instanceof List && !((List) idArr).isEmpty()) {
  1728. Object first = ((List) idArr).get(0);
  1729. if (first != null) {
  1730. return String.valueOf(first);
  1731. }
  1732. }
  1733. Object nameArr = m == null ? null : m.get(fieldPrefix);
  1734. if (nameArr instanceof List && !((List) nameArr).isEmpty()) {
  1735. Object first = ((List) nameArr).get(0);
  1736. if (first != null) {
  1737. return String.valueOf(first);
  1738. }
  1739. }
  1740. return "";
  1741. }
  1742. private String firstEmployeeId(Map m, String fieldPrefix) {
  1743. Object idArr = m == null ? null : m.get(fieldPrefix + "_id");
  1744. if (!(idArr instanceof List) || ((List) idArr).isEmpty()) {
  1745. return "";
  1746. }
  1747. Object first = ((List) idArr).get(0);
  1748. return first == null ? "" : String.valueOf(first);
  1749. }
  1750. private String firstNonBlank(String... vals) {
  1751. for (String v : vals) {
  1752. if (StringUtils.isNotBlank(v)) {
  1753. return v;
  1754. }
  1755. }
  1756. return "";
  1757. }
  1758. /**
  1759. * yyyyMM → epoch ms(该月 1 号 00:00)
  1760. */
  1761. private long monthToTs(String monthText) {
  1762. int y = Integer.parseInt(monthText.substring(0, 4));
  1763. int m = Integer.parseInt(monthText.substring(4, 6));
  1764. Calendar c = Calendar.getInstance();
  1765. c.set(y, m - 1, 1, 0, 0, 0);
  1766. c.set(Calendar.MILLISECOND, 0);
  1767. return c.getTimeInMillis();
  1768. }
  1769. }