| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662 |
- #!/usr/bin/env python3
- """采集协议单缴费模型 v1 → v2 迁移(生产专用,可重复执行)。
- 规则(与 docs/superpowers/specs/2026-10-15-protocol-save-auto-submit-and-v1-to-v2-migration-design.md 一致):
- - 单样本 + 无退费:1:1 迁到 sample_payment / schedule / transfer,并置 payment_model_version=2
- - 多样本 + 无退费:默认只进人工清单;加 --multi-average 时按样本数平均分合同/应缴/转账
- - 有退费痕迹 / 无有效样本 / 无样本编码 / 无 v1 缴费主表:只进人工清单
- - 不物理删除 v1 payment / payment_detail
- 用法(在 backend 目录、已配置 .env):
- python -m scripts.migrate_protocol_payment_v1_to_v2 --list-only
- python -m scripts.migrate_protocol_payment_v1_to_v2 --dry-run
- python -m scripts.migrate_protocol_payment_v1_to_v2
- python -m scripts.migrate_protocol_payment_v1_to_v2 --multi-average
- python -m scripts.migrate_protocol_payment_v1_to_v2 --multi-average --include-open-payments
- python -m scripts.migrate_protocol_payment_v1_to_v2 --protocol-id <ID>
- """
- from __future__ import annotations
- import argparse
- import csv
- import os
- import sys
- from collections import defaultdict
- from dataclasses import dataclass, field
- from datetime import datetime
- from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
- from app.core.database import SessionLocal
- from app.core.utils import generate_uuid
- from app.models.models import (
- BizCollectionProtocol,
- BizProtocolPayment,
- BizProtocolPaymentDetail,
- BizProtocolPaymentSchedule,
- BizProtocolPaymentTransfer,
- BizProtocolSample,
- BizProtocolSamplePayment,
- )
- REFUND_STATUSES = frozenset({"tfqrz", "ytf"})
- OPEN_STATUSES = frozenset({"dfj", "jfqrz"})
- VOID_SAMPLE_STATUS = "yzf"
- CENTS = Decimal("0.01")
- def _dec(value: Any) -> Optional[Decimal]:
- if value is None or value == "":
- return None
- try:
- return Decimal(str(value))
- except Exception:
- return None
- def _money(value: Any) -> Optional[Decimal]:
- d = _dec(value)
- if d is None:
- return None
- return d.quantize(CENTS, rounding=ROUND_HALF_UP)
- def split_amount_across(total: Optional[Decimal], n: int) -> List[Optional[Decimal]]:
- """金额按分均分,余数落在最后一项;total 为空则返回全 None。"""
- if n <= 0:
- raise ValueError("n must be positive")
- if total is None:
- return [None] * n
- cents = int((total.quantize(CENTS, rounding=ROUND_HALF_UP) * 100).to_integral_value(rounding=ROUND_DOWN))
- base, rem = divmod(cents, n)
- out: List[Optional[Decimal]] = []
- for i in range(n):
- c = base + (rem if i == n - 1 else 0)
- out.append((Decimal(c) / Decimal(100)).quantize(CENTS))
- return out
- def detail_has_refund(d: BizProtocolPaymentDetail) -> bool:
- status = (d.pay_status or "").strip()
- if status in REFUND_STATUSES:
- return True
- amt = _dec(d.refund_amount)
- if amt is not None and amt > 0:
- return True
- for attr in (
- "refund_attachments",
- "refund_remark",
- "refund_finance_confirm_attachments",
- "refund_finance_remark",
- ):
- if str(getattr(d, attr, None) or "").strip():
- return True
- return False
- def _is_active_sample(s: BizProtocolSample) -> bool:
- if int(s.del_flag or 0) != 0:
- return False
- status = (s.sample_status or "").strip()
- if status == VOID_SAMPLE_STATUS:
- return False
- return True
- def _schedule_no_of(d: BizProtocolPaymentDetail, *, pay_mode: str) -> int:
- if (pay_mode or "").strip() != "fq":
- return 1
- no = int(d.installment_no or 0)
- return no if no > 0 else 1
- def _append_remark(existing: Optional[str], extra: str) -> str:
- base = (existing or "").strip()
- if not base:
- return extra[:512]
- joined = f"{base} | {extra}"
- return joined[:512]
- @dataclass
- class ProtocolCandidate:
- protocol: BizCollectionProtocol
- samples: List[BizProtocolSample]
- payment: Optional[BizProtocolPayment]
- details: List[BizProtocolPaymentDetail]
- skip_reason: Optional[str] = None
- has_open_payment: bool = False
- @property
- def sample_count(self) -> int:
- return len(self.samples)
- @property
- def protocol_id(self) -> str:
- return str(self.protocol.id)
- @dataclass
- class MigrateStats:
- scanned: int = 0
- migrated: int = 0
- skipped_already_v2: int = 0
- listed: List[Dict[str, Any]] = field(default_factory=list)
- errors: List[str] = field(default_factory=list)
- def load_v1_protocols(
- db,
- *,
- protocol_id: Optional[str] = None,
- ) -> List[BizCollectionProtocol]:
- q = db.query(BizCollectionProtocol).filter(
- BizCollectionProtocol.del_flag == 0,
- BizCollectionProtocol.payment_model_version == 1,
- )
- if protocol_id:
- q = q.filter(BizCollectionProtocol.id == protocol_id)
- return q.order_by(BizCollectionProtocol.create_time.asc()).all()
- def has_v2_sample_payment(db, protocol_id: str) -> bool:
- row = (
- db.query(BizProtocolSamplePayment.id)
- .filter(
- BizProtocolSamplePayment.collection_protocol_id == protocol_id,
- BizProtocolSamplePayment.del_flag == 0,
- )
- .first()
- )
- return row is not None
- def build_candidate(db, protocol: BizCollectionProtocol) -> ProtocolCandidate:
- samples = (
- db.query(BizProtocolSample)
- .filter(
- BizProtocolSample.collection_protocol_id == protocol.id,
- BizProtocolSample.del_flag == 0,
- )
- .order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc())
- .all()
- )
- active = [s for s in samples if _is_active_sample(s)]
- payment = (
- db.query(BizProtocolPayment)
- .filter(
- BizProtocolPayment.collection_protocol_id == protocol.id,
- BizProtocolPayment.del_flag == 0,
- )
- .order_by(BizProtocolPayment.create_time.asc())
- .first()
- )
- details: List[BizProtocolPaymentDetail] = []
- if payment:
- details = (
- db.query(BizProtocolPaymentDetail)
- .filter(
- BizProtocolPaymentDetail.payment_id == payment.id,
- BizProtocolPaymentDetail.del_flag == 0,
- )
- .order_by(
- BizProtocolPaymentDetail.installment_no.asc(),
- BizProtocolPaymentDetail.create_time.asc(),
- )
- .all()
- )
- cand = ProtocolCandidate(
- protocol=protocol,
- samples=active,
- payment=payment,
- details=details,
- has_open_payment=any((d.pay_status or "").strip() in OPEN_STATUSES for d in details),
- )
- if has_v2_sample_payment(db, protocol.id):
- cand.skip_reason = "already_has_v2"
- return cand
- if not payment:
- cand.skip_reason = "no_v1_payment"
- return cand
- if not active:
- cand.skip_reason = "no_sample"
- return cand
- if any(not str(s.sample_code or "").strip() for s in active):
- cand.skip_reason = "no_sample_code"
- return cand
- if any(detail_has_refund(d) for d in details):
- cand.skip_reason = "has_refund"
- return cand
- if len(active) != 1:
- cand.skip_reason = "multi_sample"
- return cand
- return cand
- def _list_row(cand: ProtocolCandidate) -> Dict[str, Any]:
- p = cand.protocol
- pay = cand.payment
- return {
- "protocol_id": p.id,
- "protocol_no": p.protocol_no,
- "tenant_no": p.tenant_no,
- "sample_count": cand.sample_count,
- "sample_codes": ",".join(str(s.sample_code or "") for s in cand.samples),
- "contract_amount": str(pay.contract_amount) if pay and pay.contract_amount is not None else "",
- "detail_count": len(cand.details),
- "has_open_payment": "1" if cand.has_open_payment else "0",
- "skip_reason": cand.skip_reason or "",
- }
- def _new_common(
- *,
- tenant_no: str,
- user_id: Optional[str],
- create_dept_id: str,
- create_by: Optional[str],
- create_time: Optional[datetime],
- order_num: Optional[int],
- remark: Optional[str],
- ) -> Dict[str, Any]:
- now = datetime.now()
- return {
- "tenant_no": tenant_no,
- "order_num": order_num,
- "remark": remark,
- "create_by": create_by or user_id,
- "create_dept_id": (create_dept_id or "").strip(),
- "create_time": create_time or now,
- "update_by": user_id,
- "update_time": now,
- "del_flag": 0,
- }
- def _group_details(
- details: Sequence[BizProtocolPaymentDetail], *, pay_mode: str
- ) -> Dict[int, List[BizProtocolPaymentDetail]]:
- groups: Dict[int, List[BizProtocolPaymentDetail]] = defaultdict(list)
- for d in details:
- groups[_schedule_no_of(d, pay_mode=pay_mode)].append(d)
- return dict(sorted(groups.items(), key=lambda x: x[0]))
- def _due_from_group(
- rows: Sequence[BizProtocolPaymentDetail],
- *,
- payment: BizProtocolPayment,
- schedule_no: int,
- ) -> Tuple[Optional[Decimal], Optional[str]]:
- due_amount = None
- due_date = None
- for d in rows:
- if due_amount is None and d.due_amount is not None:
- due_amount = _money(d.due_amount)
- if not due_date and str(d.installment_due_date or "").strip():
- due_date = str(d.installment_due_date).strip()
- if due_amount is None and schedule_no == 1 and (payment.pay_mode or "").strip() != "fq":
- due_amount = _money(payment.contract_amount)
- if not due_date and (payment.pay_mode or "").strip() != "fq":
- due_date = (payment.lump_sum_deadline or "").strip() or None
- return due_amount, due_date
- def migrate_single_sample(
- db,
- cand: ProtocolCandidate,
- *,
- user_id: Optional[str],
- dry_run: bool,
- ) -> None:
- assert cand.payment is not None
- assert len(cand.samples) == 1
- sample = cand.samples[0]
- payment = cand.payment
- protocol = cand.protocol
- pay_mode = (payment.pay_mode or "dj").strip() or "dj"
- common = _new_common(
- tenant_no=protocol.tenant_no,
- user_id=user_id,
- create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
- create_by=payment.create_by,
- create_time=payment.create_time,
- order_num=payment.order_num,
- remark=payment.remark,
- )
- sp_id = generate_uuid()
- sp = BizProtocolSamplePayment(
- id=sp_id,
- collection_protocol_id=protocol.id,
- protocol_sample_id=sample.id,
- sample_code=str(sample.sample_code).strip(),
- pay_mode=pay_mode,
- contract_amount=payment.contract_amount,
- installment_count=payment.installment_count,
- installment_interval_months=payment.installment_interval_months,
- installment_start_date=payment.installment_start_date,
- installment_advance_days=payment.installment_advance_days,
- installment_ratios=payment.installment_ratios,
- lump_sum_deadline=payment.lump_sum_deadline,
- **common,
- )
- if not dry_run:
- db.add(sp)
- groups = _group_details(cand.details, pay_mode=pay_mode)
- if not groups:
- # 无明细:仍建一期空 schedule,保证方案可编辑
- groups = {1: []}
- for schedule_no, rows in groups.items():
- due_amount, due_date = _due_from_group(rows, payment=payment, schedule_no=schedule_no)
- sch_id = generate_uuid()
- sch = BizProtocolPaymentSchedule(
- id=sch_id,
- sample_payment_id=sp_id,
- collection_protocol_id=protocol.id,
- sample_code=str(sample.sample_code).strip(),
- schedule_no=schedule_no,
- due_amount=due_amount,
- due_date=due_date,
- schedule_status="open",
- **_new_common(
- tenant_no=protocol.tenant_no,
- user_id=user_id,
- create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
- create_by=payment.create_by,
- create_time=payment.create_time,
- order_num=schedule_no,
- remark=None,
- ),
- )
- if not dry_run:
- db.add(sch)
- for d in rows:
- tr = BizProtocolPaymentTransfer(
- id=generate_uuid(),
- schedule_id=sch_id,
- sample_payment_id=sp_id,
- collection_protocol_id=protocol.id,
- sample_code=str(sample.sample_code).strip(),
- transfer_amount=d.transfer_amount,
- transfer_time=d.transfer_time,
- transfer_type=d.transfer_type,
- transfer_attachments=d.transfer_attachments,
- pay_status=(d.pay_status or "dfj").strip() or "dfj",
- finance_confirm_attachments=d.finance_confirm_attachments,
- finance_remark=d.finance_remark,
- **_new_common(
- tenant_no=protocol.tenant_no,
- user_id=user_id,
- create_dept_id=d.create_dept_id or payment.create_dept_id or "",
- create_by=d.create_by,
- create_time=d.create_time,
- order_num=d.order_num,
- remark=_append_remark(d.remark, f"migrated_from_v1_detail:{d.id}"),
- ),
- )
- if not dry_run:
- db.add(tr)
- if not dry_run:
- protocol.payment_model_version = 2
- protocol.update_by = user_id
- protocol.update_time = datetime.now()
- db.add(protocol)
- def migrate_multi_average(
- db,
- cand: ProtocolCandidate,
- *,
- user_id: Optional[str],
- dry_run: bool,
- ) -> None:
- """多样本:合同/应缴/转账金额均分;状态与凭证复制;备注标注来源明细。"""
- assert cand.payment is not None
- assert len(cand.samples) >= 2
- payment = cand.payment
- protocol = cand.protocol
- samples = cand.samples
- n = len(samples)
- pay_mode = (payment.pay_mode or "dj").strip() or "dj"
- contract_parts = split_amount_across(_money(payment.contract_amount), n)
- groups = _group_details(cand.details, pay_mode=pay_mode)
- if not groups:
- groups = {1: []}
- # 每期应缴:取组代表金额后均分
- due_parts_by_no: Dict[int, List[Optional[Decimal]]] = {}
- due_date_by_no: Dict[int, Optional[str]] = {}
- for schedule_no, rows in groups.items():
- due_amount, due_date = _due_from_group(rows, payment=payment, schedule_no=schedule_no)
- due_parts_by_no[schedule_no] = split_amount_across(due_amount, n)
- due_date_by_no[schedule_no] = due_date
- for idx, sample in enumerate(samples):
- code = str(sample.sample_code).strip()
- sp_id = generate_uuid()
- sp = BizProtocolSamplePayment(
- id=sp_id,
- collection_protocol_id=protocol.id,
- protocol_sample_id=sample.id,
- sample_code=code,
- pay_mode=pay_mode,
- contract_amount=contract_parts[idx],
- installment_count=payment.installment_count,
- installment_interval_months=payment.installment_interval_months,
- installment_start_date=payment.installment_start_date,
- installment_advance_days=payment.installment_advance_days,
- installment_ratios=payment.installment_ratios,
- lump_sum_deadline=payment.lump_sum_deadline,
- **_new_common(
- tenant_no=protocol.tenant_no,
- user_id=user_id,
- create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
- create_by=payment.create_by,
- create_time=payment.create_time,
- order_num=idx + 1,
- remark=_append_remark(payment.remark, f"v1_multi_average:{n}"),
- ),
- )
- if not dry_run:
- db.add(sp)
- for schedule_no, rows in groups.items():
- sch_id = generate_uuid()
- sch = BizProtocolPaymentSchedule(
- id=sch_id,
- sample_payment_id=sp_id,
- collection_protocol_id=protocol.id,
- sample_code=code,
- schedule_no=schedule_no,
- due_amount=due_parts_by_no[schedule_no][idx],
- due_date=due_date_by_no[schedule_no],
- schedule_status="open",
- **_new_common(
- tenant_no=protocol.tenant_no,
- user_id=user_id,
- create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
- create_by=payment.create_by,
- create_time=payment.create_time,
- order_num=schedule_no,
- remark=None,
- ),
- )
- if not dry_run:
- db.add(sch)
- for d in rows:
- amt_parts = split_amount_across(_money(d.transfer_amount), n)
- tr = BizProtocolPaymentTransfer(
- id=generate_uuid(),
- schedule_id=sch_id,
- sample_payment_id=sp_id,
- collection_protocol_id=protocol.id,
- sample_code=code,
- transfer_amount=amt_parts[idx],
- transfer_time=d.transfer_time,
- transfer_type=d.transfer_type,
- transfer_attachments=d.transfer_attachments,
- pay_status=(d.pay_status or "dfj").strip() or "dfj",
- finance_confirm_attachments=d.finance_confirm_attachments,
- finance_remark=d.finance_remark,
- **_new_common(
- tenant_no=protocol.tenant_no,
- user_id=user_id,
- create_dept_id=d.create_dept_id or payment.create_dept_id or "",
- create_by=d.create_by,
- create_time=d.create_time,
- order_num=d.order_num,
- remark=_append_remark(
- d.remark, f"拆自原明细:{d.id} ({idx + 1}/{n})"
- ),
- ),
- )
- if not dry_run:
- db.add(tr)
- if not dry_run:
- protocol.payment_model_version = 2
- protocol.update_by = user_id
- protocol.update_time = datetime.now()
- db.add(protocol)
- def run_migration(
- db,
- *,
- dry_run: bool,
- list_only: bool,
- multi_average: bool,
- include_open_payments: bool,
- protocol_id: Optional[str],
- user_id: Optional[str],
- list_csv_path: Optional[str],
- ) -> MigrateStats:
- stats = MigrateStats()
- protocols = load_v1_protocols(db, protocol_id=protocol_id)
- stats.scanned = len(protocols)
- for protocol in protocols:
- if has_v2_sample_payment(db, protocol.id):
- stats.skipped_already_v2 += 1
- continue
- cand = build_candidate(db, protocol)
- # 可自动迁:单样本无 skip;或多平均且 skip 仅为 multi_sample
- can_single = cand.skip_reason is None
- can_multi = (
- multi_average
- and cand.skip_reason == "multi_sample"
- and cand.payment is not None
- and cand.sample_count >= 2
- and all(str(s.sample_code or "").strip() for s in cand.samples)
- )
- if can_multi and cand.has_open_payment and not include_open_payments:
- cand.skip_reason = "multi_sample_open_payment"
- can_multi = False
- if list_only or (not can_single and not can_multi):
- if cand.skip_reason and cand.skip_reason != "already_has_v2":
- stats.listed.append(_list_row(cand))
- continue
- try:
- if can_single:
- migrate_single_sample(db, cand, user_id=user_id, dry_run=dry_run)
- else:
- # 临时清掉 skip,便于日志
- cand.skip_reason = None
- migrate_multi_average(db, cand, user_id=user_id, dry_run=dry_run)
- if not dry_run:
- db.commit()
- else:
- db.rollback()
- stats.migrated += 1
- print(
- f"[OK] {'DRY ' if dry_run else ''}migrated "
- f"{protocol.protocol_no} ({protocol.id}) samples={cand.sample_count}"
- )
- except Exception as exc: # noqa: BLE001
- db.rollback()
- msg = f"[ERR] {protocol.protocol_no} ({protocol.id}): {exc}"
- stats.errors.append(msg)
- print(msg)
- if stats.listed:
- print(f"\n=== 人工清单 {len(stats.listed)} 条 ===")
- writer = csv.DictWriter(
- sys.stdout,
- fieldnames=list(stats.listed[0].keys()),
- extrasaction="ignore",
- )
- writer.writeheader()
- writer.writerows(stats.listed)
- if list_csv_path:
- with open(list_csv_path, "w", encoding="utf-8", newline="") as f:
- w = csv.DictWriter(f, fieldnames=list(stats.listed[0].keys()))
- w.writeheader()
- w.writerows(stats.listed)
- print(f"\n清单已写入: {list_csv_path}")
- return stats
- def main(argv: Optional[Sequence[str]] = None) -> int:
- parser = argparse.ArgumentParser(description="Migrate protocol payment v1 → v2")
- parser.add_argument("--dry-run", action="store_true", help="只演练,不写库")
- parser.add_argument("--list-only", action="store_true", help="只输出人工清单,不迁移")
- parser.add_argument(
- "--multi-average",
- action="store_true",
- help="多样本无退费时按平均分自动迁移(默认:多样本仅进清单)",
- )
- parser.add_argument(
- "--include-open-payments",
- action="store_true",
- help="与 --multi-average 联用:允许 dfj/jfqrz 明细一并均分(有财务重复确认风险)",
- )
- parser.add_argument("--protocol-id", default=None, help="只处理指定协议 ID")
- parser.add_argument("--user-id", default="migrate_v1_v2", help="写入 update_by")
- parser.add_argument(
- "--list-csv",
- default=None,
- help="人工清单另存 CSV 路径",
- )
- args = parser.parse_args(argv)
- db = SessionLocal()
- try:
- stats = run_migration(
- db,
- dry_run=bool(args.dry_run),
- list_only=bool(args.list_only),
- multi_average=bool(args.multi_average),
- include_open_payments=bool(args.include_open_payments),
- protocol_id=(args.protocol_id or "").strip() or None,
- user_id=(args.user_id or "").strip() or None,
- list_csv_path=(args.list_csv or "").strip() or None,
- )
- finally:
- db.close()
- print(
- "\n=== 汇总 ===\n"
- f"scanned_v1={stats.scanned}\n"
- f"migrated={stats.migrated}\n"
- f"skipped_already_v2_data={stats.skipped_already_v2}\n"
- f"manual_list={len(stats.listed)}\n"
- f"errors={len(stats.errors)}"
- )
- return 1 if stats.errors else 0
- if __name__ == "__main__":
- raise SystemExit(main())
|