1273 lines
45 KiB
Python
1273 lines
45 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
CM93 -> MySQL 非 Geometry 数据完整性全库最终验收 v1
|
||
|
||
目标
|
||
====
|
||
在 Geometry normalization 已通过后,对同一 Dataset 的“非 Geometry”数据链做最终只读验收。
|
||
|
||
检查:
|
||
1. Dataset / Cell 完整性
|
||
2. 每个 Cell 的 feature_count / attribute_count 与实际落库一致
|
||
3. Feature 的 dataset/cell/source_record_index 可追溯性
|
||
4. Feature source_record_index 在 Cell 内连续、唯一
|
||
5. Attribute 外键、ordinal、dictionary 映射、类型值通道
|
||
6. Coverage 与 _m_sor Feature 一一对应且同 Cell
|
||
7. Sounding 外键、point_index 连续性、depth 公式
|
||
8. raw_feature_blob / raw_attribute_blob 保留
|
||
9. chart_raw_record 分块索引、chunk_count、total_bytes、JSON metadata
|
||
10. Object / Attribute dictionary 映射完整性
|
||
11. attributes_json 可解析;_cm93_related_indices 若存在,必须指向同 Cell Feature
|
||
12. 可选:源 CM93 文件存在、source_size、SHA-256 与数据库一致
|
||
13. orphan / duplicate 统计必须为 0
|
||
|
||
不检查 Geometry validity(该项已经由独立 Geometry audit 验收)。
|
||
不修改数据库。
|
||
不依赖历史 V 系列测试输出。
|
||
不涉及 PBF。
|
||
|
||
当前 CM93JAPAN 固定基线默认:
|
||
- Cells 784
|
||
- Features 322465
|
||
- Attributes 405153
|
||
- Soundings 275700
|
||
|
||
如果以后换 Dataset,可以传 --no-fixed-baseline。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import getpass
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import sys
|
||
import zipfile
|
||
from collections import defaultdict
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Iterable
|
||
|
||
try:
|
||
import pymysql
|
||
from pymysql.cursors import DictCursor, SSCursor
|
||
except ImportError:
|
||
print("缺少 pymysql,请执行: pip install pymysql")
|
||
sys.exit(2)
|
||
|
||
|
||
EXPECTED_COUNTS = {
|
||
"cells": 784,
|
||
"features": 322465,
|
||
"attributes": 405153,
|
||
"soundings": 275700,
|
||
}
|
||
|
||
|
||
def parse_args():
|
||
p = argparse.ArgumentParser(
|
||
description="CM93 -> MySQL 非 Geometry 数据完整性全库最终验收 v1"
|
||
)
|
||
p.add_argument("--host", default="127.0.0.1")
|
||
p.add_argument("--port", type=int, default=3306)
|
||
p.add_argument("--user", default="root")
|
||
p.add_argument("--password", default=None)
|
||
p.add_argument("--database", default="cm93_chart")
|
||
p.add_argument("--dataset-id", type=int, default=1)
|
||
p.add_argument(
|
||
"--source-root",
|
||
default=None,
|
||
help="可选:CM93 根目录,例如 D:\\Ship\\CM93V2\\CM93JAPAN;提供后校验文件存在/大小/SHA256",
|
||
)
|
||
p.add_argument(
|
||
"--no-fixed-baseline",
|
||
action="store_true",
|
||
help="不要求当前 CM93JAPAN 的 784/322465/405153/275700 固定数量",
|
||
)
|
||
p.add_argument(
|
||
"--output-dir",
|
||
default=None,
|
||
)
|
||
return p.parse_args()
|
||
|
||
|
||
def scalar(cur, sql: str, params=()) -> Any:
|
||
cur.execute(sql, params)
|
||
row = cur.fetchone()
|
||
if row is None:
|
||
return None
|
||
if isinstance(row, dict):
|
||
return next(iter(row.values()))
|
||
return row[0]
|
||
|
||
|
||
def fetchall(cur, sql: str, params=()):
|
||
cur.execute(sql, params)
|
||
return list(cur.fetchall())
|
||
|
||
|
||
def add_failure(
|
||
failures: list[dict[str, Any]],
|
||
check: str,
|
||
detail: Any = None,
|
||
**kwargs,
|
||
):
|
||
row = {"check": check}
|
||
if detail is not None:
|
||
row["detail"] = detail
|
||
row.update(kwargs)
|
||
failures.append(row)
|
||
|
||
|
||
def json_load(value):
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, (dict, list)):
|
||
return value
|
||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||
value = bytes(value).decode("utf-8")
|
||
return json.loads(value)
|
||
|
||
|
||
def file_sha256(path: Path) -> str:
|
||
h = hashlib.sha256()
|
||
with path.open("rb") as f:
|
||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
|
||
def write_csv(path: Path, rows: Iterable[dict[str, Any]]):
|
||
rows = list(rows)
|
||
if not rows:
|
||
path.write_text("", encoding="utf-8-sig")
|
||
return
|
||
|
||
fields = []
|
||
seen = set()
|
||
for row in rows:
|
||
for key in row:
|
||
if key not in seen:
|
||
seen.add(key)
|
||
fields.append(key)
|
||
|
||
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
|
||
w.writeheader()
|
||
for row in rows:
|
||
out = {}
|
||
for key in fields:
|
||
v = row.get(key)
|
||
if isinstance(v, (dict, list, tuple)):
|
||
v = json.dumps(v, ensure_ascii=False, default=str)
|
||
out[key] = v
|
||
w.writerow(out)
|
||
|
||
|
||
def table_exists(cur, database: str, table: str) -> bool:
|
||
return bool(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM information_schema.tables
|
||
WHERE table_schema=%s AND table_name=%s
|
||
""",
|
||
(database, table),
|
||
))
|
||
|
||
|
||
def begin_read_only(cur, warnings: list[str]):
|
||
try:
|
||
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
||
except Exception as e:
|
||
warnings.append(f"SET SESSION TRANSACTION READ ONLY: {e}")
|
||
|
||
try:
|
||
cur.execute("START TRANSACTION READ ONLY")
|
||
except Exception as e:
|
||
warnings.append(f"START TRANSACTION READ ONLY: {e}")
|
||
cur.execute("START TRANSACTION")
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
|
||
if args.password is None:
|
||
args.password = getpass.getpass("MySQL password: ")
|
||
|
||
source_root = (
|
||
Path(args.source_root).expanduser().resolve()
|
||
if args.source_root
|
||
else None
|
||
)
|
||
|
||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
outdir = Path(
|
||
args.output_dir
|
||
or f"cm93_non_geometry_final_audit_dataset_{args.dataset_id}_{stamp}"
|
||
).resolve()
|
||
outdir.mkdir(parents=True, exist_ok=True)
|
||
|
||
failures: list[dict[str, Any]] = []
|
||
warnings: list[str] = []
|
||
|
||
report: dict[str, Any] = {
|
||
"version": "v1.0",
|
||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||
"database": args.database,
|
||
"dataset_id": args.dataset_id,
|
||
"read_only": True,
|
||
"pbf": "OUT_OF_SCOPE",
|
||
"source_root": str(source_root) if source_root else None,
|
||
}
|
||
|
||
conn = pymysql.connect(
|
||
host=args.host,
|
||
port=args.port,
|
||
user=args.user,
|
||
password=args.password,
|
||
database=args.database,
|
||
charset="utf8mb4",
|
||
cursorclass=DictCursor,
|
||
autocommit=False,
|
||
connect_timeout=20,
|
||
read_timeout=900,
|
||
write_timeout=30,
|
||
)
|
||
|
||
try:
|
||
with conn.cursor() as cur:
|
||
begin_read_only(cur, warnings)
|
||
|
||
print("[1/10] 基础表 / Dataset / 总数 ...")
|
||
|
||
required_tables = [
|
||
"chart_dataset",
|
||
"chart_cell",
|
||
"chart_feature",
|
||
"chart_feature_attribute",
|
||
"chart_coverage",
|
||
"chart_sounding_point",
|
||
"chart_raw_record",
|
||
"chart_dictionary_object",
|
||
"chart_dictionary_attribute",
|
||
]
|
||
missing_tables = [
|
||
t for t in required_tables
|
||
if not table_exists(cur, args.database, t)
|
||
]
|
||
report["missing_tables"] = missing_tables
|
||
if missing_tables:
|
||
add_failure(
|
||
failures,
|
||
"missing_tables",
|
||
missing_tables,
|
||
)
|
||
raise RuntimeError(
|
||
"缺少必要表:" + ", ".join(missing_tables)
|
||
)
|
||
|
||
dataset_count = int(scalar(
|
||
cur,
|
||
"SELECT COUNT(*) FROM chart_dataset WHERE id=%s",
|
||
(args.dataset_id,),
|
||
) or 0)
|
||
|
||
if dataset_count != 1:
|
||
add_failure(
|
||
failures,
|
||
"dataset_exists",
|
||
expected=1,
|
||
actual=dataset_count,
|
||
)
|
||
|
||
counts = {
|
||
"cells": int(scalar(
|
||
cur,
|
||
"SELECT COUNT(*) FROM chart_cell WHERE dataset_id=%s",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"features": int(scalar(
|
||
cur,
|
||
"SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"attributes": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature_attribute a
|
||
JOIN chart_feature f ON f.id=a.feature_id
|
||
WHERE f.dataset_id=%s
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"soundings": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_sounding_point s
|
||
JOIN chart_feature f ON f.id=s.feature_id
|
||
WHERE f.dataset_id=%s
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"coverages": int(scalar(
|
||
cur,
|
||
"SELECT COUNT(*) FROM chart_coverage WHERE dataset_id=%s",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"raw_records": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_raw_record r
|
||
JOIN chart_cell c ON c.id=r.cell_id
|
||
WHERE c.dataset_id=%s
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
}
|
||
report["counts"] = counts
|
||
|
||
if not args.no_fixed_baseline:
|
||
for key, expected in EXPECTED_COUNTS.items():
|
||
if counts[key] != expected:
|
||
add_failure(
|
||
failures,
|
||
f"baseline_{key}",
|
||
expected=expected,
|
||
actual=counts[key],
|
||
)
|
||
|
||
done_cells = int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_cell
|
||
WHERE dataset_id=%s AND import_status='DONE'
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0)
|
||
non_done_cells = counts["cells"] - done_cells
|
||
|
||
report["cell_status"] = {
|
||
"done": done_cells,
|
||
"non_done": non_done_cells,
|
||
}
|
||
|
||
if non_done_cells:
|
||
add_failure(
|
||
failures,
|
||
"cell_status_non_done",
|
||
actual=non_done_cells,
|
||
)
|
||
|
||
print("[2/10] Cell header counts vs 实际落库 ...")
|
||
|
||
cell_mismatch_rows = fetchall(
|
||
cur,
|
||
"""
|
||
SELECT
|
||
c.id AS cell_id,
|
||
c.source_relative_path,
|
||
c.feature_count AS expected_features,
|
||
COALESCE(f.actual_features,0) AS actual_features,
|
||
c.attribute_count AS expected_attributes,
|
||
COALESCE(a.actual_attributes,0) AS actual_attributes
|
||
FROM chart_cell c
|
||
LEFT JOIN (
|
||
SELECT cell_id,COUNT(*) AS actual_features
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
GROUP BY cell_id
|
||
) f ON f.cell_id=c.id
|
||
LEFT JOIN (
|
||
SELECT f.cell_id,COUNT(*) AS actual_attributes
|
||
FROM chart_feature_attribute a
|
||
JOIN chart_feature f ON f.id=a.feature_id
|
||
WHERE f.dataset_id=%s
|
||
GROUP BY f.cell_id
|
||
) a ON a.cell_id=c.id
|
||
WHERE c.dataset_id=%s
|
||
AND (
|
||
c.feature_count<>COALESCE(f.actual_features,0)
|
||
OR c.attribute_count<>COALESCE(a.actual_attributes,0)
|
||
)
|
||
ORDER BY c.id
|
||
""",
|
||
(args.dataset_id, args.dataset_id, args.dataset_id),
|
||
)
|
||
|
||
report["cell_count_mismatch"] = len(cell_mismatch_rows)
|
||
for row in cell_mismatch_rows:
|
||
add_failure(
|
||
failures,
|
||
"cell_header_count_mismatch",
|
||
**row,
|
||
)
|
||
|
||
print("[3/10] Feature traceability / source_record_index ...")
|
||
|
||
feature_checks = {
|
||
"feature_cell_dataset_mismatch": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature f
|
||
JOIN chart_cell c ON c.id=f.cell_id
|
||
WHERE f.dataset_id=%s
|
||
AND c.dataset_id<>f.dataset_id
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"duplicate_source_record_index": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT cell_id,source_record_index
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
GROUP BY cell_id,source_record_index
|
||
HAVING COUNT(*)>1
|
||
) x
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"source_record_index_not_contiguous": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT
|
||
cell_id,
|
||
COUNT(*) AS n,
|
||
COUNT(DISTINCT source_record_index) AS nd,
|
||
MIN(source_record_index) AS min_i,
|
||
MAX(source_record_index) AS max_i
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
GROUP BY cell_id
|
||
HAVING
|
||
nd<>n
|
||
OR min_i<>0
|
||
OR max_i<>n-1
|
||
) x
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"raw_feature_blob_null": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
AND raw_feature_blob IS NULL
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"raw_attribute_blob_null": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
AND raw_attribute_blob IS NULL
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"attributes_json_null": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
AND attributes_json IS NULL
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
}
|
||
report["feature_checks"] = feature_checks
|
||
|
||
for key, value in feature_checks.items():
|
||
if value:
|
||
add_failure(
|
||
failures,
|
||
key,
|
||
expected=0,
|
||
actual=value,
|
||
)
|
||
|
||
print("[4/10] Object / Attribute dictionary 映射 ...")
|
||
|
||
dictionary_checks = {
|
||
"feature_unknown_object_id": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature f
|
||
LEFT JOIN chart_dictionary_object d
|
||
ON d.dataset_id=f.dataset_id
|
||
AND d.object_class_id=f.object_class_id
|
||
WHERE f.dataset_id=%s
|
||
AND d.object_class_id IS NULL
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"feature_object_name_mismatch": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature f
|
||
JOIN chart_dictionary_object d
|
||
ON d.dataset_id=f.dataset_id
|
||
AND d.object_class_id=f.object_class_id
|
||
WHERE f.dataset_id=%s
|
||
AND f.object_class<>d.object_class
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"attribute_unknown_id": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature_attribute a
|
||
JOIN chart_feature f ON f.id=a.feature_id
|
||
LEFT JOIN chart_dictionary_attribute d
|
||
ON d.dataset_id=f.dataset_id
|
||
AND d.attribute_id=a.attribute_id
|
||
WHERE f.dataset_id=%s
|
||
AND d.attribute_id IS NULL
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"attribute_name_mismatch": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature_attribute a
|
||
JOIN chart_feature f ON f.id=a.feature_id
|
||
JOIN chart_dictionary_attribute d
|
||
ON d.dataset_id=f.dataset_id
|
||
AND d.attribute_id=a.attribute_id
|
||
WHERE f.dataset_id=%s
|
||
AND a.attribute_name<>d.attribute_name
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
}
|
||
report["dictionary_checks"] = dictionary_checks
|
||
|
||
for key, value in dictionary_checks.items():
|
||
if value:
|
||
add_failure(
|
||
failures,
|
||
key,
|
||
expected=0,
|
||
actual=value,
|
||
)
|
||
|
||
print("[5/10] Attribute orphan / ordinal / value_type ...")
|
||
|
||
attribute_checks = {
|
||
"attribute_orphan": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature_attribute a
|
||
LEFT JOIN chart_feature f ON f.id=a.feature_id
|
||
WHERE f.id IS NULL
|
||
""",
|
||
) or 0),
|
||
"attribute_duplicate_ordinal": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT a.feature_id,a.ordinal_no
|
||
FROM chart_feature_attribute a
|
||
JOIN chart_feature f ON f.id=a.feature_id
|
||
WHERE f.dataset_id=%s
|
||
GROUP BY a.feature_id,a.ordinal_no
|
||
HAVING COUNT(*)>1
|
||
) x
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"attribute_ordinal_not_contiguous": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT
|
||
a.feature_id,
|
||
COUNT(*) AS n,
|
||
COUNT(DISTINCT a.ordinal_no) AS nd,
|
||
MIN(a.ordinal_no) AS min_o,
|
||
MAX(a.ordinal_no) AS max_o
|
||
FROM chart_feature_attribute a
|
||
JOIN chart_feature f ON f.id=a.feature_id
|
||
WHERE f.dataset_id=%s
|
||
GROUP BY a.feature_id
|
||
HAVING
|
||
nd<>n
|
||
OR min_o<>0
|
||
OR max_o<>n-1
|
||
) x
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"attribute_unknown_value_type": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature_attribute a
|
||
JOIN chart_feature f ON f.id=a.feature_id
|
||
WHERE f.dataset_id=%s
|
||
AND a.value_type NOT IN
|
||
('BYTE','INT','LONG','REAL','WORD10','STRING','LIST','COMPLEX','UNKNOWN')
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
}
|
||
report["attribute_checks"] = attribute_checks
|
||
|
||
for key, value in attribute_checks.items():
|
||
if value:
|
||
add_failure(
|
||
failures,
|
||
key,
|
||
expected=0,
|
||
actual=value,
|
||
)
|
||
|
||
print("[6/10] Coverage <-> _m_sor Feature 映射 ...")
|
||
|
||
coverage_checks = {
|
||
"coverage_duplicate_source_record_index": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT cell_id,source_record_index
|
||
FROM chart_coverage
|
||
WHERE dataset_id=%s
|
||
GROUP BY cell_id,source_record_index
|
||
HAVING COUNT(*)>1
|
||
) x
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"coverage_without_m_sor_feature": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_coverage c
|
||
LEFT JOIN chart_feature f
|
||
ON f.dataset_id=c.dataset_id
|
||
AND f.cell_id=c.cell_id
|
||
AND f.source_record_index=c.source_record_index
|
||
AND f.object_class='_m_sor'
|
||
WHERE c.dataset_id=%s
|
||
AND f.id IS NULL
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"m_sor_without_coverage": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature f
|
||
LEFT JOIN chart_coverage c
|
||
ON c.dataset_id=f.dataset_id
|
||
AND c.cell_id=f.cell_id
|
||
AND c.source_record_index=f.source_record_index
|
||
WHERE f.dataset_id=%s
|
||
AND f.object_class='_m_sor'
|
||
AND c.id IS NULL
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"feature_coverage_cross_cell": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_feature f
|
||
JOIN chart_coverage c ON c.id=f.coverage_id
|
||
WHERE f.dataset_id=%s
|
||
AND (
|
||
c.dataset_id<>f.dataset_id
|
||
OR c.cell_id<>f.cell_id
|
||
)
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
}
|
||
report["coverage_checks"] = coverage_checks
|
||
|
||
for key, value in coverage_checks.items():
|
||
if value:
|
||
add_failure(
|
||
failures,
|
||
key,
|
||
expected=0,
|
||
actual=value,
|
||
)
|
||
|
||
print("[7/10] Sounding 数据链 ...")
|
||
|
||
sounding_checks = {
|
||
"sounding_orphan": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_sounding_point s
|
||
LEFT JOIN chart_feature f ON f.id=s.feature_id
|
||
WHERE f.id IS NULL
|
||
""",
|
||
) or 0),
|
||
"sounding_wrong_feature_type": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_sounding_point s
|
||
JOIN chart_feature f ON f.id=s.feature_id
|
||
WHERE f.dataset_id=%s
|
||
AND f.geometry_type<>'SOUNDING'
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"sounding_duplicate_point_index": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT s.feature_id,s.point_index
|
||
FROM chart_sounding_point s
|
||
JOIN chart_feature f ON f.id=s.feature_id
|
||
WHERE f.dataset_id=%s
|
||
GROUP BY s.feature_id,s.point_index
|
||
HAVING COUNT(*)>1
|
||
) x
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"sounding_index_not_contiguous": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT
|
||
s.feature_id,
|
||
COUNT(*) AS n,
|
||
COUNT(DISTINCT s.point_index) AS nd,
|
||
MIN(s.point_index) AS min_i,
|
||
MAX(s.point_index) AS max_i
|
||
FROM chart_sounding_point s
|
||
JOIN chart_feature f ON f.id=s.feature_id
|
||
WHERE f.dataset_id=%s
|
||
GROUP BY s.feature_id
|
||
HAVING
|
||
nd<>n
|
||
OR min_i<>0
|
||
OR max_i<>n-1
|
||
) x
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
"sounding_depth_formula_mismatch": int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM chart_sounding_point s
|
||
JOIN chart_feature f ON f.id=s.feature_id
|
||
WHERE f.dataset_id=%s
|
||
AND ABS(
|
||
s.depth -
|
||
CASE
|
||
WHEN s.raw_z>=12000 THEN s.raw_z-12000
|
||
ELSE s.raw_z/10.0
|
||
END
|
||
) > 0.000001
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0),
|
||
}
|
||
report["sounding_checks"] = sounding_checks
|
||
|
||
for key, value in sounding_checks.items():
|
||
if value:
|
||
add_failure(
|
||
failures,
|
||
key,
|
||
expected=0,
|
||
actual=value,
|
||
)
|
||
|
||
print("[8/10] attributes_json / relation indices 全量解析 ...")
|
||
|
||
relation_bad = 0
|
||
attributes_json_bad = 0
|
||
attributes_name_missing = 0
|
||
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
id,
|
||
cell_id,
|
||
source_record_index,
|
||
attributes_json
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
ORDER BY cell_id,source_record_index
|
||
""",
|
||
(args.dataset_id,),
|
||
)
|
||
feature_json_rows = list(cur.fetchall())
|
||
|
||
indices_by_cell: dict[int, set[int]] = defaultdict(set)
|
||
parsed_by_feature: dict[int, Any] = {}
|
||
|
||
for row in feature_json_rows:
|
||
cell_id = int(row["cell_id"])
|
||
src_idx = int(row["source_record_index"])
|
||
indices_by_cell[cell_id].add(src_idx)
|
||
|
||
try:
|
||
obj = json_load(row["attributes_json"])
|
||
if not isinstance(obj, dict):
|
||
raise ValueError("attributes_json 不是 JSON object")
|
||
parsed_by_feature[int(row["id"])] = obj
|
||
except Exception as e:
|
||
attributes_json_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"attributes_json_parse",
|
||
feature_id=row["id"],
|
||
cell_id=cell_id,
|
||
source_record_index=src_idx,
|
||
detail=f"{type(e).__name__}: {e}",
|
||
)
|
||
|
||
for row in feature_json_rows:
|
||
fid = int(row["id"])
|
||
obj = parsed_by_feature.get(fid)
|
||
if obj is None:
|
||
continue
|
||
|
||
related = obj.get("_cm93_related_indices")
|
||
if related is not None:
|
||
if not isinstance(related, list):
|
||
relation_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"related_indices_not_list",
|
||
feature_id=fid,
|
||
actual_type=type(related).__name__,
|
||
)
|
||
else:
|
||
cell_indices = indices_by_cell[int(row["cell_id"])]
|
||
for target in related:
|
||
try:
|
||
ti = int(target)
|
||
except Exception:
|
||
relation_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"related_index_not_int",
|
||
feature_id=fid,
|
||
target=repr(target),
|
||
)
|
||
continue
|
||
|
||
if ti not in cell_indices:
|
||
relation_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"related_index_missing_target",
|
||
feature_id=fid,
|
||
cell_id=row["cell_id"],
|
||
source_record_index=row["source_record_index"],
|
||
target_source_record_index=ti,
|
||
)
|
||
|
||
# Attribute row 的 attribute_name 应在对应 attributes_json 顶层出现。
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
a.feature_id,
|
||
a.attribute_name
|
||
FROM chart_feature_attribute a
|
||
JOIN chart_feature f ON f.id=a.feature_id
|
||
WHERE f.dataset_id=%s
|
||
""",
|
||
(args.dataset_id,),
|
||
)
|
||
|
||
for row in cur:
|
||
obj = parsed_by_feature.get(int(row["feature_id"]))
|
||
if obj is None:
|
||
continue
|
||
if row["attribute_name"] not in obj:
|
||
attributes_name_missing += 1
|
||
if attributes_name_missing <= 1000:
|
||
add_failure(
|
||
failures,
|
||
"attribute_name_missing_from_attributes_json",
|
||
feature_id=row["feature_id"],
|
||
attribute_name=row["attribute_name"],
|
||
)
|
||
|
||
report["json_relation_checks"] = {
|
||
"attributes_json_bad": attributes_json_bad,
|
||
"related_indices_bad": relation_bad,
|
||
"attribute_name_missing_from_attributes_json": attributes_name_missing,
|
||
}
|
||
|
||
if attributes_name_missing > 1000:
|
||
add_failure(
|
||
failures,
|
||
"attribute_name_missing_from_attributes_json_overflow",
|
||
detail=f"总数={attributes_name_missing},明细只记录前 1000 条",
|
||
)
|
||
|
||
print("[9/10] chart_raw_record 分块完整性 ...")
|
||
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
r.cell_id,
|
||
r.record_kind,
|
||
r.source_record_index,
|
||
OCTET_LENGTH(r.raw_blob) AS raw_bytes,
|
||
r.raw_metadata_json
|
||
FROM chart_raw_record r
|
||
JOIN chart_cell c ON c.id=r.cell_id
|
||
WHERE c.dataset_id=%s
|
||
ORDER BY r.cell_id,r.record_kind,r.source_record_index
|
||
""",
|
||
(args.dataset_id,),
|
||
)
|
||
|
||
raw_groups: dict[tuple[int, str], list[dict[str, Any]]] = defaultdict(list)
|
||
raw_json_bad = 0
|
||
raw_empty_blob = 0
|
||
|
||
for row in cur:
|
||
if int(row["raw_bytes"] or 0) <= 0:
|
||
raw_empty_blob += 1
|
||
add_failure(
|
||
failures,
|
||
"raw_record_empty_blob",
|
||
cell_id=row["cell_id"],
|
||
record_kind=row["record_kind"],
|
||
source_record_index=row["source_record_index"],
|
||
)
|
||
|
||
try:
|
||
meta = json_load(row["raw_metadata_json"])
|
||
if not isinstance(meta, dict):
|
||
raise ValueError("raw_metadata_json 不是 JSON object")
|
||
except Exception as e:
|
||
raw_json_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"raw_metadata_json_parse",
|
||
cell_id=row["cell_id"],
|
||
record_kind=row["record_kind"],
|
||
source_record_index=row["source_record_index"],
|
||
detail=f"{type(e).__name__}: {e}",
|
||
)
|
||
meta = None
|
||
|
||
raw_groups[
|
||
(int(row["cell_id"]), str(row["record_kind"]))
|
||
].append({
|
||
"index": int(row["source_record_index"]),
|
||
"raw_bytes": int(row["raw_bytes"] or 0),
|
||
"meta": meta,
|
||
})
|
||
|
||
raw_group_bad = 0
|
||
|
||
for (cell_id, kind), rows in raw_groups.items():
|
||
indices = [r["index"] for r in rows]
|
||
expected_indices = list(range(len(rows)))
|
||
|
||
if indices != expected_indices:
|
||
raw_group_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"raw_chunk_index_not_contiguous",
|
||
cell_id=cell_id,
|
||
record_kind=kind,
|
||
indices=indices[:50],
|
||
count=len(indices),
|
||
)
|
||
|
||
metas = [r["meta"] for r in rows if isinstance(r["meta"], dict)]
|
||
if len(metas) != len(rows):
|
||
continue
|
||
|
||
chunk_counts = {m.get("chunk_count") for m in metas}
|
||
total_bytes_set = {m.get("total_bytes") for m in metas}
|
||
chunk_indices_meta = [m.get("chunk_index") for m in metas]
|
||
actual_bytes = sum(r["raw_bytes"] for r in rows)
|
||
|
||
if chunk_counts != {len(rows)}:
|
||
raw_group_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"raw_chunk_count_mismatch",
|
||
cell_id=cell_id,
|
||
record_kind=kind,
|
||
metadata_chunk_counts=list(chunk_counts),
|
||
actual_chunk_count=len(rows),
|
||
)
|
||
|
||
if chunk_indices_meta != expected_indices:
|
||
raw_group_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"raw_metadata_chunk_index_mismatch",
|
||
cell_id=cell_id,
|
||
record_kind=kind,
|
||
metadata_indices=chunk_indices_meta[:50],
|
||
)
|
||
|
||
if len(total_bytes_set) != 1:
|
||
raw_group_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"raw_total_bytes_inconsistent",
|
||
cell_id=cell_id,
|
||
record_kind=kind,
|
||
values=list(total_bytes_set),
|
||
)
|
||
else:
|
||
expected_total = next(iter(total_bytes_set))
|
||
try:
|
||
expected_total = int(expected_total)
|
||
except Exception:
|
||
expected_total = -1
|
||
|
||
if expected_total != actual_bytes:
|
||
raw_group_bad += 1
|
||
add_failure(
|
||
failures,
|
||
"raw_total_bytes_mismatch",
|
||
cell_id=cell_id,
|
||
record_kind=kind,
|
||
expected=expected_total,
|
||
actual=actual_bytes,
|
||
)
|
||
|
||
# 每个 Cell 至少应有 HEADER 和 FEATURE 原始表。
|
||
raw_required_missing = int(scalar(
|
||
cur,
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT c.id
|
||
FROM chart_cell c
|
||
LEFT JOIN chart_raw_record h
|
||
ON h.cell_id=c.id AND h.record_kind='HEADER'
|
||
LEFT JOIN chart_raw_record f
|
||
ON f.cell_id=c.id AND f.record_kind='FEATURE'
|
||
WHERE c.dataset_id=%s
|
||
GROUP BY c.id
|
||
HAVING COUNT(DISTINCT h.record_kind)=0
|
||
OR COUNT(DISTINCT f.record_kind)=0
|
||
) x
|
||
""",
|
||
(args.dataset_id,),
|
||
) or 0)
|
||
|
||
report["raw_checks"] = {
|
||
"raw_metadata_json_bad": raw_json_bad,
|
||
"raw_empty_blob": raw_empty_blob,
|
||
"raw_group_bad": raw_group_bad,
|
||
"required_header_or_feature_missing_cells": raw_required_missing,
|
||
"raw_group_count": len(raw_groups),
|
||
}
|
||
|
||
if raw_required_missing:
|
||
add_failure(
|
||
failures,
|
||
"raw_required_kind_missing",
|
||
expected=0,
|
||
actual=raw_required_missing,
|
||
)
|
||
|
||
print("[10/10] 可选源文件 SHA256 + 最终判定 ...")
|
||
|
||
source_checks = {
|
||
"enabled": source_root is not None,
|
||
"files_checked": 0,
|
||
"files_missing": 0,
|
||
"size_mismatch": 0,
|
||
"sha256_mismatch": 0,
|
||
}
|
||
|
||
if source_root is not None:
|
||
if not source_root.is_dir():
|
||
add_failure(
|
||
failures,
|
||
"source_root_not_found",
|
||
path=str(source_root),
|
||
)
|
||
else:
|
||
cells = fetchall(
|
||
cur,
|
||
"""
|
||
SELECT
|
||
id,
|
||
source_relative_path,
|
||
source_size,
|
||
source_sha256
|
||
FROM chart_cell
|
||
WHERE dataset_id=%s
|
||
ORDER BY id
|
||
""",
|
||
(args.dataset_id,),
|
||
)
|
||
|
||
for cell in cells:
|
||
rel = str(cell["source_relative_path"]).replace("\\", "/")
|
||
path = source_root / Path(rel)
|
||
source_checks["files_checked"] += 1
|
||
|
||
if not path.is_file():
|
||
source_checks["files_missing"] += 1
|
||
add_failure(
|
||
failures,
|
||
"source_file_missing",
|
||
cell_id=cell["id"],
|
||
path=str(path),
|
||
)
|
||
continue
|
||
|
||
actual_size = path.stat().st_size
|
||
expected_size = int(cell["source_size"])
|
||
|
||
if actual_size != expected_size:
|
||
source_checks["size_mismatch"] += 1
|
||
add_failure(
|
||
failures,
|
||
"source_size_mismatch",
|
||
cell_id=cell["id"],
|
||
path=str(path),
|
||
expected=expected_size,
|
||
actual=actual_size,
|
||
)
|
||
|
||
expected_hash = str(cell["source_sha256"] or "").lower()
|
||
actual_hash = file_sha256(path).lower()
|
||
|
||
if expected_hash != actual_hash:
|
||
source_checks["sha256_mismatch"] += 1
|
||
add_failure(
|
||
failures,
|
||
"source_sha256_mismatch",
|
||
cell_id=cell["id"],
|
||
path=str(path),
|
||
expected=expected_hash,
|
||
actual=actual_hash,
|
||
)
|
||
|
||
report["source_checks"] = source_checks
|
||
|
||
conn.rollback()
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
report["warnings"] = warnings
|
||
report["failure_count"] = len(failures)
|
||
report["overall"] = "PASS" if not failures else "FAIL"
|
||
|
||
write_csv(outdir / "失败明细_v1.csv", failures)
|
||
write_csv(outdir / "Cell计数不一致_v1.csv", cell_mismatch_rows)
|
||
|
||
(outdir / "最终验收报告_v1.json").write_text(
|
||
json.dumps(
|
||
report,
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
default=str,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
c = report["counts"]
|
||
lines = [
|
||
"CM93 -> MySQL 非 Geometry 数据完整性全库最终验收 v1",
|
||
"=" * 82,
|
||
f"Dataset ID : {args.dataset_id}",
|
||
"",
|
||
f"Cells : {c['cells']}",
|
||
f"Features : {c['features']}",
|
||
f"Attributes : {c['attributes']}",
|
||
f"Soundings : {c['soundings']}",
|
||
f"Coverages : {c['coverages']}",
|
||
f"Raw records : {c['raw_records']}",
|
||
"",
|
||
f"Cell count mismatch : {report['cell_count_mismatch']}",
|
||
f"Feature check failures : {sum(report['feature_checks'].values())}",
|
||
f"Dictionary failures : {sum(report['dictionary_checks'].values())}",
|
||
f"Attribute failures : {sum(report['attribute_checks'].values())}",
|
||
f"Coverage failures : {sum(report['coverage_checks'].values())}",
|
||
f"Sounding failures : {sum(report['sounding_checks'].values())}",
|
||
f"JSON/relation failures : {sum(report['json_relation_checks'].values())}",
|
||
f"Raw failures : "
|
||
f"{report['raw_checks']['raw_metadata_json_bad'] + report['raw_checks']['raw_empty_blob'] + report['raw_checks']['raw_group_bad'] + report['raw_checks']['required_header_or_feature_missing_cells']}",
|
||
"",
|
||
]
|
||
|
||
if source_root is not None:
|
||
s = report["source_checks"]
|
||
lines += [
|
||
f"Source files checked : {s['files_checked']}",
|
||
f"Source files missing : {s['files_missing']}",
|
||
f"Source size mismatch : {s['size_mismatch']}",
|
||
f"Source SHA256 mismatch : {s['sha256_mismatch']}",
|
||
"",
|
||
]
|
||
|
||
lines += [
|
||
f"Failure count : {report['failure_count']}",
|
||
f"OVERALL: {report['overall']}",
|
||
]
|
||
|
||
summary = "\n".join(lines)
|
||
|
||
(outdir / "最终验收摘要_v1.txt").write_text(
|
||
summary,
|
||
encoding="utf-8",
|
||
)
|
||
|
||
zip_path = outdir.with_suffix(".zip")
|
||
with zipfile.ZipFile(
|
||
zip_path,
|
||
"w",
|
||
compression=zipfile.ZIP_DEFLATED,
|
||
) as z:
|
||
for p in sorted(outdir.rglob("*")):
|
||
if p.is_file():
|
||
z.write(p, p.relative_to(outdir.parent))
|
||
|
||
print("")
|
||
print(summary)
|
||
print("")
|
||
print(f"报告目录: {outdir}")
|
||
print(f"报告 ZIP : {zip_path}")
|
||
print("把 ZIP 上传给 ChatGPT。")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|