backfill-ssc-from-excel.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. # -*- coding: utf-8 -*-
  2. """从「已匹配200编号」明细回填人员底数社保卡号(c_social_insurance_code)。
  3. 匹配顺序:身份证号 → 银行卡号。仅补充库中空值,不覆盖已有不同卡号。
  4. 凭据只从环境变量读取,勿写入仓库。
  5. python -X utf8 scripts/backfill-ssc-from-excel.py
  6. python -X utf8 scripts/backfill-ssc-from-excel.py --apply
  7. """
  8. from __future__ import annotations
  9. import argparse
  10. import csv
  11. import json
  12. import os
  13. import re
  14. import sys
  15. import threading
  16. import time
  17. import urllib.error
  18. import urllib.parse
  19. import urllib.request
  20. from collections import defaultdict
  21. from concurrent.futures import ThreadPoolExecutor, as_completed
  22. from pathlib import Path
  23. from openpyxl import load_workbook
  24. sys.stdout.reconfigure(encoding="utf-8")
  25. HOST = os.environ.get("SJ_DMS_HOST", "121.43.55.7")
  26. PORT = int(os.environ.get("SJ_DMS_PORT", "2101"))
  27. BASE = f"http://{HOST}:{PORT}"
  28. COLUMN_ID = "1847"
  29. MODEL_ID = "1970"
  30. DEFAULT_XLSX = Path(r"C:\Users\Liumouren\Desktop\松江区老年农民土地退养补助金发放人员明细-已匹配200编号.xlsx")
  31. REPORT_DIR = Path(r"C:\Users\Liumouren\Desktop")
  32. _token_lock = threading.Lock()
  33. _token = {"value": "", "ts": 0.0}
  34. def digits(v) -> str:
  35. if v is None:
  36. return ""
  37. if isinstance(v, float):
  38. if v != v: # NaN
  39. return ""
  40. s = format(v, ".0f")
  41. elif isinstance(v, int):
  42. s = str(v)
  43. else:
  44. s = str(v).strip()
  45. if s.lower() in ("none", "null", "nan"):
  46. return ""
  47. if re.fullmatch(r"\d+\.0+", s):
  48. s = s.split(".", 1)[0]
  49. return re.sub(r"\D", "", s)
  50. def norm_id(v) -> str:
  51. if v is None:
  52. return ""
  53. s = str(v).strip().replace(" ", "").replace("\u3000", "").upper()
  54. if re.fullmatch(r"\d+\.0+", s):
  55. s = s.split(".", 1)[0]
  56. return s
  57. def norm_ssc(v) -> str:
  58. s = digits(v)
  59. return s if len(s) >= 10 else ""
  60. def post_form(path: str, fields: dict, token: str | None = None, timeout: int = 90) -> dict:
  61. body = urllib.parse.urlencode({k: "" if v is None else str(v) for k, v in fields.items()}).encode("utf-8")
  62. headers = {
  63. "Content-Type": "application/x-www-form-urlencoded",
  64. "Content-Length": str(len(body)),
  65. }
  66. if token:
  67. headers["Token"] = token
  68. headers["token"] = token
  69. req = urllib.request.Request(BASE + path, data=body, method="POST", headers=headers)
  70. try:
  71. with urllib.request.urlopen(req, timeout=timeout) as resp:
  72. raw = resp.read().decode("utf-8", "replace")
  73. except urllib.error.HTTPError as e:
  74. raw = e.read().decode("utf-8", "replace")
  75. try:
  76. return json.loads(raw) if raw else {}
  77. except json.JSONDecodeError:
  78. return {"code": 599, "message": raw[:300]}
  79. def login(force: bool = False) -> str:
  80. now = time.time()
  81. with _token_lock:
  82. if not force and _token["value"] and now - _token["ts"] < 20 * 60:
  83. return _token["value"]
  84. user = os.environ.get("SJ_OAUTH_USER", "user_liu")
  85. password = os.environ.get("SJ_OAUTH_PASSWORD", "")
  86. client_id = os.environ.get("SJ_OAUTH_CLIENT_ID", "1")
  87. if not password:
  88. raise SystemExit("缺少 SJ_OAUTH_PASSWORD")
  89. res = post_form(
  90. "/proxy_oauth/user/login",
  91. {"userName": user, "password": password, "clientId": client_id},
  92. )
  93. if res.get("code") != 200 or not res.get("message"):
  94. raise SystemExit("OAuth 登录失败: " + json.dumps(res, ensure_ascii=False)[:400])
  95. _token["value"] = res["message"]
  96. _token["ts"] = now
  97. return _token["value"]
  98. def fetch_page(page: int, page_size: int = 100) -> list[dict]:
  99. token = login()
  100. res = post_form(
  101. "/proxy_dms/content/selectContentList",
  102. {
  103. "columnId": COLUMN_ID,
  104. "modelId": MODEL_ID,
  105. "page": str(page),
  106. "pageSize": str(page_size),
  107. "states": "0,3",
  108. },
  109. token,
  110. )
  111. code = res.get("code")
  112. if code in (208, 212):
  113. token = login(True)
  114. res = post_form(
  115. "/proxy_dms/content/selectContentList",
  116. {
  117. "columnId": COLUMN_ID,
  118. "modelId": MODEL_ID,
  119. "page": str(page),
  120. "pageSize": str(page_size),
  121. "states": "0,3",
  122. },
  123. token,
  124. )
  125. code = res.get("code")
  126. if code == 202:
  127. return []
  128. if code != 200:
  129. raise RuntimeError(f"selectContentList page={page} {res}")
  130. return (res.get("content") or {}).get("data") or []
  131. def load_personnel(workers: int = 8) -> list[dict]:
  132. first = fetch_page(0)
  133. rows = list(first)
  134. if len(first) < 100:
  135. return rows
  136. page = 1
  137. while True:
  138. batch_pages = list(range(page, page + workers))
  139. got_short = False
  140. with ThreadPoolExecutor(max_workers=workers) as ex:
  141. futs = {ex.submit(fetch_page, p): p for p in batch_pages}
  142. by_page = {}
  143. for fut in as_completed(futs):
  144. by_page[futs[fut]] = fut.result()
  145. for p in batch_pages:
  146. chunk = by_page[p]
  147. rows.extend(chunk)
  148. if len(chunk) < 100:
  149. got_short = True
  150. break
  151. if got_short:
  152. break
  153. page += workers
  154. if page > 800:
  155. break
  156. print(f" 已拉人员 {len(rows)} ...", flush=True)
  157. return rows
  158. def load_excel(path: Path) -> list[dict]:
  159. wb = load_workbook(path, read_only=True, data_only=True)
  160. ws = wb[wb.sheetnames[0]]
  161. out = []
  162. for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
  163. cells = list(row) + [None] * 9
  164. town, village, name, idn, ssc, bank, card, phone, amt = cells[:9]
  165. item = {
  166. "row": i,
  167. "town": "" if town is None else str(town).strip(),
  168. "village": "" if village is None else str(village).strip(),
  169. "name": "" if name is None else str(name).strip(),
  170. "id_number": norm_id(idn),
  171. "ssc": norm_ssc(ssc),
  172. "card": digits(card),
  173. }
  174. out.append(item)
  175. wb.close()
  176. return out
  177. def index_people(people: list[dict]):
  178. by_id = defaultdict(list)
  179. by_card = defaultdict(list)
  180. for p in people:
  181. dms_id = str(p.get("id") or "").strip()
  182. if not dms_id:
  183. continue
  184. rec = {
  185. "dms_id": dms_id,
  186. "biz_id": str(p.get("c_biz_id") or "").strip(),
  187. "name": str(p.get("c_name") or "").strip(),
  188. "id_number": norm_id(p.get("c_id_number")),
  189. "card": digits(p.get("c_bank_card_number")),
  190. "ssc": norm_ssc(p.get("c_social_insurance_code")),
  191. "town": str(p.get("c_town_id") or "").strip(),
  192. "village": str(p.get("c_village_id") or "").strip(),
  193. }
  194. if rec["id_number"]:
  195. by_id[rec["id_number"]].append(rec)
  196. if rec["card"]:
  197. by_card[rec["card"]].append(rec)
  198. return by_id, by_card
  199. def unique_or_none(items: list[dict]) -> tuple[dict | None, str]:
  200. if not items:
  201. return None, "none"
  202. ids = {x["dms_id"] for x in items}
  203. if len(ids) > 1:
  204. return None, "multi"
  205. return items[0], "one"
  206. def match_one(row: dict, by_id, by_card):
  207. id_hit, id_st = unique_or_none(by_id.get(row["id_number"], []) if row["id_number"] else [])
  208. card_hit, card_st = unique_or_none(by_card.get(row["card"], []) if row["card"] else [])
  209. if id_st == "multi":
  210. return None, "id_multi"
  211. if card_st == "multi" and id_st != "one":
  212. return None, "card_multi"
  213. if id_st == "one" and card_st == "one" and id_hit["dms_id"] != card_hit["dms_id"]:
  214. return None, "id_card_mismatch"
  215. if id_st == "one":
  216. return id_hit, "id"
  217. if card_st == "one":
  218. return card_hit, "card"
  219. return None, "unmatched"
  220. def update_ssc(dms_id: str, ssc: str) -> tuple[bool, str]:
  221. payload = {"id": dms_id, "c_social_insurance_code": ssc}
  222. last = ""
  223. for attempt in range(4):
  224. try:
  225. token = login(attempt > 0)
  226. res = post_form(
  227. "/proxy_dms/content/updateContent",
  228. {
  229. "columnId": COLUMN_ID,
  230. "modelId": MODEL_ID,
  231. "content": json.dumps(payload, ensure_ascii=False),
  232. },
  233. token,
  234. )
  235. code = res.get("code")
  236. if code in (208, 212):
  237. login(True)
  238. continue
  239. if code == 200:
  240. return True, "ok"
  241. last = str(res.get("message") or code)
  242. if code in (500, 599) or "超时" in last:
  243. time.sleep(1.5 * (attempt + 1))
  244. continue
  245. return False, last
  246. except Exception as e:
  247. last = str(e)
  248. time.sleep(1.5 * (attempt + 1))
  249. return False, last
  250. def write_csv(path: Path, rows: list[dict], fields: list[str]):
  251. with path.open("w", encoding="utf-8-sig", newline="") as f:
  252. w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
  253. w.writeheader()
  254. for r in rows:
  255. w.writerow(r)
  256. def main():
  257. ap = argparse.ArgumentParser()
  258. ap.add_argument("--xlsx", default=str(DEFAULT_XLSX))
  259. ap.add_argument("--apply", action="store_true")
  260. ap.add_argument("--workers", type=int, default=6)
  261. args = ap.parse_args()
  262. xlsx = Path(args.xlsx)
  263. if not xlsx.exists():
  264. raise SystemExit("找不到 Excel: " + str(xlsx))
  265. print("解析 Excel ...", flush=True)
  266. excel_rows = load_excel(xlsx)
  267. with_ssc = [r for r in excel_rows if r["ssc"]]
  268. print(f" 明细 {len(excel_rows)} 行,有社保卡 {len(with_ssc)}", flush=True)
  269. print("登录并拉取人员底数 ...", flush=True)
  270. login(True)
  271. people = load_personnel()
  272. print(f" 库中人员 {len(people)}", flush=True)
  273. by_id, by_card = index_people(people)
  274. db_empty = sum(1 for p in people if not norm_ssc(p.get("c_social_insurance_code")))
  275. print(f" 库中社保卡为空 {db_empty}", flush=True)
  276. planned = {}
  277. details = []
  278. stats = defaultdict(int)
  279. for row in with_ssc:
  280. person, how = match_one(row, by_id, by_card)
  281. stats[how] += 1
  282. if person is None:
  283. details.append({**row, "result": how, "biz_id": "", "db_ssc": "", "action": "skip"})
  284. continue
  285. cur = person["ssc"]
  286. action = "skip"
  287. if not cur:
  288. action = "fill"
  289. elif cur == row["ssc"]:
  290. action = "same"
  291. stats["same"] += 1
  292. else:
  293. action = "conflict_diff"
  294. stats["conflict_diff"] += 1
  295. rec = {
  296. **row,
  297. "result": how,
  298. "biz_id": person["biz_id"],
  299. "dms_id": person["dms_id"],
  300. "db_name": person["name"],
  301. "db_ssc": cur,
  302. "action": action,
  303. }
  304. details.append(rec)
  305. if action == "fill":
  306. prev = planned.get(person["dms_id"])
  307. if prev and prev["ssc"] != row["ssc"]:
  308. rec["action"] = "conflict_multi_excel"
  309. stats["conflict_multi_excel"] += 1
  310. stats["fill"] -= 1
  311. planned.pop(person["dms_id"], None)
  312. else:
  313. planned[person["dms_id"]] = rec
  314. stats["fill"] += 1
  315. summary = {
  316. "excel_rows": len(excel_rows),
  317. "excel_with_ssc": len(with_ssc),
  318. "db_people": len(people),
  319. "db_ssc_empty": db_empty,
  320. "match": {k: stats[k] for k in sorted(stats)},
  321. "to_fill_unique_people": len(planned),
  322. "apply": bool(args.apply),
  323. }
  324. print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True)
  325. applied = []
  326. failed = []
  327. if args.apply and planned:
  328. items = list(planned.values())
  329. print(f"开始回填 {len(items)} 人 ...", flush=True)
  330. done = 0
  331. with ThreadPoolExecutor(max_workers=max(1, args.workers)) as ex:
  332. futs = {ex.submit(update_ssc, rec["dms_id"], rec["ssc"]): rec for rec in items}
  333. for fut in as_completed(futs):
  334. rec = futs[fut]
  335. ok, msg = fut.result()
  336. done += 1
  337. if ok:
  338. applied.append(rec)
  339. else:
  340. rec = dict(rec)
  341. rec["error"] = msg
  342. failed.append(rec)
  343. if done % 200 == 0 or done == len(items):
  344. print(f" 进度 {done}/{len(items)} 成功 {len(applied)} 失败 {len(failed)}", flush=True)
  345. summary["applied"] = len(applied)
  346. summary["failed"] = len(failed)
  347. stamp = time.strftime("%Y%m%d-%H%M%S")
  348. report_json = REPORT_DIR / f"社保卡号回填报告-{stamp}.json"
  349. report_csv = REPORT_DIR / f"社保卡号回填明细-{stamp}.csv"
  350. report_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
  351. write_csv(
  352. report_csv,
  353. details,
  354. [
  355. "row", "town", "village", "name", "id_number", "card", "ssc",
  356. "result", "biz_id", "db_name", "db_ssc", "action",
  357. ],
  358. )
  359. if failed:
  360. fail_csv = REPORT_DIR / f"社保卡号回填失败-{stamp}.csv"
  361. write_csv(
  362. fail_csv,
  363. failed,
  364. ["row", "town", "village", "name", "id_number", "ssc", "biz_id", "dms_id", "error"],
  365. )
  366. print("失败清单", fail_csv)
  367. print("报告", report_json)
  368. print("明细", report_csv)
  369. if __name__ == "__main__":
  370. main()