Browse Source

- 台账列:姓名、身份证、街镇/村、银行卡、开户行、社保卡号、同城/异地、状态、退养资格;备注展示空。导出 `POST /api/personnel/export-ledger`。
- 同城/异地:`c_local_or_remote`(兼容备注 `LOCAL_REMOTE`)。
- 村级新增默认无土地有资格;接口/导入默认有土地。镇经发只通过/驳回本次新增(`PENDING_TOWN_APPROVE`),待审/已审为月人员表。新增名单 `POST /api/personnel/export-new-month` 导出纸质 xlsx。
- 无土地:城乡居保 + 退休(社保)+ 本区农业户口。有土地:城乡居保 + 退休 + 本区承包地。
- 死亡预警只提示:`c_death_status=已死亡` 或有死亡日期,且业务态不是丧葬/已退出;不改 `c_biz_status`。
- 社保卡号字段即 `c_social_insurance_code`。历史底数可用 `scripts/backfill-ssc-from-excel.py` 按身份证从「已匹配200编号」明细只补空值。

DESKTOP-6LTVLN7\Liumouren 2 weeks ago
parent
commit
8d820f0282
48 changed files with 5069 additions and 213 deletions
  1. 21 9
      docs/README.md
  2. 9 9
      docs/api-business-flow-guide.md
  3. 14 5
      docs/district-month-batch.md
  4. 161 0
      scripts/add-personnel-fields-20261012.js
  5. 442 0
      scripts/audit-amount-lifecycle-20261009.js
  6. 251 0
      scripts/audit-amount-sum-20261009.js
  7. 413 0
      scripts/backfill-ssc-from-excel.py
  8. 443 0
      scripts/build-field-xlsx-20260904.py
  9. 193 0
      scripts/migrate-excel-field-gaps-20260904.js
  10. 188 0
      scripts/migrate-household-address-20260909.js
  11. 58 0
      scripts/pack-desktop-20261012.js
  12. 1 1
      scripts/seed-history-3months.js
  13. 206 0
      scripts/test-personnel-fields-20261012.js
  14. 21 1
      src/main/java/com/yykj/sjnmtybt/config/SjnmtybtProperties.java
  15. 58 27
      src/main/java/com/yykj/sjnmtybt/controller/AmountStandardController.java
  16. 2 2
      src/main/java/com/yykj/sjnmtybt/controller/BatchController.java
  17. 13 2
      src/main/java/com/yykj/sjnmtybt/controller/DashboardController.java
  18. 19 2
      src/main/java/com/yykj/sjnmtybt/controller/PaymentController.java
  19. 52 3
      src/main/java/com/yykj/sjnmtybt/controller/PersonnelController.java
  20. 2 2
      src/main/java/com/yykj/sjnmtybt/controller/WorkflowController.java
  21. 22 8
      src/main/java/com/yykj/sjnmtybt/dms/DmsClient.java
  22. 33 0
      src/main/java/com/yykj/sjnmtybt/dms/DmsRowUtils.java
  23. 2 0
      src/main/java/com/yykj/sjnmtybt/dto/request/AlertQueryRequest.java
  24. 2 2
      src/main/java/com/yykj/sjnmtybt/dto/request/AmountStandardSaveRequest.java
  25. 4 0
      src/main/java/com/yykj/sjnmtybt/dto/request/PersonnelQueryRequest.java
  26. 26 2
      src/main/java/com/yykj/sjnmtybt/dto/request/PersonnelSaveRequest.java
  27. 8 0
      src/main/java/com/yykj/sjnmtybt/dto/request/SpecialBizCreateRequest.java
  28. 3 2
      src/main/java/com/yykj/sjnmtybt/dto/request/YearlyAdjustRequest.java
  29. 26 1
      src/main/java/com/yykj/sjnmtybt/dto/response/PersonnelVO.java
  30. 8 0
      src/main/java/com/yykj/sjnmtybt/dto/response/SpecialBizVO.java
  31. 1 0
      src/main/java/com/yykj/sjnmtybt/dto/response/WorkbenchVO.java
  32. 57 0
      src/main/java/com/yykj/sjnmtybt/integration/DeathCheckClient.java
  33. 147 0
      src/main/java/com/yykj/sjnmtybt/integration/InsuranceProfileClient.java
  34. 118 0
      src/main/java/com/yykj/sjnmtybt/integration/LandChangeClient.java
  35. 30 0
      src/main/java/com/yykj/sjnmtybt/integration/UnconfiguredDeathCheckClient.java
  36. 30 0
      src/main/java/com/yykj/sjnmtybt/integration/UnconfiguredInsuranceProfileClient.java
  37. 30 0
      src/main/java/com/yykj/sjnmtybt/integration/UnconfiguredLandChangeClient.java
  38. 3 2
      src/main/java/com/yykj/sjnmtybt/schedule/DistrictMonthBatchScheduler.java
  39. 53 0
      src/main/java/com/yykj/sjnmtybt/schedule/ExternalPersonnelScheduler.java
  40. 18 0
      src/main/java/com/yykj/sjnmtybt/service/AmountStandardIds.java
  41. 391 81
      src/main/java/com/yykj/sjnmtybt/service/BizDataService.java
  42. 574 48
      src/main/java/com/yykj/sjnmtybt/service/BizFlowService.java
  43. 2 2
      src/main/java/com/yykj/sjnmtybt/service/DictCatalogService.java
  44. 237 0
      src/main/java/com/yykj/sjnmtybt/service/ExternalPersonnelSyncService.java
  45. 367 2
      src/main/java/com/yykj/sjnmtybt/service/PersonnelImportExportService.java
  46. 192 0
      src/main/java/com/yykj/sjnmtybt/service/TownStatAmounts.java
  47. 14 0
      src/main/resources/application.yml
  48. 104 0
      src/test/java/com/yykj/sjnmtybt/service/TownStatAmountsTest.java

+ 21 - 9
docs/README.md

@@ -34,14 +34,16 @@
 - 村页录入/导入人员后为「待提交资格」,点「提交资格」由镇经发按人审批。
 - **不再** ensure 村批、选人、提交初审。
 - 补发(含回盘失败待补发、恢复发放、调标差额)由镇级特殊业务写入当月全区明细。
+- 全区月批每月 **15 日 01:00** 生成;资格通过日 ≤15 进本月起始月,≥16 进下月。详见 [district-month-batch.md](./district-month-batch.md)。
 
-### 2. 调标差额补发(`STANDARD_ADJUST`)
+### 2. 调标差额补发(仅区人社)
 
-- 入口 A:金额标准页 `POST /api/amount-standards/yearly-adjust`(手填新标准并落库)。
-- 入口 B:特殊业务页只选**街镇**,可选备注/附件;新旧土地标准与生效月从金额标准**历史**自动带出(`skipSaveStandards`)。
-- 批次形态:差额挂当年/当月 **全区批** 补发明细,**不再**新建 `VB{year}99-*` 村批。
-- 审批:特殊业务单走分管领导;出盘可走对照文件 `adjustOnly=true`。
-- 同镇同自然年未归档调标批不可重复提交(`409`);差额须为正;重建前软删同号残留。
+- 调标只开给区人社:`POST /api/amount-standards/district-yearly-adjust`(或 `yearly-adjust` 且 `districtWide=true`)。标准只写全区一份。
+- 街镇特殊业务 **不再发起** `STANDARD_ADJUST`(接口 400)。
+- 完成后按村生成 `VB{year}99-{villageId}` 差额专批(`payMonth={year}99`,只记补发),**不**改写当月全区月批 `DB*`。
+- 写流程通知 `DISTRICT_ADJUST_NOTICE`;街镇(经发/社区事务/分管领导)登录读 `GET /api/amount-standards/adjust-notice` 弹「已调标」。
+- 同镇同自然年未归档调标批不可重复提交(`409`);差额须为正。
+- 收口后未出盘全区月批若仍停在街镇测试月标(如叶榭 1380、石湖荡 1100),用 `POST /api/amount-standards/realign-open-payments` 收到现行全区月标;领导审镇统计批前对齐父批本镇切片。
 
 ### 3. 回盘与待补发台账
 
@@ -55,7 +57,7 @@
 
 - 月度清单「提交分管领导」前:该镇该发放月特殊业务不能有「待分管领导审批」。
 - 仅「待分管领导审批」可审;通过后写人员状态/继承人/丧葬/恢复补发等副作用。
-- 创建校验:RESUME 仅暂停/停止;PAUSE 仅正常/草稿;FUNERAL 不重复;调标必选镇且标准有正差额。
+- 创建校验:RESUME 仅暂停/停止;PAUSE 仅正常/草稿;FUNERAL 不重复。调标不走特殊业务创建。
 - 全链路脚本:`scripts/e2e-lifecycle-full.js`。
 
 ### 4. 历月发放查询:应发 / 实发
@@ -67,15 +69,25 @@
 
 ### 5. 金额标准
 
+- 全区统一一份(`c_town_id=T_DISTRICT`),不按街镇分别维护。核算读全区当前 YEARLY_ADJUST。
 - **年度标准**(`YEARLY_ADJUST`):一行双金额 — `c_amount` 土地月补 + `c_funeral_amount` 丧葬。
-- 日常核算优先读当前年度标准;兼容历史 `MONTHLY`/`FUNERAL` 单行。
-- 脏数据:`POST /api/amount-standards/cleanup-legacy-pairs` 将成对历史两行合并为一行。
+- 收口脏数据:`POST /api/amount-standards/unify-district` 保留人员明细统一 1060/24868,软删各镇重复行与测试行。
+- 兼容:`POST /api/amount-standards/cleanup-legacy-pairs` 将成对历史 MONTHLY+FUNERAL 两行合并。
 
 ### 6. 前端列表展示
 
 - 列表不展示非批次 UUID/单号列;保留**批次 ID**及业务码(身份证、区划代码、出盘编号等)。
 - 详情/表单可为运维保留人员 ID。
 
+### 7. 人员台账 / 资格 / 死亡预警(20260916)
+
+- 台账列:姓名、身份证、街镇/村、银行卡、开户行、社保卡号、同城/异地、状态、退养资格;备注展示空。导出 `POST /api/personnel/export-ledger`。
+- 同城/异地:`c_local_or_remote`(兼容备注 `LOCAL_REMOTE`)。
+- 村级新增默认无土地有资格;接口/导入默认有土地。镇经发只通过/驳回本次新增(`PENDING_TOWN_APPROVE`),待审/已审为月人员表。新增名单 `POST /api/personnel/export-new-month` 导出纸质 xlsx。
+- 无土地:城乡居保 + 退休(社保)+ 本区农业户口。有土地:城乡居保 + 退休 + 本区承包地。
+- 死亡预警只提示:`c_death_status=已死亡` 或有死亡日期,且业务态不是丧葬/已退出;不改 `c_biz_status`。
+- 社保卡号字段即 `c_social_insurance_code`。历史底数可用 `scripts/backfill-ssc-from-excel.py` 按身份证从「已匹配200编号」明细只补空值。
+
 ---
 
 ## 推荐阅读顺序

+ 9 - 9
docs/api-business-flow-guide.md

@@ -5,7 +5,7 @@
 > Swagger:`http://localhost:8088/sjnmtybt/swagger-ui.html`  
 > 统一响应:`{ "code": 200, "message": "...", "data": ... }`(`code≠200` 为失败)  
 > **正式前端**:`sj_tdtybz`(页面流程以新前端为准;联调映射见 `sj_tdtybz/docs/api-integration.md`)。旧 `sjnmtybt-web` 停用作业务入口。  
-> **现行主线(2026-08-13)**:村/镇经发不再提交村批,改为资格审批 + 每月 1 号全区一批次。详见 [district-month-batch.md](./district-month-batch.md)。下文 §0.5 / 泳道一已按此修订;历史 `VILLAGE` 批只读。
+> **现行主线(2026-09-15)**:村/镇经发不再提交村批,改为资格审批 + 每月 **15 号**全区一批次。详见 [district-month-batch.md](./district-month-batch.md)。下文 §0.5 / 泳道一已按此修订;历史 `VILLAGE` 批只读。
 
 **文档结构速览**
 
@@ -156,7 +156,7 @@ p_DISTRICT_SUPERVISOR01
 
 ```mermaid
 flowchart TD
-  A0[每月1号或管理员手动<br/>生成全区 DISTRICT 批] --> C[STAGE3<br/>PENDING_INSURANCE_MATCH]
+  A0[每月15号或管理员手动<br/>生成全区 DISTRICT 批] --> C[STAGE3<br/>PENDING_INSURANCE_MATCH]
   C --> M[自动居保]
   M --> D[全区主批 STAGE4 / PENDING_STATUS_MAINTAIN]
   D --> T[各镇 TOWN 统计记录]
@@ -192,7 +192,7 @@ flowchart TD
 |----|------|------|----------------------|
 | DRAFT | 待提交资格 | 村已录入,尚未提交资格审批 | 否 |
 | PENDING_TOWN_APPROVE | 待资格审批 | 已提交,等镇经发按人审资格 | 否 |
-| NEW | 新增 | 资格已通过,等待下个 1 号纳入全区月发 | 否(1 号自动纳入) |
+| NEW | 新增 | 资格已通过,等待下个 15 号纳入全区月发 | 否(15 号自动纳入;通过日≤15 进本月起始月) |
 | PENDING_INSURANCE_MATCH | 待居保匹配 | 等居保结果 | 否 |
 | NORMAL | 正常发放 | 资格正常,可纳入月补贴清单 | 是(`payThisMonth=true`) |
 | PAUSED | 暂停发放 | 临时停发,可经特殊业务恢复 | 否 |
@@ -209,7 +209,7 @@ flowchart TD
 | 提交退养资格 | `POST /api/personnel/{id}/qualify/submit` | → `PENDING_TOWN_APPROVE`,写资格日志 |
 | 镇经发资格通过 | `POST /api/personnel/{id}/qualify/review` pass=true | → `NEW`,资格 `WITH_LAND`/`WITHOUT_LAND` |
 | 镇经发认定无资格 | 同上 pass=false + INELIGIBLE | → `ABNORMAL` |
-| 每月 1 号纳入 | `generate-district-month` | `NEW`/`NORMAL` 写入全区发放明细 |
+| 每月 15 号纳入 | `generate-district-month` | `NEW`/`NORMAL` 写入全区发放明细 |
 | 居保失败 | `trigger` | → `ABNORMAL` + 告警 |
 | 居保死亡 | `trigger` | → `FUNERAL`,土地月补清零或按规则保留,写入丧葬金额 |
 | 居保人工确认通过 | `POST /api/insurance-match/manual-confirm`(pass=true) | `insuranceMatchResult→MANUAL_PASS`,`bizStatus→NORMAL` |
@@ -381,9 +381,9 @@ flowchart LR
 ## 2. 泳道一:月度补助金申请(主线)
 
 ```
-录入人员 → 提交资格 → 镇经发按人审批 →(新增)等待每月 1 号
+录入人员 → 提交资格 → 镇经发按人审批 →(新增)等待每月 15 号
     └─ 生成全区月批 → 自动居保 → 本镇月度清单 → 特殊业务门禁 → 分管领导 → 出盘回盘
-1 号之后新录入默认进下月;本月要发走特殊业务(纳入/补发)。
+通过日 ≤15 进本月起始月;16 号及之后新录入默认进下月;本月要发走特殊业务(纳入/补发)。
 ```
 
 ### 步骤 A. 村合作社:人员录入与提交资格
@@ -411,7 +411,7 @@ flowchart LR
 
 | 顺序 | 方法 | 路径 |
 |------|------|------|
-| 定时 | cron `0 0 1 1 * ?` | 每月 1 日 01:00 |
+| 定时 | cron `0 0 1 15 * ?` | 每月 15 日 01:00 |
 | 手动 | POST | `/api/batches/generate-district-month?payMonth=&matchInsurance=` |
 | 查询 | POST | `/api/batches/page`(`batchLevel=DISTRICT` / `TOWN`) |
 
@@ -494,7 +494,7 @@ flowchart LR
 
 > 死亡只能经「死亡登记/丧葬」特殊业务领导审批落地;居保匹配禁止因 `deathDate`/备注把正常人改成死亡。
 
-资格通过后人员为 `NEW`,1 号纳入全区明细后再居保。比对范围=发放明细人员,按 `townId` 切本镇。
+资格通过后人员为 `NEW`,15 号纳入全区明细后再居保。比对范围=发放明细人员,按 `townId` 切本镇。
 
 **触发请求**
 
@@ -840,7 +840,7 @@ flowchart LR
 3. `POST /api/personnel` → `POST /api/personnel/page`  
 4. `POST /api/personnel/{id}/qualify/submit`  
 5. `POST /api/personnel/{id}/qualify/review` `{pass:true, retireQualifyType:"WITH_LAND"}`  
-6. `POST /api/batches/generate-district-month`(每月 1 号定时;今日已过 1 号由管理员手动)  
+6. `POST /api/batches/generate-district-month`(每月 15 号定时;今日已过 15 号由管理员手动)  
 7. `POST /api/insurance-match/trigger`(生成时已自动触发)→ `GET .../results?townId=`(FAIL 时 `manual-confirm`)  
 8. `POST /api/payments/details/page`(`batchId`=全区批 + `townId` 切本镇)  
 9. `POST /api/batches/{镇统计id}/submit-leader`(本镇特殊业务须全部通过)  

+ 14 - 5
docs/district-month-batch.md

@@ -18,20 +18,29 @@
 |----|----------|------|
 | `DRAFT` | 待提交资格 | 村已录入,尚未提交资格审批 |
 | `PENDING_TOWN_APPROVE` | 待资格审批 | 已提交,等镇经发 |
-| `NEW` | 新增 | 资格已通过,等待下个 1 号纳入月发 |
+| `NEW` | 新增 | 资格已通过,等待下个 15 号纳入月发 |
 | `NORMAL` | 正常发放 | 已在发 |
 | `ABNORMAL` | 异常待处理 | 居保失败、无资格等 |
-| 其余 | 暂停/停止/死亡丧葬/已退出/归档 | 同原规则,不进 1 号自动名单 |
+| 其余 | 暂停/停止/死亡丧葬/已退出/归档 | 同原规则,不进 15 号自动名单 |
 
-## 每月 1 号(01:00)
+## 每月 15 号(01:00)
+
+定时 cron:`0 0 1 15 * ?`(与 `BizFlowService.DISTRICT_MONTH_GENERATE_DAY = 15` 一致)。
+
+**资格通过日与下次发放起始月**(`nextPayMonthAfterQualify` / `qualifiesForCurrentDistrictMonth`):
+
+- 通过日 **≤ 当月 15 号**:起始月 = **本月**(等当月 15 号定时进 `DB{yyyyMM}`)
+- 通过日 **≥ 16 号**:起始月 = **下月**(等下月 15 号纳入)
+
+不要用「仅当天=15 才进本月」;否则 1~14 日通过的人会被写成下月、错过当月批。
 
 1. 生成(或刷新)当月 **一条全区批次** `DB{yyyyMM}`,`batchLevel=DISTRICT`。
 2. 为每个街镇 upsert **一条镇统计记录** `TB{yyyyMM}-{townId}`,`batchLevel=TOWN`,`c_parent_batch_id=全区批ID`。
 3. 纳入发放明细(全部挂全区批 `c_batch_id`,`c_town_id` 用人身上的街镇):
    - 居保 `SUCCESS/MANUAL_PASS` + 业务 `NORMAL` + 资格 `WITH_LAND/WITHOUT_LAND`
-   - **或** 业务 `NEW`(须已有资格)
+   - **或** 业务 `NEW`(须已有资格 + 下次发放起始月不晚于本月)
 4. 自动触发居保匹配。
-5. 1 号之后新录入的人 **不自动追加本月**;本月要发须特殊业务审批通过后加人。
+5. **15 号之后**新录入/新通过的人 **不自动追加本月**;本月要发须特殊业务审批通过后加人。
 
 配置:`sjnmtybt.schedule.district-month-batch=true`(默认 true)。管理员:`POST /api/batches/generate-district-month?payMonth=yyyyMM`。
 

+ 161 - 0
scripts/add-personnel-fields-20261012.js

@@ -0,0 +1,161 @@
+/**
+ * 人员模型 1970 只补用户清单里缺少的字段,不删不改已有列。
+ * 凭据:环境变量 SJ_OAUTH_PASSWORD,不要把密码写进本文件。
+ */
+const http = require('http')
+const qs = require('querystring')
+
+const HOST = '121.43.55.7'
+const PORT = 2101
+const USER = process.env.SJ_OAUTH_USER || 'user_liu'
+const PASS = process.env.SJ_OAUTH_PASSWORD
+const MODEL_ID = 1970
+if (!PASS) {
+  console.error('缺少 SJ_OAUTH_PASSWORD')
+  process.exit(1)
+}
+
+function post(urlPath, fields, token) {
+  return new Promise((resolve, reject) => {
+    const body = qs.stringify(fields || {})
+    const headers = {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      'Content-Length': Buffer.byteLength(body),
+    }
+    if (token) {
+      headers.Token = token
+      headers.token = token
+    }
+    const req = http.request(
+      { hostname: HOST, port: PORT, path: urlPath, method: 'POST', headers },
+      (res) => {
+        let data = ''
+        res.on('data', (c) => (data += c))
+        res.on('end', () => {
+          try { resolve(JSON.parse(data)) } catch (e) { resolve({ raw: data, status: res.statusCode }) }
+        })
+      },
+    )
+    req.on('error', reject)
+    req.write(body)
+    req.end()
+  })
+}
+
+function text(shortName, alias, seq) {
+  return {
+    alias,
+    customType: '',
+    describe: '格式为富文本',
+    frontType: 'content',
+    id: 2,
+    must: false,
+    name: 'c_' + shortName,
+    searchType: '2',
+    sequence: seq || 1,
+    showParam: 'name,alias,desc,type,front_type,must,default_value',
+    type: 'text',
+  }
+}
+
+function normalizeFieldList(raw) {
+  let fl = raw
+  if (typeof fl === 'string') fl = JSON.parse(fl)
+  const out = {}
+  Object.keys(fl || {}).forEach((k) => {
+    let v = fl[k]
+    if (typeof v === 'string') v = JSON.parse(v)
+    out[k] = v
+  })
+  return out
+}
+
+const ADD = [
+  ['person_type', '人员类型'],
+  ['urban_medicare_confirm', '转至城镇医保确认状态'],
+  ['household_district', '户籍区县'],
+  ['household_town', '户籍街镇'],
+  ['household_village', '户籍村居委'],
+  ['contact_name', '联系人'],
+  ['contact_phone', '联系电话'],
+  ['insurance_join_date', '参保日期'],
+  ['person_mark', '人员标示'],
+  ['change_reason', '变更原因'],
+  ['benefit_apply_date', '待遇申领日期'],
+  ['sentence_start', '刑期开始时间'],
+  ['sentence_end', '刑期结束时间'],
+  ['cancel_apply_date', '注销申办日期'],
+  ['cancel_reason', '注销原因'],
+  ['cancel_death_month', '注销(死亡)年月'],
+  ['has_heir', '是否有继承人'],
+  ['heir_id_type', '继承人证件类型'],
+  ['heir_bank_name', '继承人银行名称'],
+  ['heir_household_address', '继承人户籍地址'],
+  ['heir_address', '继承人联系地址'],
+  ['local_or_remote', '同城或异地'],
+]
+
+async function main() {
+  const login = await post('/proxy_oauth/user/login', {
+    userName: USER,
+    password: PASS,
+    clientId: '1',
+  })
+  const token = login.message
+  if (!token || String(token).indexOf('.') < 0) {
+    console.error('login failed', login.code, String(login.message || login.msg || '').slice(0, 160))
+    process.exit(1)
+  }
+  const modelRes = await post('/proxy_dms/model/getModelById', { modelId: String(MODEL_ID) }, token)
+  if (modelRes.code != 200) throw new Error('getModel fail ' + JSON.stringify(modelRes).slice(0, 300))
+  const model = modelRes.content
+  const fieldList = normalizeFieldList(model.fieldList)
+  const addfieldList = {}
+  let seq = 80
+  Object.keys(fieldList).forEach((k) => {
+    const n = Number(fieldList[k] && fieldList[k].sequence) || 0
+    if (n > seq) seq = n
+  })
+  ADD.forEach(([shortName, alias]) => {
+    const full = 'c_' + shortName
+    if (fieldList[full]) return
+    seq += 1
+    const p = text(shortName, alias, seq)
+    fieldList[full] = p
+    addfieldList[shortName] = {
+      alias: p.alias,
+      type: p.type,
+      frontType: p.frontType,
+      must: p.must,
+      name: shortName,
+      describe: p.describe,
+      searchType: p.searchType,
+      sortType: p.sortType,
+      sequence: p.sequence,
+      showParam: p.showParam,
+      customType: '',
+      id: p.id,
+    }
+  })
+  const added = Object.keys(addfieldList)
+  console.log('existing', Object.keys(fieldList).length - added.length, 'toAdd', added.join(',') || 'none')
+  if (!added.length) return
+  const res = await post('/proxy_dms/model/updateFieldList', {
+    modelId: String(MODEL_ID),
+    fieldList: JSON.stringify(fieldList),
+    addfieldList: JSON.stringify(addfieldList),
+    delfieldList: JSON.stringify({}),
+    updatefieldList: JSON.stringify({}),
+  }, token)
+  const verify = await post('/proxy_dms/model/getModelById', { modelId: String(MODEL_ID) }, token)
+  const after = normalizeFieldList(verify.content && verify.content.fieldList)
+  const still = added.filter((n) => !after['c_' + n])
+  console.log('update', res.code, String(res.message || res.content || '').slice(0, 200))
+  console.log('after', Object.keys(after).length, 'stillMissing', still.join(',') || 'none')
+  if (still.length) process.exit(2)
+}
+
+main().catch((e) => {
+  console.error(e)
+  process.exit(1)
+})

+ 442 - 0
scripts/audit-amount-lifecycle-20261009.js

@@ -0,0 +1,442 @@
+/**
+ * 只读核对线上金额与生命周期断点。不调用会回写统计的接口。
+ * node scripts/audit-amount-lifecycle-20261009.js
+ */
+const http = require('http')
+const fs = require('fs')
+const path = require('path')
+
+const HOST = '121.43.55.7'
+const PORT = 11091
+const MONTHS = ['202607', '202608', '202609']
+
+function req(method, apiPath, body) {
+  return new Promise((resolve) => {
+    const data = body == null ? null : JSON.stringify(body)
+    const headers = { Accept: 'application/json' }
+    if (data) {
+      headers['Content-Type'] = 'application/json'
+      headers['Content-Length'] = Buffer.byteLength(data)
+    }
+    const r = http.request(
+      { hostname: HOST, port: PORT, path: '/sjnmtybt' + apiPath, method, headers, timeout: 120000 },
+      (res) => {
+        let raw = ''
+        res.on('data', (c) => (raw += c))
+        res.on('end', () => {
+          try {
+            resolve(JSON.parse(raw))
+          } catch (e) {
+            resolve({ code: res.statusCode, parseError: true, raw: String(raw).slice(0, 180) })
+          }
+        })
+      },
+    )
+    r.on('error', (e) => resolve({ code: 0, error: e.message }))
+    r.on('timeout', () => {
+      r.destroy()
+      resolve({ code: 0, error: 'timeout' })
+    })
+    if (data) r.write(data)
+    r.end()
+  })
+}
+
+const n = (v) => {
+  const x = Number(v || 0)
+  return Number.isFinite(x) ? x : 0
+}
+const round2 = (v) => Math.round(n(v) * 100) / 100
+const near = (a, b, tol = 0.05) => Math.abs(round2(a) - round2(b)) <= tol
+
+async function pageAll(apiPath, body, pageSize) {
+  const all = []
+  let total = 0
+  let pageNum = 1
+  for (;;) {
+    const res = await req('POST', apiPath, { ...body, pageNum, pageSize })
+    if (res.code !== 200) return { error: res.message || res.error || res.raw, list: all, total }
+    const list = res.data?.list || []
+    total = Number(res.data?.total || 0)
+    all.push(...list)
+    if (!list.length || all.length >= total || pageNum > 80) break
+    pageNum += 1
+  }
+  return { list: all, total, pages: pageNum }
+}
+
+function linePayable(d) {
+  return n(d.monthlyAmount) + n(d.oneTimeAmount) + n(d.supplementAmount) + n(d.funeralAmount) - n(d.deductAmount)
+}
+
+;(async () => {
+  const report = { at: new Date().toISOString(), host: HOST + ':' + PORT, findings: [] }
+  const add = (level, title, detail) => {
+    report.findings.push({ level, title, detail })
+    console.log(level + ' | ' + title + (detail ? ' | ' + JSON.stringify(detail).slice(0, 400) : ''))
+  }
+
+  const regions = await req('GET', '/api/regions?level=TOWN&parentId=D_SJ')
+  const towns = (regions.data || []).filter((t) => t && t.enabled !== false)
+  report.towns = towns.map((t) => ({
+    id: t.id,
+    name: t.name,
+    districtSharePercent: t.districtSharePercent,
+    townSharePercent: t.townSharePercent,
+  }))
+  const townName = Object.fromEntries(towns.map((t) => [t.id, t.name]))
+  const shareOf = Object.fromEntries(towns.map((t) => [t.id, t]))
+  for (const t of towns) {
+    const d = n(t.districtSharePercent)
+    const w = n(t.townSharePercent)
+    if (!near(d + w, 100, 0.01)) {
+      add('BUG', '街镇分担比例合计不是 100', { town: t.name, district: d, townShare: w })
+    }
+  }
+
+  const villagesByTown = {}
+  for (const t of towns) {
+    const vs = await req('GET', '/api/regions?level=VILLAGE&parentId=' + encodeURIComponent(t.id))
+    villagesByTown[t.id] = (vs.data || []).filter((v) => v && v.enabled !== false)
+  }
+  report.villageCounts = Object.fromEntries(
+    towns.map((t) => [t.name, villagesByTown[t.id].length]),
+  )
+
+  const stdDistrict = await req(
+    'GET',
+    '/api/amount-standards/current?townId=T_DISTRICT&standardType=MONTHLY',
+  )
+  const districtMonthly = n(stdDistrict.data?.amount)
+  const districtFuneral = n(stdDistrict.data?.funeralAmount)
+  report.districtStandard = {
+    monthly: districtMonthly,
+    funeral: districtFuneral,
+    id: stdDistrict.data?.id,
+    raw: stdDistrict.code === 200 ? undefined : stdDistrict,
+  }
+  if (!(districtMonthly > 0)) add('BUG', '全区现行月标不是正数', report.districtStandard)
+  for (const t of towns) {
+    const s = await req(
+      'GET',
+      '/api/amount-standards/current?townId=' + encodeURIComponent(t.id) + '&standardType=MONTHLY',
+    )
+    const amt = n(s.data?.amount)
+    if (amt && !near(amt, districtMonthly, 0.01)) {
+      add('BUG', '街镇当前月标与全区标准不一致', { town: t.name, townAmount: amt, district: districtMonthly })
+    }
+  }
+
+  const statuses = [
+    'DRAFT',
+    'PENDING_TOWN_APPROVE',
+    'NEW',
+    'PENDING_INSURANCE_MATCH',
+    'NORMAL',
+    'PAUSED',
+    'STOPPED',
+    'FUNERAL',
+    'EXITED',
+    'ABNORMAL',
+    'ARCHIVED',
+  ]
+  report.personnelTotals = {}
+  for (const st of statuses) {
+    const p = await req('POST', '/api/personnel/page', { pageNum: 1, pageSize: 1, bizStatus: st })
+    report.personnelTotals[st] = p.code === 200 ? Number(p.data?.total || 0) : p.message || p.error
+  }
+  add('INFO', '人员状态底数', report.personnelTotals)
+
+  const pendingSpecial = await req(
+    'GET',
+    '/api/special-biz/page?approveStatus=' + encodeURIComponent('待分管领导审批') + '&pageNum=1&pageSize=200',
+  )
+  const pendingList = pendingSpecial.data?.list || []
+  report.pendingSpecial = {
+    total: Number(pendingSpecial.data?.total || pendingList.length),
+    byTown: {},
+    byType: {},
+  }
+  for (const s of pendingList) {
+    const tn = townName[s.townId] || s.townId || '未知'
+    report.pendingSpecial.byTown[tn] = (report.pendingSpecial.byTown[tn] || 0) + 1
+    const tp = s.bizType || '未知'
+    report.pendingSpecial.byType[tp] = (report.pendingSpecial.byType[tp] || 0) + 1
+  }
+
+  report.months = {}
+  for (const ym of MONTHS) {
+    const batchRes = await req('POST', '/api/batches/page', { pageNum: 1, pageSize: 100, payMonth: ym })
+    const batches = batchRes.data?.list || []
+    const district = batches.filter((b) => b.batchLevel === 'DISTRICT')
+    const townBatches = batches.filter((b) => b.batchLevel === 'TOWN')
+    const other = batches.filter((b) => b.batchLevel !== 'DISTRICT' && b.batchLevel !== 'TOWN')
+    const month = {
+      batchQueryTotal: Number(batchRes.data?.total || batches.length),
+      loaded: batches.length,
+      district: district.map((b) => ({
+        id: b.id,
+        batchNo: b.batchNo,
+        status: b.status,
+        peopleCount: b.peopleCount,
+        totalAmount: n(b.totalAmount),
+        normalAmount: n(b.normalAmount),
+        supplementAmount: n(b.supplementAmount),
+        funeralAmount: n(b.funeralAmount),
+        deductAmount: n(b.deductAmount),
+        exportStatus: b.exportStatus,
+        returnStatus: b.returnStatus,
+      })),
+      townBatches: townBatches.map((b) => ({
+        id: b.id,
+        batchNo: b.batchNo,
+        townId: b.townId,
+        town: townName[b.townId] || b.townId,
+        status: b.status,
+        parentBatchId: b.parentBatchId,
+        peopleCount: n(b.peopleCount),
+        totalAmount: n(b.totalAmount),
+      })),
+      otherLevels: other.map((b) => ({
+        batchNo: b.batchNo,
+        level: b.batchLevel,
+        townId: b.townId,
+        villageId: b.villageId,
+        status: b.status,
+        payMonth: b.payMonth,
+        peopleCount: b.peopleCount,
+        totalAmount: n(b.totalAmount),
+      })),
+    }
+    if (!district.length) add('BUG', ym + ' 没有全区月批', { total: month.batchQueryTotal })
+    if (district.length > 1) add('BUG', ym + ' 全区月批多于 1 条', { n: district.length, nos: district.map((b) => b.batchNo) })
+    const townIds = new Set(townBatches.map((b) => b.townId))
+    for (const t of towns) {
+      if (district.length && !townIds.has(t.id)) {
+        add('BUG', ym + ' 缺少镇统计批', { town: t.name })
+      }
+    }
+
+    if (district[0]) {
+      const db = district[0]
+      const details = await pageAll('/api/payments/details/page', { batchId: db.id }, 400)
+      month.detailError = details.error
+      month.detailTotal = details.total
+      month.detailLoaded = details.list.length
+      const rows = details.list.filter((d) => d && d.changeFlag !== 'REMOVE')
+      const seen = new Map()
+      let formulaBad = 0
+      let standardBad = 0
+      let shareBad = 0
+      let neg = 0
+      const examples = []
+      const byTown = {}
+      const byVillage = {}
+      let sum = { monthly: 0, oneTime: 0, supplement: 0, funeral: 0, deduct: 0, payable: 0, actual: 0, fail: 0 }
+      for (const d of rows) {
+        const pid = d.personnelId || d.idNumber || d.id
+        if (seen.has(pid)) {
+          if (examples.length < 12) examples.push({ kind: '重复人员', name: d.name, town: townName[d.townId] || d.townId })
+        } else seen.set(pid, 1)
+        const expect = linePayable(d)
+        const got = n(d.totalAmount)
+        if (!near(expect, got)) {
+          formulaBad += 1
+          if (examples.length < 12) {
+            examples.push({
+              kind: '应发不等于分项',
+              name: d.name,
+              town: townName[d.townId] || d.townId,
+              expect: round2(expect),
+              got: round2(got),
+            })
+          }
+        }
+        if (n(d.monthlyAmount) < 0 || n(d.supplementAmount) < 0 || n(d.funeralAmount) < 0 || n(d.deductAmount) < 0 || got < 0) {
+          neg += 1
+        }
+        const monthly = n(d.monthlyAmount)
+        const funeral = n(d.funeralAmount)
+        const supplement = n(d.supplementAmount)
+        if (districtMonthly > 0 && monthly > 0 && !near(monthly, districtMonthly) && funeral === 0 && supplement === 0) {
+          standardBad += 1
+          if (examples.length < 12) {
+            examples.push({
+              kind: '月标偏离全区标准',
+              name: d.name,
+              town: townName[d.townId] || d.townId,
+              monthly: round2(monthly),
+              standard: districtMonthly,
+            })
+          }
+        }
+        const share = shareOf[d.townId]
+        const total = got
+        if (share && total > 0 && (d.districtAmount != null || d.townAmount != null)) {
+          const ds = n(d.districtAmount)
+          const ts = n(d.townAmount)
+          if (!near(ds + ts, total)) {
+            shareBad += 1
+          } else {
+            const expectD = total * n(share.districtSharePercent) / 100
+            if (!near(ds, expectD, 0.2)) shareBad += 1
+          }
+        }
+        const keyT = d.townId || '未知'
+        const keyV = (d.townId || '') + '|' + (d.villageId || '未知')
+        if (!byTown[keyT]) byTown[keyT] = { count: 0, payable: 0, monthly: 0, supplement: 0, funeral: 0, deduct: 0, actual: 0, fail: 0 }
+        if (!byVillage[keyV]) byVillage[keyV] = { townId: d.townId, villageId: d.villageId, count: 0, payable: 0 }
+        byTown[keyT].count += 1
+        byTown[keyT].payable += got
+        byTown[keyT].monthly += monthly
+        byTown[keyT].supplement += supplement
+        byTown[keyT].funeral += funeral
+        byTown[keyT].deduct += n(d.deductAmount)
+        byVillage[keyV].count += 1
+        byVillage[keyV].payable += got
+        sum.monthly += monthly
+        sum.oneTime += n(d.oneTimeAmount)
+        sum.supplement += supplement
+        sum.funeral += funeral
+        sum.deduct += n(d.deductAmount)
+        sum.payable += got
+        if (d.returnSuccess === false) {
+          sum.fail += 1
+          byTown[keyT].fail += 1
+        }
+        if (d.actualAmount != null) {
+          sum.actual += n(d.actualAmount)
+          byTown[keyT].actual += n(d.actualAmount)
+        }
+      }
+      for (const k of Object.keys(sum)) sum[k] = round2(sum[k])
+      month.sum = sum
+      month.formulaBad = formulaBad
+      month.standardBad = standardBad
+      month.shareBad = shareBad
+      month.negative = neg
+      month.uniquePeople = seen.size
+      month.examples = examples
+      if (details.total && details.list.length < details.total) {
+        add('BUG', ym + ' 发放明细没有拉全', { total: details.total, loaded: details.list.length, error: details.error })
+      }
+      if (!near(sum.payable, n(db.totalAmount), 1)) {
+        add('BUG', ym + ' 全区批头应发与明细合计不一致', {
+          header: round2(db.totalAmount),
+          detail: sum.payable,
+          headerPeople: db.peopleCount,
+          detailPeople: rows.length,
+        })
+      } else {
+        add('OK', ym + ' 全区批头应发与明细一致', { amount: sum.payable, people: rows.length })
+      }
+      if (Number(db.peopleCount || 0) !== rows.length) {
+        add('BUG', ym + ' 全区批人数与明细条数不一致', { header: db.peopleCount, detail: rows.length })
+      }
+      if (formulaBad) add('BUG', ym + ' 有明细应发不等于分项之和', { formulaBad, examples })
+      if (standardBad) add('BUG', ym + ' 有人月标偏离全区现行标准', { standardBad, districtMonthly, examples })
+      if (shareBad) add('BUG', ym + ' 区镇分担金额与比例不一致', { shareBad })
+      if (neg) add('BUG', ym + ' 存在负数金额', { neg })
+
+      month.townCompare = []
+      for (const tb of townBatches) {
+        const slice = byTown[tb.townId] || { count: 0, payable: 0 }
+        const row = {
+          town: townName[tb.townId] || tb.townId,
+          status: tb.status,
+          headerPeople: n(tb.peopleCount),
+          headerAmount: round2(tb.totalAmount),
+          slicePeople: slice.count,
+          sliceAmount: round2(slice.payable),
+          parentOk: !tb.parentBatchId || tb.parentBatchId === db.id,
+        }
+        month.townCompare.push(row)
+        if (!near(row.headerAmount, row.sliceAmount, 1) || row.headerPeople !== row.slicePeople) {
+          add('BUG', ym + ' 镇统计与全区明细切片不一致', row)
+        }
+        if (!row.parentOk) add('BUG', ym + ' 镇统计未挂到全区父批', row)
+      }
+      const emptyVillages = []
+      for (const t of towns) {
+        for (const v of villagesByTown[t.id]) {
+          const hit = byVillage[t.id + '|' + v.id]
+          if (!hit || !hit.count) emptyVillages.push(t.name + '/' + v.name)
+        }
+      }
+      month.emptyVillageCount = emptyVillages.length
+      month.emptyVillageSample = emptyVillages.slice(0, 15)
+      month.villageWithPay = Object.values(byVillage).filter((v) => v.count > 0).length
+
+      const summary = await req('GET', '/api/dashboard/summary?payMonth=' + ym)
+      const dashPeople = n(summary.data?.shouldPayCount ?? summary.data?.peopleCount)
+      const dashAmount = n(summary.data?.shouldPayAmount ?? summary.data?.totalAmount)
+      month.dashboard = {
+        code: summary.code,
+        keys: summary.data ? Object.keys(summary.data) : [],
+        people: dashPeople,
+        amount: round2(dashAmount),
+        rawPeople: summary.data?.shouldPayCount,
+        rawAmount: summary.data?.shouldPayAmount,
+        totalAmount: summary.data?.totalAmount,
+      }
+      if (summary.code === 200 && (dashPeople || dashAmount)) {
+        if (!near(dashAmount, sum.payable, 1) || dashPeople !== rows.length) {
+          add('BUG', ym + ' 展板汇总额与明细不一致', {
+            dashPeople,
+            dashAmount: round2(dashAmount),
+            detailPeople: rows.length,
+            detailAmount: sum.payable,
+          })
+        }
+      }
+    }
+
+    report.months[ym] = month
+  }
+
+  const memberIds = new Set()
+  const aug = report.months['202608']
+  if (aug && aug.detailLoaded) {
+    // member ids not stored; re-page is expensive. Use unique count only.
+  }
+
+  const newPage = await pageAll('/api/personnel/page', { bizStatus: 'NEW' }, 200)
+  let newShouldInAug = 0
+  let newMissingStart = 0
+  const newSamples = []
+  for (const p of newPage.list || []) {
+    const start = String(p.nextPayStartMonth || p.enjoyStartMonth || '').replace('-', '')
+    if (!start) {
+      newMissingStart += 1
+      continue
+    }
+    if (start <= '202608') {
+      newShouldInAug += 1
+      if (newSamples.length < 8) {
+        newSamples.push({ name: p.name, town: townName[p.townId] || p.townId, start, qualify: p.retireQualifyType })
+      }
+    }
+  }
+  report.newPeople = {
+    total: newPage.total,
+    loaded: (newPage.list || []).length,
+    missingStart: newMissingStart,
+    startOnOrBefore202608: newShouldInAug,
+    sample: newSamples,
+    error: newPage.error,
+  }
+  if (newShouldInAug && aug && aug.uniquePeople) {
+    add('INFO', '资格已通过且起始月不晚于 202608 的新增人员', {
+      count: newShouldInAug,
+      note: '若 8 月批已生成后才通过,按规则不自动补进当月,需特殊业务加人',
+      sample: newSamples,
+    })
+  }
+
+  const out = path.join(__dirname, '..', '..', 'docs', '金额与生命周期核对-20261009.json')
+  fs.writeFileSync(out, JSON.stringify(report, null, 2))
+  console.log('WROTE ' + out + ' findings=' + report.findings.length)
+})().catch((e) => {
+  console.error(e)
+  process.exit(1)
+})

+ 251 - 0
scripts/audit-amount-sum-20261009.js

@@ -0,0 +1,251 @@
+/**
+ * 只读:把 202608 全区明细按镇/村加总,并和批头、展板、统计表对照。
+ * node scripts/audit-amount-sum-20261009.js
+ */
+const http = require('http')
+const fs = require('fs')
+const path = require('path')
+
+function req(method, apiPath, body) {
+  return new Promise((resolve) => {
+    const data = body == null ? null : JSON.stringify(body)
+    const headers = { Accept: 'application/json' }
+    if (data) {
+      headers['Content-Type'] = 'application/json'
+      headers['Content-Length'] = Buffer.byteLength(data)
+    }
+    const r = http.request(
+      { hostname: '121.43.55.7', port: 11091, path: '/sjnmtybt' + apiPath, method, headers, timeout: 90000 },
+      (res) => {
+        let raw = ''
+        res.on('data', (c) => (raw += c))
+        res.on('end', () => {
+          try {
+            resolve(JSON.parse(raw))
+          } catch (e) {
+            resolve({ code: res.statusCode, parseError: true, raw: String(raw).slice(0, 160) })
+          }
+        })
+      },
+    )
+    r.on('error', (e) => resolve({ code: 0, error: e.message }))
+    r.on('timeout', () => {
+      r.destroy()
+      resolve({ code: 0, error: 'timeout' })
+    })
+    if (data) r.write(data)
+    r.end()
+  })
+}
+
+async function reqRetry(method, apiPath, body) {
+  let last
+  for (let i = 0; i < 4; i++) {
+    last = await req(method, apiPath, body)
+    if (last.code === 200) return last
+    await new Promise((r) => setTimeout(r, 800 * (i + 1)))
+  }
+  return last
+}
+
+const n = (v) => {
+  const x = Number(v || 0)
+  return Number.isFinite(x) ? x : 0
+}
+const r2 = (v) => Math.round(n(v) * 100) / 100
+
+;(async () => {
+  const towns = (await req('GET', '/api/regions?level=TOWN&parentId=D_SJ')).data || []
+  const townName = Object.fromEntries(towns.map((t) => [t.id, t.name]))
+  const share = Object.fromEntries(towns.map((t) => [t.id, t]))
+  const villageName = {}
+  for (const t of towns) {
+    const vs = (await req('GET', '/api/regions?level=VILLAGE&parentId=' + encodeURIComponent(t.id))).data || []
+    for (const v of vs) villageName[v.id] = { name: v.name, town: t.name, townId: t.id }
+  }
+
+  const batches = (await reqRetry('POST', '/api/batches/page', { pageNum: 1, pageSize: 50, payMonth: '202608' })).data?.list || []
+  const district = batches.find((b) => b.batchLevel === 'DISTRICT')
+  const townBatches = batches.filter((b) => b.batchLevel === 'TOWN')
+  if (!district) throw new Error('no district batch')
+
+  const std = await req('GET', '/api/amount-standards/current?townId=T_DISTRICT&standardType=MONTHLY')
+  const monthlyStd = n(std.data?.amount)
+
+  const pageSize = 200
+  let pageNum = 1
+  let total = 0
+  let loaded = 0
+  let formulaBad = 0
+  let standardBad = 0
+  let shareBad = 0
+  let dup = 0
+  const seen = new Set()
+  const byTown = {}
+  const byVillage = {}
+  const monthlyHist = {}
+  const examples = []
+  let sumPay = 0
+  let sumMonthly = 0
+  let sumSup = 0
+  let sumFun = 0
+  let sumDed = 0
+  let sumOne = 0
+  let sumActual = 0
+  let fail = 0
+  let actualNull = 0
+  const pageErrors = []
+
+  for (;;) {
+    const res = await reqRetry('POST', '/api/payments/details/page', {
+      batchId: district.id,
+      pageNum,
+      pageSize,
+    })
+    if (res.code !== 200) {
+      pageErrors.push({ pageNum, code: res.code, message: res.message || res.error || res.raw })
+      break
+    }
+    const list = res.data?.list || []
+    total = Number(res.data?.total || 0)
+    if (!list.length) break
+    for (const d of list) {
+      if (!d || d.changeFlag === 'REMOVE') continue
+      loaded += 1
+      const pid = d.personnelId || d.id
+      if (pid && seen.has(pid)) dup += 1
+      if (pid) seen.add(pid)
+      const expect = n(d.monthlyAmount) + n(d.oneTimeAmount) + n(d.supplementAmount) + n(d.funeralAmount) - n(d.deductAmount)
+      const got = n(d.totalAmount)
+      if (Math.abs(expect - got) > 0.05) {
+        formulaBad += 1
+        if (examples.length < 8) examples.push({ kind: 'formula', name: d.name, town: townName[d.townId], expect: r2(expect), got: r2(got) })
+      }
+      const m = r2(d.monthlyAmount)
+      monthlyHist[String(m)] = (monthlyHist[String(m)] || 0) + 1
+      if (monthlyStd > 0 && m > 0 && Math.abs(m - monthlyStd) > 0.05 && n(d.funeralAmount) === 0 && n(d.supplementAmount) === 0) {
+        standardBad += 1
+        if (examples.length < 12) examples.push({ kind: 'standard', name: d.name, town: townName[d.townId], monthly: m })
+      }
+      const sh = share[d.townId]
+      if (sh && got > 0 && d.districtAmount != null) {
+        const expectD = got * n(sh.districtSharePercent) / 100
+        if (Math.abs(n(d.districtAmount) + n(d.townAmount) - got) > 0.2 || Math.abs(n(d.districtAmount) - expectD) > 0.5) {
+          shareBad += 1
+        }
+      }
+      const tid = d.townId || '?'
+      const vid = d.villageId || '?'
+      if (!byTown[tid]) byTown[tid] = { count: 0, payable: 0, actual: 0, fail: 0 }
+      if (!byVillage[vid]) byVillage[vid] = { townId: tid, count: 0, payable: 0 }
+      byTown[tid].count += 1
+      byTown[tid].payable += got
+      byVillage[vid].count += 1
+      byVillage[vid].payable += got
+      sumPay += got
+      sumMonthly += n(d.monthlyAmount)
+      sumSup += n(d.supplementAmount)
+      sumFun += n(d.funeralAmount)
+      sumDed += n(d.deductAmount)
+      sumOne += n(d.oneTimeAmount)
+      if (d.actualAmount == null) actualNull += 1
+      else sumActual += n(d.actualAmount)
+      if (d.returnSuccess === false) {
+        fail += 1
+        byTown[tid].fail += 1
+      }
+    }
+    process.stdout.write('\rpage ' + pageNum + ' loaded ' + loaded + '/' + total)
+    if (loaded >= total || pageNum > 250) break
+    pageNum += 1
+  }
+  console.log('')
+
+  const dash = await req('GET', '/api/dashboard/summary?payMonth=202608')
+  const stats = await reqRetry('POST', '/api/batch-stats/page', { pageNum: 1, pageSize: 200, payMonth: '202608' })
+
+  const townCompare = townBatches.map((tb) => {
+    const s = byTown[tb.townId] || { count: 0, payable: 0, fail: 0 }
+    return {
+      town: townName[tb.townId] || tb.townId,
+      status: tb.status,
+      headerPeople: n(tb.peopleCount),
+      headerAmount: r2(tb.totalAmount),
+      slicePeople: s.count,
+      sliceAmount: r2(s.payable),
+      peopleGap: n(tb.peopleCount) - s.count,
+      amountGap: r2(n(tb.totalAmount) - s.payable),
+      parentBatchId: tb.parentBatchId,
+      parentOk: tb.parentBatchId === district.id,
+      fail: s.fail,
+    }
+  })
+
+  const emptyVillages = Object.entries(villageName)
+    .filter(([id]) => !byVillage[id] || !byVillage[id].count)
+    .map(([, v]) => v.town + '/' + v.name)
+
+  const out = {
+    district: {
+      id: district.id,
+      batchNo: district.batchNo,
+      status: district.status,
+      peopleCount: district.peopleCount,
+      totalAmount: r2(district.totalAmount),
+      normalAmount: r2(district.normalAmount),
+      supplementAmount: r2(district.supplementAmount),
+      funeralAmount: r2(district.funeralAmount),
+      deductAmount: r2(district.deductAmount),
+      exportStatus: district.exportStatus,
+      returnStatus: district.returnStatus,
+    },
+    standard: { monthly: monthlyStd, funeral: n(std.data?.funeralAmount) },
+    scan: {
+      apiTotal: total,
+      loaded,
+      unique: seen.size,
+      dup,
+      formulaBad,
+      standardBad,
+      shareBad,
+      actualNull,
+      fail,
+      sumPay: r2(sumPay),
+      sumMonthly: r2(sumMonthly),
+      sumSup: r2(sumSup),
+      sumFun: r2(sumFun),
+      sumDed: r2(sumDed),
+      sumOne: r2(sumOne),
+      sumActual: r2(sumActual),
+      pages: pageNum,
+      pageErrors,
+      monthlyHist,
+      examples,
+      complete: loaded >= total && !pageErrors.length,
+    },
+    townCompare,
+    villageWithPay: Object.values(byVillage).filter((v) => v.count).length,
+    villageMaster: Object.keys(villageName).length,
+    emptyVillageCount: emptyVillages.length,
+    emptyVillageSample: emptyVillages.slice(0, 20),
+    dashboard: dash.data,
+    statCount: (stats.data?.list || []).length,
+    statTotal: stats.data?.total,
+    statSample: (stats.data?.list || []).slice(0, 5).map((s) => ({
+      grain: s.grain || s.level || s.statLevel,
+      townId: s.townId,
+      villageId: s.villageId,
+      shouldPayCount: s.shouldPayCount,
+      shouldPayAmount: s.shouldPayAmount,
+      actualPayAmount: s.actualPayAmount,
+      failCount: s.failCount,
+      keys: Object.keys(s),
+    })),
+  }
+  const file = path.join(__dirname, '..', '..', 'docs', '金额核对-202608.json')
+  fs.writeFileSync(file, JSON.stringify(out, null, 2))
+  console.log('WROTE', file, 'complete', out.scan.complete, 'loaded', loaded, '/', total)
+})().catch((e) => {
+  console.error(e)
+  process.exit(1)
+})

+ 413 - 0
scripts/backfill-ssc-from-excel.py

@@ -0,0 +1,413 @@
+# -*- coding: utf-8 -*-
+"""从「已匹配200编号」明细回填人员底数社保卡号(c_social_insurance_code)。
+
+匹配顺序:身份证号 → 银行卡号。仅补充库中空值,不覆盖已有不同卡号。
+凭据只从环境变量读取,勿写入仓库。
+
+  python -X utf8 scripts/backfill-ssc-from-excel.py
+  python -X utf8 scripts/backfill-ssc-from-excel.py --apply
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import os
+import re
+import sys
+import threading
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from collections import defaultdict
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from pathlib import Path
+
+from openpyxl import load_workbook
+
+sys.stdout.reconfigure(encoding="utf-8")
+
+HOST = os.environ.get("SJ_DMS_HOST", "121.43.55.7")
+PORT = int(os.environ.get("SJ_DMS_PORT", "2101"))
+BASE = f"http://{HOST}:{PORT}"
+COLUMN_ID = "1847"
+MODEL_ID = "1970"
+DEFAULT_XLSX = Path(r"C:\Users\Liumouren\Desktop\松江区老年农民土地退养补助金发放人员明细-已匹配200编号.xlsx")
+REPORT_DIR = Path(r"C:\Users\Liumouren\Desktop")
+
+_token_lock = threading.Lock()
+_token = {"value": "", "ts": 0.0}
+
+
+def digits(v) -> str:
+    if v is None:
+        return ""
+    if isinstance(v, float):
+        if v != v:  # NaN
+            return ""
+        s = format(v, ".0f")
+    elif isinstance(v, int):
+        s = str(v)
+    else:
+        s = str(v).strip()
+        if s.lower() in ("none", "null", "nan"):
+            return ""
+        if re.fullmatch(r"\d+\.0+", s):
+            s = s.split(".", 1)[0]
+    return re.sub(r"\D", "", s)
+
+
+def norm_id(v) -> str:
+    if v is None:
+        return ""
+    s = str(v).strip().replace(" ", "").replace("\u3000", "").upper()
+    if re.fullmatch(r"\d+\.0+", s):
+        s = s.split(".", 1)[0]
+    return s
+
+
+def norm_ssc(v) -> str:
+    s = digits(v)
+    return s if len(s) >= 10 else ""
+
+
+def post_form(path: str, fields: dict, token: str | None = None, timeout: int = 90) -> dict:
+    body = urllib.parse.urlencode({k: "" if v is None else str(v) for k, v in fields.items()}).encode("utf-8")
+    headers = {
+        "Content-Type": "application/x-www-form-urlencoded",
+        "Content-Length": str(len(body)),
+    }
+    if token:
+        headers["Token"] = token
+        headers["token"] = token
+    req = urllib.request.Request(BASE + path, data=body, method="POST", headers=headers)
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            raw = resp.read().decode("utf-8", "replace")
+    except urllib.error.HTTPError as e:
+        raw = e.read().decode("utf-8", "replace")
+    try:
+        return json.loads(raw) if raw else {}
+    except json.JSONDecodeError:
+        return {"code": 599, "message": raw[:300]}
+
+
+def login(force: bool = False) -> str:
+    now = time.time()
+    with _token_lock:
+        if not force and _token["value"] and now - _token["ts"] < 20 * 60:
+            return _token["value"]
+        user = os.environ.get("SJ_OAUTH_USER", "user_liu")
+        password = os.environ.get("SJ_OAUTH_PASSWORD", "")
+        client_id = os.environ.get("SJ_OAUTH_CLIENT_ID", "1")
+        if not password:
+            raise SystemExit("缺少 SJ_OAUTH_PASSWORD")
+        res = post_form(
+            "/proxy_oauth/user/login",
+            {"userName": user, "password": password, "clientId": client_id},
+        )
+        if res.get("code") != 200 or not res.get("message"):
+            raise SystemExit("OAuth 登录失败: " + json.dumps(res, ensure_ascii=False)[:400])
+        _token["value"] = res["message"]
+        _token["ts"] = now
+        return _token["value"]
+
+
+def fetch_page(page: int, page_size: int = 100) -> list[dict]:
+    token = login()
+    res = post_form(
+        "/proxy_dms/content/selectContentList",
+        {
+            "columnId": COLUMN_ID,
+            "modelId": MODEL_ID,
+            "page": str(page),
+            "pageSize": str(page_size),
+            "states": "0,3",
+        },
+        token,
+    )
+    code = res.get("code")
+    if code in (208, 212):
+        token = login(True)
+        res = post_form(
+            "/proxy_dms/content/selectContentList",
+            {
+                "columnId": COLUMN_ID,
+                "modelId": MODEL_ID,
+                "page": str(page),
+                "pageSize": str(page_size),
+                "states": "0,3",
+            },
+            token,
+        )
+        code = res.get("code")
+    if code == 202:
+        return []
+    if code != 200:
+        raise RuntimeError(f"selectContentList page={page} {res}")
+    return (res.get("content") or {}).get("data") or []
+
+
+def load_personnel(workers: int = 8) -> list[dict]:
+    first = fetch_page(0)
+    rows = list(first)
+    if len(first) < 100:
+        return rows
+    page = 1
+    while True:
+        batch_pages = list(range(page, page + workers))
+        got_short = False
+        with ThreadPoolExecutor(max_workers=workers) as ex:
+            futs = {ex.submit(fetch_page, p): p for p in batch_pages}
+            by_page = {}
+            for fut in as_completed(futs):
+                by_page[futs[fut]] = fut.result()
+        for p in batch_pages:
+            chunk = by_page[p]
+            rows.extend(chunk)
+            if len(chunk) < 100:
+                got_short = True
+                break
+        if got_short:
+            break
+        page += workers
+        if page > 800:
+            break
+        print(f"  已拉人员 {len(rows)} ...", flush=True)
+    return rows
+
+
+def load_excel(path: Path) -> list[dict]:
+    wb = load_workbook(path, read_only=True, data_only=True)
+    ws = wb[wb.sheetnames[0]]
+    out = []
+    for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
+        cells = list(row) + [None] * 9
+        town, village, name, idn, ssc, bank, card, phone, amt = cells[:9]
+        item = {
+            "row": i,
+            "town": "" if town is None else str(town).strip(),
+            "village": "" if village is None else str(village).strip(),
+            "name": "" if name is None else str(name).strip(),
+            "id_number": norm_id(idn),
+            "ssc": norm_ssc(ssc),
+            "card": digits(card),
+        }
+        out.append(item)
+    wb.close()
+    return out
+
+
+def index_people(people: list[dict]):
+    by_id = defaultdict(list)
+    by_card = defaultdict(list)
+    for p in people:
+        dms_id = str(p.get("id") or "").strip()
+        if not dms_id:
+            continue
+        rec = {
+            "dms_id": dms_id,
+            "biz_id": str(p.get("c_biz_id") or "").strip(),
+            "name": str(p.get("c_name") or "").strip(),
+            "id_number": norm_id(p.get("c_id_number")),
+            "card": digits(p.get("c_bank_card_number")),
+            "ssc": norm_ssc(p.get("c_social_insurance_code")),
+            "town": str(p.get("c_town_id") or "").strip(),
+            "village": str(p.get("c_village_id") or "").strip(),
+        }
+        if rec["id_number"]:
+            by_id[rec["id_number"]].append(rec)
+        if rec["card"]:
+            by_card[rec["card"]].append(rec)
+    return by_id, by_card
+
+
+def unique_or_none(items: list[dict]) -> tuple[dict | None, str]:
+    if not items:
+        return None, "none"
+    ids = {x["dms_id"] for x in items}
+    if len(ids) > 1:
+        return None, "multi"
+    return items[0], "one"
+
+
+def match_one(row: dict, by_id, by_card):
+    id_hit, id_st = unique_or_none(by_id.get(row["id_number"], []) if row["id_number"] else [])
+    card_hit, card_st = unique_or_none(by_card.get(row["card"], []) if row["card"] else [])
+    if id_st == "multi":
+        return None, "id_multi"
+    if card_st == "multi" and id_st != "one":
+        return None, "card_multi"
+    if id_st == "one" and card_st == "one" and id_hit["dms_id"] != card_hit["dms_id"]:
+        return None, "id_card_mismatch"
+    if id_st == "one":
+        return id_hit, "id"
+    if card_st == "one":
+        return card_hit, "card"
+    return None, "unmatched"
+
+
+def update_ssc(dms_id: str, ssc: str) -> tuple[bool, str]:
+    payload = {"id": dms_id, "c_social_insurance_code": ssc}
+    last = ""
+    for attempt in range(4):
+        try:
+            token = login(attempt > 0)
+            res = post_form(
+                "/proxy_dms/content/updateContent",
+                {
+                    "columnId": COLUMN_ID,
+                    "modelId": MODEL_ID,
+                    "content": json.dumps(payload, ensure_ascii=False),
+                },
+                token,
+            )
+            code = res.get("code")
+            if code in (208, 212):
+                login(True)
+                continue
+            if code == 200:
+                return True, "ok"
+            last = str(res.get("message") or code)
+            if code in (500, 599) or "超时" in last:
+                time.sleep(1.5 * (attempt + 1))
+                continue
+            return False, last
+        except Exception as e:
+            last = str(e)
+            time.sleep(1.5 * (attempt + 1))
+    return False, last
+
+
+def write_csv(path: Path, rows: list[dict], fields: list[str]):
+    with path.open("w", encoding="utf-8-sig", newline="") as f:
+        w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
+        w.writeheader()
+        for r in rows:
+            w.writerow(r)
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--xlsx", default=str(DEFAULT_XLSX))
+    ap.add_argument("--apply", action="store_true")
+    ap.add_argument("--workers", type=int, default=6)
+    args = ap.parse_args()
+    xlsx = Path(args.xlsx)
+    if not xlsx.exists():
+        raise SystemExit("找不到 Excel: " + str(xlsx))
+
+    print("解析 Excel ...", flush=True)
+    excel_rows = load_excel(xlsx)
+    with_ssc = [r for r in excel_rows if r["ssc"]]
+    print(f"  明细 {len(excel_rows)} 行,有社保卡 {len(with_ssc)}", flush=True)
+
+    print("登录并拉取人员底数 ...", flush=True)
+    login(True)
+    people = load_personnel()
+    print(f"  库中人员 {len(people)}", flush=True)
+    by_id, by_card = index_people(people)
+    db_empty = sum(1 for p in people if not norm_ssc(p.get("c_social_insurance_code")))
+    print(f"  库中社保卡为空 {db_empty}", flush=True)
+
+    planned = {}
+    details = []
+    stats = defaultdict(int)
+    for row in with_ssc:
+        person, how = match_one(row, by_id, by_card)
+        stats[how] += 1
+        if person is None:
+            details.append({**row, "result": how, "biz_id": "", "db_ssc": "", "action": "skip"})
+            continue
+        cur = person["ssc"]
+        action = "skip"
+        if not cur:
+            action = "fill"
+        elif cur == row["ssc"]:
+            action = "same"
+            stats["same"] += 1
+        else:
+            action = "conflict_diff"
+            stats["conflict_diff"] += 1
+        rec = {
+            **row,
+            "result": how,
+            "biz_id": person["biz_id"],
+            "dms_id": person["dms_id"],
+            "db_name": person["name"],
+            "db_ssc": cur,
+            "action": action,
+        }
+        details.append(rec)
+        if action == "fill":
+            prev = planned.get(person["dms_id"])
+            if prev and prev["ssc"] != row["ssc"]:
+                rec["action"] = "conflict_multi_excel"
+                stats["conflict_multi_excel"] += 1
+                stats["fill"] -= 1
+                planned.pop(person["dms_id"], None)
+            else:
+                planned[person["dms_id"]] = rec
+                stats["fill"] += 1
+
+    summary = {
+        "excel_rows": len(excel_rows),
+        "excel_with_ssc": len(with_ssc),
+        "db_people": len(people),
+        "db_ssc_empty": db_empty,
+        "match": {k: stats[k] for k in sorted(stats)},
+        "to_fill_unique_people": len(planned),
+        "apply": bool(args.apply),
+    }
+    print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True)
+
+    applied = []
+    failed = []
+    if args.apply and planned:
+        items = list(planned.values())
+        print(f"开始回填 {len(items)} 人 ...", flush=True)
+        done = 0
+        with ThreadPoolExecutor(max_workers=max(1, args.workers)) as ex:
+            futs = {ex.submit(update_ssc, rec["dms_id"], rec["ssc"]): rec for rec in items}
+            for fut in as_completed(futs):
+                rec = futs[fut]
+                ok, msg = fut.result()
+                done += 1
+                if ok:
+                    applied.append(rec)
+                else:
+                    rec = dict(rec)
+                    rec["error"] = msg
+                    failed.append(rec)
+                if done % 200 == 0 or done == len(items):
+                    print(f"  进度 {done}/{len(items)} 成功 {len(applied)} 失败 {len(failed)}", flush=True)
+        summary["applied"] = len(applied)
+        summary["failed"] = len(failed)
+
+    stamp = time.strftime("%Y%m%d-%H%M%S")
+    report_json = REPORT_DIR / f"社保卡号回填报告-{stamp}.json"
+    report_csv = REPORT_DIR / f"社保卡号回填明细-{stamp}.csv"
+    report_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
+    write_csv(
+        report_csv,
+        details,
+        [
+            "row", "town", "village", "name", "id_number", "card", "ssc",
+            "result", "biz_id", "db_name", "db_ssc", "action",
+        ],
+    )
+    if failed:
+        fail_csv = REPORT_DIR / f"社保卡号回填失败-{stamp}.csv"
+        write_csv(
+            fail_csv,
+            failed,
+            ["row", "town", "village", "name", "id_number", "ssc", "biz_id", "dms_id", "error"],
+        )
+        print("失败清单", fail_csv)
+    print("报告", report_json)
+    print("明细", report_csv)
+
+
+if __name__ == "__main__":
+    main()

+ 443 - 0
scripts/build-field-xlsx-20260904.py

@@ -0,0 +1,443 @@
+# -*- coding: utf-8 -*-
+"""按原 xlsx 格式补全业务表字段类型与描述,输出到桌面与 docs。"""
+import json
+from openpyxl import Workbook, load_workbook
+from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
+from openpyxl.utils import get_column_letter
+from pathlib import Path
+
+ROOT = Path(r"c:\Users\Liumouren\Desktop\临时文件\元以科技\松江\松江农民退养补贴")
+SRC = ROOT / "docs" / "松江农民土地退补系统字段名称.xlsx"
+FRESH = ROOT / "docs" / "_dms_fields_fresh_20260904.json"
+OUT_DESKTOP = Path(r"c:\Users\Liumouren\Desktop") / "松江农民土地退补系统字段名称.xlsx"
+OUT_DOCS = ROOT / "docs" / "松江农民土地退补系统字段名称.xlsx"
+
+models = json.loads(FRESH.read_text(encoding="utf-8"))
+
+TYPE_CN = {
+    "text": "文本",
+    "double": "数值(小数)",
+    "integer": "数值(整数)",
+    "timestamp": "日期时间",
+    "long": "长整型",
+    "float": "数值(小数)",
+}
+FRONT_CN = {
+    "content": "文本框",
+    "float_num": "小数",
+    "int_num": "整数",
+    "date_time": "日期时间",
+    "date": "日期",
+}
+
+
+def type_label(f):
+    t = f.get("type") or ""
+    ft = f.get("frontType") or ""
+    base = TYPE_CN.get(t, t or "")
+    if ft and ft in FRONT_CN:
+        return f"{base}/{FRONT_CN[ft]}" if base else FRONT_CN[ft]
+    return base
+
+
+index = []
+for key, m in models.items():
+    for f in m["fields"]:
+        index.append((key, f))
+
+
+def find_field(name_hints, prefer_keys=None):
+    hints = [h for h in name_hints if h]
+    prefer_keys = prefer_keys or []
+    candidates = []
+    for key, f in index:
+        alias = (f.get("alias") or "").strip()
+        name = (f.get("name") or "").strip()
+        short = name[2:] if name.startswith("c_") else name
+        for h in hints:
+            h = str(h).strip()
+            if not h:
+                continue
+            target = h if h.startswith("c_") else ("c_" + h)
+            if alias == h or name == h or short == h or name == target:
+                candidates.append((key, f, 0))
+            elif h in alias or (alias and alias in h):
+                candidates.append((key, f, 1))
+    if prefer_keys:
+        for pk in prefer_keys:
+            for c in candidates:
+                if c[0] == pk:
+                    return c
+    return candidates[0] if candidates else None
+
+
+EXPLICIT = {
+    "人员姓名": (["personnel"], ["姓名", "c_name"]),
+    "人员身份证号码": (["personnel"], ["身份证号", "c_id_number"]),
+    "社会保险登记号": (["personnel"], ["个人社会保险登记码", "c_social_insurance_code"]),
+    "手机号": (["personnel"], ["手机号", "c_phone_number"]),
+    "联系地址": (["personnel"], ["家庭住址", "c_address"]),
+    "户籍性质": (["personnel"], ["户籍性质", "c_household_type"]),
+    "街镇": (["personnel"], ["街镇ID", "c_town_id"]),
+    "村居": (["personnel"], ["村居ID", "c_village_id"]),
+    "村委代码": (["personnel"], ["村委代码", "c_village_code"]),
+    "委托方案代码": (["personnel", "region"], ["委托方案代码", "c_entrust_plan_code"]),
+    "委托方案名称": (["personnel"], ["委托方案名称", "c_entrust_plan_name"]),
+    "收款人名称": (["personnel"], ["收款人名称", "c_payee_name"]),
+    "银行卡号": (["personnel"], ["银行卡号", "c_bank_card_number"]),
+    "发放银行": (["personnel", "batchStat", "batch"], ["开户行", "c_bank_name", "发放银行"]),
+    "收款银行行号": (["personnel"], ["收款银行行号", "c_bank_branch_code"]),
+    "退养资格类型": (["personnel"], ["退养资格类型", "c_retire_qualify_type"]),
+    "居保状态": (["personnel"], ["居保养老状态", "c_insurance_status"]),
+    "死亡状态": (["personnel"], ["死亡状态", "c_death_status"]),
+    "死亡日期": (["personnel"], ["死亡日期", "c_death_date"]),
+    "继承人姓名": (["personnel"], ["继承人姓名", "c_heir_name"]),
+    "继承人与逝者关系": (["personnel"], ["继承人关系", "c_heir_relation"]),
+    "继承人联系方式": (["personnel"], ["继承人电话", "c_heir_phone"]),
+    "继承人身份证": (["personnel"], ["继承人身份证", "c_heir_id_number"]),
+    "继承人银行卡": (["personnel"], ["继承人银行卡", "c_heir_bank_card"]),
+    "丧葬费补差额": (["personnel"], ["丧葬费补差额", "c_funeral_diff_amount"]),
+    "丧葬是否已发放": (["personnel", "payment"], ["c_remarks"]),
+    "补贴金额": (["standard"], ["金额", "c_amount"]),
+    "丧葬金额": (["standard"], ["丧葬金额", "c_funeral_amount"]),
+    "生效时间": (["standard"], ["生效时间", "c_date_time"]),
+    "失效时间": (["standard"], ["失效时间", "c_expire_time"]),
+    "标准类型": (["standard"], ["标准类型", "c_standard_type"]),
+    "是否当前有效": (["standard"], ["是否当前有效", "c_current_flag"]),
+    "调整原因": (["standard"], ["调整原因", "c_adjust_reason"]),
+    "享受起始年月": (["personnel"], ["享受起始年月", "代发起始年月", "c_enjoy_start_month"]),
+    "代发截止年月": (["personnel"], ["代发截止年月", "c_enjoy_end_month"]),
+    "补发开始年月": (["personnel"], ["补发开始年月", "c_supplement_start_month"]),
+    "下次发放起始年月": (["personnel"], ["下次发放起始年月", "c_next_pay_start_month"]),
+    "区级资金承担比例": (["region"], ["区级资金承担比例", "c_district_share_percent"]),
+    "镇级资金承担比例": (["region"], ["镇级资金承担比例", "c_town_share_percent"]),
+    "是否已出盘": (["batch", "payment"], ["是否已出盘", "出盘", "c_disk_out"]),
+    "实发金额": (["payment"], ["实发金额", "c_actual_amount"]),
+    "回盘是否成功": (["payment"], ["回盘是否成功", "c_return_success"]),
+    "回盘结果": (["payment"], ["回盘结果", "c_return_result"]),
+    "调标前月补": (["payment"], ["调标前月补", "c_old_monthly_amount"]),
+    "调标后月补": (["payment"], ["调标后月补", "c_new_monthly_amount"]),
+    "调标应发月数": (["payment"], ["调标应发月数", "c_adjust_months"]),
+    "告警等级": (["alert"], ["告警等级", "c_level"]),
+    "告警类型": (["alert"], ["告警类型", "c_type"]),
+    "告警内容": (["alert"], ["告警内容", "c_content"]),
+    "失败原因码": (["alert", "payment"], ["失败原因码", "c_fail_code"]),
+    "处理状态": (["alert", "process"], ["处理状态", "c_status"]),
+    "处理结果": (["alert", "process"], ["处理结果", "c_result"]),
+    "发起人": (["alert", "process"], ["发起人", "c_initiator"]),
+    "接收人": (["alert", "process"], ["接收人", "c_receiver"]),
+    "流程id": (["process", "alert"], ["流程ID", "流程id", "c_process_id"]),
+    "处理人": (["alert", "process"], ["处理人", "c_handler"]),
+    "处理时间": (["alert", "process"], ["处理时间", "c_handle_time"]),
+    "文件地址": (["alert", "batch"], ["文件地址", "c_file_url"]),
+    "备注描述": (["alert", "process"], ["备注", "c_remarks"]),
+    "关联批次ID": (["batchStat", "payment", "batch"], ["关联批次ID", "批次ID", "c_batch_id"]),
+    "批次号": (["batch", "batchStat", "payment"], ["批次号", "c_batch_number"]),
+    "汇总表编号": (["batchStat"], ["汇总表编号", "c_summary_no"]),
+    "发放年月": (["batch", "batchStat", "payment"], ["发放年月", "c_pay_month"]),
+    "镇id": (["batchStat", "batch", "personnel"], ["街镇ID", "镇ID", "c_town_id"]),
+    "村id": (["batchStat", "batch", "personnel"], ["村居ID", "村ID", "c_village_id"]),
+    "统计粒度": (["batchStat"], ["统计粒度", "c_stat_level"]),
+    "应支付人数": (["batchStat", "batch"], ["应支付人数", "c_payable_count"]),
+    "应支付总额": (["batchStat", "batch"], ["应支付总额", "c_payable_amount"]),
+    "实支付人数": (["batchStat", "batch"], ["实支付人数", "c_paid_count"]),
+    "实支付总额": (["batchStat", "batch"], ["实支付总额", "c_paid_amount"]),
+    "支付未成功人数": (["batchStat", "batch"], ["支付未成功人数", "失败人数", "c_fail_count"]),
+    "支付未成功总额": (["batchStat", "batch"], ["支付未成功总额", "c_fail_amount"]),
+    "标准金额": (["batchStat", "payment"], ["标准金额", "月补金额", "c_standard_amount"]),
+    "死亡金额": (["batchStat", "payment"], ["死亡金额", "丧葬金额", "c_funeral_amount"]),
+    "补充金额": (["batchStat", "payment"], ["补充金额", "c_extra_amount"]),
+    "补发金额": (["batchStat", "payment"], ["补发金额", "c_supplement_amount"]),
+    "回盘日期": (["batch", "batchStat", "payment"], ["回盘日期", "c_return_date"]),
+    "经办人": (["batch", "batchStat"], ["经办人", "c_operator"]),
+    "复核人": (["batch", "batchStat"], ["复核人", "c_reviewer"]),
+    "备注": (["batchStat", "batch", "personnel"], ["备注", "c_remarks"]),
+    "人数": (["batch", "batchStat"], ["人数", "c_person_count"]),
+}
+
+CAT_DEFAULT = {
+    "人员基本信息": ("村合作社填报", "随时更新"),
+    "区划与委托": ("系统维护/村合作社填报", "按需更新"),
+    "银行信息": ("村合作社填报", "随时更新"),
+    "退养资格": ("镇经发填报", "随时更新"),
+    "居保匹配": ("大数据匹配", "随时更新"),
+    "死亡状态匹配": ("大数据匹配", "随时更新"),
+    "死亡与继承": ("镇社区事务受理中心/系统计算", "随时更新"),
+    "金额标准": ("政策文件/系统计算", "一年一次或按业务"),
+    "告警": ("系统计算", "每月更新"),
+    "批次统计": ("系统计算", "每月更新"),
+}
+
+TABLE_META = {
+    "personnel": ("人员基本信息表", "村合作社/镇社区/系统", "随时更新"),
+    "region": ("区划表", "系统维护", "按需更新"),
+    "standard": ("金额标准表", "政策文件/镇经发", "一年一次或调标时"),
+    "alert": ("告警表", "系统计算", "随时更新"),
+    "process": ("流程/特殊业务表", "业务部门填报", "随时更新"),
+    "batch": ("批次表", "系统计算", "每月更新"),
+    "batchStat": ("批次统计表", "系统计算", "每月更新"),
+    "payment": ("发放明细表", "系统计算/回盘导入", "每月更新"),
+}
+
+SPECIAL_NOTES = {
+    "丧葬是否已发放": "当前多用人员备注 FUNERAL_PAID 标记,非独立字段",
+    "生效时间": "对应标准表 c_date_time",
+    "是否当前有效": "对应标准表 c_current_flag",
+    "死亡状态": "取值:正常/已死亡;与业务态 c_biz_status=FUNERAL 并存",
+    "街镇": "存业务ID,界面展示中文名",
+    "村居": "存业务ID,界面展示中文名",
+    "镇id": "存业务ID,界面展示中文名",
+    "村id": "存业务ID,界面展示中文名",
+    "发放银行": "人员表对应开户行 c_bank_name",
+}
+
+wb0 = load_workbook(SRC)
+ws0 = wb0.active
+orig = []
+for i, row in enumerate(ws0.iter_rows(values_only=True), 1):
+    if i == 1:
+        continue
+    vals = list(row)
+    while len(vals) < 7:
+        vals.append(None)
+    cat, name, typ, src, freq, note, must = vals[:7]
+    orig.append(
+        {
+            "cat": cat,
+            "name": name,
+            "type": typ,
+            "source": src,
+            "freq": freq,
+            "note": note,
+            "must": must,
+        }
+    )
+
+cur_cat = None
+for r in orig:
+    if r["cat"]:
+        cur_cat = r["cat"]
+    else:
+        r["cat"] = cur_cat
+
+enriched = []
+used_fields = set()
+for r in orig:
+    name = r["name"]
+    prefer, hints = EXPLICIT.get(name, (None, [name]))
+    hit = find_field(hints, prefer) if prefer is not None else None
+    if hit is None:
+        hit = find_field([name] + list(hints or []), prefer)
+    note_parts = []
+    if r["note"]:
+        note_parts.append(str(r["note"]))
+    typ = r["type"]
+    must = r["must"]
+    src = r["source"]
+    freq = r["freq"]
+    if hit:
+        key, f, _ = hit
+        used_fields.add((key, f["name"]))
+        typ = type_label(f)
+        if must is None and f.get("must"):
+            must = "是"
+        m = models[key]
+        note_parts.append(f"DMS:{m['label']} {f['name']}")
+        desc = f.get("describe") or ""
+        if desc and desc not in ("格式为富文本", "number", "时间戳"):
+            note_parts.append(str(desc))
+        if name in SPECIAL_NOTES:
+            note_parts.append(SPECIAL_NOTES[name])
+    else:
+        note_parts.append("未在DMS单字段精确匹配,请核对业务口径")
+        if name in SPECIAL_NOTES:
+            note_parts.append(SPECIAL_NOTES[name])
+    if not src or not freq:
+        ds, df = CAT_DEFAULT.get(r["cat"], ("业务填报", "按需更新"))
+        src = src or ds
+        freq = freq or df
+    enriched.append(
+        [
+            r["cat"],
+            name,
+            typ,
+            src,
+            freq,
+            ";".join([p for p in note_parts if p]),
+            must or "",
+        ]
+    )
+
+rows_sheet1 = []
+prev = None
+for row in enriched:
+    cat = row[0]
+    show_cat = cat if cat != prev else None
+    prev = cat
+    rows_sheet1.append([show_cat, row[1], row[2], row[3], row[4], row[5], row[6]])
+
+# 人员表其余未出现字段
+extra_rows = []
+append_cat = "人员表补充字段"
+for f in models["personnel"]["fields"]:
+    if ("personnel", f["name"]) in used_fields:
+        continue
+    used_fields.add(("personnel", f["name"]))
+    note = f"DMS:人员基本信息表 {f['name']}"
+    if f["name"] == "c_death_status":
+        note += ";取值正常/已死亡;与 c_biz_status=FUNERAL 并存"
+    extra_rows.append(
+        [
+            None,
+            f.get("alias") or f["name"],
+            type_label(f),
+            "系统/业务填报",
+            "随时更新",
+            note,
+            "是" if f.get("must") else "",
+        ]
+    )
+if extra_rows:
+    extra_rows[0][0] = append_cat
+
+thin = Border(
+    left=Side(style="thin", color="B0B0B0"),
+    right=Side(style="thin", color="B0B0B0"),
+    top=Side(style="thin", color="B0B0B0"),
+    bottom=Side(style="thin", color="B0B0B0"),
+)
+header_fill = PatternFill("solid", fgColor="1F4E79")
+header_font = Font(color="FFFFFF", bold=True, name="微软雅黑", size=11)
+cat_fill = PatternFill("solid", fgColor="D6E3F0")
+body_font = Font(name="微软雅黑", size=10)
+wrap = Alignment(wrap_text=True, vertical="center")
+
+HEADERS7 = ["类别", "字段名", "字段类型", "数据来源", "更新频率", "备注", "是否必须满足"]
+HEADERS_FULL = HEADERS7 + ["字段编码", "业务表", "栏目ID", "模型ID"]
+
+
+def style_header(ws, headers):
+    for col, h in enumerate(headers, 1):
+        cell = ws.cell(1, col, h)
+        cell.fill = header_fill
+        cell.font = header_font
+        cell.alignment = Alignment(horizontal="center", vertical="center")
+        cell.border = thin
+    ws.row_dimensions[1].height = 22
+    ws.freeze_panes = "A2"
+    ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}1"
+
+
+def autosize(ws, widths):
+    for i, w in enumerate(widths, 1):
+        ws.column_dimensions[get_column_letter(i)].width = w
+
+
+def write_rows(ws, rows, headers):
+    style_header(ws, headers)
+    for r_i, row in enumerate(rows, 2):
+        for c_i, val in enumerate(row, 1):
+            cell = ws.cell(r_i, c_i, "" if val is None else val)
+            cell.font = body_font
+            cell.alignment = wrap
+            cell.border = thin
+            if c_i == 1 and val:
+                cell.fill = cat_fill
+                cell.font = Font(name="微软雅黑", size=10, bold=True)
+
+
+wb = Workbook()
+ws1 = wb.active
+ws1.title = "业务字段清单"
+write_rows(ws1, rows_sheet1, HEADERS7)
+autosize(ws1, [16, 18, 16, 22, 12, 55, 12])
+
+if extra_rows:
+    r0 = len(rows_sheet1) + 3
+    tip = ws1.cell(r0 - 1, 1, "—— 以下为对照 DMS 后补充的人员表字段 ——")
+    tip.font = Font(name="微软雅黑", bold=True, color="C00000")
+    for i, row in enumerate(extra_rows):
+        for c_i, val in enumerate(row, 1):
+            cell = ws1.cell(r0 + i, c_i, "" if val is None else val)
+            cell.font = body_font
+            cell.alignment = wrap
+            cell.border = thin
+            if c_i == 1 and val:
+                cell.fill = cat_fill
+                cell.font = Font(name="微软雅黑", size=10, bold=True)
+
+ws2 = wb.create_sheet("业务表字段全量")
+full_rows = []
+for key in ["personnel", "region", "standard", "payment", "batch", "batchStat", "alert", "process"]:
+    m = models[key]
+    tname, src, freq = TABLE_META[key]
+    first = True
+    for f in m["fields"]:
+        note = f"字段编码 {f['name']}"
+        desc = f.get("describe") or ""
+        if desc and desc not in ("格式为富文本", "number", "时间戳"):
+            note += ";" + str(desc)
+        if f["name"] == "c_death_status":
+            note += ";取值正常/已死亡;与 c_biz_status=FUNERAL 并存"
+        if f["name"] == "c_return_success":
+            note += ";回盘失败以 false + 备注回盘失败|RETURN_FAIL 为准"
+        full_rows.append(
+            [
+                tname if first else None,
+                f.get("alias") or f["name"],
+                type_label(f),
+                src,
+                freq,
+                note,
+                "是" if f.get("must") else "",
+                f["name"],
+                m["tableCode"],
+                m["columnId"],
+                m["modelId"],
+            ]
+        )
+        first = False
+write_rows(ws2, full_rows, HEADERS_FULL)
+autosize(ws2, [18, 20, 16, 22, 14, 50, 12, 28, 18, 10, 10])
+
+ws3 = wb.create_sheet("业务表索引")
+style_header(ws3, ["业务表", "表编码", "栏目ID", "模型ID", "字段数", "说明"])
+idx_rows = [
+    ("人员基本信息表", "sjnmtybt_ryjbxx", 1847, 1970, len(models["personnel"]["fields"]), "人员底数、资格、居保、死亡继承等"),
+    ("区划表", "sjnmtybt_xzqh", 1842, 1965, len(models["region"]["fields"]), "区/镇/村及资金比例、委托方案"),
+    ("金额标准表", "sjnmtybt_jebz", 1843, 1966, len(models["standard"]["fields"]), "月补/丧葬标准、调标"),
+    ("发放明细表", "sjnmtybt_ffmx", 1849, 1974, len(models["payment"]["fields"]), "应发实发、回盘、调标差额明细"),
+    ("批次表", "sjnmtybt_pcb", 1845, 1968, len(models["batch"]["fields"]), "全区/村月批、出盘回盘状态"),
+    ("批次统计表", "sjnmtybt_pctj", 1846, 1969, len(models["batchStat"]["fields"]), "镇/村汇总统计"),
+    ("告警表", "sjnmtybt_alart", 1841, 1964, len(models["alert"]["fields"]), "死亡/回盘失败等告警"),
+    ("流程/特殊业务表", "sjnmtybt_lcb", 1844, 1967, len(models["process"]["fields"]), "特殊业务审批与流程"),
+]
+for i, row in enumerate(idx_rows, 2):
+    for c, v in enumerate(row, 1):
+        cell = ws3.cell(i, c, v)
+        cell.font = body_font
+        cell.border = thin
+        cell.alignment = wrap
+autosize(ws3, [18, 22, 10, 10, 10, 40])
+
+ws4 = wb.create_sheet("填写说明")
+notes = [
+    "本文件在《松江农民土地退补系统字段名称》原表格式基础上补全:字段类型、备注(含DMS字段编码/表归属)、以及业务表全量字段。",
+    "原表列:类别、字段名、字段类型、数据来源、更新频率、备注、是否必须满足。",
+    "「业务字段清单」:保留原业务口径字段顺序并补类型/备注;文末追加人员表其余未列出字段。",
+    "「业务表字段全量」:8 张核心业务表全部自定义字段(同原列格式,并增加字段编码/业务表/栏目ID/模型ID)。",
+    "字段类型示例:文本/文本框、数值(小数)/小数、数值(整数)/整数、日期时间/日期时间。",
+    "死亡状态 c_death_status 与业务态 c_biz_status=FUNERAL 并存;回盘失败以 c_return_success=false + 备注为准。",
+    "生成日期:2026-09-04;数据来源:生产 DMS 模型 getModelById。",
+]
+for i, t in enumerate(notes, 1):
+    cell = ws4.cell(i, 1, t)
+    cell.font = body_font
+    cell.alignment = wrap
+ws4.column_dimensions["A"].width = 120
+
+wb.save(OUT_DESKTOP)
+wb.save(OUT_DOCS)
+print("saved", OUT_DESKTOP)
+print("saved", OUT_DOCS)
+print("sheet1_orig", len(rows_sheet1), "personnel_extra", len(extra_rows), "full", len(full_rows))

+ 193 - 0
scripts/migrate-excel-field-gaps-20260904.js

@@ -0,0 +1,193 @@
+/**
+ * 对照 docs/松江农民土地退补系统字段名称.xlsx 补齐 DMS 缺口(2026-09-04)
+ *
+ * 缺口结论:
+ * 1. 人员表缺独立「死亡状态」字段 c_death_status(原仅靠 c_biz_status=FUNERAL / c_death_date)
+ * 2. 人员表 c_supplement_start_month 别名损坏(补发��始年月)→ 修成「补发开始年月」
+ *
+ * 用法:node migrate-excel-field-gaps-20260904.js
+ */
+const http = require("http");
+const qs = require("querystring");
+const fs = require("fs");
+const path = require("path");
+
+const HOST = "121.43.55.7";
+const PORT = 2101;
+const PERSONNEL_MODEL = 1970;
+
+function post(urlPath, fields, token) {
+  return new Promise((resolve, reject) => {
+    const body = qs.stringify(fields || {});
+    const headers = {
+      "Content-Type": "application/x-www-form-urlencoded",
+      "Content-Length": Buffer.byteLength(body),
+    };
+    if (token) {
+      headers.Token = token;
+      headers.token = token;
+    }
+    const req = http.request(
+      { hostname: HOST, port: PORT, path: urlPath, method: "POST", headers },
+      (res) => {
+        let data = "";
+        res.on("data", (c) => (data += c));
+        res.on("end", () => {
+          try {
+            resolve(JSON.parse(data));
+          } catch (e) {
+            resolve({ raw: data, status: res.statusCode });
+          }
+        });
+      }
+    );
+    req.on("error", reject);
+    req.write(body);
+    req.end();
+  });
+}
+
+function text(shortName, alias, must, seq) {
+  return {
+    alias,
+    customType: "",
+    describe: "格式为富文本",
+    frontType: "content",
+    id: 2,
+    must: !!must,
+    name: "c_" + shortName,
+    searchType: "2",
+    sequence: seq || 1,
+    showParam: "name,alias,desc,type,front_type,must,default_value",
+    type: "text",
+  };
+}
+
+function normalizeFieldList(raw) {
+  let fl = raw;
+  if (typeof fl === "string") fl = JSON.parse(fl);
+  const out = {};
+  Object.keys(fl || {}).forEach((k) => {
+    let v = fl[k];
+    if (typeof v === "string") v = JSON.parse(v);
+    out[k] = v;
+  });
+  return out;
+}
+
+async function getModel(token, modelId) {
+  const res = await post("/proxy_dms/model/getModelById", { modelId: String(modelId) }, token);
+  if (res.code != 200) throw new Error("getModel " + modelId + " fail: " + JSON.stringify(res));
+  return res.content;
+}
+
+async function applyUpdates(token, modelId, addMap, renameAliasMap) {
+  const model = await getModel(token, modelId);
+  const fieldList = normalizeFieldList(model.fieldList);
+  if (Object.keys(fieldList).length < 2) {
+    throw new Error("model " + modelId + " fieldList 异常,拒绝覆盖");
+  }
+  const addfieldList = {};
+
+  Object.keys(addMap || {}).forEach((shortName) => {
+    const full = "c_" + shortName;
+    if (fieldList[full]) return;
+    const p = addMap[shortName];
+    p.name = full;
+    fieldList[full] = p;
+    addfieldList[shortName] = {
+      alias: p.alias,
+      type: p.type,
+      frontType: p.frontType,
+      must: p.must,
+      name: shortName,
+      describe: p.describe,
+      searchType: p.searchType,
+      sortType: p.sortType,
+      sequence: p.sequence,
+      showParam: p.showParam,
+      customType: p.customType || "",
+      id: p.id,
+    };
+  });
+
+  // 仅改 alias:只更新 fieldList 元数据,勿走 updatefieldList(生产 DMS 对 rename 会 500)
+  const aliasFixed = [];
+  Object.keys(renameAliasMap || {}).forEach((full) => {
+    if (!fieldList[full]) return;
+    const nextAlias = renameAliasMap[full];
+    if (fieldList[full].alias === nextAlias) return;
+    fieldList[full] = Object.assign({}, fieldList[full], { alias: nextAlias });
+    aliasFixed.push(full);
+  });
+
+  if (Object.keys(addfieldList).length === 0 && aliasFixed.length === 0) {
+    return {
+      modelId,
+      modelName: model.modelName,
+      code: 200,
+      message: "无需变更",
+      added: [],
+      updated: [],
+    };
+  }
+
+  const res = await post(
+    "/proxy_dms/model/updateFieldList",
+    {
+      modelId: String(modelId),
+      fieldList: JSON.stringify(fieldList),
+      addfieldList: JSON.stringify(addfieldList),
+      delfieldList: JSON.stringify({}),
+      updatefieldList: JSON.stringify({}),
+    },
+    token
+  );
+  const verify = await getModel(token, modelId);
+  const vfl = normalizeFieldList(verify.fieldList);
+  return {
+    modelId,
+    modelName: model.modelName,
+    code: res.code,
+    message: res.message || res.content,
+    added: Object.keys(addfieldList),
+    updated: aliasFixed,
+    deathStatus: vfl.c_death_status || null,
+    supplementAlias: vfl.c_supplement_start_month ? vfl.c_supplement_start_month.alias : null,
+  };
+}
+
+async function main() {
+  const login = await post("/proxy_oauth/user/login", {
+    userName: "user_liu",
+    password: "WE176852439@lmx",
+    clientId: "1",
+  });
+  const token = login.message;
+  if (!token) throw new Error("login failed: " + JSON.stringify(login));
+
+  const result = await applyUpdates(
+    token,
+    PERSONNEL_MODEL,
+    {
+      // 大数据死亡状态匹配专用;与业务态 c_biz_status=FUNERAL 并存
+      death_status: text("death_status", "死亡状态", false, 40),
+    },
+    {
+      // 仅修复损坏别名;其余 Excel 名与现有 alias 语义等价,不强制改名以免影响管理端展示习惯
+      c_supplement_start_month: "补发开始年月",
+    }
+  );
+
+  const outPath = path.join(
+    __dirname,
+    "../../docs/_migrate_excel_field_gaps_20260904.json"
+  );
+  fs.writeFileSync(outPath, JSON.stringify(result, null, 2), "utf8");
+  console.log(JSON.stringify(result, null, 2));
+}
+
+main().catch((e) => {
+  console.error(e);
+  process.exit(1);
+});

+ 188 - 0
scripts/migrate-household-address-20260909.js

@@ -0,0 +1,188 @@
+/**
+ * 人员模型 1970:新增户籍地址 c_household_address;
+ * 将 c_address 别名统一为「联系地址」(原「家庭地址/联系地址」拆分)。
+ *
+ * 只改 fieldList,勿走 updatefieldList(生产 DMS 对 rename 会 500)。
+ * 用法:node migrate-household-address-20260909.js
+ */
+const http = require("http");
+const qs = require("querystring");
+const fs = require("fs");
+const path = require("path");
+
+const HOST = "121.43.55.7";
+const PORT = 2101;
+const PERSONNEL_MODEL = 1970;
+
+function post(urlPath, fields, token) {
+  return new Promise((resolve, reject) => {
+    const body = qs.stringify(fields || {});
+    const headers = {
+      "Content-Type": "application/x-www-form-urlencoded",
+      "Content-Length": Buffer.byteLength(body),
+    };
+    if (token) {
+      headers.Token = token;
+      headers.token = token;
+    }
+    const req = http.request(
+      { hostname: HOST, port: PORT, path: urlPath, method: "POST", headers },
+      (res) => {
+        let data = "";
+        res.on("data", (c) => (data += c));
+        res.on("end", () => {
+          try {
+            resolve(JSON.parse(data));
+          } catch (e) {
+            resolve({ raw: data, status: res.statusCode });
+          }
+        });
+      }
+    );
+    req.on("error", reject);
+    req.write(body);
+    req.end();
+  });
+}
+
+function text(shortName, alias, must, seq) {
+  return {
+    alias,
+    customType: "",
+    describe: "格式为富文本",
+    frontType: "content",
+    id: 2,
+    must: !!must,
+    name: "c_" + shortName,
+    searchType: "2",
+    sequence: seq || 1,
+    showParam: "name,alias,desc,type,front_type,must,default_value",
+    type: "text",
+  };
+}
+
+function normalizeFieldList(raw) {
+  let fl = raw;
+  if (typeof fl === "string") fl = JSON.parse(fl);
+  const out = {};
+  Object.keys(fl || {}).forEach((k) => {
+    let v = fl[k];
+    if (typeof v === "string") v = JSON.parse(v);
+    out[k] = v;
+  });
+  return out;
+}
+
+async function getModel(token, modelId) {
+  const res = await post("/proxy_dms/model/getModelById", { modelId: String(modelId) }, token);
+  if (res.code != 200) throw new Error("getModel " + modelId + " fail: " + JSON.stringify(res));
+  return res.content;
+}
+
+async function applyUpdates(token, modelId, addMap, renameAliasMap) {
+  const model = await getModel(token, modelId);
+  const fieldList = normalizeFieldList(model.fieldList);
+  if (Object.keys(fieldList).length < 2) {
+    throw new Error("model " + modelId + " fieldList 异常,拒绝覆盖");
+  }
+  const addfieldList = {};
+
+  Object.keys(addMap || {}).forEach((shortName) => {
+    const full = "c_" + shortName;
+    if (fieldList[full]) return;
+    const p = addMap[shortName];
+    p.name = full;
+    fieldList[full] = p;
+    addfieldList[shortName] = {
+      alias: p.alias,
+      type: p.type,
+      frontType: p.frontType,
+      must: p.must,
+      name: shortName,
+      describe: p.describe,
+      searchType: p.searchType,
+      sortType: p.sortType,
+      sequence: p.sequence,
+      showParam: p.showParam,
+      customType: p.customType || "",
+      id: p.id,
+    };
+  });
+
+  const aliasFixed = [];
+  Object.keys(renameAliasMap || {}).forEach((full) => {
+    if (!fieldList[full]) return;
+    const nextAlias = renameAliasMap[full];
+    if (fieldList[full].alias === nextAlias) return;
+    fieldList[full] = Object.assign({}, fieldList[full], { alias: nextAlias });
+    aliasFixed.push(full);
+  });
+
+  if (Object.keys(addfieldList).length === 0 && aliasFixed.length === 0) {
+    return {
+      modelId,
+      modelName: model.modelName,
+      code: 200,
+      message: "无需变更",
+      added: [],
+      updated: [],
+    };
+  }
+
+  const res = await post(
+    "/proxy_dms/model/updateFieldList",
+    {
+      modelId: String(modelId),
+      fieldList: JSON.stringify(fieldList),
+      addfieldList: JSON.stringify(addfieldList),
+      delfieldList: JSON.stringify({}),
+      updatefieldList: JSON.stringify({}),
+    },
+    token
+  );
+  const verify = await getModel(token, modelId);
+  const vfl = normalizeFieldList(verify.fieldList);
+  return {
+    modelId,
+    modelName: model.modelName,
+    code: res.code,
+    message: res.message || res.content,
+    added: Object.keys(addfieldList),
+    updated: aliasFixed,
+    addressAlias: vfl.c_address ? vfl.c_address.alias : null,
+    householdAddress: vfl.c_household_address || null,
+  };
+}
+
+async function main() {
+  const login = await post("/proxy_oauth/user/login", {
+    userName: "user_liu",
+    password: "WE176852439@lmx",
+    clientId: "1",
+  });
+  const token = login.message;
+  if (!token) throw new Error("login failed: " + JSON.stringify(login));
+
+  const result = await applyUpdates(
+    token,
+    PERSONNEL_MODEL,
+    {
+      household_address: text("household_address", "户籍地址", false, 41),
+    },
+    {
+      c_address: "联系地址",
+    }
+  );
+
+  const outPath = path.join(
+    __dirname,
+    "../../docs/_migrate_household_address_20260909.json"
+  );
+  fs.writeFileSync(outPath, JSON.stringify(result, null, 2), "utf8");
+  console.log(JSON.stringify(result, null, 2));
+}
+
+main().catch((e) => {
+  console.error(e);
+  process.exit(1);
+});

+ 58 - 0
scripts/pack-desktop-20261012.js

@@ -0,0 +1,58 @@
+const fs = require('fs')
+const path = require('path')
+
+const root = path.resolve(__dirname, '../..')
+const desk = path.join(process.env.USERPROFILE, 'Desktop')
+const out = path.join(desk, '松江农民退养补贴-部署包-20261012')
+const fe = path.join(out, 'frontend')
+const be = path.join(out, 'backend')
+fs.mkdirSync(be, { recursive: true })
+fs.copyFileSync(
+  path.join(root, 'sj_nmtybt_server/target/sjnmtybt.war'),
+  path.join(be, 'sjnmtybt.war'),
+)
+
+function copyDir(src, dest) {
+  fs.mkdirSync(dest, { recursive: true })
+  for (const name of fs.readdirSync(src)) {
+    const s = path.join(src, name)
+    const d = path.join(dest, name)
+    if (fs.statSync(s).isDirectory()) copyDir(s, d)
+    else fs.copyFileSync(s, d)
+  }
+}
+copyDir(path.join(root, 'sj_tdtybz/dist'), fe)
+
+const note = [
+  '松江农民退养补贴 部署包 2026-10-12',
+  '',
+  '目录',
+  '  frontend/              前端静态资源,覆盖 2130 站点根目录',
+  '  backend/sjnmtybt.war   后端,放入 Tomcat webapps',
+  '',
+  '线上位置(与上次一致)',
+  '  前端页面:http://121.43.55.7:2130/',
+  '  前端配置:http://121.43.55.7:2130/config/config.js',
+  '  后端接口:http://121.43.55.7:11091/sjnmtybt',
+  '  后端包:Tomcat webapps/sjnmtybt.war',
+  '',
+  "前端 config.js 已是 apiBase='/api',useMock=false。",
+  '2130 不要改成 /sjnmtybt/api,否则接口会被页面回成 HTML。',
+  '换 WAR 后需重启 11091 上的 Tomcat 才生效。',
+  '',
+].join('\n')
+fs.writeFileSync(path.join(out, '部署说明.txt'), note, 'utf8')
+
+function count(dir) {
+  let n = 0
+  for (const name of fs.readdirSync(dir)) {
+    const p = path.join(dir, name)
+    n += fs.statSync(p).isDirectory() ? count(p) : 1
+  }
+  return n
+}
+const war = fs.statSync(path.join(be, 'sjnmtybt.war'))
+console.log('OUT=' + out)
+console.log('WAR_MB=' + (war.size / 1024 / 1024).toFixed(1))
+console.log('FE_FILES=' + count(fe))
+console.log('INDEX=' + fs.existsSync(path.join(fe, 'index.html')))

+ 1 - 1
scripts/seed-history-3months.js

@@ -323,7 +323,7 @@ function lifecycleLogs(ym, town, tbId, scenario, t0) {
     role: "定时任务",
     org: "区平台",
     action: "生成全区月批",
-    opinion: "每月1日自动生成全区批次并纳入符合条件人员",
+    opinion: "每月15日自动生成全区批次并纳入符合条件人员",
     result: "成功",
     time: t0,
     remark: "DISTRICT_GENERATE",

+ 206 - 0
scripts/test-personnel-fields-20261012.js

@@ -0,0 +1,206 @@
+/**
+ * 2026-10-12 人员新字段:档案保存、服刑暂停、死亡登记审批回读。
+ * 暂停测完会恢复发放。死亡登记使用新建的「字段联测勿发放」,不改在册实发人员。
+ */
+const http = require('http')
+
+function req(method, path, body) {
+  return new Promise((resolve, reject) => {
+    const data = body ? JSON.stringify(body) : null
+    const headers = { 'Content-Type': 'application/json' }
+    if (data) headers['Content-Length'] = Buffer.byteLength(data)
+    const r = http.request({ hostname: '127.0.0.1', port: 8088, path: '/sjnmtybt' + path, method, headers }, (res) => {
+      let buf = ''
+      res.on('data', (c) => (buf += c))
+      res.on('end', () => {
+        try { resolve(JSON.parse(buf)) } catch (e) { reject(new Error(buf.slice(0, 400))) }
+      })
+    })
+    r.on('error', reject)
+    if (data) r.write(data)
+    r.end()
+  })
+}
+
+function idCard() {
+  const body = '31011719500101' + String(100 + Math.floor(Math.random() * 800)).padStart(3, '0')
+  const w = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
+  const m = '10X98765432'
+  let s = 0
+  for (let i = 0; i < 17; i++) s += Number(body[i]) * w[i]
+  return body + m[s % 11]
+}
+
+function assert(cond, msg, extra) {
+  if (!cond) {
+    console.error('FAIL', msg, extra == null ? '' : JSON.stringify(extra))
+    process.exitCode = 1
+  } else {
+    console.log('OK', msg)
+  }
+}
+
+async function getPerson(id) {
+  const j = await req('GET', '/api/personnel/' + encodeURIComponent(id))
+  if (j.code !== 200) throw new Error('get ' + id + ' ' + (j.message || j.msg))
+  return j.data
+}
+
+async function main() {
+  const base = await getPerson('P33040')
+  console.log('pauseTarget', base.id, base.name, base.bizStatus, base.updateTime)
+  const saved = await req('POST', '/api/personnel', {
+    id: base.id,
+    name: base.name,
+    idNumber: base.idNumber,
+    townId: base.townId,
+    villageId: base.villageId,
+    expectedUpdateTime: base.updateTime,
+    personType: '老年农民',
+    householdDistrict: '松江区',
+    householdTown: '叶榭镇',
+    householdVillage: '井凌桥村',
+    contactName: '联测联系人',
+    contactPhone: '13800001111',
+    insuranceJoinDate: '2010-05-01',
+    benefitApplyDate: '2020-01-15',
+    urbanMedicareConfirm: '未确认',
+    personMark: '联测标示',
+    localOrRemote: '同城',
+    operatorRoleKey: 'admin',
+  })
+  assert(saved.code === 200, '档案字段保存', saved.message)
+  const back = await getPerson('P33040')
+  assert(back.personType === '老年农民', '人员类型回读', back.personType)
+  assert(back.householdDistrict === '松江区', '户籍区县回读', back.householdDistrict)
+  assert(back.householdTown === '叶榭镇', '户籍街镇回读', back.householdTown)
+  assert(back.householdVillage === '井凌桥村', '户籍村居委回读', back.householdVillage)
+  assert(back.contactName === '联测联系人', '联系人回读', back.contactName)
+  assert(back.contactPhone === '13800001111', '联系电话回读', back.contactPhone)
+  assert(String(back.insuranceJoinDate || '').startsWith('2010-05-01'), '参保日期回读', back.insuranceJoinDate)
+  assert(back.personMark === '联测标示', '人员标示回读', back.personMark)
+  assert(back.localOrRemote === '同城', '同城或异地回读', back.localOrRemote)
+  assert(back.bizStatus === 'NORMAL', '档案保存不改业务状态', back.bizStatus)
+
+  const bad = await req('POST', '/api/special-biz', {
+    personnelId: 'P33040',
+    bizType: 'PAUSE',
+    reason: '服刑收监',
+    amount: 0,
+    operator: '联测',
+    operatorRoleKey: 'social',
+  })
+  assert(bad.code !== 200, '服刑暂停缺刑期被拒绝', bad.message)
+
+  let pause = await req('POST', '/api/special-biz', {
+    personnelId: 'P33040',
+    bizType: 'PAUSE',
+    reason: '服刑收监',
+    sentenceStart: '2024-03-01',
+    sentenceEnd: '2026-03-01',
+    amount: 0,
+    operator: '联测',
+    operatorRoleKey: 'social',
+  })
+  if (pause.code !== 200 && String(pause.message || '').includes('在途')) {
+    const pending = await req('GET', '/api/special-biz/page?bizType=PAUSE&approveStatus=' + encodeURIComponent('待分管领导审批') + '&pageNum=1&pageSize=20')
+    const hit = ((pending.data && pending.data.list) || []).find((p) => p.personnelId === 'P33040')
+    pause = { code: hit ? 200 : 400, data: hit, message: hit ? '复用在途暂停单' : '没有在途暂停单' }
+  }
+  assert(pause.code === 200 && pause.data && pause.data.id, '暂停单创建', pause.message)
+  const pauseId = pause.data && pause.data.id
+  const pauseOk = await req('POST', '/api/special-biz/leader-approve', {
+    bizId: pauseId,
+    action: 'PASS',
+    opinion: '联测通过暂停',
+    operator: '联测领导',
+  })
+  assert(pauseOk.code === 200, '暂停审批通过', pauseOk.message)
+  const paused = await getPerson('P33040')
+  assert(paused.bizStatus === 'PAUSED', '暂停后业务状态', paused.bizStatus)
+  assert(String(paused.sentenceStart || '').startsWith('2024-03-01'), '刑期开始写入', paused.sentenceStart)
+  assert(String(paused.sentenceEnd || '').startsWith('2026-03-01'), '刑期结束写入', paused.sentenceEnd)
+  assert(String(paused.changeReason || '').includes('服刑'), '变更原因写入', paused.changeReason)
+
+  const resume = await req('POST', '/api/special-biz', {
+    personnelId: 'P33040',
+    bizType: 'RESUME',
+    reason: '刑期届满恢复',
+    operator: '联测',
+    operatorRoleKey: 'social',
+  })
+  assert(resume.code === 200 && resume.data && resume.data.id, '恢复单创建', resume.message)
+  const resumeOk = await req('POST', '/api/special-biz/leader-approve', {
+    bizId: resume.data.id,
+    action: 'PASS',
+    opinion: '联测通过恢复',
+    operator: '联测领导',
+  })
+  assert(resumeOk.code === 200, '恢复审批通过', resumeOk.message)
+  const restored = await getPerson('P33040')
+  assert(restored.bizStatus === 'NORMAL' || restored.bizStatus === 'NEW', '恢复后回到在发', restored.bizStatus)
+  assert(String(restored.sentenceStart || '').startsWith('2024-03-01'), '恢复后刑期仍保留', restored.sentenceStart)
+  assert(String(restored.changeReason || '').includes('恢复发放'), '恢复变更原因', restored.changeReason)
+
+  const card = idCard()
+  const created = await req('POST', '/api/personnel', {
+    name: '字段联测勿发放',
+    idNumber: card,
+    townId: base.townId,
+    villageId: base.villageId,
+    bizStatus: 'NORMAL',
+    source: '字段联测',
+    retireQualifyType: 'WITHOUT_LAND',
+    operatorRoleKey: 'admin',
+    householdType: '农业',
+  })
+  assert(created.code === 200 && created.data && created.data.id, '新建死亡联测人员', created.message || card)
+  const deathId = created.data && created.data.id
+  console.log('deathTarget', deathId, card, created.data && created.data.bizStatus)
+  const death = await req('POST', '/api/special-biz', {
+    personnelId: deathId,
+    bizType: 'FUNERAL',
+    deathDate: '2026-10-02',
+    reason: '死亡登记;死亡日期 2026-10-02;与逝者关系:子女',
+    heirName: '联测继承人',
+    heirIdNumber: idCard(),
+    heirBankCard: '6222021234567890123',
+    heirPhone: '13900002222',
+    heirRelation: '子女',
+    heirIdType: '居民身份证',
+    heirBankName: '中国农业银行',
+    heirHouseholdAddress: '上海市松江区叶榭镇联测路1号',
+    heirAddress: '上海市松江区叶榭镇联测路2号',
+    fileUrls: 'local://field-test-death.pdf',
+    amount: 1,
+    operator: '联测',
+    operatorRoleKey: 'social',
+  })
+  assert(death.code === 200 && death.data && death.data.id, '死亡登记单创建', death.message)
+  const deathOk = await req('POST', '/api/special-biz/leader-approve', {
+    bizId: death.data && death.data.id,
+    action: 'PASS',
+    opinion: '联测通过死亡登记',
+    operator: '联测领导',
+  })
+  assert(deathOk.code === 200, '死亡登记审批通过', deathOk.message)
+  const dead = await getPerson(deathId)
+  assert(dead.deathStatus === '已死亡', '死亡状态', dead.deathStatus)
+  assert(String(dead.deathDate || '').startsWith('2026-10-02'), '死亡日期', dead.deathDate)
+  assert(dead.cancelReason === '死亡', '注销原因', dead.cancelReason)
+  assert(String(dead.cancelApplyDate || '').length >= 10, '注销申办日期', dead.cancelApplyDate)
+  assert(dead.cancelDeathMonth === '202610', '注销死亡年月', dead.cancelDeathMonth)
+  assert(dead.hasHeir === '是', '是否有继承人', dead.hasHeir)
+  assert(dead.heirName === '联测继承人', '继承人姓名', dead.heirName)
+  assert(dead.heirIdType === '居民身份证', '继承人证件类型', dead.heirIdType)
+  assert(dead.heirBankName === '中国农业银行', '继承人银行名称', dead.heirBankName)
+  assert(String(dead.heirHouseholdAddress || '').includes('联测路1号'), '继承人户籍地址', dead.heirHouseholdAddress)
+  assert(String(dead.heirAddress || '').includes('联测路2号'), '继承人联系地址', dead.heirAddress)
+  assert(dead.heirRelation === '子女', '与参保人关系', dead.heirRelation)
+  console.log('deathStatusBiz', dead.bizStatus, 'cancelApply', dead.cancelApplyDate)
+}
+
+main().catch((e) => {
+  console.error(e)
+  process.exit(1)
+})

+ 21 - 1
src/main/java/com/yykj/sjnmtybt/config/SjnmtybtProperties.java

@@ -17,11 +17,31 @@ public class SjnmtybtProperties {
     private Upload upload = new Upload();
     /** 定时任务 */
     private Schedule schedule = new Schedule();
+    /** 第三方接口。地址、请求方式和报文未定,未配置时任务只记日志。 */
+    private External external = new External();
 
     @Data
     public static class Schedule {
-        /** 每月 1 日 01:00 生成全区月批并居保匹配 */
+        /** 每月 15 日 01:00 生成全区月批并居保匹配 */
         private boolean districtMonthBatch = true;
+        /** 每月 5 日 02:00 拉取土地接口中「老年农民退包」 */
+        private boolean landChangePull = true;
+        /** 每日 02:30 对昨日及更早待补全人员调用居保匹配 */
+        private boolean insuranceProfileEnrich = true;
+    }
+
+    @Data
+    public static class External {
+        private Endpoint land = new Endpoint();
+        private Endpoint death = new Endpoint();
+        private Endpoint insurance = new Endpoint();
+    }
+
+    /** 第三方接口占位。url 为空或报文未实现时不发起 HTTP。 */
+    @Data
+    public static class Endpoint {
+        private boolean enabled = true;
+        private String url = "";
     }
 
     @Data

+ 58 - 27
src/main/java/com/yykj/sjnmtybt/controller/AmountStandardController.java

@@ -6,6 +6,7 @@ import com.yykj.sjnmtybt.dto.request.AmountStandardSaveRequest;
 import com.yykj.sjnmtybt.dto.request.YearlyAdjustRequest;
 import com.yykj.sjnmtybt.dto.response.AmountStandardVO;
 import com.yykj.sjnmtybt.enums.StandardTypeEnum;
+import com.yykj.sjnmtybt.service.AmountStandardIds;
 import com.yykj.sjnmtybt.service.BizDataService;
 import com.yykj.sjnmtybt.service.BizFlowService;
 import com.yykj.sjnmtybt.service.WorkdayService;
@@ -34,15 +35,15 @@ public class AmountStandardController {
     }
 
     @Operation(summary = "分页查询金额标准",
-            description = "可按街镇、标准类型、是否当前有效筛选;调标补发会读取历史新旧标准算差额。")
+            description = "全区统一标准,不再按街镇分列;townId 忽略。调标补发读历史新旧标准算差额。")
     @GetMapping
     public ApiResponse<PageResult<AmountStandardVO>> page(
-            @Parameter(description = "街镇ID") @RequestParam(required = false) String townId,
+            @Parameter(description = "已废弃,忽略") @RequestParam(required = false) String townId,
             @Parameter(description = "标准类型") @RequestParam(required = false) StandardTypeEnum standardType,
             @Parameter(description = "是否当前有效") @RequestParam(required = false) Boolean current,
             @Parameter(description = "页码") @RequestParam(required = false, defaultValue = "1") Integer pageNum,
             @Parameter(description = "每页条数") @RequestParam(required = false, defaultValue = "20") Integer pageSize) {
-        return ApiResponse.ok(biz.pageStandards(townId, standardType, current, pageNum, pageSize));
+        return ApiResponse.ok(biz.pageStandards(AmountStandardIds.DISTRICT_TOWN_ID, standardType, current, pageNum, pageSize));
     }
 
     @Operation(summary = "新增或维护标准",
@@ -71,36 +72,49 @@ public class AmountStandardController {
         return ApiResponse.ok("清理完成", flow.cleanupLegacyStandardPairs(townId));
     }
 
-    @Operation(summary = "查询街镇当前有效标准",
-            description = "优先返回当前 YEARLY_ADJUST(含土地+丧葬);"
-                    + "若无则按 standardType 回退历史 MONTHLY/FUNERAL 单行。")
+    @Operation(summary = "查询全区当前有效标准",
+            description = "返回全区 YEARLY_ADJUST(含土地+丧葬);townId 忽略。")
     @GetMapping("/current")
     public ApiResponse<AmountStandardVO> current(
-            @Parameter(description = "街镇ID", required = true) @RequestParam String townId,
+            @Parameter(description = "已废弃,忽略") @RequestParam(required = false) String townId,
             @Parameter(description = "标准类型,默认 MONTHLY(土地);FUNERAL 取丧葬额")
             @RequestParam(required = false, defaultValue = "MONTHLY") StandardTypeEnum standardType) {
-        PageResult<AmountStandardVO> yearly = biz.pageStandards(townId, StandardTypeEnum.YEARLY_ADJUST, true, 1, 1);
-        if (yearly.getList() != null && !yearly.getList().isEmpty()) {
-            AmountStandardVO y = yearly.getList().get(0);
-            if (standardType == StandardTypeEnum.FUNERAL) {
-                AmountStandardVO view = new AmountStandardVO();
-                view.setId(y.getId());
-                view.setTownId(y.getTownId());
-                view.setStandardType(StandardTypeEnum.FUNERAL);
-                view.setAmount(y.getFuneralAmount() != null ? y.getFuneralAmount() : y.getAmount());
-                view.setFuneralAmount(y.getFuneralAmount());
-                view.setCurrent(true);
-                view.setEffectiveTime(y.getEffectiveTime());
-                view.setAdjustReason(y.getAdjustReason());
-                return ApiResponse.ok(view);
-            }
-            return ApiResponse.ok(y);
+        AmountStandardVO y = biz.resolveDistrictCurrentYearly();
+        if (y == null) {
+            return ApiResponse.fail(404, "未找到全区当前有效标准");
         }
-        PageResult<AmountStandardVO> page = biz.pageStandards(townId, standardType, true, 1, 1);
-        if (page.getList() == null || page.getList().isEmpty()) {
-            return ApiResponse.fail(404, "未找到当前有效标准: " + townId + "/" + standardType);
+        if (standardType == StandardTypeEnum.FUNERAL) {
+            AmountStandardVO view = new AmountStandardVO();
+            view.setId(y.getId());
+            view.setTownId(AmountStandardIds.DISTRICT_TOWN_ID);
+            view.setTownName(AmountStandardIds.DISTRICT_TOWN_NAME);
+            view.setStandardType(StandardTypeEnum.FUNERAL);
+            view.setAmount(y.getFuneralAmount() != null ? y.getFuneralAmount() : y.getAmount());
+            view.setFuneralAmount(y.getFuneralAmount());
+            view.setCurrent(true);
+            view.setEffectiveTime(y.getEffectiveTime());
+            view.setAdjustReason(y.getAdjustReason());
+            return ApiResponse.ok(view);
         }
-        return ApiResponse.ok(page.getList().get(0));
+        y.setTownId(AmountStandardIds.DISTRICT_TOWN_ID);
+        y.setTownName(AmountStandardIds.DISTRICT_TOWN_NAME);
+        return ApiResponse.ok(y);
+    }
+
+    @Operation(summary = "收口全区统一标准",
+            description = "保留人员明细统一 1060/24868,软删各镇重复行与测试脏数据;并对齐未出盘月批明细。")
+    @PostMapping("/unify-district")
+    public ApiResponse<Map<String, Object>> unifyDistrict() {
+        return ApiResponse.ok("已收口为全区统一标准", flow.unifyDistrictStandards());
+    }
+
+    @Operation(summary = "未出盘月批月标对齐全区现行标准",
+            description = "只改仍可改金额的全区月批明细(跳过月标为 0 的停发/跨月丧葬行),并回写镇统计。")
+    @PostMapping("/realign-open-payments")
+    public ApiResponse<Map<String, Object>> realignOpenPayments(
+            @Parameter(description = "发放年月 yyyyMM,空则处理所有未出盘全区月批")
+            @RequestParam(required = false) String payMonth) {
+        return ApiResponse.ok("未出盘月批已对齐全区标准", flow.realignUnexportedDistrictMonthly(payMonth));
     }
 
     @Operation(summary = "年调标(土地月补+丧葬同时)",
@@ -110,9 +124,26 @@ public class AmountStandardController {
                     + "与特殊业务 STANDARD_ADJUST 共用实现(后者 skipSaveStandards)。")
     @PostMapping("/yearly-adjust")
     public ApiResponse<Map<String, Object>> yearlyAdjust(@Validated @RequestBody YearlyAdjustRequest req) {
+        if (Boolean.TRUE.equals(req.getDistrictWide())) {
+            return ApiResponse.ok("全区调标已完成,已生成各镇补发批次", flow.submitDistrictYearlyAdjust(req));
+        }
         return ApiResponse.ok("调标补发批次已生成,待分管领导审批", flow.submitYearlyAdjust(req));
     }
 
+    @Operation(summary = "区人社全区调标",
+            description = "仅区人社:按统一新标准遍历各街镇落库并自动生成 VB{year}99 补发批;街镇登录提示已调标。")
+    @PostMapping("/district-yearly-adjust")
+    public ApiResponse<Map<String, Object>> districtYearlyAdjust(@RequestBody YearlyAdjustRequest req) {
+        req.setDistrictWide(true);
+        return ApiResponse.ok("全区调标已完成,已生成各镇补发批次", flow.submitDistrictYearlyAdjust(req));
+    }
+
+    @Operation(summary = "最近一次全区调标通知(街镇登录弹窗)")
+    @GetMapping("/adjust-notice")
+    public ApiResponse<Map<String, Object>> adjustNotice() {
+        return ApiResponse.ok(flow.latestDistrictAdjustNotice());
+    }
+
     @Operation(summary = "查询出盘截止日", description = "不晚于当月20日的最近工作日。")
     @GetMapping("/export-deadline")
     public ApiResponse<Map<String, Object>> exportDeadline(

+ 2 - 2
src/main/java/com/yykj/sjnmtybt/controller/BatchController.java

@@ -110,12 +110,12 @@ public class BatchController {
 
     @Operation(summary = "生成/重跑当月全区批次",
             description = "幂等:同月已有未归档 DISTRICT 则刷新选人、镇统计,并可立即居保匹配。管理员联调/补跑用。"
-                    + "simulateFirstDay=true 时把「新增」人员的下次发放起始月改到本月再纳入(仅联调,不改正式 1 号规则)。")
+                    + "simulateFirstDay=true 时把「新增」人员的下次发放起始月改到本月再纳入(仅联调,不改正式每月 15 号规则)。")
     @PostMapping("/generate-district-month")
     public ApiResponse<java.util.Map<String, Object>> generateDistrictMonth(
             @Parameter(description = "发放年月 yyyyMM,默认当月") @RequestParam(required = false) String payMonth,
             @Parameter(description = "是否立即居保匹配,默认 true") @RequestParam(required = false) Boolean matchInsurance,
-            @Parameter(description = "联调模拟每月1号:把新增人员纳入本月") @RequestParam(required = false) Boolean simulateFirstDay) {
+            @Parameter(description = "联调模拟每月15号:把新增人员纳入本月") @RequestParam(required = false) Boolean simulateFirstDay) {
         return ApiResponse.ok(flow.generateDistrictMonthBatch(payMonth, matchInsurance, simulateFirstDay));
     }
 

+ 13 - 2
src/main/java/com/yykj/sjnmtybt/controller/DashboardController.java

@@ -57,8 +57,19 @@ public class DashboardController {
             @Parameter(description = "村居ID") @RequestParam(required = false) String villageId,
             @Parameter(description = "历史月数,默认6") @RequestParam(required = false, defaultValue = "6") Integer months,
             @Parameter(description = "操作人,用于待办已读过滤") @RequestParam(required = false) String operator,
-            @Parameter(description = "角色 key:village/town/social/leader/agri/hr/admin") @RequestParam(required = false) String roleKey) {
-        return ApiResponse.ok(flow.workbench(payMonth, townId, villageId, months, operator, roleKey));
+            @Parameter(description = "角色 key:village/town/social/leader/agri/hr/admin") @RequestParam(required = false) String roleKey,
+            @Parameter(description = "true 时跳过发放明细全量,供展板首屏;村表再补一次") @RequestParam(required = false) Boolean skipPaymentScope) {
+        return ApiResponse.ok(flow.workbench(payMonth, townId, villageId, months, operator, roleKey,
+                Boolean.TRUE.equals(skipPaymentScope)));
+    }
+
+    @Operation(summary = "本镇各村发放人数与金额",
+            description = "展板村表专用。优先读村级统计,没有才扫当月发放明细并回写统计,不重跑完整 workbench。")
+    @GetMapping("/village-charts")
+    public ApiResponse<java.util.List<WorkbenchVO.VillageChartRow>> villageCharts(
+            @Parameter(description = "发放年月 yyyyMM") @RequestParam(required = false) String payMonth,
+            @Parameter(description = "街镇ID") @RequestParam String townId) {
+        return ApiResponse.ok(flow.villagePayCharts(payMonth, townId));
     }
 
     @Operation(summary = "标记工作台待办已读")

+ 19 - 2
src/main/java/com/yykj/sjnmtybt/controller/PaymentController.java

@@ -2,6 +2,7 @@ package com.yykj.sjnmtybt.controller;
 
 import com.yykj.sjnmtybt.common.ApiResponse;
 import com.yykj.sjnmtybt.common.PageResult;
+import com.yykj.sjnmtybt.dto.request.BatchMembersUpdateRequest;
 import com.yykj.sjnmtybt.dto.request.DiscExportRequest;
 import com.yykj.sjnmtybt.dto.request.PaymentDetailQueryRequest;
 import com.yykj.sjnmtybt.dto.request.PaymentDetailUpdateRequest;
@@ -43,7 +44,7 @@ public class PaymentController {
                     + "出盘清单可按街镇×银行拆分。")
     @PostMapping("/details/page")
     public ApiResponse<PageResult<PaymentDetailVO>> detailPage(@RequestBody PaymentDetailQueryRequest req) {
-        // 按批次查明细时:先对齐同批月标 / 清理调标误写标准金额,再返回
+        // 镇统计改挂全区父批切片;全区/镇批不再在只读分页里全量回写合计
         if (req != null && StringUtils.hasText(req.getBatchId())) {
             flow.rewritePaymentQueryForTownStat(req);
             flow.ensureBatchPaymentAmountsConsistent(req.getBatchId());
@@ -80,14 +81,30 @@ public class PaymentController {
         return ApiResponse.ok("已从批次移出", flow.removePaymentDetail(id));
     }
 
+    @Operation(summary = "摘离误挂在镇统计批上的发放明细",
+            description = "镇统计批只做汇总。同一人同月若在全区批已有发放明细,挂在 TB* 上的是重复行。软摘离,不改全区批。")
+    @PostMapping("/detach-town-stat-details")
+    public ApiResponse<Map<String, Object>> detachTownStatDetails() {
+        return ApiResponse.ok(flow.detachTownStatPayments());
+    }
+
     @Operation(summary = "生成月补贴清单",
             description = "按批次人员生成/补齐发放明细(人均一行)。已有明细则直接返回;"
-                    + "创建村级批次时也会同步写入,便于初审前查看明细。")
+                    + "镇统计批会改写到全区父批,不再往镇统计批插第二份明细。")
     @PostMapping("/batches/{batchId}/generate")
     public ApiResponse<PageResult<PaymentDetailVO>> generate(@PathVariable String batchId) {
         return ApiResponse.ok("发放明细已生成", flow.generatePaymentList(batchId));
     }
 
+    @Operation(summary = "测算锁定前增量加人",
+            description = "向全区发放批补写明细(镇统计批改挂父批)。测算已锁/已出盘/已审核待出盘不可加。")
+    @PostMapping("/batches/{batchId}/add-members")
+    public ApiResponse<Map<String, Object>> addMembers(
+            @PathVariable String batchId,
+            @Validated @RequestBody BatchMembersUpdateRequest req) {
+        return ApiResponse.ok("人员已纳入本批次", flow.addPaymentMembers(batchId, req));
+    }
+
     @Operation(summary = "导出批次发放清单(xlsx)",
             description = "下载该批次人员详细发放信息(含月补/补发/扣减/应发等),不改变批次状态。"
                     + "与「出盘」不同:出盘走 /export-disk 仅标记状态并记录盘号。")

+ 52 - 3
src/main/java/com/yykj/sjnmtybt/controller/PersonnelController.java

@@ -21,12 +21,14 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
+import org.springframework.util.StringUtils;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 
@@ -67,9 +69,39 @@ public class PersonnelController {
             description = "CSV(UTF-8 BOM)。街镇/村居导出中文名;含手机号与继承人信息。")
     @PostMapping("/export")
     public ResponseEntity<byte[]> export(@RequestBody PersonnelQueryRequest req) {
+        if (req != null && "ledger".equalsIgnoreCase(req.getExportStyle())) {
+            return csvResponse(importExport.exportLedgerBytes(req), "人员台账.csv");
+        }
+        if (req != null && "newMonth".equalsIgnoreCase(req.getExportStyle())) {
+            return xlsxResponse(importExport.exportNewMonthBytes(req), "松江区老年农民享受土地退养补助金新增名单.xlsx");
+        }
         return csvResponse(importExport.exportBytes(req), "人员底数导出.csv");
     }
 
+    @Operation(summary = "导出村合作社人员列表",
+            description = "按当前筛选导出本村人员,列与人员新增录入列表一致。")
+    @PostMapping("/export-roster")
+    public ResponseEntity<byte[]> exportRoster(@RequestBody PersonnelQueryRequest req) {
+        if (req == null) req = new PersonnelQueryRequest();
+        return csvResponse(importExport.exportRosterBytes(req), "本村人员列表.csv");
+    }
+
+    @Operation(summary = "导出人员台账(查询统计列)")
+    @PostMapping("/export-ledger")
+    public ResponseEntity<byte[]> exportLedger(@RequestBody PersonnelQueryRequest req) {
+        if (req == null) req = new PersonnelQueryRequest();
+        req.setExportStyle("ledger");
+        return csvResponse(importExport.exportLedgerBytes(req), "人员台账.csv");
+    }
+
+    @Operation(summary = "导出本月新增人员名单(纸质表 xlsx,供线下审批)")
+    @PostMapping("/export-new-month")
+    public ResponseEntity<byte[]> exportNewMonth(@RequestBody PersonnelQueryRequest req) {
+        if (req == null) req = new PersonnelQueryRequest();
+        req.setExportStyle("newMonth");
+        return xlsxResponse(importExport.exportNewMonthBytes(req), "松江区老年农民享受土地退养补助金新增名单.xlsx");
+    }
+
     @Operation(summary = "批量导入人员",
             description = "支持 xlsx/csv。街镇/村居填中文名(或「街镇/村居」);同身份证已存在则更新。"
                     + "人员状态可下拉,留空时新增默认为草稿、更新已有人则保持原状态。"
@@ -107,7 +139,8 @@ public class PersonnelController {
     }
 
     @Operation(summary = "新增或编辑人员",
-            description = "有 id 则更新,无 id 则新增。月补标准按街镇当前有效土地标准带出。退养资格只能由镇经发资格审批写入,本接口忽略。")
+            description = "有 id 则更新,无 id 则新增。月补标准按街镇当前有效土地标准带出。"
+                    + "退养资格:村级新增默无土地有资格,接口/导入默认有土地,可改;镇经发审批只做通过/驳回。")
     @PostMapping
     public ApiResponse<PersonnelVO> save(@Validated @RequestBody PersonnelSaveRequest req) {
         return ApiResponse.ok("保存成功", flow.savePersonnel(req));
@@ -176,8 +209,24 @@ public class PersonnelController {
         return ApiResponse.ok("状态已更新", flow.updatePersonnelStatus(req));
     }
 
+    @Operation(summary = "村提交后补做居保匹配",
+            description = "不改业务状态。已有匹配结论的不覆盖。失败原因写入居保状态,如非农保退休。")
+    @PostMapping("/qualify-insurance-match")
+    public ApiResponse<List<Map<String, Object>>> qualifyInsuranceMatch(@RequestBody Map<String, Object> body) {
+        Object raw = body == null ? null : body.get("personnelIds");
+        List<String> ids = new ArrayList<String>();
+        if (raw instanceof List) {
+            for (Object o : (List<?>) raw) {
+                if (o != null && StringUtils.hasText(String.valueOf(o))) {
+                    ids.add(String.valueOf(o).trim());
+                }
+            }
+        }
+        return ApiResponse.ok(flow.matchQualifyInsurance(ids));
+    }
+
     @Operation(summary = "村合作社提交退养资格审批",
-            description = "草稿/驳回补材料人员提交后进入「待资格审批」;写入资格流程日志。村合作社不再提交村批。")
+            description = "草稿/驳回补材料人员提交后进入「待资格审批」,并立即做居保匹配(不改业务状态)。村合作社不再提交村批。")
     @PostMapping("/{id}/qualify/submit")
     public ApiResponse<PersonnelVO> submitQualify(
             @Parameter(description = "人员ID", required = true) @PathVariable String id,
@@ -195,7 +244,7 @@ public class PersonnelController {
     }
 
     @Operation(summary = "镇经发中心审批退养资格",
-            description = "通过须选择有土地/无土地有资格,人员变为「新增」等下月 1 号纳入;不通过可无资格或驳回补材料。")
+            description = "通过须选择有土地/无土地有资格,人员变为「新增」;通过日≤15号进当月起始月,之后等下月15号纳入;不通过可无资格或驳回补材料。")
     @PostMapping("/{id}/qualify/review")
     public ApiResponse<PersonnelVO> reviewQualify(
             @Parameter(description = "人员ID", required = true) @PathVariable String id,

+ 2 - 2
src/main/java/com/yykj/sjnmtybt/controller/WorkflowController.java

@@ -96,8 +96,8 @@ public class WorkflowController {
     @GetMapping("/special-biz-nodes")
     public ApiResponse<List<Map<String, Object>>> specialBizNodes() {
         List<Map<String, Object>> list = new ArrayList<Map<String, Object>>();
-        list.add(node("DEATH_INPUT", "死亡信息录入", "村合作社", "线上", "录入后系统预警,停止继续发放"));
-        list.add(node("SYSTEM_ALERT", "系统预警", "系统", "自动", "触发告警,关联人员停发"));
+        list.add(node("DEATH_INPUT", "死亡信息录入", "村合作社", "线上", "接口比对仅预警,核实确认后再停发"));
+        list.add(node("SYSTEM_ALERT", "系统预警", "系统", "自动", "仅预警,核实确认后再停发"));
         list.add(node("DEATH_STATUS_UPDATE", "死亡状态更新", "业务部门", "线上", "更新人员死亡状态"));
         list.add(node("FUNERAL_APPLY", "丧葬补贴申请", "业务部门", "线上", "一次性丧葬补助,随特殊业务出盘"));
         list.add(node("PAUSE_RESUME_SUPPLEMENT", "暂停/恢复/补发", "业务部门", "线上", "状态类特殊业务"));

+ 22 - 8
src/main/java/com/yykj/sjnmtybt/dms/DmsClient.java

@@ -159,14 +159,9 @@ public class DmsClient {
                 }
                 List<Future<PageSlice>> futures = pool.invokeAll(tasks, 120, TimeUnit.SECONDS);
                 boolean stop = false;
-                for (Future<PageSlice> f : futures) {
-                    PageSlice slice;
-                    try {
-                        slice = f.get();
-                    } catch (Exception e) {
-                        throw new BusinessException(500, "DMS条件并行翻页失败: " + e.getMessage());
-                    }
-                    if (slice == null || slice.rows.isEmpty()) {
+                for (int i = 0; i < futures.size(); i++) {
+                    PageSlice slice = pageOrRetry(futures.get(i), columnId, modelId, base + i, size, states, searchJson);
+                    if (slice.rows.isEmpty()) {
                         stop = true;
                         break;
                     }
@@ -190,6 +185,25 @@ public class DmsClient {
         return all;
     }
 
+    /** 并行翻页时某一页偶发空结果会把后面的页丢掉,先重试一次再认定到头。 */
+    private PageSlice pageOrRetry(Future<PageSlice> future, Integer columnId, Integer modelId,
+                                  int page, int size, String states, String searchJson) {
+        PageSlice slice;
+        try {
+            slice = future.get();
+        } catch (Exception e) {
+            throw new BusinessException(500, "DMS条件并行翻页失败: " + e.getMessage());
+        }
+        if (slice != null && slice.rows != null && !slice.rows.isEmpty()) {
+            return slice;
+        }
+        PageSlice retried = selectPage(columnId, modelId, page, size, states, searchJson, false);
+        if (retried == null || retried.rows == null) {
+            return new PageSlice(0, Collections.emptyList());
+        }
+        return retried;
+    }
+
     /** 构造 DMS search 等值条件 JSON 数组(searchType=1) */
     public static String buildEqSearchJson(String... fieldValuePairs) {
         if (fieldValuePairs == null || fieldValuePairs.length == 0) {

+ 33 - 0
src/main/java/com/yykj/sjnmtybt/dms/DmsRowUtils.java

@@ -103,6 +103,39 @@ public final class DmsRowUtils {
         return null;
     }
 
+    /** 去掉 remarks 里的某个 tag=value 段,其余原文保留。 */
+    public static String withoutRemarkTag(String remarks, String tag) {
+        if (remarks == null || tag == null) {
+            return remarks == null ? "" : remarks;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (String part : remarks.split("\\|")) {
+            String p = part.trim();
+            if (p.isEmpty()) {
+                continue;
+            }
+            int i = p.indexOf('=');
+            if (i > 0 && tag.equals(p.substring(0, i).trim())) {
+                continue;
+            }
+            if (sb.length() > 0) {
+                sb.append('|');
+            }
+            sb.append(p);
+        }
+        return sb.toString();
+    }
+
+    /** 写入或替换 remarks 中的 tag。 */
+    public static String upsertRemarkTag(String remarks, String tag, String value) {
+        String base = withoutRemarkTag(remarks, tag);
+        String piece = tag + "=" + (value == null ? "" : value.trim());
+        if (!hasText(base)) {
+            return piece;
+        }
+        return base + "|" + piece;
+    }
+
     public static boolean hasText(String s) {
         return s != null && !s.trim().isEmpty() && !"null".equalsIgnoreCase(s.trim());
     }

+ 2 - 0
src/main/java/com/yykj/sjnmtybt/dto/request/AlertQueryRequest.java

@@ -2,6 +2,7 @@ package com.yykj.sjnmtybt.dto.request;
 
 import com.yykj.sjnmtybt.common.PageQuery;
 import com.yykj.sjnmtybt.enums.AlertHandleStatusEnum;
+import com.yykj.sjnmtybt.enums.AlertLevelEnum;
 import com.yykj.sjnmtybt.enums.AlertTypeEnum;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Data;
@@ -15,6 +16,7 @@ public class AlertQueryRequest extends PageQuery {
     @Schema(description = "街镇ID") private String townId;
     @Schema(description = "村居ID") private String villageId;
     @Schema(description = "告警类型") private AlertTypeEnum type;
+    @Schema(description = "告警等级") private AlertLevelEnum level;
     @Schema(description = "处理状态") private AlertHandleStatusEnum handleStatus;
     @Schema(description = "人员姓名") private String personnelName;
     @Schema(description = "关联人员ID") private String personnelId;

+ 2 - 2
src/main/java/com/yykj/sjnmtybt/dto/request/AmountStandardSaveRequest.java

@@ -6,7 +6,6 @@ import com.yykj.sjnmtybt.enums.StandardTypeEnum;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Data;
 
-import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotNull;
 import java.math.BigDecimal;
 
@@ -14,7 +13,8 @@ import java.math.BigDecimal;
 @Schema(description = "金额标准新增/调标。推荐 standardType=YEARLY_ADJUST 并同时传 amount+funeralAmount。")
 public class AmountStandardSaveRequest {
     @Schema(description = "编辑时传ID") private String id;
-    @NotBlank(message = "街镇ID不能为空") @Schema(description = "街镇ID", required = true) private String townId;
+    @Schema(description = "街镇ID;全区统一标准固定写 T_DISTRICT,可空")
+    private String townId;
     @NotNull(message = "标准类型不能为空")
     @Schema(description = "推荐 YEARLY_ADJUST(年度标准双金额);兼容 MONTHLY/FUNERAL 历史单行", required = true)
     private StandardTypeEnum standardType;

+ 4 - 0
src/main/java/com/yykj/sjnmtybt/dto/request/PersonnelQueryRequest.java

@@ -23,8 +23,12 @@ public class PersonnelQueryRequest extends PageQuery {
     @Schema(description = "退养资格类型") private RetireQualifyTypeEnum retireQualifyType;
     @Schema(description = "仅查退养资格未认定(无 WITH_LAND/WITHOUT_LAND/INELIGIBLE)") private Boolean retireQualifyUnset;
     @Schema(description = "发放年月,用于当月发放库筛选", example = "202510") private String payMonth;
+    @Schema(description = "享受起始年月 yyyyMM,镇经发月名单按此成表") private String enjoyStartMonth;
     @Schema(description = "轻量模式:不回填街镇月补标准(列表/看板加速)") private Boolean lite;
     @Schema(description = "排除的业务状态,逗号分隔,如 DRAFT,PENDING_TOWN_APPROVE") private String bizStatusNotIn;
     @Schema(description = "仅包含的业务状态,逗号分隔(与 bizStatusNotIn 互斥时优先)") private String bizStatusIn;
     @Schema(description = "非本村视角隐藏各村 DRAFT(人员台账:未提交资格仅本村可见)") private Boolean hideDraftUnlessVillage;
+    @Schema(description = "创建时间起(毫秒),用于本月新增名单") private Long createdSince;
+    @Schema(description = "导出样式:ledger=人员台账列;newMonth=本月新增名单") private String exportStyle;
+    @Schema(description = "村级导出不包含银行卡、开户行") private Boolean omitBank;
 }

+ 26 - 2
src/main/java/com/yykj/sjnmtybt/dto/request/PersonnelSaveRequest.java

@@ -15,8 +15,10 @@ public class PersonnelSaveRequest {
     @NotBlank(message = "姓名不能为空") @Schema(description = "姓名", required = true) private String name;
     @NotBlank(message = "身份证号不能为空") @Schema(description = "身份证号", required = true) private String idNumber;
     @Schema(description = "个人社会保险登记码") private String socialInsuranceCode;
+    @Schema(description = "同城/异地") private String localOrRemote;
     @Schema(description = "手机号") private String phoneNumber;
-    @Schema(description = "家庭住址") private String address;
+    @Schema(description = "联系地址") private String address;
+    @Schema(description = "户籍地址") private String householdAddress;
     @Schema(description = "户籍性质:农业/非农") private String householdType;
     @NotBlank(message = "街镇ID不能为空") @Schema(description = "街镇ID", required = true) private String townId;
     @NotBlank(message = "村居ID不能为空") @Schema(description = "录入来源村", required = true) private String villageId;
@@ -36,15 +38,37 @@ public class PersonnelSaveRequest {
     @Schema(description = "代发基数") private BigDecimal baseAmount;
     @Schema(description = "一次性代发金额") private BigDecimal oneTimeAmount;
     @Schema(description = "土地退养增资额") private BigDecimal landSubsidyIncrease;
+    @Schema(description = "死亡状态:正常/已死亡(可空;清空死亡日期时同步回正常)") private String deathStatus;
     @Schema(description = "死亡日期 yyyy-MM-dd") private String deathDate;
     @Schema(description = "继承人姓名") private String heirName;
     @Schema(description = "继承人身份证") private String heirIdNumber;
     @Schema(description = "继承人银行卡") private String heirBankCard;
     @Schema(description = "继承人电话") private String heirPhone;
     @Schema(description = "继承人与逝者关系,如配偶/子女/父母") private String heirRelation;
+    @Schema(description = "人员类型") private String personType;
+    @Schema(description = "转至城镇医保确认状态") private String urbanMedicareConfirm;
+    @Schema(description = "户籍区县") private String householdDistrict;
+    @Schema(description = "户籍街镇") private String householdTown;
+    @Schema(description = "户籍村居委") private String householdVillage;
+    @Schema(description = "联系人") private String contactName;
+    @Schema(description = "联系电话") private String contactPhone;
+    @Schema(description = "参保日期") private String insuranceJoinDate;
+    @Schema(description = "人员标示") private String personMark;
+    @Schema(description = "变更原因") private String changeReason;
+    @Schema(description = "待遇申领日期") private String benefitApplyDate;
+    @Schema(description = "刑期开始时间 yyyy-MM-dd") private String sentenceStart;
+    @Schema(description = "刑期结束时间 yyyy-MM-dd") private String sentenceEnd;
+    @Schema(description = "注销申办日期") private String cancelApplyDate;
+    @Schema(description = "注销原因") private String cancelReason;
+    @Schema(description = "注销(死亡)年月 yyyyMM") private String cancelDeathMonth;
+    @Schema(description = "是否有继承人:是/否") private String hasHeir;
+    @Schema(description = "继承人证件类型") private String heirIdType;
+    @Schema(description = "继承人银行名称") private String heirBankName;
+    @Schema(description = "继承人户籍地址") private String heirHouseholdAddress;
+    @Schema(description = "继承人联系地址") private String heirAddress;
     @Schema(description = "丧葬费补差额") private BigDecimal funeralDiffAmount;
     @Schema(description = "录入来源", example = "村级录入") private String source;
-    @Schema(description = "退养资格类型:仅镇经发资格审批写入,人员保存接口忽略此字段")
+    @Schema(description = "退养资格类型:村级新增可勾选(默认无土地有资格);接口/导入默认有土地;镇经发核实")
     private RetireQualifyTypeEnum retireQualifyType;
     @Schema(description = "备注(业务说明,不含系统前缀)") private String remarks;
     @Schema(description = "操作角色 key:village/town/social/leader/agri/hr/admin(继承人修改权限校验)")

+ 8 - 0
src/main/java/com/yykj/sjnmtybt/dto/request/SpecialBizCreateRequest.java

@@ -37,6 +37,14 @@ public class SpecialBizCreateRequest {
     @Schema(description = "继承人身份证") private String heirIdNumber;
     @Schema(description = "继承人银行卡") private String heirBankCard;
     @Schema(description = "继承人电话") private String heirPhone;
+    @Schema(description = "继承人与逝者关系") private String heirRelation;
+    @Schema(description = "刑期开始 yyyy-MM-dd(暂停且原因为服刑时必填)") private String sentenceStart;
+    @Schema(description = "刑期结束 yyyy-MM-dd") private String sentenceEnd;
+    @Schema(description = "注销申办日期 yyyy-MM-dd(死亡登记,空则取提交当日)") private String cancelApplyDate;
+    @Schema(description = "继承人证件类型") private String heirIdType;
+    @Schema(description = "继承人银行名称") private String heirBankName;
+    @Schema(description = "继承人户籍地址") private String heirHouseholdAddress;
+    @Schema(description = "继承人联系地址") private String heirAddress;
     @Schema(description = "操作人") private String operator;
     @Schema(description = "操作角色 key:village/town/social/leader/agri/hr/admin")
     private String operatorRoleKey;

+ 3 - 2
src/main/java/com/yykj/sjnmtybt/dto/request/YearlyAdjustRequest.java

@@ -3,15 +3,16 @@ package com.yykj.sjnmtybt.dto.request;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Data;
 
-import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotNull;
 import java.math.BigDecimal;
 
 @Data
 @Schema(description = "年调标申请(土地月补+丧葬同时调整,一年一次)")
 public class YearlyAdjustRequest {
-    @NotBlank @Schema(description = "街镇ID;一年一次土地+丧葬联动调标并生成各村补发批", required = true)
+    @Schema(description = "街镇ID;全区调标时可空,由后端遍历各镇")
     private String townId;
+    @Schema(description = "true=区人社全区调标,遍历各街镇落标准并自动生成补发批")
+    private Boolean districtWide;
     @NotNull @Schema(description = "新月补标准", required = true) private BigDecimal monthlyAmount;
     @NotNull @Schema(description = "新丧葬标准", required = true) private BigDecimal funeralAmount;
     @Schema(description = "生效年月 yyyyMM,默认当月;补发月数=该月序号-1") private String effectiveMonth;

+ 26 - 1
src/main/java/com/yykj/sjnmtybt/dto/response/PersonnelVO.java

@@ -15,8 +15,11 @@ public class PersonnelVO {
     @Schema(description = "姓名") private String name;
     @Schema(description = "公民身份证号码") private String idNumber;
     @Schema(description = "个人社会保险登记码") private String socialInsuranceCode;
+    @Schema(description = "同城/异地") private String localOrRemote;
+    @Schema(description = "死亡预警(仅提示,不改业务状态)") private Boolean deathWarning;
     @Schema(description = "手机号") private String phoneNumber;
-    @Schema(description = "家庭住址/联系地址") private String address;
+    @Schema(description = "联系地址") private String address;
+    @Schema(description = "户籍地址") private String householdAddress;
     @Schema(description = "户籍性质:农业/非农") private String householdType;
     @Schema(description = "区ID") private String districtId;
     @Schema(description = "街镇ID") private String townId;
@@ -46,12 +49,34 @@ public class PersonnelVO {
     @Schema(description = "是否本月发放") private Boolean payThisMonth;
     @Schema(description = "录入来源:村级录入/Excel导入/历史台账/死亡名单") private String source;
     @Schema(description = "录入村居ID") private String inputVillageId;
+    @Schema(description = "死亡状态(大数据匹配/死亡登记):正常/已死亡") private String deathStatus;
     @Schema(description = "死亡日期 yyyy-MM-dd") private String deathDate;
     @Schema(description = "继承人姓名") private String heirName;
     @Schema(description = "继承人身份证") private String heirIdNumber;
     @Schema(description = "继承人银行卡") private String heirBankCard;
     @Schema(description = "继承人电话") private String heirPhone;
     @Schema(description = "继承人与逝者关系,如配偶/子女/父母") private String heirRelation;
+    @Schema(description = "人员类型") private String personType;
+    @Schema(description = "转至城镇医保确认状态") private String urbanMedicareConfirm;
+    @Schema(description = "户籍区县") private String householdDistrict;
+    @Schema(description = "户籍街镇") private String householdTown;
+    @Schema(description = "户籍村居委") private String householdVillage;
+    @Schema(description = "联系人") private String contactName;
+    @Schema(description = "联系电话") private String contactPhone;
+    @Schema(description = "参保日期") private String insuranceJoinDate;
+    @Schema(description = "人员标示") private String personMark;
+    @Schema(description = "变更原因") private String changeReason;
+    @Schema(description = "待遇申领日期") private String benefitApplyDate;
+    @Schema(description = "刑期开始时间 yyyy-MM-dd") private String sentenceStart;
+    @Schema(description = "刑期结束时间 yyyy-MM-dd") private String sentenceEnd;
+    @Schema(description = "注销申办日期 yyyy-MM-dd") private String cancelApplyDate;
+    @Schema(description = "注销原因") private String cancelReason;
+    @Schema(description = "注销(死亡)年月 yyyyMM") private String cancelDeathMonth;
+    @Schema(description = "是否有继承人:是/否") private String hasHeir;
+    @Schema(description = "继承人证件类型") private String heirIdType;
+    @Schema(description = "继承人银行名称") private String heirBankName;
+    @Schema(description = "继承人户籍地址") private String heirHouseholdAddress;
+    @Schema(description = "继承人联系地址") private String heirAddress;
     @Schema(description = "丧葬费补差额") private BigDecimal funeralDiffAmount;
     @Schema(description = "土地退养增资额") private BigDecimal landSubsidyIncrease;
     @Schema(description = "退养资格类型:WITH_LAND 有土地退养 / WITHOUT_LAND 无土地有资格 / INELIGIBLE 无资格")

+ 8 - 0
src/main/java/com/yykj/sjnmtybt/dto/response/SpecialBizVO.java

@@ -28,6 +28,14 @@ public class SpecialBizVO {
     @Schema(description = "继承人身份证") private String heirIdNumber;
     @Schema(description = "继承人银行卡") private String heirBankCard;
     @Schema(description = "继承人电话") private String heirPhone;
+    @Schema(description = "继承人与逝者关系") private String heirRelation;
+    @Schema(description = "刑期开始") private String sentenceStart;
+    @Schema(description = "刑期结束") private String sentenceEnd;
+    @Schema(description = "注销申办日期") private String cancelApplyDate;
+    @Schema(description = "继承人证件类型") private String heirIdType;
+    @Schema(description = "继承人银行名称") private String heirBankName;
+    @Schema(description = "继承人户籍地址") private String heirHouseholdAddress;
+    @Schema(description = "继承人联系地址") private String heirAddress;
     @Schema(description = "审批状态:待分管领导审批/已通过/已驳回") private String approveStatus;
     @Schema(description = "审批单ID") private String approvalId;
     @Schema(description = "审批意见") private String opinion;

+ 1 - 0
src/main/java/com/yykj/sjnmtybt/dto/response/WorkbenchVO.java

@@ -30,6 +30,7 @@ public class WorkbenchVO {
     @Schema(description = "按街镇资格汇总") private List<TownQualifyRow> townQualify = new ArrayList<TownQualifyRow>();
     @Schema(description = "本月各村人数/金额(镇级)") private List<VillageChartRow> villageCharts = new ArrayList<VillageChartRow>();
     @Schema(description = "本月批次状态计数") private Map<String, Integer> batchStatusCounts = new HashMap<String, Integer>();
+    @Schema(description = "范围内人员业务状态计数") private Map<String, Integer> personnelStatusCounts = new HashMap<String, Integer>();
     @Schema(description = "未关闭告警全量") private List<AlertVO> recentAlerts = new ArrayList<AlertVO>();
     @Schema(description = "本月批次全量") private List<BatchVO> monthBatches = new ArrayList<BatchVO>();
     @Schema(description = "角色待办(含跳转 path)") private List<WorkbenchTodoVO> todos = new ArrayList<WorkbenchTodoVO>();

+ 57 - 0
src/main/java/com/yykj/sjnmtybt/integration/DeathCheckClient.java

@@ -0,0 +1,57 @@
+package com.yykj.sjnmtybt.integration;
+
+/**
+ * 死亡比对接口。居保匹配前对尚未死亡的人员调用。
+ * 只用于预警,不得据此停发或发放丧葬补贴。地址、方式和报文未定。
+ */
+public interface DeathCheckClient {
+
+    DeathCheckResult check(String idNumber, String name);
+
+    class DeathCheckResult {
+        private boolean configured;
+        private boolean dead;
+        private String deathDate;
+        private String message;
+
+        public static DeathCheckResult skipped(String message) {
+            DeathCheckResult r = new DeathCheckResult();
+            r.configured = false;
+            r.dead = false;
+            r.message = message;
+            return r;
+        }
+
+        public boolean isConfigured() {
+            return configured;
+        }
+
+        public void setConfigured(boolean configured) {
+            this.configured = configured;
+        }
+
+        public boolean isDead() {
+            return dead;
+        }
+
+        public void setDead(boolean dead) {
+            this.dead = dead;
+        }
+
+        public String getDeathDate() {
+            return deathDate;
+        }
+
+        public void setDeathDate(String deathDate) {
+            this.deathDate = deathDate;
+        }
+
+        public String getMessage() {
+            return message;
+        }
+
+        public void setMessage(String message) {
+            this.message = message;
+        }
+    }
+}

+ 147 - 0
src/main/java/com/yykj/sjnmtybt/integration/InsuranceProfileClient.java

@@ -0,0 +1,147 @@
+package com.yykj.sjnmtybt.integration;
+
+/**
+ * 居保匹配接口。除匹配结论外,还用于补全居民信息。地址、方式和报文未定。
+ */
+public interface InsuranceProfileClient {
+
+    InsuranceProfileResult fetch(String idNumber, String name);
+
+    class InsuranceProfileResult {
+        private boolean configured;
+        private boolean found;
+        private String message;
+        private String phoneNumber;
+        private String address;
+        private String householdAddress;
+        private String householdType;
+        private String socialInsuranceCode;
+        private String bankCardNumber;
+        private String bankName;
+        private String bankType;
+        private String localOrRemote;
+        /** true=匹配通过,false=失败;null=接口未给结论 */
+        private Boolean matched;
+        private String matchReason;
+
+        public static InsuranceProfileResult skipped(String message) {
+            InsuranceProfileResult r = new InsuranceProfileResult();
+            r.configured = false;
+            r.found = false;
+            r.message = message;
+            return r;
+        }
+
+        public boolean isConfigured() {
+            return configured;
+        }
+
+        public void setConfigured(boolean configured) {
+            this.configured = configured;
+        }
+
+        public boolean isFound() {
+            return found;
+        }
+
+        public void setFound(boolean found) {
+            this.found = found;
+        }
+
+        public String getMessage() {
+            return message;
+        }
+
+        public void setMessage(String message) {
+            this.message = message;
+        }
+
+        public String getPhoneNumber() {
+            return phoneNumber;
+        }
+
+        public void setPhoneNumber(String phoneNumber) {
+            this.phoneNumber = phoneNumber;
+        }
+
+        public String getAddress() {
+            return address;
+        }
+
+        public void setAddress(String address) {
+            this.address = address;
+        }
+
+        public String getHouseholdAddress() {
+            return householdAddress;
+        }
+
+        public void setHouseholdAddress(String householdAddress) {
+            this.householdAddress = householdAddress;
+        }
+
+        public String getHouseholdType() {
+            return householdType;
+        }
+
+        public void setHouseholdType(String householdType) {
+            this.householdType = householdType;
+        }
+
+        public String getSocialInsuranceCode() {
+            return socialInsuranceCode;
+        }
+
+        public void setSocialInsuranceCode(String socialInsuranceCode) {
+            this.socialInsuranceCode = socialInsuranceCode;
+        }
+
+        public String getBankCardNumber() {
+            return bankCardNumber;
+        }
+
+        public void setBankCardNumber(String bankCardNumber) {
+            this.bankCardNumber = bankCardNumber;
+        }
+
+        public String getBankName() {
+            return bankName;
+        }
+
+        public void setBankName(String bankName) {
+            this.bankName = bankName;
+        }
+
+        public String getBankType() {
+            return bankType;
+        }
+
+        public void setBankType(String bankType) {
+            this.bankType = bankType;
+        }
+
+        public String getLocalOrRemote() {
+            return localOrRemote;
+        }
+
+        public void setLocalOrRemote(String localOrRemote) {
+            this.localOrRemote = localOrRemote;
+        }
+
+        public Boolean getMatched() {
+            return matched;
+        }
+
+        public void setMatched(Boolean matched) {
+            this.matched = matched;
+        }
+
+        public String getMatchReason() {
+            return matchReason;
+        }
+
+        public void setMatchReason(String matchReason) {
+            this.matchReason = matchReason;
+        }
+    }
+}

+ 118 - 0
src/main/java/com/yykj/sjnmtybt/integration/LandChangeClient.java

@@ -0,0 +1,118 @@
+package com.yykj.sjnmtybt.integration;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 土地变动接口。请求地址、方式和返回报文未定,实现类在未约定前不得发起 HTTP。
+ */
+public interface LandChangeClient {
+
+    LandPullResult pullRetiredPackage();
+
+    class LandPullResult {
+        private boolean configured;
+        private String message;
+        private List<LandChangePerson> people = new ArrayList<LandChangePerson>();
+
+        public static LandPullResult skipped(String message) {
+            LandPullResult r = new LandPullResult();
+            r.configured = false;
+            r.message = message;
+            r.people = Collections.emptyList();
+            return r;
+        }
+
+        public boolean isConfigured() {
+            return configured;
+        }
+
+        public void setConfigured(boolean configured) {
+            this.configured = configured;
+        }
+
+        public String getMessage() {
+            return message;
+        }
+
+        public void setMessage(String message) {
+            this.message = message;
+        }
+
+        public List<LandChangePerson> getPeople() {
+            return people;
+        }
+
+        public void setPeople(List<LandChangePerson> people) {
+            this.people = people == null ? new ArrayList<LandChangePerson>() : people;
+        }
+    }
+
+    /** 土地接口中的一条人员变动。区划可给名称或 ID,入库时再解析。 */
+    class LandChangePerson {
+        private String name;
+        private String idNumber;
+        private String changeReason;
+        private String townId;
+        private String townName;
+        private String villageId;
+        private String villageName;
+
+        public String getName() {
+            return name;
+        }
+
+        public void setName(String name) {
+            this.name = name;
+        }
+
+        public String getIdNumber() {
+            return idNumber;
+        }
+
+        public void setIdNumber(String idNumber) {
+            this.idNumber = idNumber;
+        }
+
+        public String getChangeReason() {
+            return changeReason;
+        }
+
+        public void setChangeReason(String changeReason) {
+            this.changeReason = changeReason;
+        }
+
+        public String getTownId() {
+            return townId;
+        }
+
+        public void setTownId(String townId) {
+            this.townId = townId;
+        }
+
+        public String getTownName() {
+            return townName;
+        }
+
+        public void setTownName(String townName) {
+            this.townName = townName;
+        }
+
+        public String getVillageId() {
+            return villageId;
+        }
+
+        public void setVillageId(String villageId) {
+            this.villageId = villageId;
+        }
+
+        public String getVillageName() {
+            return villageName;
+        }
+
+        public void setVillageName(String villageName) {
+            this.villageName = villageName;
+        }
+    }
+}

+ 30 - 0
src/main/java/com/yykj/sjnmtybt/integration/UnconfiguredDeathCheckClient.java

@@ -0,0 +1,30 @@
+package com.yykj.sjnmtybt.integration;
+
+import com.yykj.sjnmtybt.config.SjnmtybtProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 死亡比对接口占位。url 为空,或地址已填但报文尚未约定时,都不发起请求。
+ */
+@Component
+public class UnconfiguredDeathCheckClient implements DeathCheckClient {
+
+    private final SjnmtybtProperties props;
+
+    public UnconfiguredDeathCheckClient(SjnmtybtProperties props) {
+        this.props = props;
+    }
+
+    @Override
+    public DeathCheckResult check(String idNumber, String name) {
+        SjnmtybtProperties.Endpoint ep = props.getExternal() == null ? null : props.getExternal().getDeath();
+        if (ep == null || !ep.isEnabled()) {
+            return DeathCheckResult.skipped("死亡接口未启用");
+        }
+        if (!StringUtils.hasText(ep.getUrl())) {
+            return DeathCheckResult.skipped("死亡接口地址未配置");
+        }
+        return DeathCheckResult.skipped("死亡接口地址已配置,但请求方式与返回报文未定,暂不调用");
+    }
+}

+ 30 - 0
src/main/java/com/yykj/sjnmtybt/integration/UnconfiguredInsuranceProfileClient.java

@@ -0,0 +1,30 @@
+package com.yykj.sjnmtybt.integration;
+
+import com.yykj.sjnmtybt.config.SjnmtybtProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 居保匹配接口占位。url 为空,或地址已填但报文尚未约定时,都不发起请求。
+ */
+@Component
+public class UnconfiguredInsuranceProfileClient implements InsuranceProfileClient {
+
+    private final SjnmtybtProperties props;
+
+    public UnconfiguredInsuranceProfileClient(SjnmtybtProperties props) {
+        this.props = props;
+    }
+
+    @Override
+    public InsuranceProfileResult fetch(String idNumber, String name) {
+        SjnmtybtProperties.Endpoint ep = props.getExternal() == null ? null : props.getExternal().getInsurance();
+        if (ep == null || !ep.isEnabled()) {
+            return InsuranceProfileResult.skipped("居保匹配接口未启用");
+        }
+        if (!StringUtils.hasText(ep.getUrl())) {
+            return InsuranceProfileResult.skipped("居保匹配接口地址未配置");
+        }
+        return InsuranceProfileResult.skipped("居保匹配接口地址已配置,但请求方式与返回报文未定,暂不调用");
+    }
+}

+ 30 - 0
src/main/java/com/yykj/sjnmtybt/integration/UnconfiguredLandChangeClient.java

@@ -0,0 +1,30 @@
+package com.yykj.sjnmtybt.integration;
+
+import com.yykj.sjnmtybt.config.SjnmtybtProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 土地接口占位。url 为空,或地址已填但报文尚未约定时,都不发起请求。
+ */
+@Component
+public class UnconfiguredLandChangeClient implements LandChangeClient {
+
+    private final SjnmtybtProperties props;
+
+    public UnconfiguredLandChangeClient(SjnmtybtProperties props) {
+        this.props = props;
+    }
+
+    @Override
+    public LandPullResult pullRetiredPackage() {
+        SjnmtybtProperties.Endpoint ep = props.getExternal() == null ? null : props.getExternal().getLand();
+        if (ep == null || !ep.isEnabled()) {
+            return LandPullResult.skipped("土地接口未启用");
+        }
+        if (!StringUtils.hasText(ep.getUrl())) {
+            return LandPullResult.skipped("土地接口地址未配置");
+        }
+        return LandPullResult.skipped("土地接口地址已配置,但请求方式与返回报文未定,暂不调用");
+    }
+}

+ 3 - 2
src/main/java/com/yykj/sjnmtybt/schedule/DistrictMonthBatchScheduler.java

@@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
 /**
- * 每月 1 日 01:00 生成全区当月批次并自动居保匹配。
+ * 每月 15 日 01:00 生成全区当月批次并自动居保匹配。
  */
 @Component
 public class DistrictMonthBatchScheduler {
@@ -23,7 +23,8 @@ public class DistrictMonthBatchScheduler {
         this.props = props;
     }
 
-    @Scheduled(cron = "0 0 1 1 * ?")
+    /** cron 日须与 {@link BizFlowService#DISTRICT_MONTH_GENERATE_DAY} 一致 */
+    @Scheduled(cron = "0 0 1 15 * ?")
     public void generateCurrentMonth() {
         if (props.getSchedule() == null || !props.getSchedule().isDistrictMonthBatch()) {
             return;

+ 53 - 0
src/main/java/com/yykj/sjnmtybt/schedule/ExternalPersonnelScheduler.java

@@ -0,0 +1,53 @@
+package com.yykj.sjnmtybt.schedule;
+
+import com.yykj.sjnmtybt.config.SjnmtybtProperties;
+import com.yykj.sjnmtybt.service.ExternalPersonnelSyncService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 土地接口每月 5 日入库;居保匹配每日补全前一日及更早的待补全人员(土地数据因此在 6 日补全)。
+ */
+@Component
+public class ExternalPersonnelScheduler {
+
+    private static final Logger log = LoggerFactory.getLogger(ExternalPersonnelScheduler.class);
+
+    private final ExternalPersonnelSyncService sync;
+    private final SjnmtybtProperties props;
+
+    public ExternalPersonnelScheduler(ExternalPersonnelSyncService sync, SjnmtybtProperties props) {
+        this.sync = sync;
+        this.props = props;
+    }
+
+    /** 每月 5 日 02:00 */
+    @Scheduled(cron = "0 0 2 5 * ?")
+    public void pullLandChanges() {
+        if (props.getSchedule() == null || !props.getSchedule().isLandChangePull()) {
+            return;
+        }
+        try {
+            log.info("land-change: start");
+            sync.pullLandChanges();
+        } catch (Exception e) {
+            log.error("land-change failed", e);
+        }
+    }
+
+    /** 每日 02:30。当天入库的人员 create_time 仍在今天,要到次日才进入补全。 */
+    @Scheduled(cron = "0 30 2 * * ?")
+    public void enrichInsuranceProfiles() {
+        if (props.getSchedule() == null || !props.getSchedule().isInsuranceProfileEnrich()) {
+            return;
+        }
+        try {
+            log.info("insurance-enrich: start");
+            sync.enrichPendingProfiles();
+        } catch (Exception e) {
+            log.error("insurance-enrich failed", e);
+        }
+    }
+}

+ 18 - 0
src/main/java/com/yykj/sjnmtybt/service/AmountStandardIds.java

@@ -0,0 +1,18 @@
+package com.yykj.sjnmtybt.service;
+
+import org.springframework.util.StringUtils;
+
+/** 补助标准按全区一份维护,不再按街镇分列。 */
+public final class AmountStandardIds {
+    public static final String DISTRICT_TOWN_ID = "T_DISTRICT";
+    public static final String DISTRICT_TOWN_NAME = "全区";
+
+    private AmountStandardIds() {}
+
+    public static boolean isDistrict(String townId) {
+        if (!StringUtils.hasText(townId)) {
+            return true;
+        }
+        return DISTRICT_TOWN_ID.equals(townId) || "D_SJ".equals(townId) || DISTRICT_TOWN_NAME.equals(townId);
+    }
+}

+ 391 - 81
src/main/java/com/yykj/sjnmtybt/service/BizDataService.java

@@ -281,6 +281,19 @@ public class BizDataService {
     private volatile long personnelListCacheExpireAt;
     private static final long PERSONNEL_LIST_CACHE_TTL_MS = 900_000L;
     private final AtomicBoolean personnelRefreshing = new AtomicBoolean(false);
+    /** 写后覆盖层:SWR 全量重拉若读到更旧的 DMS 行,不得盖回待审等旧状态 */
+    private final Map<String, PersonnelWriteOverlay> personnelWriteOverlay =
+            new HashMap<String, PersonnelWriteOverlay>();
+
+    private static final class PersonnelWriteOverlay {
+        final Map<String, Object> row;
+        final long expireAt;
+
+        PersonnelWriteOverlay(Map<String, Object> row, long expireAt) {
+            this.row = row;
+            this.expireAt = expireAt;
+        }
+    }
 
     /**
      * 仅用于清空底数、恢复初始化等必须整表重建的场景。
@@ -320,26 +333,87 @@ public class BizDataService {
 
     /** 把已回写 DMS 的字段合并进缓存中对应的人。 */
     public void patchPersonnelCache(String id, Map<String, Object> fields) {
+        patchPersonnelCache(id, fields, null);
+    }
+
+    /**
+     * 写后必须留下 overlay。缓存未建或 id 对不上时用 fallbackRow upsert,禁止静默 no-op,
+     * 否则资格审批待审列表会在 SWR 后回档。
+     */
+    public void patchPersonnelCache(String id, Map<String, Object> fields, Map<String, Object> fallbackRow) {
         if (!StringUtils.hasText(id) || fields == null || fields.isEmpty()) {
             return;
         }
         synchronized (personnelListLock) {
-            if (personnelListCache == null || personnelRowByKey == null) {
-                return;
+            Map<String, Object> old = personnelRowByKey == null ? null : personnelRowByKey.get(id);
+            if (old == null && fallbackRow != null) {
+                old = fallbackRow;
             }
-            Map<String, Object> old = personnelRowByKey.get(id);
-            if (old == null) {
-                return;
-            }
-            Map<String, Object> merged = new LinkedHashMap<String, Object>(old);
+            Map<String, Object> merged = old == null
+                    ? new LinkedHashMap<String, Object>(fields)
+                    : new LinkedHashMap<String, Object>(old);
             merged.putAll(fields);
-            if (!fields.containsKey("update_time")) {
+            if (!merged.containsKey("update_time")) {
                 merged.put("update_time", Long.valueOf(System.currentTimeMillis()));
             }
-            upsertPersonnelCacheLocked(merged);
+            rememberPersonnelOverlayLocked(id, merged);
+            if (personnelListCache != null && (old != null || fallbackRow != null)) {
+                upsertPersonnelCacheLocked(merged);
+            }
+        }
+    }
+
+    private void rememberPersonnelOverlayLocked(String id, Map<String, Object> merged) {
+        long exp = System.currentTimeMillis() + PERSONNEL_LIST_CACHE_TTL_MS;
+        PersonnelWriteOverlay ov = new PersonnelWriteOverlay(new LinkedHashMap<String, Object>(merged), exp);
+        if (StringUtils.hasText(id)) {
+            personnelWriteOverlay.put(id, ov);
+        }
+        String dmsId = DmsRowUtils.str(merged, "id");
+        String biz = DmsRowUtils.personnelBizId(merged);
+        if (StringUtils.hasText(dmsId)) {
+            personnelWriteOverlay.put(dmsId, ov);
+        }
+        if (StringUtils.hasText(biz)) {
+            personnelWriteOverlay.put(biz, ov);
+        }
+    }
+
+    private void applyPersonnelWriteOverlayLocked() {
+        if (personnelWriteOverlay.isEmpty() || personnelListCache == null) {
+            return;
+        }
+        long now = System.currentTimeMillis();
+        java.util.Iterator<Map.Entry<String, PersonnelWriteOverlay>> it =
+                personnelWriteOverlay.entrySet().iterator();
+        java.util.IdentityHashMap<PersonnelWriteOverlay, Boolean> applied =
+                new java.util.IdentityHashMap<PersonnelWriteOverlay, Boolean>();
+        while (it.hasNext()) {
+            Map.Entry<String, PersonnelWriteOverlay> e = it.next();
+            PersonnelWriteOverlay ov = e.getValue();
+            if (ov == null || ov.expireAt <= now) {
+                it.remove();
+                continue;
+            }
+            if (applied.containsKey(ov)) {
+                continue;
+            }
+            applied.put(ov, Boolean.TRUE);
+            Map<String, Object> dmsRow = personnelRowByKey == null ? null : personnelRowByKey.get(e.getKey());
+            long dmsUt = dmsRow == null ? 0L : nzLong(DmsRowUtils.longVal(dmsRow, "update_time"));
+            long ovUt = nzLong(DmsRowUtils.longVal(ov.row, "update_time"));
+            if (dmsRow == null || ovUt >= dmsUt) {
+                upsertPersonnelCacheLocked(ov.row);
+            } else {
+                it.remove();
+            }
         }
     }
 
+    private static long nzLong(Long v) {
+        return v == null ? 0L : v.longValue();
+    }
+
     /** 从缓存去掉已销毁/已删除的人(按业务号或 DMS id)。 */
     public void removePersonnelFromCache(java.util.Collection<String> ids) {
         if (ids == null || ids.isEmpty()) {
@@ -544,6 +618,7 @@ public class BizDataService {
         }
         personnelListCache = all;
         personnelRowByKey = idx;
+        applyPersonnelWriteOverlayLocked();
         personnelListCacheExpireAt = System.currentTimeMillis() + PERSONNEL_LIST_CACHE_TTL_MS;
         System.out.println("[personnel-cache] loaded " + all.size() + " in "
                 + (System.currentTimeMillis() - t0) + "ms");
@@ -669,6 +744,10 @@ public class BizDataService {
                     }
                 }
             }
+            // 历史回盘种子用 H606002 等非人员主键写告警/明细,按身份证回挂真实档案
+            if (found == null) {
+                found = resolvePersonnelByLegacyPaymentId(id);
+            }
         }
         if (found != null) {
             enrichMonthlyFromTownStandard(found, new HashMap<String, BigDecimal>());
@@ -676,37 +755,97 @@ public class BizDataService {
         return found;
     }
 
+    /** 告警/明细上的历史种子人员 ID,按发放明细身份证对到现有人员。 */
+    private PersonnelVO resolvePersonnelByLegacyPaymentId(String legacyId) {
+        if (!StringUtils.hasText(legacyId) || legacyId.startsWith("P")
+                || legacyId.matches("(?i)^(TB|DB|VB|MB).+")) {
+            return null;
+        }
+        List<PaymentDetailVO> pays = loadPaymentsByPersonnelId(legacyId);
+        if (pays == null || pays.isEmpty()) {
+            return null;
+        }
+        PaymentDetailVO d = pays.get(0);
+        String idNo = d == null ? null : d.getIdNumber();
+        String name = d == null ? null : d.getName();
+        String townId = d == null ? null : d.getTownId();
+        String villageId = d == null ? null : d.getVillageId();
+        PersonnelVO byIdNo = null;
+        PersonnelVO byName = null;
+        for (PersonnelVO p : loadPersonnelListCached()) {
+            if (p == null) {
+                continue;
+            }
+            if (StringUtils.hasText(idNo) && idNo.equals(p.getIdNumber())) {
+                byIdNo = p;
+                break;
+            }
+            if (byName == null && StringUtils.hasText(name) && name.equals(p.getName())
+                    && (!StringUtils.hasText(townId) || townId.equals(p.getTownId()))
+                    && (!StringUtils.hasText(villageId) || villageId.equals(p.getVillageId()))) {
+                byName = p;
+            }
+        }
+        return byIdNo != null ? byIdNo : byName;
+    }
+
     /**
-     * 月补标准以街镇金额标准表当前有效值为准(关联带出),覆盖人员表冗余字段。
-     * 优先 YEARLY_ADJUST(土地额),再回退历史 MONTHLY 单行——与 /api/amount-standards/current 一致。
+     * 月补标准取全区统一当前有效值(T_DISTRICT),与街镇无关。
      */
     public BigDecimal currentMonthlyStandard(String townId) {
-        if (!StringUtils.hasText(townId) || !dmsOn()) {
+        if (!dmsOn()) {
             return null;
         }
-        PageResult<AmountStandardVO> yearly = pageStandards(townId, StandardTypeEnum.YEARLY_ADJUST, true, 1, 1);
-        if (yearly.getList() != null && !yearly.getList().isEmpty()
-                && yearly.getList().get(0).getAmount() != null) {
-            return yearly.getList().get(0).getAmount();
-        }
-        PageResult<AmountStandardVO> page = pageStandards(townId, StandardTypeEnum.MONTHLY, true, 1, 1);
-        if (page.getList() == null || page.getList().isEmpty()) {
+        AmountStandardVO y = resolveDistrictCurrentYearly();
+        return y == null ? null : y.getAmount();
+    }
+
+    /** 全区当前年度标准;尚未收口时回退「人员明细统一 1060/24868」,再回退任一当前 YEARLY。 */
+    public AmountStandardVO resolveDistrictCurrentYearly() {
+        if (!dmsOn()) {
             return null;
         }
-        return page.getList().get(0).getAmount();
+        AmountStandardVO official = null;
+        AmountStandardVO anyCurrent = null;
+        for (AmountStandardVO s : loadStandardListCached()) {
+            if (s == null || Boolean.TRUE.equals(s.getDeleted())) {
+                continue;
+            }
+            if (s.getStandardType() != StandardTypeEnum.YEARLY_ADJUST
+                    && s.getStandardType() != StandardTypeEnum.MONTHLY) {
+                continue;
+            }
+            if (!Boolean.TRUE.equals(s.getCurrent())) {
+                continue;
+            }
+            if (AmountStandardIds.isDistrict(s.getTownId())
+                    && s.getStandardType() == StandardTypeEnum.YEARLY_ADJUST) {
+                return s;
+            }
+            if (s.getStandardType() == StandardTypeEnum.YEARLY_ADJUST && isOfficial1060(s)) {
+                official = s;
+            }
+            if (anyCurrent == null && s.getStandardType() == StandardTypeEnum.YEARLY_ADJUST) {
+                anyCurrent = s;
+            }
+        }
+        return official != null ? official : anyCurrent;
+    }
+
+    static boolean isOfficial1060(AmountStandardVO s) {
+        return s != null
+                && s.getAmount() != null && s.getAmount().compareTo(new BigDecimal("1060")) == 0
+                && s.getFuneralAmount() != null && s.getFuneralAmount().compareTo(new BigDecimal("24868")) == 0;
     }
 
     private void enrichMonthlyFromTownStandard(PersonnelVO p, Map<String, BigDecimal> cache) {
-        if (p == null || !StringUtils.hasText(p.getTownId())) {
+        if (p == null) {
             return;
         }
-        BigDecimal amt = cache.get(p.getTownId());
-        if (!cache.containsKey(p.getTownId())) {
-            amt = currentMonthlyStandard(p.getTownId());
-            cache.put(p.getTownId(), amt);
+        if (!cache.containsKey(AmountStandardIds.DISTRICT_TOWN_ID)) {
+            cache.put(AmountStandardIds.DISTRICT_TOWN_ID, currentMonthlyStandard(null));
         }
-        // 以街镇标准表为准:有则覆盖;无标准则清空,禁止展示人员表里的陈旧冗余额
-        p.setMonthlyStandard(amt);
+        p.setMonthlyStandard(cache.get(AmountStandardIds.DISTRICT_TOWN_ID));
     }
 
     private Map<String, String> buildRegionAliasMap() {
@@ -789,9 +928,28 @@ public class BizDataService {
         if (!matchBizStatusFilters(p, req)) {
             return false;
         }
+        if (req.getCreatedSince() != null && p.getCreateTime() != null
+                && p.getCreateTime() < req.getCreatedSince()) {
+            return false;
+        }
+        if (StringUtils.hasText(req.getEnjoyStartMonth())) {
+            String want = compactYearMonth(req.getEnjoyStartMonth());
+            String got = compactYearMonth(p.getEnjoyStartMonth());
+            if (!StringUtils.hasText(want) || !want.equals(got)) {
+                return false;
+            }
+        }
         return true;
     }
 
+    private static String compactYearMonth(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return "";
+        }
+        String s = raw.replace("-", "").trim();
+        return s.length() >= 6 ? s.substring(0, 6) : "";
+    }
+
     private boolean matchBizStatusFilters(PersonnelVO p, PersonnelQueryRequest req) {
         if (Boolean.TRUE.equals(req.getHideDraftUnlessVillage())
                 && !StringUtils.hasText(req.getVillageId())
@@ -830,9 +988,17 @@ public class BizDataService {
         vo.setName(DmsRowUtils.str(row, "c_name"));
         vo.setIdNumber(DmsRowUtils.str(row, "c_id_number"));
         vo.setSocialInsuranceCode(DmsRowUtils.str(row, "c_social_insurance_code"));
+        String localOrRemote = DmsRowUtils.firstNonBlank(
+                DmsRowUtils.str(row, "c_local_or_remote"),
+                DmsRowUtils.remarkTag(remarks, "LOCAL_REMOTE"));
+        vo.setLocalOrRemote(localOrRemote);
+        vo.setDeathWarning(isDeathWarning(DmsRowUtils.str(row, "c_death_status"),
+                DmsRowUtils.str(row, "c_death_date"),
+                enumOrNull(PersonnelStatusEnum.class, DmsRowUtils.str(row, "c_biz_status"))));
         Object phone = row.get("c_phone_number");
         vo.setPhoneNumber(phone == null ? null : String.valueOf(phone));
         vo.setAddress(DmsRowUtils.str(row, "c_address"));
+        vo.setHouseholdAddress(DmsRowUtils.str(row, "c_household_address"));
         vo.setHouseholdType(DmsRowUtils.str(row, "c_household_type"));
         vo.setDistrictId(DmsRowUtils.str(row, "c_district_id"));
         vo.setTownId(DmsRowUtils.str(row, "c_town_id"));
@@ -859,6 +1025,7 @@ public class BizDataService {
         vo.setPayThisMonth(DmsRowUtils.bool(row, "c_pay_this_month"));
         vo.setSource(DmsRowUtils.str(row, "c_source"));
         vo.setInputVillageId(DmsRowUtils.str(row, "c_input_village_id"));
+        vo.setDeathStatus(DmsRowUtils.str(row, "c_death_status"));
         vo.setDeathDate(DmsRowUtils.str(row, "c_death_date"));
         vo.setHeirName(DmsRowUtils.str(row, "c_heir_name"));
         vo.setHeirIdNumber(DmsRowUtils.str(row, "c_heir_id_number"));
@@ -867,22 +1034,49 @@ public class BizDataService {
         vo.setHeirRelation(DmsRowUtils.firstNonBlank(
                 DmsRowUtils.str(row, "c_heir_relation"),
                 DmsRowUtils.remarkTag(remarks, "heirRelation")));
+        vo.setPersonType(DmsRowUtils.str(row, "c_person_type"));
+        vo.setUrbanMedicareConfirm(DmsRowUtils.str(row, "c_urban_medicare_confirm"));
+        vo.setHouseholdDistrict(DmsRowUtils.str(row, "c_household_district"));
+        vo.setHouseholdTown(DmsRowUtils.str(row, "c_household_town"));
+        vo.setHouseholdVillage(DmsRowUtils.str(row, "c_household_village"));
+        vo.setContactName(DmsRowUtils.str(row, "c_contact_name"));
+        vo.setContactPhone(DmsRowUtils.str(row, "c_contact_phone"));
+        vo.setInsuranceJoinDate(DmsRowUtils.str(row, "c_insurance_join_date"));
+        vo.setPersonMark(DmsRowUtils.str(row, "c_person_mark"));
+        vo.setChangeReason(DmsRowUtils.str(row, "c_change_reason"));
+        vo.setBenefitApplyDate(DmsRowUtils.str(row, "c_benefit_apply_date"));
+        vo.setSentenceStart(DmsRowUtils.str(row, "c_sentence_start"));
+        vo.setSentenceEnd(DmsRowUtils.str(row, "c_sentence_end"));
+        vo.setCancelApplyDate(DmsRowUtils.str(row, "c_cancel_apply_date"));
+        vo.setCancelReason(DmsRowUtils.str(row, "c_cancel_reason"));
+        vo.setCancelDeathMonth(DmsRowUtils.str(row, "c_cancel_death_month"));
+        vo.setHasHeir(DmsRowUtils.str(row, "c_has_heir"));
+        vo.setHeirIdType(DmsRowUtils.str(row, "c_heir_id_type"));
+        vo.setHeirBankName(DmsRowUtils.str(row, "c_heir_bank_name"));
+        vo.setHeirHouseholdAddress(DmsRowUtils.str(row, "c_heir_household_address"));
+        vo.setHeirAddress(DmsRowUtils.str(row, "c_heir_address"));
         vo.setFuneralDiffAmount(DmsRowUtils.decimal(row, "c_funeral_diff_amount"));
         vo.setLandSubsidyIncrease(DmsRowUtils.decimal(row, "c_land_subsidy_increase"));
         vo.setRetireQualifyType(resolveRetireQualifyType(
                 DmsRowUtils.str(row, "c_retire_qualify_type"), remarks));
-        // 待提交资格:尚未生效,不展示上次通过留下的有地/无地
-        if (vo.getBizStatus() == PersonnelStatusEnum.DRAFT
-                && vo.getRetireQualifyType() != RetireQualifyTypeEnum.INELIGIBLE) {
-            vo.setRetireQualifyType(null);
-        }
-        vo.setRemarks(remarks);
+        vo.setRemarks(DmsRowUtils.withoutRemarkTag(remarks, "PROFILE_SYNC"));
         vo.setCreateTime(DmsRowUtils.longVal(row, "create_time"));
         vo.setUpdateTime(DmsRowUtils.longVal(row, "update_time"));
         applyRegionDisplayNames(vo);
         return vo;
     }
 
+    /** 死亡预警:大数据/死亡日期提示,不把业务态改成丧葬。 */
+    static boolean isDeathWarning(String deathStatus, String deathDate, PersonnelStatusEnum biz) {
+        if (biz == PersonnelStatusEnum.FUNERAL || biz == PersonnelStatusEnum.EXITED) {
+            return false;
+        }
+        if ("已死亡".equals(deathStatus == null ? "" : deathStatus.trim())) {
+            return true;
+        }
+        return StringUtils.hasText(deathDate);
+    }
+
     /** 人员列表展示用:按区划字典把街镇/村居 ID 转成中文名,避免前端缓存未加载时露出 V_YX_003 */
     private void applyRegionDisplayNames(PersonnelVO vo) {
         if (vo == null) {
@@ -1094,8 +1288,23 @@ public class BizDataService {
             if (needPays.isEmpty()) {
                 continue;
             }
-            // 同月全区批只拉一次发放明细,再按镇切片,避免列表页 N 镇 N 次全量 DMS 翻页
-            List<PaymentDetailVO> allPays = listPaymentsByBatch(parent, null);
+            // 同月全区批:若本页仅涉及单镇,按镇过滤拉明细,避免整区翻页导致月补贴列表超时
+            String sliceTownId = null;
+            boolean singleTown = true;
+            for (BatchVO b : needPays) {
+                String tid = b.getTownId() == null ? "" : b.getTownId().trim();
+                if (!StringUtils.hasText(tid)) {
+                    singleTown = false;
+                    break;
+                }
+                if (sliceTownId == null) {
+                    sliceTownId = tid;
+                } else if (!sliceTownId.equals(tid)) {
+                    singleTown = false;
+                    break;
+                }
+            }
+            List<PaymentDetailVO> allPays = listPaymentsByBatch(parent, singleTown ? sliceTownId : null);
             Map<String, List<PaymentDetailVO>> paysByTown = new LinkedHashMap<String, List<PaymentDetailVO>>();
             if (allPays != null) {
                 for (PaymentDetailVO d : allPays) {
@@ -1128,48 +1337,20 @@ public class BizDataService {
         return parent;
     }
 
-    /** 统计表命中时回填镇切片,避免再扫发放明细。 */
+    /** 只认街镇粒度,且月标合计已写入的统计行。村居行和未写月标的回盘行不能覆盖批次头。 */
     public BatchStatVO findTownMonthStat(String payMonth, String townId) {
-        if (!StringUtils.hasText(payMonth) || !StringUtils.hasText(townId)) {
+        BatchStatVO stat = TownStatAmounts.pickTownStat(loadStatListCached(), payMonth, townId);
+        if (!TownStatAmounts.canApply(stat)) {
             return null;
         }
-        String ym = payMonth.replace("-", "").trim();
-        for (BatchStatVO s : loadStatListCached()) {
-            if (s == null) {
-                continue;
-            }
-            String sm = s.getPayMonth() == null ? "" : s.getPayMonth().replace("-", "").trim();
-            if (!ym.equals(sm) || !townId.equals(s.getTownId())) {
-                continue;
-            }
-            if (s.getShouldPayCount() != null && s.getShouldPayCount() > 0) {
-                return s;
-            }
-        }
-        return null;
+        return stat;
     }
 
     private boolean applyTownStatToBatch(BatchVO b, BatchStatVO stat) {
-        if (b == null || stat == null) {
+        if (b == null || !TownStatAmounts.canApply(stat)) {
             return false;
         }
-        int n = stat.getShouldPayCount() == null ? 0 : stat.getShouldPayCount();
-        java.math.BigDecimal sum = stat.getShouldPayAmount() == null
-                ? java.math.BigDecimal.ZERO : stat.getShouldPayAmount();
-        if (n <= 0 && sum.compareTo(java.math.BigDecimal.ZERO) <= 0) {
-            return false;
-        }
-        b.setPeopleCount(n);
-        b.setTotalAmount(sum);
-        b.setNormalAmount(stat.getStandardAmount() == null ? java.math.BigDecimal.ZERO : stat.getStandardAmount());
-        b.setSupplementAmount(stat.getSupplementAmount() == null ? java.math.BigDecimal.ZERO : stat.getSupplementAmount());
-        b.setFuneralAmount(stat.getDeathAmount() == null ? java.math.BigDecimal.ZERO : stat.getDeathAmount());
-        java.math.BigDecimal deduct = sum.subtract(
-                nz(b.getNormalAmount()).add(nz(b.getSupplementAmount())).add(nz(b.getFuneralAmount())));
-        if (deduct.compareTo(java.math.BigDecimal.ZERO) < 0) {
-            deduct = java.math.BigDecimal.ZERO;
-        }
-        b.setDeductAmount(deduct);
+        TownStatAmounts.apply(b, stat);
         return true;
     }
 
@@ -1484,14 +1665,11 @@ public class BizDataService {
     private final AtomicBoolean batchRefreshing = new AtomicBoolean(false);
 
     public void invalidateBatchCache() {
-        batchByIdCacheExpireAt = 0L;
-        if (batchByIdCache != null) {
-            startCacheRefresh(batchRefreshing, "batch-cache-refresh", batchByIdCacheLock, new Runnable() {
-                @Override
-                public void run() {
-                    reloadBatchCacheLocked();
-                }
-            });
+        // 必须丢弃快照:仅把 expireAt 置 0 仍会走 stale-while-revalidate,审批后列表仍读旧状态
+        synchronized (batchByIdCacheLock) {
+            batchByIdCache = null;
+            batchRowByKey = null;
+            batchByIdCacheExpireAt = 0L;
         }
     }
 
@@ -2081,7 +2259,7 @@ public class BizDataService {
         if (slice.rows != null) {
             for (Map<String, Object> row : slice.rows) {
                 PaymentDetailVO d = toPaymentFromRow(row);
-                if (d != null && matchesPaymentQueryFilters(d, req)) {
+                if (d != null && !skipTownStatPayment(d, req) && matchesPaymentQueryFilters(d, req)) {
                     list.add(d);
                 }
             }
@@ -2159,6 +2337,9 @@ public class BizDataService {
         }
         List<PaymentDetailVO> filtered = new ArrayList<PaymentDetailVO>();
         for (PaymentDetailVO d : candidates) {
+            if (skipTownStatPayment(d, req)) {
+                continue;
+            }
             if (matchesPaymentQueryFilters(d, req)) {
                 filtered.add(d);
             }
@@ -2231,6 +2412,7 @@ public class BizDataService {
                 return queryPaymentsFromDms(search);
             }
         });
+        java.util.Set<String> seenIds = new java.util.HashSet<String>();
         for (PaymentDetailVO d : all) {
             if (d == null || isRemovedPayment(d)) {
                 continue;
@@ -2241,6 +2423,9 @@ public class BizDataService {
             if (StringUtils.hasText(townId) && !townId.equals(d.getTownId())) {
                 continue;
             }
+            if (StringUtils.hasText(d.getId()) && !seenIds.add(d.getId())) {
+                continue;
+            }
             out.add(d);
         }
         return out;
@@ -2360,6 +2545,15 @@ public class BizDataService {
         }
     }
 
+    /** 镇统计批上的明细是重复挂账,不作为这个人的第二笔发放。显式按该批查询时仍返回,便于清理。 */
+    private boolean skipTownStatPayment(PaymentDetailVO d, PaymentDetailQueryRequest req) {
+        if (d == null || !TownStatAmounts.isTownStatBatchId(d.getBatchId())) {
+            return false;
+        }
+        String asked = req == null ? null : req.getBatchId();
+        return !TownStatAmounts.isTownStatBatchId(asked);
+    }
+
     private boolean isRemovedPayment(PaymentDetailVO d) {
         if (d == null) {
             return true;
@@ -2498,7 +2692,8 @@ public class BizDataService {
             if (Boolean.TRUE.equals(s.getDeleted())) {
                 return false;
             }
-            if (StringUtils.hasText(townId) && !townId.equals(s.getTownId())) {
+            // 列表与核算都只认全区行;未收口前 resolveDistrictCurrentYearly 另有回退
+            if (!AmountStandardIds.isDistrict(s.getTownId())) {
                 return false;
             }
             if (type != null && s.getStandardType() != type) {
@@ -2527,6 +2722,10 @@ public class BizDataService {
         AmountStandardVO vo = new AmountStandardVO();
         vo.setId(DmsRowUtils.str(row, "id"));
         vo.setTownId(DmsRowUtils.str(row, "c_town_id"));
+        if (AmountStandardIds.isDistrict(vo.getTownId())) {
+            vo.setTownId(AmountStandardIds.DISTRICT_TOWN_ID);
+            vo.setTownName(AmountStandardIds.DISTRICT_TOWN_NAME);
+        }
         vo.setAmount(DmsRowUtils.decimal(row, "c_amount"));
         vo.setFuneralAmount(DmsRowUtils.decimal(row, "c_funeral_amount"));
         vo.setEffectiveTime(DmsRowUtils.longVal(row, "c_date_time"));
@@ -2738,6 +2937,9 @@ public class BizDataService {
             if (req.getType() != null && a.getType() != req.getType()) {
                 return false;
             }
+            if (req.getLevel() != null && a.getLevel() != req.getLevel()) {
+                return false;
+            }
             if (req.getHandleStatus() != null && a.getHandleStatus() != req.getHandleStatus()) {
                 return false;
             }
@@ -2851,6 +3053,8 @@ public class BizDataService {
         vo.setContent(DmsRowUtils.str(row, "c_content"));
         // 历史告警可能未落库批次字段:从正文「批次 VB… / 202608」回填,供列表「批次号」展示
         fillAlertBatchFromContent(vo);
+        // 审批超时等批次级:历史误把批次 ID/号写入人员字段,读出时清空以免前端当人员查
+        sanitizeBatchLevelAlertPersonnel(vo);
         String code = DmsRowUtils.str(row, "c_fail_reason_code");
         if (StringUtils.hasText(code)) {
             if (code.startsWith("CODE_")) {
@@ -2890,6 +3094,34 @@ public class BizDataService {
         if (needNo) vo.setBatchNo(token);
     }
 
+    /** 批次级告警:人员字段若等于批次 ID/号或呈批次形态,则清空(仅 VO,不回写 DMS) */
+    private static void sanitizeBatchLevelAlertPersonnel(AlertVO vo) {
+        if (vo == null || vo.getType() != AlertTypeEnum.APPROVAL_TIMEOUT) {
+            return;
+        }
+        String pid = vo.getPersonnelId() == null ? "" : vo.getPersonnelId().trim();
+        String pname = vo.getPersonnelName() == null ? "" : vo.getPersonnelName().trim();
+        String bid = vo.getBatchId() == null ? "" : vo.getBatchId().trim();
+        String bno = vo.getBatchNo() == null ? "" : vo.getBatchNo().trim();
+        boolean badId = !StringUtils.hasText(pid)
+                || pid.equals(bid)
+                || pid.equals(bno)
+                || pid.matches("(?i)^(TB|DB|VB|MB).+")
+                || (bno.length() >= 6 && pid.equals(bno));
+        boolean badName = !StringUtils.hasText(pname)
+                || pname.equals(bno)
+                || pname.equals(bid)
+                || pname.equals(pid)
+                || pname.matches("(?i)^(TB|DB|VB|MB).+")
+                || pname.matches("^\\d{6}$");
+        if (badId) {
+            vo.setPersonnelId(null);
+        }
+        if (badName) {
+            vo.setPersonnelName(null);
+        }
+    }
+
     // ---------- 统计 / 流程 ----------
     private final Object statListLock = new Object();
     private volatile List<BatchStatVO> statListCache;
@@ -2909,6 +3141,13 @@ public class BizDataService {
         }
     }
 
+    /** 刚写入村级统计后立刻重拉,避免异步刷新窗口内村表仍走发放明细。 */
+    public void reloadStatCacheNow() {
+        synchronized (statListLock) {
+            reloadStatCacheLocked();
+        }
+    }
+
     private List<BatchStatVO> loadStatListCached() {
         List<BatchStatVO> hit = statListCache;
         if (hit != null && statListCacheExpireAt > System.currentTimeMillis()) {
@@ -3070,11 +3309,53 @@ public class BizDataService {
                 return Long.compare(tb, ta);
             }
         });
+        Map<String, Map<String, Object>> prev = processRowByKey;
+        if (prev != null) {
+            long now = System.currentTimeMillis();
+            for (Map.Entry<String, Map<String, Object>> entry : prev.entrySet()) {
+                if (entry.getValue() == null || idx.containsKey(entry.getKey())) {
+                    continue;
+                }
+                Long operateAt = DmsRowUtils.longVal(entry.getValue(), "c_operate_time");
+                if (operateAt != null && now - operateAt < 120_000L) {
+                    idx.put(entry.getKey(), entry.getValue());
+                    all.add(toProcess(entry.getValue()));
+                }
+            }
+        }
         processCache = all;
         processRowByKey = idx;
         processCacheExpireAt = System.currentTimeMillis() + PROCESS_CACHE_TTL_MS;
     }
 
+    /** 新建流程单立刻可被审批读到,避免异步刷新窗口里查不到刚写入的单据。 */
+    public void pinProcessRow(String id, Map<String, Object> content) {
+        if (!StringUtils.hasText(id) || content == null) {
+            return;
+        }
+        Map<String, Object> row = new LinkedHashMap<String, Object>(content);
+        row.put("id", id.trim());
+        synchronized (processListLock) {
+            Map<String, Map<String, Object>> idx = processRowByKey;
+            if (idx == null) {
+                idx = new HashMap<String, Map<String, Object>>();
+                processRowByKey = idx;
+            }
+            idx.put(id.trim(), row);
+            if (processCache != null) {
+                List<ProcessLogVO> next = new ArrayList<ProcessLogVO>();
+                next.add(toProcess(row));
+                for (ProcessLogVO old : processCache) {
+                    if (old == null || id.trim().equals(old.getId())) {
+                        continue;
+                    }
+                    next.add(old);
+                }
+                processCache = next;
+            }
+        }
+    }
+
     public List<ProcessLogVO> listProcessFiltered(ProcessLogQueryRequest req) {
         if (!dmsOn() || req == null) {
             return Collections.emptyList();
@@ -3273,7 +3554,7 @@ public class BizDataService {
         return PageResult.of(pageNum, pageSize, all.size(), all.subList(from, to));
     }
 
-    /** 列表默认:待提交/新增优先,同组内按创建时间或 ID 倒序(新录入排前) */
+    /** 列表默认:待办/新增优先;已退出(丧葬发完)与已归档沉底。同组内按创建时间或 ID 倒序。 */
     private void sortPersonnelForList(List<PersonnelVO> list) {
         if (list == null || list.size() < 2) {
             return;
@@ -3308,8 +3589,20 @@ public class BizDataService {
                 return 2;
             case NEW:
                 return 3;
-            default:
+            case FUNERAL:
+                return 4;
+            case PAUSED:
+            case STOPPED:
+                return 8;
+            case NORMAL:
+            case PENDING_INSURANCE_MATCH:
                 return 10;
+            case ARCHIVED:
+                return 80;
+            case EXITED:
+                return 90;
+            default:
+                return 15;
         }
     }
 
@@ -3408,6 +3701,23 @@ public class BizDataService {
         return false;
     }
 
+    /** 写特殊业务审批结果后立刻改内存行,避免流程缓存 SWR 仍返回「待分管领导审批」。 */
+    public void patchProcessRowCache(String id, Map<String, Object> patch) {
+        if (!StringUtils.hasText(id) || patch == null || patch.isEmpty()) {
+            return;
+        }
+        synchronized (processListLock) {
+            Map<String, Map<String, Object>> idx = processRowByKey;
+            if (idx == null) {
+                return;
+            }
+            Map<String, Object> row = idx.get(id.trim());
+            if (row != null) {
+                row.putAll(patch);
+            }
+        }
+    }
+
     /** 流程表原始行(按 DMS id)。 */
     public Map<String, Object> findProcessRaw(String id) {
         if (!StringUtils.hasText(id)) {

File diff suppressed because it is too large
+ 574 - 48
src/main/java/com/yykj/sjnmtybt/service/BizFlowService.java


+ 2 - 2
src/main/java/com/yykj/sjnmtybt/service/DictCatalogService.java

@@ -65,7 +65,7 @@ public class DictCatalogService {
         put(map, group("personnelStatus", "bizStatus", "人员业务状态",
                 item("DRAFT", "待提交资格", "村已录入,尚未提交退养资格审批"),
                 item("PENDING_TOWN_APPROVE", "待资格审批", "已提交,等镇经发中心审退养资格"),
-                item("NEW", "新增", "资格已通过,等待下个自然月 1 号纳入全区月发"),
+                item("NEW", "新增", "资格已通过,等待下个自然月 15 号纳入全区月发"),
                 item("PENDING_INSURANCE_MATCH", "待居保匹配", "已纳入月批,等居保结果"),
                 item("NORMAL", "正常发放", "资格正常,可纳入月补贴清单"),
                 item("PAUSED", "暂停发放", "临时停发,可经特殊业务恢复"),
@@ -92,7 +92,7 @@ public class DictCatalogService {
                 item("NAME_MISMATCH", "姓名不一致", "姓名与证件/居保侧不一致"),
                 item("INSURANCE_MATCH_FAIL", "居保匹配失败", "居保比对失败或无法判定"),
                 item("AMOUNT_ABNORMAL", "金额异常", "发放金额异常"),
-                item("DEATH_STATUS", "死亡停发预警", "死亡信息触发停发预警"),
+                item("DEATH_STATUS", "死亡停发预警", "接口比对仅预警,核实确认后再停发"),
                 item("MATERIAL_PENDING", "材料待补", "材料不齐待补充"),
                 item("RETURN_FAIL", "回盘失败", "财政回盘失败"),
                 item("LAND_MATCH_FAIL", "土地匹配不符合", "镇初审标记土地匹配失败,低级告警,待村人工处理")));

+ 237 - 0
src/main/java/com/yykj/sjnmtybt/service/ExternalPersonnelSyncService.java

@@ -0,0 +1,237 @@
+package com.yykj.sjnmtybt.service;
+
+import com.yykj.sjnmtybt.common.BusinessException;
+import com.yykj.sjnmtybt.config.SjnmtybtProperties;
+import com.yykj.sjnmtybt.dms.DmsClient;
+import com.yykj.sjnmtybt.dms.DmsRowUtils;
+import com.yykj.sjnmtybt.dto.request.PersonnelSaveRequest;
+import com.yykj.sjnmtybt.dto.response.PersonnelVO;
+import com.yykj.sjnmtybt.dto.response.RegionVO;
+import com.yykj.sjnmtybt.enums.PersonnelStatusEnum;
+import com.yykj.sjnmtybt.enums.RegionLevelEnum;
+import com.yykj.sjnmtybt.enums.RetireQualifyTypeEnum;
+import com.yykj.sjnmtybt.integration.InsuranceProfileClient;
+import com.yykj.sjnmtybt.integration.InsuranceProfileClient.InsuranceProfileResult;
+import com.yykj.sjnmtybt.integration.LandChangeClient;
+import com.yykj.sjnmtybt.integration.LandChangeClient.LandChangePerson;
+import com.yykj.sjnmtybt.integration.LandChangeClient.LandPullResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 人工新增与土地接口入库后的第三方补全。
+ * 接口地址和报文未定:客户端返回未配置时只记日志,不改人员、不把待补全标记成完成。
+ */
+@Service
+public class ExternalPersonnelSyncService {
+
+    public static final String SOURCE_MANUAL = "人工新增";
+    public static final String SOURCE_LAND = "土地接口";
+    public static final String CHANGE_RETIRED_PACKAGE = "老年农民退包";
+    public static final String PROFILE_SYNC = "PROFILE_SYNC";
+    public static final String SYNC_PENDING = "PENDING";
+    public static final String SYNC_DONE = "DONE";
+
+    private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
+    private static final int ENRICH_LIMIT = 500;
+    private static final Logger log = LoggerFactory.getLogger(ExternalPersonnelSyncService.class);
+
+    private final LandChangeClient landClient;
+    private final InsuranceProfileClient insuranceClient;
+    private final BizFlowService flow;
+    private final BizDataService data;
+    private final DmsClient dms;
+    private final SjnmtybtProperties props;
+
+    public ExternalPersonnelSyncService(LandChangeClient landClient,
+                                        InsuranceProfileClient insuranceClient,
+                                        BizFlowService flow,
+                                        BizDataService data,
+                                        DmsClient dms,
+                                        SjnmtybtProperties props) {
+        this.landClient = landClient;
+        this.insuranceClient = insuranceClient;
+        this.flow = flow;
+        this.data = data;
+        this.dms = dms;
+        this.props = props;
+    }
+
+    /** 每月 5 日:土地接口中变动原因为老年农民退包的人员入库。 */
+    public void pullLandChanges() {
+        LandPullResult pulled = landClient.pullRetiredPackage();
+        if (pulled == null || !pulled.isConfigured()) {
+            log.info("land-change: skip ({})", pulled == null ? "无结果" : pulled.getMessage());
+            return;
+        }
+        List<LandChangePerson> people = pulled.getPeople();
+        if (people == null || people.isEmpty()) {
+            log.info("land-change: no rows");
+            return;
+        }
+        Map<String, Map<String, Object>> byId = data.mapPersonnelByIdNumber();
+        int saved = 0;
+        int skipped = 0;
+        for (LandChangePerson person : people) {
+            if (person == null || !CHANGE_RETIRED_PACKAGE.equals(trim(person.getChangeReason()))) {
+                skipped++;
+                continue;
+            }
+            String idNumber = trim(person.getIdNumber());
+            if (!StringUtils.hasText(person.getName()) || !StringUtils.hasText(idNumber)) {
+                skipped++;
+                continue;
+            }
+            if (byId.containsKey(idNumber.toUpperCase())) {
+                skipped++;
+                continue;
+            }
+            String townId = firstText(person.getTownId(), regionId(RegionLevelEnum.TOWN, person.getTownName(), "D_SJ"));
+            String villageId = firstText(person.getVillageId(),
+                    regionId(RegionLevelEnum.VILLAGE, person.getVillageName(), townId));
+            if (!StringUtils.hasText(townId) || !StringUtils.hasText(villageId)) {
+                log.info("land-change: skip {}, region unresolved", idNumber);
+                skipped++;
+                continue;
+            }
+            PersonnelSaveRequest req = new PersonnelSaveRequest();
+            req.setName(person.getName().trim());
+            req.setIdNumber(idNumber);
+            req.setTownId(townId);
+            req.setVillageId(villageId);
+            req.setSource(SOURCE_LAND);
+            req.setRetireQualifyType(RetireQualifyTypeEnum.WITH_LAND);
+            req.setBizStatus(PersonnelStatusEnum.DRAFT);
+            req.setOperatorRoleKey("land-api");
+            try {
+                flow.savePersonnel(req);
+                saved++;
+            } catch (BusinessException ex) {
+                log.info("land-change: skip {} ({})", idNumber, ex.getMessage());
+                skipped++;
+            }
+        }
+        log.info("land-change: saved={} skipped={}", saved, skipped);
+    }
+
+    /**
+     * 每日:补全「昨天及更早」仍标记待居保补全的人工新增、土地接口人员。
+     * 当天新入库的要到次日才调用。
+     */
+    public void enrichPendingProfiles() {
+        long startOfToday = LocalDate.now(SHANGHAI).atStartOfDay(SHANGHAI).toInstant().toEpochMilli();
+        List<Map<String, Object>> pending = new ArrayList<Map<String, Object>>();
+        for (Map<String, Object> row : data.listAllPersonnelRawRows()) {
+            if (row == null || !SYNC_PENDING.equals(DmsRowUtils.remarkTag(DmsRowUtils.str(row, "c_remarks"), PROFILE_SYNC))) {
+                continue;
+            }
+            String source = DmsRowUtils.str(row, "c_source");
+            if (!SOURCE_MANUAL.equals(source) && !SOURCE_LAND.equals(source)) {
+                continue;
+            }
+            Long created = DmsRowUtils.longVal(row, "create_time");
+            if (created == null || created.longValue() <= 0L || created.longValue() >= startOfToday) {
+                continue;
+            }
+            pending.add(row);
+            if (pending.size() >= ENRICH_LIMIT) {
+                break;
+            }
+        }
+        if (pending.isEmpty()) {
+            log.info("insurance-enrich: no pending rows before today");
+            return;
+        }
+        int filled = 0;
+        for (Map<String, Object> row : pending) {
+            String idNumber = DmsRowUtils.str(row, "c_id_number");
+            String bizId = DmsRowUtils.personnelBizId(row);
+            PersonnelVO person = data.getPersonnel(StringUtils.hasText(bizId) ? bizId : DmsRowUtils.str(row, "id"));
+            if (person != null) {
+                flow.probeDeathBeforeInsurance(person);
+            }
+            InsuranceProfileResult profile = insuranceClient.fetch(idNumber, DmsRowUtils.str(row, "c_name"));
+            if (profile == null || !profile.isConfigured()) {
+                log.info("insurance-enrich: skip remaining {} ({})",
+                        pending.size() - filled,
+                        profile == null ? "无结果" : profile.getMessage());
+                return;
+            }
+            applyProfile(row, profile);
+            filled++;
+        }
+        log.info("insurance-enrich: filled={}", filled);
+    }
+
+    private void applyProfile(Map<String, Object> row, InsuranceProfileResult profile) {
+        Map<String, Object> patch = new LinkedHashMap<String, Object>();
+        putIfBlank(patch, row, "c_phone_number", profile.getPhoneNumber());
+        putIfBlank(patch, row, "c_address", profile.getAddress());
+        putIfBlank(patch, row, "c_household_address", profile.getHouseholdAddress());
+        putIfBlank(patch, row, "c_household_type", profile.getHouseholdType());
+        putIfBlank(patch, row, "c_social_insurance_code", profile.getSocialInsuranceCode());
+        putIfBlank(patch, row, "c_bank_card_number", profile.getBankCardNumber());
+        putIfBlank(patch, row, "c_bank_name", profile.getBankName());
+        putIfBlank(patch, row, "c_bank_type", profile.getBankType());
+        putIfBlank(patch, row, "c_local_or_remote", profile.getLocalOrRemote());
+        if (profile.getMatched() != null) {
+            patch.put("c_insurance_match_result", Boolean.TRUE.equals(profile.getMatched()) ? "SUCCESS" : "FAIL");
+            if (StringUtils.hasText(profile.getMatchReason())) {
+                patch.put("c_insurance_status", profile.getMatchReason().trim());
+            }
+        } else if (!profile.isFound() && StringUtils.hasText(profile.getMessage())) {
+            patch.put("c_insurance_status", profile.getMessage().trim());
+        }
+        String remarks = DmsRowUtils.upsertRemarkTag(DmsRowUtils.str(row, "c_remarks"), PROFILE_SYNC, SYNC_DONE);
+        patch.put("c_remarks", remarks);
+        SjnmtybtProperties.Ref ref = props.getDms().getColumns().getPersonnel();
+        String dmsId = DmsRowUtils.str(row, "id");
+        dms.updateContent(ref.getColumnId(), ref.getModelId(), dmsId, patch);
+        String bizId = DmsRowUtils.personnelBizId(row);
+        data.patchPersonnelCache(StringUtils.hasText(bizId) ? bizId : dmsId, patch, row);
+    }
+
+    private static void putIfBlank(Map<String, Object> patch, Map<String, Object> row, String key, String value) {
+        if (!StringUtils.hasText(value) || StringUtils.hasText(DmsRowUtils.str(row, key))) {
+            return;
+        }
+        patch.put(key, value.trim());
+    }
+
+    private String regionId(RegionLevelEnum level, String name, String parentId) {
+        if (!StringUtils.hasText(name)) {
+            return null;
+        }
+        String expect = name.trim();
+        List<RegionVO> regions = data.listRegions(level, parentId);
+        if (regions == null) {
+            return null;
+        }
+        for (RegionVO region : regions) {
+            if (region != null && expect.equals(region.getName())) {
+                return region.getId();
+            }
+        }
+        return null;
+    }
+
+    private static String firstText(String a, String b) {
+        if (StringUtils.hasText(a)) {
+            return a.trim();
+        }
+        return StringUtils.hasText(b) ? b.trim() : null;
+    }
+
+    private static String trim(String s) {
+        return s == null ? "" : s.trim();
+    }
+}

+ 367 - 2
src/main/java/com/yykj/sjnmtybt/service/PersonnelImportExportService.java

@@ -15,14 +15,22 @@ import com.yykj.sjnmtybt.dto.response.RegionVO;
 import com.yykj.sjnmtybt.enums.InsuranceMatchResultEnum;
 import com.yykj.sjnmtybt.enums.PersonnelStatusEnum;
 import com.yykj.sjnmtybt.enums.RegionLevelEnum;
+import com.yykj.sjnmtybt.enums.RetireQualifyTypeEnum;
+import org.apache.poi.ss.usermodel.BorderStyle;
 import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
 import org.apache.poi.ss.usermodel.DataValidation;
 import org.apache.poi.ss.usermodel.DataValidationConstraint;
 import org.apache.poi.ss.usermodel.DataValidationHelper;
+import org.apache.poi.ss.usermodel.Font;
+import org.apache.poi.ss.usermodel.HorizontalAlignment;
+import org.apache.poi.ss.usermodel.PrintSetup;
 import org.apache.poi.ss.usermodel.Row;
 import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.VerticalAlignment;
 import org.apache.poi.ss.usermodel.Workbook;
 import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.apache.poi.ss.util.CellRangeAddress;
 import org.apache.poi.ss.util.CellRangeAddressList;
 import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.springframework.stereotype.Service;
@@ -36,6 +44,7 @@ import java.io.InputStreamReader;
 import java.math.BigDecimal;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -63,7 +72,7 @@ public class PersonnelImportExportService {
             "姓名", "身份证号", "手机号", "街镇", "村居", "社保登记码",
             "银行卡号", "开户行", "收款人", "享受起始年月", "人员状态",
             "继承人姓名", "继承人身份证", "继承人关系", "继承人银行卡", "继承人电话",
-            "死亡日期", "备注"
+            "死亡日期", "备注", "联系地址", "户籍地址"
     };
 
     private static final String[] STATUS_LABELS = {
@@ -264,11 +273,293 @@ public class PersonnelImportExportService {
                     .append(csv(p.getHeirBankCard())).append(',')
                     .append(csv(p.getHeirPhone())).append(',')
                     .append(csv(p.getDeathDate())).append(',')
-                    .append(csv(stripSeed(p.getRemarks()))).append('\n');
+                    .append(csv(stripSeed(p.getRemarks()))).append(',')
+                    .append(csv(p.getAddress())).append(',')
+                    .append(csv(p.getHouseholdAddress())).append('\n');
         }
         return withBom(sb.toString());
     }
 
+    /** 村合作社人员列表:列与页面一致,按当前筛选导出全量。 */
+    public byte[] exportRosterBytes(PersonnelQueryRequest req) {
+        List<PersonnelVO> list = loadExportList(req);
+        Map<String, String> townNames = loadTownNameMap();
+        Map<String, String> villageNames = loadVillageNameMap(null);
+        boolean omitBank = req != null && Boolean.TRUE.equals(req.getOmitBank());
+        String[] headers = omitBank
+                ? new String[]{
+                "姓名", "身份证号", "手机号", "街镇", "村居", "户籍性质",
+                "月补标准", "业务状态", "居保匹配", "退养资格", "来源"
+        }
+                : new String[]{
+                "姓名", "身份证号", "手机号", "街镇", "村居", "户籍性质", "银行卡号", "开户行",
+                "月补标准", "业务状态", "居保匹配", "退养资格", "来源"
+        };
+        StringBuilder sb = new StringBuilder();
+        sb.append(String.join(",", headers)).append('\n');
+        for (PersonnelVO p : list) {
+            String village = StringUtils.hasText(p.getVillageName())
+                    ? p.getVillageName() : villageNames.get(p.getVillageId());
+            if (!StringUtils.hasText(village)) village = p.getVillageId();
+            sb.append(csv(p.getName())).append(',')
+                    .append(csv(p.getIdNumber())).append(',')
+                    .append(csv(p.getPhoneNumber())).append(',')
+                    .append(csv(resolveTownName(p, townNames))).append(',')
+                    .append(csv(village)).append(',')
+                    .append(csv(p.getHouseholdType())).append(',');
+            if (!omitBank) {
+                sb.append(csv(p.getBankCardNumber())).append(',')
+                        .append(csv(p.getBankName())).append(',');
+            }
+            sb.append(csv(p.getMonthlyStandard() == null ? "" : p.getMonthlyStandard().stripTrailingZeros().toPlainString())).append(',')
+                    .append(csv(statusLabel(p.getBizStatus()))).append(',')
+                    .append(csv(insuranceLabel(p.getInsuranceMatchResult()))).append(',')
+                    .append(csv(qualifyLabel(p.getRetireQualifyType()))).append(',')
+                    .append(csv(p.getSource())).append('\n');
+        }
+        return withBom(sb.toString());
+    }
+
+    public byte[] exportLedgerBytes(PersonnelQueryRequest req) {
+        List<PersonnelVO> list = loadExportList(req);
+        Map<String, String> townNames = loadTownNameMap();
+        Map<String, String> villageNames = loadVillageNameMap(null);
+        boolean omitBank = req != null && Boolean.TRUE.equals(req.getOmitBank());
+        String[] headers = omitBank
+                ? new String[]{
+                "姓名", "身份证号", "街镇", "村居", "户籍性质",
+                "社保卡号", "同城/异地", "状态", "退养资格", "备注"
+        }
+                : new String[]{
+                "姓名", "身份证号", "街镇", "村居", "户籍性质", "银行卡号", "开户行",
+                "社保卡号", "同城/异地", "状态", "退养资格", "备注"
+        };
+        StringBuilder sb = new StringBuilder();
+        sb.append(String.join(",", headers)).append('\n');
+        for (PersonnelVO p : list) {
+            sb.append(csv(p.getName())).append(',')
+                    .append(csv(p.getIdNumber())).append(',')
+                    .append(csv(resolveTownName(p, townNames))).append(',')
+                    .append(csv(resolveVillageName(p, townNames, villageNames))).append(',')
+                    .append(csv(p.getHouseholdType())).append(',');
+            if (!omitBank) {
+                sb.append(csv(p.getBankCardNumber())).append(',')
+                        .append(csv(p.getBankName())).append(',');
+            }
+            sb.append(csv(p.getSocialInsuranceCode())).append(',')
+                    .append(csv(p.getLocalOrRemote())).append(',')
+                    .append(csv(statusLabel(p.getBizStatus()))).append(',')
+                    .append(csv(qualifyLabel(p.getRetireQualifyType()))).append(',')
+                    .append("").append('\n');
+        }
+        return withBom(sb.toString());
+    }
+
+    public byte[] exportNewMonthBytes(PersonnelQueryRequest req) {
+        if (req == null) {
+            req = new PersonnelQueryRequest();
+        }
+            if (!StringUtils.hasText(req.getBizStatusIn())) {
+            req.setBizStatusIn("NEW,PENDING_TOWN_APPROVE");
+        }
+        if (!StringUtils.hasText(req.getEnjoyStartMonth()) && StringUtils.hasText(req.getPayMonth())) {
+            req.setEnjoyStartMonth(req.getPayMonth());
+        }
+        List<PersonnelVO> list = loadExportList(req);
+        Map<String, String> townNames = loadTownNameMap();
+        Map<String, String> villageNames = loadVillageNameMap(null);
+        list.sort(Comparator
+                .comparing((PersonnelVO p) -> nz(p.getVillageName()), Comparator.naturalOrder())
+                .thenComparing(p -> nz(p.getName()), Comparator.naturalOrder()));
+
+        String townName = resolveExportTownName(req, list, townNames);
+        String payMonth = StringUtils.hasText(req.getPayMonth())
+                ? req.getPayMonth().replace("-", "") : "";
+        if (payMonth.length() < 6) {
+            payMonth = LocalDate.now().toString().replace("-", "").substring(0, 6);
+        }
+        String year = payMonth.substring(0, 4);
+        int monthNum = Integer.parseInt(payMonth.substring(4, 6));
+
+        try {
+            XSSFWorkbook wb = new XSSFWorkbook();
+            Sheet sheet = wb.createSheet("新增名单");
+            sheet.setColumnWidth(0, 8 * 256);
+            sheet.setColumnWidth(1, 12 * 256);
+            sheet.setColumnWidth(2, 24 * 256);
+            sheet.setColumnWidth(3, 14 * 256);
+            sheet.setColumnWidth(4, 18 * 256);
+            sheet.setColumnWidth(5, 14 * 256);
+            sheet.setColumnWidth(6, 12 * 256);
+            sheet.setColumnWidth(7, 14 * 256);
+            PrintSetup print = sheet.getPrintSetup();
+            print.setPaperSize(PrintSetup.A4_PAPERSIZE);
+            print.setLandscape(false);
+            print.setFitWidth((short) 1);
+            print.setFitHeight((short) 1);
+            sheet.setFitToPage(true);
+            sheet.setMargin(Sheet.LeftMargin, 0.6);
+            sheet.setMargin(Sheet.RightMargin, 0.6);
+            sheet.setMargin(Sheet.TopMargin, 0.6);
+            sheet.setMargin(Sheet.BottomMargin, 0.6);
+
+            Font song = wb.createFont();
+            song.setFontName("宋体");
+            song.setFontHeightInPoints((short) 11);
+
+            CellStyle titleStyle = wb.createCellStyle();
+            Font titleFont = wb.createFont();
+            titleFont.setFontName("宋体");
+            titleFont.setBold(true);
+            titleFont.setFontHeightInPoints((short) 16);
+            titleStyle.setFont(titleFont);
+            titleStyle.setAlignment(HorizontalAlignment.CENTER);
+            titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+
+            CellStyle metaStyle = wb.createCellStyle();
+            Font metaFont = wb.createFont();
+            metaFont.setFontName("宋体");
+            metaFont.setFontHeightInPoints((short) 12);
+            metaStyle.setFont(metaFont);
+            metaStyle.setAlignment(HorizontalAlignment.CENTER);
+            metaStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+
+            CellStyle metaRight = wb.createCellStyle();
+            metaRight.cloneStyleFrom(metaStyle);
+            metaRight.setAlignment(HorizontalAlignment.RIGHT);
+
+            CellStyle box = wb.createCellStyle();
+            box.setBorderTop(BorderStyle.THIN);
+            box.setBorderBottom(BorderStyle.THIN);
+            box.setBorderLeft(BorderStyle.THIN);
+            box.setBorderRight(BorderStyle.THIN);
+            box.setAlignment(HorizontalAlignment.CENTER);
+            box.setVerticalAlignment(VerticalAlignment.CENTER);
+            box.setWrapText(true);
+            box.setFont(song);
+
+            CellStyle boxLeft = wb.createCellStyle();
+            boxLeft.cloneStyleFrom(box);
+            boxLeft.setAlignment(HorizontalAlignment.LEFT);
+
+            Row titleRow = sheet.createRow(0);
+            titleRow.setHeightInPoints(24);
+            Cell titleCell = titleRow.createCell(0);
+            titleCell.setCellValue("松江区" + townName + "老年农民享受土地退养补助金新增名单");
+            titleCell.setCellStyle(titleStyle);
+            sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 7));
+
+            Row metaRow = sheet.createRow(1);
+            Cell metaLeft = metaRow.createCell(0);
+            metaLeft.setCellValue(year + "年" + monthNum + "月");
+            metaLeft.setCellStyle(metaStyle);
+            sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, 5));
+            Cell unit = metaRow.createCell(6);
+            unit.setCellValue("单位:元");
+            unit.setCellStyle(metaRight);
+            sheet.addMergedRegion(new CellRangeAddress(1, 1, 6, 7));
+
+            String[] headers = {
+                    "序号", "姓名", "身份证号码", "户籍性质(农/非)",
+                    "村集体经济合作社", "享受起始年月", "享受标准", "备注"
+            };
+            Row header = sheet.createRow(2);
+            header.setHeightInPoints(36);
+            for (int i = 0; i < headers.length; i++) {
+                Cell c = header.createCell(i);
+                c.setCellValue(headers[i]);
+                c.setCellStyle(box);
+            }
+
+            int dataStart = 3;
+            int blankPad = 20;
+            int totalRows = Math.max(list.size(), blankPad);
+            for (int i = 0; i < totalRows; i++) {
+                Row row = sheet.createRow(dataStart + i);
+                row.setHeightInPoints(20);
+                PersonnelVO p = i < list.size() ? list.get(i) : null;
+                String village = "";
+                String household = "";
+                String start = "";
+                String std = "";
+                if (p != null) {
+                    village = StringUtils.hasText(p.getVillageName())
+                            ? p.getVillageName() : nz(villageNames.get(p.getVillageId()));
+                    household = paperHousehold(p.getHouseholdType());
+                    start = paperStartMonth(p.getEnjoyStartMonth());
+                    std = paperStandard(p.getMonthlyStandard());
+                }
+                writeStyled(row, 0, p == null ? "" : String.valueOf(i + 1), box);
+                writeStyled(row, 1, p == null ? "" : nz(p.getName()), box);
+                writeStyled(row, 2, p == null ? "" : nz(p.getIdNumber()), box);
+                writeStyled(row, 3, household, box);
+                writeStyled(row, 4, village, box);
+                writeStyled(row, 5, start, box);
+                writeStyled(row, 6, std, box);
+                writeStyled(row, 7, "", boxLeft);
+            }
+
+            int stamp = dataStart + totalRows + 1;
+            Row s1 = sheet.createRow(stamp);
+            s1.setHeightInPoints(22);
+            writePlain(s1, 0, "经办单位(盖章):(镇经济发展服务中心)", song);
+            sheet.addMergedRegion(new CellRangeAddress(stamp, stamp, 0, 3));
+            writePlain(s1, 4, "审核单位(盖章):(镇社区事务受理服务中心)", song);
+            sheet.addMergedRegion(new CellRangeAddress(stamp, stamp, 4, 7));
+            Row gap = sheet.createRow(stamp + 1);
+            gap.setHeightInPoints(36);
+            Row s2 = sheet.createRow(stamp + 2);
+            s2.setHeightInPoints(22);
+            writePlain(s2, 0, "负责人签名:", song);
+            sheet.addMergedRegion(new CellRangeAddress(stamp + 2, stamp + 2, 0, 3));
+            writePlain(s2, 4, "负责人签名:", song);
+            sheet.addMergedRegion(new CellRangeAddress(stamp + 2, stamp + 2, 4, 7));
+            Row s3 = sheet.createRow(stamp + 3);
+            s3.setHeightInPoints(22);
+            writePlain(s3, 0, "日期:", song);
+            sheet.addMergedRegion(new CellRangeAddress(stamp + 3, stamp + 3, 0, 3));
+            writePlain(s3, 4, "日期:", song);
+            sheet.addMergedRegion(new CellRangeAddress(stamp + 3, stamp + 3, 4, 7));
+
+            ByteArrayOutputStream bos = new ByteArrayOutputStream();
+            wb.write(bos);
+            wb.close();
+            return bos.toByteArray();
+        } catch (Exception e) {
+            throw new BusinessException("导出新增名单失败:" + e.getMessage());
+        }
+    }
+
+    private List<PersonnelVO> loadExportList(PersonnelQueryRequest req) {
+        if (req.getPageNum() == null) req.setPageNum(1);
+        if (req.getPageSize() == null || req.getPageSize() < 500) req.setPageSize(20000);
+        PageResult<PersonnelVO> page = data.pagePersonnel(req);
+        return page.getList() == null ? new ArrayList<PersonnelVO>() : page.getList();
+    }
+
+    private String resolveTownName(PersonnelVO p, Map<String, String> townNames) {
+        if (p == null) return "";
+        if (StringUtils.hasText(p.getTownName())) return p.getTownName();
+        String n = townNames.get(p.getTownId());
+        return StringUtils.hasText(n) ? n : p.getTownId();
+    }
+
+    private String resolveVillageName(PersonnelVO p, Map<String, String> townNames, Map<String, String> villageNames) {
+        if (p == null) return "";
+        String villageName = StringUtils.hasText(p.getVillageName()) ? p.getVillageName() : villageNames.get(p.getVillageId());
+        if (!StringUtils.hasText(villageName)) villageName = p.getVillageId();
+        String townName = resolveTownName(p, townNames);
+        return StringUtils.hasText(townName) && StringUtils.hasText(villageName)
+                ? (townName + "/" + villageName) : villageName;
+    }
+
+    private static String qualifyLabel(RetireQualifyTypeEnum t) {
+        if (t == RetireQualifyTypeEnum.WITH_LAND) return "有土地退养";
+        if (t == RetireQualifyTypeEnum.WITHOUT_LAND) return "无土地有资格";
+        if (t == RetireQualifyTypeEnum.INELIGIBLE) return "无资格";
+        return "";
+    }
+
     public Map<String, Object> importFile(MultipartFile file, String defaultTownId, String defaultVillageId) {
         if (file == null || file.isEmpty()) {
             throw new BusinessException(400, "请上传导入文件");
@@ -719,6 +1010,57 @@ public class PersonnelImportExportService {
         return s == null ? "" : s;
     }
 
+    private String resolveExportTownName(PersonnelQueryRequest req, List<PersonnelVO> rows,
+                                        Map<String, String> townNames) {
+        if (req != null && StringUtils.hasText(req.getTownId())) {
+            String n = townNames.get(req.getTownId());
+            if (StringUtils.hasText(n)) return n;
+        }
+        if (rows != null) {
+            for (PersonnelVO p : rows) {
+                String n = resolveTownName(p, townNames);
+                if (StringUtils.hasText(n)) return n;
+            }
+        }
+        return "";
+    }
+
+    private static String paperHousehold(String v) {
+        if (!StringUtils.hasText(v)) return "";
+        if (v.contains("非")) return "非农";
+        if (v.contains("农")) return "农业";
+        return v.trim();
+    }
+
+    private static String paperStartMonth(String v) {
+        if (!StringUtils.hasText(v)) return "";
+        String s = v.replace("-", "");
+        if (s.length() >= 6) {
+            return s.substring(0, 4) + "年" + Integer.parseInt(s.substring(4, 6)) + "月";
+        }
+        return v;
+    }
+
+    private static String paperStandard(BigDecimal amt) {
+        if (amt == null) return "";
+        return amt.stripTrailingZeros().toPlainString();
+    }
+
+    private static void writeStyled(Row row, int col, String value, CellStyle style) {
+        Cell c = row.createCell(col);
+        c.setCellValue(value == null ? "" : value);
+        c.setCellStyle(style);
+    }
+
+    private static void writePlain(Row row, int col, String value, Font font) {
+        Cell c = row.createCell(col);
+        c.setCellValue(value == null ? "" : value);
+        CellStyle style = row.getSheet().getWorkbook().createCellStyle();
+        style.setFont(font);
+        style.setVerticalAlignment(VerticalAlignment.CENTER);
+        c.setCellStyle(style);
+    }
+
     /** 身份证(15/18 位,18 位含国标校验码);失败抛出具体原因 */
     static void validateIdNumber(String idNumber) {
         validateIdNumber(idNumber, "身份证号");
@@ -769,6 +1111,8 @@ public class PersonnelImportExportService {
         String heirPhone;
         String deathDate;
         String remarks;
+        String address;
+        String householdAddress;
 
         if (named) {
             name = col(cols, colMap, "姓名");
@@ -789,6 +1133,8 @@ public class PersonnelImportExportService {
             heirPhone = col(cols, colMap, "继承人电话");
             deathDate = col(cols, colMap, "死亡日期");
             remarks = col(cols, colMap, "备注");
+            address = firstCol(cols, colMap, "联系地址", "家庭地址");
+            householdAddress = firstCol(cols, colMap, "户籍地址");
         } else if (legacy && cols.size() >= 4 && !looksLikePhone(get(cols, 2))) {
             // 旧:0姓名 1身份证 2街镇ID 3村居ID 4社保 5银行卡 6开户行 7收款人 8起始 9备注
             name = get(cols, 0);
@@ -809,6 +1155,8 @@ public class PersonnelImportExportService {
             heirPhone = null;
             deathDate = null;
             remarks = get(cols, 9);
+            address = null;
+            householdAddress = null;
         } else if (cols.size() <= 16) {
             // 上一版 16 列:无人员状态、无继承人关系
             name = get(cols, 0);
@@ -829,6 +1177,8 @@ public class PersonnelImportExportService {
             heirPhone = get(cols, 13);
             deathDate = get(cols, 14);
             remarks = get(cols, 15);
+            address = null;
+            householdAddress = null;
         } else {
             name = get(cols, 0);
             idNumber = get(cols, 1);
@@ -848,6 +1198,8 @@ public class PersonnelImportExportService {
             heirPhone = get(cols, 15);
             deathDate = get(cols, 16);
             remarks = get(cols, 17);
+            address = get(cols, 18);
+            householdAddress = get(cols, 19);
         }
 
         if (!StringUtils.hasText(name)) throw new IllegalArgumentException("姓名不能为空");
@@ -901,9 +1253,14 @@ public class PersonnelImportExportService {
         req.setHeirBankCard(emptyToNull(heirBank));
         req.setHeirPhone(emptyToNull(heirPhone));
         req.setDeathDate(emptyToNull(deathDate));
+        req.setAddress(emptyToNull(address));
+        req.setHouseholdAddress(emptyToNull(householdAddress));
         req.setRemarks(emptyToNull(remarks));
         req.setSource("Excel导入");
         req.setOperatorRoleKey("admin");
+        if (existId == null && req.getRetireQualifyType() == null) {
+            req.setRetireQualifyType(RetireQualifyTypeEnum.WITH_LAND);
+        }
         // 导入更新:带上当前 update_time 以满足乐观锁(与打开详情编辑一致)
         if (existRow != null) {
             Long ut = DmsRowUtils.longVal(existRow, "update_time");
@@ -962,6 +1319,14 @@ public class PersonnelImportExportService {
         return false;
     }
 
+    private static String insuranceLabel(InsuranceMatchResultEnum r) {
+        if (r == null || r == InsuranceMatchResultEnum.PENDING) return "未匹配";
+        if (r == InsuranceMatchResultEnum.SUCCESS || r == InsuranceMatchResultEnum.MANUAL_PASS) return "匹配通过";
+        if (r == InsuranceMatchResultEnum.LOSE_ELIGIBILITY) return "丧失资格";
+        if (r == InsuranceMatchResultEnum.FAIL) return "匹配失败";
+        return r.name();
+    }
+
     private static String statusLabel(PersonnelStatusEnum st) {
         if (st == null) return "";
         switch (st) {

+ 192 - 0
src/main/java/com/yykj/sjnmtybt/service/TownStatAmounts.java

@@ -0,0 +1,192 @@
+package com.yykj.sjnmtybt.service;
+
+import com.yykj.sjnmtybt.dto.response.BatchStatVO;
+import com.yykj.sjnmtybt.dto.response.BatchVO;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 镇批次头和展板只认「街镇」粒度,并且月标合计没落库时不能把应发倒挤成扣减。
+ */
+public final class TownStatAmounts {
+
+    private TownStatAmounts() {
+    }
+
+    /** 镇统计批 TB202609-T_YEXIE 只做汇总,不能再挂一份发放明细。 */
+    public static boolean isTownStatBatchId(String batchId) {
+        if (batchId == null) {
+            return false;
+        }
+        return batchId.matches("TB\\d{6}-T_.+");
+    }
+
+    public static final class PayLine {
+        public String id;
+        public String townId;
+        public BigDecimal monthlyAmount;
+        public BigDecimal supplementAmount;
+        public BigDecimal funeralAmount;
+        public BigDecimal deductAmount;
+        public BigDecimal totalAmount;
+        public BigDecimal actualAmount;
+        public Boolean returnSuccess;
+    }
+
+    public static final class TownSum {
+        public String townId;
+        public int people;
+        public BigDecimal should = BigDecimal.ZERO;
+        public BigDecimal monthly = BigDecimal.ZERO;
+        public BigDecimal supplement = BigDecimal.ZERO;
+        public BigDecimal funeral = BigDecimal.ZERO;
+        public BigDecimal deduct = BigDecimal.ZERO;
+        public BigDecimal actual = BigDecimal.ZERO;
+        public int fail;
+    }
+
+    public static boolean isTownGrain(BatchStatVO stat) {
+        if (stat == null || text(stat.getVillageId())) {
+            return false;
+        }
+        String grain = stat.getGrain() == null ? "" : stat.getGrain().trim();
+        if ("街镇".equals(grain)) {
+            return true;
+        }
+        if (text(grain) && !"街镇".equals(grain)) {
+            return false;
+        }
+        String batchId = stat.getBatchId() == null ? "" : stat.getBatchId();
+        return batchId.startsWith("TB");
+    }
+
+    /**
+     * 回盘统计常常只写了应发、没写月标合计。这种行不能拿来覆盖批次头,
+     * 否则正常金额变成 0、扣减变成整笔应发。
+     */
+    public static boolean canApply(BatchStatVO stat) {
+        if (!isTownGrain(stat)) {
+            return false;
+        }
+        int people = stat.getShouldPayCount() == null ? 0 : stat.getShouldPayCount();
+        BigDecimal should = nz(stat.getShouldPayAmount());
+        if (people <= 0 && should.signum() <= 0) {
+            return false;
+        }
+        return nz(stat.getStandardAmount()).signum() > 0;
+    }
+
+    public static void apply(BatchVO batch, BatchStatVO stat) {
+        int people = stat.getShouldPayCount() == null ? 0 : stat.getShouldPayCount();
+        BigDecimal should = nz(stat.getShouldPayAmount());
+        BigDecimal normal = nz(stat.getStandardAmount());
+        BigDecimal supplement = nz(stat.getSupplementAmount());
+        BigDecimal funeral = nz(stat.getDeathAmount());
+        BigDecimal deduct = should.subtract(normal.add(supplement).add(funeral));
+        if (deduct.signum() < 0) {
+            deduct = BigDecimal.ZERO;
+        }
+        batch.setPeopleCount(people);
+        batch.setTotalAmount(should);
+        batch.setNormalAmount(normal);
+        batch.setSupplementAmount(supplement);
+        batch.setFuneralAmount(funeral);
+        batch.setDeductAmount(deduct);
+    }
+
+    /** 同一街镇只留一条。优先镇统计批号,其次人数更多的那条。 */
+    public static BatchStatVO pickTownStat(List<BatchStatVO> rows, String payMonth, String townId) {
+        if (rows == null || !text(payMonth) || !text(townId)) {
+            return null;
+        }
+        String ym = payMonth.replace("-", "").trim();
+        if (ym.length() > 6) {
+            ym = ym.substring(0, 6);
+        }
+        String preferId = "TB" + ym + "-" + townId;
+        BatchStatVO best = null;
+        for (BatchStatVO row : rows) {
+            if (!sameMonthTown(row, ym, townId) || !isTownGrain(row)) {
+                continue;
+            }
+            if (preferId.equals(row.getBatchId())) {
+                return row;
+            }
+            if (best == null || peopleOf(row) > peopleOf(best)) {
+                best = row;
+            }
+        }
+        return best;
+    }
+
+    public static List<TownSum> rollup(List<PayLine> lines, Map<String, Boolean> townReturned) {
+        Map<String, TownSum> byTown = new LinkedHashMap<String, TownSum>();
+        Set<String> seen = new HashSet<String>();
+        if (lines == null) {
+            return new ArrayList<TownSum>();
+        }
+        for (PayLine line : lines) {
+            if (line == null || !text(line.townId)) {
+                continue;
+            }
+            if (text(line.id) && !seen.add(line.id)) {
+                continue;
+            }
+            TownSum sum = byTown.get(line.townId);
+            if (sum == null) {
+                sum = new TownSum();
+                sum.townId = line.townId;
+                byTown.put(line.townId, sum);
+            }
+            BigDecimal should = line.totalAmount != null ? nz(line.totalAmount) : lineShould(line);
+            sum.people++;
+            sum.should = sum.should.add(should);
+            sum.monthly = sum.monthly.add(nz(line.monthlyAmount));
+            sum.supplement = sum.supplement.add(nz(line.supplementAmount));
+            sum.funeral = sum.funeral.add(nz(line.funeralAmount));
+            sum.deduct = sum.deduct.add(nz(line.deductAmount));
+            boolean returned = townReturned != null && Boolean.TRUE.equals(townReturned.get(line.townId));
+            if (Boolean.FALSE.equals(line.returnSuccess)) {
+                sum.fail++;
+            } else if (Boolean.TRUE.equals(line.returnSuccess) || (line.returnSuccess == null && returned)) {
+                BigDecimal actual = line.actualAmount != null ? nz(line.actualAmount) : should;
+                sum.actual = sum.actual.add(actual);
+            }
+        }
+        return new ArrayList<TownSum>(byTown.values());
+    }
+
+    private static boolean sameMonthTown(BatchStatVO row, String ym, String townId) {
+        if (row == null || !townId.equals(row.getTownId())) {
+            return false;
+        }
+        String month = row.getPayMonth() == null ? "" : row.getPayMonth().replace("-", "").trim();
+        if (month.length() > 6) {
+            month = month.substring(0, 6);
+        }
+        return ym.equals(month);
+    }
+
+    private static int peopleOf(BatchStatVO row) {
+        return row.getShouldPayCount() == null ? 0 : row.getShouldPayCount();
+    }
+
+    private static BigDecimal lineShould(PayLine line) {
+        return nz(line.monthlyAmount).add(nz(line.supplementAmount)).add(nz(line.funeralAmount))
+                .subtract(nz(line.deductAmount));
+    }
+
+    private static BigDecimal nz(BigDecimal value) {
+        return value == null ? BigDecimal.ZERO : value;
+    }
+
+    private static boolean text(String value) {
+        return value != null && !value.trim().isEmpty();
+    }
+}

+ 14 - 0
src/main/resources/application.yml

@@ -47,7 +47,21 @@ sjnmtybt:
       payment-detail: { column-id: 1849, model-id: 1974 }
   # 后端调 DMS 的服务账号(与前端用户 Token 分离)
   schedule:
+    # 每月 15 日 01:00 生成全区月批(cron 见 DistrictMonthBatchScheduler)
     district-month-batch: true
+    # 每月 5 日 02:00 土地接口「老年农民退包」;每日 02:30 次日居保补全。url 为空时不请求
+    land-change-pull: true
+    insurance-profile-enrich: true
+  external:
+    land:
+      enabled: true
+      url: ""
+    death:
+      enabled: true
+      url: ""
+    insurance:
+      enabled: true
+      url: ""
   oauth:
     enabled: true
     login-url: http://121.43.55.7:2101/proxy_oauth/user/login

+ 104 - 0
src/test/java/com/yykj/sjnmtybt/service/TownStatAmountsTest.java

@@ -0,0 +1,104 @@
+package com.yykj.sjnmtybt.service;
+
+import com.yykj.sjnmtybt.dto.response.BatchStatVO;
+import com.yykj.sjnmtybt.dto.response.BatchVO;
+import com.yykj.sjnmtybt.service.TownStatAmounts.PayLine;
+import com.yykj.sjnmtybt.service.TownStatAmounts.TownSum;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TownStatAmountsTest {
+
+    @Test
+    void townStatBatchIsNotASecondDisbursement() {
+        assertTrue(TownStatAmounts.isTownStatBatchId("TB202609-T_YEXIE"));
+        assertFalse(TownStatAmounts.isTownStatBatchId("DB202609"));
+    }
+
+    @Test
+    void missingStandardCannotBecomeDeduct() {
+        BatchStatVO stat = town("T_YEXIE", "TB202608-T_YEXIE", 9882, "13720412");
+        stat.setStandardAmount(BigDecimal.ZERO);
+        assertTrue(TownStatAmounts.isTownGrain(stat));
+        assertFalse(TownStatAmounts.canApply(stat));
+    }
+
+    @Test
+    void villageStatIsNotTownHeader() {
+        BatchStatVO stat = town("T_YEXIE", "VS202609-T_YEXIE-V_YX_002", 1366, "1447960");
+        stat.setPayMonth("202609");
+        stat.setGrain("村居");
+        stat.setVillageId("V_YX_002");
+        stat.setStandardAmount(new BigDecimal("1447960"));
+        assertFalse(TownStatAmounts.canApply(stat));
+        assertEquals(null, TownStatAmounts.pickTownStat(Collections.singletonList(stat), "202609", "T_YEXIE"));
+    }
+
+    @Test
+    void pickTownBatchInsteadOfSmallerRow() {
+        BatchStatVO villageLike = town("T_XINBANG", "VS202609-T_XINBANG-V1", 906, "960360");
+        villageLike.setGrain("街镇");
+        villageLike.setPayMonth("202609");
+        BatchStatVO townRow = town("T_XINBANG", "TB202609-T_XINBANG", 7940, "8416400");
+        townRow.setPayMonth("202609");
+        townRow.setStandardAmount(new BigDecimal("8416400"));
+        BatchStatVO picked = TownStatAmounts.pickTownStat(
+                Arrays.asList(villageLike, townRow), "2026-09", "T_XINBANG");
+        assertEquals("TB202609-T_XINBANG", picked.getBatchId());
+        assertTrue(TownStatAmounts.canApply(picked));
+        BatchVO batch = new BatchVO();
+        TownStatAmounts.apply(batch, picked);
+        assertEquals(0, BigDecimal.ZERO.compareTo(batch.getDeductAmount()));
+        assertEquals(0, new BigDecimal("8416400").compareTo(batch.getNormalAmount()));
+    }
+
+    @Test
+    void rollupUsesDetailLinesAndOnlyReturnedTownHasActual() {
+        PayLine a = line("p1", "T_YEXIE", "1060");
+        PayLine dup = line("p1", "T_YEXIE", "1060");
+        PayLine b = line("p2", "T_YEXIE", "1060");
+        PayLine c = line("p3", "T_XINBANG", "1060");
+        Map<String, Boolean> returned = new HashMap<String, Boolean>();
+        returned.put("T_YEXIE", true);
+        returned.put("T_XINBANG", false);
+        List<TownSum> sums = TownStatAmounts.rollup(Arrays.asList(a, dup, b, c), returned);
+        TownSum yexie = sums.get(0);
+        TownSum xinbang = sums.get(1);
+        assertEquals(2, yexie.people);
+        assertEquals(0, new BigDecimal("2120").compareTo(yexie.should));
+        assertEquals(0, new BigDecimal("2120").compareTo(yexie.actual));
+        assertEquals(1, xinbang.people);
+        assertEquals(0, BigDecimal.ZERO.compareTo(xinbang.actual));
+        assertEquals(0, new BigDecimal("1060").compareTo(xinbang.monthly));
+    }
+
+    private static BatchStatVO town(String townId, String batchId, int people, String amount) {
+        BatchStatVO stat = new BatchStatVO();
+        stat.setTownId(townId);
+        stat.setBatchId(batchId);
+        stat.setGrain("街镇");
+        stat.setPayMonth("202608");
+        stat.setShouldPayCount(people);
+        stat.setShouldPayAmount(new BigDecimal(amount));
+        return stat;
+    }
+
+    private static PayLine line(String id, String townId, String monthly) {
+        PayLine line = new PayLine();
+        line.id = id;
+        line.townId = townId;
+        line.monthlyAmount = new BigDecimal(monthly);
+        line.totalAmount = new BigDecimal(monthly);
+        return line;
+    }
+}

Some files were not shown because too many files changed in this diff