Browse Source

feat(workhours): 新增审批结果回写工时汇总表接口

审批结束或撤销后调 POST /approval/writeback?instanceId=&result= 回写工时汇总表
(人+天唯一)。同意→子表行已审批=明细工时, 拒绝/撤销→子表行已提交=0, 主表合计按
子表行幂等重算。入参即审批单表单实例ID, 用 ydClient retrieve_id 直查详情; 类型按
是否含 tableField_mmae8t99 结构判定。子表取数 <50 直接用 ==50 才递归取全; 子表
全量覆盖按白名单重建(成员/关联字段 _id 后缀); 10 线程 + RateLimiter(20QPS) + 重试。
返回统一 McR<ApprovalWriteBackResult>。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
malk 2 weeks ago
parent
commit
1ad67b28e6

+ 38 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/ApprovalController.java

@@ -0,0 +1,38 @@
+package com.malk.controller;
+
+import com.malk.server.common.McR;
+import com.malk.server.workhours.ApprovalWriteBackResult;
+import com.malk.service.workhours.ApprovalWriteBackService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 审批结果回写工时汇总表 — 接口
+ * <p>
+ * 审批结束或撤销后调用,传流程实例ID + 审批结果(0=拒绝/撤销,1=同意)。
+ * 异常由全局 {@link com.malk.filter.CatchException} 统一包装为 McR。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/approval")
+public class ApprovalController {
+
+    @Autowired
+    private ApprovalWriteBackService approvalWriteBackService;
+
+    /**
+     * 审批结果回写工时汇总表
+     * GET /approval/writeback?instanceId=xxx&result=1
+     *
+     * @param instanceId 流程实例ID
+     * @param result     审批结果:0=拒绝/撤销,1=同意
+     */
+    @PostMapping("/writeback")
+    public McR<ApprovalWriteBackResult> writeback(@RequestParam String instanceId,
+                                                  @RequestParam int result) {
+
+        log.info("writeback, {}, {}", instanceId, result);
+        return McR.success(approvalWriteBackService.writeBack(instanceId, result));
+    }
+}

+ 57 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/ApprovalWriteBackResult.java

@@ -0,0 +1,57 @@
+package com.malk.server.workhours;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 审批结果回写工时汇总表 — 返回对象
+ */
+@Data
+@Builder
+public class ApprovalWriteBackResult {
+
+    /**
+     * 审批单类型:工时审批 / 其他工时审批
+     */
+    private String formType;
+
+    /**
+     * 审批单表单实例ID
+     */
+    private String formInstanceId;
+
+    /**
+     * 审批结果:0=拒绝/撤销,1=同意
+     */
+    private int result;
+
+    /**
+     * 审批明细行数
+     */
+    private int detailRows;
+
+    /**
+     * 命中的汇总表日记录分组数
+     */
+    private int groups;
+
+    /**
+     * 成功定位并更新的汇总表记录数
+     */
+    private int hitRecords;
+
+    /**
+     * 更新的子表行数
+     */
+    private int updatedRows;
+
+    /**
+     * 未匹配到子表行 / 汇总记录的行数(warn)
+     */
+    private int missRows;
+
+    /**
+     * 处理失败的记录数
+     */
+    private int failRecords;
+}

+ 7 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/WHConf.java

@@ -17,6 +17,13 @@ public class WHConf {
 
     private String formUuidRequiredHours;
 
+    // prd 审批回写:工时汇总表 + 两类审批单
+    private String formUuidWorkHoursSummary;
+
+    private String formUuidApproval;
+
+    private String formUuidOtherApproval;
+
     private String yidaAppType;
 
     private String yidaSystemToken;

+ 501 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ApprovalWriteBackService.java

@@ -0,0 +1,501 @@
+package com.malk.service.workhours;
+
+import com.alibaba.fastjson.JSON;
+import com.google.common.util.concurrent.RateLimiter;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.workhours.ApprovalWriteBackResult;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.aliwork.YDService;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 审批结果回写工时汇总表
+ * <p>
+ * 审批结束或撤销后调用,入参为流程实例ID + 审批结果(0=拒绝/撤销,1=同意)。
+ * 工时审批含两个明细子表(项目/非项目工时),其他工时审批含一个明细子表。
+ * 明细按「填报人 + 工时日期(天) + 项目编号/Activity」1:1 对应工时汇总表(人+天一条)里的子表行:
+ * - 拒绝 → 对应子表行「已提交工时」置 0
+ * - 同意 → 对应子表行「已审批工时」= 该明细工时
+ * 主表合计按子表行重算(幂等,撤销/重发不翻倍)。
+ * <p>
+ * 设计文档:后端/阿科德斯/审批结果回写工时汇总表.md
+ */
+@Slf4j
+@Service
+public class ApprovalWriteBackService {
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private YDService ydService;
+
+    @Autowired
+    private WHConf whConf;
+
+    private static final int THREAD_POOL_SIZE = 10;
+    private static final int MAX_RETRY = 2;
+
+    /**
+     * 明细审批意见「同意」取值(单选,已确认)
+     */
+    private static final String OPINION_APPROVE = "同意";
+
+    // ===== 工时汇总表:记录定位键 + 主表合计字段 =====
+    private static final String SUMMARY_SUBMITTER_ID = "textField_mmbffvd9"; // 填报人ID(文本)
+    private static final String SUMMARY_DAY_TEXT = "textField_mmbffvdb";     // 工时日期(文本) yyyyMMdd
+
+    private static final String M_DAY_SUBMITTED = "numberField_mmd1smem";    // 已提交工时(日)
+    private static final String M_DAY_APPROVED = "numberField_mmeakgi4";     // 已审批工时(日)
+    private static final String M_BIL_SUBMITTED = "numberField_mmacxewa";    // Billable已提交
+    private static final String M_BIL_APPROVED = "numberField_mmacxewg";     // Billable已审批
+    private static final String M_NON_SUBMITTED = "numberField_mmad6jbz";    // Non Billable已提交
+    private static final String M_NON_APPROVED = "numberField_mmad6jc1";     // Non Billable已审批
+    private static final String M_OTH_SUBMITTED = "numberField_mmd1smeq";    // 已提交(其他)
+    private static final String M_OTH_APPROVED = "numberField_mmd1smes";     // 已审批(其他)
+
+    // ===== 工时汇总表:三个子表 =====
+    private static final String SUB_BIL_TABLE = "tableField_mmczo634";
+    private static final String SUB_BIL_KEY = "textField_mmacxew3";          // 项目编号
+    private static final String SUB_BIL_SUBMITTED = "numberField_mmczo636";  // 已提交工时
+    private static final String SUB_BIL_APPROVED = "numberField_mmczo637";   // 已审批工时
+
+    private static final String SUB_NON_TABLE = "tableField_mmczo63h";
+    private static final String SUB_NON_KEY = "textField_mmczo639";          // 项目编号
+    private static final String SUB_NON_SUBMITTED = "numberField_mmczo63c";
+    private static final String SUB_NON_APPROVED = "numberField_mmczo63d";
+
+    private static final String SUB_OTH_TABLE = "tableField_mmeakgid";
+    private static final String SUB_OTH_KEY = "selectField_mmeakgie";        // Activity
+    private static final String SUB_OTH_SUBMITTED = "numberField_mmeakgij";
+    private static final String SUB_OTH_APPROVED = "numberField_mmeakgii";
+
+    // ===== 审批单明细子表字段(按类别)=====
+    // 工时审批·项目工时明细(Billable)
+    private static final String AP_BIL_TABLE = "tableField_mmae8t99";
+    private static final String AP_BIL_USERID = "textField_mmd1ozk4";
+    private static final String AP_BIL_KEY = "textField_mmacxew3";           // 项目编号
+    private static final String AP_BIL_DAYTEXT = "textField_mmd8g654";
+    private static final String AP_BIL_HOURS = "numberField_mmacxew9";
+    private static final String AP_BIL_OPINION = "radioField_mpwhk294";
+    // 工时审批·非项目工时明细(Non Billable)
+    private static final String AP_NON_TABLE = "tableField_mmd1wu9h";
+    private static final String AP_NON_USERID = "textField_mmd1wu9f";
+    private static final String AP_NON_KEY = "textField_mmd1wu9d";           // 项目编号
+    private static final String AP_NON_DAYTEXT = "textField_mmd8g655";
+    private static final String AP_NON_HOURS = "numberField_mmd1wu9e";
+    private static final String AP_NON_OPINION = "radioField_mpwhk295";
+    // 其他工时审批·明细(与非项目同子表ID,但语义为 Activity)
+    private static final String AP_OTH_TABLE = "tableField_mmd1wu9h";
+    private static final String AP_OTH_USERID = "textField_mmd1wu9f";
+    private static final String AP_OTH_KEY = "selectField_mmeakgie";         // Activity
+    private static final String AP_OTH_DAYTEXT = "textField_mmd8g655";
+    private static final String AP_OTH_HOURS = "numberField_mmd1wu9e";
+    private static final String AP_OTH_OPINION = "radioField_mpwhk294";
+
+    private enum Cat {BILLABLE, NON_BILLABLE, OTHER}
+
+    /**
+     * 单条审批明细(归一化)
+     */
+    private static class DetailRow {
+        Cat cat;
+        String userId;
+        String dayText;
+        String key;       // 项目编号 / Activity
+        double hours;
+        boolean approve;  // 有效意见:true=同意, false=拒绝
+    }
+
+    /**
+     * 审批结果回写工时汇总表
+     *
+     * @param formInstanceId 流程实例ID
+     * @param result            审批结果:0=拒绝/撤销,1=同意
+     * @return 回写结果统计
+     */
+    public ApprovalWriteBackResult writeBack(String formInstanceId, int result) {
+        McException.assertAccessException(StringUtils.isBlank(formInstanceId), "实例ID不能为空");
+
+        // 1. 按实例ID查审批单表单详情(表单接口 retrieve_id),含主表 + 内联子表(子表内联上限 50)
+        Map approvalFormData = ydClient.queryData(YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formInstanceId(formInstanceId)
+                .build(), YDConf.FORM_QUERY.retrieve_id).getFormData();
+        log.info("[审批回写] 审批单详情 formInstanceId={} keys={}", formInstanceId,
+                approvalFormData == null ? null : approvalFormData.keySet());
+        McException.assertAccessException(approvalFormData == null || approvalFormData.isEmpty(),
+                "未查询到审批单数据,实例ID=" + formInstanceId);
+
+        // 2. 类型判定:工时审批含「项目工时明细」子表 tableField_mmae8t99,其他工时审批没有
+        boolean isOther = !approvalFormData.containsKey(AP_BIL_TABLE);
+        boolean approveWhole = (result == 1);
+
+        // 3. 取审批单明细(内联子表 <50 直接用,==50 才 queryDetails 递归取全,避免无效请求)
+        List<DetailRow> rows = new ArrayList<>();
+        if (isOther) {
+            rows.addAll(loadDetailRows(formInstanceId, approvalFormData, Cat.OTHER, approveWhole));
+        } else {
+            rows.addAll(loadDetailRows(formInstanceId, approvalFormData, Cat.BILLABLE, approveWhole));
+            rows.addAll(loadDetailRows(formInstanceId, approvalFormData, Cat.NON_BILLABLE, approveWhole));
+        }
+
+        // 4. 按汇总表记录键(填报人 + 工时日期文本)分组
+        Map<String, List<DetailRow>> groups = new LinkedHashMap<>();
+        for (DetailRow r : rows) {
+            if (StringUtils.isBlank(r.userId) || StringUtils.isBlank(r.dayText)) {
+                continue;
+            }
+            groups.computeIfAbsent(r.userId + "" + r.dayText, k -> new ArrayList<>()).add(r);
+        }
+
+        // 5. 并发逐「日记录」处理
+        AtomicInteger hitRecords = new AtomicInteger(0);
+        AtomicInteger updatedRows = new AtomicInteger(0);
+        AtomicInteger missRows = new AtomicInteger(0);
+        AtomicInteger failRecords = new AtomicInteger(0);
+
+        RateLimiter limiter = RateLimiter.create(20.0);
+        ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
+        try {
+            List<Future<?>> futures = new ArrayList<>();
+            for (Map.Entry<String, List<DetailRow>> e : groups.entrySet()) {
+                List<DetailRow> groupRows = e.getValue();
+                String userId = groupRows.get(0).userId;
+                String dayText = groupRows.get(0).dayText;
+                futures.add(executor.submit(() -> {
+                    for (int retry = 0; retry <= MAX_RETRY; retry++) {
+                        try {
+                            int[] r = applyToRecord(userId, dayText, groupRows, limiter);
+                            if (r == null) {
+                                // 未命中汇总表记录
+                                break;
+                            }
+                            hitRecords.incrementAndGet();
+                            updatedRows.addAndGet(r[0]);
+                            missRows.addAndGet(r[1]);
+                            return;
+                        } catch (Exception ex) {
+                            if (retry < MAX_RETRY) {
+                                sleep(1000L * (retry + 1));
+                            } else {
+                                failRecords.incrementAndGet();
+                                log.error("[审批回写] 记录处理失败 userId={} day={}", userId, dayText, ex);
+                            }
+                        }
+                    }
+                }));
+            }
+            for (Future<?> f : futures) {
+                try {
+                    f.get();
+                } catch (Exception ex) {
+                    log.error("[审批回写] 线程执行异常", ex);
+                }
+            }
+        } finally {
+            executor.shutdown();
+        }
+
+        ApprovalWriteBackResult stats = ApprovalWriteBackResult.builder()
+                .formType(isOther ? "其他工时审批" : "工时审批")
+                .formInstanceId(formInstanceId)
+                .result(result)
+                .detailRows(rows.size())
+                .groups(groups.size())
+                .hitRecords(hitRecords.get())
+                .updatedRows(updatedRows.get())
+                .missRows(missRows.get())
+                .failRecords(failRecords.get())
+                .build();
+        log.info("[审批回写] 完成 {}", stats);
+        return stats;
+    }
+
+    /**
+     * 处理单条汇总表日记录
+     *
+     * @return {updatedRows, missRows};汇总表无此记录返回 null
+     */
+    private int[] applyToRecord(String userId, String dayText, List<DetailRow> groupRows, RateLimiter limiter) {
+        // a. 定位汇总表记录(填报人ID + 工时日期文本,代码内精确过滤)
+        limiter.acquire();
+        List<Map> found = (List<Map>) ydClient.queryData(YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formUuid(whConf.getFormUuidWorkHoursSummary())
+                .searchFieldJson(JSON.toJSONString(UtilMap.map(
+                        SUMMARY_SUBMITTER_ID + ", " + SUMMARY_DAY_TEXT, userId, dayText)))
+                .build(), YDConf.FORM_QUERY.retrieve_search_form).getData();
+
+        String summaryInstId = null;
+        for (Map item : found) {
+            Map fd = (Map) item.get("formData");
+            if (fd == null) continue;
+            if (userId.equals(String.valueOf(fd.get(SUMMARY_SUBMITTER_ID)))
+                    && dayText.equals(String.valueOf(fd.get(SUMMARY_DAY_TEXT)))) {
+                summaryInstId = String.valueOf(item.get("formInstanceId"));
+                break;
+            }
+        }
+        if (StringUtils.isBlank(summaryInstId)) {
+            log.warn("[审批回写] 未命中汇总表记录 userId={} day={}", userId, dayText);
+            return null;
+        }
+
+        // b. 取汇总表记录完整数据(含三子表)
+        limiter.acquire();
+        Map formData = ydClient.queryData(YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formInstanceId(summaryInstId)
+                .build(), YDConf.FORM_QUERY.retrieve_id).getFormData();
+
+        Map<String, Object> updateData = new HashMap<>();
+        int updated = 0;
+        int miss = 0;
+
+        // c. 按类别分组本记录的明细,逐类别重建子表 + 置值
+        Map<Cat, List<DetailRow>> byCat = new EnumMap<>(Cat.class);
+        for (DetailRow r : groupRows) {
+            byCat.computeIfAbsent(r.cat, k -> new ArrayList<>()).add(r);
+        }
+
+        // 各类别已提交/已审批合计(受影响类别从重建行重算,未受影响沿用主表现值)
+        double bilSubmitted = num(formData, M_BIL_SUBMITTED), bilApproved = num(formData, M_BIL_APPROVED);
+        double nonSubmitted = num(formData, M_NON_SUBMITTED), nonApproved = num(formData, M_NON_APPROVED);
+        double othSubmitted = num(formData, M_OTH_SUBMITTED), othApproved = num(formData, M_OTH_APPROVED);
+
+        for (Map.Entry<Cat, List<DetailRow>> e : byCat.entrySet()) {
+            Cat cat = e.getKey();
+            String subTable = subTable(cat);
+            String keyField = subKey(cat);
+            String submittedField = subSubmitted(cat);
+            String approvedField = subApproved(cat);
+
+            // 子表取数控制:内联 <50 直接用,==50 才递归取全,避免全量覆盖时漏行被删
+            List<Map> rebuilt = rebuildRows(
+                    resolveDetailRows(summaryInstId, subTable, (List<Map>) formData.get(subTable)));
+
+            for (DetailRow dr : e.getValue()) {
+                Map target = null;
+                for (Map row : rebuilt) {
+                    if (dr.key != null && dr.key.equals(String.valueOf(row.get(keyField)))) {
+                        target = row;
+                        break;
+                    }
+                }
+                if (target == null) {
+                    miss++;
+                    log.warn("[审批回写] 子表行未匹配 cat={} key={} userId={} day={}", cat, dr.key, userId, dayText);
+                    continue;
+                }
+                if (dr.approve) {
+                    target.put(approvedField, dr.hours);
+                } else {
+                    target.put(submittedField, 0);
+                }
+                updated++;
+            }
+
+            updateData.put(subTable, rebuilt);
+            double sumSubmitted = sumRows(rebuilt, submittedField);
+            double sumApproved = sumRows(rebuilt, approvedField);
+            switch (cat) {
+                case BILLABLE:
+                    bilSubmitted = sumSubmitted;
+                    bilApproved = sumApproved;
+                    break;
+                case NON_BILLABLE:
+                    nonSubmitted = sumSubmitted;
+                    nonApproved = sumApproved;
+                    break;
+                case OTHER:
+                    othSubmitted = sumSubmitted;
+                    othApproved = sumApproved;
+                    break;
+            }
+        }
+
+        // d. 主表合计重算
+        updateData.put(M_BIL_SUBMITTED, bilSubmitted);
+        updateData.put(M_BIL_APPROVED, bilApproved);
+        updateData.put(M_NON_SUBMITTED, nonSubmitted);
+        updateData.put(M_NON_APPROVED, nonApproved);
+        updateData.put(M_OTH_SUBMITTED, othSubmitted);
+        updateData.put(M_OTH_APPROVED, othApproved);
+        updateData.put(M_DAY_SUBMITTED, bilSubmitted + nonSubmitted + othSubmitted);
+        updateData.put(M_DAY_APPROVED, bilApproved + nonApproved + othApproved);
+
+        // e. 全量覆盖更新(子表整组替换 + 主表合计)
+        limiter.acquire();
+        ydClient.operateData(YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formInstanceId(summaryInstId)
+                .updateFormDataJson(JSON.toJSONString(updateData))
+                .ignoreEmpty(false)
+                .useLatestVersion(true)
+                .build(), YDConf.FORM_OPERATION.update);
+
+        return new int[]{updated, miss};
+    }
+
+    /**
+     * 加载审批单某类别明细(内联子表优先,必要时递归取全)
+     */
+    private List<DetailRow> loadDetailRows(String formInstanceId, Map approvalFormData, Cat cat, boolean approveWhole) {
+        String tableId = apTable(cat);
+        List<Map> raw = resolveDetailRows(formInstanceId, tableId, (List<Map>) approvalFormData.get(tableId));
+
+        List<DetailRow> list = new ArrayList<>();
+        for (Map row : raw) {
+            DetailRow dr = new DetailRow();
+            dr.cat = cat;
+            dr.userId = str(row, apUserId(cat));
+            dr.dayText = str(row, apDayText(cat));
+            dr.key = str(row, apKey(cat));
+            dr.hours = num(row, apHours(cat));
+            String opinion = str(row, apOpinion(cat));
+            // 有效意见:整单拒绝/撤销 → 全拒绝;整单同意 → 看单条明细意见
+            dr.approve = approveWhole && OPINION_APPROVE.equals(opinion);
+            list.add(dr);
+        }
+        return list;
+    }
+
+    /**
+     * 子表取数控制:主表详情已内联返回子表时,行数 &lt;50 视为完整直接用;
+     * ==50(可能被宜搭截断)或内联缺失,才调 {@link YDService#queryDetails} 递归取全,避免无效请求。
+     * 复用 ydService 封装,不重复造分页轮子。
+     */
+    private List<Map> resolveDetailRows(String formInstanceId, String tableId, List<Map> inlineRows) {
+        if (inlineRows != null && inlineRows.size() < 50) {
+            return inlineRows;
+        }
+        return ydService.queryDetails(YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formInstanceId(formInstanceId)
+                .tableFieldId(tableId)
+                .pageNumber(1)
+                .build());
+    }
+
+    /**
+     * 重建子表行:保留全部字段,成员/关联字段用 _id 取真实ID(全量覆盖写回前置)
+     */
+    private List<Map> rebuildRows(List<Map> rows) {
+        List<Map> out = new ArrayList<>();
+        if (rows == null) {
+            return out;
+        }
+        for (Map row : rows) {
+            Map newRow = new HashMap();
+            for (Object keyObj : row.keySet()) {
+                String key = String.valueOf(keyObj);
+                if (key.endsWith("_value") || (key.startsWith("employeeField_") && !key.endsWith("_id"))) {
+                    continue;
+                }
+                String k = key;
+                if (key.endsWith("_id")) {
+                    if (key.startsWith("employeeField_") || key.startsWith("associationFormField_")) {
+                        k = key.substring(0, key.length() - 3);
+                    } else {
+                        continue;
+                    }
+                }
+                newRow.put(k, YDConf.getDataByCompId(row, k));
+            }
+            out.add(newRow);
+        }
+        return out;
+    }
+
+    // ===== 字段映射辅助 =====
+    private String apTable(Cat c) {
+        return c == Cat.BILLABLE ? AP_BIL_TABLE : (c == Cat.NON_BILLABLE ? AP_NON_TABLE : AP_OTH_TABLE);
+    }
+
+    private String apUserId(Cat c) {
+        return c == Cat.BILLABLE ? AP_BIL_USERID : (c == Cat.NON_BILLABLE ? AP_NON_USERID : AP_OTH_USERID);
+    }
+
+    private String apDayText(Cat c) {
+        return c == Cat.BILLABLE ? AP_BIL_DAYTEXT : (c == Cat.NON_BILLABLE ? AP_NON_DAYTEXT : AP_OTH_DAYTEXT);
+    }
+
+    private String apKey(Cat c) {
+        return c == Cat.BILLABLE ? AP_BIL_KEY : (c == Cat.NON_BILLABLE ? AP_NON_KEY : AP_OTH_KEY);
+    }
+
+    private String apHours(Cat c) {
+        return c == Cat.BILLABLE ? AP_BIL_HOURS : (c == Cat.NON_BILLABLE ? AP_NON_HOURS : AP_OTH_HOURS);
+    }
+
+    private String apOpinion(Cat c) {
+        return c == Cat.BILLABLE ? AP_BIL_OPINION : (c == Cat.NON_BILLABLE ? AP_NON_OPINION : AP_OTH_OPINION);
+    }
+
+    private String subTable(Cat c) {
+        return c == Cat.BILLABLE ? SUB_BIL_TABLE : (c == Cat.NON_BILLABLE ? SUB_NON_TABLE : SUB_OTH_TABLE);
+    }
+
+    private String subKey(Cat c) {
+        return c == Cat.BILLABLE ? SUB_BIL_KEY : (c == Cat.NON_BILLABLE ? SUB_NON_KEY : SUB_OTH_KEY);
+    }
+
+    private String subSubmitted(Cat c) {
+        return c == Cat.BILLABLE ? SUB_BIL_SUBMITTED : (c == Cat.NON_BILLABLE ? SUB_NON_SUBMITTED : SUB_OTH_SUBMITTED);
+    }
+
+    private String subApproved(Cat c) {
+        return c == Cat.BILLABLE ? SUB_BIL_APPROVED : (c == Cat.NON_BILLABLE ? SUB_NON_APPROVED : SUB_OTH_APPROVED);
+    }
+
+    // ===== 通用工具 =====
+    private double num(Map m, String k) {
+        Object v = m == null ? null : m.get(k);
+        if (v == null) {
+            return 0d;
+        }
+        try {
+            return Double.parseDouble(String.valueOf(v));
+        } catch (Exception e) {
+            return 0d;
+        }
+    }
+
+    private double sumRows(List<Map> rows, String field) {
+        double sum = 0d;
+        for (Map r : rows) {
+            sum += num(r, field);
+        }
+        return sum;
+    }
+
+    private String str(Map m, String k) {
+        Object v = m == null ? null : m.get(k);
+        return v == null ? "" : String.valueOf(v);
+    }
+
+    private void sleep(long ms) {
+        try {
+            Thread.sleep(ms);
+        } catch (InterruptedException ie) {
+            Thread.currentThread().interrupt();
+        }
+    }
+}

+ 4 - 0
mjava-akdsbeisen/src/main/resources/application-dev.yml

@@ -70,5 +70,9 @@ workhours:
   formUuidHoliday: "FORM-BMG66GA16LC4CNCZLUUTKAX0G27U3YTMC27NM1"
   formUuidPersonnel: "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP"
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"
+  # 审批回写:工时汇总表 + 两类审批单(当前指向测试环境,正式见注释)
+  formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
+  formUuidApproval: "FORM-230BB68C42304492A242CA278EF4A853LYIY"        # 正式 FORM-DDA1683645954101890AF6BA723705F9M0XV
+  formUuidOtherApproval: "FORM-4828E0E40CD34038825E8C6E25417B2718NF"   # 正式 FORM-BA14F6322DBD470F8CF84CCE131DEA31TIJS
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"