mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Add cutover migration monitor and day-based batch retention.
Protect baselines and compare/migration-referenced batches from purge or delete, and let operators mark baselines and clean high-freq data manually. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c7539e379d
commit
8631f58113
23 changed files with 3837 additions and 91 deletions
1
netx_api/biz_migration/__init__.py
Normal file
1
netx_api/biz_migration/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""biz_migration package — cutover monitor (separate from CompareJob)."""
|
||||
343
netx_api/biz_migration/evaluate.py
Normal file
343
netx_api/biz_migration/evaluate.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
"""Cutover monitor: relative-to-baseline drift + dual-device migration verdict."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..biz_state.compare_engine import apply_port_map, compare_rows
|
||||
|
||||
PORT_METRIC_ID = "interface_brief"
|
||||
PORT_STATUS_FIELDS = ("admin", "phy", "prot")
|
||||
|
||||
|
||||
def _key_str(key: tuple[str, ...] | list[str]) -> str:
|
||||
return "|".join(str(x) for x in key)
|
||||
|
||||
|
||||
def port_status_label(row: dict[str, Any] | None) -> str:
|
||||
"""Compact admin/phy/prot for board (e.g. up/up/up)."""
|
||||
if not row:
|
||||
return "—"
|
||||
parts = [str(row.get(f) or "-").lower() for f in PORT_STATUS_FIELDS]
|
||||
if all(p == "-" for p in parts):
|
||||
return "—"
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
|
||||
"""Return metric_id → set of key strings (old-side / before-map identity).
|
||||
|
||||
Supports:
|
||||
{"ports": ["gei-0/1/0/1", ...]} → interface_brief
|
||||
{"items": [{"metric_id": "...", "key": "a|b"}, {"metric_id":"...", "keys":["a","b"]}]}
|
||||
"""
|
||||
out: dict[str, set[str]] = {}
|
||||
data = raw or {}
|
||||
ports = data.get("ports") or []
|
||||
if isinstance(ports, list):
|
||||
for p in ports:
|
||||
s = str(p or "").strip()
|
||||
if s:
|
||||
out.setdefault(PORT_METRIC_ID, set()).add(s)
|
||||
out.setdefault("_ports", set()).add(s)
|
||||
items = data.get("items") or []
|
||||
if isinstance(items, list):
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
mid = str(it.get("metric_id") or "").strip()
|
||||
if not mid:
|
||||
continue
|
||||
bucket = out.setdefault(mid, set())
|
||||
if it.get("key") is not None:
|
||||
bucket.add(str(it.get("key") or "").strip())
|
||||
keys = it.get("keys")
|
||||
if isinstance(keys, list):
|
||||
if all(not isinstance(x, (list, tuple, dict)) for x in keys):
|
||||
bucket.add("|".join(str(x).strip() for x in keys))
|
||||
else:
|
||||
for x in keys:
|
||||
bucket.add(str(x).strip())
|
||||
return {k: {x for x in v if x} for k, v in out.items() if v}
|
||||
|
||||
|
||||
def side_verdict(
|
||||
*,
|
||||
kind: str,
|
||||
in_expect: bool,
|
||||
window_active: bool,
|
||||
) -> tuple[str, str]:
|
||||
"""Map compare kind → (verdict, color) for one device vs its baseline."""
|
||||
k = str(kind or "")
|
||||
if k == "unchanged":
|
||||
return "ok", "green"
|
||||
if k == "removed":
|
||||
if in_expect:
|
||||
return "expected_gone", "yellow"
|
||||
return "anomaly_gone", "red"
|
||||
if k == "added":
|
||||
if in_expect:
|
||||
return "expected_new", "green"
|
||||
return "unexpected_new", "yellow" if window_active else "yellow"
|
||||
if k == "changed":
|
||||
if in_expect:
|
||||
return "expected_change", "yellow"
|
||||
return "anomaly_change", "red"
|
||||
return "ok", "gray"
|
||||
|
||||
|
||||
def dual_verdict(
|
||||
*,
|
||||
old_kind: str,
|
||||
new_kind: str,
|
||||
in_expect: bool,
|
||||
window_active: bool,
|
||||
acceptance: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
"""Synthesize old+new into migration board verdict.
|
||||
|
||||
``acceptance=True`` (本批完成终验): unfinished expect items become red
|
||||
(``unfinished`` / ``lost``), not yellow migrating.
|
||||
"""
|
||||
if not in_expect:
|
||||
if old_kind in ("removed", "changed") or new_kind in ("removed", "changed"):
|
||||
if old_kind == "removed" and new_kind in ("", "removed"):
|
||||
return "lost", "red"
|
||||
if old_kind in ("removed", "changed") or new_kind in ("changed",):
|
||||
return "anomaly", "red"
|
||||
if old_kind == "added" or new_kind == "added":
|
||||
return "unexpected_new", "yellow"
|
||||
return "not_involved", "gray"
|
||||
|
||||
if old_kind in ("removed", "changed") and new_kind in ("added", "unchanged", "changed"):
|
||||
return "migrated", "green"
|
||||
if old_kind == "removed" and new_kind in ("", "removed"):
|
||||
return "lost", "red"
|
||||
if old_kind in ("unchanged", "") and new_kind in ("unchanged", "added", "changed"):
|
||||
if acceptance:
|
||||
return "unfinished", "red"
|
||||
return "migrating", "yellow"
|
||||
if old_kind == "unchanged" and new_kind in ("", "removed"):
|
||||
if acceptance:
|
||||
return "unfinished", "red"
|
||||
return "migrating", "yellow"
|
||||
ov, oc = side_verdict(kind=old_kind or "unchanged", in_expect=True, window_active=window_active)
|
||||
if oc == "red" or (new_kind in ("removed",) and old_kind != "removed"):
|
||||
return "anomaly", "red"
|
||||
if acceptance:
|
||||
return "unfinished", "red"
|
||||
if window_active or ov.startswith("expected"):
|
||||
return "migrating", "yellow"
|
||||
return "migrating", "yellow"
|
||||
|
||||
|
||||
def build_diff_index_from_compare(result: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Normalize compare_rows result diffs → key_str → {kind, before, after, key}."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for d in result.get("diffs") or []:
|
||||
key = d.get("key")
|
||||
if isinstance(key, dict):
|
||||
key_list = [str(v) for v in key.values()]
|
||||
ks = _key_str(key_list)
|
||||
elif isinstance(key, (list, tuple)):
|
||||
key_list = [str(x) for x in key]
|
||||
ks = _key_str(key_list)
|
||||
else:
|
||||
ks = str(key or "")
|
||||
key_list = [ks] if ks else []
|
||||
before = dict(d.get("before") or {}) if d.get("before") else {}
|
||||
after = dict(d.get("after") or {}) if d.get("after") else {}
|
||||
out[ks] = {
|
||||
"kind": str(d.get("kind") or ""),
|
||||
"key": key_list,
|
||||
"before": before,
|
||||
"after": after,
|
||||
"mapped_before": dict(d.get("mapped_before") or {}) if d.get("mapped_before") else {},
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def expect_keys_for_metric(
|
||||
expect: dict[str, set[str]],
|
||||
*,
|
||||
metric_id: str,
|
||||
iface_fields: list[str],
|
||||
) -> set[str]:
|
||||
keys = set(expect.get(metric_id) or ())
|
||||
if iface_fields and expect.get("_ports"):
|
||||
keys |= set(expect["_ports"])
|
||||
return keys
|
||||
|
||||
|
||||
def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Current-side snapshot from a compare diff (empty if removed / missing)."""
|
||||
if not diff:
|
||||
return {}
|
||||
kind = str(diff.get("kind") or "")
|
||||
if kind == "removed":
|
||||
return {}
|
||||
return dict(diff.get("after") or {})
|
||||
|
||||
|
||||
def evaluate_metric_dual(
|
||||
*,
|
||||
metric_id: str,
|
||||
key_fields: list[str],
|
||||
iface_fields: list[str],
|
||||
compare_fields: list[str],
|
||||
old_baseline_rows: list[dict[str, Any]],
|
||||
old_current_rows: list[dict[str, Any]],
|
||||
new_baseline_rows: list[dict[str, Any]] | None,
|
||||
new_current_rows: list[dict[str, Any]],
|
||||
port_map: dict[str, str],
|
||||
expect: dict[str, set[str]],
|
||||
window_active: bool,
|
||||
acceptance: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge."""
|
||||
expect_keys = expect_keys_for_metric(expect, metric_id=metric_id, iface_fields=iface_fields)
|
||||
|
||||
old_cmp = compare_rows(
|
||||
before_rows=old_baseline_rows,
|
||||
after_rows=old_current_rows,
|
||||
key_fields=key_fields,
|
||||
iface_fields=[],
|
||||
compare_fields=compare_fields,
|
||||
port_map=None,
|
||||
)
|
||||
old_idx = build_diff_index_from_compare(old_cmp)
|
||||
|
||||
if new_baseline_rows is not None:
|
||||
new_before = new_baseline_rows
|
||||
elif port_map and iface_fields:
|
||||
new_before = [
|
||||
apply_port_map(r, iface_fields=iface_fields, port_map=port_map)
|
||||
for r in old_baseline_rows
|
||||
]
|
||||
else:
|
||||
new_before = []
|
||||
|
||||
new_cmp = compare_rows(
|
||||
before_rows=new_before,
|
||||
after_rows=new_current_rows,
|
||||
key_fields=key_fields,
|
||||
iface_fields=[],
|
||||
compare_fields=compare_fields,
|
||||
port_map=None,
|
||||
)
|
||||
new_idx = build_diff_index_from_compare(new_cmp)
|
||||
|
||||
rev_map = {v: k for k, v in port_map.items()}
|
||||
|
||||
canon_keys: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(canon: str) -> None:
|
||||
if canon and canon not in seen:
|
||||
seen.add(canon)
|
||||
canon_keys.append(canon)
|
||||
|
||||
for ok in sorted(expect_keys):
|
||||
_add(ok)
|
||||
for ks in sorted(old_idx):
|
||||
_add(ks)
|
||||
for ks in sorted(new_idx):
|
||||
_add(rev_map.get(ks, ks))
|
||||
|
||||
rows_out: list[dict[str, Any]] = []
|
||||
progress_ok = 0
|
||||
progress_total = 0
|
||||
anomaly = 0
|
||||
|
||||
for old_ks in canon_keys:
|
||||
new_ks = port_map.get(old_ks, old_ks)
|
||||
in_exp = old_ks in expect_keys
|
||||
if in_exp:
|
||||
progress_total += 1
|
||||
|
||||
od = old_idx.get(old_ks)
|
||||
nd = new_idx.get(new_ks) or new_idx.get(old_ks)
|
||||
|
||||
old_kind = str((od or {}).get("kind") or "")
|
||||
new_kind = str((nd or {}).get("kind") or "")
|
||||
if od is None and nd is None and not in_exp:
|
||||
continue
|
||||
if od is None:
|
||||
old_kind = ""
|
||||
if nd is None:
|
||||
new_kind = ""
|
||||
|
||||
verdict, color = dual_verdict(
|
||||
old_kind=old_kind or "",
|
||||
new_kind=new_kind or "",
|
||||
in_expect=in_exp,
|
||||
window_active=window_active,
|
||||
acceptance=acceptance,
|
||||
)
|
||||
if in_exp and verdict == "migrated" and color == "green":
|
||||
progress_ok += 1
|
||||
if color == "red":
|
||||
anomaly += 1
|
||||
|
||||
old_cur = _current_row(od)
|
||||
new_cur = _current_row(nd)
|
||||
|
||||
rows_out.append(
|
||||
{
|
||||
"metric_id": metric_id,
|
||||
"key": (od or nd or {}).get("key") or [old_ks],
|
||||
"key_str": old_ks,
|
||||
"new_key_str": new_ks,
|
||||
"verdict": verdict,
|
||||
"color": color,
|
||||
"in_expect": in_exp,
|
||||
"old_kind": old_kind,
|
||||
"new_kind": new_kind,
|
||||
"old": old_cur,
|
||||
"new": new_cur,
|
||||
"old_baseline": dict((od or {}).get("before") or {}),
|
||||
"new_baseline": dict((nd or {}).get("before") or {}),
|
||||
"old_status": port_status_label(old_cur)
|
||||
if old_cur
|
||||
else ("gone" if old_kind == "removed" else "—"),
|
||||
"new_status": port_status_label(new_cur)
|
||||
if new_cur
|
||||
else ("gone" if new_kind == "removed" else "—"),
|
||||
"old_side": side_verdict(
|
||||
kind=old_kind or "unchanged",
|
||||
in_expect=in_exp,
|
||||
window_active=window_active,
|
||||
)
|
||||
if old_kind
|
||||
else ("", "gray"),
|
||||
"new_side": side_verdict(
|
||||
kind=new_kind or "unchanged",
|
||||
in_expect=in_exp,
|
||||
window_active=window_active,
|
||||
)
|
||||
if new_kind
|
||||
else ("", "gray"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"metric_id": metric_id,
|
||||
"old_summary": old_cmp.get("summary") or {},
|
||||
"new_summary": new_cmp.get("summary") or {},
|
||||
"progress_ok": progress_ok,
|
||||
"progress_total": progress_total if progress_total else len(expect_keys),
|
||||
"anomaly": anomaly,
|
||||
"rows": rows_out,
|
||||
}
|
||||
|
||||
|
||||
def port_sheet_def() -> dict[str, Any]:
|
||||
"""Default sheet for port-status cutover monitor (interface_brief only)."""
|
||||
return {
|
||||
"metric_id": PORT_METRIC_ID,
|
||||
"key_fields": ["interface"],
|
||||
"iface_fields": ["interface"],
|
||||
"compare_fields": ["admin", "phy", "prot"],
|
||||
"row_filters": [],
|
||||
"field_rules": [],
|
||||
}
|
||||
973
netx_api/biz_migration/service.py
Normal file
973
netx_api/biz_migration/service.py
Normal file
|
|
@ -0,0 +1,973 @@
|
|||
"""CRUD + evaluate for cutover migration monitor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..biz_state.compare_rules import apply_row_filters
|
||||
from ..biz_state.compare_service import _load_metric_rows, _port_map_dict
|
||||
from ..models import (
|
||||
BizMigrationBatch,
|
||||
BizMigrationDiff,
|
||||
BizMigrationProject,
|
||||
BizMigrationRedTicket,
|
||||
BizMigrationRun,
|
||||
BizPortMapping,
|
||||
BizStateBatch,
|
||||
BizStateTask,
|
||||
)
|
||||
from ..timeutil import utcnow_naive
|
||||
from .evaluate import PORT_METRIC_ID, evaluate_metric_dual, parse_expect_set, port_sheet_def
|
||||
|
||||
|
||||
def _task_brief(db: Session, task_id: str) -> dict[str, Any]:
|
||||
t = db.get(BizStateTask, task_id) if task_id else None
|
||||
if not t:
|
||||
return {"id": task_id or "", "ne_name": "", "ne_ip": "", "vendor": ""}
|
||||
return {
|
||||
"id": t.id,
|
||||
"ne_name": t.ne_name,
|
||||
"ne_ip": t.ne_ip,
|
||||
"vendor": t.vendor,
|
||||
"status": t.status,
|
||||
"note": t.note,
|
||||
"interval_sec": t.interval_sec,
|
||||
"collect_running": bool(t.collect_running),
|
||||
}
|
||||
|
||||
|
||||
def _batch_brief(db: Session, batch_id: str) -> dict[str, Any]:
|
||||
b = db.get(BizStateBatch, batch_id) if batch_id else None
|
||||
if not b:
|
||||
return {"id": batch_id or "", "status": "", "started_at": None}
|
||||
return {
|
||||
"id": b.id,
|
||||
"status": b.status,
|
||||
"started_at": b.started_at.isoformat() if b.started_at else None,
|
||||
"row_count": b.row_count,
|
||||
}
|
||||
|
||||
|
||||
def project_to_dict(db: Session, p: BizMigrationProject) -> dict[str, Any]:
|
||||
return {
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"old_task_id": p.old_task_id,
|
||||
"new_task_id": p.new_task_id,
|
||||
"old_baseline_batch_id": p.old_baseline_batch_id,
|
||||
"new_baseline_batch_id": p.new_baseline_batch_id,
|
||||
"mapping_id": p.mapping_id,
|
||||
"status": p.status,
|
||||
"note": p.note,
|
||||
"old_task": _task_brief(db, p.old_task_id),
|
||||
"new_task": _task_brief(db, p.new_task_id),
|
||||
"old_baseline": _batch_brief(db, p.old_baseline_batch_id),
|
||||
"new_baseline": _batch_brief(db, p.new_baseline_batch_id),
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def batch_to_dict(b: BizMigrationBatch) -> dict[str, Any]:
|
||||
return {
|
||||
"id": b.id,
|
||||
"project_id": b.project_id,
|
||||
"batch_label": b.batch_label,
|
||||
"status": b.status,
|
||||
"expect_set": dict(b.expect_set_json or {}),
|
||||
"started_at": b.started_at.isoformat() if b.started_at else None,
|
||||
"ended_at": b.ended_at.isoformat() if b.ended_at else None,
|
||||
"accept_status": getattr(b, "accept_status", None) or "none",
|
||||
"accept_run_id": getattr(b, "accept_run_id", None) or "",
|
||||
"accept_summary": dict(getattr(b, "accept_summary_json", None) or {}),
|
||||
"note": b.note,
|
||||
"created_at": b.created_at.isoformat() if b.created_at else None,
|
||||
"updated_at": b.updated_at.isoformat() if b.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def list_projects(db: Session) -> list[dict[str, Any]]:
|
||||
rows = db.query(BizMigrationProject).order_by(BizMigrationProject.created_at.desc()).all()
|
||||
return [project_to_dict(db, p) for p in rows]
|
||||
|
||||
|
||||
def create_project(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
||||
name = str(body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name_required")
|
||||
old_task_id = str(body.get("old_task_id") or "").strip()
|
||||
new_task_id = str(body.get("new_task_id") or "").strip()
|
||||
if not old_task_id or not new_task_id:
|
||||
raise HTTPException(status_code=400, detail="old_new_task_required")
|
||||
if not db.get(BizStateTask, old_task_id) or not db.get(BizStateTask, new_task_id):
|
||||
raise HTTPException(status_code=404, detail="task_not_found")
|
||||
mapping_id = str(body.get("mapping_id") or "").strip()
|
||||
if mapping_id and not db.get(BizPortMapping, mapping_id):
|
||||
raise HTTPException(status_code=404, detail="mapping_not_found")
|
||||
p = BizMigrationProject(
|
||||
id=uuid4().hex,
|
||||
name=name,
|
||||
old_task_id=old_task_id,
|
||||
new_task_id=new_task_id,
|
||||
old_baseline_batch_id=str(body.get("old_baseline_batch_id") or "").strip(),
|
||||
new_baseline_batch_id=str(body.get("new_baseline_batch_id") or "").strip(),
|
||||
mapping_id=mapping_id,
|
||||
status=str(body.get("status") or "draft").strip() or "draft",
|
||||
note=str(body.get("note") or "")[:500],
|
||||
)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return project_to_dict(db, p)
|
||||
|
||||
|
||||
def get_project(db: Session, project_id: str) -> dict[str, Any]:
|
||||
p = db.get(BizMigrationProject, project_id)
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
return project_to_dict(db, p)
|
||||
|
||||
|
||||
def patch_project(db: Session, project_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
p = db.get(BizMigrationProject, project_id)
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
if "name" in body and body["name"] is not None:
|
||||
p.name = str(body["name"]).strip() or p.name
|
||||
if "note" in body and body["note"] is not None:
|
||||
p.note = str(body["note"])[:500]
|
||||
if "status" in body and body["status"] is not None:
|
||||
p.status = str(body["status"]).strip() or p.status
|
||||
if "mapping_id" in body and body["mapping_id"] is not None:
|
||||
mid = str(body["mapping_id"] or "").strip()
|
||||
if mid and not db.get(BizPortMapping, mid):
|
||||
raise HTTPException(status_code=404, detail="mapping_not_found")
|
||||
p.mapping_id = mid
|
||||
if "old_baseline_batch_id" in body and body["old_baseline_batch_id"] is not None:
|
||||
bid = str(body["old_baseline_batch_id"] or "").strip()
|
||||
if bid and not db.get(BizStateBatch, bid):
|
||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||
p.old_baseline_batch_id = bid
|
||||
if "new_baseline_batch_id" in body and body["new_baseline_batch_id"] is not None:
|
||||
bid = str(body["new_baseline_batch_id"] or "").strip()
|
||||
if bid and not db.get(BizStateBatch, bid):
|
||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||
p.new_baseline_batch_id = bid
|
||||
p.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return project_to_dict(db, p)
|
||||
|
||||
|
||||
def delete_project(db: Session, project_id: str) -> dict[str, Any]:
|
||||
p = db.get(BizMigrationProject, project_id)
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
batches = db.query(BizMigrationBatch).filter(BizMigrationBatch.project_id == project_id).all()
|
||||
for b in batches:
|
||||
runs = db.query(BizMigrationRun).filter(BizMigrationRun.batch_id == b.id).all()
|
||||
for r in runs:
|
||||
db.query(BizMigrationDiff).filter(BizMigrationDiff.run_id == r.id).delete()
|
||||
db.delete(r)
|
||||
db.delete(b)
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_batches(db: Session, project_id: str) -> list[dict[str, Any]]:
|
||||
if not db.get(BizMigrationProject, project_id):
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
rows = (
|
||||
db.query(BizMigrationBatch)
|
||||
.filter(BizMigrationBatch.project_id == project_id)
|
||||
.order_by(BizMigrationBatch.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [batch_to_dict(b) for b in rows]
|
||||
|
||||
|
||||
def create_batch(db: Session, project_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
if not db.get(BizMigrationProject, project_id):
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
label = str(body.get("batch_label") or "").strip() or "batch"
|
||||
expect = body.get("expect_set") if isinstance(body.get("expect_set"), dict) else {}
|
||||
b = BizMigrationBatch(
|
||||
id=uuid4().hex,
|
||||
project_id=project_id,
|
||||
batch_label=label,
|
||||
status=str(body.get("status") or "pending").strip() or "pending",
|
||||
expect_set_json=dict(expect or {}),
|
||||
note=str(body.get("note") or "")[:500],
|
||||
)
|
||||
db.add(b)
|
||||
db.commit()
|
||||
db.refresh(b)
|
||||
out = batch_to_dict(b)
|
||||
out["open_red_count"] = count_open_red_tickets(db, project_id)
|
||||
return out
|
||||
|
||||
|
||||
def _latest_success_batch(db: Session, task_id: str) -> BizStateBatch | None:
|
||||
return (
|
||||
db.query(BizStateBatch)
|
||||
.filter(
|
||||
BizStateBatch.task_id == task_id,
|
||||
BizStateBatch.status.in_(("success", "partial")),
|
||||
)
|
||||
.order_by(BizStateBatch.started_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def pinned_baseline_batch_ids(db: Session) -> set[str]:
|
||||
"""Batch IDs that must survive retention purge."""
|
||||
ids: set[str] = set()
|
||||
for p in db.query(BizMigrationProject).all():
|
||||
if p.old_baseline_batch_id:
|
||||
ids.add(p.old_baseline_batch_id)
|
||||
if p.new_baseline_batch_id:
|
||||
ids.add(p.new_baseline_batch_id)
|
||||
return ids
|
||||
|
||||
|
||||
def run_evaluate(
|
||||
db: Session,
|
||||
*,
|
||||
batch_id: str,
|
||||
old_batch_id: str = "",
|
||||
new_batch_id: str = "",
|
||||
acceptance: bool = False,
|
||||
purpose: str = "manual",
|
||||
) -> dict[str, Any]:
|
||||
mb = db.get(BizMigrationBatch, batch_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||
proj = db.get(BizMigrationProject, mb.project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
if not proj.old_baseline_batch_id:
|
||||
raise HTTPException(status_code=400, detail="old_baseline_required")
|
||||
|
||||
old_batch = db.get(BizStateBatch, old_batch_id.strip()) if old_batch_id.strip() else None
|
||||
if not old_batch:
|
||||
old_batch = _latest_success_batch(db, proj.old_task_id)
|
||||
new_batch = db.get(BizStateBatch, new_batch_id.strip()) if new_batch_id.strip() else None
|
||||
if not new_batch:
|
||||
new_batch = _latest_success_batch(db, proj.new_task_id)
|
||||
if not old_batch:
|
||||
raise HTTPException(status_code=400, detail="old_current_batch_required")
|
||||
if not new_batch:
|
||||
raise HTTPException(status_code=400, detail="new_current_batch_required")
|
||||
old_cur = old_batch.id
|
||||
new_cur = new_batch.id
|
||||
|
||||
port_map = _port_map_dict(db, proj.mapping_id)
|
||||
expect = parse_expect_set(mb.expect_set_json if isinstance(mb.expect_set_json, dict) else {})
|
||||
# Final acceptance: window closed → unfinished expect = red
|
||||
window_active = (mb.status == "active") and (not acceptance)
|
||||
sheets = [port_sheet_def()]
|
||||
|
||||
sheet_cards: list[dict[str, Any]] = []
|
||||
all_rows: list[dict[str, Any]] = []
|
||||
seq = 0
|
||||
verdict_counts: dict[str, int] = {}
|
||||
|
||||
for sheet in sheets:
|
||||
mid = sheet["metric_id"]
|
||||
key_fields = list(sheet.get("key_fields") or [])
|
||||
if not key_fields:
|
||||
continue
|
||||
iface_fields = list(sheet.get("iface_fields") or [])
|
||||
compare_fields = list(sheet.get("compare_fields") or [])
|
||||
row_filters = list(sheet.get("row_filters") or [])
|
||||
|
||||
old_base = apply_row_filters(
|
||||
_load_metric_rows(db, batch_id=proj.old_baseline_batch_id, metric_id=mid),
|
||||
row_filters,
|
||||
)
|
||||
old_now = apply_row_filters(
|
||||
_load_metric_rows(db, batch_id=old_cur, metric_id=mid),
|
||||
row_filters,
|
||||
)
|
||||
new_base_rows = None
|
||||
if proj.new_baseline_batch_id:
|
||||
new_base_rows = apply_row_filters(
|
||||
_load_metric_rows(db, batch_id=proj.new_baseline_batch_id, metric_id=mid),
|
||||
row_filters,
|
||||
)
|
||||
new_now = apply_row_filters(
|
||||
_load_metric_rows(db, batch_id=new_cur, metric_id=mid),
|
||||
row_filters,
|
||||
)
|
||||
|
||||
one = evaluate_metric_dual(
|
||||
metric_id=mid,
|
||||
key_fields=key_fields,
|
||||
iface_fields=iface_fields,
|
||||
compare_fields=compare_fields,
|
||||
old_baseline_rows=old_base,
|
||||
old_current_rows=old_now,
|
||||
new_baseline_rows=new_base_rows,
|
||||
new_current_rows=new_now,
|
||||
port_map=port_map,
|
||||
expect=expect,
|
||||
window_active=window_active,
|
||||
acceptance=acceptance,
|
||||
)
|
||||
sheet_cards.append(
|
||||
{
|
||||
"metric_id": mid,
|
||||
"title": "端口状态",
|
||||
"progress_ok": one["progress_ok"],
|
||||
"progress_total": one["progress_total"],
|
||||
"anomaly": one["anomaly"],
|
||||
"old_summary": one["old_summary"],
|
||||
"new_summary": one["new_summary"],
|
||||
}
|
||||
)
|
||||
for r in one["rows"]:
|
||||
r["seq"] = seq
|
||||
seq += 1
|
||||
all_rows.append(r)
|
||||
v = str(r.get("verdict") or "")
|
||||
if v:
|
||||
verdict_counts[v] = verdict_counts.get(v, 0) + 1
|
||||
|
||||
run = BizMigrationRun(
|
||||
id=uuid4().hex,
|
||||
project_id=proj.id,
|
||||
batch_id=mb.id,
|
||||
old_batch_id=old_cur,
|
||||
new_batch_id=new_cur,
|
||||
purpose=str(purpose or ("acceptance" if acceptance else "manual"))[:32],
|
||||
status="success",
|
||||
summary_json={
|
||||
"metric_focus": PORT_METRIC_ID,
|
||||
"acceptance": acceptance,
|
||||
"sheet_cards": sheet_cards,
|
||||
"progress": {
|
||||
"ok": sum(c["progress_ok"] for c in sheet_cards),
|
||||
"total": sum(c["progress_total"] for c in sheet_cards),
|
||||
},
|
||||
"anomaly": sum(c["anomaly"] for c in sheet_cards),
|
||||
"verdict_counts": verdict_counts,
|
||||
"window_active": window_active,
|
||||
"expect_ports": sorted(expect.get("_ports") or ()),
|
||||
},
|
||||
message="",
|
||||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
for r in all_rows:
|
||||
key_list = r.get("key") or []
|
||||
search = " ".join(
|
||||
[
|
||||
str(r.get("key_str") or ""),
|
||||
str(r.get("new_key_str") or ""),
|
||||
str(r.get("verdict") or ""),
|
||||
str(r.get("old_status") or ""),
|
||||
str(r.get("new_status") or ""),
|
||||
str(r.get("metric_id") or ""),
|
||||
]
|
||||
)
|
||||
db.add(
|
||||
BizMigrationDiff(
|
||||
id=uuid4().hex,
|
||||
run_id=run.id,
|
||||
metric_id=str(r.get("metric_id") or ""),
|
||||
seq=int(r.get("seq") or 0),
|
||||
verdict=str(r.get("verdict") or ""),
|
||||
color=str(r.get("color") or ""),
|
||||
key_json={
|
||||
"key": key_list,
|
||||
"key_str": r.get("key_str"),
|
||||
"new_key_str": r.get("new_key_str"),
|
||||
"old_status": r.get("old_status"),
|
||||
"new_status": r.get("new_status"),
|
||||
},
|
||||
old_kind=str(r.get("old_kind") or ""),
|
||||
new_kind=str(r.get("new_kind") or ""),
|
||||
old_json=dict(r.get("old") or {}),
|
||||
new_json=dict(r.get("new") or {}),
|
||||
in_expect=bool(r.get("in_expect")),
|
||||
search_text=search[:2000],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
return run_to_dict(db, run)
|
||||
|
||||
|
||||
def run_to_dict(db: Session, run: BizMigrationRun, *, include_diffs: bool = False) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"id": run.id,
|
||||
"project_id": run.project_id,
|
||||
"batch_id": run.batch_id,
|
||||
"old_batch_id": run.old_batch_id,
|
||||
"new_batch_id": run.new_batch_id,
|
||||
"purpose": getattr(run, "purpose", None) or "manual",
|
||||
"status": run.status,
|
||||
"summary": dict(run.summary_json or {}),
|
||||
"message": run.message,
|
||||
"created_at": run.created_at.isoformat() if run.created_at else None,
|
||||
"old_batch": _batch_brief(db, run.old_batch_id),
|
||||
"new_batch": _batch_brief(db, run.new_batch_id),
|
||||
}
|
||||
if include_diffs:
|
||||
diffs = (
|
||||
db.query(BizMigrationDiff)
|
||||
.filter(BizMigrationDiff.run_id == run.id)
|
||||
.order_by(BizMigrationDiff.seq.asc())
|
||||
.limit(5000)
|
||||
.all()
|
||||
)
|
||||
out["diffs"] = [diff_to_dict(d) for d in diffs]
|
||||
return out
|
||||
|
||||
|
||||
def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]:
|
||||
kj = d.key_json if isinstance(d.key_json, dict) else {}
|
||||
return {
|
||||
"id": d.id,
|
||||
"metric_id": d.metric_id,
|
||||
"seq": d.seq,
|
||||
"verdict": d.verdict,
|
||||
"color": d.color,
|
||||
"key": kj,
|
||||
"key_str": kj.get("key_str") or "",
|
||||
"new_key_str": kj.get("new_key_str") or "",
|
||||
"old_status": kj.get("old_status") or "",
|
||||
"new_status": kj.get("new_status") or "",
|
||||
"old_kind": d.old_kind,
|
||||
"new_kind": d.new_kind,
|
||||
"old": d.old_json,
|
||||
"new": d.new_json,
|
||||
"in_expect": d.in_expect,
|
||||
}
|
||||
|
||||
|
||||
def list_baseline_ports(db: Session, project_id: str) -> dict[str, Any]:
|
||||
"""List interface names from project old baseline for expect-set picking."""
|
||||
p = db.get(BizMigrationProject, project_id)
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
if not p.old_baseline_batch_id:
|
||||
return {"batch_id": "", "ports": [], "mapped": {}}
|
||||
rows = _load_metric_rows(
|
||||
db, batch_id=p.old_baseline_batch_id, metric_id=PORT_METRIC_ID
|
||||
)
|
||||
port_map = _port_map_dict(db, p.mapping_id)
|
||||
ports: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
name = str(r.get("interface") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
ports.append(
|
||||
{
|
||||
"interface": name,
|
||||
"admin": r.get("admin") or "",
|
||||
"phy": r.get("phy") or "",
|
||||
"prot": r.get("prot") or "",
|
||||
"description": r.get("description") or "",
|
||||
"mapped_to": port_map.get(name) or "",
|
||||
}
|
||||
)
|
||||
ports.sort(key=lambda x: str(x["interface"]))
|
||||
return {
|
||||
"batch_id": p.old_baseline_batch_id,
|
||||
"metric_id": PORT_METRIC_ID,
|
||||
"ports": ports,
|
||||
"mapped": port_map,
|
||||
}
|
||||
|
||||
|
||||
def _iface_brief_item(*, vendor: str, device_type: str) -> dict[str, Any]:
|
||||
from ..biz_state.profiles import profiles_for_vendor
|
||||
from ..lldp_shared import resolve_vendor_key
|
||||
|
||||
vkey = resolve_vendor_key(vendor, device_type)
|
||||
for p in profiles_for_vendor(vkey):
|
||||
if p.metric_id == PORT_METRIC_ID and p.kind == "collect":
|
||||
return {
|
||||
"source_profile_id": p.profile_id,
|
||||
"kind": "catalog",
|
||||
"enabled": True,
|
||||
"title": p.title or "interface brief",
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"no_interface_brief_profile_for_vendor:{vkey or vendor or 'unknown'}",
|
||||
)
|
||||
|
||||
|
||||
def _enabled_metric_ids(db: Session, task_id: str) -> set[str]:
|
||||
from ..biz_state.profiles import get_profile
|
||||
from ..models import BizStateTaskItem
|
||||
|
||||
out: set[str] = set()
|
||||
items = (
|
||||
db.query(BizStateTaskItem)
|
||||
.filter(BizStateTaskItem.task_id == task_id, BizStateTaskItem.enabled.is_(True))
|
||||
.all()
|
||||
)
|
||||
for it in items:
|
||||
if it.kind == "custom_raw":
|
||||
out.add("__custom__")
|
||||
continue
|
||||
p = get_profile(str(it.source_profile_id or ""))
|
||||
if p and p.metric_id:
|
||||
out.add(p.metric_id)
|
||||
return out
|
||||
|
||||
|
||||
def _is_port_highfreq_task(db: Session, task: BizStateTask) -> bool:
|
||||
"""True when task is interface_brief-only with short interval (cutover HF)."""
|
||||
metrics = _enabled_metric_ids(db, task.id)
|
||||
return metrics == {PORT_METRIC_ID} and int(task.interval_sec or 0) <= 300
|
||||
|
||||
|
||||
def _ensure_side_highfreq(
|
||||
db: Session,
|
||||
*,
|
||||
template: BizStateTask,
|
||||
project_name: str,
|
||||
interval_sec: int,
|
||||
retention_days: int,
|
||||
) -> tuple[BizStateTask, bool]:
|
||||
"""Return (task, created). Reuse if already HF port-only; else create sibling."""
|
||||
from ..biz_state import service as biz_svc
|
||||
|
||||
if _is_port_highfreq_task(db, template):
|
||||
if template.status != "running":
|
||||
biz_svc.update_task(db, template.id, {"status": "running"})
|
||||
refreshed = db.get(BizStateTask, template.id)
|
||||
return refreshed or template, False
|
||||
return template, False
|
||||
|
||||
item = _iface_brief_item(vendor=template.vendor, device_type=template.device_type)
|
||||
note = f"割接高频-端口/{project_name}"[:256]
|
||||
created = biz_svc.create_task(
|
||||
db,
|
||||
{
|
||||
"source": template.source,
|
||||
"ne_id": template.ne_id,
|
||||
"ne_name": template.ne_name,
|
||||
"ne_ip": template.ne_ip,
|
||||
"vendor": template.vendor,
|
||||
"device_type": template.device_type,
|
||||
"note": note,
|
||||
"status": "running",
|
||||
"interval_sec": interval_sec,
|
||||
"retention_days": retention_days,
|
||||
"items": [item],
|
||||
},
|
||||
)
|
||||
task = db.get(BizStateTask, str(created.get("id") or ""))
|
||||
if not task:
|
||||
raise HTTPException(status_code=500, detail="highfreq_task_create_failed")
|
||||
return task, True
|
||||
|
||||
|
||||
def ensure_port_highfreq(
|
||||
db: Session,
|
||||
project_id: str,
|
||||
*,
|
||||
interval_sec: int = 60,
|
||||
retention_days: int = 7,
|
||||
collect_now: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Create/bind interface_brief-only high-freq biz_state tasks for old/new NEs.
|
||||
|
||||
Collection stays in biz_state — migration only points at the tasks.
|
||||
Later metrics can be added on the same tasks via the biz-state UI.
|
||||
"""
|
||||
from ..biz_state.collect_runner import dispatch_collect
|
||||
|
||||
proj = db.get(BizMigrationProject, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
old_tpl = db.get(BizStateTask, proj.old_task_id) if proj.old_task_id else None
|
||||
new_tpl = db.get(BizStateTask, proj.new_task_id) if proj.new_task_id else None
|
||||
if not old_tpl or not new_tpl:
|
||||
raise HTTPException(status_code=400, detail="old_new_task_required")
|
||||
|
||||
iv = max(60, int(interval_sec or 60))
|
||||
ret = max(1, int(retention_days or 7))
|
||||
old_task, old_created = _ensure_side_highfreq(
|
||||
db,
|
||||
template=old_tpl,
|
||||
project_name=proj.name,
|
||||
interval_sec=iv,
|
||||
retention_days=ret,
|
||||
)
|
||||
# re-load templates after possible commits inside ensure
|
||||
new_tpl = db.get(BizStateTask, proj.new_task_id)
|
||||
if not new_tpl:
|
||||
raise HTTPException(status_code=400, detail="new_task_required")
|
||||
new_task, new_created = _ensure_side_highfreq(
|
||||
db,
|
||||
template=new_tpl,
|
||||
project_name=proj.name,
|
||||
interval_sec=iv,
|
||||
retention_days=ret,
|
||||
)
|
||||
|
||||
proj = db.get(BizMigrationProject, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
proj.old_task_id = old_task.id
|
||||
proj.new_task_id = new_task.id
|
||||
proj.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
|
||||
collect: dict[str, Any] = {"old": None, "new": None}
|
||||
if collect_now:
|
||||
for side, tid in (("old", old_task.id), ("new", new_task.id)):
|
||||
try:
|
||||
dispatch_collect(tid)
|
||||
collect[side] = {"ok": True, "task_id": tid}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
collect[side] = {"ok": False, "task_id": tid, "error": str(exc)[:200]}
|
||||
|
||||
return {
|
||||
"project": project_to_dict(db, proj),
|
||||
"old_task": _task_brief(db, old_task.id),
|
||||
"new_task": _task_brief(db, new_task.id),
|
||||
"old_created": old_created,
|
||||
"new_created": new_created,
|
||||
"interval_sec": iv,
|
||||
"collect": collect,
|
||||
}
|
||||
|
||||
|
||||
def collect_project_now(db: Session, project_id: str) -> dict[str, Any]:
|
||||
"""Trigger immediate collect on project's old/new biz_state tasks."""
|
||||
from ..biz_state.collect_runner import dispatch_collect
|
||||
|
||||
proj = db.get(BizMigrationProject, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
out: dict[str, Any] = {"old": None, "new": None}
|
||||
for side, tid in (("old", proj.old_task_id), ("new", proj.new_task_id)):
|
||||
if not tid:
|
||||
out[side] = {"ok": False, "error": "task_missing"}
|
||||
continue
|
||||
task = db.get(BizStateTask, tid)
|
||||
if not task:
|
||||
out[side] = {"ok": False, "error": "task_not_found"}
|
||||
continue
|
||||
if bool(task.collect_running):
|
||||
out[side] = {"ok": False, "error": "collect_already_running", "task_id": tid}
|
||||
continue
|
||||
try:
|
||||
dispatch_collect(tid)
|
||||
out[side] = {"ok": True, "task_id": tid}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
out[side] = {"ok": False, "task_id": tid, "error": str(exc)[:200]}
|
||||
return out
|
||||
|
||||
|
||||
def _red_ticket_to_dict(t: BizMigrationRedTicket) -> dict[str, Any]:
|
||||
return {
|
||||
"id": t.id,
|
||||
"project_id": t.project_id,
|
||||
"batch_id": t.batch_id,
|
||||
"run_id": t.run_id,
|
||||
"metric_id": t.metric_id,
|
||||
"key_str": t.key_str,
|
||||
"new_key_str": t.new_key_str,
|
||||
"verdict": t.verdict,
|
||||
"color": t.color,
|
||||
"old_status": t.old_status,
|
||||
"new_status": t.new_status,
|
||||
"detail": dict(t.detail_json or {}),
|
||||
"status": t.status,
|
||||
"carried_to_batch_id": t.carried_to_batch_id,
|
||||
"note": t.note,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
"resolved_at": t.resolved_at.isoformat() if t.resolved_at else None,
|
||||
}
|
||||
|
||||
|
||||
def count_open_red_tickets(db: Session, project_id: str) -> int:
|
||||
return (
|
||||
db.query(BizMigrationRedTicket)
|
||||
.filter(
|
||||
BizMigrationRedTicket.project_id == project_id,
|
||||
BizMigrationRedTicket.status.in_(("open", "carried")),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def list_red_tickets(
|
||||
db: Session,
|
||||
project_id: str,
|
||||
*,
|
||||
status: str = "",
|
||||
limit: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
if not db.get(BizMigrationProject, project_id):
|
||||
raise HTTPException(status_code=404, detail="project_not_found")
|
||||
q = db.query(BizMigrationRedTicket).filter(BizMigrationRedTicket.project_id == project_id)
|
||||
if status.strip():
|
||||
q = q.filter(BizMigrationRedTicket.status == status.strip())
|
||||
rows = (
|
||||
q.order_by(BizMigrationRedTicket.created_at.desc())
|
||||
.limit(min(500, max(1, limit)))
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"open_count": count_open_red_tickets(db, project_id),
|
||||
"items": [_red_ticket_to_dict(t) for t in rows],
|
||||
}
|
||||
|
||||
|
||||
def resolve_red_ticket(db: Session, ticket_id: str, *, note: str = "") -> dict[str, Any]:
|
||||
t = db.get(BizMigrationRedTicket, ticket_id)
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail="red_ticket_not_found")
|
||||
t.status = "resolved"
|
||||
t.resolved_at = utcnow_naive()
|
||||
if note:
|
||||
t.note = str(note)[:500]
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return _red_ticket_to_dict(t)
|
||||
|
||||
|
||||
def _persist_red_tickets_from_run(
|
||||
db: Session,
|
||||
*,
|
||||
project_id: str,
|
||||
batch_id: str,
|
||||
run_id: str,
|
||||
) -> list[BizMigrationRedTicket]:
|
||||
"""Create open red tickets from acceptance run red diffs (expect + anomaly)."""
|
||||
diffs = (
|
||||
db.query(BizMigrationDiff)
|
||||
.filter(BizMigrationDiff.run_id == run_id, BizMigrationDiff.color == "red")
|
||||
.order_by(BizMigrationDiff.seq.asc())
|
||||
.all()
|
||||
)
|
||||
created: list[BizMigrationRedTicket] = []
|
||||
for d in diffs:
|
||||
kj = d.key_json if isinstance(d.key_json, dict) else {}
|
||||
t = BizMigrationRedTicket(
|
||||
id=uuid4().hex,
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
run_id=run_id,
|
||||
metric_id=d.metric_id,
|
||||
key_str=str(kj.get("key_str") or "")[:256],
|
||||
new_key_str=str(kj.get("new_key_str") or "")[:256],
|
||||
verdict=d.verdict,
|
||||
color=d.color or "red",
|
||||
old_status=str(kj.get("old_status") or "")[:64],
|
||||
new_status=str(kj.get("new_status") or "")[:64],
|
||||
detail_json={
|
||||
"old_kind": d.old_kind,
|
||||
"new_kind": d.new_kind,
|
||||
"in_expect": d.in_expect,
|
||||
"old": d.old_json,
|
||||
"new": d.new_json,
|
||||
},
|
||||
status="open",
|
||||
)
|
||||
db.add(t)
|
||||
created.append(t)
|
||||
return created
|
||||
|
||||
|
||||
def finish_batch(
|
||||
db: Session,
|
||||
batch_id: str,
|
||||
*,
|
||||
mark_done: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""本批完成:关窗 → 终验 → 验收小结 → 红单留痕(不硬卡下一批)."""
|
||||
mb = db.get(BizMigrationBatch, batch_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||
if mb.status not in ("active", "review"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"batch_not_finishable:{mb.status}",
|
||||
)
|
||||
|
||||
# Close window first so acceptance uses non-active rules
|
||||
if mb.status == "active":
|
||||
mb.ended_at = utcnow_naive()
|
||||
mb.status = "done" if mark_done else "review"
|
||||
mb.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
|
||||
run = run_evaluate(db, batch_id=batch_id, acceptance=True, purpose="acceptance")
|
||||
summary = dict(run.get("summary") or {})
|
||||
progress = dict(summary.get("progress") or {})
|
||||
anomaly = int(summary.get("anomaly") or 0)
|
||||
ok = int(progress.get("ok") or 0)
|
||||
total = int(progress.get("total") or 0)
|
||||
passed = anomaly == 0 and (total == 0 or ok >= total)
|
||||
|
||||
accept_summary = {
|
||||
"passed": passed,
|
||||
"progress_ok": ok,
|
||||
"progress_total": total,
|
||||
"anomaly": anomaly,
|
||||
"verdict_counts": dict(summary.get("verdict_counts") or {}),
|
||||
"expect_ports": list(summary.get("expect_ports") or []),
|
||||
"run_id": run.get("id"),
|
||||
"old_batch_id": run.get("old_batch_id"),
|
||||
"new_batch_id": run.get("new_batch_id"),
|
||||
"finished_at": utcnow_naive().isoformat(),
|
||||
}
|
||||
|
||||
mb = db.get(BizMigrationBatch, batch_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||
mb.accept_run_id = str(run.get("id") or "")
|
||||
mb.accept_status = "passed" if passed else "failed"
|
||||
mb.accept_summary_json = accept_summary
|
||||
mb.updated_at = utcnow_naive()
|
||||
|
||||
# Replace prior open tickets from this batch's previous acceptance (re-finish)
|
||||
db.query(BizMigrationRedTicket).filter(
|
||||
BizMigrationRedTicket.batch_id == batch_id,
|
||||
BizMigrationRedTicket.status == "open",
|
||||
).delete()
|
||||
reds = _persist_red_tickets_from_run(
|
||||
db,
|
||||
project_id=mb.project_id,
|
||||
batch_id=batch_id,
|
||||
run_id=str(run.get("id") or ""),
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(mb)
|
||||
|
||||
return {
|
||||
"batch": batch_to_dict(mb),
|
||||
"run": run,
|
||||
"accept_summary": accept_summary,
|
||||
"red_tickets": [_red_ticket_to_dict(t) for t in reds],
|
||||
"open_red_count": count_open_red_tickets(db, mb.project_id),
|
||||
"can_continue": True, # 带红继续:永不硬卡
|
||||
}
|
||||
|
||||
|
||||
def patch_batch(db: Session, batch_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
b = db.get(BizMigrationBatch, batch_id)
|
||||
if not b:
|
||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||
if "batch_label" in body and body["batch_label"] is not None:
|
||||
b.batch_label = str(body["batch_label"]).strip() or b.batch_label
|
||||
if "note" in body and body["note"] is not None:
|
||||
b.note = str(body["note"])[:500]
|
||||
if "expect_set" in body and isinstance(body["expect_set"], dict):
|
||||
b.expect_set_json = dict(body["expect_set"])
|
||||
if "status" in body and body["status"] is not None:
|
||||
st = str(body["status"]).strip()
|
||||
# Prefer finish_batch for review/done from active (auto acceptance)
|
||||
if st in ("review", "done") and b.status == "active":
|
||||
return finish_batch(db, batch_id, mark_done=(st == "done"))["batch"]
|
||||
prev = b.status
|
||||
b.status = st or b.status
|
||||
if st == "active" and prev != "active":
|
||||
b.started_at = utcnow_naive()
|
||||
# 带红:标记既有 open 红单为 carried(不关闭)
|
||||
open_reds = (
|
||||
db.query(BizMigrationRedTicket)
|
||||
.filter(
|
||||
BizMigrationRedTicket.project_id == b.project_id,
|
||||
BizMigrationRedTicket.status == "open",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for t in open_reds:
|
||||
t.status = "carried"
|
||||
t.carried_to_batch_id = b.id
|
||||
b.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
db.refresh(b)
|
||||
return batch_to_dict(b)
|
||||
|
||||
|
||||
def get_run(db: Session, run_id: str) -> dict[str, Any]:
|
||||
run = db.get(BizMigrationRun, run_id)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="run_not_found")
|
||||
return run_to_dict(db, run, include_diffs=False)
|
||||
|
||||
|
||||
def list_run_diffs(
|
||||
db: Session,
|
||||
run_id: str,
|
||||
*,
|
||||
metric_id: str = "",
|
||||
verdict: str = "",
|
||||
color: str = "",
|
||||
kw: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
if not db.get(BizMigrationRun, run_id):
|
||||
raise HTTPException(status_code=404, detail="run_not_found")
|
||||
q = db.query(BizMigrationDiff).filter(BizMigrationDiff.run_id == run_id)
|
||||
if metric_id:
|
||||
q = q.filter(BizMigrationDiff.metric_id == metric_id)
|
||||
if verdict:
|
||||
q = q.filter(BizMigrationDiff.verdict == verdict)
|
||||
if color:
|
||||
q = q.filter(BizMigrationDiff.color == color)
|
||||
if kw.strip():
|
||||
like = f"%{kw.strip()}%"
|
||||
q = q.filter(BizMigrationDiff.search_text.ilike(like))
|
||||
total = q.count()
|
||||
rows = q.order_by(BizMigrationDiff.seq.asc()).offset(max(0, offset)).limit(min(500, max(1, limit))).all()
|
||||
return {
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"items": [diff_to_dict(d) for d in rows],
|
||||
}
|
||||
|
||||
|
||||
def list_runs(db: Session, batch_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizMigrationRun)
|
||||
.filter(BizMigrationRun.batch_id == batch_id)
|
||||
.order_by(BizMigrationRun.created_at.desc())
|
||||
.limit(min(100, max(1, limit)))
|
||||
.all()
|
||||
)
|
||||
return [run_to_dict(db, r) for r in rows]
|
||||
|
||||
|
||||
def board(db: Session, batch_id: str, run_id: str = "") -> dict[str, Any]:
|
||||
"""Progress / anomaly board for a migration batch (latest or given run)."""
|
||||
mb = db.get(BizMigrationBatch, batch_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||
proj = get_project(db, mb.project_id)
|
||||
run = None
|
||||
if run_id:
|
||||
run = db.get(BizMigrationRun, run_id)
|
||||
if not run or run.batch_id != batch_id:
|
||||
raise HTTPException(status_code=404, detail="run_not_found")
|
||||
else:
|
||||
run = (
|
||||
db.query(BizMigrationRun)
|
||||
.filter(BizMigrationRun.batch_id == batch_id)
|
||||
.order_by(BizMigrationRun.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
"project": proj,
|
||||
"batch": batch_to_dict(mb),
|
||||
"run": run_to_dict(db, run) if run else None,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue