migrate_pl_plan_to_multi_stop.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. #!/usr/bin/env python3
  2. """将历史运输计划迁移为「1 装货段 + 1 送货段」多段模型。
  3. 规则:
  4. - 无 pl_plan_load 的计划:supplier → 装货段,customer/destination → 送货段 seq=1
  5. - 复制 pl_plan_product 到装货/送货段产品行(plan_qty = actual_qty 或 quantity)
  6. - pl_plan_contract:supplier → side=load,demander → side=delivery,并写入 stop_id
  7. - 已归档计划:inbound_* 写入装货段产品,outbound_* 写入送货段产品
  8. 用法:
  9. cd backend && source venv/bin/activate
  10. python scripts/migrate_pl_plan_to_multi_stop.py --dry-run
  11. ENV_FILE=.env.production python scripts/migrate_pl_plan_to_multi_stop.py
  12. 环境变量:
  13. ENV_FILE / DATABASE_URL 目标库(由 app Settings 读取)
  14. """
  15. from __future__ import annotations
  16. import argparse
  17. import os
  18. import sys
  19. from dataclasses import dataclass, field
  20. from datetime import datetime
  21. from decimal import Decimal
  22. from pathlib import Path
  23. from typing import Optional
  24. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  25. if str(BACKEND_ROOT) not in sys.path:
  26. sys.path.insert(0, str(BACKEND_ROOT))
  27. from sqlalchemy.orm import Session
  28. from app.core.database import SessionLocal
  29. from app.core.utils import generate_uuid
  30. from app.models.pl_models import (
  31. PlPlan,
  32. PlPlanContract,
  33. PlPlanDelivery,
  34. PlPlanDeliveryProduct,
  35. PlPlanLoad,
  36. PlPlanLoadProduct,
  37. PlPlanProduct,
  38. )
  39. from app.services.pl_plan_stop_rules import resolve_stop_dibang_defaults
  40. from app.services.pl_plan_service import PLAN_CONTRACT_SIDE_DEMANDER, PLAN_CONTRACT_SIDE_SUPPLIER
  41. from app.services.pl_plan_stop_service import PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_LOAD
  42. @dataclass
  43. class MigrationStats:
  44. plans_scanned: int = 0
  45. plans_migrated: int = 0
  46. loads_created: int = 0
  47. deliveries_created: int = 0
  48. load_products_created: int = 0
  49. delivery_products_created: int = 0
  50. contracts_updated: int = 0
  51. skipped_existing_load: int = 0
  52. warnings: list[str] = field(default_factory=list)
  53. def _plan_qty(prod: PlPlanProduct) -> Optional[Decimal]:
  54. if prod.actual_qty is not None:
  55. return prod.actual_qty
  56. return prod.quantity
  57. def _migrate_plan(db: Session, plan: PlPlan, stats: MigrationStats, dry_run: bool) -> None:
  58. existing_load = (
  59. db.query(PlPlanLoad)
  60. .filter(
  61. PlPlanLoad.plan_id == plan.id,
  62. PlPlanLoad.tenant_no == plan.tenant_no,
  63. PlPlanLoad.del_flag == 0,
  64. )
  65. .first()
  66. )
  67. if existing_load:
  68. stats.skipped_existing_load += 1
  69. return
  70. products = (
  71. db.query(PlPlanProduct)
  72. .filter(
  73. PlPlanProduct.plan_id == plan.id,
  74. PlPlanProduct.del_flag == 0,
  75. )
  76. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  77. .all()
  78. )
  79. now = datetime.now()
  80. archived = (plan.archive_status or "").strip() == "archived"
  81. load_customer_name = (plan.supplier_name or "").strip() or None
  82. default_gen, default_dir = resolve_stop_dibang_defaults(load_customer_name, "load")
  83. load_id = generate_uuid()
  84. load_row = PlPlanLoad(
  85. id=load_id,
  86. plan_id=plan.id,
  87. customer_id=plan.supplier_id,
  88. customer_name=plan.supplier_name,
  89. address=plan.pickup_address,
  90. lng=plan.pickup_lng,
  91. lat=plan.pickup_lat,
  92. load_time=plan.pickup_time,
  93. contact=plan.pickup_contact,
  94. notes=plan.pickup_notes,
  95. generate_dibang=1 if default_gen else 0,
  96. dibang_direction=default_dir or "none",
  97. tenant_no=plan.tenant_no,
  98. order_num=0,
  99. remark=None,
  100. create_by=plan.create_by,
  101. create_dept_id=plan.create_dept_id or "",
  102. create_time=now,
  103. update_by=plan.update_by,
  104. update_time=now,
  105. del_flag=0,
  106. )
  107. delivery_customer_name = (plan.customer_name or plan.destination_name or "").strip() or None
  108. del_gen, del_dir = resolve_stop_dibang_defaults(delivery_customer_name, "delivery")
  109. delivery_id = generate_uuid()
  110. delivery_row = PlPlanDelivery(
  111. id=delivery_id,
  112. plan_id=plan.id,
  113. seq=1,
  114. customer_id=plan.customer_id or plan.destination_id,
  115. customer_name=plan.customer_name or plan.destination_name,
  116. address=plan.destination_address,
  117. lng=plan.destination_lng,
  118. lat=plan.destination_lat,
  119. delivery_time=plan.delivery_time,
  120. contact=plan.delivery_contact,
  121. notes=plan.destination_notes,
  122. generate_dibang=1 if del_gen else 0,
  123. dibang_direction=del_dir or "none",
  124. tenant_no=plan.tenant_no,
  125. order_num=0,
  126. remark=None,
  127. create_by=plan.create_by,
  128. create_dept_id=plan.create_dept_id or "",
  129. create_time=now,
  130. update_by=plan.update_by,
  131. update_time=now,
  132. del_flag=0,
  133. )
  134. load_products: list[PlPlanLoadProduct] = []
  135. delivery_products: list[PlPlanDeliveryProduct] = []
  136. for idx, prod in enumerate(products):
  137. load_prod = PlPlanLoadProduct(
  138. id=generate_uuid(),
  139. load_id=load_id,
  140. plan_id=plan.id,
  141. plan_product_id=prod.id,
  142. product_id=prod.product_id,
  143. product_no=prod.product_no,
  144. product_name=prod.product_name,
  145. model=prod.model,
  146. unit=prod.unit or "吨",
  147. package_spec=prod.package_spec,
  148. delivery_method=prod.delivery_method,
  149. plan_qty=_plan_qty(prod),
  150. inbound_qty=prod.inbound_qty if archived else None,
  151. inbound_price=prod.inbound_price if archived else None,
  152. inbound_amount=prod.inbound_amount if archived else None,
  153. tenant_no=plan.tenant_no,
  154. order_num=idx,
  155. remark=prod.remark,
  156. create_by=plan.create_by,
  157. create_dept_id=plan.create_dept_id or "",
  158. create_time=now,
  159. update_by=plan.update_by,
  160. update_time=now,
  161. del_flag=0,
  162. )
  163. load_products.append(load_prod)
  164. delivery_prod = PlPlanDeliveryProduct(
  165. id=generate_uuid(),
  166. delivery_id=delivery_id,
  167. plan_id=plan.id,
  168. plan_product_id=prod.id,
  169. product_id=prod.product_id,
  170. product_no=prod.product_no,
  171. product_name=prod.product_name,
  172. model=prod.model,
  173. unit=prod.unit or "吨",
  174. package_spec=prod.package_spec,
  175. delivery_method=prod.delivery_method,
  176. plan_qty=_plan_qty(prod),
  177. outbound_qty=prod.outbound_qty if archived else None,
  178. outbound_price=prod.outbound_price if archived else None,
  179. outbound_amount=prod.outbound_amount if archived else None,
  180. tenant_no=plan.tenant_no,
  181. order_num=idx,
  182. remark=prod.remark,
  183. create_by=plan.create_by,
  184. create_dept_id=plan.create_dept_id or "",
  185. create_time=now,
  186. update_by=plan.update_by,
  187. update_time=now,
  188. del_flag=0,
  189. )
  190. delivery_products.append(delivery_prod)
  191. contract_rows = (
  192. db.query(PlPlanContract)
  193. .filter(
  194. PlPlanContract.plan_id == plan.id,
  195. PlPlanContract.tenant_no == plan.tenant_no,
  196. PlPlanContract.del_flag == 0,
  197. )
  198. .all()
  199. )
  200. contracts_to_update: list[PlPlanContract] = []
  201. for row in contract_rows:
  202. if (row.stop_id or "").strip():
  203. continue
  204. side = (row.side or "").strip()
  205. if side == PLAN_CONTRACT_SIDE_SUPPLIER:
  206. row.side = PLAN_CONTRACT_SIDE_LOAD
  207. row.stop_id = load_id
  208. contracts_to_update.append(row)
  209. elif side == PLAN_CONTRACT_SIDE_DEMANDER:
  210. row.side = PLAN_CONTRACT_SIDE_DELIVERY
  211. row.stop_id = delivery_id
  212. contracts_to_update.append(row)
  213. if not dry_run:
  214. db.add(load_row)
  215. db.add(delivery_row)
  216. for lp in load_products:
  217. db.add(lp)
  218. for dp in delivery_products:
  219. db.add(dp)
  220. for row in contracts_to_update:
  221. row.update_by = plan.update_by
  222. row.update_time = now
  223. stats.plans_migrated += 1
  224. stats.loads_created += 1
  225. stats.deliveries_created += 1
  226. stats.load_products_created += len(load_products)
  227. stats.delivery_products_created += len(delivery_products)
  228. stats.contracts_updated += len(contracts_to_update)
  229. def run_migration(tenant_no: Optional[str], dry_run: bool) -> MigrationStats:
  230. stats = MigrationStats()
  231. db = SessionLocal()
  232. try:
  233. query = db.query(PlPlan).filter(PlPlan.del_flag == 0)
  234. if tenant_no:
  235. query = query.filter(PlPlan.tenant_no == tenant_no)
  236. plans = query.order_by(PlPlan.create_time.asc()).all()
  237. stats.plans_scanned = len(plans)
  238. for plan in plans:
  239. _migrate_plan(db, plan, stats, dry_run)
  240. if dry_run:
  241. db.rollback()
  242. else:
  243. db.commit()
  244. except Exception:
  245. db.rollback()
  246. raise
  247. finally:
  248. db.close()
  249. return stats
  250. def print_stats(stats: MigrationStats, dry_run: bool) -> None:
  251. mode = "DRY-RUN" if dry_run else "COMMITTED"
  252. print(f"=== migrate_pl_plan_to_multi_stop ({mode}) ===")
  253. print(f"plans_scanned: {stats.plans_scanned}")
  254. print(f"plans_migrated: {stats.plans_migrated}")
  255. print(f"skipped_existing_load: {stats.skipped_existing_load}")
  256. print(f"loads_created: {stats.loads_created}")
  257. print(f"deliveries_created: {stats.deliveries_created}")
  258. print(f"load_products_created: {stats.load_products_created}")
  259. print(f"delivery_products_created: {stats.delivery_products_created}")
  260. print(f"contracts_updated: {stats.contracts_updated}")
  261. if stats.warnings:
  262. print("warnings:")
  263. for w in stats.warnings:
  264. print(f" - {w}")
  265. def main() -> None:
  266. parser = argparse.ArgumentParser(description="迁移运输计划至装货/送货多段模型")
  267. parser.add_argument("--dry-run", action="store_true", help="仅统计不写库")
  268. parser.add_argument("--tenant-no", default=None, help="仅迁移指定租户")
  269. parser.add_argument(
  270. "--env-file",
  271. default=os.environ.get("ENV_FILE"),
  272. help="环境配置文件路径(如 .env.production)",
  273. )
  274. args = parser.parse_args()
  275. if args.env_file:
  276. os.environ["ENV_FILE"] = args.env_file
  277. stats = run_migration(args.tenant_no, args.dry_run)
  278. print_stats(stats, args.dry_run)
  279. if __name__ == "__main__":
  280. main()