#!/usr/bin/env python3 """将历史运输计划迁移为「1 装货段 + 1 送货段」多段模型。 规则: - 无 pl_plan_load 的计划:supplier → 装货段,customer/destination → 送货段 seq=1 - 复制 pl_plan_product 到装货/送货段产品行(plan_qty = actual_qty 或 quantity) - pl_plan_contract:supplier → side=load,demander → side=delivery,并写入 stop_id - 已归档计划:inbound_* 写入装货段产品,outbound_* 写入送货段产品 用法: cd backend && source venv/bin/activate python scripts/migrate_pl_plan_to_multi_stop.py --dry-run ENV_FILE=.env.production python scripts/migrate_pl_plan_to_multi_stop.py 环境变量: ENV_FILE / DATABASE_URL 目标库(由 app Settings 读取) """ from __future__ import annotations import argparse import os import sys from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal from pathlib import Path from typing import Optional BACKEND_ROOT = Path(__file__).resolve().parents[1] if str(BACKEND_ROOT) not in sys.path: sys.path.insert(0, str(BACKEND_ROOT)) from sqlalchemy.orm import Session from app.core.database import SessionLocal from app.core.utils import generate_uuid from app.models.pl_models import ( PlPlan, PlPlanContract, PlPlanDelivery, PlPlanDeliveryProduct, PlPlanLoad, PlPlanLoadProduct, PlPlanProduct, ) from app.services.pl_plan_stop_rules import resolve_stop_dibang_defaults from app.services.pl_plan_service import PLAN_CONTRACT_SIDE_DEMANDER, PLAN_CONTRACT_SIDE_SUPPLIER from app.services.pl_plan_stop_service import PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_LOAD @dataclass class MigrationStats: plans_scanned: int = 0 plans_migrated: int = 0 loads_created: int = 0 deliveries_created: int = 0 load_products_created: int = 0 delivery_products_created: int = 0 contracts_updated: int = 0 skipped_existing_load: int = 0 warnings: list[str] = field(default_factory=list) def _plan_qty(prod: PlPlanProduct) -> Optional[Decimal]: if prod.actual_qty is not None: return prod.actual_qty return prod.quantity def _migrate_plan(db: Session, plan: PlPlan, stats: MigrationStats, dry_run: bool) -> None: existing_load = ( db.query(PlPlanLoad) .filter( PlPlanLoad.plan_id == plan.id, PlPlanLoad.tenant_no == plan.tenant_no, PlPlanLoad.del_flag == 0, ) .first() ) if existing_load: stats.skipped_existing_load += 1 return products = ( db.query(PlPlanProduct) .filter( PlPlanProduct.plan_id == plan.id, PlPlanProduct.del_flag == 0, ) .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc()) .all() ) now = datetime.now() archived = (plan.archive_status or "").strip() == "archived" load_customer_name = (plan.supplier_name or "").strip() or None default_gen, default_dir = resolve_stop_dibang_defaults(load_customer_name, "load") load_id = generate_uuid() load_row = PlPlanLoad( id=load_id, plan_id=plan.id, customer_id=plan.supplier_id, customer_name=plan.supplier_name, address=plan.pickup_address, lng=plan.pickup_lng, lat=plan.pickup_lat, load_time=plan.pickup_time, contact=plan.pickup_contact, notes=plan.pickup_notes, generate_dibang=1 if default_gen else 0, dibang_direction=default_dir or "none", tenant_no=plan.tenant_no, order_num=0, remark=None, create_by=plan.create_by, create_dept_id=plan.create_dept_id or "", create_time=now, update_by=plan.update_by, update_time=now, del_flag=0, ) delivery_customer_name = (plan.customer_name or plan.destination_name or "").strip() or None del_gen, del_dir = resolve_stop_dibang_defaults(delivery_customer_name, "delivery") delivery_id = generate_uuid() delivery_row = PlPlanDelivery( id=delivery_id, plan_id=plan.id, seq=1, customer_id=plan.customer_id or plan.destination_id, customer_name=plan.customer_name or plan.destination_name, address=plan.destination_address, lng=plan.destination_lng, lat=plan.destination_lat, delivery_time=plan.delivery_time, contact=plan.delivery_contact, notes=plan.destination_notes, generate_dibang=1 if del_gen else 0, dibang_direction=del_dir or "none", tenant_no=plan.tenant_no, order_num=0, remark=None, create_by=plan.create_by, create_dept_id=plan.create_dept_id or "", create_time=now, update_by=plan.update_by, update_time=now, del_flag=0, ) load_products: list[PlPlanLoadProduct] = [] delivery_products: list[PlPlanDeliveryProduct] = [] for idx, prod in enumerate(products): load_prod = PlPlanLoadProduct( id=generate_uuid(), load_id=load_id, plan_id=plan.id, plan_product_id=prod.id, product_id=prod.product_id, product_no=prod.product_no, product_name=prod.product_name, model=prod.model, unit=prod.unit or "吨", package_spec=prod.package_spec, delivery_method=prod.delivery_method, plan_qty=_plan_qty(prod), inbound_qty=prod.inbound_qty if archived else None, inbound_price=prod.inbound_price if archived else None, inbound_amount=prod.inbound_amount if archived else None, tenant_no=plan.tenant_no, order_num=idx, remark=prod.remark, create_by=plan.create_by, create_dept_id=plan.create_dept_id or "", create_time=now, update_by=plan.update_by, update_time=now, del_flag=0, ) load_products.append(load_prod) delivery_prod = PlPlanDeliveryProduct( id=generate_uuid(), delivery_id=delivery_id, plan_id=plan.id, plan_product_id=prod.id, product_id=prod.product_id, product_no=prod.product_no, product_name=prod.product_name, model=prod.model, unit=prod.unit or "吨", package_spec=prod.package_spec, delivery_method=prod.delivery_method, plan_qty=_plan_qty(prod), outbound_qty=prod.outbound_qty if archived else None, outbound_price=prod.outbound_price if archived else None, outbound_amount=prod.outbound_amount if archived else None, tenant_no=plan.tenant_no, order_num=idx, remark=prod.remark, create_by=plan.create_by, create_dept_id=plan.create_dept_id or "", create_time=now, update_by=plan.update_by, update_time=now, del_flag=0, ) delivery_products.append(delivery_prod) contract_rows = ( db.query(PlPlanContract) .filter( PlPlanContract.plan_id == plan.id, PlPlanContract.tenant_no == plan.tenant_no, PlPlanContract.del_flag == 0, ) .all() ) contracts_to_update: list[PlPlanContract] = [] for row in contract_rows: if (row.stop_id or "").strip(): continue side = (row.side or "").strip() if side == PLAN_CONTRACT_SIDE_SUPPLIER: row.side = PLAN_CONTRACT_SIDE_LOAD row.stop_id = load_id contracts_to_update.append(row) elif side == PLAN_CONTRACT_SIDE_DEMANDER: row.side = PLAN_CONTRACT_SIDE_DELIVERY row.stop_id = delivery_id contracts_to_update.append(row) if not dry_run: db.add(load_row) db.add(delivery_row) for lp in load_products: db.add(lp) for dp in delivery_products: db.add(dp) for row in contracts_to_update: row.update_by = plan.update_by row.update_time = now stats.plans_migrated += 1 stats.loads_created += 1 stats.deliveries_created += 1 stats.load_products_created += len(load_products) stats.delivery_products_created += len(delivery_products) stats.contracts_updated += len(contracts_to_update) def run_migration(tenant_no: Optional[str], dry_run: bool) -> MigrationStats: stats = MigrationStats() db = SessionLocal() try: query = db.query(PlPlan).filter(PlPlan.del_flag == 0) if tenant_no: query = query.filter(PlPlan.tenant_no == tenant_no) plans = query.order_by(PlPlan.create_time.asc()).all() stats.plans_scanned = len(plans) for plan in plans: _migrate_plan(db, plan, stats, dry_run) if dry_run: db.rollback() else: db.commit() except Exception: db.rollback() raise finally: db.close() return stats def print_stats(stats: MigrationStats, dry_run: bool) -> None: mode = "DRY-RUN" if dry_run else "COMMITTED" print(f"=== migrate_pl_plan_to_multi_stop ({mode}) ===") print(f"plans_scanned: {stats.plans_scanned}") print(f"plans_migrated: {stats.plans_migrated}") print(f"skipped_existing_load: {stats.skipped_existing_load}") print(f"loads_created: {stats.loads_created}") print(f"deliveries_created: {stats.deliveries_created}") print(f"load_products_created: {stats.load_products_created}") print(f"delivery_products_created: {stats.delivery_products_created}") print(f"contracts_updated: {stats.contracts_updated}") if stats.warnings: print("warnings:") for w in stats.warnings: print(f" - {w}") def main() -> None: parser = argparse.ArgumentParser(description="迁移运输计划至装货/送货多段模型") parser.add_argument("--dry-run", action="store_true", help="仅统计不写库") parser.add_argument("--tenant-no", default=None, help="仅迁移指定租户") parser.add_argument( "--env-file", default=os.environ.get("ENV_FILE"), help="环境配置文件路径(如 .env.production)", ) args = parser.parse_args() if args.env_file: os.environ["ENV_FILE"] = args.env_file stats = run_migration(args.tenant_no, args.dry_run) print_stats(stats, args.dry_run) if __name__ == "__main__": main()