mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
数据目录 F1-A4:码值项加「已列全」标记(complete: true),只有人工明确勾选、或剖析整表统计(没有只看前若干行)得到的码值才有;码值不在码值表中的检查只认标了它的码值表,给模型的话改为提示核对取值,不再叫它照目录里的码值改。以前经对话只补了 status=9 表示作废,之后 status = 1 就被报提醒,模型还可能把对的 SQL 改错。提案补充码值时沿用原有的标记、自己不能标;目录页的码值编辑和填写含义弹窗加「已列出全部取值」勾选,表格里标「已列全」,检查脚本跟上
This commit is contained in:
+41
-10
@@ -15,7 +15,8 @@ notes 结构(每个「项」都是 {value, source, status},可选 note、upd
|
||||
"valid_filter": 项(str,SQL 条件片段), "dedup": 项(str),
|
||||
"columns": {列名: {"label": 项, "meaning": 项, "unit": 项,
|
||||
"measure": 项("flow"|"stock"|"ratio"|"identifier"|"status"|"attribute"),
|
||||
"codes": 项(dict[码值, 含义];含义可为空串,表示待填写)}},
|
||||
"codes": 项(dict[码值, 含义];含义可为空串,表示待填写;项上可带 complete: true,
|
||||
表示已列出全部取值)}},
|
||||
"relations": [{"id", "columns", "to_table", "to_columns", "cardinality", "coverage",
|
||||
"source", "status", "note"?}]}
|
||||
|
||||
@@ -81,6 +82,9 @@ COLUMN_FIELD_LABEL = {"label": "中文名", "meaning": "含义", "unit": "单位
|
||||
_INITIAL_STATUS = {"fk": "verified", "human": "confirmed"}
|
||||
|
||||
_ITEM_KEYS = frozenset({"value", "source", "status", "note", "updated_at"})
|
||||
#: 码值项另有一个「已列全」标记(complete: true):码值表列出了这一列的全部取值。只有人工明确确认、或者数据剖析
|
||||
#: 没截断地看完了全表取值时才有;没有它的码值表只是「已知的一部分」,SQL 检查不能拿它判断某个值「不在码值表里」
|
||||
_CODES_ITEM_KEYS = _ITEM_KEYS | {"complete"}
|
||||
_RELATION_KEYS = frozenset({"id", "columns", "to_table", "to_columns", "cardinality", "coverage", "source",
|
||||
"status", "note", "updated_at"})
|
||||
_BUSINESS_DATE_KEYS = frozenset({"column", "rule", "timezone"})
|
||||
@@ -127,16 +131,24 @@ def unreviewed_status(item: Mapping[str, Any]) -> str:
|
||||
|
||||
|
||||
def make_item(value: Any, source: str, status: str | None = None, *, note: str | None = None,
|
||||
at: str | None = None) -> dict[str, Any]:
|
||||
"""构造一个项。status 不给时按来源取初始状态(initial_status)。"""
|
||||
at: str | None = None, complete: bool = False) -> dict[str, Any]:
|
||||
"""构造一个项。status 不给时按来源取初始状态(initial_status)。complete 只用于码值项(已列出全部取值),
|
||||
为假时不写这个键:没有标记和标记为否是一回事,写法统一,冻结进快照的哈希才稳定。"""
|
||||
out: dict[str, Any] = {"value": value, "source": source, "status": status or initial_status(source)}
|
||||
if note:
|
||||
out["note"] = note
|
||||
if at:
|
||||
out["updated_at"] = at
|
||||
if complete:
|
||||
out["complete"] = True
|
||||
return out
|
||||
|
||||
|
||||
def codes_complete(item: Any) -> bool:
|
||||
"""码值项是不是标了「已列全」。"""
|
||||
return isinstance(item, Mapping) and item.get("complete") is True
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 异常
|
||||
# ==========================================================================
|
||||
@@ -220,12 +232,15 @@ _COLUMN_VALUE_RULES: dict[str, tuple[Any, str]] = {
|
||||
}
|
||||
|
||||
|
||||
def _item_problems(where: str, item: Any, rule: tuple[Any, str]) -> list[str]:
|
||||
def _item_problems(where: str, item: Any, rule: tuple[Any, str], *,
|
||||
keys: frozenset[str] = _ITEM_KEYS) -> list[str]:
|
||||
if not isinstance(item, dict) or not {"value", "source", "status"} <= set(item):
|
||||
return [f"{where}不是有效的目录项:需要值、来源和状态"]
|
||||
problems: list[str] = []
|
||||
if extra := sorted(set(item) - _ITEM_KEYS):
|
||||
if extra := sorted(set(item) - keys):
|
||||
problems.append(f"{where}含有不认识的字段「{'、'.join(extra)}」")
|
||||
if "complete" in item and not isinstance(item["complete"], bool):
|
||||
problems.append(f"{where}是否已列出全部取值应为是或否")
|
||||
if item["source"] not in ITEM_SOURCES:
|
||||
problems.append(f"{where}的来源「{item['source']}」不在可选值内")
|
||||
if item["status"] not in ITEM_STATUSES:
|
||||
@@ -309,7 +324,8 @@ def validate_notes(notes: Any) -> list[str]:
|
||||
for field_name in COLUMN_FIELDS:
|
||||
if field_name in items:
|
||||
problems += _item_problems(f"列 {col} 的{COLUMN_FIELD_LABEL[field_name]}", items[field_name],
|
||||
_COLUMN_VALUE_RULES[field_name])
|
||||
_COLUMN_VALUE_RULES[field_name],
|
||||
keys=_CODES_ITEM_KEYS if field_name == "codes" else _ITEM_KEYS)
|
||||
relations = notes.get("relations", [])
|
||||
if not isinstance(relations, list):
|
||||
problems.append("关联关系应为列表")
|
||||
@@ -481,6 +497,14 @@ def _value_of(key: Slot, item: Mapping[str, Any]) -> Any:
|
||||
return item.get("value")
|
||||
|
||||
|
||||
def _edit_value(key: Slot, item: Mapping[str, Any]) -> Any:
|
||||
"""人工编辑时比较「改没改」看的部分:值,码值另加「已列全」标记——只勾上这个标记也是人工改动(记为人工填写、
|
||||
已确认),检查据此认定码值表是完整的。"""
|
||||
if key[0] == "c" and key[2] == "codes":
|
||||
return item.get("value"), codes_complete(item)
|
||||
return _value_of(key, item)
|
||||
|
||||
|
||||
def _core(item: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""去掉修改时间的项:判断起草结果和已有的是不是一回事。"""
|
||||
return {k: v for k, v in item.items() if k != "updated_at"}
|
||||
@@ -630,6 +654,8 @@ def _normalize_submitted(submitted: Mapping[str, Any], table: str) -> dict[str,
|
||||
if isinstance(item, dict) and "value" in item:
|
||||
item.setdefault("source", "human")
|
||||
item.setdefault("status", "confirmed")
|
||||
if item.get("complete") is False:
|
||||
del item["complete"] # 「没列全」就是没有这个标记,写法统一(make_item)
|
||||
|
||||
for name in TABLE_FIELDS:
|
||||
fill(out.get(name))
|
||||
@@ -657,7 +683,7 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str,
|
||||
"""人工提交的整份目录 → 要写入的目录(不改入参)。
|
||||
|
||||
- 值改过的项、新填的项:记为 human / confirmed。提交里写的来源和状态不作数——客户端不能冒充外键约束
|
||||
或数据剖析。
|
||||
或数据剖析。码值的「已列全」标记跟着值走:只勾上或取消它也算改过(_edit_value)。
|
||||
- 值没动的项:保持原来的来源和状态;提交里把状态改成确认、驳回或来源的初始状态的,照改(等同单项审阅);
|
||||
改成别的状态的不认(不能把推断改成「有确证」)。
|
||||
- 已有、但提交里没有的表级项、列级项:删掉。
|
||||
@@ -681,7 +707,7 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str,
|
||||
out: dict[Slot, dict[str, Any]] = {}
|
||||
for key, item in new.items():
|
||||
prev = old.get(key)
|
||||
if prev is None or _value_of(key, item) != _value_of(key, prev):
|
||||
if prev is None or _edit_value(key, item) != _edit_value(key, prev):
|
||||
fresh = {k: v for k, v in item.items() if k != "updated_at"}
|
||||
fresh.update(source="human", status="confirmed", updated_at=at)
|
||||
out[key] = fresh
|
||||
@@ -1015,7 +1041,11 @@ def apply_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
|
||||
rel["note"] = prev["note"] # 覆盖率沿用了,说明它怎么测的那段备注也留着
|
||||
slots[key] = rel
|
||||
else:
|
||||
slots[key] = {"value": change.after, "source": "human", "status": "confirmed", "updated_at": at}
|
||||
# 码值是补充、不删已有的码:原来标了「已列全」的,补充之后仍然列全。提案自己不能标「已列全」——
|
||||
# 用户在对话里说一个码值的意思,不等于确认这一列只有这些取值
|
||||
done = key[0] == "c" and key[2] == "codes" and prev is not None and prev.get("status") != "rejected" \
|
||||
and codes_complete(prev)
|
||||
slots[key] = make_item(change.after, "human", "confirmed", at=at, complete=done)
|
||||
out = _assemble(slots)
|
||||
if problems := validate_notes(out):
|
||||
raise CatalogInvalid(problems)
|
||||
@@ -2079,7 +2109,8 @@ __all__ = [
|
||||
"DraftReport", "INFERRED_MARK", "ITEM_SOURCES", "ITEM_STATUSES", "JoinEdge", "KIND_LABEL", "MEASURE_LABEL",
|
||||
"MergeStats", "NEW_RELATION_PATH", "PATCH_MAX_CHANGES", "PROFILE_VERIFY_COVERAGE", "PatchChange", "PatchPlan",
|
||||
"QUERY_SNAPSHOT_KIND", "REVIEW_ACTIONS", "TABLE_FIELDS", "TABLE_FIELD_LABEL", "TABLE_KINDS", "TableDraft",
|
||||
"TableDraftResult", "UI_KIND_LABEL", "UI_MEASURE_LABEL", "apply_human_edit", "apply_patch", "describe_slot",
|
||||
"TableDraftResult", "UI_KIND_LABEL", "UI_MEASURE_LABEL", "apply_human_edit", "apply_patch", "codes_complete",
|
||||
"describe_slot",
|
||||
"draft_catalog", "draft_structure", "draft_with_model", "fk_relations", "frozen_catalog", "infer_name_relations",
|
||||
"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",
|
||||
|
||||
@@ -528,12 +528,14 @@ class _RelationOutcome:
|
||||
|
||||
@dataclass
|
||||
class _CodesOutcome:
|
||||
"""一列的取值分布:[(码值, 行数)],按行数从多到少。"""
|
||||
"""一列的取值分布:[(码值, 行数)],按行数从多到少。complete:看的是全表(没有只看前若干行),取到的就是
|
||||
这一列的全部取值,码值项标「已列全」,SQL 检查才拿它判断某个值「不在码值表里」。"""
|
||||
|
||||
column: str
|
||||
values: list[tuple[str, int]]
|
||||
rows: int
|
||||
note: str
|
||||
complete: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -874,7 +876,7 @@ async def _check_codes(cx: _Context, table: str, column: str, out: _TableOutcome
|
||||
scope = f"按前 {scan_cap} 行统计" if scan_cap else "统计全表"
|
||||
spread = "、".join(f"{k}({_share(n, rows)})" for k, n in values)
|
||||
note = f"{NOTE_PREFIX}{cx.day}):{scope},{rows} 行非空值共 {len(values)} 个取值:{spread}。"
|
||||
out.codes.append(_CodesOutcome(column=column, values=values, rows=rows, note=note))
|
||||
out.codes.append(_CodesOutcome(column=column, values=values, rows=rows, note=note, complete=scan_cap is None))
|
||||
|
||||
|
||||
def _is_date_column(col: dict[str, Any]) -> bool:
|
||||
@@ -980,7 +982,8 @@ def _apply(existing: dict[str, Any] | None, out: _TableOutcome, *,
|
||||
- 没被确认、驳回的关系:记为 source=profile,覆盖率够、父键唯一的 verified,否则 proposed;按 merge_notes
|
||||
的规则并入(剖析顶得掉命名推断、模型起草,顶不掉外键和人工)。
|
||||
- 人工确认过的关系:只补覆盖率和基数(_patch_confirmed)。
|
||||
- 码值候选:profile / proposed,含义沿用别的来源写过的(_codes_value);人工确认、驳回过的码值不碰。
|
||||
- 码值候选:profile / proposed,含义沿用别的来源写过的(_codes_value);整表统计得到的标「已列全」,只看了
|
||||
前若干行的不标;人工确认、驳回过的码值不碰。
|
||||
- 业务日期:只有一个日期类列时提议,profile / proposed;已有别的来源定的业务日期不顶掉。
|
||||
- covered={"profile"}:这次完整检查过、却没再得出的剖析结论删掉(比如取值变多、不再像码值的列,列被遮罩
|
||||
之后它的码值);没查成的检查(超时、预算用完……)把剖析以前写的结论原样带上,不因为这次没查成就删。
|
||||
@@ -1015,7 +1018,8 @@ def _apply(existing: dict[str, Any] | None, out: _TableOutcome, *,
|
||||
item = (described.get(c.column) or {}).get("codes") if isinstance(described.get(c.column), dict) else None
|
||||
if isinstance(item, dict) and item.get("status") in ("confirmed", "rejected"):
|
||||
continue
|
||||
columns[c.column] = {"codes": catalog.make_item(_codes_value(c, item), "profile", "proposed", note=c.note)}
|
||||
columns[c.column] = {"codes": catalog.make_item(_codes_value(c, item), "profile", "proposed", note=c.note,
|
||||
complete=c.complete)}
|
||||
findings.append(_codes_finding(c, columns[c.column]["codes"]["value"]))
|
||||
for column in out.keep_codes:
|
||||
item = (described.get(column) or {}).get("codes") if isinstance(described.get(column), dict) else None
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- join_unconfirmed:关联条件对不上目录里的任何关系;
|
||||
- ratio_aggregated:对比率列直接求和或求平均;
|
||||
- missing_valid_filter:表定义了有效记录条件,查询完全没涉及条件里的列;
|
||||
- unknown_code:对有码值的列写了 = 或 IN,值不在码值表里;
|
||||
- unknown_code:对码值表标了「已列全」的列写了 = 或 IN,值不在码值表里(只补了一部分的码值表不拿来判断);
|
||||
- wrong_date_column:表定义了业务日期,查询却按这张表的另一个时间列分组或筛选。
|
||||
|
||||
**级别看依据。** 依据全是 confirmed / verified(人工确认、外键约束、数据剖析)时,fanout_sum、stock_summed 是 error,
|
||||
@@ -882,7 +882,9 @@ class _ScopeCheck:
|
||||
ref = self.resolve(column)
|
||||
item = ref[0].column_item(ref[1], "codes") if ref else None
|
||||
codes = item.get("value") if item else None
|
||||
if not isinstance(codes, Mapping) or not codes:
|
||||
# 只有标了「已列全」的码值表才能说某个值「不在里面」:经对话只补了 status=9 表示作废,之后 status = 1
|
||||
# 就会被报,模型还会照着改错。没标的码值表只是已知的一部分,不判(宁可漏报)
|
||||
if not isinstance(codes, Mapping) or not codes or not catalog.codes_complete(item):
|
||||
continue
|
||||
for value in values:
|
||||
literal = _literal(value)
|
||||
@@ -898,14 +900,16 @@ class _ScopeCheck:
|
||||
shown = "、".join(f"{k}={v}" for k, v in list(codes.items())[:10]) + ("等" if len(codes) > 10 else "")
|
||||
told = "、".join(f"「{v}」" for v in values[:5])
|
||||
listed = "、".join(f"{k}({v})" for k, v in list(codes.items())[:10])
|
||||
hint = f";拿不准就先用 db_schema__{self.checker.source_name} 查这张表的码值" if self.checker.source_name else ""
|
||||
hint = f",或者用 db_schema__{self.checker.source_name} 看这张表的码值说明" if self.checker.source_name else ""
|
||||
# 给模型的话只叫它核对,不叫它「照目录改」:码值表也可能漏了某个取值,照改会把本来对的 SQL 改错
|
||||
out.append(SqlCheck(
|
||||
code="unknown_code", level=_level("unknown_code", [item.get("status")]), table=table.name,
|
||||
column=column, sql_excerpt=self.excerpt(node),
|
||||
message=(f"「{table.label()}」的「{table.column_label(column)}」没有码值{told}(已知码值:{shown})。"
|
||||
"请按数据目录中的码值修改筛选条件"),
|
||||
for_model=(f"{table.name}.{column} 的码值只有 {listed},SQL 里写的 {'、'.join(values[:5])} 不在其中。"
|
||||
f"照目录里的码值改{hint}")))
|
||||
message=(f"「{table.label()}」的「{table.column_label(column)}」没有码值{told}(数据目录中已列出的全部码值:"
|
||||
f"{shown})。请核对筛选条件里的取值"),
|
||||
for_model=(f"{table.name}.{column} 的码值表已列全,只有 {listed},SQL 里写的 {'、'.join(values[:5])} "
|
||||
f"不在其中。核对这个取值有没有写错(比如写成了含义、别的列的取值,或者类型不对);拿不准就先查一下"
|
||||
f"这一列实际有哪些取值{hint}。确认取值存在就保留原写法,并在结果说明里写明数据目录的码值表可能缺了它")))
|
||||
return out
|
||||
|
||||
# ---- wrong_date_column
|
||||
|
||||
+9
-4
@@ -24,6 +24,11 @@ def _item(value: Any, status: str) -> dict[str, Any]:
|
||||
return make_item(value, _SOURCE_OF[status], status)
|
||||
|
||||
|
||||
def _codes(value: dict[str, str], status: str) -> dict[str, Any]:
|
||||
"""码值项,标了「已列全」:码值不在码值表中的检查只认这样的码值表。"""
|
||||
return make_item(value, _SOURCE_OF[status], status, complete=True)
|
||||
|
||||
|
||||
def _rel(table: str, columns: list[str], to_table: str, to_columns: list[str], cardinality: str,
|
||||
status: str) -> dict[str, Any]:
|
||||
source = {"confirmed": "human", "verified": "fk"}.get(status, "name")
|
||||
@@ -51,7 +56,7 @@ def notes(status: str = "confirmed", override: dict[tuple[str, str], str] | None
|
||||
"total_amount": {"label": _item("订单金额", "confirmed"), "unit": _item("元", s),
|
||||
"measure": _item("flow", s)},
|
||||
"status": {"label": _item("订单状态", "confirmed"), "measure": _item("status", s),
|
||||
"codes": _item({"1": "已支付", "2": "已退款"}, s)},
|
||||
"codes": _codes({"1": "已支付", "2": "已退款"}, s)},
|
||||
"ordered_at": {"label": _item("下单时间", "confirmed")},
|
||||
},
|
||||
},
|
||||
@@ -72,7 +77,7 @@ def notes(status: str = "confirmed", override: dict[tuple[str, str], str] | None
|
||||
"valid_filter": _item("status = 1", s),
|
||||
"columns": {
|
||||
"visitor_count": {"label": _item("入园人数", "confirmed"), "measure": _item("flow", s)},
|
||||
"status": {"label": _item("检票状态", "confirmed"), "codes": _item({"1": "有效", "0": "作废"}, s)},
|
||||
"status": {"label": _item("检票状态", "confirmed"), "codes": _codes({"1": "有效", "0": "作废"}, s)},
|
||||
"visit_time": {"label": _item("检票时间", "confirmed")},
|
||||
},
|
||||
"relations": [_rel("visits", ["park_id"], "parks", ["id"], "many_to_one", s)],
|
||||
@@ -102,8 +107,8 @@ def notes(status: str = "confirmed", override: dict[tuple[str, str], str] | None
|
||||
"channels": {
|
||||
"label": _item("渠道", "confirmed"),
|
||||
"columns": {"channel_type": {"label": _item("渠道类型", "confirmed"),
|
||||
"codes": _item({"线上直销": "官网和小程序", "线下": "窗口售票",
|
||||
"分销": "旅行社和第三方平台"}, s)}},
|
||||
"codes": _codes({"线上直销": "官网和小程序", "线下": "窗口售票",
|
||||
"分销": "旅行社和第三方平台"}, s)}},
|
||||
},
|
||||
}
|
||||
for (table, path), wanted in (override or {}).items():
|
||||
|
||||
@@ -252,3 +252,93 @@ def test_relation_graph_keeps_structure_relations_of_partially_annotated_tables(
|
||||
assert edges == {"gates"}
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# A4:码值「已列全」
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def _codes(value: dict, *, complete: bool | None, status: str = "confirmed") -> dict:
|
||||
item = catalog.make_item(value, "human" if status == "confirmed" else "profile", status)
|
||||
if complete is not None:
|
||||
item["complete"] = complete
|
||||
return item
|
||||
|
||||
|
||||
async def test_unknown_code_only_for_codes_marked_complete(scenic_db):
|
||||
cache = await _schema(scenic_db)
|
||||
sql = "SELECT COUNT(*) FROM visits WHERE status = 3"
|
||||
|
||||
def found(item: dict) -> list[str]:
|
||||
notes = scenic_notes.notes()
|
||||
notes["visits"]["columns"]["status"]["codes"] = item
|
||||
checker = sqlcheck.SqlChecker(kind="sqlite", schema_cache=cache, notes=notes, source_name="scenic")
|
||||
return [c.code for c in checker.check(sql)]
|
||||
|
||||
assert "unknown_code" not in found(_codes({"1": "有效", "0": "作废"}, complete=None))
|
||||
assert "unknown_code" in found(_codes({"1": "有效", "0": "作废"}, complete=True))
|
||||
|
||||
|
||||
async def test_partial_codes_from_a_patch_are_not_treated_as_the_full_list(client, make_source, scenic_db):
|
||||
"""审查探针 A4:经提案只写了 status=9 表示作废,之后 status = 1 不该被报「码值不在码值表中」。"""
|
||||
sid = await make_source(scenic_db, draft=["orders"])
|
||||
detail = await _notes(client, sid, "orders")
|
||||
r = await client.post(f"/api/datasources/{sid}/catalog/orders/patch", json={
|
||||
"changes": [{"path": "columns.status.codes", "value": {"9": "作废"}}], "if_version": detail["version"]})
|
||||
assert r.status_code == 200, r.text
|
||||
codes = r.json()["notes"]["columns"]["status"]["codes"]
|
||||
assert codes["value"] == {"9": "作废"} and "complete" not in codes
|
||||
sql = "SELECT COUNT(*) FROM orders WHERE status = 1"
|
||||
assert not any(c[0] == "unknown_code" for c in await _found(sid, sql))
|
||||
|
||||
# 人工在目录页把码值补全,并勾上「已列出全部取值」:这时才报
|
||||
notes = r.json()["notes"]
|
||||
notes["columns"]["status"]["codes"] = {**codes, "value": {"1": "已支付", "2": "已退款", "9": "作废"},
|
||||
"complete": True}
|
||||
r = await _put(client, sid, "orders", notes, r.json()["version"])
|
||||
assert r.status_code == 200, r.text
|
||||
saved = r.json()["notes"]["columns"]["status"]["codes"]
|
||||
assert (saved["source"], saved["status"], saved.get("complete")) == ("human", "confirmed", True)
|
||||
assert not any(c[0] == "unknown_code" for c in await _found(sid, sql))
|
||||
checker = await _checker(sid)
|
||||
[check] = [c for c in checker.check("SELECT COUNT(*) FROM orders WHERE status = 5") if c.code == "unknown_code"]
|
||||
assert check.level == "warning"
|
||||
# 给模型的话:提示核对取值,不叫它「照目录里的码值改」
|
||||
assert "照目录里的码值改" not in check.for_model and "核对" in check.for_model
|
||||
|
||||
|
||||
def test_codes_complete_flag_is_validated_and_counts_as_an_edit():
|
||||
base = {"columns": {"status": {"codes": _codes({"1": "", "0": ""}, complete=None, status="proposed")}}}
|
||||
assert catalog.validate_notes(base) == []
|
||||
assert catalog.validate_notes({"columns": {"status": {"codes": _codes({"1": "有效"}, complete=True)}}}) == []
|
||||
bad = catalog.validate_notes({"columns": {"status": {"codes": {**_codes({"1": "有效"}, complete=None),
|
||||
"complete": "是"}}}})
|
||||
assert bad and "码值" in bad[0]
|
||||
wrong_place = catalog.validate_notes({"label": {**catalog.make_item("入园记录", "human"), "complete": True}})
|
||||
assert wrong_place and "complete" in wrong_place[0]
|
||||
|
||||
# 值没动、只勾上「已列全」:算人工改动,记为人工填写、已确认
|
||||
ticked = {"columns": {"status": {"codes": {**base["columns"]["status"]["codes"], "complete": True}}}}
|
||||
out = catalog.apply_human_edit(base, ticked, table="visits", at=AT)
|
||||
codes = out["columns"]["status"]["codes"]
|
||||
assert (codes["source"], codes["status"], codes["complete"]) == ("human", "confirmed", True)
|
||||
# 取消勾选:不留 complete: false,和没有这个标记写法一样
|
||||
unticked = {"columns": {"status": {"codes": {**codes, "complete": False}}}}
|
||||
out = catalog.apply_human_edit(out, unticked, table="visits", at=AT)
|
||||
assert "complete" not in out["columns"]["status"]["codes"]
|
||||
|
||||
|
||||
async def test_profile_marks_codes_complete_only_when_the_whole_table_was_read(client, make_source, scenic_db):
|
||||
whole = await make_source(scenic_db, options=_on(), draft=["visits"])
|
||||
r = await client.post(_url(whole), json={"tables": ["visits"]})
|
||||
assert r.status_code == 200, r.text
|
||||
codes = (await _notes(client, whole, "visits"))["notes"]["columns"]["status"]["codes"]
|
||||
assert "统计全表" in codes["note"] and codes.get("complete") is True
|
||||
|
||||
# 不做整表统计(只看前若干行):取值可能没看全,不标
|
||||
capped = await make_source(scenic_db, options=_on(max_scan_rows=0, sample_size=10), draft=["visits"])
|
||||
r = await client.post(_url(capped), json={"tables": ["visits"]})
|
||||
assert r.status_code == 200, r.text
|
||||
codes = (await _notes(client, capped, "visits"))["notes"]["columns"]["status"]["codes"]
|
||||
assert "按前 10 行统计" in codes["note"] and "complete" not in codes
|
||||
|
||||
|
||||
|
||||
@@ -2465,6 +2465,11 @@ export const CODES_TEXT = {
|
||||
saved: (n: number) => (n ? `已填写 ${formatNumber(n)} 个码值的含义` : '已保存'),
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
// 「已列出全部取值」:只有勾了的码值表,SQL 检查才提醒「码值不在码值表中」;只列了一部分(比如对话里只补了一个码)不勾
|
||||
complete: '已列出全部取值',
|
||||
completeHint: '勾选表示这一列只会出现这些取值,SQL 检查会据此提醒码值表中没有的取值;只列了一部分时不要勾选',
|
||||
completeMark: '已列全',
|
||||
completeMarkHint: '已列出全部取值:SQL 检查会提醒码值表中没有的取值',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -2492,7 +2497,7 @@ export const SQL_CHECK_RULE_HINT: Record<SqlCheckCode, string> = {
|
||||
join_unconfirmed: '关联条件对不上数据目录中有确证的关系,可能连错了列',
|
||||
ratio_aggregated: '对比率列直接求和或求平均,得不到正确的比率',
|
||||
missing_valid_filter: '表定义了有效记录条件,查询没有按它筛选',
|
||||
unknown_code: '按码值筛选时用了码值表中没有的值',
|
||||
unknown_code: '码值表已列出全部取值,按码值筛选时却用了其中没有的值',
|
||||
wrong_date_column: '表定义了业务日期,查询却按另一个时间列分组或筛选',
|
||||
}
|
||||
|
||||
|
||||
@@ -3,28 +3,32 @@ import type { CatalogItem } from '../../types'
|
||||
import { isComposing, Modal, Spinner } from '../../components/ui'
|
||||
import { CODES_TEXT as KT } from '../../lib/terms'
|
||||
import { StatusChip } from './parts'
|
||||
import { codesComplete } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
// 填写码值的含义。数据剖析只知道列里出现过哪些取值(码值候选的含义是空串),含义要人填:逐个码值一个输入框,
|
||||
// 比在编辑模式里改「码值=含义」的多行文字省事。保存走整份提交,这一项记为人工填写、已确认;
|
||||
// 暂不清楚的含义可以留空,表格里照旧显示「含义待填写」。
|
||||
// 底下的「已列出全部取值」写明当前是否已列全,可以勾选或取消:只有勾了的码值表,SQL 检查才会提醒「取值不在码值表中」。
|
||||
// ===========================================================================
|
||||
|
||||
export function CodesDialog({ column, item, saving, onSave, onClose }: {
|
||||
column: string
|
||||
item: CatalogItem<Record<string, string>>
|
||||
saving: boolean
|
||||
/** 新的码值对照(码值不变,只改含义) */
|
||||
onSave: (value: Record<string, string>) => void
|
||||
/** 新的码值对照(码值不变,只改含义)和「已列出全部取值」 */
|
||||
onSave: (value: Record<string, string>, complete: boolean) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [initial] = useState(() => ({ ...item.value }))
|
||||
const [value, setValue] = useState<Record<string, string>>(initial)
|
||||
const [wasComplete] = useState(() => codesComplete(item))
|
||||
const [complete, setComplete] = useState(wasComplete)
|
||||
const codes = Object.keys(initial)
|
||||
const dirty = codes.some((k) => (value[k] ?? '').trim() !== (initial[k] ?? '').trim())
|
||||
const dirty = complete !== wasComplete || codes.some((k) => (value[k] ?? '').trim() !== (initial[k] ?? '').trim())
|
||||
const save = () => {
|
||||
if (!dirty || saving) return
|
||||
onSave(Object.fromEntries(codes.map((k) => [k, (value[k] ?? '').trim()])))
|
||||
onSave(Object.fromEntries(codes.map((k) => [k, (value[k] ?? '').trim()])), complete)
|
||||
}
|
||||
return (
|
||||
<Modal open onClose={onClose} dirty={dirty} width={520} title={KT.title(column)}
|
||||
@@ -72,6 +76,13 @@ export function CodesDialog({ column, item, saving, onSave, onClose }: {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<label className="flex items-start gap-2 rounded border bg-bg px-2 py-1.5 text-xs" data-codes-complete-field={complete ? 'true' : 'false'}>
|
||||
<input type="checkbox" className="mt-[3px]" checked={complete} onChange={(e) => setComplete(e.target.checked)} data-codes-complete="" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{KT.complete}</span>
|
||||
<span className="block text-2xs leading-relaxed text-faint">{KT.completeHint}</span>
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-2xs leading-relaxed text-faint">{KT.hint}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from '../../lib/terms'
|
||||
import { ItemMark } from './parts'
|
||||
import type { ReviewTarget } from './parts'
|
||||
import { COLUMN_FIELDS, columnPath } from './model'
|
||||
import type { ColumnField } from './model'
|
||||
import { COLUMN_FIELDS, codesComplete, columnPath, completeOf } from './model'
|
||||
import type { ColumnDraft, ColumnField } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
// 单表详情的列表格:列名 / 类型、中文名、含义、单位、度量类型、码值。每格的值旁边是只有图标的状态标识,点开看来源、
|
||||
@@ -30,9 +30,9 @@ export interface ColumnModel {
|
||||
items: CatalogColumnNotes | undefined
|
||||
}
|
||||
|
||||
type ColumnTexts = Partial<Record<ColumnField, string>>
|
||||
type ColumnTexts = ColumnDraft
|
||||
|
||||
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange }: {
|
||||
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete }: {
|
||||
columns: ColumnModel[]
|
||||
/** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */
|
||||
form: Record<string, ColumnTexts> | null
|
||||
@@ -47,6 +47,8 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
|
||||
/** 码值里有含义空着的:打开逐个填写含义的弹窗 */
|
||||
onFillCodes: (col: string) => void
|
||||
onChange: (col: string, field: ColumnField, value: string) => void
|
||||
/** 编辑中勾选或取消码值的「已列出全部取值」 */
|
||||
onCodesComplete: (col: string, value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="relative overflow-x-auto rounded-lg border" data-catalog-columns="">
|
||||
@@ -68,7 +70,8 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
|
||||
<tbody>
|
||||
{columns.map((c) => (
|
||||
<ColumnRow key={c.name} col={c} editing={!!form} draft={form?.[c.name]} initial={initial?.[c.name]} problems={problems} busy={busy}
|
||||
systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onFillCodes={onFillCodes} onChange={onChange} />
|
||||
systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onFillCodes={onFillCodes} onChange={onChange}
|
||||
onCodesComplete={onCodesComplete} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -76,7 +79,8 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
|
||||
)
|
||||
}
|
||||
|
||||
const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange }: {
|
||||
const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange,
|
||||
onCodesComplete }: {
|
||||
col: ColumnModel
|
||||
editing: boolean
|
||||
/** 这一列的表单。只有改了这一列时引用才会变,别的行不重画 */
|
||||
@@ -89,6 +93,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
onConfirmColumn: (col: string) => void
|
||||
onFillCodes: (col: string) => void
|
||||
onChange: (col: string, field: ColumnField, value: string) => void
|
||||
onCodesComplete: (col: string, value: boolean) => void
|
||||
}) {
|
||||
const { name, structure, items } = col
|
||||
const pending = COLUMN_FIELDS.filter((f) => items?.[f]?.status === 'proposed').length
|
||||
@@ -97,7 +102,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
const it = items?.[f] as CatalogItem | undefined
|
||||
const where = CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f])
|
||||
const problem = problems[`c:${name}:${f}`]
|
||||
const changed = editing && (draft?.[f] ?? '') !== (initial?.[f] ?? '')
|
||||
const changed = editing && ((draft?.[f] ?? '') !== (initial?.[f] ?? '') || (f === 'codes' && completeOf(draft) !== completeOf(initial)))
|
||||
return (
|
||||
<td className="px-2 py-1.5 align-top" data-cell={f} data-status={it?.status}>
|
||||
<div className="flex items-start gap-1.5">
|
||||
@@ -170,15 +175,23 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
{cell(
|
||||
'codes',
|
||||
<Value item={codes} render={(v) => (
|
||||
<Codes value={v as Record<string, string>} label={KT.fillLabel(name)}
|
||||
<Codes value={v as Record<string, string>} label={KT.fillLabel(name)} complete={codesComplete(codes)}
|
||||
onFill={codes?.status !== 'rejected' && !busy ? () => onFillCodes(name) : undefined} />
|
||||
)} />,
|
||||
<textarea className="field mono !min-h-0 !px-1.5 !py-1 !text-2xs" rows={Math.min(4, Math.max(1, (draft?.codes ?? '').split('\n').length))}
|
||||
value={draft?.codes ?? ''}
|
||||
placeholder={codes?.status === 'rejected' ? CT.rejectedPlaceholder(codesInline(codes.value)) : CT.codesPlaceholder}
|
||||
aria-label={CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL.codes)}
|
||||
aria-invalid={problems[`c:${name}:codes`] ? true : undefined}
|
||||
onChange={(e) => onChange(name, 'codes', e.target.value)} data-input="codes" />,
|
||||
<>
|
||||
<textarea className="field mono !min-h-0 !px-1.5 !py-1 !text-2xs" rows={Math.min(4, Math.max(1, (draft?.codes ?? '').split('\n').length))}
|
||||
value={draft?.codes ?? ''}
|
||||
placeholder={codes?.status === 'rejected' ? CT.rejectedPlaceholder(codesInline(codes.value)) : CT.codesPlaceholder}
|
||||
aria-label={CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL.codes)}
|
||||
aria-invalid={problems[`c:${name}:codes`] ? true : undefined}
|
||||
onChange={(e) => onChange(name, 'codes', e.target.value)} data-input="codes" />
|
||||
{/* 只列了一部分取值时不勾:SQL 检查只拿勾了的码值表判断「取值不在码值表中」 */}
|
||||
<label className="mt-1 flex items-start gap-1 text-2xs leading-snug text-dim" title={KT.completeHint}>
|
||||
<input type="checkbox" className="mt-[2px]" checked={completeOf(draft)} disabled={!(draft?.codes ?? '').trim()}
|
||||
onChange={(e) => onCodesComplete(name, e.target.checked)} data-input="codes-complete" />
|
||||
{KT.complete}
|
||||
</label>
|
||||
</>,
|
||||
)}
|
||||
<td className="px-1 py-1.5 align-top">
|
||||
{!editing && pending > 0 && (
|
||||
@@ -190,7 +203,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
)
|
||||
}, (a, b) => a.col === b.col && a.editing === b.editing && a.draft === b.draft && a.initial === b.initial && a.busy === b.busy
|
||||
&& a.systemNotes === b.systemNotes && a.onReview === b.onReview && a.onConfirmColumn === b.onConfirmColumn && a.onFillCodes === b.onFillCodes
|
||||
&& a.onChange === b.onChange
|
||||
&& a.onChange === b.onChange && a.onCodesComplete === b.onCodesComplete
|
||||
// 格式问题每次都是新对象:只比这一列的几格
|
||||
&& COLUMN_FIELDS.every((f) => a.problems[`c:${a.col.name}:${f}`] === b.problems[`c:${b.col.name}:${f}`]))
|
||||
|
||||
@@ -206,11 +219,11 @@ export function Value<T>({ item, render }: { item: CatalogItem<T> | undefined; r
|
||||
export const pendingCodes = (v: Record<string, string> | undefined): number =>
|
||||
Object.values(v ?? {}).filter((x) => !String(x ?? '').trim()).length
|
||||
|
||||
function Codes({ value, label, onFill }: { value: Record<string, string>; label: string; onFill?: () => void }) {
|
||||
function Codes({ value, label, complete, onFill }: { value: Record<string, string>; label: string; complete: boolean; onFill?: () => void }) {
|
||||
const entries = Object.entries(value)
|
||||
const pending = pendingCodes(value)
|
||||
return (
|
||||
<span className="block">
|
||||
<span className="block" data-codes-complete={complete ? 'true' : 'false'}>
|
||||
<span className="flex flex-wrap gap-1" title={entries.map(([k, v]) => `${k}=${v.trim() || KT.pending}`).join('\n')}>
|
||||
{entries.slice(0, CODES_SHOWN).map(([k, v]) => (
|
||||
<span key={k} className="inline-flex max-w-full items-center gap-1 rounded border bg-bg px-1 text-2xs" data-code={k}>
|
||||
@@ -222,6 +235,11 @@ function Codes({ value, label, onFill }: { value: Record<string, string>; label:
|
||||
))}
|
||||
{entries.length > CODES_SHOWN && <span className="text-2xs text-faint">+{entries.length - CODES_SHOWN}</span>}
|
||||
</span>
|
||||
{complete && (
|
||||
<span className="mt-1 inline-flex items-center gap-0.5 text-2xs text-faint" title={KT.completeMarkHint} data-codes-complete-mark="">
|
||||
<CheckCheck size={10} aria-hidden /> {KT.completeMark}
|
||||
</span>
|
||||
)}
|
||||
{pending > 0 && onFill && (
|
||||
// 各码值的「含义待填写」已经写在上面,这里只给入口;几个待填写写在悬停里
|
||||
<button type="button" className="mt-1 inline-flex items-center gap-1 whitespace-nowrap rounded px-1 text-2xs text-dim underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
|
||||
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
|
||||
} from './model'
|
||||
import type { ColumnField, EditForm, RelationDraft, TableField, TableKey } from './model'
|
||||
import type { CodesItem, ColumnField, EditForm, RelationDraft, TableField, TableKey } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
// 单表详情:表级各项、列表格、关联关系。每一项标出来源和状态,点状态标识做单项确认 / 驳回 / 恢复(审阅接口);
|
||||
@@ -182,15 +182,16 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
|
||||
const fillCodesOf = useCallback((col: string) => setCodesFor(col), [])
|
||||
|
||||
/**
|
||||
* 填好的码值含义整份提交:只换这一列码值的值,服务端记为人工填写、已确认。别人刚改过(409)时收起弹窗、挂冲突横幅;
|
||||
* 填好的码值含义(和「已列出全部取值」)整份提交:只换这一列码值的值,服务端记为人工填写、已确认。别人刚改过(409)时收起弹窗、挂冲突横幅;
|
||||
* 其余失败弹窗留着,填的字不丢
|
||||
*/
|
||||
const saveCodes = useCallback(async (col: string, value: Record<string, string>) => {
|
||||
const saveCodes = useCallback(async (col: string, value: Record<string, string>, complete: boolean) => {
|
||||
const d = detailRef.current
|
||||
const item = d?.notes.columns?.[col]?.codes
|
||||
if (!d || !item) return
|
||||
const notes = structuredClone(d.notes)
|
||||
notes.columns![col] = { ...notes.columns![col], codes: { ...item, value } }
|
||||
// 「已列出全部取值」跟着交:取消勾选交 false(服务端不留这个键),只改勾选也算人工改动
|
||||
notes.columns![col] = { ...notes.columns![col], codes: { ...item, value, complete } as CodesItem }
|
||||
setBusy(`codes:${col}`)
|
||||
setWriteError(null)
|
||||
try {
|
||||
@@ -217,6 +218,9 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
|
||||
const setColumn = useCallback((col: string, f: ColumnField, v: string) => {
|
||||
setEdit((e) => e && ({ ...e, form: { ...e.form, columns: { ...e.form.columns, [col]: { ...e.form.columns[col], [f]: v } } } }))
|
||||
}, [])
|
||||
const setCodesComplete = useCallback((col: string, v: boolean) => {
|
||||
setEdit((e) => e && ({ ...e, form: { ...e.form, columns: { ...e.form.columns, [col]: { ...e.form.columns[col], complete: v } } } }))
|
||||
}, [])
|
||||
const setTable = (k: TableKey, v: string) => setEdit((e) => e && ({ ...e, form: { ...e.form, table: { ...e.form.table, [k]: v } } }))
|
||||
const setRelations = (fn: (rs: RelationDraft[]) => RelationDraft[]) =>
|
||||
setEdit((e) => e && ({ ...e, form: { ...e.form, relations: fn(e.form.relations) } }))
|
||||
@@ -419,7 +423,8 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
|
||||
? (
|
||||
<ColumnTable columns={shownColumns} form={edit?.form.columns ?? null} initial={edit?.initial.columns ?? null}
|
||||
problems={problems} busy={!!busy} systemNotes={detail.system_notes}
|
||||
onReview={review} onConfirmColumn={confirmColumn} onFillCodes={fillCodesOf} onChange={setColumn} />
|
||||
onReview={review} onConfirmColumn={confirmColumn} onFillCodes={fillCodesOf} onChange={setColumn}
|
||||
onCodesComplete={setCodesComplete} />
|
||||
)
|
||||
: <p className="rounded-lg border px-3 py-6 text-center text-xs text-faint">{CT.noColumns}</p>}
|
||||
</section>
|
||||
@@ -447,7 +452,7 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
|
||||
|
||||
{codesFor && notes.columns?.[codesFor]?.codes && (
|
||||
<CodesDialog column={codesFor} item={notes.columns[codesFor]!.codes!} saving={busy === `codes:${codesFor}`}
|
||||
onSave={(v) => void saveCodes(codesFor, v)} onClose={() => setCodesFor(null)} />
|
||||
onSave={(v, complete) => void saveCodes(codesFor, v, complete)} onClose={() => setCodesFor(null)} />
|
||||
)}
|
||||
|
||||
{edit && (
|
||||
|
||||
@@ -203,13 +203,27 @@ export function relationRemoval(orig: CatalogRelation | null): 'delete' | 'rejec
|
||||
return orig.status === 'rejected' ? null : 'reject'
|
||||
}
|
||||
|
||||
/** 一列的表单:每个字段一段文字;complete 是码值的「已列出全部取值」勾选 */
|
||||
export type ColumnDraft = Partial<Record<ColumnField, string>> & { complete?: boolean }
|
||||
|
||||
export interface EditForm {
|
||||
table: Partial<Record<TableKey, string>>
|
||||
/** 列名 → 字段 → 文字 */
|
||||
columns: Record<string, Partial<Record<ColumnField, string>>>
|
||||
columns: Record<string, ColumnDraft>
|
||||
relations: RelationDraft[]
|
||||
}
|
||||
|
||||
/** 码值项:可带「已列全」标记(服务端 catalog.codes_complete) */
|
||||
export type CodesItem = CatalogItem<Record<string, string>> & { complete?: boolean }
|
||||
|
||||
/**
|
||||
* 码值表是不是标了「已列出全部取值」。只有标了的,SQL 检查才会提醒「码值不在码值表中」;没标的只是已知的一部分取值
|
||||
* (比如对话里只补了一个码的含义),不拿来判断。被驳回的不算
|
||||
*/
|
||||
export function codesComplete(item: CatalogItem<Record<string, string>> | undefined): boolean {
|
||||
return !!item && item.status !== 'rejected' && (item as CodesItem).complete === true
|
||||
}
|
||||
|
||||
const splitList = (s: string) => s.split(/[、,,;;\s]+/).map((x) => x.trim()).filter(Boolean)
|
||||
const joinList = (v: string[]) => v.join('、')
|
||||
|
||||
@@ -264,7 +278,7 @@ export function formFromNotes(notes: CatalogNotes, structure: CatalogStructureCo
|
||||
for (const col of names) {
|
||||
if (columns[col]) continue
|
||||
const items = notes.columns?.[col]
|
||||
columns[col] = Object.fromEntries(COLUMN_FIELDS.map((f) => [f, columnText(items, f)]))
|
||||
columns[col] = { ...Object.fromEntries(COLUMN_FIELDS.map((f) => [f, columnText(items, f)])), complete: codesComplete(items?.codes) }
|
||||
}
|
||||
const relations = (notes.relations ?? []).map((r) => relationDraft(r))
|
||||
return { table, columns, relations }
|
||||
@@ -284,12 +298,16 @@ export function relationDraft(r: CatalogRelation | null, key?: string): Relation
|
||||
const sameRelation = (a: RelationDraft, b: RelationDraft) =>
|
||||
a.columns === b.columns && a.to_table === b.to_table && a.to_columns === b.to_columns && a.cardinality === b.cardinality
|
||||
|
||||
/** 表单里改了几处:表级、列级各算一格,关系增删改各算一条 */
|
||||
/** 表单里「已列出全部取值」算不算勾上:码值清空了就不算(没有码值,谈不上列全) */
|
||||
export const completeOf = (d: ColumnDraft | undefined): boolean => !!d?.complete && !!(d.codes ?? '').trim()
|
||||
|
||||
/** 表单里改了几处:表级、列级各算一格(码值的「已列全」勾选另算一格),关系增删改各算一条 */
|
||||
export function changeCount(initial: EditForm, form: EditForm): number {
|
||||
let n = 0
|
||||
for (const k of TABLE_KEYS) if ((initial.table[k] ?? '') !== (form.table[k] ?? '')) n++
|
||||
for (const [col, fields] of Object.entries(form.columns)) {
|
||||
for (const f of COLUMN_FIELDS) if ((initial.columns[col]?.[f] ?? '') !== (fields[f] ?? '')) n++
|
||||
if (completeOf(initial.columns[col]) !== completeOf(fields)) n++
|
||||
}
|
||||
const before = new Map(initial.relations.map((r) => [r.key, r]))
|
||||
const after = new Set(form.relations.map((r) => r.key))
|
||||
@@ -400,6 +418,16 @@ export function buildNotes(orig: CatalogNotes, initial: EditForm, form: EditForm
|
||||
const o = orig.columns?.[col]?.[f] as CatalogItem | undefined
|
||||
const a = initial.columns[col]?.[f] ?? ''
|
||||
const b = form.columns[col]?.[f] ?? a
|
||||
if (f === 'codes') {
|
||||
// 码值的文字没动、只改了「已列全」勾选:值原样、带上新的勾选交回(服务端也算改动,记为人工填写、已确认)
|
||||
const was = completeOf(initial.columns[col])
|
||||
const now = form.columns[col] ? completeOf(form.columns[col]) : was
|
||||
if (a === b && was === now) { if (o) items[f] = o; continue }
|
||||
if (!b.trim()) continue
|
||||
const value = a === b && o ? o.value : parseColumn(f, b)
|
||||
items[f] = { ...(o ?? {}), value, complete: now }
|
||||
continue
|
||||
}
|
||||
const next = put(o, a, b, () => parseColumn(f, b))
|
||||
if (next) items[f] = next
|
||||
}
|
||||
|
||||
@@ -955,6 +955,11 @@ await section('数据目录 · 编辑即确认', async () => {
|
||||
check('码值格式不对:就地写第几行、底部写几处格式不正确、不能保存', (await page.locator('[data-column="status"] [role="alert"]').innerText()) === '第 3 行应写成「码值=含义」'
|
||||
&& (await page.locator('[data-catalog-problems]').innerText()).includes('1 处格式不正确') && await page.locator('[data-catalog-save]').isDisabled())
|
||||
await codes.fill('1=已入园\n2=已退票\n9=已作废')
|
||||
const complete = page.locator('[data-column="status"] [data-input="codes-complete"]')
|
||||
check('码值下有「已列出全部取值」:原来没标的不勾,没有码值的列勾不了', !(await complete.isChecked())
|
||||
&& (await page.locator('[data-column="status"] [data-cell="codes"]').innerText()).includes('已列出全部取值')
|
||||
&& await page.locator('[data-column="visitor_count"] [data-input="codes-complete"]').isDisabled())
|
||||
await complete.check()
|
||||
await page.locator('[data-column="visitor_count"] [data-input="unit"]').fill('人次')
|
||||
await page.locator('[data-add-relation]').click()
|
||||
const draft = page.locator('[data-relation-draft^="new-"]')
|
||||
@@ -981,10 +986,10 @@ await section('数据目录 · 编辑即确认', async () => {
|
||||
&& 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-catalog-changes]').innerText()) === '7 处修改', await page.locator('[data-catalog-changes]').innerText())
|
||||
await page.locator(`[data-relation-draft="${GATE}"] [data-relation-reject]`).click()
|
||||
await shot(page, 'catalog-edit')
|
||||
check('保存栏写「7 处修改」(驳回一条关系也算一处)', (await page.locator('[data-catalog-changes]').innerText()) === '7 处修改', await page.locator('[data-catalog-changes]').innerText())
|
||||
check('保存栏写「8 处修改」(勾「已列出全部取值」、驳回一条关系各算一处)', (await page.locator('[data-catalog-changes]').innerText()) === '8 处修改', 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 /)
|
||||
@@ -994,6 +999,7 @@ await section('数据目录 · 编辑即确认', async () => {
|
||||
check('……改过的项交新值:中文名、业务主键、码值、单位', n.label?.value === '入园流水' && JSON.stringify(n.keys?.value) === '["ticket_no","park_id"]'
|
||||
&& JSON.stringify(n.columns?.status?.codes?.value) === JSON.stringify({ 1: '已入园', 2: '已退票', 9: '已作废' })
|
||||
&& n.columns?.visitor_count?.unit?.value === '人次', JSON.stringify({ label: n.label, keys: n.keys }))
|
||||
check('……码值带上「已列出全部取值」', n.columns?.status?.codes?.complete === true, JSON.stringify(n.columns?.status?.codes))
|
||||
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))
|
||||
@@ -1005,6 +1011,8 @@ await section('数据目录 · 编辑即确认', async () => {
|
||||
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 page.locator('[data-column="status"] [data-codes-complete-mark]').count() === 1
|
||||
&& (await page.locator('[data-column="status"] [data-codes-complete-mark]').innerText()).includes('已列全'))
|
||||
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('已保存,改动过的项已记为人工确认')))
|
||||
|
||||
@@ -1173,6 +1181,8 @@ function profileReply(state, { ms = 0 } = {}) {
|
||||
note: `数据剖析(${PROFILE_DAY}):抽样覆盖率 62%,可能不是这条关系;子表抽样 200 个不同键值,父表对上 124 个;被指向列是主键。` }))
|
||||
notes.columns.status.codes = item({ 1: '已入园', 9: '已作废', 2: '' }, 'profile', 'proposed')
|
||||
notes.columns.status.codes.note = `数据剖析(${PROFILE_DAY}):统计全表,182000 行非空值共 3 个取值:1(94%)、9(5%)、2(1%)。`
|
||||
// 统计的是全表:服务端给码值候选标「已列全」
|
||||
notes.columns.status.codes.complete = true
|
||||
writeEntry(visits, notes)
|
||||
const tables = [
|
||||
{
|
||||
@@ -1415,6 +1425,11 @@ await section('数据目录 · 数据剖析', async () => {
|
||||
&& await until(async () => page.evaluate(() => document.activeElement?.getAttribute('data-code') === '2')))
|
||||
check('……写明剖析说明(各取值的占比)', (await codesBox.innerText()).includes('1(94%)、9(5%)、2(1%)'))
|
||||
check('……没填时保存禁用', await page.locator('[data-codes-save]').isDisabled())
|
||||
check('……写明当前已列出全部取值(剖析统计了全表),可以取消', await codesBox.locator('[data-codes-complete]').isChecked()
|
||||
&& (await codesBox.innerText()).includes('已列出全部取值') && (await statusCodes.locator('[data-codes-complete-mark]').count()) === 1)
|
||||
await codesBox.locator('[data-codes-complete]').uncheck()
|
||||
check('……只改勾选也能保存', !(await page.locator('[data-codes-save]').isDisabled()))
|
||||
await codesBox.locator('[data-codes-complete]').check()
|
||||
await codesBox.locator('input[data-code="2"]').fill('已退票')
|
||||
if (SHOTS) await sleep(350)
|
||||
await shot(page, 'catalog-codes-fill')
|
||||
@@ -1422,6 +1437,7 @@ await section('数据目录 · 数据剖析', async () => {
|
||||
await page.keyboard.press('Enter')
|
||||
check('回车保存:整份提交,只换这一列码值的含义,带读到的版本', await until(async () => (await codesBox.count()) === 0)
|
||||
&& JSON.stringify(lastBody(sent, /^PUT .*catalog\/visits$/)?.notes?.columns?.status?.codes?.value) === JSON.stringify({ 1: '已入园', 9: '已作废', 2: '已退票' })
|
||||
&& lastBody(sent, /^PUT .*catalog\/visits$/)?.notes?.columns?.status?.codes?.complete === true
|
||||
&& lastBody(sent, /^PUT .*catalog\/visits$/)?.if_version === v1, JSON.stringify(lastBody(sent, /^PUT .*catalog\/visits$/)?.notes?.columns?.status))
|
||||
check('……保存后不再有「含义待填写」,这一项记为人工确认', await until(async () => (await statusCodes.locator('[data-code-pending]').count()) === 0)
|
||||
&& (await statusCodes.innerText()).includes('已退票') && (await markStatus(page, 'columns.status.codes')) === 'confirmed')
|
||||
|
||||
Reference in New Issue
Block a user