12 Commits 38b846cfdb ... e175e235b4

Autor SHA1 Mensaje Fecha
  malk e175e235b4 docs(workhours): 完成应填报工时文档收敛 hace 3 semanas
  malk 7429f65755 docs(workhours): 制定文档收敛执行计划 hace 3 semanas
  malk c1132c5275 docs(workhours): 设计文档目录重构方案 hace 3 semanas
  malk b343217817 docs(workhours): 归档重复与离职清理结果 hace 3 semanas
  malk baaeaf627b feat(workhours): 增加重复数据安全清理接口 hace 3 semanas
  malk f2b2827d9b test(workhours): 锁定离职日期过滤边界 hace 3 semanas
  malk 1596fa0265 feat(workhours): 增加重复记录保留规则 hace 3 semanas
  malk daea7ec25e docs(workhours): 制定应报工时数据清理计划 hace 3 semanas
  malk 9d0f2debfd docs(workhours): 设计重复与离职后数据清理方案 hace 3 semanas
  malk ec39476819 fix(workhours): 完成工时补漏清理与历史回填 hace 3 semanas
  malk 0fbb0c160b feat(workhours): sync 加防御开关: status=离职 且 offlineDate=空 时跳过, 避免误写 hace 4 semanas
  malk bcecf963c0 feat(workhours+personnel): 离职时间追溯 + 外部员工按天算 PM + 项目变更审批增量同步 hace 4 semanas
Se han modificado 18 ficheros con 3009 adiciones y 200 borrados
  1. 386 0
      docs/superpowers/plans/2026-07-15-workhours-data-cleanup.md
  2. 379 0
      docs/superpowers/plans/2026-07-15-workhours-docs-restructure.md
  3. 119 0
      docs/superpowers/specs/2026-07-15-workhours-data-cleanup-design.md
  4. 159 0
      docs/superpowers/specs/2026-07-15-workhours-docs-restructure-design.md
  5. 5 0
      mjava-akdsbeisen/pom.xml
  6. 124 0
      mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java
  7. 1 0
      mjava-akdsbeisen/src/main/java/com/malk/server/personnel/PersonnelSyncConf.java
  8. 26 0
      mjava-akdsbeisen/src/main/java/com/malk/server/workhours/WHConf.java
  9. 32 2
      mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java
  10. 1190 189
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java
  11. 153 0
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursDuplicateResolver.java
  12. 41 0
      mjava-akdsbeisen/src/main/java/com/malk/timer/ProjectChangeSyncTimer.java
  13. 9 7
      mjava-akdsbeisen/src/main/java/com/malk/timer/WorkHoursCalcTimer.java
  14. 7 2
      mjava-akdsbeisen/src/main/resources/application-dev.yml
  15. 4 0
      mjava-akdsbeisen/src/main/resources/application-prod.yml
  16. 260 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java
  17. 53 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursDuplicateResolverTest.java
  18. 61 0
      mjava-akdsbeisen/src/test/java/com/malk/timer/WorkHoursTimerScheduleTest.java

+ 386 - 0
docs/superpowers/plans/2026-07-15-workhours-data-cleanup.md

@@ -0,0 +1,386 @@
+# Workhours Data Cleanup Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Safely identify and delete duplicate required-hours records plus records after an employee's offline date, while preserving all other empty or unmatched data.
+
+**Architecture:** Add a package-private pure resolver that deterministically selects one keeper per employee/date key. `WorkHoursCalcService` will scan required-hours records by month, exclude post-offline records from duplicate grouping so the two deletion sets are disjoint, and expose a dry-run-first controller endpoint. Production deletion remains blocked until online dry-run counts are reported and the user confirms a second time.
+
+**Tech Stack:** Java 8, Spring Boot 2.1, JUnit 4, Mockito, DingTalk YiDa form APIs, Maven.
+
+## 2026-07-15 实际执行修订
+
+用户确认实际顺序调整为:隔离分支本地启动服务处理正式宜搭数据 → 更新文档归档 → 最后整理提交并部署。没有为了 dry-run 先部署生产服务。
+
+当前结果:
+
+- 重复记录删除 35 条,失败 0。
+- 离职日后记录删除 796 条,失败 0。
+- 删除后重复、离职后、未来日期三项 dry-run 的 `toDelete` 均为 0。
+- 两份业务文档已更新;最终生产部署仍待执行。
+
+---
+
+### Task 1: Deterministic Duplicate Resolver
+
+**Files:**
+- Create: `mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursDuplicateResolver.java`
+- Create: `mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursDuplicateResolverTest.java`
+
+- [ ] **Step 1: Write the failing resolver tests**
+
+Create tests for completeness, modified-time tie-breaking, and invalid keys:
+
+```java
+@Test
+public void resolveShouldKeepMostCompleteCandidate() {
+    Candidate sparse = candidate("old", "u1|2026-07-15", 2, 200L, 200L);
+    Candidate complete = candidate("complete", "u1|2026-07-15", 5, 100L, 100L);
+
+    Resolution result = WorkHoursDuplicateResolver.resolve(Arrays.asList(sparse, complete));
+
+    assertEquals(Collections.singletonList("old"), result.getDeleteInstanceIds());
+    assertEquals("complete", result.getGroups().get(0).getKeepInstanceId());
+}
+
+@Test
+public void resolveShouldKeepMostRecentlyModifiedWhenCompletenessMatches() {
+    Candidate older = candidate("older", "u1|2026-07-15", 5, 100L, 100L);
+    Candidate newer = candidate("newer", "u1|2026-07-15", 5, 200L, 100L);
+
+    Resolution result = WorkHoursDuplicateResolver.resolve(Arrays.asList(older, newer));
+
+    assertEquals(Collections.singletonList("older"), result.getDeleteInstanceIds());
+}
+
+@Test
+public void resolveShouldPreserveCandidatesWithoutReliableKey() {
+    Resolution result = WorkHoursDuplicateResolver.resolve(Collections.singletonList(
+            candidate("invalid", null, 5, 200L, 100L)));
+
+    assertEquals(1, result.getSkippedInvalidKey());
+    assertTrue(result.getDeleteInstanceIds().isEmpty());
+}
+```
+
+- [ ] **Step 2: Run the resolver tests and verify RED**
+
+Run:
+
+```bash
+mvn -pl mjava-akdsbeisen -am \
+  -Dmaven.test.skip=false -DskipTests=false \
+  -Dsurefire.failIfNoSpecifiedTests=false \
+  -Dtest=WorkHoursDuplicateResolverTest test
+```
+
+Expected: test compilation fails because `WorkHoursDuplicateResolver` does not exist.
+
+- [ ] **Step 3: Implement the minimal resolver**
+
+Create package-private typed classes:
+
+```java
+final class WorkHoursDuplicateResolver {
+    private WorkHoursDuplicateResolver() {
+    }
+
+    static Resolution resolve(List<Candidate> candidates) {
+        Map<String, List<Candidate>> grouped = new LinkedHashMap<>();
+        int skippedInvalidKey = 0;
+        for (Candidate candidate : candidates) {
+            if (candidate.getKey() == null || candidate.getKey().isEmpty()) {
+                skippedInvalidKey++;
+                continue;
+            }
+            grouped.computeIfAbsent(candidate.getKey(), key -> new ArrayList<>()).add(candidate);
+        }
+
+        List<DuplicateGroup> groups = new ArrayList<>();
+        List<String> deleteIds = new ArrayList<>();
+        for (Map.Entry<String, List<Candidate>> entry : grouped.entrySet()) {
+            List<Candidate> group = entry.getValue();
+            if (group.size() <= 1) continue;
+            group.sort(KEEPER_ORDER);
+            Candidate keeper = group.get(0);
+            List<String> groupDeleteIds = group.subList(1, group.size()).stream()
+                    .map(Candidate::getInstanceId)
+                    .collect(Collectors.toList());
+            deleteIds.addAll(groupDeleteIds);
+            groups.add(new DuplicateGroup(entry.getKey(), keeper.getInstanceId(), groupDeleteIds));
+        }
+        return new Resolution(grouped.size(), skippedInvalidKey, groups, deleteIds);
+    }
+}
+```
+
+`KEEPER_ORDER` sorts completeness, modified time, and created time descending, then instance ID ascending. Add explicit constructors and typed getters for `Candidate`, `DuplicateGroup`, and `Resolution` because the project targets Java 8.
+
+- [ ] **Step 4: Run the resolver tests and verify GREEN**
+
+Run the command from Step 2.
+
+Expected: 3 tests, 0 failures, 0 errors.
+
+- [ ] **Step 5: Commit the resolver**
+
+```bash
+git add mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursDuplicateResolver.java \
+        mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursDuplicateResolverTest.java
+git commit -m "feat(workhours): 增加重复记录保留规则"
+```
+
+### Task 2: Lock the Offline-Date Boundary
+
+**Files:**
+- Modify: `mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java`
+- Modify: `mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java`
+
+- [ ] **Step 1: Write a failing boundary test**
+
+```java
+@Test
+public void isAfterOfflineDateShouldKeepOfflineDayAndRejectFollowingDay() {
+    LocalDate offlineDate = LocalDate.of(2026, 7, 15);
+
+    assertFalse(WorkHoursCalcService.isAfterOfflineDate(offlineDate, offlineDate));
+    assertTrue(WorkHoursCalcService.isAfterOfflineDate(offlineDate.plusDays(1), offlineDate));
+    assertFalse(WorkHoursCalcService.isAfterOfflineDate(offlineDate.plusDays(1), null));
+}
+```
+
+- [ ] **Step 2: Run the service test and verify RED**
+
+Run:
+
+```bash
+mvn -pl mjava-akdsbeisen -am \
+  -Dmaven.test.skip=false -DskipTests=false \
+  -Dsurefire.failIfNoSpecifiedTests=false \
+  -Dtest=WorkHoursCalcServiceTest test
+```
+
+Expected: test compilation fails because `isAfterOfflineDate` does not exist.
+
+- [ ] **Step 3: Implement and reuse the boundary helper**
+
+```java
+static boolean isAfterOfflineDate(LocalDate workDay, LocalDate offlineDate) {
+    return workDay != null && offlineDate != null && workDay.isAfter(offlineDate);
+}
+```
+
+Replace the three direct `workDay.isAfter(offlineDate)` checks in single-day sync, concurrent upsert, and offline cleanup with this helper.
+
+- [ ] **Step 4: Run the service test and verify GREEN**
+
+Run the command from Step 2.
+
+Expected: all `WorkHoursCalcServiceTest` tests pass.
+
+- [ ] **Step 5: Commit the boundary test and refactor**
+
+```bash
+git add mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java \
+        mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java
+git commit -m "test(workhours): 锁定离职日期过滤边界"
+```
+
+### Task 3: Dry-Run-First Duplicate Cleanup Endpoint
+
+**Files:**
+- Modify: `mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java`
+- Modify: `mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java`
+- Modify: `mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java`
+
+- [ ] **Step 1: Write a failing dry-run integration test**
+
+Mock two required-hours records with the same employee/date. Make one candidate more complete, and verify dry-run reports one deletion without invoking YiDa delete:
+
+```java
+@Test
+public void cleanupDuplicateHoursDryRunShouldReportOneDeletionWithoutDeleting() {
+    YDClient ydClient = mock(YDClient.class);
+    WHConf conf = requiredHoursConf();
+    DDR_New<Object> page = pageOf(
+            requiredHoursRecord("sparse", "employee-1", LocalDate.of(2026, 7, 15)),
+            completeRequiredHoursRecord("complete", "employee-1", LocalDate.of(2026, 7, 15)));
+    when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+            .thenReturn(personnelPageWithoutOfflineDate(), page, emptyPage());
+    WorkHoursCalcService service = serviceWith(ydClient, conf);
+
+    Map<String, Object> stats = service.cleanupDuplicateHours(true);
+
+    assertEquals(1, stats.get("duplicateGroups"));
+    assertEquals(1, stats.get("toDelete"));
+    assertEquals(0, stats.get("deleted"));
+    verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.delete_batch));
+}
+```
+
+- [ ] **Step 2: Run the service test and verify RED**
+
+Run the Task 2 test command.
+
+Expected: compilation fails because `cleanupDuplicateHours` does not exist.
+
+- [ ] **Step 3: Implement monthly scan and candidate mapping**
+
+Add:
+
+```java
+public Map<String, Object> cleanupDuplicateHours(boolean dryRun)
+```
+
+Implementation requirements:
+
+1. Load all personnel details and build `userId -> offlineDate`.
+2. Scan required-hours records from 2026-04 through the current month using the existing monthly date-range pattern.
+3. Build candidate key only when employee and work day are both present.
+4. Exclude `isAfterOfflineDate(workDay, offlineDate)` records from duplicate grouping and count them in `excludedAfterOffline`; these are handled by `cleanupAfterOffline`.
+5. Score completeness using hours, Manager, employee number, attribute, department, company, and CF fields.
+6. Normalize `gmtModified` and `gmtCreate` to epoch milliseconds when numeric; use 0 for unavailable values.
+7. Resolve duplicates with `WorkHoursDuplicateResolver`.
+8. In dry-run mode, return stats and up to five group samples without calling delete.
+9. In formal mode, delete resolver IDs in batches of at most 100 and report `deleted` and `fail`.
+
+- [ ] **Step 4: Add the controller endpoint**
+
+```java
+@GetMapping("/cleanup-duplicates")
+public Map<String, Object> cleanupDuplicates(
+        @RequestParam(defaultValue = "true") boolean dryRun) {
+    Map<String, Object> result = new LinkedHashMap<>();
+    try {
+        long start = System.currentTimeMillis();
+        Map<String, Object> stats = workHoursCalcService.cleanupDuplicateHours(dryRun);
+        result.put("success", true);
+        result.put("message", dryRun ? "重复工时清理预览完成(未删除)" : "重复工时清理完成");
+        result.put("stats", stats);
+        result.put("costMs", System.currentTimeMillis() - start);
+    } catch (Exception e) {
+        log.error("重复工时清理失败", e);
+        result.put("success", false);
+        result.put("message", e.getMessage());
+    }
+    return result;
+}
+```
+
+Default `dryRun=true` is intentional so an omitted parameter cannot delete production data.
+
+- [ ] **Step 5: Run focused tests and verify GREEN**
+
+Run:
+
+```bash
+mvn -pl mjava-akdsbeisen -am \
+  -Dmaven.test.skip=false -DskipTests=false \
+  -Dsurefire.failIfNoSpecifiedTests=false \
+  -Dtest=WorkHoursDuplicateResolverTest,WorkHoursCalcServiceTest,WorkHoursTimerScheduleTest test
+```
+
+Expected: all focused tests pass with 0 failures and 0 errors.
+
+- [ ] **Step 6: Commit the endpoint**
+
+```bash
+git add mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java \
+        mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java \
+        mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java
+git commit -m "feat(workhours): 增加重复数据安全清理接口"
+```
+
+### Task 4: Verify, Deploy the Dry-Run Capability, and Audit Production
+
+**Files:**
+- Verify only; no additional source files.
+
+- [ ] **Step 1: Run the final build**
+
+```bash
+mvn -q -pl mjava-akdsbeisen -am clean package \
+  -Dmaven.test.skip=false -DskipTests=false \
+  -Dsurefire.failIfNoSpecifiedTests=false \
+  -Dtest=WorkHoursDuplicateResolverTest,WorkHoursCalcServiceTest,WorkHoursTimerScheduleTest
+```
+
+Expected: exit code 0; all test report XML files show zero failures and errors.
+
+- [ ] **Step 2: Verify source and artifact**
+
+```bash
+git diff --check HEAD~3 HEAD
+jar tf mjava-akdsbeisen/target/mjava-akdsbeisen.jar | rg '(^|/)h2-[^/]*\.jar$' || true
+shasum -a 256 mjava-akdsbeisen/target/mjava-akdsbeisen.jar
+```
+
+Expected: no diff errors, no H2 dependency, and a SHA-256 value.
+
+- [ ] **Step 3: Deploy using the already approved protected workflow**
+
+Use the isolated JAR, preserve the original dirty repository's local JAR, and run:
+
+```bash
+/Users/malk/.agents/skills/mcli/deploy/bin/deploy.sh akds --skip-build --backup --yes
+```
+
+Expected: remote backup created, upload completes, and `./server.sh status` reports RUNNING with prod profile.
+
+- [ ] **Step 4: Run online read-only audits**
+
+```bash
+curl -sS 'http://127.0.0.1:9055/api/akds/workhours/cleanup-duplicates?dryRun=true'
+curl -sS 'http://127.0.0.1:9055/api/akds/workhours/cleanup-after-offline?dryRun=true'
+```
+
+Expected: duplicate audit returns exact disjoint duplicate counts; offline audit continues to return 796 before deletion.
+
+- [ ] **Step 5: Stop before deletion and request confirmation**
+
+Report target form, duplicate groups, duplicate delete count, offline delete count, combined delete count, keeper rule, and samples. Do not call either formal endpoint until the user explicitly confirms.
+
+### Task 5: Execute Confirmed Deletion and Verify Idempotency
+
+**Files:**
+- Modify after successful deletion: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时月度计算.md`
+- Modify after successful deletion: `/Users/malk/Desktop/Tech/claude/临时/阿科德斯-应报工时修复-2026-07-15.md`
+
+- [x] **Step 1: Delete valid-record duplicates after confirmation**
+
+```bash
+curl -sS 'http://127.0.0.1:9055/api/akds/workhours/cleanup-duplicates?dryRun=false'
+```
+
+Expected: `deleted == toDelete` and `fail == 0`.
+
+- [x] **Step 2: Delete post-offline records**
+
+```bash
+curl -sS 'http://127.0.0.1:9055/api/akds/workhours/cleanup-after-offline?dryRun=false'
+```
+
+Expected: `deleted == toDelete` and `fail == 0`.
+
+- [x] **Step 3: Re-run all read-only audits**
+
+```bash
+curl -sS 'http://127.0.0.1:9055/api/akds/workhours/cleanup-duplicates?dryRun=true'
+curl -sS 'http://127.0.0.1:9055/api/akds/workhours/cleanup-after-offline?dryRun=true'
+curl -sS 'http://127.0.0.1:9055/api/akds/workhours/cleanup-future?dryRun=true'
+```
+
+Expected: every response has `toDelete=0` and `fail=0`.
+
+- [x] **Step 4: Update both workhours documents**
+
+Record actual duplicate groups, duplicate deletions, offline deletions, verification results, commit IDs, and deployment state. Keep unmatched personnel and empty-source preservation rules explicit.
+
+- [ ] **Step 5: Final repository and remote verification**
+
+```bash
+git status --short
+ssh root@120.55.113.155 'cd /home/server/akds && ./server.sh status'
+```
+
+Expected: code worktree clean and remote service RUNNING.

+ 379 - 0
docs/superpowers/plans/2026-07-15-workhours-docs-restructure.md

@@ -0,0 +1,379 @@
+# 应填报工时文档重构 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 把应填报工时的业务规则、程序逻辑、运维治理和变更记录收敛到一个项目目录,同时保留原月度文档路径作为兼容入口,并清理临时目录中的交接文件。
+
+**Architecture:** 使用一个总入口 README 和四份单一职责专题文档。两个原始长文档通过 `mv` 放入项目内归档目录,当前有效内容按职责提取到专题文档;根目录原路径只保留简短索引,临时目录原路径不再保留。
+
+**Tech Stack:** Markdown、CommonMark、Shell `mkdir`/`mv`/`test`、Codex `apply_patch`
+
+---
+
+## 文件结构与职责
+
+**创建:**
+
+- `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/README.md`:唯一项目入口、状态摘要和导航。
+- `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/01-业务规则与同步流程.md`:唯一业务口径与程序主流程。
+- `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/02-字段接口与程序实现.md`:字段、类、方法、接口和数据格式。
+- `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/03-运维验证与数据治理.md`:本地运行、dry-run、删除确认、复查、测试和部署。
+- `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/04-变更记录.md`:功能历史、故障修复和数据治理结果。
+- `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/归档/2026-07-15-单文件原稿.md`:月度计算原始长文档。
+- `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/归档/2026-07-15-Claude交接与修复原稿.md`:临时交接原稿。
+
+**重建:**
+
+- `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时月度计算.md`:原路径兼容索引,不再维护正文。
+
+**移除原路径:**
+
+- `/Users/malk/Desktop/Tech/claude/临时/阿科德斯-应报工时修复-2026-07-15.md`:使用 `mv` 归档后原路径自然消失。
+
+### Task 1: 建立项目目录并归档两个原稿
+
+**Files:**
+
+- Create directory: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/归档`
+- Move: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时月度计算.md`
+- Move: `/Users/malk/Desktop/Tech/claude/临时/阿科德斯-应报工时修复-2026-07-15.md`
+
+- [x] **Step 1: 确认两个源文件存在且目标文件不存在**
+
+Run:
+
+```bash
+test -f '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时月度计算.md'
+test -f '/Users/malk/Desktop/Tech/claude/临时/阿科德斯-应报工时修复-2026-07-15.md'
+test ! -e '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/归档/2026-07-15-单文件原稿.md'
+test ! -e '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/归档/2026-07-15-Claude交接与修复原稿.md'
+```
+
+Expected: 四条命令退出码均为 0。
+
+- [x] **Step 2: 创建项目与归档目录**
+
+Run:
+
+```bash
+mkdir -p '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/归档'
+```
+
+Expected: 目录存在,不影响阿科德斯其他业务文档。
+
+- [x] **Step 3: 使用 mv 归档月度计算原稿**
+
+Run:
+
+```bash
+mv '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时月度计算.md' '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/归档/2026-07-15-单文件原稿.md'
+```
+
+Expected: 原稿完整保存在归档目录。
+
+- [x] **Step 4: 使用 mv 归档临时交接原稿**
+
+Run:
+
+```bash
+mv '/Users/malk/Desktop/Tech/claude/临时/阿科德斯-应报工时修复-2026-07-15.md' '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/归档/2026-07-15-Claude交接与修复原稿.md'
+```
+
+Expected: 临时目录原路径不存在,归档副本存在。
+
+### Task 2: 创建项目总入口和兼容入口
+
+**Files:**
+
+- Create: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/README.md`
+- Create: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时月度计算.md`
+
+- [x] **Step 1: 创建 README 项目总入口**
+
+Use `apply_patch`。README 必须包含以下确定内容:
+
+```markdown
+# 阿科德斯 · 应填报工时
+
+> 更新日期:2026-07-15
+> 唯一维护入口:本目录
+> 当前状态:数据治理完成,本次重复与离职逻辑待生产部署
+
+## 当前结论
+
+- 月度全量只生成 `workDay <= today` 的记录。
+- 每日 03:30、12:45 执行近 3 天工作日补漏,每次重新读取全量人员档案和离职日期。
+- 离职当天保留,离职日之后不再写入。
+- 重复键为员工+日期,清理时保留业务字段最完整的一条。
+- 2026-07-15 已删除重复 35 条、离职后 796 条;重复、离职后、未来日期复查均为 0。
+- 项目变更同步开关保持关闭。
+
+## 文档导航
+
+- [业务规则与同步流程](01-业务规则与同步流程.md)
+- [字段接口与程序实现](02-字段接口与程序实现.md)
+- [运维验证与数据治理](03-运维验证与数据治理.md)
+- [变更记录](04-变更记录.md)
+- [历史原稿](归档/)
+
+## 代码状态
+
+- 仓库:`/Users/malk/server/cur/akds-codex-workhours-20260715`
+- 分支:`codex/akds-workhours-20260715`
+- 文档重构前最新提交:`4c1e037`
+- 未 push;本次重复与离职逻辑尚未部署。
+```
+
+- [x] **Step 2: 重建原路径兼容入口**
+
+Use `apply_patch`,内容保持一页内:
+
+```markdown
+# 应填报工时月度计算
+
+本文档已拆分,后续统一维护在 [应填报工时项目目录](应填报工时/README.md)。
+
+## 快速入口
+
+- [业务规则与同步流程](应填报工时/01-业务规则与同步流程.md)
+- [字段接口与程序实现](应填报工时/02-字段接口与程序实现.md)
+- [运维验证与数据治理](应填报工时/03-运维验证与数据治理.md)
+- [变更记录](应填报工时/04-变更记录.md)
+
+> 当前状态:2026-07-15 数据治理完成;重复与离职逻辑待生产部署。历史原稿仅用于追溯,不再作为操作依据。
+```
+
+### Task 3: 创建业务规则与同步流程文档
+
+**Files:**
+
+- Create: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/01-业务规则与同步流程.md`
+- Source: `归档/2026-07-15-单文件原稿.md`
+
+- [x] **Step 1: 按程序调用链整理当前业务规则**
+
+Use `apply_patch`,文档章节固定为:
+
+```markdown
+# 业务规则与同步流程
+
+## 1. 业务目标
+## 2. 当前有效规则
+## 3. 月度全量同步
+## 4. 每日增量补漏
+## 5. 工作日计算
+## 6. Manager 计算
+## 7. 未来日期与离职日期过滤
+## 8. 重复记录治理
+## 9. 规则边界与保留逻辑
+```
+
+必须明确:
+
+- `calculateAndSyncMonthlyHours` 和 `incrementalSync` 都调用 `queryAllPersonnelDetails`。
+- `incrementalSync(3)` 是近 3 个自然日内的工作日补漏,已有键直接跳过。
+- `workDay > today` 跳过。
+- `workDay > offlineDate` 跳过,离职当天保留。
+- 状态离职但离职日期为空时整人防御跳过;当前没有此类数据。
+- 外部员工当天无有效 PM 时不生成记录。
+- 重复清理排除离职后记录,无法形成可靠键的记录保留。
+
+- [x] **Step 2: 对照 Java 源码核验方法名和边界**
+
+Run:
+
+```bash
+rg -n 'calculateAndSyncMonthlyHours|incrementalSync|queryAllPersonnelDetails|isAfterOfflineDate|cleanupDuplicateHours|cleanupAfterOffline|computeDailyPms' '/Users/malk/server/cur/akds-codex-workhours-20260715/mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java'
+```
+
+Expected: 文档使用的方法名均能在源码中命中,离职边界为严格大于。
+
+### Task 4: 创建字段接口与程序实现文档
+
+**Files:**
+
+- Create: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/02-字段接口与程序实现.md`
+- Source: `归档/2026-07-15-单文件原稿.md`
+
+- [x] **Step 1: 整理表单与字段映射**
+
+Use `apply_patch`,只保留当前有效字段:人员、人员属性、员工编号、部门、归属公司、是否 CF、离职日期、在职状态、应填报日期、应填报工时和 Manager。系统令牌只写“通过配置或环境提供”,不得记录值。
+
+- [x] **Step 2: 按模块整理类与方法职责**
+
+文档必须覆盖:
+
+- `WHConf`
+- `WorkHoursCalcService`
+- `WorkHoursDuplicateResolver`
+- `WorkHoursController`
+- `WorkHoursCalcTimer`
+- `ProjectChangeSyncTimer`
+
+- [x] **Step 3: 整理 HTTP 接口与安全默认值**
+
+接口表至少包含:
+
+- `/workhours/sync`
+- `/workhours/sync-one`
+- `/workhours/sync-batch`
+- `/workhours/backfill-cf`
+- `/workhours/backfill-company-attr`
+- `/workhours/cleanup-future`
+- `/workhours/cleanup-after-offline`
+- `/workhours/cleanup-duplicates`
+- `/workhours/sync-project-changes`
+
+必须注明 `/cleanup-duplicates` 默认 `dryRun=true`,其他删除接口正式执行时必须显式写 `dryRun=false`,文档示例不使用省略参数的危险写法。
+
+- [x] **Step 4: 整理数据格式与性能实现**
+
+保留员工字段数组、部门 ID 数组、日期 epoch 毫秒、日期查询区间格式;记录分月扫描、100 条批量删除、10 线程、20 QPS 和重试退避。
+
+### Task 5: 创建运维验证与数据治理文档
+
+**Files:**
+
+- Create: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/03-运维验证与数据治理.md`
+- Source: 两份归档原稿
+
+- [x] **Step 1: 写本地隔离服务操作流程**
+
+必须使用独立端口,明确关闭:
+
+```text
+--enable.scheduling=false
+--workhours.projectChangeSyncEnabled=false
+--spring.task.scheduling.enabled=false
+```
+
+说明旧定时器未全部受统一开关控制,因此操作完成后立即停止本地服务。
+
+- [x] **Step 2: 写数据治理安全流程**
+
+顺序固定为:
+
+1. dry-run 获取精确数量和样本。
+2. 复述目标表、保留规则和待删除数量。
+3. 用户明确确认。
+4. 显式 `dryRun=false` 正式执行。
+5. 重复、离职后、未来日期三项 dry-run 复查。
+
+- [x] **Step 3: 写 2026-07-15 实际结果**
+
+记录:
+
+- 重复 dry-run:43,851 条、35 组、70 条重复、待删 35、离职后排除 796。
+- 重复正式删除:35,失败 0。
+- 离职后正式删除:796,失败 0。
+- 复查:现存 43,020、唯一键 43,020、三项 `toDelete=0`。
+- 未来日期复查截止 2026-07-15,扫描 6,908。
+
+- [x] **Step 4: 写测试、打包和部署流程**
+
+说明 16 项聚焦测试通过、JAR 不含 H2、部署必须备份旧 JAR并重启生产服务;当前状态仍为待部署,不能写成已上线。
+
+### Task 6: 创建变更记录
+
+**Files:**
+
+- Create: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/04-变更记录.md`
+- Source: 两份归档原稿
+
+- [x] **Step 1: 迁移功能变更历史**
+
+按日期保留 2026-04-08 至 2026-07-15 的重要变更。每行只写变更摘要、验证结果和关联提交,不复制操作步骤。
+
+- [x] **Step 2: 迁移重要故障修复**
+
+只保留仍有诊断价值的问题:日期查询数组格式、部门 ID、组件扫描、分月绕过 30,000、QPS 限流、未来数据、空源值、定时开关、重复和离职后治理。
+
+- [x] **Step 3: 标明历史与当前状态边界**
+
+文档顶部写明:“历史记录只用于追溯,当前规则以 `01-业务规则与同步流程.md` 为准,当前操作以 `03-运维验证与数据治理.md` 为准。”
+
+### Task 7: 验证目录、链接、内容和敏感信息
+
+**Files:**
+
+- Verify all files under: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时`
+- Verify compatibility index: `/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时月度计算.md`
+
+- [x] **Step 1: 检查目录结构和原路径状态**
+
+Run:
+
+```bash
+find '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时' -maxdepth 2 -type f -print | sort
+test -f '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时月度计算.md'
+test ! -e '/Users/malk/Desktop/Tech/claude/临时/阿科德斯-应报工时修复-2026-07-15.md'
+```
+
+Expected: 2 个归档文件、README、4 个专题文档和兼容入口存在;临时原路径不存在。
+
+- [x] **Step 2: 检查 Markdown 相对链接目标**
+
+逐个核对兼容入口和 README 的 9 个链接目标均存在,不使用 `file://` 或失效的临时路径。
+
+- [x] **Step 3: 检查当前状态与关键业务规则**
+
+Run:
+
+```bash
+rg -n '离职当天保留|次日起不再写入|dryRun=true|待部署|43020|35|796' '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时'
+```
+
+Expected: 当前规则、数据结果和待部署状态在对应单一职责文档中存在。
+
+- [x] **Step 4: 检查敏感信息**
+
+Run:
+
+```bash
+rg -n -i 'systemToken\s*[:=]\s*[A-Za-z0-9]|access[_-]?token\s*[:=]\s*[A-Za-z0-9]|password\s*[:=]\s*[^$]' '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时' -g '*.md' -g '!归档/**'
+```
+
+Expected: 当前维护文档不命中真实凭据;归档原稿不做内容输出,并在 README 中标记为仅限追溯。
+
+- [x] **Step 5: 检查文件长度与职责边界**
+
+Run:
+
+```bash
+wc -l '/Users/malk/Desktop/Tech/claude/后端/阿科德斯/应填报工时/'*.md
+```
+
+Expected: README 和兼容入口简短;四份专题文档不再把全部职责集中到单文件。
+
+### Task 8: 更新设计状态并提交计划与状态
+
+**Files:**
+
+- Modify: `docs/superpowers/specs/2026-07-15-workhours-docs-restructure-design.md`
+- Modify: `docs/superpowers/plans/2026-07-15-workhours-docs-restructure.md`
+
+- [x] **Step 1: 更新设计状态**
+
+把设计文档状态改为“文档拆分完成,生产部署仍暂停”,并记录临时原路径已清理、归档目标存在。
+
+- [x] **Step 2: 勾选实际完成步骤并执行差异检查**
+
+Run:
+
+```bash
+git diff --check
+git status --short
+```
+
+Expected: 仅设计与计划状态发生仓库内变更,业务代码无变化。
+
+- [x] **Step 3: 提交仓库内状态更新**
+
+Run:
+
+```bash
+git add docs/superpowers/specs/2026-07-15-workhours-docs-restructure-design.md docs/superpowers/plans/2026-07-15-workhours-docs-restructure.md
+git commit -m "docs(workhours): 完成应填报工时文档收敛"
+```
+
+Expected: 提交成功,不 push,不部署。

+ 119 - 0
docs/superpowers/specs/2026-07-15-workhours-data-cleanup-design.md

@@ -0,0 +1,119 @@
+# 应报工时重复与离职后数据清理设计
+
+> 日期:2026-07-15
+> 状态:数据清理与删除后复查已完成,等待最终代码部署
+
+## 目标
+
+清理「应填报工时」表中的两类无效数据:
+
+1. 同一员工同一应填报日期存在多条记录时,只保留一条。
+2. 员工已有离职日期时,删除应填报日期晚于离职日期的记录。
+
+离职当天及之前的数据保留。无法匹配人员档案、人员档案公司或属性为空等其他情况继续沿用现有保留逻辑,不在本次清理范围内。
+
+## 已确认现状
+
+- 每日增量同步在 03:30、12:45 执行,每次重新读取全量人员档案。
+- 人员档案的离职日期字段为 `dateField_mh8xhqc7`。
+- 写入前已有 `workDay > offlineDate` 过滤,离职日后不再新增应报工时。
+- 状态为离职但离职日期为空时,代码会整人防御跳过;当前数据不存在这种情况。
+- 本地隔离服务连接正式宜搭执行 dry-run:扫描 43,851 条,识别 74 名有离职日期的员工、796 条离职日后记录。
+
+## 重复记录判定
+
+重复键为:
+
+```text
+employeeId + "|" + workDay
+```
+
+- `employeeId` 从 `employeeField_mmd8onl4` 提取。
+- `workDay` 从 `dateField_mmd8onl5` 解析。
+- 员工或日期缺失时无法形成可靠重复键,该记录保留并计入 `skippedInvalidKey`。
+- 仅键完全相同且数量大于 1 的记录视为重复组。
+
+## 重复组保留规则
+
+每个重复组只保留一条,按以下顺序选择:
+
+1. 保留业务字段非空数量最多的记录。
+2. 完整度相同时,保留 `gmtModified` 最近的记录。
+3. 修改时间仍相同时,保留 `gmtCreate` 最近的记录。
+4. 仍相同时,按 `formInstanceId` 做稳定排序,保证重复执行结果一致。
+
+完整度仅统计已有应报工时业务字段:工时、Manager、员工编号、属性、部门、归属公司、是否 CF 员工。核心键字段不计分。
+
+不合并不同记录的字段,避免构造从未真实存在过的组合数据。此前公司和属性已完成统一回填,因此选择最完整记录即可。
+
+## 接口与执行流程
+
+新增一次性接口:
+
+```text
+GET /workhours/cleanup-duplicates?dryRun=true
+GET /workhours/cleanup-duplicates
+```
+
+执行分为四步:
+
+1. 按月份扫描从 2026-04 至当前月的应报工时,绕过宜搭搜索结果 30,000 条上限。
+2. 在内存中按重复键分组并选择保留记录,收集其余实例 ID。
+3. dry-run 返回统计与前 5 个重复组样本,不调用删除接口。
+4. 正式模式按每批最多 100 条调用 `delete_batch`,返回实际删除数与失败数。
+
+返回统计至少包含:
+
+- `total`
+- `uniqueKeys`
+- `duplicateGroups`
+- `duplicateRecords`
+- `toDelete`
+- `skippedInvalidKey`
+- `deleted`
+- `fail`
+- `samples`
+
+## 删除安全门槛
+
+正式删除前必须把以下范围复述给用户并取得二次确认:
+
+- 目标表单:应填报工时表。
+- 重复组数量、重复记录总数和待删除实例数。
+- 离职后待删除记录数,当前 dry-run 为 796。
+- 重复记录保留规则。
+
+正式删除后必须再次执行:
+
+1. `/workhours/cleanup-duplicates?dryRun=true`,期望 `toDelete=0`。
+2. `/workhours/cleanup-after-offline?dryRun=true`,期望 `toDelete=0`。
+3. `/workhours/cleanup-future?dryRun=true`,期望 `toDelete=0`。
+
+## 实际执行结果
+
+- 重复数据:35 个重复组、70 条重复记录,按保留规则删除 35 条,失败 0。
+- 离职后数据:删除 796 条,失败 0;离职当天及之前记录保留。
+- 删除后现存 43,020 条,员工+日期唯一键 43,020 个。
+- 重复复查:`duplicateGroups=0`、`toDelete=0`、`skippedInvalidKey=0`。
+- 离职后复查:`toDelete=0`。
+- 未来日期复查:截止 2026-07-15,`toDelete=0`。
+- 本地服务完成后已停止;本次代码尚未部署到生产服务。
+
+## 测试
+
+采用测试驱动实现,至少覆盖:
+
+1. 同组中保留业务字段最完整的记录。
+2. 完整度相同时保留最近修改的记录。
+3. 员工或日期缺失的记录不进入删除集合。
+4. dry-run 只返回待删除数量,不调用 `delete_batch`。
+5. 离职边界保持 `workDay > offlineDate`:离职当天保留,次日删除或跳过写入。
+
+最终执行模块测试、完整打包、差异检查,再提交和部署。
+
+## 不在本次范围
+
+- 不删除无法匹配人员档案的记录。
+- 不清空人员档案公司或属性为空的历史字段。
+- 不处理项目变更审批 Manager 回算。
+- 不启用 `projectChangeSyncEnabled`。

+ 159 - 0
docs/superpowers/specs/2026-07-15-workhours-docs-restructure-design.md

@@ -0,0 +1,159 @@
+# 应填报工时文档重构设计
+
+> 日期:2026-07-15
+> 状态:文档拆分完成,生产部署仍暂停
+
+## 目标
+
+把分散、重复且同时承载多种职责的应填报工时文档,收敛为一个项目目录。后续业务规则、程序逻辑、数据治理和变更记录统一在该目录维护。
+
+本次只整理文档,不修改程序代码,不执行部署。
+
+执行结果:两份原稿已移动到项目归档目录,原月度文档路径已重建为兼容入口,临时交接文件原路径已清理。
+
+## 当前问题
+
+`应填报工时月度计算.md` 已超过 480 行,同时包含:
+
+- 当前业务规则与同步流程
+- 宜搭字段、接口和程序实现
+- 本地运行、数据删除和验证操作
+- 多阶段变更历史与故障修复记录
+- 一次性数据修复结果
+
+临时目录中的 `阿科德斯-应报工时修复-2026-07-15.md` 又重复记录了执行结果、交接过程和过期恢复步骤。两份文档存在重复和历史口径混入当前流程的风险。
+
+## 目标目录
+
+```text
+后端/阿科德斯/
+├── 应填报工时月度计算.md
+└── 应填报工时/
+    ├── README.md
+    ├── 01-业务规则与同步流程.md
+    ├── 02-字段接口与程序实现.md
+    ├── 03-运维验证与数据治理.md
+    ├── 04-变更记录.md
+    └── 归档/
+        ├── 2026-07-15-单文件原稿.md
+        └── 2026-07-15-Claude交接与修复原稿.md
+```
+
+## 文件职责
+
+### 兼容入口:`应填报工时月度计算.md`
+
+保留原路径,改为不超过一页的兼容索引:
+
+- 标明唯一维护入口为 `应填报工时/README.md`
+- 展示当前数据与部署状态
+- 链接各专题文档
+- 不再复制字段表、接口表和历史记录
+
+### `README.md`
+
+作为项目总入口,仅保留:
+
+- 项目目标和当前有效结论
+- 当前代码分支、提交与部署状态
+- 数据修复最终结果
+- 文档导航
+- 尚未启用的项目变更同步等后续项
+
+### `01-业务规则与同步流程.md`
+
+维护唯一的业务口径和程序主流程:
+
+- 月度全量同步
+- 每日增量补漏
+- 工作日计算
+- 内部/外部员工 Manager 规则
+- 未来日期过滤
+- 离职日期边界
+- 重复记录判定与保留规则
+
+### `02-字段接口与程序实现.md`
+
+维护实现细节:
+
+- 表单与字段映射
+- Java 模块、类和方法职责
+- Controller 接口
+- 宜搭请求和字段格式
+- 分页、限流、并发与重试策略
+
+### `03-运维验证与数据治理.md`
+
+维护操作流程和安全门槛:
+
+- 本地隔离服务启动与停止
+- dry-run 优先规则
+- 回填、重复、离职后和未来数据清理
+- 正式删除前确认要求
+- 删除后复查清单
+- 测试、打包和生产部署流程
+- 敏感日志与定时任务注意事项
+
+### `04-变更记录.md`
+
+维护按日期排序的变化与结果:
+
+- 功能变更历史
+- 重要故障及修复
+- 2026-07-15 数据治理实际数量
+- 关联提交和部署状态
+
+历史记录只描述当时情况,不再承担当前操作指引。
+
+## 原文件处理
+
+### 月度计算单文件
+
+1. 使用 `mv` 把当前完整原稿移动到 `应填报工时/归档/2026-07-15-单文件原稿.md`。
+2. 在原路径新建简短兼容索引。
+3. 从归档原稿提取内容,按职责写入 4 份专题文档。
+
+### 临时修复交接文件
+
+目标文件:
+
+```text
+/Users/malk/Desktop/Tech/claude/临时/阿科德斯-应报工时修复-2026-07-15.md
+```
+
+处理方式:
+
+1. 把当前有效的执行结果、代码状态和待部署状态收敛到 `README.md`、`03-运维验证与数据治理.md`、`04-变更记录.md`。
+2. 历史 Claude 交接、过期恢复步骤和原始疑问不进入当前流程文档。
+3. 使用 `mv` 将完整原稿移到 `应填报工时/归档/2026-07-15-Claude交接与修复原稿.md`。
+4. 原临时路径不再保留文件,后续不从临时目录维护应填报工时逻辑。
+
+采用移动归档而非不可恢复删除,既满足原路径清理,也保留必要追溯能力。
+
+## 内容去重规则
+
+- 当前规则只在 `01` 维护,其他文档通过链接引用。
+- 字段和接口只在 `02` 维护。
+- 可执行操作和安全要求只在 `03` 维护。
+- 时间线和历史数据只在 `04` 维护。
+- README 和兼容入口只写摘要,不复制大段正文。
+- 归档文件只用于追溯,不作为当前操作依据。
+
+## 验证标准
+
+整理完成后检查:
+
+1. 原兼容入口存在且所有链接有效。
+2. `应填报工时/` 下每份专题文档职责单一。
+3. 临时目录中的原交接文件已不存在,归档副本存在。
+4. 当前有效规则与 Java 程序逻辑一致:每日读取离职日期、离职当天保留、次日起不写、重复清理默认 dry-run。
+5. 数据结果一致:重复删除 35、离职后删除 796,三项复查 `toDelete=0`。
+6. 本次部署状态明确为“待部署”,不误写为已上线。
+7. 文档不记录系统令牌、访问令牌、Cookie 或密码。
+
+## 不在本次范围
+
+- 不修改 Java 程序和配置。
+- 不执行生产部署。
+- 不处理项目变更审批 Manager 回算功能。
+- 不重组阿科德斯目录下其他业务文档。

+ 5 - 0
mjava-akdsbeisen/pom.xml

@@ -36,6 +36,11 @@
             <artifactId>spring-boot-configuration-processor</artifactId>
             <optional>true</optional>
         </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>

+ 124 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java

@@ -123,6 +123,130 @@ public class WorkHoursController {
         return result;
     }
 
+    /**
+     * 回填存量应填报工时的「归属公司」+「属性」两字段, 值取自人员档案
+     * ppExt: 一次性接口, 不设定时; 主流程 upsertDailyHours 已带最新两字段, 后续变动自动同步, 老记录靠本接口一次对齐
+     * GET /workhours/backfill-company-attr            (实际更新)
+     * GET /workhours/backfill-company-attr?dryRun=true(仅扫描预览, 不更新)
+     */
+    @GetMapping("/backfill-company-attr")
+    public Map<String, Object> backfillCompanyAttr(@RequestParam(defaultValue = "false") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.backfillCompanyAndAttribute(dryRun);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", dryRun ? "公司+属性回填预览完成(未更新)" : "公司+属性回填完成");
+            result.put("stats", stats);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("公司+属性回填失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 清理同一员工同一日期的重复应填报工时(一次性接口,默认仅预览)
+     * GET /workhours/cleanup-duplicates             (仅预览)
+     * GET /workhours/cleanup-duplicates?dryRun=false(实际删除)
+     */
+    @GetMapping("/cleanup-duplicates")
+    public Map<String, Object> cleanupDuplicates(@RequestParam(defaultValue = "true") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.cleanupDuplicateHours(dryRun);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", dryRun ? "重复工时清理预览完成(未删除)" : "重复工时清理完成");
+            result.put("stats", stats);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("重复工时清理失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 清理已离职员工「离职日之后」的历史应报工时(一次性接口)
+     * GET /workhours/cleanup-after-offline            (实际删除)
+     * GET /workhours/cleanup-after-offline?dryRun=true(仅统计不删除)
+     */
+    @GetMapping("/cleanup-after-offline")
+    public Map<String, Object> cleanupAfterOffline(@RequestParam(defaultValue = "false") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.cleanupAfterOffline(dryRun);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", dryRun ? "离职后工时清理预览完成(未删除)" : "离职后工时清理完成");
+            result.put("stats", stats);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("离职后工时清理失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 清理已写入的"未来"应报工时 (workDay > cutoff, cutoff 缺省 today)
+     * GET /workhours/cleanup-future                    (实际删除, cutoff=today)
+     * GET /workhours/cleanup-future?dryRun=true        (仅预览)
+     * GET /workhours/cleanup-future?cutoff=2026-07-15  (指定保留截止日)
+     */
+    @GetMapping("/cleanup-future")
+    public Map<String, Object> cleanupFuture(@RequestParam(required = false) String cutoff,
+                                             @RequestParam(defaultValue = "false") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            LocalDate cutoffDate = (cutoff == null || cutoff.isEmpty()) ? null : LocalDate.parse(cutoff);
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.cleanupFutureHours(cutoffDate, dryRun);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", dryRun ? "未来工时清理预览完成(未删除)" : "未来工时清理完成");
+            result.put("stats", stats);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("未来工时清理失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 项目变更审批增量同步(手动触发;定时器 ProjectChangeSyncTimer 定期跑)
+     * GET /workhours/sync-project-changes?daysBack=7
+     * 只更新变更时间未来的应报工时 Manager, 变更前记录一律不动
+     */
+    @GetMapping("/sync-project-changes")
+    public Map<String, Object> syncProjectChanges(@RequestParam(defaultValue = "7") int daysBack) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.syncProjectChanges(daysBack);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", "项目变更增量同步完成");
+            result.put("stats", stats);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("项目变更同步失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
     /**
      * 全量同步当月应填报工时
      * GET /workhours/sync?month=2026-04

+ 1 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/personnel/PersonnelSyncConf.java

@@ -28,6 +28,7 @@ public class PersonnelSyncConf {
     private String fieldIsCf        = "textField_mow9w7d8";
     private String fieldCostCenter  = "textField_mow9w7d9";
     private String fieldStatus      = "radioField_mp1sngq1";
+    private String fieldOfflineDate = "dateField_mh8xhqc7";   // 离职时间 (首次判定离职时写入当天, 已有值不覆盖)
 
     // 内外部判定: extAttrKeyUserType 留空 -> 走部门白名单; 命中 externalDeptIds -> 外部
     private String extAttrKeyUserType   = "";

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

@@ -20,6 +20,10 @@ public class WHConf {
     // prd 外部员工关联项目经理:项目档案表(含成员子表),用于按 userId 匹配 PM 写入 Manager 字段
     private String formUuidProject;
 
+    // prd 项目变更审批表: 复用项目档案子表结构 (tableField_mkowyn6d) + 新增"原项目经理" 字段;
+    // 定时任务扫描该表单最近 gmtModified 的实例, 对审批通过日之后的应报工时重算 Manager
+    private String formUuidProjectChange;
+
     // prd 审批回写:工时汇总表 + 两类审批单
     private String formUuidWorkHoursSummary;
 
@@ -44,4 +48,26 @@ public class WHConf {
     private String otherApprovalSyncTotalField;    // 其他工时审批 - 同步总数(数字)
 
     private String otherApprovalSyncFailField;     // 其他工时审批 - 失败数量(数字)
+
+    // prd 补发起(bug1)配置:汇总表 + 两类审批单
+    // fixme 若原匹配到审批单已 COMPLETED,前端逻辑是 queryProcess(RUNNING) 命中失败 → 走 create 新建单,
+    //       后端 ReSubmitApprovalService 完全对齐该逻辑
+
+    // 工时审批流程 processCode(startInstance 用)
+    private String approvalProcessCode;
+
+    // 其他工时审批流程 processCode
+    private String otherApprovalProcessCode;
+
+    // 补发起 startInstance 的发起人 userId
+    // fixme 前端 v2.35 已确认: yida_pub_account 虚拟账号不触发钉钉待办通知(审批人收不到红点),
+    //       必须改用真人钉钉账号 userId(建议命名 System); 查询/更新类操作仍可用 pub_account
+    private String approvalOriginatorUserId;
+
+    // 补发起 deptId(可空)
+    private String approvalOriginatorDeptId;
+
+    // 汇总表: 审批状态字段(SelectField, 3 档 "暂存/审批中/已审批")
+    // prd 前端在保存/提交时写入此字段, 后端补发起 Scheduler 按 "审批中" + 近 3 天 gmtModified 扫描兜底
+    private String summaryApprovalStatusField;
 }

+ 32 - 2
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -17,6 +17,8 @@ import org.springframework.stereotype.Service;
 
 import javax.annotation.PostConstruct;
 
+import java.time.LocalDate;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -68,6 +70,13 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
     private static final double DD_API_QPS = 50.0;
     private static final double YIDA_API_QPS = 30.0;
 
+    private static final ZoneId CST = ZoneId.of("Asia/Shanghai");
+
+    /** 当天 CST 00:00 epoch 毫秒 (离职时间按天存储, 与应报工时日期格式对齐) */
+    private static long todayCstStartMillis() {
+        return LocalDate.now(CST).atStartOfDay(CST).toInstant().toEpochMilli();
+    }
+
     @Override
     public Map<String, Object> fullSync(Integer limitOverride) {
         long start = System.currentTimeMillis();
@@ -595,20 +604,41 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         }
 
         // 宜搭里有、钉钉里没有 -> 标记离职 (limit 模式下拉取非全量, 跳过此步)
+        // ppExt 离职时间: 首次判定离职 status+offlineDate 一起写; 已"离职"但 offlineDate 空 -> 补写(当天);
+        //                已有 offlineDate 且状态一致 -> 完全跳过, 保护首次判定日不被覆盖
         if (!skipMarkOff) {
+            long todayMs = todayCstStartMillis();
+            boolean writeOfflineDate = notBlank(conf.getFieldOfflineDate());
             for (Map.Entry<String, YidaRecord> e : yidaMap.entrySet()) {
                 if (dingUserMap.containsKey(e.getKey())) continue;
                 YidaRecord yida = e.getValue();
                 Object currentStatus = yida.formData.get(conf.getFieldStatus());
-                if (conf.getStatusValueInactive().equals(String.valueOf(currentStatus))) continue;   // 已离职跳过
+                boolean alreadyInactive = conf.getStatusValueInactive().equals(String.valueOf(currentStatus));
+                boolean offlineDateBlank = writeOfflineDate && isBlankValue(yida.formData.get(conf.getFieldOfflineDate()));
+
+                if (alreadyInactive && (!writeOfflineDate || !offlineDateBlank)) continue;   // 全部就绪, 跳过
+
                 Map<String, Object> formData = new LinkedHashMap<>();
-                formData.put(conf.getFieldStatus(), conf.getStatusValueInactive());
+                if (!alreadyInactive) {
+                    formData.put(conf.getFieldStatus(), conf.getStatusValueInactive());
+                }
+                if (writeOfflineDate && offlineDateBlank) {
+                    formData.put(conf.getFieldOfflineDate(), todayMs);
+                }
+                if (formData.isEmpty()) continue;   // 防御: 无字段变化不写入
                 actions.add(new Action(ACTION_MARK_OFF, e.getKey(), yida.instanceId, formData));
             }
         }
         return actions;
     }
 
+    /** dateField 在 formData 里可能是 Long 时间戳或 "yyyy-MM-dd HH:mm:ss" 字符串, 空/空串/字面 "null" 一律视为空 */
+    private boolean isBlankValue(Object v) {
+        if (v == null) return true;
+        String s = String.valueOf(v).trim();
+        return s.isEmpty() || "null".equalsIgnoreCase(s);
+    }
+
     // ==================== 内部: 字段映射 ====================
 
     @SuppressWarnings("unchecked")

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1190 - 189
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java


+ 153 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursDuplicateResolver.java

@@ -0,0 +1,153 @@
+package com.malk.service.workhours;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 应填报工时重复记录解析器:同一员工+日期只保留最完整、最近更新的一条。
+ */
+final class WorkHoursDuplicateResolver {
+
+    private static final Comparator<Candidate> KEEPER_ORDER = new Comparator<Candidate>() {
+        @Override
+        public int compare(Candidate left, Candidate right) {
+            int result = Integer.compare(right.getCompleteness(), left.getCompleteness());
+            if (result != 0) return result;
+            result = Long.compare(right.getModifiedAt(), left.getModifiedAt());
+            if (result != 0) return result;
+            result = Long.compare(right.getCreatedAt(), left.getCreatedAt());
+            if (result != 0) return result;
+            return left.getInstanceId().compareTo(right.getInstanceId());
+        }
+    };
+
+    private WorkHoursDuplicateResolver() {
+    }
+
+    static Resolution resolve(List<Candidate> candidates) {
+        Map<String, List<Candidate>> grouped = new LinkedHashMap<>();
+        int skippedInvalidKey = 0;
+        if (candidates != null) {
+            for (Candidate candidate : candidates) {
+                if (candidate == null || candidate.getKey() == null || candidate.getKey().isEmpty()) {
+                    skippedInvalidKey++;
+                    continue;
+                }
+                grouped.computeIfAbsent(candidate.getKey(), key -> new ArrayList<>()).add(candidate);
+            }
+        }
+
+        List<DuplicateGroup> duplicateGroups = new ArrayList<>();
+        List<String> deleteInstanceIds = new ArrayList<>();
+        for (Map.Entry<String, List<Candidate>> entry : grouped.entrySet()) {
+            List<Candidate> group = entry.getValue();
+            if (group.size() <= 1) continue;
+            Collections.sort(group, KEEPER_ORDER);
+            Candidate keeper = group.get(0);
+            List<String> groupDeleteIds = new ArrayList<>();
+            for (int i = 1; i < group.size(); i++) {
+                groupDeleteIds.add(group.get(i).getInstanceId());
+            }
+            deleteInstanceIds.addAll(groupDeleteIds);
+            duplicateGroups.add(new DuplicateGroup(entry.getKey(), keeper.getInstanceId(), groupDeleteIds));
+        }
+        return new Resolution(grouped.size(), skippedInvalidKey, duplicateGroups, deleteInstanceIds);
+    }
+
+    static final class Candidate {
+        private final String instanceId;
+        private final String key;
+        private final int completeness;
+        private final long modifiedAt;
+        private final long createdAt;
+
+        Candidate(String instanceId, String key, int completeness, long modifiedAt, long createdAt) {
+            this.instanceId = instanceId;
+            this.key = key;
+            this.completeness = completeness;
+            this.modifiedAt = modifiedAt;
+            this.createdAt = createdAt;
+        }
+
+        String getInstanceId() {
+            return instanceId;
+        }
+
+        String getKey() {
+            return key;
+        }
+
+        int getCompleteness() {
+            return completeness;
+        }
+
+        long getModifiedAt() {
+            return modifiedAt;
+        }
+
+        long getCreatedAt() {
+            return createdAt;
+        }
+    }
+
+    static final class DuplicateGroup {
+        private final String key;
+        private final String keepInstanceId;
+        private final List<String> deleteInstanceIds;
+
+        DuplicateGroup(String key, String keepInstanceId, List<String> deleteInstanceIds) {
+            this.key = key;
+            this.keepInstanceId = keepInstanceId;
+            this.deleteInstanceIds = new ArrayList<>(deleteInstanceIds);
+        }
+
+        String getKey() {
+            return key;
+        }
+
+        String getKeepInstanceId() {
+            return keepInstanceId;
+        }
+
+        List<String> getDeleteInstanceIds() {
+            return Collections.unmodifiableList(deleteInstanceIds);
+        }
+    }
+
+    static final class Resolution {
+        private final int uniqueKeys;
+        private final int skippedInvalidKey;
+        private final List<DuplicateGroup> groups;
+        private final List<String> deleteInstanceIds;
+
+        Resolution(int uniqueKeys,
+                   int skippedInvalidKey,
+                   List<DuplicateGroup> groups,
+                   List<String> deleteInstanceIds) {
+            this.uniqueKeys = uniqueKeys;
+            this.skippedInvalidKey = skippedInvalidKey;
+            this.groups = new ArrayList<>(groups);
+            this.deleteInstanceIds = new ArrayList<>(deleteInstanceIds);
+        }
+
+        int getUniqueKeys() {
+            return uniqueKeys;
+        }
+
+        int getSkippedInvalidKey() {
+            return skippedInvalidKey;
+        }
+
+        List<DuplicateGroup> getGroups() {
+            return Collections.unmodifiableList(groups);
+        }
+
+        List<String> getDeleteInstanceIds() {
+            return Collections.unmodifiableList(deleteInstanceIds);
+        }
+    }
+}

+ 41 - 0
mjava-akdsbeisen/src/main/java/com/malk/timer/ProjectChangeSyncTimer.java

@@ -0,0 +1,41 @@
+package com.malk.timer;
+
+import com.malk.service.workhours.WorkHoursCalcService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+/**
+ * 项目变更审批增量同步定时器
+ * <p>
+ * 启用后每天 2 次错峰扫描项目变更审批表单最近 7 天 gmtModified 的实例,
+ * 对涉及员工的应报工时按「变更生效日之后」重算 Manager (仅外部员工受影响; 内部员工 Manager 走钉钉主管不受本任务影响)。
+ * <p>
+ * cron 时点故意错开 WorkHoursCalcTimer 的 03:30/12:45, 避免宜搭 QPS 突发叠加。
+ */
+@Slf4j
+@Configuration
+@EnableScheduling
+// fixme 审批表 Schema 未核验前默认不注册定时器,避免按错误字段映射回写 Manager
+@ConditionalOnProperty(prefix = "workhours", name = "projectChangeSyncEnabled", havingValue = "true")
+public class ProjectChangeSyncTimer {
+
+    @Autowired
+    private WorkHoursCalcService workHoursCalcService;
+
+    // 每天 03:15 / 12:30 两次错峰 (均早于 WorkHoursCalcTimer 的 03:30 / 12:45 15 分钟, 避免 QPS 叠加)
+    @Scheduled(cron = "0 15 3 * * ?")
+    @Scheduled(cron = "0 30 12 * * ?")
+    public void syncProjectChanges() {
+        log.info("开始执行项目变更审批增量同步任务");
+        try {
+            workHoursCalcService.syncProjectChanges(7);
+            log.info("项目变更审批增量同步任务执行完成");
+        } catch (Exception e) {
+            log.error("项目变更审批增量同步任务执行失败", e);
+        }
+    }
+}

+ 9 - 7
mjava-akdsbeisen/src/main/java/com/malk/timer/WorkHoursCalcTimer.java

@@ -3,6 +3,7 @@ package com.malk.timer;
 import com.malk.service.workhours.WorkHoursCalcService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -10,6 +11,8 @@ import org.springframework.scheduling.annotation.Scheduled;
 @Slf4j
 @Configuration
 @EnableScheduling
+// fixme 遵循各 profile 的 enable.scheduling 总开关,dev=false 时不注册定时任务
+@ConditionalOnProperty(prefix = "enable", name = "scheduling", havingValue = "true")
 public class WorkHoursCalcTimer {
 
     @Autowired
@@ -27,16 +30,15 @@ public class WorkHoursCalcTimer {
         }
     }
 
-    // prd: 增量同步改为每天 3 次(07:00 / 12:15 / 18:30),查询人员档案最近2天修改的
-    // fixme: 单条 cron 的时/分字段相互独立,无法表达「7:00 与 12:15 与 18:30」三个离散时点
-    //        (会变成时×分笛卡尔积),故用 @Scheduled 可重复注解挂 3 条独立 cron
-    @Scheduled(cron = "0 0 7 * * ?")
-    @Scheduled(cron = "0 15 12 * * ?")
-    @Scheduled(cron = "0 30 18 * * ?")
+    // prd: 增量同步改为每天 2 次(03:30 / 12:45),全员 × 近 3 天工作日窗口 补漏
+    // fixme: 已存在记录一律 skip (补漏语义, 字段刷新走 /workhours/backfill-* 接口);
+    //        单条 cron 的时/分字段相互独立无法表达多个离散时点, 用 @Scheduled 可重复注解挂 2 条独立 cron
+    @Scheduled(cron = "0 30 3 * * ?")
+    @Scheduled(cron = "0 45 12 * * ?")
     public void calcDailyIncrementalSync() {
         log.info("开始执行应填报工时【增量】同步任务");
         try {
-            workHoursCalcService.incrementalSync(2);
+            workHoursCalcService.incrementalSync(3);
             log.info("应填报工时增量同步任务执行完成");
         } catch (Exception e) {
             log.error("应填报工时增量同步任务执行失败", e);

+ 7 - 2
mjava-akdsbeisen/src/main/resources/application-dev.yml

@@ -24,8 +24,8 @@ spring:
       connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci           # SqlServer, Oracle 无需设置类型
     driver-class-name: com.mysql.cj.jdbc.Driver
     username: root
-    password: 123456
-    url: jdbc:mysql://localhost:3306/paineng?serverTimezone=Asia/Shanghai&useUnicode=yes&characterEncoding=UTF-8&useSSL=true
+    password: ide123
+    url: jdbc:mysql://127.0.0.1:3306/mjava?serverTimezone=Asia/Shanghai&useUnicode=yes&characterEncoding=UTF-8&useSSL=true
   jpa:
     hibernate:
       ddl-auto: none      # JPA对表没有任何操作
@@ -67,11 +67,15 @@ beisen:
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
 
 workhours:
+  # fixme 项目变更审批表 Schema 核验通过后才允许开启自动回写
+  projectChangeSyncEnabled: false
   formUuidHoliday: "FORM-BMG66GA16LC4CNCZLUUTKAX0G27U3YTMC27NM1"
   formUuidPersonnel: "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP"
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"
   # 外部员工关联项目经理:项目档案表(含成员子表 tableField_mkowyn6d)
   formUuidProject: "FORM-A4A04FCDE2024B23B386504954760F55HIB9"
+  # 项目变更审批:子表复用项目档案 tableField_mkowyn6d 结构 + 原项目经理 employeeField_mrd9y8i9
+  formUuidProjectChange: "FORM-B86D895C90AE460B9749CC15E9079457PC79"
   # 审批回写:工时汇总表 + 两类审批单(当前指向测试环境,正式见注释)
   formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
   formUuidApproval: "FORM-230BB68C42304492A242CA278EF4A853LYIY"        # 正式 FORM-DDA1683645954101890AF6BA723705F9M0XV
@@ -95,6 +99,7 @@ tianyancha:
   timeoutMs: 8000
 
 # 客户档案·22 字段 fieldId 映射 (云璞租户)
+# appType/formUuid 仅用于日志与联调排错; 后端不写宜搭 (由调用方自行 update)
 customer:
   yidaAppType: "APP_W73TG2OPB9M2J21FUSFA"
   yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"

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

@@ -51,11 +51,15 @@ beisen:
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
 
 workhours:
+  # fixme 项目变更审批表 Schema 核验通过后才允许开启自动回写
+  projectChangeSyncEnabled: false
   formUuidHoliday: "FORM-BMG66GA16LC4CNCZLUUTKAX0G27U3YTMC27NM1"
   formUuidPersonnel: "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP"
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"
   # 外部员工关联项目经理:项目档案表(含成员子表 tableField_mkowyn6d)
   formUuidProject: "FORM-A4A04FCDE2024B23B386504954760F55HIB9"
+  # 项目变更审批:子表复用项目档案 tableField_mkowyn6d 结构 + 原项目经理 employeeField_mrd9y8i9
+  formUuidProjectChange: "FORM-B86D895C90AE460B9749CC15E9079457PC79"
   # 审批回写:工时汇总表 + 两类审批单(当前指向测试环境,正式见注释)
   formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
   formUuidApproval: "FORM-230BB68C42304492A242CA278EF4A853LYIY"        # 正式 FORM-DDA1683645954101890AF6BA723705F9M0XV

+ 260 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java

@@ -0,0 +1,260 @@
+package com.malk.service.workhours;
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import org.junit.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.lang.reflect.Constructor;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class WorkHoursCalcServiceTest {
+
+    @Test
+    public void buildCompanyAttributeUpdateShouldNotClearHistoryWhenSourceIsEmpty() {
+        Map<String, String> update = WorkHoursCalcService.buildCompanyAttributeUpdate(
+                "上海", null, "外部", "内部");
+
+        assertFalse(update.containsKey("textField_mmekrcji"));
+        assertEquals("内部", update.get("radioField_mkow4ydo"));
+    }
+
+    @Test
+    public void buildCompanyAttributeUpdateShouldOnlyContainChangedNonEmptyValues() {
+        Map<String, String> update = WorkHoursCalcService.buildCompanyAttributeUpdate(
+                "上海", "上海", "外部", "内部");
+
+        assertEquals(1, update.size());
+        assertEquals("内部", update.get("radioField_mkow4ydo"));
+    }
+
+    @Test
+    public void syncOneEmployeeOneDayShouldRejectFutureDateBeforeCallingExternalServices() {
+        WorkHoursCalcService service = new WorkHoursCalcService();
+
+        Map<String, Object> result = service.syncOneEmployeeOneDay("employee-1", LocalDate.now().plusDays(1));
+
+        assertEquals(false, result.get("success"));
+        assertEquals("workDay 在 today 之后, 按业务规则不写入未来数据", result.get("error"));
+    }
+
+    @Test
+    public void isAfterOfflineDateShouldKeepOfflineDayAndRejectFollowingDay() {
+        LocalDate offlineDate = LocalDate.of(2026, 7, 15);
+
+        assertFalse(WorkHoursCalcService.isAfterOfflineDate(offlineDate, offlineDate));
+        assertEquals(true, WorkHoursCalcService.isAfterOfflineDate(offlineDate.plusDays(1), offlineDate));
+        assertFalse(WorkHoursCalcService.isAfterOfflineDate(offlineDate.plusDays(1), null));
+    }
+
+    @Test
+    public void cleanupFutureHoursDryRunShouldReportFutureRecordsWithoutDeleting() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = new WHConf();
+        conf.setYidaAppType("app");
+        conf.setYidaSystemToken("token");
+        conf.setFormUuidRequiredHours("required-hours");
+
+        DDR_New<Object> firstPage = new DDR_New<>();
+        firstPage.setTotalCount(1);
+        firstPage.setData(Collections.singletonList(requiredHoursRecord(
+                "instance-1", "employee-1", LocalDate.of(2026, 7, 16))));
+        DDR_New<Object> emptyPage = new DDR_New<>();
+        emptyPage.setTotalCount(0);
+        emptyPage.setData(Collections.emptyList());
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(firstPage, emptyPage);
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> stats = service.cleanupFutureHours(LocalDate.of(2026, 7, 15), true);
+
+        assertEquals(1, stats.get("toDelete"));
+        assertEquals(0, stats.get("deleted"));
+        assertEquals(0, stats.get("fail"));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.delete_batch));
+    }
+
+    @Test
+    public void cleanupDuplicateHoursDryRunShouldReportOneDeletionWithoutDeleting() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        DDR_New<Object> personnelPage = pageOf(Collections.singletonList(personnelRecord("employee-1")));
+        DDR_New<Object> duplicatePage = pageOf(Arrays.asList(
+                requiredHoursRecord("sparse", "employee-1", LocalDate.of(2026, 7, 15)),
+                completeRequiredHoursRecord("complete", "employee-1", LocalDate.of(2026, 7, 15))));
+        DDR_New<Object> emptyPage = pageOf(Collections.emptyList());
+        AtomicBoolean requiredHoursReturned = new AtomicBoolean(false);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    YDParam param = (YDParam) invocation.getArguments()[0];
+                    if (conf.getFormUuidPersonnel().equals(param.getFormUuid())) return personnelPage;
+                    if (requiredHoursReturned.compareAndSet(false, true)) return duplicatePage;
+                    return emptyPage;
+                });
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> stats = service.cleanupDuplicateHours(true);
+
+        assertEquals(1, stats.get("duplicateGroups"));
+        assertEquals(1, stats.get("toDelete"));
+        assertEquals(0, stats.get("deleted"));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.delete_batch));
+    }
+
+    @Test
+    public void cleanupDuplicateHoursShouldExcludeRecordsAfterOfflineDate() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        DDR_New<Object> personnelPage = pageOf(Collections.singletonList(
+                personnelRecord("employee-1", LocalDate.of(2026, 7, 14))));
+        DDR_New<Object> duplicatePage = pageOf(Arrays.asList(
+                requiredHoursRecord("post-offline-1", "employee-1", LocalDate.of(2026, 7, 15)),
+                requiredHoursRecord("post-offline-2", "employee-1", LocalDate.of(2026, 7, 15))));
+        DDR_New<Object> emptyPage = pageOf(Collections.emptyList());
+        AtomicBoolean requiredHoursReturned = new AtomicBoolean(false);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    YDParam param = (YDParam) invocation.getArguments()[0];
+                    if (conf.getFormUuidPersonnel().equals(param.getFormUuid())) return personnelPage;
+                    if (requiredHoursReturned.compareAndSet(false, true)) return duplicatePage;
+                    return emptyPage;
+                });
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> stats = service.cleanupDuplicateHours(true);
+
+        assertEquals(2, stats.get("excludedAfterOffline"));
+        assertEquals(0, stats.get("duplicateGroups"));
+        assertEquals(0, stats.get("toDelete"));
+    }
+
+    @Test
+    public void computeDailyPmsShouldMergeDistinctManagersAndIncludeOfflineDate() throws Exception {
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        LocalDate offlineDate = LocalDate.of(2026, 7, 15);
+        List<Object> assignments = Arrays.asList(
+                assignment("project-1", offlineDate, "manager-a"),
+                assignment("project-2", null, "manager-b"),
+                assignment("project-3", null, "manager-a"));
+
+        List<String> onOfflineDate = ReflectionTestUtils.invokeMethod(
+                service, "computeDailyPms", assignments, offlineDate);
+        List<String> afterOfflineDate = ReflectionTestUtils.invokeMethod(
+                service, "computeDailyPms", assignments, offlineDate.plusDays(1));
+
+        assertEquals(Arrays.asList("manager-a", "manager-b"), onOfflineDate);
+        assertEquals(Arrays.asList("manager-b", "manager-a"), afterOfflineDate);
+    }
+
+    @Test
+    public void computeDailyPmsShouldReturnEmptyWhenAllManagersAreMissing() throws Exception {
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        List<Object> assignments = new ArrayList<>();
+        assignments.add(assignment("project-1", null, null));
+        assignments.add(assignment("project-2", LocalDate.of(2026, 7, 14), "manager-a"));
+
+        List<String> managers = ReflectionTestUtils.invokeMethod(
+                service, "computeDailyPms", assignments, LocalDate.of(2026, 7, 15));
+
+        assertEquals(Collections.emptyList(), managers);
+    }
+
+    private static Object assignment(String projectInstanceId,
+                                     LocalDate offlineDate,
+                                     String managerId) throws Exception {
+        Class<?> assignmentClass = Class.forName(
+                "com.malk.service.workhours.WorkHoursCalcService$Assignment");
+        Constructor<?> constructor = assignmentClass.getDeclaredConstructor(
+                String.class, LocalDate.class, String.class);
+        constructor.setAccessible(true);
+        return constructor.newInstance(projectInstanceId, offlineDate, managerId);
+    }
+
+    private static Map<String, Object> requiredHoursRecord(String instanceId,
+                                                            String employeeId,
+                                                            LocalDate workDay) {
+        Map<String, Object> formData = new LinkedHashMap<>();
+        formData.put("employeeField_mmd8onl4_id", Arrays.asList(employeeId));
+        formData.put("dateField_mmd8onl5", workDay.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
+        Map<String, Object> record = new LinkedHashMap<>();
+        record.put("formInstanceId", instanceId);
+        record.put("formData", formData);
+        return record;
+    }
+
+    private static Map<String, Object> completeRequiredHoursRecord(String instanceId,
+                                                                    String employeeId,
+                                                                    LocalDate workDay) {
+        Map<String, Object> record = requiredHoursRecord(instanceId, employeeId, workDay);
+        Map<String, Object> formData = (Map<String, Object>) record.get("formData");
+        formData.put("numberField_mmd8onl6", 8);
+        formData.put("employeeField_mh8xhqc3_id", Collections.singletonList("manager-1"));
+        formData.put("textField_mh8xhqc1", "E001");
+        formData.put("radioField_mkow4ydo", "内部");
+        formData.put("departmentSelectField_mkow4ydr_id", Collections.singletonList("dept-1"));
+        formData.put("textField_mmekrcji", "上海");
+        formData.put("textField_mpp7a2k7", "否");
+        return record;
+    }
+
+    private static Map<String, Object> personnelRecord(String employeeId) {
+        return personnelRecord(employeeId, null);
+    }
+
+    private static Map<String, Object> personnelRecord(String employeeId, LocalDate offlineDate) {
+        Map<String, Object> formData = new LinkedHashMap<>();
+        formData.put("employeeField_mkow4ydp_id", Collections.singletonList(employeeId));
+        if (offlineDate != null) {
+            formData.put("dateField_mh8xhqc7",
+                    offlineDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
+        }
+        Map<String, Object> record = new LinkedHashMap<>();
+        record.put("formData", formData);
+        return record;
+    }
+
+    private static DDR_New<Object> pageOf(List<Map<String, Object>> records) {
+        DDR_New<Object> page = new DDR_New<>();
+        page.setTotalCount(records.size());
+        page.setData(records);
+        return page;
+    }
+
+    private static WHConf requiredHoursConf() {
+        WHConf conf = new WHConf();
+        conf.setYidaAppType("app");
+        conf.setYidaSystemToken("token");
+        conf.setFormUuidPersonnel("personnel");
+        conf.setFormUuidRequiredHours("required-hours");
+        return conf;
+    }
+}

+ 53 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursDuplicateResolverTest.java

@@ -0,0 +1,53 @@
+package com.malk.service.workhours;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class WorkHoursDuplicateResolverTest {
+
+    @Test
+    public void resolveShouldKeepMostCompleteCandidate() {
+        WorkHoursDuplicateResolver.Candidate sparse = candidate("old", "u1|2026-07-15", 2, 200L, 200L);
+        WorkHoursDuplicateResolver.Candidate complete = candidate("complete", "u1|2026-07-15", 5, 100L, 100L);
+
+        WorkHoursDuplicateResolver.Resolution result = WorkHoursDuplicateResolver.resolve(
+                Arrays.asList(sparse, complete));
+
+        assertEquals(Collections.singletonList("old"), result.getDeleteInstanceIds());
+        assertEquals("complete", result.getGroups().get(0).getKeepInstanceId());
+    }
+
+    @Test
+    public void resolveShouldKeepMostRecentlyModifiedWhenCompletenessMatches() {
+        WorkHoursDuplicateResolver.Candidate older = candidate("older", "u1|2026-07-15", 5, 100L, 100L);
+        WorkHoursDuplicateResolver.Candidate newer = candidate("newer", "u1|2026-07-15", 5, 200L, 100L);
+
+        WorkHoursDuplicateResolver.Resolution result = WorkHoursDuplicateResolver.resolve(
+                Arrays.asList(older, newer));
+
+        assertEquals(Collections.singletonList("older"), result.getDeleteInstanceIds());
+    }
+
+    @Test
+    public void resolveShouldPreserveCandidatesWithoutReliableKey() {
+        WorkHoursDuplicateResolver.Resolution result = WorkHoursDuplicateResolver.resolve(
+                Collections.singletonList(candidate("invalid", null, 5, 200L, 100L)));
+
+        assertEquals(1, result.getSkippedInvalidKey());
+        assertTrue(result.getDeleteInstanceIds().isEmpty());
+    }
+
+    private static WorkHoursDuplicateResolver.Candidate candidate(String instanceId,
+                                                                   String key,
+                                                                   int completeness,
+                                                                   long modifiedAt,
+                                                                   long createdAt) {
+        return new WorkHoursDuplicateResolver.Candidate(
+                instanceId, key, completeness, modifiedAt, createdAt);
+    }
+}

+ 61 - 0
mjava-akdsbeisen/src/test/java/com/malk/timer/WorkHoursTimerScheduleTest.java

@@ -0,0 +1,61 @@
+package com.malk.timer;
+
+import org.junit.Test;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+
+public class WorkHoursTimerScheduleTest {
+
+    @Test
+    public void workHoursIncrementalTimerShouldRunAtThreeThirtyAndTwelveFortyFive() throws Exception {
+        Method method = WorkHoursCalcTimer.class.getMethod("calcDailyIncrementalSync");
+
+        assertEquals(setOf("0 30 3 * * ?", "0 45 12 * * ?"), scheduledCrons(method));
+    }
+
+    @Test
+    public void workHoursTimerShouldRespectGlobalSchedulingSwitch() {
+        ConditionalOnProperty condition = WorkHoursCalcTimer.class.getAnnotation(ConditionalOnProperty.class);
+
+        assertEquals("enable", condition.prefix());
+        assertEquals(Arrays.asList("scheduling"), Arrays.asList(condition.name()));
+        assertEquals("true", condition.havingValue());
+        assertEquals(false, condition.matchIfMissing());
+    }
+
+    @Test
+    public void projectChangeTimerShouldRunFifteenMinutesBeforeWorkHoursTimer() throws Exception {
+        Method method = ProjectChangeSyncTimer.class.getMethod("syncProjectChanges");
+
+        assertEquals(setOf("0 15 3 * * ?", "0 30 12 * * ?"), scheduledCrons(method));
+    }
+
+    @Test
+    public void projectChangeTimerShouldRequireExplicitEnablement() {
+        ConditionalOnProperty condition = ProjectChangeSyncTimer.class.getAnnotation(ConditionalOnProperty.class);
+
+        assertEquals("workhours", condition.prefix());
+        assertEquals(Arrays.asList("projectChangeSyncEnabled"), Arrays.asList(condition.name()));
+        assertEquals("true", condition.havingValue());
+        assertEquals(false, condition.matchIfMissing());
+    }
+
+    private static Set<String> scheduledCrons(Method method) {
+        Set<String> crons = new HashSet<>();
+        for (Scheduled scheduled : method.getAnnotationsByType(Scheduled.class)) {
+            crons.add(scheduled.cron());
+        }
+        return crons;
+    }
+
+    private static Set<String> setOf(String... values) {
+        return new HashSet<>(Arrays.asList(values));
+    }
+}