diff --git a/netx_api/biz_migration/evaluate.py b/netx_api/biz_migration/evaluate.py index a3a6d1b..9c21859 100644 --- a/netx_api/biz_migration/evaluate.py +++ b/netx_api/biz_migration/evaluate.py @@ -79,9 +79,10 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]: if not isinstance(it, dict): continue mid = str(it.get("metric_id") or "").strip() - if not mid: + sid = str(it.get("sheet_id") or "").strip() or mid + if not sid: continue - bucket = out.setdefault(mid, set()) + bucket = out.setdefault(sid, set()) if it.get("key") is not None: k = it.get("key") if isinstance(k, (list, tuple)): @@ -747,10 +748,20 @@ def expect_keys_for_metric( *, metric_id: str, iface_fields: list[str], + sheet_id: str = "", ) -> set[str]: - keys = set(expect.get(metric_id) or ()) + """Resolve expect keys for a compare sheet. + + Prefer ``sheet_id`` (split tables); fall back to ``metric_id`` for legacy + unsplit sheets / expect items that only carry metric_id. + """ + sid = str(sheet_id or "").strip() + mid = str(metric_id or "").strip() + keys = set(expect.get(sid) or ()) if sid else set() + if not keys and mid: + keys = set(expect.get(mid) or ()) # ports: shorthand is only for interface_brief, not every sheet with iface fields - if metric_id == PORT_METRIC_ID and expect.get("_ports"): + if mid == PORT_METRIC_ID and expect.get("_ports"): keys |= set(expect["_ports"]) return keys @@ -879,15 +890,34 @@ def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]: return dict(diff.get("after") or {}) +def override_for_sheet( + sheet_overrides: list[dict[str, Any]] | None, + *, + sheet_id: str, + metric_id: str, +) -> dict[str, Any]: + """Match a monitor override. sheet_id wins; a legacy metric-only override applies to every split.""" + sid = str(sheet_id or "").strip() + mid = str(metric_id or "").strip() + legacy: dict[str, Any] | None = None + for ov in sheet_overrides or []: + if not isinstance(ov, dict): + continue + ov_sid = str(ov.get("sheet_id") or "").strip() + ov_mid = str(ov.get("metric_id") or "").strip() + if ov_sid and ov_sid == sid: + return ov + if not ov_sid and ov_mid and ov_mid in (sid, mid) and legacy is None: + legacy = ov + return legacy or {} + + def override_for_metric( sheet_overrides: list[dict[str, Any]] | None, metric_id: str, ) -> dict[str, Any]: mid = str(metric_id or "").strip() - for ov in sheet_overrides or []: - if isinstance(ov, dict) and str(ov.get("metric_id") or "").strip() == mid: - return ov - return {} + return override_for_sheet(sheet_overrides, sheet_id=mid, metric_id=mid) def evaluate_metric_dual( @@ -907,9 +937,13 @@ def evaluate_metric_dual( field_rules: list[dict[str, Any]] | None = None, sheet_override: dict[str, Any] | None = None, out_of_expect: str = "strict", + sheet_id: str = "", ) -> 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) + sid = str(sheet_id or "").strip() or str(metric_id or "").strip() + expect_keys = expect_keys_for_metric( + expect, metric_id=metric_id, iface_fields=iface_fields, sheet_id=sid + ) ov = sheet_override or {} if ov.get("skip_dual"): return { @@ -1155,6 +1189,8 @@ def evaluate_metric_dual( def port_sheet_def() -> dict[str, Any]: """Default sheet for port-status cutover monitor (interface_brief only).""" return { + "sheet_id": PORT_METRIC_ID, + "title": PORT_METRIC_ID, "metric_id": PORT_METRIC_ID, "key_fields": ["interface"], "iface_fields": ["interface"], diff --git a/netx_api/biz_migration/monitor_templates.py b/netx_api/biz_migration/monitor_templates.py index 2ca10dc..740c511 100644 --- a/netx_api/biz_migration/monitor_templates.py +++ b/netx_api/biz_migration/monitor_templates.py @@ -127,68 +127,89 @@ def _success_stateful( } -def preset_override_for_metric(metric_id: str) -> dict[str, Any]: - """Default dual-verdict overlay for a compare-template sheet.""" +def preset_override_for_metric(metric_id: str, *, sheet_id: str = "") -> dict[str, Any]: + """Default dual-verdict overlay for a compare-template sheet. + + ``metric_id`` is the collected source table; ``sheet_id`` identifies a + filtered split (e.g. bgp_peer.vpnv4). Presets key off the source metric. + """ mid = str(metric_id or "").strip() - if mid == PORT_METRIC_ID: + sid = str(sheet_id or "").strip() + # Callers may pass only a split id like "bgp_peer.vpnv4" + if not mid and sid: + mid = sid.split(".", 1)[0] + if not sid: + sid = mid + look = mid.split(".", 1)[0] if "." in mid else mid + + def _out(body: dict[str, Any]) -> dict[str, Any]: + body["metric_id"] = mid or look + if sid and sid != (mid or look): + body["sheet_id"] = sid + return body + + if look == PORT_METRIC_ID: up = [ {"type": "value", "field": "admin", "op": "in", "value": ["up"]}, {"type": "value", "field": "phy", "op": "in", "value": ["up"]}, ] - return { - "metric_id": mid, - "status_fields": list(PORT_STATUS_FIELDS), - "down_values": ["down"], - "up_values": ["up"], - "success": [ - _success_stateful( - old_down_groups=[ - [ - {"type": "value", "field": "admin", "op": "in", "value": ["down"]}, - {"type": "value", "field": "phy", "op": "in", "value": ["down"]}, - ] - ], - new_up_conds=up, - ) - ], - "anomaly": _default_anomaly_for_state("admin", ["down"]), - } - if mid == "bgp_peer": + return _out( + { + "status_fields": list(PORT_STATUS_FIELDS), + "down_values": ["down"], + "up_values": ["up"], + "success": [ + _success_stateful( + old_down_groups=[ + [ + {"type": "value", "field": "admin", "op": "in", "value": ["down"]}, + {"type": "value", "field": "phy", "op": "in", "value": ["down"]}, + ] + ], + new_up_conds=up, + ) + ], + "anomaly": _default_anomaly_for_state("admin", ["down"]), + } + ) + if look == "bgp_peer": up = [{"type": "value", "field": "state", "op": "eq", "value": "established"}] - return { - "metric_id": mid, - "status_fields": ["state"], - "down_values": ["idle", "active", "connect", "down"], - "up_values": ["established"], - "success": [ - _success_stateful( - old_down_groups=[ - [ - { - "type": "value", - "field": "state", - "op": "in", - "value": ["idle", "active", "connect", "down"], - } - ] - ], - new_up_conds=up, - ) - ], - "anomaly": _default_anomaly_for_state( - "state", ["idle", "active", "connect", "down"] - ), - } - if mid in ("arp", "nd6_cache", "lldp_neighbor"): - return { - "metric_id": mid, - "status_fields": [], - "down_values": [], - "up_values": [], - "success": [_success_presence_migrate()], - "anomaly": _default_anomaly_presence_only(), - } - if "isis" in mid or "ospf" in mid or "adjacency" in mid: + return _out( + { + "status_fields": ["state"], + "down_values": ["idle", "active", "connect", "down"], + "up_values": ["established"], + "success": [ + _success_stateful( + old_down_groups=[ + [ + { + "type": "value", + "field": "state", + "op": "in", + "value": ["idle", "active", "connect", "down"], + } + ] + ], + new_up_conds=up, + ) + ], + "anomaly": _default_anomaly_for_state( + "state", ["idle", "active", "connect", "down"] + ), + } + ) + if look in ("arp", "nd6_cache", "lldp_neighbor"): + return _out( + { + "status_fields": [], + "down_values": [], + "up_values": [], + "success": [_success_presence_migrate()], + "anomaly": _default_anomaly_presence_only(), + } + ) + if "isis" in look or "ospf" in look or "adjacency" in look: up = [ { "type": "value", @@ -197,45 +218,48 @@ def preset_override_for_metric(metric_id: str) -> dict[str, Any]: "value": ["up", "full", "2way"], } ] - return { - "metric_id": mid, - "status_fields": ["state"], - "down_values": ["down", "init", "idle"], - "up_values": ["up", "full", "2way"], - "success": [ - _success_stateful( - old_down_groups=[ - [ - { - "type": "value", - "field": "state", - "op": "in", - "value": ["down", "init", "idle"], - } - ] - ], - new_up_conds=up, - ) - ], - "anomaly": _default_anomaly_for_state("state", ["down", "init", "idle"]), - } - if "route" in mid or "vrf" in mid: - return { - "metric_id": mid, + return _out( + { + "status_fields": ["state"], + "down_values": ["down", "init", "idle"], + "up_values": ["up", "full", "2way"], + "success": [ + _success_stateful( + old_down_groups=[ + [ + { + "type": "value", + "field": "state", + "op": "in", + "value": ["down", "init", "idle"], + } + ] + ], + new_up_conds=up, + ) + ], + "anomaly": _default_anomaly_for_state("state", ["down", "init", "idle"]), + } + ) + if "route" in look or "vrf" in look: + return _out( + { + "status_fields": [], + "down_values": [], + "up_values": [], + "success": [_success_presence_migrate()], + "anomaly": _default_anomaly_presence_only(), + } + ) + return _out( + { "status_fields": [], "down_values": [], "up_values": [], "success": [_success_presence_migrate()], "anomaly": _default_anomaly_presence_only(), } - return { - "metric_id": mid, - "status_fields": [], - "down_values": [], - "up_values": [], - "success": [_success_presence_migrate()], - "anomaly": _default_anomaly_presence_only(), - } + ) def ensure_default_monitor_templates(db: Session) -> None: @@ -264,7 +288,14 @@ def ensure_default_monitor_templates(db: Session) -> None: ] if zte: zte_sheets = cmp_svc.template_metrics(zte) - zte_overrides = [preset_override_for_metric(str(s.get("metric_id") or "")) for s in zte_sheets if s.get("metric_id")] + zte_overrides = [ + preset_override_for_metric( + str(s.get("metric_id") or ""), + sheet_id=str(s.get("sheet_id") or s.get("metric_id") or ""), + ) + for s in zte_sheets + if s.get("metric_id") + ] seeds.append( BizMonitorTemplate( id=uuid4().hex, diff --git a/netx_api/biz_migration/service.py b/netx_api/biz_migration/service.py index fd5f83f..952c852 100644 --- a/netx_api/biz_migration/service.py +++ b/netx_api/biz_migration/service.py @@ -9,7 +9,7 @@ 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, template_metrics +from ..biz_state.compare_service import _load_metric_rows, _port_map_dict, sheet_key, sheet_title, template_metrics from ..models import ( BizCompareTemplate, BizMigrationBatch, @@ -27,7 +27,7 @@ from . import monitor_templates as mon_tpl from .evaluate import ( PORT_METRIC_ID, evaluate_metric_dual, - override_for_metric, + override_for_sheet, parse_expect_set, port_sheet_def, ) @@ -124,7 +124,12 @@ def resolve_collect_metric_ids(db: Session, proj: BizMigrationProject) -> list[s if collect: return collect sheets, _, _ = resolve_evaluate_sheets(db, mt) - return [str(s.get("metric_id") or "").strip() for s in sheets if str(s.get("metric_id") or "").strip()] + seen: list[str] = [] + for s in sheets: + mid = str(s.get("metric_id") or "").strip() + if mid and mid not in seen: + seen.append(mid) + return seen def project_to_dict(db: Session, p: BizMigrationProject) -> dict[str, Any]: @@ -372,6 +377,7 @@ def run_evaluate( for sheet in sheets: mid = str(sheet.get("metric_id") or "").strip() + sid = sheet_key(sheet) key_fields = list(sheet.get("key_fields") or []) if not mid or not key_fields: continue @@ -379,7 +385,7 @@ def run_evaluate( compare_fields = list(sheet.get("compare_fields") or []) row_filters = list(sheet.get("row_filters") or []) field_rules = list(sheet.get("field_rules") or []) - sheet_ov = override_for_metric(sheet_overrides, mid) + sheet_ov = override_for_sheet(sheet_overrides, sheet_id=sid, metric_id=mid) if sheet_ov.get("skip_dual"): continue @@ -402,6 +408,7 @@ def run_evaluate( one = evaluate_metric_dual( metric_id=mid, + sheet_id=sid, key_fields=key_fields, iface_fields=iface_fields, compare_fields=compare_fields, @@ -420,7 +427,8 @@ def run_evaluate( sheet_cards.append( { "metric_id": mid, - "title": mid, + "sheet_id": sid, + "title": sheet_title(sheet), "progress_ok": one["progress_ok"], "progress_total": one["progress_total"], "anomaly": one["anomaly"], @@ -433,6 +441,7 @@ def run_evaluate( ) for r in one["rows"]: r["seq"] = seq + r["sheet_id"] = sid seq += 1 all_rows.append(r) v = str(r.get("verdict") or "") @@ -460,7 +469,7 @@ def run_evaluate( "anomaly": sum(c["anomaly"] for c in sheet_cards), "new_baseline_missing": any(bool(c.get("new_baseline_missing")) for c in sheet_cards), "missing_metrics": [ - str(c.get("metric_id") or "") + str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "") for c in sheet_cards if c.get("new_baseline_missing") ], @@ -499,6 +508,7 @@ def run_evaluate( "old_status": r.get("old_status"), "new_status": r.get("new_status"), "rule_hit": r.get("rule_hit") or "", + "sheet_id": r.get("sheet_id") or "", }, old_kind=str(r.get("old_kind") or ""), new_kind=str(r.get("new_kind") or ""), @@ -545,6 +555,7 @@ def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]: return { "id": d.id, "metric_id": d.metric_id, + "sheet_id": kj.get("sheet_id") or d.metric_id or "", "seq": d.seq, "verdict": d.verdict, "color": d.color, @@ -610,10 +621,11 @@ def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any] out_sheets: list[dict[str, Any]] = [] for sheet in sheets: mid = str(sheet.get("metric_id") or "").strip() + sid = sheet_key(sheet) key_fields = [str(k) for k in (sheet.get("key_fields") or []) if str(k).strip()] if not mid or not key_fields: continue - sheet_ov = override_for_metric(sheet_overrides, mid) + sheet_ov = override_for_sheet(sheet_overrides, sheet_id=sid, metric_id=mid) if sheet_ov.get("skip_dual"): continue iface_fields = [str(k) for k in (sheet.get("iface_fields") or []) if str(k).strip()] @@ -643,6 +655,8 @@ def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any] out_sheets.append( { "metric_id": mid, + "sheet_id": sid, + "title": sheet_title(sheet), "key_fields": key_fields, "iface_fields": iface_fields, "items": items, diff --git a/netx_api/biz_state/compare_rules.py b/netx_api/biz_state/compare_rules.py index 2596457..104382d 100644 --- a/netx_api/biz_state/compare_rules.py +++ b/netx_api/biz_state/compare_rules.py @@ -44,6 +44,9 @@ def eval_leaf_filter(row: Mapping[str, Any], filt: Mapping[str, Any]) -> bool: if op in ("not_in", "nin"): opts = {str(x).strip().lower() for x in _as_list(expect) if str(x).strip()} return raw.lower() not in opts + if op == "contains": + needle = str(expect or "").strip().lower() + return bool(needle) and needle in raw.lower() if op == "empty": return not raw if op in ("not_empty", "nonempty"): diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index 7ed3e9f..816cefc 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -225,6 +225,8 @@ def _sheet_meta_from_summary(summary: dict[str, Any], run: BizCompareRun, tpl: A return [ { "metric_id": run.metric_id, + "sheet_id": run.metric_id, + "title": run.metric_id, "key_fields": list((tpl.key_fields if tpl else None) or []), "iface_fields": list((tpl.iface_fields if tpl else None) or []), "compare_fields": list((tpl.compare_fields if tpl else None) or []), @@ -285,10 +287,23 @@ def _normalize_field_rules(raw: Any) -> list[dict[str, Any]]: return out +def sheet_key(sheet: dict[str, Any] | None) -> str: + """Unique compare-item id. Falls back to metric_id so old sheets stay valid.""" + data = sheet or {} + return str(data.get("sheet_id") or data.get("metric_id") or "").strip() + + +def sheet_title(sheet: dict[str, Any] | None) -> str: + data = sheet or {} + return str(data.get("title") or "").strip() or sheet_key(data) + + def _sheet_def( *, metric_id: str, key_fields: list[str], + sheet_id: str | None = None, + title: str | None = None, iface_fields: list[str] | None = None, compare_fields: list[str] | None = None, display_fields: list[str] | None = None, @@ -296,6 +311,8 @@ def _sheet_def( field_rules: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: mid = str(metric_id or "").strip() + sid = str(sheet_id or "").strip() or mid + ttl = str(title or "").strip() or sid keys = _str_list(key_fields) ifaces = _str_list(iface_fields) # compare_fields empty → presence-only mode (intentional) @@ -320,6 +337,8 @@ def _sheet_def( display_fields=_str_list(display_fields), ) sheet: dict[str, Any] = { + "sheet_id": sid, + "title": ttl, "metric_id": mid, "key_fields": keys, "iface_fields": ifaces, @@ -389,13 +408,78 @@ def _default_sheet_for_metric(metric_id: str, *, compare_roles: tuple[str, ...] ) +def _sheets_split_by_field( + metric_id: str, + field: str, + slices: tuple[tuple[str, str, str], ...], + *, + op: str = "eq", + compare_roles: tuple[str, ...] = ("state",), +) -> list[dict[str, Any]]: + """One collected metric → many compare sheets, each a row_filter slice. + + ``slices`` is ``(sheet_id, title, filter_value)``. Any metric can be split + this way (BGP afi, ISIS af, …); the engine does not special-case names. + """ + out: list[dict[str, Any]] = [] + for sid, title, value in slices: + base = _default_sheet_for_metric(metric_id, compare_roles=compare_roles) + out.append( + _sheet_def( + metric_id=metric_id, + sheet_id=sid, + title=title, + key_fields=list(base.get("key_fields") or []), + iface_fields=list(base.get("iface_fields") or []), + compare_fields=list(base.get("compare_fields") or []), + display_fields=list(base.get("display_fields") or []), + row_filters=[{"field": field, "op": op, "value": value}], + field_rules=list(base.get("field_rules") or []), + ) + ) + return out + + +def _bgp_afi_sheets() -> list[dict[str, Any]]: + return _sheets_split_by_field( + "bgp_peer", + "afi", + ( + ("bgp_peer.ipv4", "BGP IPv4", "ipv4"), + ("bgp_peer.ipv6", "BGP IPv6", "ipv6"), + ("bgp_peer.vpnv4", "BGP VPNv4", "vpnv4"), + ("bgp_peer.vpnv6", "BGP VPNv6", "vpnv6"), + ), + op="eq", + ) + + +def _isis_af_sheets() -> list[dict[str, Any]]: + return _sheets_split_by_field( + "isis_adjacency", + "af", + ( + ("isis_adjacency.ipv4", "ISIS IPv4", "IPv4"), + ("isis_adjacency.ipv6", "ISIS IPv6", "IPv6"), + ), + op="contains", + ) + + +def _builtin_source_splits() -> dict[str, list[dict[str, Any]]]: + return { + "bgp_peer": _bgp_afi_sheets(), + "isis_adjacency": _isis_af_sheets(), + } + + def _default_zte_status_sheets() -> list[dict[str, Any]]: return [ - _default_sheet_for_metric("isis_adjacency", compare_roles=("state",)), + *_isis_af_sheets(), _default_sheet_for_metric("interface_brief", compare_roles=("state",)), _default_sheet_for_metric("arp", compare_roles=("state",)), _default_sheet_for_metric("nd6_cache", compare_roles=("state",)), - _default_sheet_for_metric("bgp_peer", compare_roles=("state",)), + *_bgp_afi_sheets(), ] @@ -421,6 +505,8 @@ def _normalize_sheet(raw: Any) -> dict[str, Any] | None: disp_arg = None return _sheet_def( metric_id=mid, + sheet_id=str(raw.get("sheet_id") or "").strip() or mid, + title=str(raw.get("title") or "").strip() or None, key_fields=keys, iface_fields=_str_list(raw.get("iface_fields")), compare_fields=_str_list(raw.get("compare_fields")), @@ -458,10 +544,10 @@ def template_metrics(t: BizCompareTemplate) -> list[dict[str, Any]]: sheet = _normalize_sheet(item) if not sheet: continue - mid = sheet["metric_id"] - if mid in seen: + sid = sheet_key(sheet) + if sid in seen: continue - seen.add(mid) + seen.add(sid) out.append(sheet) if out: return out @@ -494,10 +580,10 @@ def _parse_metrics_body(body: dict[str, Any]) -> list[dict[str, Any]]: sheet = _normalize_sheet(raw) if not sheet: continue - mid = sheet["metric_id"] - if mid in seen: - raise HTTPException(status_code=400, detail=f"duplicate_metric:{mid}") - seen.add(mid) + sid = sheet_key(sheet) + if sid in seen: + raise HTTPException(status_code=400, detail=f"duplicate_sheet:{sid}") + seen.add(sid) sheets.append(sheet) if not sheets: raise HTTPException(status_code=400, detail="metrics_required") @@ -524,6 +610,8 @@ def _parse_metrics_body(body: dict[str, Any]) -> list[dict[str, Any]]: return [ _sheet_def( metric_id=mid, + sheet_id=str(body.get("sheet_id") or "").strip() or mid, + title=str(body.get("title") or "").strip() or None, key_fields=keys, iface_fields=_str_list(body.get("iface_fields")), compare_fields=compare, @@ -541,7 +629,7 @@ def _template_out(t: BizCompareTemplate) -> dict[str, Any]: "id": t.id, "name": t.name, "metrics": sheets, - "metric_ids": [s["metric_id"] for s in sheets], + "metric_ids": list(dict.fromkeys(s["metric_id"] for s in sheets if s.get("metric_id"))), # legacy mirrors (first sheet) "metric_id": (first or {}).get("metric_id") or t.metric_id or "", "key_fields": list((first or {}).get("key_fields") or t.key_fields or []), @@ -698,8 +786,44 @@ def ensure_default_templates(db: Session) -> None: ensure_default_zte_status_template(db) +def upgrade_builtin_split_sheets(db: Session) -> None: + """Split unfiltered whole-table sheets on the built-in ZTE template only. + + A sheet is replaced when its id is still the source metric and it has no + row filters. Custom templates and already-split sheets are left alone. + """ + row = ( + db.query(BizCompareTemplate) + .filter(BizCompareTemplate.name == "ZTE status default") + .one_or_none() + ) + if not row: + return + existing = template_metrics(row) + splits = _builtin_source_splits() + out: list[dict[str, Any]] = [] + changed = False + replaced: set[str] = set() + for s in existing: + mid = str(s.get("metric_id") or "") + if mid in splits and sheet_key(s) == mid and not list(s.get("row_filters") or []): + if mid not in replaced: + out.extend(splits[mid]) + replaced.add(mid) + changed = True + continue + out.append(s) + if not changed or not out: + return + _apply_sheets_to_row(row, out) + row.note = "Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP, address-family sheets)" + row.updated_at = _utcnow() + db.commit() + + def list_templates(db: Session) -> list[dict[str, Any]]: ensure_default_templates(db) + upgrade_builtin_split_sheets(db) rows = db.query(BizCompareTemplate).order_by(BizCompareTemplate.name.asc()).all() return [_template_out(t) for t in rows] @@ -1143,6 +1267,8 @@ def _run_sheet( summary["after_raw_count"] = len(after_raw) summary["row_filters"] = len(row_filters) return { + "sheet_id": sheet_key(sheet), + "title": sheet_title(sheet), "metric_id": sheet["metric_id"], "key_fields": key_fields, "iface_fields": iface_fields, @@ -1198,7 +1324,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> s = one["summary"] for k in agg: agg[k] += int(s.get(k) or 0) - mapping_by_metric[one["metric_id"]] = one["mapping_stats"] + mapping_by_metric[sheet_key(one)] = one["mapping_stats"] first = sheet_results[0] field_counts: dict[str, int] = {} @@ -1221,6 +1347,8 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> "top_changed_fields": top_fields, "sheets": [ { + "sheet_id": s.get("sheet_id") or s["metric_id"], + "title": s.get("title") or s.get("sheet_id") or s["metric_id"], "metric_id": s["metric_id"], "key_fields": s["key_fields"], "iface_fields": s["iface_fields"], @@ -1255,7 +1383,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> _persist_sheet_diffs( db, run_id=run_id, - metric_id=str(s["metric_id"]), + metric_id=sheet_key(s), diffs=list(s.get("diffs") or []), ) j.updated_at = _utcnow() @@ -1352,6 +1480,8 @@ def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> di sj = sf + su sheet_cards.append( { + "sheet_id": sheet_key(sh), + "title": sheet_title(sh), "metric_id": sh.get("metric_id") or "", "mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"), "added": sa, @@ -1413,6 +1543,8 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: # Never return full diffs in run detail (million-row safe) sheets = [ { + "sheet_id": sh.get("sheet_id") or sh.get("metric_id") or "", + "title": sh.get("title") or sh.get("sheet_id") or sh.get("metric_id") or "", "metric_id": sh.get("metric_id") or "", "key_fields": list(sh.get("key_fields") or []), "iface_fields": list(sh.get("iface_fields") or []), @@ -1459,6 +1591,20 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: } +def _lookup_sheet(sheets: list[dict[str, Any]], key: str) -> dict[str, Any] | None: + """Find a result sheet by sheet_id, or by metric_id when that source is unique.""" + k = str(key or "").strip() + if not k: + return sheets[0] if sheets else None + for s in sheets: + if sheet_key(s) == k: + return s + hits = [s for s in sheets if str(s.get("metric_id") or "") == k] + if len(hits) == 1: + return hits[0] + return None + + def list_run_diffs( db: Session, run_id: str, @@ -1480,7 +1626,9 @@ def list_run_diffs( summary = dict(r.summary_json or {}) tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None sheets = _sheet_meta_from_summary(summary, r, tpl) - mid = (metric_id or "").strip() or (sheets[0].get("metric_id") if sheets else r.metric_id) or "" + asked = (metric_id or "").strip() + sheet = _lookup_sheet(sheets, asked) if asked else (sheets[0] if sheets else None) + mid = sheet_key(sheet) if sheet else (asked or str(r.metric_id or "")) if _run_has_diff_rows(db, run_id): q = db.query(BizCompareDiff).filter( @@ -1509,10 +1657,9 @@ def list_run_diffs( } # Legacy: diffs embedded in summary_json / diffs_json - sheet = next((s for s in sheets if str(s.get("metric_id") or "") == mid), None) if sheet is None and sheets: sheet = sheets[0] - mid = str(sheet.get("metric_id") or mid) + mid = sheet_key(sheet) inline = list((sheet or {}).get("diffs") or []) if not inline and mid == r.metric_id: inline = list(r.diffs_json or []) @@ -1556,7 +1703,9 @@ def _iter_sheet_diffs(db: Session, run_id: str, metric_id: str) -> list[dict[str summary = dict(r.summary_json or {}) sheets = list(summary.get("sheets") or []) for sh in sheets: - if str(sh.get("metric_id") or "") == metric_id: + if sheet_key(sh) == metric_id or ( + str(sh.get("metric_id") or "") == metric_id and sheet_key(sh) == metric_id + ): return list(sh.get("diffs") or []) if metric_id == r.metric_id: return list(r.diffs_json or []) @@ -1584,17 +1733,17 @@ def export_run_zip(db: Session, run_id: str) -> bytes: ] for card in list(s.get("sheet_cards") or []): manifest.append( - f"- {card.get('metric_id')}: diff={card.get('diff_count')} " + f"- {card.get('title') or card.get('sheet_id') or card.get('metric_id')}: diff={card.get('diff_count')} " f"pass={card.get('pass_rate')}% " f"+{card.get('added')}/-{card.get('removed')}/~{card.get('changed')}/= {card.get('unchanged')}" ) zf.writestr("manifest.txt", "\n".join(manifest) + "\n") for sheet in list(detail.get("sheets") or []): - mid = str(sheet.get("metric_id") or "sheet") - safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "sheet" + sid = sheet_key(sheet) or "sheet" + safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in sid)[:80] or "sheet" sheet_full = { **sheet, - "diffs": _iter_sheet_diffs(db, run_id, mid), + "diffs": _iter_sheet_diffs(db, run_id, sid), } zf.writestr(f"tables/{safe}.csv", _sheet_csv(sheet_full)) sum_lines = ["metric_id,mode,before,after,added,removed,changed,unchanged,diff_count,pass_rate"] @@ -1603,7 +1752,7 @@ def export_run_zip(db: Session, run_id: str) -> bytes: ",".join( _csv_cell(x) for x in ( - card.get("metric_id"), + card.get("title") or card.get("sheet_id") or card.get("metric_id"), card.get("mode"), card.get("before_count"), card.get("after_count"), diff --git a/netx_api/biz_state/parsers/zte/bgp_peer.py b/netx_api/biz_state/parsers/zte/bgp_peer.py index 5552bdd..2374e12 100644 --- a/netx_api/biz_state/parsers/zte/bgp_peer.py +++ b/netx_api/biz_state/parsers/zte/bgp_peer.py @@ -76,7 +76,6 @@ def normalize_bgp_peer( "up_down": m.group("up")[:32], "state": state[:64], "pfx_rcd": pfx[:32], - "state_or_pfx": state_raw[:64], } ) return out diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index ef8bb81..8c289a3 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -298,7 +298,6 @@ _BGP_PEER_FIELDS: list[FieldDef] = [ FieldDef("as_num", length=16, role="state", display_name="AS"), FieldDef("state", length=64, role="state", display_name="State"), FieldDef("pfx_rcd", length=32, role="state", display_name="PfxRcd"), - FieldDef("state_or_pfx", length=64, role="meta", display_name="State/PfxRcd"), FieldDef("ver", length=8, role="meta", display_name="Ver"), FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"), FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"), diff --git a/netx_api/models/biz_migration.py b/netx_api/models/biz_migration.py index ca61c10..1e74a5b 100644 --- a/netx_api/models/biz_migration.py +++ b/netx_api/models/biz_migration.py @@ -44,7 +44,7 @@ class BizMigrationBatch(Base): batch_label: Mapped[str] = mapped_column(String(128), default="") # pending | active | review | done status: Mapped[str] = mapped_column(String(32), default="pending", index=True) - # {"ports": ["gei-..."], "items": [{"metric_id":"bgp_peer","key":"..."}]} + # {"ports": ["gei-..."], "items": [{"metric_id":"bgp_peer","sheet_id":"bgp_peer.vpnv4","key":"..."}]} expect_set_json: Mapped[dict] = mapped_column(_JsonType, default=dict) started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/netx_api/models/biz_state.py b/netx_api/models/biz_state.py index b24247a..c643c3f 100644 --- a/netx_api/models/biz_state.py +++ b/netx_api/models/biz_state.py @@ -205,8 +205,11 @@ class BizStateCommandOverride(Base): class BizCompareTemplate(Base): """Compare template: one or more metric sheets with key/iface/compare roles. - ``metrics_json`` is the source of truth (list of sheet defs). Legacy - ``metric_id`` / ``key_fields`` / … mirror the first sheet for older rows. + ``metrics_json`` is the source of truth (list of sheet defs). Each sheet has + ``metric_id`` (collected source table) and optional ``sheet_id`` / ``title`` / + ``row_filters`` so one source can be split into multiple compare items + (e.g. bgp_peer → vpnv4/vpnv6). Legacy ``metric_id`` / ``key_fields`` / … + mirror the first sheet for older rows. Empty ``compare_fields`` on a sheet = presence-only (entry set match). """ diff --git a/tests/test_biz_migration_evaluate.py b/tests/test_biz_migration_evaluate.py index ae767a1..36826e5 100644 --- a/tests/test_biz_migration_evaluate.py +++ b/tests/test_biz_migration_evaluate.py @@ -27,6 +27,55 @@ PORT_OVERRIDE = { class ParseExpectSetTests(unittest.TestCase): + def test_items_sheet_id_bucket(self): + got = parse_expect_set( + { + "items": [ + {"metric_id": "bgp_peer", "sheet_id": "bgp_peer.vpnv4", "key": "1.1.1.1"}, + {"metric_id": "bgp_peer", "sheet_id": "bgp_peer.ipv4", "key": "2.2.2.2"}, + ] + } + ) + self.assertEqual(got["bgp_peer.vpnv4"], {"1.1.1.1"}) + self.assertEqual(got["bgp_peer.ipv4"], {"2.2.2.2"}) + self.assertNotIn("bgp_peer", got) + + def test_expect_keys_prefer_sheet_id(self): + expect = parse_expect_set( + { + "items": [ + {"metric_id": "bgp_peer", "sheet_id": "bgp_peer.vpnv4", "key": "1.1.1.1"}, + {"metric_id": "bgp_peer", "key": "9.9.9.9"}, + ] + } + ) + self.assertEqual( + expect_keys_for_metric( + expect, metric_id="bgp_peer", iface_fields=[], sheet_id="bgp_peer.vpnv4" + ), + {"1.1.1.1"}, + ) + self.assertEqual( + expect_keys_for_metric(expect, metric_id="bgp_peer", iface_fields=[]), + {"9.9.9.9"}, + ) + + def test_override_for_sheet_prefers_sheet_id(self): + from netx_api.biz_migration.evaluate import override_for_sheet + + overrides = [ + {"metric_id": "bgp_peer", "status_fields": ["state"]}, + {"metric_id": "bgp_peer", "sheet_id": "bgp_peer.vpnv4", "status_fields": ["state", "pfx_rcd"]}, + ] + hit = override_for_sheet( + overrides, sheet_id="bgp_peer.vpnv4", metric_id="bgp_peer" + ) + self.assertEqual(hit["status_fields"], ["state", "pfx_rcd"]) + legacy = override_for_sheet( + overrides, sheet_id="bgp_peer.ipv4", metric_id="bgp_peer" + ) + self.assertEqual(legacy["status_fields"], ["state"]) + def test_ports_go_to_interface_brief(self): got = parse_expect_set({"ports": ["gei-1", "gei-2", ""]}) self.assertEqual(got["interface_brief"], {"gei-1", "gei-2"}) diff --git a/tests/test_biz_state_compare.py b/tests/test_biz_state_compare.py index df79cb9..7ee8f70 100644 --- a/tests/test_biz_state_compare.py +++ b/tests/test_biz_state_compare.py @@ -10,6 +10,7 @@ from netx_api.biz_state.compare_rules import ( arp_dynamic_row_filters, effective_compare_fields, effective_display_fields, + eval_leaf_filter, normalize_value, values_equal, ) @@ -314,6 +315,57 @@ class CompareDiffPagingTests(unittest.TestCase): class CompareSheetDefaultsTests(unittest.TestCase): + def test_contains_filter_op(self) -> None: + row = {"af": "IPv4,IPv6"} + self.assertTrue(eval_leaf_filter(row, {"field": "af", "op": "contains", "value": "IPv4"})) + self.assertTrue(eval_leaf_filter(row, {"field": "af", "op": "contains", "value": "ipv6"})) + self.assertFalse(eval_leaf_filter(row, {"field": "af", "op": "contains", "value": "vpn"})) + + def test_zte_default_splits_bgp_and_isis(self) -> None: + from netx_api.biz_state.compare_service import _default_zte_status_sheets, sheet_key + + sheets = _default_zte_status_sheets() + ids = [sheet_key(s) for s in sheets] + self.assertIn("bgp_peer.ipv4", ids) + self.assertIn("bgp_peer.vpnv4", ids) + self.assertIn("bgp_peer.vpnv6", ids) + self.assertIn("isis_adjacency.ipv4", ids) + self.assertIn("isis_adjacency.ipv6", ids) + # Same source metric may appear multiple times + self.assertEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_peer"), 4) + vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4") + self.assertEqual(vpnv4["row_filters"], [{"field": "afi", "op": "eq", "value": "vpnv4"}]) + isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4") + self.assertEqual(isis4["row_filters"][0]["op"], "contains") + + def test_normalize_allows_duplicate_metric_with_distinct_sheet_id(self) -> None: + from netx_api.biz_state.compare_service import _normalize_sheet, sheet_key + + a = _normalize_sheet( + { + "sheet_id": "bgp_peer.vpnv4", + "title": "BGP VPNv4", + "metric_id": "bgp_peer", + "key_fields": ["afi", "neighbor"], + "compare_fields": ["state"], + "row_filters": [{"field": "afi", "op": "eq", "value": "vpnv4"}], + } + ) + b = _normalize_sheet( + { + "sheet_id": "bgp_peer.ipv4", + "title": "BGP IPv4", + "metric_id": "bgp_peer", + "key_fields": ["afi", "neighbor"], + "compare_fields": ["state"], + "row_filters": [{"field": "afi", "op": "eq", "value": "ipv4"}], + } + ) + self.assertIsNotNone(a) + self.assertIsNotNone(b) + self.assertEqual(a["metric_id"], b["metric_id"]) + self.assertNotEqual(sheet_key(a), sheet_key(b)) + def test_arp_default_sheet_has_row_filters(self) -> None: from netx_api.biz_state.compare_service import _default_sheet_for_metric diff --git a/tests/test_zte_status_parsers.py b/tests/test_zte_status_parsers.py index 12ead32..ce6dbd4 100644 --- a/tests/test_zte_status_parsers.py +++ b/tests/test_zte_status_parsers.py @@ -145,6 +145,12 @@ class ZteStatusParserTests(unittest.TestCase): conn = [r for r in rows if r["state"] == "Connect"] self.assertGreaterEqual(len(est), 3) self.assertGreaterEqual(len(conn), 3) + self.assertTrue(all("state_or_pfx" not in r for r in rows)) + self.assertTrue(all(r["pfx_rcd"].isdigit() for r in est)) + self.assertTrue(all(r["pfx_rcd"] == "" for r in conn)) + one = next(r for r in rows if r["neighbor"] == "10.206.63.205") + self.assertEqual(one["state"], "Established") + self.assertEqual(one["pfx_rcd"], "642") ipv4 = _section(self.log, "show bgp ipv4 unicast summary", ("show bgp vpnv6",)) rows2 = normalize_bgp_peer(raw_text=ipv4, command="show bgp ipv4 unicast summary") diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1d65c47..d2a887a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -345,6 +345,11 @@ const en = { pickMetric: "Pick metric…", addSheet: "Add to template", removeSheet: "Remove sheet", + sheetIdRequired: "sheet_id is required (unique per split)", + duplicateSheetId: "sheet_id must be unique", + sheetTitle: "Sheet title", + sheetId: "sheet_id", + splitSheet: "Split / duplicate", colSheets: "Sheets", sheetsUnit: "sheets", presenceShort: "presence", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 72732d0..36d839b 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -344,6 +344,11 @@ const zh = { pickMetric: "选择监控项…", addSheet: "加入模板", removeSheet: "移出本表", + sheetIdRequired: "请填写 sheet_id(拆表唯一标识)", + duplicateSheetId: "sheet_id 不能重复", + sheetTitle: "表标题", + sheetId: "sheet_id", + splitSheet: "拆表复制", colSheets: "监控表", sheetsUnit: "张表", presenceShort: "仅条目", diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index 71f7113..9d510c7 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -49,6 +49,8 @@ type MetricField = { type MetricSchema = { metric_id: string; fields: MetricField[] }; type MetricSheet = { + sheet_id?: string; + title?: string; metric_id: string; key_fields: string[]; iface_fields: string[]; @@ -111,6 +113,8 @@ type DiffRow = { }; type RunSheet = { + sheet_id?: string; + title?: string; metric_id: string; key_fields: string[]; iface_fields: string[]; @@ -285,12 +289,22 @@ function taskLabel(row: TaskOpt) { return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`; } +function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string { + return String(s?.sheet_id || s?.metric_id || "").trim(); +} + +function sheetLabel(s: { title?: string; sheet_id?: string; metric_id?: string } | null | undefined): string { + return String(s?.title || s?.sheet_id || s?.metric_id || "").trim() || "—"; +} + function templateSheets(tpl?: Template | null): MetricSheet[] { if (!tpl) return []; if (tpl.metrics?.length) return tpl.metrics; if (tpl.metric_id) { return [ { + sheet_id: tpl.metric_id, + title: tpl.metric_id, metric_id: tpl.metric_id, key_fields: [...(tpl.key_fields || [])], iface_fields: [...(tpl.iface_fields || [])], @@ -311,6 +325,8 @@ function defaultSheetForMetric(schema: MetricSchema | undefined, metricId: strin .filter((f) => !f.is_key && (f.role === "state" || f.role === "meta")) .map((f) => f.name); const sheet: MetricSheet = { + sheet_id: metricId, + title: metricId, metric_id: metricId, key_fields, iface_fields: fields.filter((f) => f.is_interface).map((f) => f.name), @@ -353,6 +369,8 @@ function cloneSheet(s: MetricSheet): MetricSheet { ? [...s.display_fields] : [...keys, ...compare]; return { + sheet_id: s.sheet_id || s.metric_id, + title: s.title || s.sheet_id || s.metric_id, metric_id: s.metric_id, key_fields: keys, iface_fields: [...(s.iface_fields || [])], @@ -368,6 +386,8 @@ const TEMPLATE_EXPORT_VERSION = 1; function templateExportPayload(tpl: Template) { const metrics = templateSheets(tpl).map((s) => ({ + sheet_id: s.sheet_id || s.metric_id, + title: s.title || s.sheet_id || s.metric_id, metric_id: s.metric_id, key_fields: [...(s.key_fields || [])], iface_fields: [...(s.iface_fields || [])], @@ -424,6 +444,8 @@ function parseTemplateImport(raw: unknown): { : []; if (!mid || !keys.length) continue; metrics.push({ + sheet_id: String(m.sheet_id || mid).trim() || mid, + title: String(m.title || m.sheet_id || mid).trim() || mid, metric_id: mid, key_fields: keys, iface_fields: Array.isArray(m.iface_fields) @@ -530,6 +552,7 @@ const FILTER_OPS = [ "ne", "in", "not_in", + "contains", "empty", "not_empty", "regex", @@ -834,13 +857,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage setResultSheetId(""); return; } - if (!resultSheetId || !runSheets.some((s) => s.metric_id === resultSheetId)) { - setResultSheetId(runSheets[0].metric_id); + if (!resultSheetId || !runSheets.some((s) => sheetIdentity(s) === resultSheetId)) { + setResultSheetId(sheetIdentity(runSheets[0])); } }, [runSheets, resultSheetId]); const activeRunSheet = useMemo( - () => runSheets.find((s) => s.metric_id === resultSheetId) || runSheets[0] || null, + () => runSheets.find((s) => sheetIdentity(s) === resultSheetId) || runSheets[0] || null, [runSheets, resultSheetId], ); @@ -851,7 +874,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage useEffect(() => { const runId = String(runDetail?.id || ""); - const mid = resultSheetId || activeRunSheet?.metric_id || ""; + const mid = resultSheetId || sheetIdentity(activeRunSheet) || ""; if (!runId || !mid || jobDetailTab !== "result") { setPagedDiffs([]); setResultTotal(0); @@ -1002,6 +1025,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const resultPages = Math.max(1, Math.ceil(resultTotal / Math.max(1, resultPageSize))); const sheetCards = useMemo(() => { const raw = (summary.sheet_cards || []) as Array<{ + sheet_id?: string; + title?: string; metric_id: string; mode?: string; added?: number; @@ -1023,7 +1048,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage return String(a.metric_id).localeCompare(String(b.metric_id)); }); }, [summary.sheet_cards]); - const activeSheetCard = sheetCards.find((c) => c.metric_id === resultSheetId) || sheetCards[0]; + const activeSheetCard = sheetCards.find((c) => sheetIdentity(c) === resultSheetId) || sheetCards[0]; const activeFail = sheetFailOf(activeSheetCard || {}); const activeSuccess = sheetSuccessOf(activeSheetCard || {}); const activePassRate = sheetPassRateOf(activeSheetCard || {}); @@ -1125,7 +1150,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage if (on) { const schema = metrics.find((m) => m.metric_id === metricId); setTplSheets((prev) => { - if (prev.some((s) => s.metric_id === metricId)) return prev; const next = [...prev, defaultSheetForMetric(schema, metricId)]; setTplSheetIdx(next.length - 1); if (!prev.length) setShowMetricPicker(false); @@ -1133,13 +1157,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }); return; } + // Uncheck removes ALL sheets of this source metric setTplSheets((prev) => { - const idx = prev.findIndex((s) => s.metric_id === metricId); - if (idx < 0) return prev; - const next = prev.filter((_, i) => i !== idx); + const next = prev.filter((s) => s.metric_id !== metricId); setTplSheetIdx((cur) => { if (!next.length) return 0; - if (cur > idx) return cur - 1; if (cur >= next.length) return next.length - 1; return cur; }); @@ -1147,6 +1169,28 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }); }; + const duplicateTplSheet = (idx: number) => { + setTplSheets((prev) => { + const src = prev[idx]; + if (!src) return prev; + const baseId = sheetIdentity(src); + let n = 2; + let candidate = `${baseId}.${n}`; + const used = new Set(prev.map((s) => sheetIdentity(s))); + while (used.has(candidate)) { + n += 1; + candidate = `${baseId}.${n}`; + } + const copy = cloneSheet(src); + copy.sheet_id = candidate; + copy.title = `${sheetLabel(src)} (${n})`; + const next = [...prev]; + next.splice(idx + 1, 0, copy); + setTplSheetIdx(idx + 1); + return next; + }); + }; + const removeTplMetric = (idx: number) => { setTplSheets((prev) => { const next = prev.filter((_, i) => i !== idx); @@ -1167,16 +1211,27 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage } for (const s of tplSheets) { if (!s.key_fields.length) { - showError(`${metricLabel(s.metric_id)}: ${t("bizCompare.keyRequired")}`); + showError(`${sheetLabel(s)}: ${t("bizCompare.keyRequired")}`); return; } + if (!sheetIdentity(s)) { + showError(`${s.metric_id}: ${t("bizCompare.sheetIdRequired")}`); + return; + } + } + const ids = tplSheets.map((s) => sheetIdentity(s)); + if (new Set(ids).size !== ids.length) { + showError(t("bizCompare.duplicateSheetId")); + return; } setBusy(true); try { const body = { - name: tplName || tplSheets.map((s) => s.metric_id).join("+"), + name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"), note: tplNote, metrics: tplSheets.map((s) => ({ + sheet_id: sheetIdentity(s), + title: s.title || sheetIdentity(s), metric_id: s.metric_id, key_fields: s.key_fields, iface_fields: s.iface_fields, @@ -1239,6 +1294,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage name: body.name, note: body.note, metrics: body.metrics.map((s) => ({ + sheet_id: sheetIdentity(s), + title: s.title || sheetIdentity(s), metric_id: s.metric_id, key_fields: s.key_fields, iface_fields: s.iface_fields, @@ -1701,9 +1758,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
- {s.metric_id}
+ {sheets.map((s, i) => (
+
+ {sheetLabel(s)}
{!s.compare_fields?.length ? (
· {t("bizCompare.presenceShort")}
) : null}
@@ -1885,7 +1942,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const filterN = toOrGroups(s.row_filters || []).length;
return (