migrate_protocol_payment_v1_to_v2.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. #!/usr/bin/env python3
  2. """采集协议单缴费模型 v1 → v2 迁移(生产专用,可重复执行)。
  3. 规则(与 docs/superpowers/specs/2026-10-15-protocol-save-auto-submit-and-v1-to-v2-migration-design.md 一致):
  4. - 单样本 + 无退费:1:1 迁到 sample_payment / schedule / transfer,并置 payment_model_version=2
  5. - 多样本 + 无退费:默认只进人工清单;加 --multi-average 时按样本数平均分合同/应缴/转账
  6. - 有退费痕迹 / 无有效样本 / 无样本编码 / 无 v1 缴费主表:只进人工清单
  7. - 不物理删除 v1 payment / payment_detail
  8. 用法(在 backend 目录、已配置 .env):
  9. python -m scripts.migrate_protocol_payment_v1_to_v2 --list-only
  10. python -m scripts.migrate_protocol_payment_v1_to_v2 --dry-run
  11. python -m scripts.migrate_protocol_payment_v1_to_v2
  12. python -m scripts.migrate_protocol_payment_v1_to_v2 --multi-average
  13. python -m scripts.migrate_protocol_payment_v1_to_v2 --multi-average --include-open-payments
  14. python -m scripts.migrate_protocol_payment_v1_to_v2 --protocol-id <ID>
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import csv
  19. import os
  20. import sys
  21. from collections import defaultdict
  22. from dataclasses import dataclass, field
  23. from datetime import datetime
  24. from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP
  25. from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
  26. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  27. from app.core.database import SessionLocal
  28. from app.core.utils import generate_uuid
  29. from app.models.models import (
  30. BizCollectionProtocol,
  31. BizProtocolPayment,
  32. BizProtocolPaymentDetail,
  33. BizProtocolPaymentSchedule,
  34. BizProtocolPaymentTransfer,
  35. BizProtocolSample,
  36. BizProtocolSamplePayment,
  37. )
  38. REFUND_STATUSES = frozenset({"tfqrz", "ytf"})
  39. OPEN_STATUSES = frozenset({"dfj", "jfqrz"})
  40. VOID_SAMPLE_STATUS = "yzf"
  41. CENTS = Decimal("0.01")
  42. def _dec(value: Any) -> Optional[Decimal]:
  43. if value is None or value == "":
  44. return None
  45. try:
  46. return Decimal(str(value))
  47. except Exception:
  48. return None
  49. def _money(value: Any) -> Optional[Decimal]:
  50. d = _dec(value)
  51. if d is None:
  52. return None
  53. return d.quantize(CENTS, rounding=ROUND_HALF_UP)
  54. def split_amount_across(total: Optional[Decimal], n: int) -> List[Optional[Decimal]]:
  55. """金额按分均分,余数落在最后一项;total 为空则返回全 None。"""
  56. if n <= 0:
  57. raise ValueError("n must be positive")
  58. if total is None:
  59. return [None] * n
  60. cents = int((total.quantize(CENTS, rounding=ROUND_HALF_UP) * 100).to_integral_value(rounding=ROUND_DOWN))
  61. base, rem = divmod(cents, n)
  62. out: List[Optional[Decimal]] = []
  63. for i in range(n):
  64. c = base + (rem if i == n - 1 else 0)
  65. out.append((Decimal(c) / Decimal(100)).quantize(CENTS))
  66. return out
  67. def detail_has_refund(d: BizProtocolPaymentDetail) -> bool:
  68. status = (d.pay_status or "").strip()
  69. if status in REFUND_STATUSES:
  70. return True
  71. amt = _dec(d.refund_amount)
  72. if amt is not None and amt > 0:
  73. return True
  74. for attr in (
  75. "refund_attachments",
  76. "refund_remark",
  77. "refund_finance_confirm_attachments",
  78. "refund_finance_remark",
  79. ):
  80. if str(getattr(d, attr, None) or "").strip():
  81. return True
  82. return False
  83. def _is_active_sample(s: BizProtocolSample) -> bool:
  84. if int(s.del_flag or 0) != 0:
  85. return False
  86. status = (s.sample_status or "").strip()
  87. if status == VOID_SAMPLE_STATUS:
  88. return False
  89. return True
  90. def _schedule_no_of(d: BizProtocolPaymentDetail, *, pay_mode: str) -> int:
  91. if (pay_mode or "").strip() != "fq":
  92. return 1
  93. no = int(d.installment_no or 0)
  94. return no if no > 0 else 1
  95. def _append_remark(existing: Optional[str], extra: str) -> str:
  96. base = (existing or "").strip()
  97. if not base:
  98. return extra[:512]
  99. joined = f"{base} | {extra}"
  100. return joined[:512]
  101. @dataclass
  102. class ProtocolCandidate:
  103. protocol: BizCollectionProtocol
  104. samples: List[BizProtocolSample]
  105. payment: Optional[BizProtocolPayment]
  106. details: List[BizProtocolPaymentDetail]
  107. skip_reason: Optional[str] = None
  108. has_open_payment: bool = False
  109. @property
  110. def sample_count(self) -> int:
  111. return len(self.samples)
  112. @property
  113. def protocol_id(self) -> str:
  114. return str(self.protocol.id)
  115. @dataclass
  116. class MigrateStats:
  117. scanned: int = 0
  118. migrated: int = 0
  119. skipped_already_v2: int = 0
  120. listed: List[Dict[str, Any]] = field(default_factory=list)
  121. errors: List[str] = field(default_factory=list)
  122. def load_v1_protocols(
  123. db,
  124. *,
  125. protocol_id: Optional[str] = None,
  126. ) -> List[BizCollectionProtocol]:
  127. q = db.query(BizCollectionProtocol).filter(
  128. BizCollectionProtocol.del_flag == 0,
  129. BizCollectionProtocol.payment_model_version == 1,
  130. )
  131. if protocol_id:
  132. q = q.filter(BizCollectionProtocol.id == protocol_id)
  133. return q.order_by(BizCollectionProtocol.create_time.asc()).all()
  134. def has_v2_sample_payment(db, protocol_id: str) -> bool:
  135. row = (
  136. db.query(BizProtocolSamplePayment.id)
  137. .filter(
  138. BizProtocolSamplePayment.collection_protocol_id == protocol_id,
  139. BizProtocolSamplePayment.del_flag == 0,
  140. )
  141. .first()
  142. )
  143. return row is not None
  144. def build_candidate(db, protocol: BizCollectionProtocol) -> ProtocolCandidate:
  145. samples = (
  146. db.query(BizProtocolSample)
  147. .filter(
  148. BizProtocolSample.collection_protocol_id == protocol.id,
  149. BizProtocolSample.del_flag == 0,
  150. )
  151. .order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc())
  152. .all()
  153. )
  154. active = [s for s in samples if _is_active_sample(s)]
  155. payment = (
  156. db.query(BizProtocolPayment)
  157. .filter(
  158. BizProtocolPayment.collection_protocol_id == protocol.id,
  159. BizProtocolPayment.del_flag == 0,
  160. )
  161. .order_by(BizProtocolPayment.create_time.asc())
  162. .first()
  163. )
  164. details: List[BizProtocolPaymentDetail] = []
  165. if payment:
  166. details = (
  167. db.query(BizProtocolPaymentDetail)
  168. .filter(
  169. BizProtocolPaymentDetail.payment_id == payment.id,
  170. BizProtocolPaymentDetail.del_flag == 0,
  171. )
  172. .order_by(
  173. BizProtocolPaymentDetail.installment_no.asc(),
  174. BizProtocolPaymentDetail.create_time.asc(),
  175. )
  176. .all()
  177. )
  178. cand = ProtocolCandidate(
  179. protocol=protocol,
  180. samples=active,
  181. payment=payment,
  182. details=details,
  183. has_open_payment=any((d.pay_status or "").strip() in OPEN_STATUSES for d in details),
  184. )
  185. if has_v2_sample_payment(db, protocol.id):
  186. cand.skip_reason = "already_has_v2"
  187. return cand
  188. if not payment:
  189. cand.skip_reason = "no_v1_payment"
  190. return cand
  191. if not active:
  192. cand.skip_reason = "no_sample"
  193. return cand
  194. if any(not str(s.sample_code or "").strip() for s in active):
  195. cand.skip_reason = "no_sample_code"
  196. return cand
  197. if any(detail_has_refund(d) for d in details):
  198. cand.skip_reason = "has_refund"
  199. return cand
  200. if len(active) != 1:
  201. cand.skip_reason = "multi_sample"
  202. return cand
  203. return cand
  204. def _list_row(cand: ProtocolCandidate) -> Dict[str, Any]:
  205. p = cand.protocol
  206. pay = cand.payment
  207. return {
  208. "protocol_id": p.id,
  209. "protocol_no": p.protocol_no,
  210. "tenant_no": p.tenant_no,
  211. "sample_count": cand.sample_count,
  212. "sample_codes": ",".join(str(s.sample_code or "") for s in cand.samples),
  213. "contract_amount": str(pay.contract_amount) if pay and pay.contract_amount is not None else "",
  214. "detail_count": len(cand.details),
  215. "has_open_payment": "1" if cand.has_open_payment else "0",
  216. "skip_reason": cand.skip_reason or "",
  217. }
  218. def _new_common(
  219. *,
  220. tenant_no: str,
  221. user_id: Optional[str],
  222. create_dept_id: str,
  223. create_by: Optional[str],
  224. create_time: Optional[datetime],
  225. order_num: Optional[int],
  226. remark: Optional[str],
  227. ) -> Dict[str, Any]:
  228. now = datetime.now()
  229. return {
  230. "tenant_no": tenant_no,
  231. "order_num": order_num,
  232. "remark": remark,
  233. "create_by": create_by or user_id,
  234. "create_dept_id": (create_dept_id or "").strip(),
  235. "create_time": create_time or now,
  236. "update_by": user_id,
  237. "update_time": now,
  238. "del_flag": 0,
  239. }
  240. def _group_details(
  241. details: Sequence[BizProtocolPaymentDetail], *, pay_mode: str
  242. ) -> Dict[int, List[BizProtocolPaymentDetail]]:
  243. groups: Dict[int, List[BizProtocolPaymentDetail]] = defaultdict(list)
  244. for d in details:
  245. groups[_schedule_no_of(d, pay_mode=pay_mode)].append(d)
  246. return dict(sorted(groups.items(), key=lambda x: x[0]))
  247. def _due_from_group(
  248. rows: Sequence[BizProtocolPaymentDetail],
  249. *,
  250. payment: BizProtocolPayment,
  251. schedule_no: int,
  252. ) -> Tuple[Optional[Decimal], Optional[str]]:
  253. due_amount = None
  254. due_date = None
  255. for d in rows:
  256. if due_amount is None and d.due_amount is not None:
  257. due_amount = _money(d.due_amount)
  258. if not due_date and str(d.installment_due_date or "").strip():
  259. due_date = str(d.installment_due_date).strip()
  260. if due_amount is None and schedule_no == 1 and (payment.pay_mode or "").strip() != "fq":
  261. due_amount = _money(payment.contract_amount)
  262. if not due_date and (payment.pay_mode or "").strip() != "fq":
  263. due_date = (payment.lump_sum_deadline or "").strip() or None
  264. return due_amount, due_date
  265. def migrate_single_sample(
  266. db,
  267. cand: ProtocolCandidate,
  268. *,
  269. user_id: Optional[str],
  270. dry_run: bool,
  271. ) -> None:
  272. assert cand.payment is not None
  273. assert len(cand.samples) == 1
  274. sample = cand.samples[0]
  275. payment = cand.payment
  276. protocol = cand.protocol
  277. pay_mode = (payment.pay_mode or "dj").strip() or "dj"
  278. common = _new_common(
  279. tenant_no=protocol.tenant_no,
  280. user_id=user_id,
  281. create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
  282. create_by=payment.create_by,
  283. create_time=payment.create_time,
  284. order_num=payment.order_num,
  285. remark=payment.remark,
  286. )
  287. sp_id = generate_uuid()
  288. sp = BizProtocolSamplePayment(
  289. id=sp_id,
  290. collection_protocol_id=protocol.id,
  291. protocol_sample_id=sample.id,
  292. sample_code=str(sample.sample_code).strip(),
  293. pay_mode=pay_mode,
  294. contract_amount=payment.contract_amount,
  295. installment_count=payment.installment_count,
  296. installment_interval_months=payment.installment_interval_months,
  297. installment_start_date=payment.installment_start_date,
  298. installment_advance_days=payment.installment_advance_days,
  299. installment_ratios=payment.installment_ratios,
  300. lump_sum_deadline=payment.lump_sum_deadline,
  301. **common,
  302. )
  303. if not dry_run:
  304. db.add(sp)
  305. groups = _group_details(cand.details, pay_mode=pay_mode)
  306. if not groups:
  307. # 无明细:仍建一期空 schedule,保证方案可编辑
  308. groups = {1: []}
  309. for schedule_no, rows in groups.items():
  310. due_amount, due_date = _due_from_group(rows, payment=payment, schedule_no=schedule_no)
  311. sch_id = generate_uuid()
  312. sch = BizProtocolPaymentSchedule(
  313. id=sch_id,
  314. sample_payment_id=sp_id,
  315. collection_protocol_id=protocol.id,
  316. sample_code=str(sample.sample_code).strip(),
  317. schedule_no=schedule_no,
  318. due_amount=due_amount,
  319. due_date=due_date,
  320. schedule_status="open",
  321. **_new_common(
  322. tenant_no=protocol.tenant_no,
  323. user_id=user_id,
  324. create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
  325. create_by=payment.create_by,
  326. create_time=payment.create_time,
  327. order_num=schedule_no,
  328. remark=None,
  329. ),
  330. )
  331. if not dry_run:
  332. db.add(sch)
  333. for d in rows:
  334. tr = BizProtocolPaymentTransfer(
  335. id=generate_uuid(),
  336. schedule_id=sch_id,
  337. sample_payment_id=sp_id,
  338. collection_protocol_id=protocol.id,
  339. sample_code=str(sample.sample_code).strip(),
  340. transfer_amount=d.transfer_amount,
  341. transfer_time=d.transfer_time,
  342. transfer_type=d.transfer_type,
  343. transfer_attachments=d.transfer_attachments,
  344. pay_status=(d.pay_status or "dfj").strip() or "dfj",
  345. finance_confirm_attachments=d.finance_confirm_attachments,
  346. finance_remark=d.finance_remark,
  347. **_new_common(
  348. tenant_no=protocol.tenant_no,
  349. user_id=user_id,
  350. create_dept_id=d.create_dept_id or payment.create_dept_id or "",
  351. create_by=d.create_by,
  352. create_time=d.create_time,
  353. order_num=d.order_num,
  354. remark=_append_remark(d.remark, f"migrated_from_v1_detail:{d.id}"),
  355. ),
  356. )
  357. if not dry_run:
  358. db.add(tr)
  359. if not dry_run:
  360. protocol.payment_model_version = 2
  361. protocol.update_by = user_id
  362. protocol.update_time = datetime.now()
  363. db.add(protocol)
  364. def migrate_multi_average(
  365. db,
  366. cand: ProtocolCandidate,
  367. *,
  368. user_id: Optional[str],
  369. dry_run: bool,
  370. ) -> None:
  371. """多样本:合同/应缴/转账金额均分;状态与凭证复制;备注标注来源明细。"""
  372. assert cand.payment is not None
  373. assert len(cand.samples) >= 2
  374. payment = cand.payment
  375. protocol = cand.protocol
  376. samples = cand.samples
  377. n = len(samples)
  378. pay_mode = (payment.pay_mode or "dj").strip() or "dj"
  379. contract_parts = split_amount_across(_money(payment.contract_amount), n)
  380. groups = _group_details(cand.details, pay_mode=pay_mode)
  381. if not groups:
  382. groups = {1: []}
  383. # 每期应缴:取组代表金额后均分
  384. due_parts_by_no: Dict[int, List[Optional[Decimal]]] = {}
  385. due_date_by_no: Dict[int, Optional[str]] = {}
  386. for schedule_no, rows in groups.items():
  387. due_amount, due_date = _due_from_group(rows, payment=payment, schedule_no=schedule_no)
  388. due_parts_by_no[schedule_no] = split_amount_across(due_amount, n)
  389. due_date_by_no[schedule_no] = due_date
  390. for idx, sample in enumerate(samples):
  391. code = str(sample.sample_code).strip()
  392. sp_id = generate_uuid()
  393. sp = BizProtocolSamplePayment(
  394. id=sp_id,
  395. collection_protocol_id=protocol.id,
  396. protocol_sample_id=sample.id,
  397. sample_code=code,
  398. pay_mode=pay_mode,
  399. contract_amount=contract_parts[idx],
  400. installment_count=payment.installment_count,
  401. installment_interval_months=payment.installment_interval_months,
  402. installment_start_date=payment.installment_start_date,
  403. installment_advance_days=payment.installment_advance_days,
  404. installment_ratios=payment.installment_ratios,
  405. lump_sum_deadline=payment.lump_sum_deadline,
  406. **_new_common(
  407. tenant_no=protocol.tenant_no,
  408. user_id=user_id,
  409. create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
  410. create_by=payment.create_by,
  411. create_time=payment.create_time,
  412. order_num=idx + 1,
  413. remark=_append_remark(payment.remark, f"v1_multi_average:{n}"),
  414. ),
  415. )
  416. if not dry_run:
  417. db.add(sp)
  418. for schedule_no, rows in groups.items():
  419. sch_id = generate_uuid()
  420. sch = BizProtocolPaymentSchedule(
  421. id=sch_id,
  422. sample_payment_id=sp_id,
  423. collection_protocol_id=protocol.id,
  424. sample_code=code,
  425. schedule_no=schedule_no,
  426. due_amount=due_parts_by_no[schedule_no][idx],
  427. due_date=due_date_by_no[schedule_no],
  428. schedule_status="open",
  429. **_new_common(
  430. tenant_no=protocol.tenant_no,
  431. user_id=user_id,
  432. create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
  433. create_by=payment.create_by,
  434. create_time=payment.create_time,
  435. order_num=schedule_no,
  436. remark=None,
  437. ),
  438. )
  439. if not dry_run:
  440. db.add(sch)
  441. for d in rows:
  442. amt_parts = split_amount_across(_money(d.transfer_amount), n)
  443. tr = BizProtocolPaymentTransfer(
  444. id=generate_uuid(),
  445. schedule_id=sch_id,
  446. sample_payment_id=sp_id,
  447. collection_protocol_id=protocol.id,
  448. sample_code=code,
  449. transfer_amount=amt_parts[idx],
  450. transfer_time=d.transfer_time,
  451. transfer_type=d.transfer_type,
  452. transfer_attachments=d.transfer_attachments,
  453. pay_status=(d.pay_status or "dfj").strip() or "dfj",
  454. finance_confirm_attachments=d.finance_confirm_attachments,
  455. finance_remark=d.finance_remark,
  456. **_new_common(
  457. tenant_no=protocol.tenant_no,
  458. user_id=user_id,
  459. create_dept_id=d.create_dept_id or payment.create_dept_id or "",
  460. create_by=d.create_by,
  461. create_time=d.create_time,
  462. order_num=d.order_num,
  463. remark=_append_remark(
  464. d.remark, f"拆自原明细:{d.id} ({idx + 1}/{n})"
  465. ),
  466. ),
  467. )
  468. if not dry_run:
  469. db.add(tr)
  470. if not dry_run:
  471. protocol.payment_model_version = 2
  472. protocol.update_by = user_id
  473. protocol.update_time = datetime.now()
  474. db.add(protocol)
  475. def run_migration(
  476. db,
  477. *,
  478. dry_run: bool,
  479. list_only: bool,
  480. multi_average: bool,
  481. include_open_payments: bool,
  482. protocol_id: Optional[str],
  483. user_id: Optional[str],
  484. list_csv_path: Optional[str],
  485. ) -> MigrateStats:
  486. stats = MigrateStats()
  487. protocols = load_v1_protocols(db, protocol_id=protocol_id)
  488. stats.scanned = len(protocols)
  489. for protocol in protocols:
  490. if has_v2_sample_payment(db, protocol.id):
  491. stats.skipped_already_v2 += 1
  492. continue
  493. cand = build_candidate(db, protocol)
  494. # 可自动迁:单样本无 skip;或多平均且 skip 仅为 multi_sample
  495. can_single = cand.skip_reason is None
  496. can_multi = (
  497. multi_average
  498. and cand.skip_reason == "multi_sample"
  499. and cand.payment is not None
  500. and cand.sample_count >= 2
  501. and all(str(s.sample_code or "").strip() for s in cand.samples)
  502. )
  503. if can_multi and cand.has_open_payment and not include_open_payments:
  504. cand.skip_reason = "multi_sample_open_payment"
  505. can_multi = False
  506. if list_only or (not can_single and not can_multi):
  507. if cand.skip_reason and cand.skip_reason != "already_has_v2":
  508. stats.listed.append(_list_row(cand))
  509. continue
  510. try:
  511. if can_single:
  512. migrate_single_sample(db, cand, user_id=user_id, dry_run=dry_run)
  513. else:
  514. # 临时清掉 skip,便于日志
  515. cand.skip_reason = None
  516. migrate_multi_average(db, cand, user_id=user_id, dry_run=dry_run)
  517. if not dry_run:
  518. db.commit()
  519. else:
  520. db.rollback()
  521. stats.migrated += 1
  522. print(
  523. f"[OK] {'DRY ' if dry_run else ''}migrated "
  524. f"{protocol.protocol_no} ({protocol.id}) samples={cand.sample_count}"
  525. )
  526. except Exception as exc: # noqa: BLE001
  527. db.rollback()
  528. msg = f"[ERR] {protocol.protocol_no} ({protocol.id}): {exc}"
  529. stats.errors.append(msg)
  530. print(msg)
  531. if stats.listed:
  532. print(f"\n=== 人工清单 {len(stats.listed)} 条 ===")
  533. writer = csv.DictWriter(
  534. sys.stdout,
  535. fieldnames=list(stats.listed[0].keys()),
  536. extrasaction="ignore",
  537. )
  538. writer.writeheader()
  539. writer.writerows(stats.listed)
  540. if list_csv_path:
  541. with open(list_csv_path, "w", encoding="utf-8", newline="") as f:
  542. w = csv.DictWriter(f, fieldnames=list(stats.listed[0].keys()))
  543. w.writeheader()
  544. w.writerows(stats.listed)
  545. print(f"\n清单已写入: {list_csv_path}")
  546. return stats
  547. def main(argv: Optional[Sequence[str]] = None) -> int:
  548. parser = argparse.ArgumentParser(description="Migrate protocol payment v1 → v2")
  549. parser.add_argument("--dry-run", action="store_true", help="只演练,不写库")
  550. parser.add_argument("--list-only", action="store_true", help="只输出人工清单,不迁移")
  551. parser.add_argument(
  552. "--multi-average",
  553. action="store_true",
  554. help="多样本无退费时按平均分自动迁移(默认:多样本仅进清单)",
  555. )
  556. parser.add_argument(
  557. "--include-open-payments",
  558. action="store_true",
  559. help="与 --multi-average 联用:允许 dfj/jfqrz 明细一并均分(有财务重复确认风险)",
  560. )
  561. parser.add_argument("--protocol-id", default=None, help="只处理指定协议 ID")
  562. parser.add_argument("--user-id", default="migrate_v1_v2", help="写入 update_by")
  563. parser.add_argument(
  564. "--list-csv",
  565. default=None,
  566. help="人工清单另存 CSV 路径",
  567. )
  568. args = parser.parse_args(argv)
  569. db = SessionLocal()
  570. try:
  571. stats = run_migration(
  572. db,
  573. dry_run=bool(args.dry_run),
  574. list_only=bool(args.list_only),
  575. multi_average=bool(args.multi_average),
  576. include_open_payments=bool(args.include_open_payments),
  577. protocol_id=(args.protocol_id or "").strip() or None,
  578. user_id=(args.user_id or "").strip() or None,
  579. list_csv_path=(args.list_csv or "").strip() or None,
  580. )
  581. finally:
  582. db.close()
  583. print(
  584. "\n=== 汇总 ===\n"
  585. f"scanned_v1={stats.scanned}\n"
  586. f"migrated={stats.migrated}\n"
  587. f"skipped_already_v2_data={stats.skipped_already_v2}\n"
  588. f"manual_list={len(stats.listed)}\n"
  589. f"errors={len(stats.errors)}"
  590. )
  591. return 1 if stats.errors else 0
  592. if __name__ == "__main__":
  593. raise SystemExit(main())