数据目录 F1-A4:码值项加「已列全」标记(complete: true),只有人工明确勾选、或剖析整表统计(没有只看前若干行)得到的码值才有;码值不在码值表中的检查只认标了它的码值表,给模型的话改为提示核对取值,不再叫它照目录里的码值改。以前经对话只补了 status=9 表示作废,之后 status = 1 就被报提醒,模型还可能把对的 SQL 改错。提案补充码值时沿用原有的标记、自己不能标;目录页的码值编辑和填写含义弹窗加「已列出全部取值」勾选,表格里标「已列全」,检查脚本跟上

This commit is contained in:
ThearchyHelios
2026-10-04 01:13:13 +08:00
parent fd3ec43662
commit b078d08ffe
11 changed files with 275 additions and 58 deletions
+41 -10
View File
@@ -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",
+8 -4
View File
@@ -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
+11 -7
View File
@@ -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
View File
@@ -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():
+90
View File
@@ -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
+6 -1
View File
@@ -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: '表定义了业务日期,查询却按另一个时间列分组或筛选',
}
+15 -4
View File
@@ -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>
+35 -17
View File
@@ -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"
+11 -6
View File
@@ -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 && (
+31 -3
View File
@@ -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
}
+18 -2
View File
@@ -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')