mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
数据目录 F1-A2:在目录页去掉外键约束、命名推断、数据剖析、模型起草得出的关联关系改为驳回、保留编号,只有人工添加的才真正删除;改了两端的旧关系同样驳回。以前删掉的推断关系下次起草又加回来,关系图按表结构现推也会带回来,照样驱动 SQL 检查和助手挑表。表结构推得出的人工关系(人工改过基数的外键关系)去掉时也只驳回。编辑界面上这类关系不给「删除」只给「驳回」(保存时等同单项驳回,可撤销),检查脚本跟上
This commit is contained in:
+42
-10
@@ -47,7 +47,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.data.names import name_key
|
||||
from app.db.models import Artifact, CatalogNote, Run
|
||||
from app.db.models import Artifact, CatalogNote, DataSource, Run
|
||||
|
||||
# ==========================================================================
|
||||
# 取值
|
||||
@@ -653,14 +653,19 @@ def _normalize_submitted(submitted: Mapping[str, Any], table: str) -> dict[str,
|
||||
|
||||
|
||||
def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str, Any], *, table: str,
|
||||
at: str | None = None) -> dict[str, Any]:
|
||||
at: str | None = None, inferable: Iterable[str] = ()) -> dict[str, Any]:
|
||||
"""人工提交的整份目录 → 要写入的目录(不改入参)。
|
||||
|
||||
- 值改过的项、新填的项:记为 human / confirmed。提交里写的来源和状态不作数——客户端不能冒充外键约束
|
||||
或数据剖析。
|
||||
- 值没动的项:保持原来的来源和状态;提交里把状态改成确认、驳回或来源的初始状态的,照改(等同单项审阅);
|
||||
改成别的状态的不认(不能把推断改成「有确证」)。
|
||||
- 已有、但提交里没有的项:删掉。
|
||||
- 已有、但提交里没有的表级项、列级项:删掉。
|
||||
- 已有、但提交里没有的关联关系:外键约束、命名推断、数据剖析、模型起草来的,转为驳回、保留编号;只有人工
|
||||
新建的才真正删掉。删掉的推断关系下次起草会再提出来,关系图(助手挑表、SQL 检查)也会按表结构现推回来,
|
||||
照样驱动检查和助手——驳回才留得住。改了两端的关系编号跟着变,旧编号在提交里就「没有了」,同样按这条处理。
|
||||
inferable 是表结构能推出来的关系编号(外键约束、命名推断,structure_relation_ids):人工改过基数的外键关系
|
||||
记成了人工填写,删掉照样会被现推回来,也只能驳回。
|
||||
- 关系编号按两端重算(_normalize_submitted)。
|
||||
|
||||
结构不合规抛 CatalogInvalid。
|
||||
@@ -672,6 +677,7 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str,
|
||||
raise CatalogInvalid(problems)
|
||||
at = at or now_iso()
|
||||
old, new = _slots(existing), _slots(normalized)
|
||||
inferable = set(inferable)
|
||||
out: dict[Slot, dict[str, Any]] = {}
|
||||
for key, item in new.items():
|
||||
prev = old.get(key)
|
||||
@@ -692,9 +698,23 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str,
|
||||
kept.pop("note", None)
|
||||
kept["updated_at"] = at
|
||||
out[key] = kept
|
||||
for key, prev in old.items():
|
||||
if key in new or key[0] != "r":
|
||||
continue
|
||||
if prev.get("source") == "human" and key[1] not in inferable:
|
||||
continue # 人工新建的:真正删掉
|
||||
out[key] = prev if prev.get("status") == "rejected" else {**prev, "status": "rejected", "updated_at": at}
|
||||
return _assemble(out)
|
||||
|
||||
|
||||
def structure_relation_ids(schema_cache: Mapping[str, Any] | None, table: str) -> set[str]:
|
||||
"""表结构能推出来的这张表的关系编号(外键约束、命名推断)。表结构里没有这张表时为空集。"""
|
||||
if not isinstance(((schema_cache or {}).get("tables") or {}).get(table), Mapping):
|
||||
return set()
|
||||
rels = (fk_relations(schema_cache, table) or []) + infer_name_relations(schema_cache, table)
|
||||
return {str(r["id"]) for r in rels}
|
||||
|
||||
|
||||
async def review_entry(session: AsyncSession, source_id: str, table: str, path: str, action: str, *,
|
||||
if_version: int, actor: str | None) -> CatalogEntry:
|
||||
"""单项审阅并写入(乐观锁同 write_entry)。版本不符抛 CatalogConflict,路径不对抛 CatalogPathError。"""
|
||||
@@ -707,13 +727,22 @@ async def review_entry(session: AsyncSession, source_id: str, table: str, path:
|
||||
|
||||
|
||||
async def save_human_edit(session: AsyncSession, source_id: str, table: str, submitted: Mapping[str, Any], *,
|
||||
if_version: int, actor: str | None) -> CatalogEntry:
|
||||
"""人工提交整份目录并写入(规则见 apply_human_edit,乐观锁同 write_entry)。"""
|
||||
if_version: int, actor: str | None,
|
||||
schema_cache: Mapping[str, Any] | None = None) -> CatalogEntry:
|
||||
"""人工提交整份目录并写入(规则见 apply_human_edit,乐观锁同 write_entry)。
|
||||
|
||||
schema_cache 用来判断哪些关系表结构推得出来(去掉时只能驳回);不给就读数据源记录上的,和 SQL 检查建关系图
|
||||
用的是同一份(sqlcheck.load_checkers)。
|
||||
"""
|
||||
entry = await read_entry(session, source_id, table)
|
||||
current = entry.version if entry else 0
|
||||
if if_version != current:
|
||||
raise CatalogConflict(table, current)
|
||||
notes = apply_human_edit(entry.notes if entry else {}, submitted, table=table)
|
||||
if schema_cache is None:
|
||||
row = await session.get(DataSource, source_id)
|
||||
schema_cache = getattr(row, "schema_cache", None)
|
||||
notes = apply_human_edit(entry.notes if entry else {}, submitted, table=table,
|
||||
inferable=structure_relation_ids(schema_cache, table))
|
||||
return await write_entry(session, source_id, table, notes, if_version=current, actor=actor)
|
||||
|
||||
|
||||
@@ -1885,8 +1914,11 @@ def relation_graph(notes_by_table: Mapping[str, Mapping[str, Any]], *,
|
||||
schema_cache: Mapping[str, Any] | None = None) -> dict[str, list[JoinEdge]]:
|
||||
"""邻接表 {表: [从这张表出发的边]},每条关系正反各一条边;被驳回的关系不进图,自己连自己的也不进。
|
||||
|
||||
schema_cache 给了的话,没起草过目录的关系按表结构现推(外键约束 + 命名推断)补进来:助手挑表时不必等
|
||||
每张表都起草过。目录里已有的关系(含驳回的)以目录为准。
|
||||
schema_cache 给了的话,目录里没有的关系按表结构现推(外键约束 + 命名推断)补进来:助手挑表时不必等每张表
|
||||
都起草过,只做过部分注释(目录里一条关系都没记)的表也不会丢掉外键关系——所以不能只给「没有目录的表」现推。
|
||||
目录里已有的关系(含驳回的)以目录为准:编号由两端的表和列算出(relation_id,含本表表名),目录里驳回过的
|
||||
编号就不再现推。这要求「去掉一条推断出来的关系」落成驳回而不是删除(apply_human_edit),删掉就留不下编号,
|
||||
下次又被现推回来。
|
||||
"""
|
||||
rels: dict[str, tuple[str, Mapping[str, Any]]] = {}
|
||||
rejected: set[str] = set()
|
||||
@@ -2052,6 +2084,6 @@ __all__ = [
|
||||
"initial_status", "join_paths", "make_item", "merge_notes", "now_iso", "parse_path", "plan_patch",
|
||||
"profile_relation_holds", "query_snapshot_meta", "read_catalog", "read_entry", "relation_graph", "relation_id",
|
||||
"render_table_index", "render_table_notes", "resolve_draft_model", "resolve_table_name", "review_entry",
|
||||
"review_item", "same_notes", "save_human_edit", "save_patch", "status_counts", "system_notes_source",
|
||||
"table_usage", "unreviewed_status", "validate_notes", "visible_notes", "write_entry",
|
||||
"review_item", "same_notes", "save_human_edit", "save_patch", "status_counts", "structure_relation_ids",
|
||||
"system_notes_source", "table_usage", "unreviewed_status", "validate_notes", "visible_notes", "write_entry",
|
||||
]
|
||||
|
||||
@@ -152,7 +152,7 @@ def test_human_edit_marks_changed_items_as_human_confirmed_and_keeps_the_rest():
|
||||
"grain": {**existing["grain"], "value": "每张门票每次检票一行"}, # 改了值
|
||||
"columns": {"amount": {"measure": existing["columns"]["amount"]["measure"], # 没动
|
||||
"unit": {"value": "元"}}}, # 新填,没写来源和状态
|
||||
"relations": existing["relations"][:1], # 删掉一条关系
|
||||
"relations": existing["relations"][:1], # 去掉一条推断出来的关系
|
||||
"dedup": {"value": "同一票号只计一次", "source": "llm", "status": "verified"}, # 冒充别的来源、状态
|
||||
}
|
||||
out = catalog.apply_human_edit(existing, submitted, table="visits", at=AT)
|
||||
@@ -161,7 +161,9 @@ def test_human_edit_marks_changed_items_as_human_confirmed_and_keeps_the_rest():
|
||||
assert out["columns"]["amount"]["unit"] == {"value": "元", "source": "human", "status": "confirmed",
|
||||
"updated_at": AT}
|
||||
assert out["dedup"]["source"] == "human" and out["dedup"]["status"] == "confirmed"
|
||||
assert len(out["relations"]) == 1 and out["relations"][0] == existing["relations"][0]
|
||||
# 去掉的推断关系转为驳回、保留编号(删掉的话下次起草、关系图现推都会把它带回来)
|
||||
assert out["relations"] == [existing["relations"][0],
|
||||
{**existing["relations"][1], "status": "rejected", "updated_at": AT}]
|
||||
assert catalog.validate_notes(out) == []
|
||||
|
||||
|
||||
@@ -181,3 +183,6 @@ def test_human_edit_relation_id_follows_its_ends():
|
||||
out = catalog.apply_human_edit(existing, {"relations": [moved]}, table="visits", at=AT)
|
||||
assert out["relations"][0]["id"] == catalog.relation_id("visits", ["gate_id"], "parks", ["id"])
|
||||
assert out["relations"][0]["source"] == "human"
|
||||
# 两条原有的推断关系都不在提交里了(一条改了两端):转为驳回
|
||||
assert [(r["id"], r["status"]) for r in out["relations"][1:]] == [(r["id"], "rejected")
|
||||
for r in existing["relations"]]
|
||||
|
||||
@@ -149,3 +149,106 @@ async def test_confirming_a_profiled_relation_by_patch_keeps_the_fanout_check(cl
|
||||
assert ("fanout_sum", "error", "gates", "id") in await _found(sid, sql)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# A2:去掉推断或外键来的关系即驳回
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def _rel(columns, to_table, to_columns, source="name", status=None, table="visits"):
|
||||
return {"id": catalog.relation_id(table, columns, to_table, to_columns), "columns": columns,
|
||||
"to_table": to_table, "to_columns": to_columns, "cardinality": "many_to_one", "coverage": None,
|
||||
"source": source, "status": status or catalog.initial_status(source)}
|
||||
|
||||
|
||||
def test_human_edit_rejects_removed_inferred_relations_and_deletes_human_ones():
|
||||
park, gate = _rel(["park_id"], "parks", ["id"], "fk"), _rel(["gate_id"], "gates", ["id"], "name")
|
||||
mine = _rel(["member_id"], "members", ["id"], "human")
|
||||
out = catalog.apply_human_edit({"relations": [park, gate, mine]}, {"relations": []}, table="visits", at=AT)
|
||||
by_id = {r["id"]: r for r in out["relations"]}
|
||||
assert set(by_id) == {park["id"], gate["id"]} # 人工新建的那条真正删掉
|
||||
for old in (park, gate):
|
||||
assert by_id[old["id"]] == {**old, "status": "rejected", "updated_at": AT} # 编号、来源、两端都留着
|
||||
# 已经驳回的再去掉一次:不变
|
||||
again = catalog.apply_human_edit(out, {"relations": []}, table="visits", at="2026-10-05T00:00:00+00:00")
|
||||
assert again == out
|
||||
|
||||
|
||||
def test_human_edit_moving_an_inferred_relation_rejects_the_old_one():
|
||||
gate = _rel(["gate_id"], "gates", ["id"], "name")
|
||||
moved = {**gate, "to_table": "parks", "to_columns": ["id"]}
|
||||
out = catalog.apply_human_edit({"relations": [gate]}, {"relations": [moved]}, table="visits", at=AT)
|
||||
new_id = catalog.relation_id("visits", ["gate_id"], "parks", ["id"])
|
||||
by_id = {r["id"]: r for r in out["relations"]}
|
||||
assert (by_id[new_id]["source"], by_id[new_id]["status"]) == ("human", "confirmed")
|
||||
assert by_id[gate["id"]]["status"] == "rejected" and by_id[gate["id"]]["source"] == "name"
|
||||
|
||||
|
||||
def test_human_edit_rejects_a_removed_human_relation_that_structure_would_bring_back():
|
||||
"""人工改过基数的外键关系记成了人工填写;去掉它时若表结构还推得出来,删掉就会被现推回来,只能驳回。"""
|
||||
park = {**_rel(["park_id"], "parks", ["id"], "fk"), "cardinality": "one_to_one", "source": "human",
|
||||
"status": "confirmed"}
|
||||
out = catalog.apply_human_edit({"relations": [park]}, {"relations": []}, table="visits", at=AT,
|
||||
inferable={park["id"]})
|
||||
assert [(r["id"], r["status"]) for r in out["relations"]] == [(park["id"], "rejected")]
|
||||
assert catalog.apply_human_edit({"relations": [park]}, {"relations": []}, table="visits", at=AT) == {}
|
||||
|
||||
|
||||
async def test_removed_or_rejected_relations_stay_out_of_checks_graph_and_redraft(client, make_source, scenic_db):
|
||||
sid = await make_source(scenic_db, draft=["visits"])
|
||||
detail = await _notes(client, sid, "visits")
|
||||
park, gate = _relation(detail["notes"], "park_id"), _relation(detail["notes"], "gate_id")
|
||||
ticket = _relation(detail["notes"], "ticket_type_id")
|
||||
assert (park["source"], gate["source"], ticket["source"]) == ("fk", "name", "fk")
|
||||
gone = {park["id"], gate["id"]}
|
||||
|
||||
# 目录页编辑时去掉两条(外键来的、命名推断的):转为驳回
|
||||
notes = {**detail["notes"], "relations": [r for r in detail["notes"]["relations"] if r["id"] not in gone]}
|
||||
r = await _put(client, sid, "visits", notes, detail["version"])
|
||||
assert r.status_code == 200, r.text
|
||||
saved = {x["id"]: x for x in r.json()["notes"]["relations"]}
|
||||
assert {saved[i]["status"] for i in gone} == {"rejected"}
|
||||
# 单项审阅里驳回第三条(界面上非人工来源的关系只给「驳回」)
|
||||
r = await client.post(f"/api/datasources/{sid}/catalog/visits/review",
|
||||
json={"path": f"relations.{ticket['id']}", "action": "reject",
|
||||
"if_version": r.json()["version"]})
|
||||
assert r.status_code == 200, r.text
|
||||
gone.add(ticket["id"])
|
||||
|
||||
def absent(edges) -> bool:
|
||||
return not any(e.relation_id in gone for e in edges)
|
||||
|
||||
# 检查器:被驳回的关系不当作「对上了」,也不拿来判断基数
|
||||
checker = await _checker(sid)
|
||||
for target in ("parks", "gates", "ticket_types"):
|
||||
assert checker.edges_between("visits", target) == []
|
||||
joined = await _found(sid, "SELECT COUNT(*) FROM visits v JOIN gates g ON v.gate_id = g.id")
|
||||
assert ("join_unconfirmed", "warning", "gates", None) in joined
|
||||
# 关系图(助手挑表也用它):不按表结构现推回来
|
||||
graph = await _graph(sid)
|
||||
assert absent(graph.get("visits", [])) and absent(graph.get("parks", [])) and absent(graph.get("gates", []))
|
||||
# 重新起草:仍是驳回
|
||||
r = await client.post(f"/api/datasources/{sid}/catalog/draft", json={"tables": ["visits"]})
|
||||
assert r.status_code == 200, r.text
|
||||
again = {x["id"]: x for x in (await _notes(client, sid, "visits"))["notes"]["relations"]}
|
||||
assert {again[i]["status"] for i in gone} == {"rejected"}
|
||||
assert absent((await _graph(sid)).get("visits", []))
|
||||
|
||||
|
||||
def test_relation_graph_keeps_structure_relations_of_partially_annotated_tables():
|
||||
"""只做过部分注释的表(目录里没有关系)照样按结构补上外键和命名推断;这张表目录里驳回的不补。"""
|
||||
cache = {"tables": {
|
||||
"visits": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "park_id", "type": "INTEGER"},
|
||||
{"name": "gate_id", "type": "INTEGER"}],
|
||||
"primary_key": ["id"], "foreign_keys": [{"columns": ["park_id"], "to_table": "parks",
|
||||
"to_columns": ["id"]}]},
|
||||
"parks": {"columns": [{"name": "id", "type": "INTEGER"}], "primary_key": ["id"], "foreign_keys": []},
|
||||
"gates": {"columns": [{"name": "id", "type": "INTEGER"}], "primary_key": ["id"], "foreign_keys": []},
|
||||
}}
|
||||
labelled = {"visits": {"label": catalog.make_item("入园记录", "human")}}
|
||||
edges = {(e.to_table, e.source) for e in catalog.relation_graph(labelled, schema_cache=cache)["visits"]}
|
||||
assert edges == {("parks", "fk"), ("gates", "name")}
|
||||
rejected = {"visits": {"relations": [_rel(["park_id"], "parks", ["id"], "fk", "rejected")]}}
|
||||
edges = {e.to_table for e in catalog.relation_graph(rejected, schema_cache=cache)["visits"]}
|
||||
assert edges == {"gates"}
|
||||
|
||||
|
||||
|
||||
@@ -2141,6 +2141,11 @@ export const CATALOG_TEXT = {
|
||||
cardinalityNone: '未知',
|
||||
addRelation: '添加关联关系',
|
||||
removeRelation: '删除这条关联关系',
|
||||
rejectRelation: '驳回',
|
||||
rejectRelationLabel: '驳回这条关联关系',
|
||||
undoRejectRelation: '撤销驳回',
|
||||
rejectingMark: '保存后驳回',
|
||||
relationRemoveHint: '人工添加的关联关系可以删除;外键约束、命名推断、数据剖析得出的只能驳回,驳回后不提供给助手、不参与 SQL 检查,之后起草也不会再提出',
|
||||
relationColumns: '本表字段,用逗号分隔',
|
||||
relationTarget: '目标表',
|
||||
relationToColumns: '目标字段,用逗号分隔',
|
||||
|
||||
@@ -23,7 +23,7 @@ import { CodesDialog } from './CodesDialog'
|
||||
import { coverageText } from './profile'
|
||||
import {
|
||||
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
|
||||
relationDraft, relationPath, tablePath, unknownKeys, validateForm,
|
||||
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
|
||||
} from './model'
|
||||
import type { ColumnField, EditForm, RelationDraft, TableField, TableKey } from './model'
|
||||
|
||||
@@ -681,42 +681,70 @@ function RelationEditor({ drafts, initial, problems, rows, onChange }: {
|
||||
const patch = (key: string, p: Partial<RelationDraft>) => onChange((rs) => rs.map((r) => (r.key === key ? { ...r, ...p } : r)))
|
||||
return (
|
||||
<div className="space-y-2" data-relation-editor="">
|
||||
<p className="text-2xs leading-relaxed text-faint" data-relation-remove-hint="">{CT.relationRemoveHint}</p>
|
||||
{drafts.map((r) => {
|
||||
const b = before.get(r.key)
|
||||
const changed = !b || b.columns !== r.columns || b.to_table !== r.to_table || b.to_columns !== r.to_columns || b.cardinality !== r.cardinality
|
||||
const problem = problems[`r:${r.key}`]
|
||||
// 人工添加的才能删除;外键约束、命名推断、数据剖析得出的只能驳回(删掉会被起草和现推带回来)
|
||||
const removal = relationRemoval(r.orig)
|
||||
const rejecting = !!r.reject
|
||||
const locked = rejecting ? 'text-faint line-through' : undefined
|
||||
return (
|
||||
<div key={r.key} className="rounded-lg border bg-panel p-2.5" data-relation-draft={r.key}>
|
||||
<div key={r.key} className="rounded-lg border bg-panel p-2.5" data-relation-draft={r.key} data-rejecting={rejecting ? 'true' : undefined}>
|
||||
<div className="grid items-end gap-2 sm:grid-cols-[1fr_1fr_1fr_8rem_auto]">
|
||||
<label className="min-w-0">
|
||||
<span className="label">{CT.relationHead.from}</span>
|
||||
<input className="field mono" value={r.columns} placeholder={CT.relationColumns}
|
||||
<input className={clsx('field mono', locked)} value={r.columns} placeholder={CT.relationColumns} disabled={rejecting}
|
||||
onChange={(e) => patch(r.key, { columns: e.target.value })} data-input="columns" />
|
||||
</label>
|
||||
<label className="min-w-0">
|
||||
<span className="label">{CT.relationTarget}</span>
|
||||
<input className="field mono" list="catalog-tables" value={r.to_table}
|
||||
<input className={clsx('field mono', locked)} list="catalog-tables" value={r.to_table} disabled={rejecting}
|
||||
onChange={(e) => patch(r.key, { to_table: e.target.value })} data-input="to_table" />
|
||||
</label>
|
||||
<label className="min-w-0">
|
||||
<span className="label">{CT.relationHead.toColumns}</span>
|
||||
<input className="field mono" value={r.to_columns} placeholder={CT.relationToColumns}
|
||||
<input className={clsx('field mono', locked)} value={r.to_columns} placeholder={CT.relationToColumns} disabled={rejecting}
|
||||
onChange={(e) => patch(r.key, { to_columns: e.target.value })} data-input="to_columns" />
|
||||
</label>
|
||||
<label className="min-w-0">
|
||||
<span className="label">{CT.relationHead.cardinality}</span>
|
||||
<select className="field" value={r.cardinality} onChange={(e) => patch(r.key, { cardinality: e.target.value })} data-input="cardinality">
|
||||
<select className={clsx('field', locked)} value={r.cardinality} disabled={rejecting}
|
||||
onChange={(e) => patch(r.key, { cardinality: e.target.value })} data-input="cardinality">
|
||||
<option value="">{CT.cardinalityNone}</option>
|
||||
{CARDINALITIES.map((c) => <option key={c} value={c}>{CATALOG_CARDINALITY_LABEL[c]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5 pb-1">
|
||||
{r.orig && !changed && <StatusChip status={r.orig.status} source={r.orig.source} />}
|
||||
{changed && <span className="chip !text-2xs" style={{ color: 'var(--accent)' }}>{CT.changedMark}</span>}
|
||||
<DeleteButton label={CT.removeRelation} onClick={() => onChange((rs) => rs.filter((x) => x.key !== r.key))} />
|
||||
{rejecting
|
||||
? (
|
||||
<>
|
||||
<span className="chip !text-2xs" style={{ color: 'var(--accent)' }} data-rejecting-mark="">{CT.rejectingMark}</span>
|
||||
<button type="button" className="btn btn-sm btn-ghost !px-1.5" onClick={() => patch(r.key, { reject: false })} data-relation-undo-reject="">
|
||||
{CT.undoRejectRelation}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{r.orig && !changed && <StatusChip status={r.orig.status} source={r.orig.source} />}
|
||||
{changed && <span className="chip !text-2xs" style={{ color: 'var(--accent)' }}>{CT.changedMark}</span>}
|
||||
{removal === 'delete' && (
|
||||
<DeleteButton label={CT.removeRelation} onClick={() => onChange((rs) => rs.filter((x) => x.key !== r.key))} />
|
||||
)}
|
||||
{removal === 'reject' && (
|
||||
// 驳回时两端和基数回到原样:驳回的是原来那条关系,不是改过的
|
||||
<button type="button" className="btn btn-sm btn-ghost !px-1.5" aria-label={CT.rejectRelationLabel} title={CT.rejectRelationLabel}
|
||||
onClick={() => patch(r.key, { ...(b ?? {}), reject: true })} data-relation-reject="">
|
||||
{CT.rejectRelation}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{problem && <div className="mt-1.5 text-2xs text-[var(--err)]" role="alert">{problem}</div>}
|
||||
{problem && !rejecting && <div className="mt-1.5 text-2xs text-[var(--err)]" role="alert">{problem}</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -173,7 +173,7 @@ export function confirmProposed(notes: CatalogNotes, pick: (path: string) => boo
|
||||
// ---------------------------------------------------------------------------
|
||||
// 编辑表单:每一项一段文字。进入编辑时按目录生成(被驳回的项留空,原值放在占位里),提交时和进入时的文字逐项
|
||||
// 比较——没动的原样交回(保留来源和状态,被驳回的照旧驳回),改了的交新值(服务端记为人工填写、已确认),
|
||||
// 清空的不交(服务端删掉这一项)
|
||||
// 清空的不交(服务端删掉这一项)。关联关系另有规矩:只有人工添加的能删除,其余的只能驳回(relationRemoval)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 表级的表单键:业务日期拆成三格 */
|
||||
@@ -189,6 +189,18 @@ export interface RelationDraft {
|
||||
to_table: string
|
||||
to_columns: string
|
||||
cardinality: string
|
||||
/** 编辑中点了「驳回」:保存时原样交回、状态改为已驳回(等同单项驳回) */
|
||||
reject?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉一条关联关系是删除还是驳回:人工添加的(以及还没保存的)可以删除;外键约束、命名推断、数据剖析、模型起草得出的
|
||||
* 只能驳回——删掉的话下次起草、助手和 SQL 检查按表结构现推都会把它带回来(服务端 apply_human_edit 也按这条处理:
|
||||
* 提交里没有的非人工关系转为驳回)。已经驳回的返回 null,没有可做的
|
||||
*/
|
||||
export function relationRemoval(orig: CatalogRelation | null): 'delete' | 'reject' | null {
|
||||
if (!orig || orig.source === 'human') return 'delete'
|
||||
return orig.status === 'rejected' ? null : 'reject'
|
||||
}
|
||||
|
||||
export interface EditForm {
|
||||
@@ -283,7 +295,7 @@ export function changeCount(initial: EditForm, form: EditForm): number {
|
||||
const after = new Set(form.relations.map((r) => r.key))
|
||||
for (const r of form.relations) {
|
||||
const b = before.get(r.key)
|
||||
if (!b || !sameRelation(b, r)) n++
|
||||
if (!b || !sameRelation(b, r) || !!b.reject !== !!r.reject) n++
|
||||
}
|
||||
for (const k of before.keys()) if (!after.has(k)) n++
|
||||
return n
|
||||
@@ -398,6 +410,7 @@ export function buildNotes(orig: CatalogNotes, initial: EditForm, form: EditForm
|
||||
const relations: CatalogRelation[] = []
|
||||
for (const r of form.relations) {
|
||||
const b = before.get(r.key)
|
||||
if (r.orig && r.reject) { relations.push({ ...r.orig, status: 'rejected' }); continue }
|
||||
if (r.orig && b && sameRelation(b, r)) { relations.push(r.orig); continue }
|
||||
relations.push({
|
||||
...(r.orig ?? { coverage: null }),
|
||||
|
||||
@@ -306,13 +306,17 @@ function countsOf(notes) {
|
||||
const valueOf = (path, it) => JSON.stringify(path.startsWith('relations.')
|
||||
? [it.columns, it.to_table, it.to_columns, it.cardinality ?? null, it.coverage ?? null] : it.value)
|
||||
|
||||
/** 整份提交(apply_human_edit):值改过的、新填的记为人工确认;值没动的按提交的状态(只认确认、驳回、初始状态) */
|
||||
/**
|
||||
* 整份提交(apply_human_edit):值改过的、新填的记为人工确认;值没动的按提交的状态(只认确认、驳回、初始状态);
|
||||
* 提交里没有的关联关系,人工添加的删掉,其余的转为驳回(删掉会被起草和现推带回来)
|
||||
*/
|
||||
function applyPut(old, submitted) {
|
||||
const prev = slots(old)
|
||||
const sub = clone(submitted)
|
||||
for (const r of sub.relations ?? []) { r.id = relId(r.columns, r.to_table, r.to_columns); r.source ??= 'human'; r.status ??= 'confirmed' }
|
||||
const out = new Map()
|
||||
for (const [path, it] of slots(sub)) {
|
||||
const given = slots(sub)
|
||||
for (const [path, it] of given) {
|
||||
const p = prev.get(path)
|
||||
if (!p || valueOf(path, it) !== valueOf(path, p)) {
|
||||
out.set(path, { ...it, source: 'human', status: 'confirmed', updated_at: new Date().toISOString() })
|
||||
@@ -321,6 +325,9 @@ function applyPut(old, submitted) {
|
||||
out.set(path, { ...p, status: ok ? it.status : p.status })
|
||||
}
|
||||
}
|
||||
for (const [path, p] of prev) {
|
||||
if (path.startsWith('relations.') && !given.has(path) && p.source !== 'human') out.set(path, { ...p, status: 'rejected' })
|
||||
}
|
||||
return assemble(out)
|
||||
}
|
||||
function applyReview(notes, path, action) {
|
||||
@@ -958,8 +965,26 @@ await section('数据目录 · 编辑即确认', async () => {
|
||||
check('……两端字段数不一致:写明', (await draft.innerText()).includes('两端的字段数不一致'))
|
||||
await draft.locator('[data-input="to_columns"]').fill('id')
|
||||
await draft.locator('[data-input="cardinality"]').selectOption('many_to_one')
|
||||
// 关联关系:人工添加的才能删除;外键约束、命名推断得出的只能驳回(删掉会被起草和现推带回来)
|
||||
const PARK = relId(['park_id'], 'parks', ['id'])
|
||||
const GATE = relId(['gate_id'], 'gates', ['id'])
|
||||
const parkDraft = page.locator(`[data-relation-draft="${PARK}"]`)
|
||||
check('关联关系:外键约束、命名推断得出的不给「删除」只给「驳回」,新加的给「删除」,写明为什么',
|
||||
await parkDraft.locator('[data-relation-reject]').count() === 1 && await parkDraft.locator('[aria-label="删除这条关联关系"]').count() === 0
|
||||
&& await page.locator(`[data-relation-draft="${GATE}"] [data-relation-reject]`).count() === 1
|
||||
&& await draft.locator('[aria-label="删除这条关联关系"]').count() === 1 && await draft.locator('[data-relation-reject]').count() === 0
|
||||
&& (await page.locator('[data-relation-remove-hint]').innerText()).includes('只能驳回'))
|
||||
await parkDraft.locator('[data-input="cardinality"]').selectOption('one_to_one')
|
||||
await parkDraft.locator('[data-relation-reject]').click()
|
||||
check('……点「驳回」:标「保存后驳回」,两端和基数回到原样、锁住不能改,可以撤销', (await parkDraft.getAttribute('data-rejecting')) === 'true'
|
||||
&& (await parkDraft.innerText()).includes('保存后驳回') && (await parkDraft.locator('[data-input="cardinality"]').inputValue()) === 'many_to_one'
|
||||
&& await parkDraft.locator('[data-input="columns"]').isDisabled() && await parkDraft.locator('[data-relation-undo-reject]').count() === 1)
|
||||
await parkDraft.locator('[data-relation-undo-reject]').click()
|
||||
check('……撤销驳回:回到原样,不算修改', (await parkDraft.getAttribute('data-rejecting')) === null
|
||||
&& (await page.locator('[data-catalog-changes]').innerText()) === '6 处修改', await page.locator('[data-catalog-changes]').innerText())
|
||||
await page.locator(`[data-relation-draft="${GATE}"] [data-relation-reject]`).click()
|
||||
await shot(page, 'catalog-edit')
|
||||
check('保存栏写「6 处修改」', (await page.locator('[data-catalog-changes]').innerText()) === '6 处修改', await page.locator('[data-catalog-changes]').innerText())
|
||||
check('保存栏写「7 处修改」(驳回一条关系也算一处)', (await page.locator('[data-catalog-changes]').innerText()) === '7 处修改', await page.locator('[data-catalog-changes]').innerText())
|
||||
await page.locator('[data-catalog-save]').click()
|
||||
await until(async () => count(sent, /^PUT /) === 1)
|
||||
const body = lastBody(sent, /^PUT /)
|
||||
@@ -972,10 +997,14 @@ await section('数据目录 · 编辑即确认', async () => {
|
||||
check('……清空的粒度不交(服务端删掉这一项)', !('grain' in n))
|
||||
check('……没动的项原样交回:来源、状态都不变(被驳回的照旧驳回)', JSON.stringify(n.dedup) === JSON.stringify(old.dedup)
|
||||
&& JSON.stringify(n.description) === JSON.stringify(old.description) && JSON.stringify(n.columns?.park_id) === JSON.stringify(old.columns.park_id))
|
||||
check('……原有的关系原样交回,新的一条带两端和基数', old.relations.every((r) => n.relations?.some((x) => JSON.stringify(x) === JSON.stringify(r)))
|
||||
const gateOld = old.relations.find((r) => r.id === GATE)
|
||||
check('……原有的关系原样交回,新的一条带两端和基数', old.relations.filter((r) => r.id !== GATE).every((r) => n.relations?.some((x) => JSON.stringify(x) === JSON.stringify(r)))
|
||||
&& n.relations?.some((r) => r.to_table === 'members' && r.columns.join() === 'member_id' && r.to_columns.join() === 'id' && r.cardinality === 'many_to_one'))
|
||||
check('……驳回的那条原样交回、状态改为已驳回(等同单项驳回,不是删掉)',
|
||||
JSON.stringify(n.relations?.find((r) => r.id === GATE)) === JSON.stringify({ ...gateOld, status: 'rejected' }))
|
||||
check('保存后退出编辑,改过的项显示「已确认 · 人工填写」', await until(async () => (await detail(page).getAttribute('data-editing')) === 'false')
|
||||
&& await markStatus(page, 'label') === 'confirmed' && (await page.locator('[data-field="label"]').innerText()).includes('人工填写'))
|
||||
check('……驳回的关系在列表里划掉、标已驳回', (await page.locator(`[data-relation="${GATE}"]`).getAttribute('data-status')) === 'rejected')
|
||||
check('……清单上的中文名跟着变', await until(async () => (await page.locator('[data-catalog-row="visits"] [data-row-label]').innerText()) === '入园流水'))
|
||||
check('……提示已保存', await until(async () => (await page.locator('body').innerText()).includes('已保存,改动过的项已记为人工确认')))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user