/** * 只读核对线上金额与生命周期断点。不调用会回写统计的接口。 * 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) })