| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- #!/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()
|