audit-amount-lifecycle-20261009.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. /**
  2. * 只读核对线上金额与生命周期断点。不调用会回写统计的接口。
  3. * node scripts/audit-amount-lifecycle-20261009.js
  4. */
  5. const http = require('http')
  6. const fs = require('fs')
  7. const path = require('path')
  8. const HOST = '121.43.55.7'
  9. const PORT = 11091
  10. const MONTHS = ['202607', '202608', '202609']
  11. function req(method, apiPath, body) {
  12. return new Promise((resolve) => {
  13. const data = body == null ? null : JSON.stringify(body)
  14. const headers = { Accept: 'application/json' }
  15. if (data) {
  16. headers['Content-Type'] = 'application/json'
  17. headers['Content-Length'] = Buffer.byteLength(data)
  18. }
  19. const r = http.request(
  20. { hostname: HOST, port: PORT, path: '/sjnmtybt' + apiPath, method, headers, timeout: 120000 },
  21. (res) => {
  22. let raw = ''
  23. res.on('data', (c) => (raw += c))
  24. res.on('end', () => {
  25. try {
  26. resolve(JSON.parse(raw))
  27. } catch (e) {
  28. resolve({ code: res.statusCode, parseError: true, raw: String(raw).slice(0, 180) })
  29. }
  30. })
  31. },
  32. )
  33. r.on('error', (e) => resolve({ code: 0, error: e.message }))
  34. r.on('timeout', () => {
  35. r.destroy()
  36. resolve({ code: 0, error: 'timeout' })
  37. })
  38. if (data) r.write(data)
  39. r.end()
  40. })
  41. }
  42. const n = (v) => {
  43. const x = Number(v || 0)
  44. return Number.isFinite(x) ? x : 0
  45. }
  46. const round2 = (v) => Math.round(n(v) * 100) / 100
  47. const near = (a, b, tol = 0.05) => Math.abs(round2(a) - round2(b)) <= tol
  48. async function pageAll(apiPath, body, pageSize) {
  49. const all = []
  50. let total = 0
  51. let pageNum = 1
  52. for (;;) {
  53. const res = await req('POST', apiPath, { ...body, pageNum, pageSize })
  54. if (res.code !== 200) return { error: res.message || res.error || res.raw, list: all, total }
  55. const list = res.data?.list || []
  56. total = Number(res.data?.total || 0)
  57. all.push(...list)
  58. if (!list.length || all.length >= total || pageNum > 80) break
  59. pageNum += 1
  60. }
  61. return { list: all, total, pages: pageNum }
  62. }
  63. function linePayable(d) {
  64. return n(d.monthlyAmount) + n(d.oneTimeAmount) + n(d.supplementAmount) + n(d.funeralAmount) - n(d.deductAmount)
  65. }
  66. ;(async () => {
  67. const report = { at: new Date().toISOString(), host: HOST + ':' + PORT, findings: [] }
  68. const add = (level, title, detail) => {
  69. report.findings.push({ level, title, detail })
  70. console.log(level + ' | ' + title + (detail ? ' | ' + JSON.stringify(detail).slice(0, 400) : ''))
  71. }
  72. const regions = await req('GET', '/api/regions?level=TOWN&parentId=D_SJ')
  73. const towns = (regions.data || []).filter((t) => t && t.enabled !== false)
  74. report.towns = towns.map((t) => ({
  75. id: t.id,
  76. name: t.name,
  77. districtSharePercent: t.districtSharePercent,
  78. townSharePercent: t.townSharePercent,
  79. }))
  80. const townName = Object.fromEntries(towns.map((t) => [t.id, t.name]))
  81. const shareOf = Object.fromEntries(towns.map((t) => [t.id, t]))
  82. for (const t of towns) {
  83. const d = n(t.districtSharePercent)
  84. const w = n(t.townSharePercent)
  85. if (!near(d + w, 100, 0.01)) {
  86. add('BUG', '街镇分担比例合计不是 100', { town: t.name, district: d, townShare: w })
  87. }
  88. }
  89. const villagesByTown = {}
  90. for (const t of towns) {
  91. const vs = await req('GET', '/api/regions?level=VILLAGE&parentId=' + encodeURIComponent(t.id))
  92. villagesByTown[t.id] = (vs.data || []).filter((v) => v && v.enabled !== false)
  93. }
  94. report.villageCounts = Object.fromEntries(
  95. towns.map((t) => [t.name, villagesByTown[t.id].length]),
  96. )
  97. const stdDistrict = await req(
  98. 'GET',
  99. '/api/amount-standards/current?townId=T_DISTRICT&standardType=MONTHLY',
  100. )
  101. const districtMonthly = n(stdDistrict.data?.amount)
  102. const districtFuneral = n(stdDistrict.data?.funeralAmount)
  103. report.districtStandard = {
  104. monthly: districtMonthly,
  105. funeral: districtFuneral,
  106. id: stdDistrict.data?.id,
  107. raw: stdDistrict.code === 200 ? undefined : stdDistrict,
  108. }
  109. if (!(districtMonthly > 0)) add('BUG', '全区现行月标不是正数', report.districtStandard)
  110. for (const t of towns) {
  111. const s = await req(
  112. 'GET',
  113. '/api/amount-standards/current?townId=' + encodeURIComponent(t.id) + '&standardType=MONTHLY',
  114. )
  115. const amt = n(s.data?.amount)
  116. if (amt && !near(amt, districtMonthly, 0.01)) {
  117. add('BUG', '街镇当前月标与全区标准不一致', { town: t.name, townAmount: amt, district: districtMonthly })
  118. }
  119. }
  120. const statuses = [
  121. 'DRAFT',
  122. 'PENDING_TOWN_APPROVE',
  123. 'NEW',
  124. 'PENDING_INSURANCE_MATCH',
  125. 'NORMAL',
  126. 'PAUSED',
  127. 'STOPPED',
  128. 'FUNERAL',
  129. 'EXITED',
  130. 'ABNORMAL',
  131. 'ARCHIVED',
  132. ]
  133. report.personnelTotals = {}
  134. for (const st of statuses) {
  135. const p = await req('POST', '/api/personnel/page', { pageNum: 1, pageSize: 1, bizStatus: st })
  136. report.personnelTotals[st] = p.code === 200 ? Number(p.data?.total || 0) : p.message || p.error
  137. }
  138. add('INFO', '人员状态底数', report.personnelTotals)
  139. const pendingSpecial = await req(
  140. 'GET',
  141. '/api/special-biz/page?approveStatus=' + encodeURIComponent('待分管领导审批') + '&pageNum=1&pageSize=200',
  142. )
  143. const pendingList = pendingSpecial.data?.list || []
  144. report.pendingSpecial = {
  145. total: Number(pendingSpecial.data?.total || pendingList.length),
  146. byTown: {},
  147. byType: {},
  148. }
  149. for (const s of pendingList) {
  150. const tn = townName[s.townId] || s.townId || '未知'
  151. report.pendingSpecial.byTown[tn] = (report.pendingSpecial.byTown[tn] || 0) + 1
  152. const tp = s.bizType || '未知'
  153. report.pendingSpecial.byType[tp] = (report.pendingSpecial.byType[tp] || 0) + 1
  154. }
  155. report.months = {}
  156. for (const ym of MONTHS) {
  157. const batchRes = await req('POST', '/api/batches/page', { pageNum: 1, pageSize: 100, payMonth: ym })
  158. const batches = batchRes.data?.list || []
  159. const district = batches.filter((b) => b.batchLevel === 'DISTRICT')
  160. const townBatches = batches.filter((b) => b.batchLevel === 'TOWN')
  161. const other = batches.filter((b) => b.batchLevel !== 'DISTRICT' && b.batchLevel !== 'TOWN')
  162. const month = {
  163. batchQueryTotal: Number(batchRes.data?.total || batches.length),
  164. loaded: batches.length,
  165. district: district.map((b) => ({
  166. id: b.id,
  167. batchNo: b.batchNo,
  168. status: b.status,
  169. peopleCount: b.peopleCount,
  170. totalAmount: n(b.totalAmount),
  171. normalAmount: n(b.normalAmount),
  172. supplementAmount: n(b.supplementAmount),
  173. funeralAmount: n(b.funeralAmount),
  174. deductAmount: n(b.deductAmount),
  175. exportStatus: b.exportStatus,
  176. returnStatus: b.returnStatus,
  177. })),
  178. townBatches: townBatches.map((b) => ({
  179. id: b.id,
  180. batchNo: b.batchNo,
  181. townId: b.townId,
  182. town: townName[b.townId] || b.townId,
  183. status: b.status,
  184. parentBatchId: b.parentBatchId,
  185. peopleCount: n(b.peopleCount),
  186. totalAmount: n(b.totalAmount),
  187. })),
  188. otherLevels: other.map((b) => ({
  189. batchNo: b.batchNo,
  190. level: b.batchLevel,
  191. townId: b.townId,
  192. villageId: b.villageId,
  193. status: b.status,
  194. payMonth: b.payMonth,
  195. peopleCount: b.peopleCount,
  196. totalAmount: n(b.totalAmount),
  197. })),
  198. }
  199. if (!district.length) add('BUG', ym + ' 没有全区月批', { total: month.batchQueryTotal })
  200. if (district.length > 1) add('BUG', ym + ' 全区月批多于 1 条', { n: district.length, nos: district.map((b) => b.batchNo) })
  201. const townIds = new Set(townBatches.map((b) => b.townId))
  202. for (const t of towns) {
  203. if (district.length && !townIds.has(t.id)) {
  204. add('BUG', ym + ' 缺少镇统计批', { town: t.name })
  205. }
  206. }
  207. if (district[0]) {
  208. const db = district[0]
  209. const details = await pageAll('/api/payments/details/page', { batchId: db.id }, 400)
  210. month.detailError = details.error
  211. month.detailTotal = details.total
  212. month.detailLoaded = details.list.length
  213. const rows = details.list.filter((d) => d && d.changeFlag !== 'REMOVE')
  214. const seen = new Map()
  215. let formulaBad = 0
  216. let standardBad = 0
  217. let shareBad = 0
  218. let neg = 0
  219. const examples = []
  220. const byTown = {}
  221. const byVillage = {}
  222. let sum = { monthly: 0, oneTime: 0, supplement: 0, funeral: 0, deduct: 0, payable: 0, actual: 0, fail: 0 }
  223. for (const d of rows) {
  224. const pid = d.personnelId || d.idNumber || d.id
  225. if (seen.has(pid)) {
  226. if (examples.length < 12) examples.push({ kind: '重复人员', name: d.name, town: townName[d.townId] || d.townId })
  227. } else seen.set(pid, 1)
  228. const expect = linePayable(d)
  229. const got = n(d.totalAmount)
  230. if (!near(expect, got)) {
  231. formulaBad += 1
  232. if (examples.length < 12) {
  233. examples.push({
  234. kind: '应发不等于分项',
  235. name: d.name,
  236. town: townName[d.townId] || d.townId,
  237. expect: round2(expect),
  238. got: round2(got),
  239. })
  240. }
  241. }
  242. if (n(d.monthlyAmount) < 0 || n(d.supplementAmount) < 0 || n(d.funeralAmount) < 0 || n(d.deductAmount) < 0 || got < 0) {
  243. neg += 1
  244. }
  245. const monthly = n(d.monthlyAmount)
  246. const funeral = n(d.funeralAmount)
  247. const supplement = n(d.supplementAmount)
  248. if (districtMonthly > 0 && monthly > 0 && !near(monthly, districtMonthly) && funeral === 0 && supplement === 0) {
  249. standardBad += 1
  250. if (examples.length < 12) {
  251. examples.push({
  252. kind: '月标偏离全区标准',
  253. name: d.name,
  254. town: townName[d.townId] || d.townId,
  255. monthly: round2(monthly),
  256. standard: districtMonthly,
  257. })
  258. }
  259. }
  260. const share = shareOf[d.townId]
  261. const total = got
  262. if (share && total > 0 && (d.districtAmount != null || d.townAmount != null)) {
  263. const ds = n(d.districtAmount)
  264. const ts = n(d.townAmount)
  265. if (!near(ds + ts, total)) {
  266. shareBad += 1
  267. } else {
  268. const expectD = total * n(share.districtSharePercent) / 100
  269. if (!near(ds, expectD, 0.2)) shareBad += 1
  270. }
  271. }
  272. const keyT = d.townId || '未知'
  273. const keyV = (d.townId || '') + '|' + (d.villageId || '未知')
  274. if (!byTown[keyT]) byTown[keyT] = { count: 0, payable: 0, monthly: 0, supplement: 0, funeral: 0, deduct: 0, actual: 0, fail: 0 }
  275. if (!byVillage[keyV]) byVillage[keyV] = { townId: d.townId, villageId: d.villageId, count: 0, payable: 0 }
  276. byTown[keyT].count += 1
  277. byTown[keyT].payable += got
  278. byTown[keyT].monthly += monthly
  279. byTown[keyT].supplement += supplement
  280. byTown[keyT].funeral += funeral
  281. byTown[keyT].deduct += n(d.deductAmount)
  282. byVillage[keyV].count += 1
  283. byVillage[keyV].payable += got
  284. sum.monthly += monthly
  285. sum.oneTime += n(d.oneTimeAmount)
  286. sum.supplement += supplement
  287. sum.funeral += funeral
  288. sum.deduct += n(d.deductAmount)
  289. sum.payable += got
  290. if (d.returnSuccess === false) {
  291. sum.fail += 1
  292. byTown[keyT].fail += 1
  293. }
  294. if (d.actualAmount != null) {
  295. sum.actual += n(d.actualAmount)
  296. byTown[keyT].actual += n(d.actualAmount)
  297. }
  298. }
  299. for (const k of Object.keys(sum)) sum[k] = round2(sum[k])
  300. month.sum = sum
  301. month.formulaBad = formulaBad
  302. month.standardBad = standardBad
  303. month.shareBad = shareBad
  304. month.negative = neg
  305. month.uniquePeople = seen.size
  306. month.examples = examples
  307. if (details.total && details.list.length < details.total) {
  308. add('BUG', ym + ' 发放明细没有拉全', { total: details.total, loaded: details.list.length, error: details.error })
  309. }
  310. if (!near(sum.payable, n(db.totalAmount), 1)) {
  311. add('BUG', ym + ' 全区批头应发与明细合计不一致', {
  312. header: round2(db.totalAmount),
  313. detail: sum.payable,
  314. headerPeople: db.peopleCount,
  315. detailPeople: rows.length,
  316. })
  317. } else {
  318. add('OK', ym + ' 全区批头应发与明细一致', { amount: sum.payable, people: rows.length })
  319. }
  320. if (Number(db.peopleCount || 0) !== rows.length) {
  321. add('BUG', ym + ' 全区批人数与明细条数不一致', { header: db.peopleCount, detail: rows.length })
  322. }
  323. if (formulaBad) add('BUG', ym + ' 有明细应发不等于分项之和', { formulaBad, examples })
  324. if (standardBad) add('BUG', ym + ' 有人月标偏离全区现行标准', { standardBad, districtMonthly, examples })
  325. if (shareBad) add('BUG', ym + ' 区镇分担金额与比例不一致', { shareBad })
  326. if (neg) add('BUG', ym + ' 存在负数金额', { neg })
  327. month.townCompare = []
  328. for (const tb of townBatches) {
  329. const slice = byTown[tb.townId] || { count: 0, payable: 0 }
  330. const row = {
  331. town: townName[tb.townId] || tb.townId,
  332. status: tb.status,
  333. headerPeople: n(tb.peopleCount),
  334. headerAmount: round2(tb.totalAmount),
  335. slicePeople: slice.count,
  336. sliceAmount: round2(slice.payable),
  337. parentOk: !tb.parentBatchId || tb.parentBatchId === db.id,
  338. }
  339. month.townCompare.push(row)
  340. if (!near(row.headerAmount, row.sliceAmount, 1) || row.headerPeople !== row.slicePeople) {
  341. add('BUG', ym + ' 镇统计与全区明细切片不一致', row)
  342. }
  343. if (!row.parentOk) add('BUG', ym + ' 镇统计未挂到全区父批', row)
  344. }
  345. const emptyVillages = []
  346. for (const t of towns) {
  347. for (const v of villagesByTown[t.id]) {
  348. const hit = byVillage[t.id + '|' + v.id]
  349. if (!hit || !hit.count) emptyVillages.push(t.name + '/' + v.name)
  350. }
  351. }
  352. month.emptyVillageCount = emptyVillages.length
  353. month.emptyVillageSample = emptyVillages.slice(0, 15)
  354. month.villageWithPay = Object.values(byVillage).filter((v) => v.count > 0).length
  355. const summary = await req('GET', '/api/dashboard/summary?payMonth=' + ym)
  356. const dashPeople = n(summary.data?.shouldPayCount ?? summary.data?.peopleCount)
  357. const dashAmount = n(summary.data?.shouldPayAmount ?? summary.data?.totalAmount)
  358. month.dashboard = {
  359. code: summary.code,
  360. keys: summary.data ? Object.keys(summary.data) : [],
  361. people: dashPeople,
  362. amount: round2(dashAmount),
  363. rawPeople: summary.data?.shouldPayCount,
  364. rawAmount: summary.data?.shouldPayAmount,
  365. totalAmount: summary.data?.totalAmount,
  366. }
  367. if (summary.code === 200 && (dashPeople || dashAmount)) {
  368. if (!near(dashAmount, sum.payable, 1) || dashPeople !== rows.length) {
  369. add('BUG', ym + ' 展板汇总额与明细不一致', {
  370. dashPeople,
  371. dashAmount: round2(dashAmount),
  372. detailPeople: rows.length,
  373. detailAmount: sum.payable,
  374. })
  375. }
  376. }
  377. }
  378. report.months[ym] = month
  379. }
  380. const memberIds = new Set()
  381. const aug = report.months['202608']
  382. if (aug && aug.detailLoaded) {
  383. // member ids not stored; re-page is expensive. Use unique count only.
  384. }
  385. const newPage = await pageAll('/api/personnel/page', { bizStatus: 'NEW' }, 200)
  386. let newShouldInAug = 0
  387. let newMissingStart = 0
  388. const newSamples = []
  389. for (const p of newPage.list || []) {
  390. const start = String(p.nextPayStartMonth || p.enjoyStartMonth || '').replace('-', '')
  391. if (!start) {
  392. newMissingStart += 1
  393. continue
  394. }
  395. if (start <= '202608') {
  396. newShouldInAug += 1
  397. if (newSamples.length < 8) {
  398. newSamples.push({ name: p.name, town: townName[p.townId] || p.townId, start, qualify: p.retireQualifyType })
  399. }
  400. }
  401. }
  402. report.newPeople = {
  403. total: newPage.total,
  404. loaded: (newPage.list || []).length,
  405. missingStart: newMissingStart,
  406. startOnOrBefore202608: newShouldInAug,
  407. sample: newSamples,
  408. error: newPage.error,
  409. }
  410. if (newShouldInAug && aug && aug.uniquePeople) {
  411. add('INFO', '资格已通过且起始月不晚于 202608 的新增人员', {
  412. count: newShouldInAug,
  413. note: '若 8 月批已生成后才通过,按规则不自动补进当月,需特殊业务加人',
  414. sample: newSamples,
  415. })
  416. }
  417. const out = path.join(__dirname, '..', '..', 'docs', '金额与生命周期核对-20261009.json')
  418. fs.writeFileSync(out, JSON.stringify(report, null, 2))
  419. console.log('WROTE ' + out + ' findings=' + report.findings.length)
  420. })().catch((e) => {
  421. console.error(e)
  422. process.exit(1)
  423. })