|
|
@@ -18,6 +18,8 @@ import org.apache.commons.lang3.StringUtils;
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
import java.util.*;
|
|
|
|
|
|
/**
|
|
|
@@ -61,6 +63,19 @@ public class ReSubmitApprovalService {
|
|
|
*/
|
|
|
private static final String APPROVAL_OPINION_AGREE = "同意";
|
|
|
|
|
|
+ private static final String SUMMARY_STATUS_IN_APPROVAL = "审批中";
|
|
|
+
|
|
|
+ private static final String SUMMARY_STATUS_DRAFT = "暂存";
|
|
|
+
|
|
|
+ private static final String SUMMARY_STATUS_APPROVED = "已审批";
|
|
|
+
|
|
|
+ private static final String S_BIL_SUBMITTED_TOTAL = "numberField_mmacxewa";
|
|
|
+ private static final String S_BIL_APPROVED_TOTAL = "numberField_mmacxewg";
|
|
|
+ private static final String S_NON_SUBMITTED_TOTAL = "numberField_mmad6jbz";
|
|
|
+ private static final String S_NON_APPROVED_TOTAL = "numberField_mmad6jc1";
|
|
|
+ private static final String S_OTHER_SUBMITTED_TOTAL = "numberField_mmd1smeq";
|
|
|
+ private static final String S_OTHER_APPROVED_TOTAL = "numberField_mmd1smes";
|
|
|
+
|
|
|
/**
|
|
|
* 宜搭单个子表组件可稳定查询和覆盖更新的最大行数。
|
|
|
*/
|
|
|
@@ -226,6 +241,20 @@ public class ReSubmitApprovalService {
|
|
|
* @return 结果统计
|
|
|
*/
|
|
|
public ReSubmitApprovalResult resubmit(String monthText, String submitterUserId) {
|
|
|
+ return resubmit(monthText, submitterUserId, false);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 对指定月份、指定员工执行补发起或只读预演。
|
|
|
+ *
|
|
|
+ * @param monthText yyyyMM
|
|
|
+ * @param submitterUserId 可空;空则处理该月所有员工
|
|
|
+ * @param dryRun true 时只统计候选,不创建或更新审批
|
|
|
+ * @return 执行或预演统计
|
|
|
+ */
|
|
|
+ public ReSubmitApprovalResult resubmit(String monthText,
|
|
|
+ String submitterUserId,
|
|
|
+ boolean dryRun) {
|
|
|
McException.assertAccessException(StringUtils.isBlank(monthText), "月份不能为空");
|
|
|
McException.assertAccessException(StringUtils.isBlank(whConf.getApprovalProcessCode()),
|
|
|
"工时审批 processCode 未配置(workhours.approvalProcessCode)");
|
|
|
@@ -262,6 +291,8 @@ public class ReSubmitApprovalService {
|
|
|
int billableCreated = 0, billableUpdated = 0;
|
|
|
int otherCreated = 0, otherUpdated = 0;
|
|
|
int skipped = 0, failed = 0;
|
|
|
+ int billableCandidates = 0, otherCandidates = 0;
|
|
|
+ double billableCandidateHours = 0, otherCandidateHours = 0;
|
|
|
|
|
|
// 3) 每个员工:构造 managerGroups + otherRows,处理 processManagerApproval / processOtherApproval
|
|
|
for (Map.Entry<String, List<Map>> e : perSubmitter.entrySet()) {
|
|
|
@@ -276,6 +307,11 @@ public class ReSubmitApprovalService {
|
|
|
}
|
|
|
// 3a) 项目工时:普通 PM 按人合单,Raymond 兜底按项目拆单
|
|
|
for (ManagerGroup mg : cr.managerGroups.values()) {
|
|
|
+ billableCandidates += mg.billableRows.size() + mg.nonBillableRows.size();
|
|
|
+ billableCandidateHours += mg.billableTotal + mg.nonBillableTotal;
|
|
|
+ if (dryRun) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
try {
|
|
|
List<String> actions = processManagerApproval(mg, monthText, uid);
|
|
|
for (String action : actions) {
|
|
|
@@ -293,6 +329,13 @@ public class ReSubmitApprovalService {
|
|
|
}
|
|
|
// 3b) 其他工时:一人一张单,approver 用该员工部门主管(记录中带),否则兜底 Raymond
|
|
|
if (!cr.otherRows.isEmpty()) {
|
|
|
+ otherCandidates += cr.otherRows.size();
|
|
|
+ for (OtherRow row : cr.otherRows) {
|
|
|
+ otherCandidateHours += row.hours;
|
|
|
+ }
|
|
|
+ if (dryRun) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
try {
|
|
|
List<String> actions = processOtherApproval(
|
|
|
cr.otherRows, cr.deptManagerId, monthText, uid);
|
|
|
@@ -314,20 +357,76 @@ public class ReSubmitApprovalService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // 4) 三档状态按汇总总额统一校正;覆盖无缺单但状态为空/错误的历史记录。
|
|
|
+ StatusSyncResult statusSync = reconcileSummaryApprovalStatuses(allRecords, dryRun);
|
|
|
+
|
|
|
ReSubmitApprovalResult r = ReSubmitApprovalResult.builder()
|
|
|
.monthText(monthText)
|
|
|
.submitterCount(perSubmitter.size())
|
|
|
+ .dryRun(dryRun)
|
|
|
+ .billableCandidates(billableCandidates)
|
|
|
+ .billableCandidateHours(billableCandidateHours)
|
|
|
+ .otherCandidates(otherCandidates)
|
|
|
+ .otherCandidateHours(otherCandidateHours)
|
|
|
.billableCreated(billableCreated)
|
|
|
.billableUpdated(billableUpdated)
|
|
|
.otherCreated(otherCreated)
|
|
|
.otherUpdated(otherUpdated)
|
|
|
.skipped(skipped)
|
|
|
.failed(failed)
|
|
|
+ .summaryStatusCandidates(statusSync.candidates)
|
|
|
+ .summaryStatusUpdated(statusSync.updated)
|
|
|
+ .summaryStatusFailed(statusSync.failed)
|
|
|
.build();
|
|
|
log.info("[补发起] 完成 {}", r);
|
|
|
return r;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 查询最近若干自然日发生修改的汇总记录,并按实际业务月份归集员工。
|
|
|
+ * <p>
|
|
|
+ * prd 定时兜底只跟踪增量修改,不按最近若干业务月份全量回扫;因此历史月份记录只要近期有变化也会命中。
|
|
|
+ *
|
|
|
+ * @param lookbackDays 最近自然日数量,最小按 1 天处理
|
|
|
+ * @return monthText -> submitterUserId 集合
|
|
|
+ */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ public Map<String, Set<String>> findRecentlyModifiedTargets(int lookbackDays) {
|
|
|
+ LocalDate today = LocalDate.now();
|
|
|
+ String[] window = modifiedDateWindow(lookbackDays, today);
|
|
|
+ List<Map> records = queryAllModified(
|
|
|
+ whConf.getFormUuidWorkHoursSummary(), window[0], window[1]);
|
|
|
+ Map<String, Set<String>> targets = new TreeMap<>();
|
|
|
+ for (Map record : records) {
|
|
|
+ Object rawFormData = record.get("formData");
|
|
|
+ if (!(rawFormData instanceof Map)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Map<String, Object> formData = (Map<String, Object>) rawFormData;
|
|
|
+ String monthText = str(formData, S_MONTH_TEXT);
|
|
|
+ String submitterUserId = str(formData, S_SUBMITTER_ID);
|
|
|
+ if (!monthText.matches("\\d{6}") || StringUtils.isBlank(submitterUserId)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ targets.computeIfAbsent(monthText, key -> new LinkedHashSet<>()).add(submitterUserId);
|
|
|
+ }
|
|
|
+ log.info("[补发起兜底] 增量扫描 from={} to={} records={} months={} targets={}",
|
|
|
+ window[0], window[1], records.size(), targets.size(),
|
|
|
+ targets.values().stream().mapToInt(Set::size).sum());
|
|
|
+ return targets;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算最近 N 个自然日的宜搭修改时间查询窗口,结束日加 1 以覆盖当天全天。
|
|
|
+ */
|
|
|
+ static String[] modifiedDateWindow(int lookbackDays, LocalDate today) {
|
|
|
+ int safeDays = Math.max(1, lookbackDays);
|
|
|
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
|
|
+ LocalDate fromDate = today.minusDays(safeDays - 1L);
|
|
|
+ LocalDate toDate = today.plusDays(1L);
|
|
|
+ return new String[]{fromDate.format(formatter), toDate.format(formatter)};
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 使用真人 System 账号发起 TimeCard 工时审批。
|
|
|
*
|
|
|
@@ -428,6 +527,84 @@ public class ReSubmitApprovalService {
|
|
|
String deptManagerId;
|
|
|
}
|
|
|
|
|
|
+ private static class StatusSyncResult {
|
|
|
+ int candidates;
|
|
|
+ int updated;
|
|
|
+ int failed;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 按主表提交/审批总额把状态统一校正为暂存、审批中或已审批。
|
|
|
+ */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private StatusSyncResult reconcileSummaryApprovalStatuses(List<Map> records,
|
|
|
+ boolean dryRun) {
|
|
|
+ StatusSyncResult result = new StatusSyncResult();
|
|
|
+ String fieldId = whConf.getSummaryApprovalStatusField();
|
|
|
+ if (StringUtils.isBlank(fieldId)) {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ for (Map record : records) {
|
|
|
+ Object rawFormData = record.get("formData");
|
|
|
+ if (!(rawFormData instanceof Map)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Map<String, Object> formData = (Map<String, Object>) rawFormData;
|
|
|
+ String targetStatus = expectedSummaryApprovalStatus(formData);
|
|
|
+ String currentStatus = str(formData, fieldId);
|
|
|
+ String formInstanceId = firstNonBlank(
|
|
|
+ str(record, "formInstanceId"),
|
|
|
+ str(record, "formInstId"));
|
|
|
+ if (StringUtils.isBlank(formInstanceId) || targetStatus.equals(currentStatus)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ result.candidates++;
|
|
|
+ if (dryRun) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Map<String, Object> updateData = new HashMap<>();
|
|
|
+ updateData.put(fieldId, targetStatus);
|
|
|
+ ydClient.operateData(YDParam.builder()
|
|
|
+ .appType(whConf.getYidaAppType())
|
|
|
+ .systemToken(whConf.getYidaSystemToken())
|
|
|
+ .formInstanceId(formInstanceId)
|
|
|
+ .updateFormDataJson(JSON.toJSONString(updateData))
|
|
|
+ .ignoreEmpty(false)
|
|
|
+ .useLatestVersion(true)
|
|
|
+ .build(), YDConf.FORM_OPERATION.update);
|
|
|
+ result.updated++;
|
|
|
+ } catch (Exception ex) {
|
|
|
+ result.failed++;
|
|
|
+ log.error("[补发起] 汇总三档状态校正失败 formInstanceId={} target={}",
|
|
|
+ formInstanceId, targetStatus, ex);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据汇总主表三类已提交/已审批总额计算状态。
|
|
|
+ */
|
|
|
+ static String expectedSummaryApprovalStatus(Map<String, Object> formData) {
|
|
|
+ double submitted = num(formData, S_BIL_SUBMITTED_TOTAL)
|
|
|
+ + num(formData, S_NON_SUBMITTED_TOTAL)
|
|
|
+ + num(formData, S_OTHER_SUBMITTED_TOTAL);
|
|
|
+ double approved = num(formData, S_BIL_APPROVED_TOTAL)
|
|
|
+ + num(formData, S_NON_APPROVED_TOTAL)
|
|
|
+ + num(formData, S_OTHER_APPROVED_TOTAL);
|
|
|
+ if (submitted > approved + 1e-6) {
|
|
|
+ return SUMMARY_STATUS_IN_APPROVAL;
|
|
|
+ }
|
|
|
+ if (approved > 1e-6) {
|
|
|
+ return SUMMARY_STATUS_APPROVED;
|
|
|
+ }
|
|
|
+ if (submitted > 1e-6) {
|
|
|
+ return SUMMARY_STATUS_IN_APPROVAL;
|
|
|
+ }
|
|
|
+ return SUMMARY_STATUS_DRAFT;
|
|
|
+ }
|
|
|
+
|
|
|
private CollectResult collectApprovalData(String uid,
|
|
|
String monthText,
|
|
|
List<Map> records,
|
|
|
@@ -462,7 +639,10 @@ public class ReSubmitApprovalService {
|
|
|
continue;
|
|
|
}
|
|
|
String projectCode = str(row, S_BIL_PROJECT_CODE);
|
|
|
- String coverageKey = approvalKey("billable", uid, dayText, projectCode);
|
|
|
+ String projectName = firstNonBlank(
|
|
|
+ str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME));
|
|
|
+ String coverageKey = approvalKey("billable", uid, dayText,
|
|
|
+ approvalDetailKey(projectCode, projectName));
|
|
|
if (!seenBillableKeys.add(coverageKey)
|
|
|
|| approvalCoverage.shouldSkip(coverageKey, approved + change)) {
|
|
|
continue;
|
|
|
@@ -470,8 +650,6 @@ public class ReSubmitApprovalService {
|
|
|
String pmRaw = firstIdOrText(row, S_BIL_MANAGER);
|
|
|
final String pm = projectManagerCache.computeIfAbsent(
|
|
|
StringUtils.defaultString(pmRaw), this::resolveProjectManager);
|
|
|
- String projectName = firstNonBlank(
|
|
|
- str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME));
|
|
|
String groupKey = approvalGroupKey(pm, projectCode, projectName);
|
|
|
ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> {
|
|
|
ManagerGroup m = new ManagerGroup();
|
|
|
@@ -502,7 +680,10 @@ public class ReSubmitApprovalService {
|
|
|
continue;
|
|
|
}
|
|
|
String projectCode = str(row, S_NON_PROJECT_CODE);
|
|
|
- String coverageKey = approvalKey("nonBillable", uid, dayText, projectCode);
|
|
|
+ String projectName = firstNonBlank(
|
|
|
+ str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME));
|
|
|
+ String coverageKey = approvalKey("nonBillable", uid, dayText,
|
|
|
+ approvalDetailKey(projectCode, projectName));
|
|
|
if (!seenNonBillableKeys.add(coverageKey)
|
|
|
|| approvalCoverage.shouldSkip(coverageKey, approved + change)) {
|
|
|
continue;
|
|
|
@@ -510,8 +691,6 @@ public class ReSubmitApprovalService {
|
|
|
String pmRaw = firstIdOrText(row, S_NON_MANAGER);
|
|
|
final String pm = projectManagerCache.computeIfAbsent(
|
|
|
StringUtils.defaultString(pmRaw), this::resolveProjectManager);
|
|
|
- String projectName = firstNonBlank(
|
|
|
- str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME));
|
|
|
String groupKey = approvalGroupKey(pm, projectCode, projectName);
|
|
|
ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> {
|
|
|
ManagerGroup m = new ManagerGroup();
|
|
|
@@ -542,7 +721,8 @@ public class ReSubmitApprovalService {
|
|
|
continue;
|
|
|
}
|
|
|
String activity = str(row, S_OTH_ACTIVITY);
|
|
|
- String coverageKey = approvalKey("other", uid, dayText, activity);
|
|
|
+ String coverageKey = approvalKey("other", uid, dayText,
|
|
|
+ approvalDetailKey(activity, ""));
|
|
|
if (!seenOtherKeys.add(coverageKey)
|
|
|
|| approvalCoverage.shouldSkip(coverageKey, approved + change)) {
|
|
|
continue;
|
|
|
@@ -706,6 +886,7 @@ public class ReSubmitApprovalService {
|
|
|
O_SUB_SUBMITTER_UID,
|
|
|
O_SUB_DAY_TEXT,
|
|
|
O_SUB_ACTIVITY,
|
|
|
+ null,
|
|
|
O_SUB_HOURS,
|
|
|
O_SUB_OPINION);
|
|
|
return;
|
|
|
@@ -723,6 +904,7 @@ public class ReSubmitApprovalService {
|
|
|
A_BIL_SUBMITTER_UID,
|
|
|
A_BIL_DAY_TEXT,
|
|
|
A_BIL_PROJECT_CODE,
|
|
|
+ A_BIL_PROJECT_NAME,
|
|
|
A_BIL_HOURS,
|
|
|
A_BIL_OPINION);
|
|
|
collectCoverageRows(
|
|
|
@@ -738,6 +920,7 @@ public class ReSubmitApprovalService {
|
|
|
A_NON_SUBMITTER_UID,
|
|
|
A_NON_DAY_TEXT,
|
|
|
A_NON_PROJECT_CODE,
|
|
|
+ A_NON_PROJECT_NAME,
|
|
|
A_NON_HOURS,
|
|
|
A_NON_OPINION);
|
|
|
}
|
|
|
@@ -753,12 +936,15 @@ public class ReSubmitApprovalService {
|
|
|
String uidField,
|
|
|
String dayField,
|
|
|
String detailKeyField,
|
|
|
+ String detailFallbackField,
|
|
|
String hoursField,
|
|
|
String opinionField) {
|
|
|
for (Map row : rows) {
|
|
|
String uid = str(row, uidField);
|
|
|
String day = str(row, dayField);
|
|
|
- String detailKey = str(row, detailKeyField);
|
|
|
+ String detailKey = approvalDetailKey(
|
|
|
+ str(row, detailKeyField),
|
|
|
+ detailFallbackField == null ? "" : str(row, detailFallbackField));
|
|
|
if (StringUtils.isAnyBlank(uid, day, detailKey)) {
|
|
|
continue;
|
|
|
}
|
|
|
@@ -837,6 +1023,20 @@ public class ReSubmitApprovalService {
|
|
|
return category + "|" + uid + "|" + day + "|" + detailKey;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 审批明细业务标识优先使用编号;历史数据编号为空时退回项目名称。
|
|
|
+ *
|
|
|
+ * @param primary 编号或活动类型
|
|
|
+ * @param fallback 项目名称兜底
|
|
|
+ * @return 带来源前缀的稳定业务标识
|
|
|
+ */
|
|
|
+ static String approvalDetailKey(String primary, String fallback) {
|
|
|
+ if (StringUtils.isNotBlank(primary)) {
|
|
|
+ return "id:" + primary;
|
|
|
+ }
|
|
|
+ return "name:" + StringUtils.defaultString(fallback);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* processManagerApproval 等价 — queryProcess(RUNNING) 有则 update / 无则 create。
|
|
|
* COMPLETED 覆盖已在 collectApprovalData 前过滤,避免历史已完成单被重复发起。
|
|
|
@@ -1578,6 +1778,36 @@ public class ReSubmitApprovalService {
|
|
|
return all;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 按宜搭 gmtModified 时间窗口分页查询表单实例。
|
|
|
+ */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private List<Map> queryAllModified(String formUuid, String fromDate, String toDate) {
|
|
|
+ List<Map> all = new ArrayList<>();
|
|
|
+ int page = 1;
|
|
|
+ while (page <= 200) {
|
|
|
+ DDR_New result = ydClient.queryData(YDParam.builder()
|
|
|
+ .appType(whConf.getYidaAppType())
|
|
|
+ .systemToken(whConf.getYidaSystemToken())
|
|
|
+ .formUuid(formUuid)
|
|
|
+ .modifiedFromTimeGMT(fromDate)
|
|
|
+ .modifiedToTimeGMT(toDate)
|
|
|
+ .currentPage(page)
|
|
|
+ .pageSize(100)
|
|
|
+ .build(), YDConf.FORM_QUERY.retrieve_search_form);
|
|
|
+ List<Map> data = result == null ? null : (List<Map>) result.getData();
|
|
|
+ if (data == null || data.isEmpty()) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ all.addAll(data);
|
|
|
+ if (data.size() < 100) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ page++;
|
|
|
+ }
|
|
|
+ return all;
|
|
|
+ }
|
|
|
+
|
|
|
// ============================================================
|
|
|
// 通用工具
|
|
|
// ============================================================
|
|
|
@@ -1594,7 +1824,7 @@ public class ReSubmitApprovalService {
|
|
|
return Collections.emptyList();
|
|
|
}
|
|
|
|
|
|
- private double num(Map m, String k) {
|
|
|
+ private static double num(Map m, String k) {
|
|
|
Object v = m == null ? null : m.get(k);
|
|
|
if (v == null || "".equals(v)) {
|
|
|
return 0d;
|