Commit c9146ddc authored by wenwen.tang's avatar wenwen.tang 😕

feat(tools): 数据回拉通道 + 合同×台账核对脚本

- tools/sync_pull.py:把线上 /opt/rentos/data 回拉到本地 data/(deploy_git.py 只管推代码)。
  --dry 先列差异 → 备份 data/_snap/pull-<ts>/ → SFTP 递归下载;默认跳过 attachments(61MB),
  sessions.json 永不回拉(线上活跃 token)。⚠️ 只增/改、不删本地独有文件。
- tools/check_contracts_vs_table.py:按文件名解析单元/租客/期限与台账逐条比对,
  复用 import_contracts 的单元解析保证「挂上的行」与「核对的行」是同一行。
  支持 --pdf 抽正文,-v 出明细,--json 落盘。
parent fcf3187c
# -*- coding: utf-8 -*-
"""对照合同原件核对台账(房租车位费 rent_parking)里的内容是否正确。
口径
合同是「事实来源」,台账是「待核对对象」。每条合同:
1) 文件名 / 正文里解析出:单元号、承租方、租赁期限
2) 找到台账对应行,比对 F(现租客)、I/J(合同期始/止)
3) 给出一致 / 不一致 / 无法判定(合同没写明)
单元号的解析规则与 import_contracts.py 完全一致(复用其函数),
保证「挂上去的那一行」与「核对的那一行」是同一行。
用法
python tools/check_contracts_vs_table.py # 只看结论
python tools/check_contracts_vs_table.py -v # 附每条合同的比对明细
python tools/check_contracts_vs_table.py --pdf # 额外抽取 PDF 正文找承租方/期限
python tools/check_contracts_vs_table.py --json out.json
"""
import argparse
import difflib
import io
import json
import os
import re
import sys
# 注意:import_contracts 在导入时已把 stdout 换成 UTF-8 包装器,
# 这里**不能再包一层**(旧包装器被 GC 时会关掉底层 buffer,报 I/O on closed file)。
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception: # noqa: BLE001
pass
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
sys.path.insert(0, ROOT)
from import_contracts import parse_units, park_hint, resolve # noqa: E402
from rentos import config as C # noqa: E402
from rentos import tables # noqa: E402
RENT_KEY = tables.RENT_KEY
# ---- 文件名里的线索 ----
_DATES = [
# 2025.10.01-2027.09.30 / 2025-10-1~2027-9-30
re.compile(r"(20\d{2})\s*[.\-/年]\s*(\d{1,2})\s*[.\-/月]\s*(\d{1,2})\s*(?:日)?\s*[-~~至到]\s*"
r"(20\d{2})\s*[.\-/年]\s*(\d{1,2})\s*[.\-/月]\s*(\d{1,2})"),
# 250616-280615(YYMMDD)
re.compile(r"(\d{2})(\d{2})(\d{2})\s*[-~~至到]\s*(\d{2})(\d{2})(\d{2})"),
# 2025年1月(只有月份,做提示用)
re.compile(r"(20\d{2})\s*年\s*(\d{1,2})\s*月"),
]
_NOISE = ["租赁合同", "房屋租赁", "车位合同", "车位", "合同", "之补充协议", "补充协议",
"出租方变更", "房屋", "租赁", "员工", "室", "号", "室 ", "公司", "有限",
"(", ")", "(", ")", "、", "&", "#"]
def parse_period(stem):
"""文件名 → (起, 止, 精度)。精度:'day' / 'month' / None。"""
m = _DATES[0].search(stem)
if m:
a = "%04d-%02d-%02d" % (int(m.group(1)), int(m.group(2)), int(m.group(3)))
b = "%04d-%02d-%02d" % (int(m.group(4)), int(m.group(5)), int(m.group(6)))
return a, b, "day"
m = _DATES[1].search(stem)
if m:
a = "20%s-%s-%s" % (m.group(1), m.group(2), m.group(3))
b = "20%s-%s-%s" % (m.group(4), m.group(5), m.group(6))
return a, b, "day"
m = _DATES[2].search(stem)
if m:
return "%04d-%02d" % (int(m.group(1)), int(m.group(2))), None, "month"
return None, None, None
def tenant_hint(stem, nums):
"""文件名 → 承租方线索(去掉单元号与日期后的中文串)。"""
s = stem
for n in nums:
s = s.replace(n, " ")
s = _DATES[0].sub(" ", s)
s = _DATES[1].sub(" ", s)
s = re.sub(r"20\d{2}\s*年\s*\d{1,2}\s*月", " ", s)
s = re.sub(r"\d{1,2}\s*月", " ", s)
for w in _NOISE:
s = s.replace(w, " ")
s = re.sub(r"[^\u4e00-\u9fa5A-Za-z]", " ", s)
s = re.sub(r"\s+", " ", s).strip()
return s
def sim(a, b):
a, b = (a or "").strip(), (b or "").strip()
if not a or not b:
return 0.0
return difflib.SequenceMatcher(None, a, b).ratio()
def pdf_text(path, maxpage=2):
"""抽 PDF 前几页文本;扫描件返回空串。"""
try:
import pypdfium2 as pdfium
except Exception: # noqa: BLE001
return ""
try:
doc = pdfium.PdfDocument(path)
out = []
for i in range(min(maxpage, len(doc))):
tp = doc[i].get_textpage()
out.append(tp.get_text_range())
doc.close()
return "\n".join(out)
except Exception: # noqa: BLE001
return ""
_PDF_COMPANY = re.compile(r"([\u4e00-\u9fa5()()]{2,30}?(?:有限公司|有限责任公司|事务所|中心|商行|店|厂))")
_PDF_DATES = re.compile(r"(20\d{2})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-v", "--verbose", action="store_true")
ap.add_argument("--pdf", action="store_true", help="抽 PDF 正文找承租方/期限")
ap.add_argument("--json", default="")
args = ap.parse_args()
idx = json.load(open(os.path.join(C.CONTRACT_DIR, "index.json"), encoding="utf-8"))
t = tables.load_table(RENT_KEY)
rows = t["rows"]
unit_map = {}
for r in rows:
u = str(r.get("C") or "").strip()
if u:
unit_map.setdefault(u, []).append(r)
out = []
for m in idx:
fn = m["name"]
stem = os.path.splitext(fn)[0]
nums = parse_units(fn)
hint = tenant_hint(stem, nums)
p1, p2, prec = parse_period(stem)
resolved = resolve(nums, stem, unit_map) if nums else []
rec = {"file": fn, "nums": nums, "tenant_hint": hint,
"period": [p1, p2, prec], "units": []}
ptxt = ""
if args.pdf and m.get("ext") == ".pdf":
ptxt = pdf_text(os.path.join(C.CONTRACT_DIR, m["id"] + ".pdf"))
if ptxt:
comps = []
for c in _PDF_COMPANY.findall(ptxt):
c = c.strip()
if c not in comps and not c.endswith(("甲方", "乙方")):
comps.append(c)
rec["pdf_companies"] = comps[:12]
ds = _PDF_DATES.findall(ptxt)
rec["pdf_dates"] = ["%s-%02d-%02d" % (y, int(mo), int(d)) for y, mo, d in ds[:12]]
rec["pdf_len"] = len(ptxt)
for raw, unit, rlist in resolved:
if not unit or not rlist:
rec["units"].append({"unit": (unit or raw), "found": False})
continue
r = rlist[0]
got_t = str(r.get("F") or "").strip()
got_i = str(r.get("I") or "").strip()
got_j = str(r.get("J") or "").strip()
tenant_score = max((sim(hint, got_t), sim(hint, got_t.replace("上海", "")),
sim(hint.replace("上海", ""), got_t)) if hint else (0,))
if not hint:
tenant_ok = "na"
elif tenant_score >= 0.5:
tenant_ok = True
elif any(hint and h and (h in got_t or got_t in h)
for h in [hint, hint.replace("上海", "")]):
tenant_ok = True
else:
tenant_ok = False
date_ok = "na"
if p1 and prec == "day":
date_ok = (got_i == p1 and got_j == p2)
elif p1 and prec == "month":
date_ok = (got_i[:7] == p1) if got_i else False
rec["units"].append({"unit": unit, "found": True, "row_id": r.get("_id"),
"owner": r.get("A"), "tenant": got_t, "begin": got_i, "end": got_j,
"tenant_ok": tenant_ok, "tenant_score": round(tenant_score, 2),
"date_ok": date_ok, "park": park_hint(stem)})
# 单元在文件名里有、台账里没有 → 另一条记录
for raw, unit, rlist in resolved:
pass
# 文件名里提到但台账没有的单元号
rec["absent"] = [raw for raw, unit, rlist in resolved if not unit]
out.append(rec)
# ---------- 输出 ----------
def mark(v):
return {True: "一致", False: "✗ 不符", "na": "—"}.get(v, "?")
bad_t, bad_d, absent, nofound = [], [], [], []
for rec in out:
for u in rec["units"]:
if not u.get("found"):
nofound.append((rec["file"], u["unit"]))
continue
if u["tenant_ok"] is False:
bad_t.append((rec["file"], u["unit"], u["tenant"], rec["tenant_hint"], u["tenant_score"]))
if u["date_ok"] is False:
bad_d.append((rec["file"], u["unit"], u["begin"], u["end"], rec["period"][0], rec["period"][1]))
if rec.get("absent"):
absent.append((rec["file"], rec["absent"]))
if args.verbose:
for rec in out:
print("=" * 100)
print(rec["file"])
print(" 线索:单元 %s / 承租方「%s」/ 期限 %s" %
(rec["nums"] or "—", rec["tenant_hint"] or "—",
" ~ ".join([x for x in rec["period"][:2] if x]) or "—"))
for u in rec["units"]:
if not u.get("found"):
print(" ✗ 台账无此单元:%s" % u["unit"])
continue
print(" → %-8s 出租方=%-6s 台账租客=%-26s 台账期=%s~%s" %
(u["unit"], u.get("owner") or "", u["tenant"] or "(空)",
u["begin"] or "", u["end"] or ""))
print(" 租客 %s(相似度 %.2f)| 期限 %s" %
(mark(u["tenant_ok"]), u["tenant_score"], mark(u["date_ok"])))
if rec.get("pdf_companies") or rec.get("pdf_dates"):
print(" PDF正文(%d字):公司 %s" % (rec.get("pdf_len", 0),
" / ".join(rec.get("pdf_companies") or [])[:160]))
if rec.get("pdf_dates"):
print(" 日期 %s" % ", ".join(rec["pdf_dates"]))
print("")
print("=" * 100)
print("汇总:%d 份合同" % len(out))
print(" 承租方与台账不符:%d" % len(bad_t))
for f, u, gt, h, s in bad_t:
print(" · %s → %s:台账「%s」 vs 合同「%s」(%.2f)" % (f[:40], u, gt or "空", h, s))
print(" 合同期限与台账不符:%d" % len(bad_d))
for f, u, gi, gj, ci, cj in bad_d:
print(" · %s → %s:台账 %s~%s vs 合同 %s~%s" % (f[:40], u, gi or "空", gj or "空", ci, cj))
if absent:
print(" 文件名提到但台账没有的单元:%d" % len(absent))
for f, a in absent:
print(" · %s → %s" % (f[:40], a))
if nofound:
print(" 解析出的单元在台账找不到(视为已删除/改名):%d" % len(nofound))
for f, u in nofound:
print(" · %s → %s" % (f[:40], u))
if args.json:
json.dump(out, open(args.json, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("\n明细已写 %s" % args.json)
return 0
if __name__ == "__main__":
sys.exit(main())
# -*- coding: utf-8 -*-
"""把线上 /opt/rentos/data 的数据**回拉**到本地 data/。
deploy_git.py 只管推代码;线上才是权威数据,本地只是预览,
所以需要一个反向通道把台账 / 名册 / 批次 / 合同索引拉回来对账。
用法
RENTOS_SSH_PASSWORD=... python tools/sync_pull.py # 全量(默认跳过附件)
RENTOS_SSH_PASSWORD=... python tools/sync_pull.py --with-attach # 连备注栏图片一起拉
RENTOS_SSH_PASSWORD=... python tools/sync_pull.py --dry # 只列差异不下载
RENTOS_SSH_PASSWORD=... python tools/sync_pull.py --only tables,config
RENTOS_SSH_PASSWORD=... python tools/sync_pull.py --no-backup
安全
- 拉之前先把本地 data/ 整体备份到 data/_snap/pull-<时间戳>/(除非 --no-backup)
- sessions.json 永不回拉:那是**线上活跃 token**,拉到本地没意义还碍事
- 只覆盖 data/ 下的内容,不碰仓库代码(代码走 deploy_git.py)
"""
import argparse
import io
import os
import shutil
import stat as statmod
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import deploy_docker as D # noqa: E402 复用 SSH
REMOTE_DATA = D.REMOTE_DIR + "/data"
LOCAL_DATA = os.path.join(BASE, "data")
# 顶层条目白名单(按名字选,不按路径)
TOPS = ["tables", "batches", "contracts", "drafts", "seed", "attachments",
"config.json", "unit_map.json", "scheduler_state.json", "send_log.json",
"receipts.json", "users.json"]
# 永不回拉
SKIP_NAMES = {"sessions.json", "__pycache__", ".DS_Store", "Thumbs.db"}
SKIP_DIRS = {"__pycache__"}
DEFAULT_SKIP = {"attachments"} # 61MB 图片,默认不拉,--with-attach 才带上
def human(n):
for u in ("B", "KB", "MB", "GB"):
if n < 1024:
return "%.1f%s" % (n, u)
n /= 1024.0
return "%.1fTB" % n
def log(msg=""):
print(msg, flush=True)
def rlist(sf, path):
"""列一层,返回 (名字, 是否目录, 大小, mtime)。"""
out = []
try:
for a in sf.listdir_attr(path):
if a.filename in SKIP_NAMES or a.filename in SKIP_DIRS:
continue
out.append((a.filename, statmod.S_ISDIR(a.st_mode or 0),
int(a.st_size or 0), int(a.st_mtime or 0)))
except IOError:
pass
return sorted(out, key=lambda x: x[0])
def walk(sf, rpath, rel=""):
"""递归列出文件:[(相对路径, 大小, mtime)]。"""
files = []
for name, isdir, size, mt in rlist(sf, rpath):
r = (rel + "/" + name) if rel else name
if isdir:
files += walk(sf, rpath + "/" + name, r)
else:
files.append((r, size, mt))
return files
def backup_local():
if not os.path.isdir(LOCAL_DATA):
return None
dst = os.path.join(LOCAL_DATA, "_snap",
"pull-" + time.strftime("%Y%m%d-%H%M%S"))
os.makedirs(os.path.dirname(dst), exist_ok=True)
for name in sorted(os.listdir(LOCAL_DATA)):
if name == "_snap":
continue
src = os.path.join(LOCAL_DATA, name)
tgt = os.path.join(dst, name)
if os.path.isdir(src):
shutil.copytree(src, tgt, symlinks=False)
else:
shutil.copy2(src, tgt)
return dst
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", default="", help="只拉这些顶层条目,逗号分隔")
ap.add_argument("--skip", default="", help="额外跳过,逗号分隔")
ap.add_argument("--with-attach", action="store_true", help="连附件图片一起拉")
ap.add_argument("--dry", action="store_true", help="只列差异")
ap.add_argument("--no-backup", action="store_true", help="跳过本地备份")
args = ap.parse_args()
tops = [t.strip() for t in args.only.split(",") if t.strip()] or list(TOPS)
skip = set(DEFAULT_SKIP) | {s.strip() for s in args.skip.split(",") if s.strip()}
if args.with_attach:
skip.discard("attachments")
cli = D.connect()
sf = cli.open_sftp()
log("远端 %s:%s" % (D.HOST, REMOTE_DATA))
log("本地 %s" % LOCAL_DATA)
# ---- 收集远端文件
plan = []
for top in sorted(tops):
if top in skip:
log(" - 跳过 %s" % top)
continue
rp = REMOTE_DATA + "/" + top
try:
st = sf.stat(rp)
except IOError:
continue
if statmod.S_ISDIR(st.st_mode):
for rel, size, mt in walk(sf, rp, top):
plan.append((rel, size, mt))
else:
plan.append((top, int(st.st_size or 0), int(st.st_mtime or 0)))
total = sum(p[1] for p in plan)
log("")
log("远端待拉:%d 个文件 / %s" % (len(plan), human(total)))
# ---- 差异
NEW, DIFF, SAME = [], [], 0
for rel, size, mt in plan:
lp = os.path.join(LOCAL_DATA, rel.replace("/", os.sep))
if not os.path.exists(lp):
NEW.append((rel, size))
elif os.path.getsize(lp) != size:
DIFF.append((rel, os.path.getsize(lp), size))
else:
SAME += 1
log(" 新增 %d / 有变化 %d / 一致 %d" % (len(NEW), len(DIFF), SAME))
for rel, size in NEW[:200]:
log(" + %-58s %s" % (rel, human(size)))
for rel, lo, rs in DIFF[:200]:
log(" ~ %-58s 本地 %s → 远端 %s" % (rel, human(lo), human(rs)))
if args.dry:
sf.close()
cli.close()
log("\n(干跑,未下载)")
return 0
# ---- 备份
if not args.no_backup:
b = backup_local()
log("\n本地已备份 → %s" % (b or "(无 data/,跳过)"))
# ---- 下载
log("\n开始下载…")
done = 0
t0 = time.time()
for i, (rel, size, mt) in enumerate(plan, 1):
lp = os.path.join(LOCAL_DATA, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(lp), exist_ok=True)
try:
sf.get(REMOTE_DATA + "/" + rel, lp)
done += 1
except Exception as e: # noqa: BLE001
log(" !! %s 失败:%s" % (rel, e))
if i % 40 == 0 or i == len(plan):
log(" %d/%d %.0fs" % (i, len(plan), time.time() - t0))
sf.close()
cli.close()
log("\n完成:%d/%d,用时 %.0fs" % (done, len(plan), time.time() - t0))
return 0
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment