fix_pl_transport_db_link_stop_ids.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. """
  2. 修复 pl_transport_db_link 悬空的 stop_id / plan_product_id。
  3. 段主键 upsert 上线前,计划保存会软删重建送货段,导致 link 仍指向旧 stop/product,
  4. 详情「送货信息段次」过滤不到地磅单。
  5. 用法(在 backend 目录、已配置 DATABASE_URL / .env):
  6. python scripts/fix_pl_transport_db_link_stop_ids.py --plan-no PO20260904024
  7. python scripts/fix_pl_transport_db_link_stop_ids.py --plan-no PO20260904024 --apply
  8. python scripts/fix_pl_transport_db_link_stop_ids.py --apply # 全租户扫描
  9. """
  10. from __future__ import annotations
  11. import argparse
  12. import os
  13. import sys
  14. from dataclasses import dataclass
  15. from datetime import datetime
  16. from typing import Optional
  17. from urllib.parse import urlparse
  18. from sqlalchemy import create_engine
  19. from sqlalchemy.engine import Engine
  20. from sqlalchemy.orm import Session, sessionmaker
  21. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  22. from app.models.pl_models import ( # noqa: E402
  23. PlPlan,
  24. PlPlanDelivery,
  25. PlPlanLoad,
  26. PlPlanProduct,
  27. PlTransportDbLink,
  28. )
  29. DEFAULT_TENANT = "shanxia2.0"
  30. @dataclass
  31. class FixProposal:
  32. link_id: str
  33. plan_id: str
  34. plan_no: str
  35. old_stop_id: Optional[str]
  36. new_stop_id: Optional[str]
  37. old_plan_product_id: str
  38. new_plan_product_id: Optional[str]
  39. reason: str
  40. can_apply: bool
  41. def _engine_from_env() -> Engine:
  42. url = os.environ.get("DATABASE_URL")
  43. if not url:
  44. from app.core.config import settings
  45. url = settings.DATABASE_URL
  46. return create_engine(url, pool_pre_ping=True)
  47. def _stop_alive(db: Session, *, stop_type: Optional[str], stop_id: Optional[str], tenant_no: str) -> bool:
  48. if not stop_id:
  49. return True
  50. if stop_type == "load":
  51. return (
  52. db.query(PlPlanLoad.id)
  53. .filter(PlPlanLoad.id == stop_id, PlPlanLoad.tenant_no == tenant_no, PlPlanLoad.del_flag == 0)
  54. .first()
  55. is not None
  56. )
  57. if stop_type == "delivery":
  58. return (
  59. db.query(PlPlanDelivery.id)
  60. .filter(
  61. PlPlanDelivery.id == stop_id,
  62. PlPlanDelivery.tenant_no == tenant_no,
  63. PlPlanDelivery.del_flag == 0,
  64. )
  65. .first()
  66. is not None
  67. )
  68. return True
  69. def _product_alive(db: Session, plan_product_id: str) -> bool:
  70. if not plan_product_id:
  71. return False
  72. return (
  73. db.query(PlPlanProduct.id)
  74. .filter(PlPlanProduct.id == plan_product_id, PlPlanProduct.del_flag == 0)
  75. .first()
  76. is not None
  77. )
  78. def _resolve_delivery_stop_id(
  79. db: Session,
  80. *,
  81. plan_id: str,
  82. tenant_no: str,
  83. old_stop_id: Optional[str],
  84. ) -> tuple[Optional[str], str]:
  85. active = (
  86. db.query(PlPlanDelivery)
  87. .filter(
  88. PlPlanDelivery.plan_id == plan_id,
  89. PlPlanDelivery.tenant_no == tenant_no,
  90. PlPlanDelivery.del_flag == 0,
  91. )
  92. .order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc())
  93. .all()
  94. )
  95. if not active:
  96. return None, "无有效送货段可改挂"
  97. if len(active) == 1:
  98. return active[0].id, "仅一条有效送货段,唯一匹配"
  99. old_name = None
  100. if old_stop_id:
  101. old = db.query(PlPlanDelivery).filter(PlPlanDelivery.id == old_stop_id).first()
  102. if old:
  103. old_name = (old.customer_name or "").strip()
  104. if old_name:
  105. matched = [d for d in active if (d.customer_name or "").strip() == old_name]
  106. if len(matched) == 1:
  107. return matched[0].id, f"按客户名匹配: {old_name}"
  108. if len(matched) > 1:
  109. return None, f"同客户名送货段不唯一: {old_name}"
  110. return None, "多条送货段且无法唯一匹配客户名"
  111. def _resolve_load_stop_id(
  112. db: Session,
  113. *,
  114. plan_id: str,
  115. tenant_no: str,
  116. ) -> tuple[Optional[str], str]:
  117. active = (
  118. db.query(PlPlanLoad)
  119. .filter(
  120. PlPlanLoad.plan_id == plan_id,
  121. PlPlanLoad.tenant_no == tenant_no,
  122. PlPlanLoad.del_flag == 0,
  123. )
  124. .all()
  125. )
  126. if not active:
  127. return None, "无有效装货段可改挂"
  128. if len(active) == 1:
  129. return active[0].id, "仅一条有效装货段,唯一匹配"
  130. return None, "多条装货段无法自动改挂"
  131. def _resolve_plan_product_id(
  132. db: Session,
  133. *,
  134. plan_id: str,
  135. tenant_no: str,
  136. old_plan_product_id: str,
  137. ) -> tuple[Optional[str], str]:
  138. if _product_alive(db, old_plan_product_id):
  139. return old_plan_product_id, "计划产品仍有效"
  140. old = (
  141. db.query(PlPlanProduct)
  142. .filter(PlPlanProduct.id == old_plan_product_id, PlPlanProduct.plan_id == plan_id)
  143. .first()
  144. )
  145. crm_product_id = (old.product_id if old else "") or ""
  146. if not crm_product_id:
  147. return None, "旧计划产品无 product_id,无法改挂"
  148. active = (
  149. db.query(PlPlanProduct)
  150. .filter(
  151. PlPlanProduct.plan_id == plan_id,
  152. PlPlanProduct.tenant_no == tenant_no,
  153. PlPlanProduct.del_flag == 0,
  154. PlPlanProduct.product_id == crm_product_id,
  155. )
  156. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  157. .all()
  158. )
  159. if len(active) == 1:
  160. return active[0].id, f"按 CRM product_id 匹配: {crm_product_id}"
  161. if not active:
  162. return None, f"无有效计划产品匹配 product_id={crm_product_id}"
  163. return None, f"多条计划产品匹配 product_id={crm_product_id}"
  164. def collect_proposals(
  165. db: Session,
  166. *,
  167. tenant_no: str,
  168. plan_no: Optional[str],
  169. ) -> list[FixProposal]:
  170. q = (
  171. db.query(PlTransportDbLink, PlPlan)
  172. .join(PlPlan, PlPlan.id == PlTransportDbLink.plan_id)
  173. .filter(
  174. PlTransportDbLink.tenant_no == tenant_no,
  175. PlTransportDbLink.del_flag == 0,
  176. PlPlan.tenant_no == tenant_no,
  177. PlPlan.del_flag == 0,
  178. )
  179. )
  180. if plan_no:
  181. q = q.filter(PlPlan.plan_no == plan_no)
  182. proposals: list[FixProposal] = []
  183. for link, plan in q.all():
  184. stop_type = (link.stop_type or "").strip() or None
  185. old_stop_id = (link.stop_id or "").strip() or None
  186. old_pp = (link.plan_product_id or "").strip()
  187. reasons: list[str] = []
  188. new_stop_id = old_stop_id
  189. stop_ok = True
  190. if not _stop_alive(db, stop_type=stop_type, stop_id=old_stop_id, tenant_no=tenant_no):
  191. if stop_type == "delivery":
  192. new_stop_id, stop_reason = _resolve_delivery_stop_id(
  193. db, plan_id=plan.id, tenant_no=tenant_no, old_stop_id=old_stop_id
  194. )
  195. elif stop_type == "load":
  196. new_stop_id, stop_reason = _resolve_load_stop_id(
  197. db, plan_id=plan.id, tenant_no=tenant_no
  198. )
  199. else:
  200. new_stop_id, stop_reason = None, f"未知 stop_type={stop_type}"
  201. reasons.append(f"stop: {stop_reason}")
  202. stop_ok = bool(new_stop_id)
  203. new_pp = old_pp
  204. product_ok = True
  205. if not _product_alive(db, old_pp):
  206. new_pp, pp_reason = _resolve_plan_product_id(
  207. db, plan_id=plan.id, tenant_no=tenant_no, old_plan_product_id=old_pp
  208. )
  209. reasons.append(f"product: {pp_reason}")
  210. product_ok = bool(new_pp)
  211. if not reasons:
  212. continue
  213. proposals.append(
  214. FixProposal(
  215. link_id=link.id,
  216. plan_id=plan.id,
  217. plan_no=plan.plan_no or "",
  218. old_stop_id=old_stop_id,
  219. new_stop_id=new_stop_id,
  220. old_plan_product_id=old_pp,
  221. new_plan_product_id=new_pp,
  222. reason="; ".join(reasons),
  223. can_apply=stop_ok and product_ok,
  224. )
  225. )
  226. return proposals
  227. def apply_proposals(db: Session, proposals: list[FixProposal]) -> int:
  228. applied = 0
  229. now = datetime.now()
  230. for p in proposals:
  231. if not p.can_apply:
  232. print(f" SKIP link={p.link_id} plan={p.plan_no}: {p.reason}")
  233. continue
  234. link = (
  235. db.query(PlTransportDbLink)
  236. .filter(PlTransportDbLink.id == p.link_id, PlTransportDbLink.del_flag == 0)
  237. .first()
  238. )
  239. if not link:
  240. continue
  241. changed = False
  242. if p.new_stop_id and p.new_stop_id != (link.stop_id or ""):
  243. link.stop_id = p.new_stop_id
  244. changed = True
  245. if p.new_plan_product_id and p.new_plan_product_id != (link.plan_product_id or ""):
  246. link.plan_product_id = p.new_plan_product_id
  247. changed = True
  248. if changed:
  249. link.update_time = now
  250. applied += 1
  251. print(
  252. f" APPLY link={p.link_id} plan={p.plan_no}: "
  253. f"stop_id {p.old_stop_id} → {p.new_stop_id}; "
  254. f"plan_product_id {p.old_plan_product_id} → {p.new_plan_product_id}"
  255. )
  256. return applied
  257. def main() -> None:
  258. parser = argparse.ArgumentParser(description="修复悬空的运输地磅 link stop/product id")
  259. parser.add_argument("--apply", action="store_true", help="实际写库(默认 dry-run)")
  260. parser.add_argument("--plan-no", default=None, help="仅处理指定计划编号")
  261. parser.add_argument("--tenant", default=DEFAULT_TENANT, help="租户编号")
  262. args = parser.parse_args()
  263. engine = _engine_from_env()
  264. parsed = urlparse(str(engine.url))
  265. print(f"目标库: {parsed.path.lstrip('/')} @ {parsed.hostname}:{parsed.port or 3306}")
  266. print(f"租户: {args.tenant}")
  267. print(f"模式: {'APPLY' if args.apply else 'DRY-RUN'}")
  268. if args.plan_no:
  269. print(f"计划: {args.plan_no}")
  270. print("---")
  271. SessionLocal = sessionmaker(bind=engine)
  272. db = SessionLocal()
  273. try:
  274. proposals = collect_proposals(db, tenant_no=args.tenant, plan_no=args.plan_no)
  275. if not proposals:
  276. print("无可修复的悬空 link。")
  277. return
  278. for p in proposals:
  279. flag = "OK" if p.can_apply else "NEED-MANUAL"
  280. print(
  281. f"[{flag}] link={p.link_id} plan={p.plan_no} ({p.plan_id})\n"
  282. f" stop_id {p.old_stop_id} → {p.new_stop_id}\n"
  283. f" plan_product_id {p.old_plan_product_id} → {p.new_plan_product_id}\n"
  284. f" reason: {p.reason}"
  285. )
  286. print("---")
  287. if not args.apply:
  288. print(f"共 {len(proposals)} 条候选。加 --apply 写库。")
  289. return
  290. n = apply_proposals(db, proposals)
  291. db.commit()
  292. print(f"已更新 {n} 条 link。")
  293. except Exception:
  294. db.rollback()
  295. raise
  296. finally:
  297. db.close()
  298. if __name__ == "__main__":
  299. main()