chengjie il y a 1 semaine
Parent
commit
490f98bb5b

+ 1 - 1
src/api/mps/mpsCommonController.js

@@ -382,7 +382,7 @@ export async function GetMPSNotice(ctx) {
382 382
         result.IsShow = true;
383 383
         result.Title = "更新通知";
384 384
         const content = [
385
-            "2026年上海中考招生计划目前完成自主招生、名额到区、名额到校、1-15志愿所的计划统计工作。若有问题,欢迎反馈,我们会及时做出修正。请将信息分享给身边的家长朋友们。非常谢谢您的支持!",
385
+            "2026年上海中考招生目前完成名额到区、名额到校的分数线输入工作,1-15志愿分数线还需要耐心等待。若有问题,欢迎反馈,我们会及时做出修正。请将信息分享给身边的家长朋友们。非常谢谢您的支持!",
386 386
             //"2026年上海中考招生计划已经全部整理完毕,包括各区各校的自主招生、名额到区、名额到校、1-15志愿等数据。请将信息分享给身边的家长朋友们。非常谢谢您的支持!",
387 387
             //"今年夏天要读的书,必须是《录取通知书》,祝孩子们中考考出最优成绩!",
388 388
             //"最新的上海16区统一招生1-15志愿各校计划整理完毕,欢迎查看!"

+ 251 - 0
秒过分数线数据导入/audit_mps_score_scores_2026_sample.py

@@ -0,0 +1,251 @@
1
+import argparse
2
+import json
3
+import math
4
+import random
5
+from collections import defaultdict
6
+from decimal import Decimal
7
+
8
+import pymysql
9
+
10
+import import_mps_score_all_district_scores_2026 as importer
11
+
12
+
13
+SAMPLE_RATE = Decimal("0.30")
14
+RANDOM_SEED = 20260720
15
+REPORT_FILE = "mps_score_scores_2026_sample_audit.json"
16
+
17
+
18
+def decimal_to_json(value):
19
+    if isinstance(value, Decimal):
20
+        return str(value)
21
+    return value
22
+
23
+
24
+def fetch_actual_scores(cursor, district_id, score_type):
25
+    cursor.execute(
26
+        """
27
+        SELECT ID, DistrictID, ScoreType, SchoolOfGraduation, SchoolFullNameJunior,
28
+               SchoolTarget, SchoolFullName, PlanNum,
29
+               ScoreTotal, Score1, Score2, Score3, Score4, ScoreTotalDifferenceValue
30
+        FROM MPS_Score
31
+        WHERE ScoreYear = %s
32
+          AND ScoreType = %s
33
+          AND DistrictID = %s
34
+        """,
35
+        (importer.YEAR, score_type, district_id),
36
+    )
37
+    return {row["ID"]: row for row in cursor.fetchall()}
38
+
39
+
40
+def values_equal(actual, expected):
41
+    return actual == expected
42
+
43
+
44
+def check_update(actual_by_id, update):
45
+    actual = actual_by_id.get(update["ID"])
46
+    if not actual:
47
+        return [{"column": "ID", "actual": None, "expected": update["ID"]}]
48
+
49
+    mismatches = []
50
+    for column in importer.SCORE_COLUMNS:
51
+        if not values_equal(actual[column], update[column]):
52
+            mismatches.append(
53
+                {
54
+                    "column": column,
55
+                    "actual": actual[column],
56
+                    "expected": update[column],
57
+                }
58
+            )
59
+
60
+    total_with_eval = update["ScoreTotal"] + Decimal("50")
61
+    if total_with_eval != update["total_with_eval"]:
62
+        mismatches.append(
63
+            {
64
+                "column": "ScoreTotal_plus_50",
65
+                "actual": total_with_eval,
66
+                "expected": update["total_with_eval"],
67
+            }
68
+        )
69
+
70
+    return mismatches
71
+
72
+
73
+def sample_updates(district_id, score_type, updates):
74
+    sample_size = math.ceil(len(updates) * float(SAMPLE_RATE))
75
+    if updates and sample_size < 1:
76
+        sample_size = 1
77
+    rng = random.Random(f"{RANDOM_SEED}:{district_id}:{score_type}")
78
+    return rng.sample(updates, sample_size)
79
+
80
+
81
+def full_recheck(actual_by_id, updates):
82
+    errors = []
83
+    for update in updates:
84
+        mismatches = check_update(actual_by_id, update)
85
+        if mismatches:
86
+            errors.append(
87
+                {
88
+                    "ID": update["ID"],
89
+                    "SchoolFullNameJunior": update["SchoolFullNameJunior"],
90
+                    "SchoolFullName": update["SchoolFullName"],
91
+                    "page": update["page"],
92
+                    "mismatches": mismatches,
93
+                }
94
+            )
95
+    return errors
96
+
97
+
98
+def audit_one(cursor, names_by_school_id, district_id, score_type):
99
+    data = importer.collect_one(cursor, names_by_school_id, district_id, score_type)
100
+    actual_by_id = fetch_actual_scores(cursor, district_id, score_type)
101
+    sampled = sample_updates(district_id, score_type, data["updates"])
102
+
103
+    sample_errors = []
104
+    for update in sampled:
105
+        mismatches = check_update(actual_by_id, update)
106
+        if mismatches:
107
+            sample_errors.append(
108
+                {
109
+                    "ID": update["ID"],
110
+                    "SchoolFullNameJunior": update["SchoolFullNameJunior"],
111
+                    "SchoolFullName": update["SchoolFullName"],
112
+                    "page": update["page"],
113
+                    "mismatches": mismatches,
114
+                }
115
+            )
116
+
117
+    full_errors = full_recheck(actual_by_id, data["updates"]) if sample_errors else []
118
+
119
+    sample_preview = []
120
+    for update in sampled[:10]:
121
+        sample_preview.append(
122
+            {
123
+                "ID": update["ID"],
124
+                "SchoolFullNameJunior": update["SchoolFullNameJunior"],
125
+                "SchoolFullName": update["SchoolFullName"],
126
+                "pdf_total": update["total_with_eval"],
127
+                "stored_score_total": update["ScoreTotal"],
128
+                "page": update["page"],
129
+            }
130
+        )
131
+
132
+    return {
133
+        "district_id": district_id,
134
+        "district": importer.DISTRICTS[district_id],
135
+        "score_type": score_type,
136
+        "pdf_parsed_rows": len(data["parsed_rows"]),
137
+        "db_rows": len(data["db_rows"]),
138
+        "auditable_imported_rows": len(data["updates"]),
139
+        "sample_rate": str(SAMPLE_RATE),
140
+        "sample_size": len(sampled),
141
+        "sample_errors": sample_errors,
142
+        "full_recheck_triggered": bool(sample_errors),
143
+        "full_recheck_errors": full_errors,
144
+        "not_audited_pending_rows": len(data["missing_db"]),
145
+        "pending_match_problems": len(data["problems"]),
146
+        "pending_duplicate_target_rows": len(data["duplicates"]),
147
+        "sample_preview": sample_preview,
148
+    }
149
+
150
+
151
+def duplicate_key_counts(cursor):
152
+    results = {}
153
+    checks = {
154
+        "名额到区": "DistrictID, SchoolTarget",
155
+        "名额到校": "DistrictID, SchoolOfGraduation, SchoolTarget",
156
+    }
157
+    for score_type, group_expr in checks.items():
158
+        cursor.execute(
159
+            f"""
160
+            SELECT COUNT(*) AS duplicate_groups
161
+            FROM (
162
+              SELECT {group_expr}, COUNT(*) c
163
+              FROM MPS_Score
164
+              WHERE ScoreYear = %s AND ScoreType = %s
165
+              GROUP BY {group_expr}
166
+              HAVING c > 1
167
+            ) t
168
+            """,
169
+            (importer.YEAR, score_type),
170
+        )
171
+        results[score_type] = cursor.fetchone()["duplicate_groups"]
172
+    return results
173
+
174
+
175
+def score_summary(cursor):
176
+    summary = defaultdict(list)
177
+    for score_type in importer.SCORE_TYPES:
178
+        cursor.execute(
179
+            """
180
+            SELECT DistrictID,
181
+                   COUNT(*) AS db_rows,
182
+                   SUM(CASE WHEN COALESCE(ScoreTotal, 0) <> 0 THEN 1 ELSE 0 END) AS scored_rows,
183
+                   SUM(CASE WHEN COALESCE(ScoreTotal, 0) = 0 THEN 1 ELSE 0 END) AS unscored_rows,
184
+                   MIN(NULLIF(ScoreTotal, 0)) AS min_score,
185
+                   MAX(NULLIF(ScoreTotal, 0)) AS max_score
186
+            FROM MPS_Score
187
+            WHERE ScoreYear = %s AND ScoreType = %s
188
+            GROUP BY DistrictID
189
+            ORDER BY DistrictID
190
+            """,
191
+            (importer.YEAR, score_type),
192
+        )
193
+        summary[score_type] = cursor.fetchall()
194
+    return summary
195
+
196
+
197
+def parse_args():
198
+    parser = argparse.ArgumentParser()
199
+    parser.add_argument("--district", type=int, action="append", choices=sorted(importer.DISTRICTS))
200
+    parser.add_argument("--score-type", choices=importer.SCORE_TYPES, action="append")
201
+    return parser.parse_args()
202
+
203
+
204
+def main():
205
+    args = parse_args()
206
+    districts = args.district or sorted(importer.DISTRICTS)
207
+    score_types = args.score_type or importer.SCORE_TYPES
208
+
209
+    conn = pymysql.connect(**importer.DB_CONFIG)
210
+    try:
211
+        with conn.cursor() as cursor:
212
+            names_by_school_id = importer.load_school_names(cursor)
213
+            audits = []
214
+            for district_id in districts:
215
+                for score_type in score_types:
216
+                    result = audit_one(cursor, names_by_school_id, district_id, score_type)
217
+                    audits.append(result)
218
+                    print(
219
+                        result["district_id"],
220
+                        result["district"],
221
+                        result["score_type"],
222
+                        "auditable",
223
+                        result["auditable_imported_rows"],
224
+                        "sample",
225
+                        result["sample_size"],
226
+                        "sample_errors",
227
+                        len(result["sample_errors"]),
228
+                        "full_recheck",
229
+                        result["full_recheck_triggered"],
230
+                        "full_errors",
231
+                        len(result["full_recheck_errors"]),
232
+                        "pending",
233
+                        result["not_audited_pending_rows"],
234
+                    )
235
+
236
+            report = {
237
+                "sample_rate": str(SAMPLE_RATE),
238
+                "random_seed": RANDOM_SEED,
239
+                "audits": audits,
240
+                "duplicate_key_counts": duplicate_key_counts(cursor),
241
+                "score_summary": score_summary(cursor),
242
+            }
243
+            with open(REPORT_FILE, "w", encoding="utf-8") as handle:
244
+                json.dump(report, handle, ensure_ascii=False, indent=2, default=decimal_to_json)
245
+            print("report", REPORT_FILE)
246
+    finally:
247
+        conn.close()
248
+
249
+
250
+if __name__ == "__main__":
251
+    main()

+ 652 - 0
秒过分数线数据导入/import_mps_score_all_district_scores_2026.py

@@ -0,0 +1,652 @@
1
+import argparse
2
+import json
3
+import os
4
+import re
5
+from collections import defaultdict
6
+from decimal import Decimal
7
+
8
+import pdfplumber
9
+import pymysql
10
+
11
+
12
+DB_CONFIG = {
13
+    "host": "589ae8e08493d.sh.cdb.myqcloud.com",
14
+    "port": 8124,
15
+    "user": "cdb_outerroot",
16
+    "password": "kylx!@#!QAZ@WSX",
17
+    "database": "kylx365_db",
18
+    "charset": "utf8mb4",
19
+    "connect_timeout": 10,
20
+    "read_timeout": 30,
21
+    "write_timeout": 30,
22
+    "cursorclass": pymysql.cursors.DictCursor,
23
+}
24
+
25
+YEAR = "2026"
26
+PREVIOUS_YEAR = "2025"
27
+BASE_DIR = "/Volumes/程杰外接SD盘/上海中考招生计划/2026/分数线"
28
+BACKUP_FILE = "mps_score_scores_2026_backup.json"
29
+REPORT_FILE = "mps_score_scores_2026_import_report.json"
30
+SCORE_TYPES = ("名额到区", "名额到校")
31
+
32
+DISTRICTS = {
33
+    1: "黄浦区",
34
+    2: "徐汇区",
35
+    3: "长宁区",
36
+    4: "静安区",
37
+    5: "普陀区",
38
+    6: "虹口区",
39
+    7: "杨浦区",
40
+    8: "闵行区",
41
+    9: "宝山区",
42
+    10: "嘉定区",
43
+    11: "浦东新区",
44
+    12: "金山区",
45
+    13: "松江区",
46
+    14: "青浦区",
47
+    15: "奉贤区",
48
+    16: "崇明区",
49
+}
50
+
51
+SCORE_COLUMNS = [
52
+    "ScoreTotal",
53
+    "Score1",
54
+    "Score2",
55
+    "Score3",
56
+    "Score4",
57
+    "ScoreTotalDifferenceValue",
58
+]
59
+
60
+
61
+def compact(value):
62
+    return re.sub(r"\s+", "", str(value or "").replace("\u3000", ""))
63
+
64
+
65
+def decimal_to_json(value):
66
+    if isinstance(value, Decimal):
67
+        return str(value)
68
+    return value
69
+
70
+
71
+def parse_decimal(value):
72
+    match = re.search(r"\d+(?:\.\d+)?", compact(value))
73
+    return Decimal(match.group(0)) if match else None
74
+
75
+
76
+def is_subsequence(needle, haystack):
77
+    iterator = iter(haystack)
78
+    return all(char in iterator for char in needle)
79
+
80
+
81
+def split_aliases(value):
82
+    aliases = []
83
+    for part in re.split(r"[,,;;、/|]", str(value or "")):
84
+        part = compact(part)
85
+        if part:
86
+            aliases.append(part)
87
+    return aliases
88
+
89
+
90
+def name_variants(name):
91
+    value = compact(name)
92
+    variants = []
93
+
94
+    def add(item, penalty):
95
+        item = compact(item)
96
+        if item and item not in [variant for variant, _penalty in variants]:
97
+            variants.append((item, penalty))
98
+
99
+    add(value, 0)
100
+    add(value.replace("上海市", "上海"), 80)
101
+    if value.startswith("上海市"):
102
+        add(value[3:], 120)
103
+    if value.startswith("上海"):
104
+        add(value[2:], 120)
105
+
106
+    base_items = list(variants)
107
+    for item, penalty in base_items:
108
+        without_brackets = re.sub(r"[((].*?[))]", "", item)
109
+        if without_brackets != item:
110
+            add(without_brackets, penalty + 500)
111
+        for bracket_part in re.findall(r"[((]([^))]+)[))]", item):
112
+            add(bracket_part, penalty + 300)
113
+    return variants
114
+
115
+
116
+def score_variant(candidate, raw):
117
+    if candidate == raw:
118
+        return 6000 + len(candidate)
119
+    if candidate in raw:
120
+        return 5000 + len(candidate)
121
+    if raw in candidate:
122
+        return 4500 + len(raw)
123
+    if is_subsequence(candidate, raw):
124
+        return 3000 + len(candidate)
125
+    if is_subsequence(raw, candidate):
126
+        return 2500 + len(raw)
127
+    return 0
128
+
129
+
130
+def candidate_score(raw_value, names):
131
+    raw = compact(raw_value)
132
+    scores = []
133
+    for name in names:
134
+        for variant, penalty in name_variants(name):
135
+            score = score_variant(variant, raw) - penalty
136
+            if score > 0:
137
+                scores.append((score, name, variant))
138
+    return max(scores, default=(0, "", ""), key=lambda item: item[0])
139
+
140
+
141
+def match_name(raw_value, candidates):
142
+    scored = []
143
+    for key, display_name, names in candidates:
144
+        best_score, matched_name, matched_variant = candidate_score(raw_value, names)
145
+        if best_score:
146
+            scored.append((best_score, key, display_name, matched_name, matched_variant))
147
+    scored.sort(reverse=True, key=lambda item: item[0])
148
+    if not scored:
149
+        return None, 0, []
150
+    best = scored[0]
151
+    ties = [item for item in scored if item[0] == best[0] and item[1] != best[1]]
152
+    if ties:
153
+        return None, best[0], scored[:5]
154
+    return best[1], best[0], scored[:5]
155
+
156
+
157
+def parse_pdf(score_type, path):
158
+    rows = []
159
+    with pdfplumber.open(path) as pdf:
160
+        for page_number, page in enumerate(pdf.pages, 1):
161
+            for table in page.extract_tables():
162
+                for row_index, row in enumerate(table):
163
+                    if not row or len(row) < 9:
164
+                        continue
165
+                    if any(
166
+                        token in compact(cell)
167
+                        for cell in row[:3]
168
+                        for token in ("录取最低分", "招生学校", "初中学校", "区名称")
169
+                    ):
170
+                        continue
171
+
172
+                    total_with_eval = parse_decimal(row[2])
173
+                    eval_score = parse_decimal(row[4])
174
+                    score1 = parse_decimal(row[5])
175
+                    score2 = parse_decimal(row[6])
176
+                    score3 = parse_decimal(row[7])
177
+                    score4 = parse_decimal(row[8])
178
+                    if None in (total_with_eval, eval_score, score1, score2, score3, score4):
179
+                        continue
180
+                    if eval_score != Decimal("50"):
181
+                        raise ValueError(
182
+                            f"{score_type} page {page_number} row {row_index} "
183
+                            f"has unexpected 综合素质评价 score: {eval_score}"
184
+                        )
185
+
186
+                    parsed = {
187
+                        "page": page_number,
188
+                        "row_index": row_index,
189
+                        "raw": row,
190
+                        "total_with_eval": total_with_eval,
191
+                        "ScoreTotal": total_with_eval - Decimal("50"),
192
+                        "Score1": score1,
193
+                        "Score2": score2,
194
+                        "Score3": score3,
195
+                        "Score4": score4,
196
+                    }
197
+                    if score_type == "名额到区":
198
+                        parsed["high_raw"] = row[1]
199
+                    else:
200
+                        parsed["junior_raw"] = row[0]
201
+                        parsed["high_raw"] = row[1]
202
+                    rows.append(parsed)
203
+    return rows
204
+
205
+
206
+def load_school_names(cursor):
207
+    cursor.execute(
208
+        """
209
+        SELECT ID, SchoolFullName, SchoolShortName, SchoolOtherName
210
+        FROM MPS_School
211
+        WHERE SchoolType1 IN ('高中', '初中')
212
+        """
213
+    )
214
+    names_by_id = defaultdict(list)
215
+    for row in cursor.fetchall():
216
+        for field in ("SchoolFullName", "SchoolShortName"):
217
+            value = compact(row[field])
218
+            if value and value not in names_by_id[int(row["ID"])]:
219
+                names_by_id[int(row["ID"])].append(value)
220
+        for alias in split_aliases(row["SchoolOtherName"]):
221
+            if alias not in names_by_id[int(row["ID"])]:
222
+                names_by_id[int(row["ID"])].append(alias)
223
+    return names_by_id
224
+
225
+
226
+def fetch_existing(cursor, score_type, district_id, year):
227
+    cursor.execute(
228
+        """
229
+        SELECT ID, ScoreYear, ScoreType, DistrictID, SchoolOfGraduation,
230
+               SchoolFullNameJunior, SchoolTarget, SchoolFullName, PlanNum,
231
+               ScoreTotal, Score1, Score2, Score3, Score4,
232
+               ScoreTotalDifferenceValue
233
+        FROM MPS_Score
234
+        WHERE ScoreYear = %s
235
+          AND ScoreType = %s
236
+          AND DistrictID = %s
237
+        ORDER BY ID
238
+        """,
239
+        (year, score_type, district_id),
240
+    )
241
+    return cursor.fetchall()
242
+
243
+
244
+def business_key(row, score_type):
245
+    if score_type == "名额到校":
246
+        return (int(row["SchoolOfGraduation"]), str(row["SchoolTarget"]))
247
+    return (str(row["SchoolTarget"]),)
248
+
249
+
250
+def previous_scores_by_key(cursor, score_type, district_id):
251
+    rows = fetch_existing(cursor, score_type, district_id, PREVIOUS_YEAR)
252
+    return {business_key(row, score_type): row["ScoreTotal"] for row in rows}
253
+
254
+
255
+def names_for_school(names_by_school_id, school_id, fallback):
256
+    names = []
257
+    fallback = compact(fallback)
258
+    if fallback:
259
+        names.append(fallback)
260
+    for name in names_by_school_id.get(int(school_id), []):
261
+        if name not in names:
262
+            names.append(name)
263
+    return names
264
+
265
+
266
+def attach_matches(score_type, parsed_rows, db_rows, names_by_school_id):
267
+    problems = []
268
+    matched = []
269
+    used_ids = defaultdict(list)
270
+
271
+    if score_type == "名额到区":
272
+        candidates = [
273
+            (
274
+                row["ID"],
275
+                row["SchoolFullName"],
276
+                names_for_school(names_by_school_id, row["SchoolTarget"], row["SchoolFullName"]),
277
+            )
278
+            for row in db_rows
279
+        ]
280
+        row_by_id = {row["ID"]: row for row in db_rows}
281
+        for parsed in parsed_rows:
282
+            db_id, match_score, top = match_name(parsed["high_raw"], candidates)
283
+            if not db_id or match_score < 2500:
284
+                problems.append({"type": "high_match", "row": parsed, "top": top})
285
+                continue
286
+            parsed["db_row"] = row_by_id[db_id]
287
+            parsed["match_score"] = match_score
288
+            matched.append(parsed)
289
+            used_ids[db_id].append(parsed)
290
+    else:
291
+        junior_by_id = {}
292
+        rows_by_junior = defaultdict(list)
293
+        row_by_id = {row["ID"]: row for row in db_rows}
294
+        for row in db_rows:
295
+            junior_id = int(row["SchoolOfGraduation"])
296
+            junior_by_id[junior_id] = (
297
+                junior_id,
298
+                row["SchoolFullNameJunior"],
299
+                names_for_school(names_by_school_id, junior_id, row["SchoolFullNameJunior"]),
300
+            )
301
+            rows_by_junior[junior_id].append(
302
+                (
303
+                    row["ID"],
304
+                    row["SchoolFullName"],
305
+                    names_for_school(names_by_school_id, row["SchoolTarget"], row["SchoolFullName"]),
306
+                )
307
+            )
308
+        junior_candidates = sorted(junior_by_id.values(), key=lambda item: item[0])
309
+
310
+        for parsed in parsed_rows:
311
+            junior_id, junior_score, junior_top = match_name(parsed["junior_raw"], junior_candidates)
312
+            if not junior_id or junior_score < 2500:
313
+                problems.append({"type": "junior_match", "row": parsed, "top": junior_top})
314
+                continue
315
+            db_id, high_score, high_top = match_name(parsed["high_raw"], rows_by_junior[junior_id])
316
+            if not db_id or high_score < 2500:
317
+                problems.append({"type": "high_match", "row": parsed, "junior_id": junior_id, "top": high_top})
318
+                continue
319
+            parsed["db_row"] = row_by_id[db_id]
320
+            parsed["junior_match_score"] = junior_score
321
+            parsed["high_match_score"] = high_score
322
+            matched.append(parsed)
323
+            used_ids[db_id].append(parsed)
324
+
325
+    duplicates = {db_id: rows for db_id, rows in used_ids.items() if len(rows) > 1}
326
+    if duplicates:
327
+        duplicate_ids = set(duplicates)
328
+        matched = [row for row in matched if row["db_row"]["ID"] not in duplicate_ids]
329
+    used_after_filter = {row["db_row"]["ID"] for row in matched}
330
+    missing_db = [row for row in db_rows if row["ID"] not in used_after_filter]
331
+    return matched, problems, duplicates, missing_db
332
+
333
+
334
+def build_updates(score_type, matched_rows, previous_scores):
335
+    updates = []
336
+    for parsed in matched_rows:
337
+        db_row = parsed["db_row"]
338
+        key = business_key(db_row, score_type)
339
+        previous = previous_scores.get(key)
340
+        diff = None if previous is None else parsed["ScoreTotal"] - previous
341
+        updates.append(
342
+            {
343
+                "ID": db_row["ID"],
344
+                "DistrictID": db_row["DistrictID"],
345
+                "SchoolOfGraduation": db_row["SchoolOfGraduation"],
346
+                "SchoolFullNameJunior": db_row["SchoolFullNameJunior"],
347
+                "SchoolTarget": db_row["SchoolTarget"],
348
+                "SchoolFullName": db_row["SchoolFullName"],
349
+                "PlanNum": db_row["PlanNum"],
350
+                "ScoreTotal": parsed["ScoreTotal"],
351
+                "Score1": parsed["Score1"],
352
+                "Score2": parsed["Score2"],
353
+                "Score3": parsed["Score3"],
354
+                "Score4": parsed["Score4"],
355
+                "ScoreTotalDifferenceValue": diff,
356
+                "total_with_eval": parsed["total_with_eval"],
357
+                "page": parsed["page"],
358
+            }
359
+        )
360
+    return updates
361
+
362
+
363
+def compare_existing_scores(db_row, update):
364
+    for column in SCORE_COLUMNS:
365
+        old_value = db_row[column]
366
+        new_value = update[column]
367
+        if old_value in (None, Decimal("0.00")):
368
+            continue
369
+        if new_value != old_value:
370
+            return False
371
+    return True
372
+
373
+
374
+def pdf_path(score_type, district_id):
375
+    return os.path.join(BASE_DIR, score_type, f"{district_id}.pdf")
376
+
377
+
378
+def collect_one(cursor, names_by_school_id, district_id, score_type):
379
+    parsed_rows = parse_pdf(score_type, pdf_path(score_type, district_id))
380
+    db_rows = fetch_existing(cursor, score_type, district_id, YEAR)
381
+    previous_scores = previous_scores_by_key(cursor, score_type, district_id)
382
+    matched, problems, duplicates, missing_db = attach_matches(
383
+        score_type, parsed_rows, db_rows, names_by_school_id
384
+    )
385
+    updates = build_updates(score_type, matched, previous_scores)
386
+    return {
387
+        "district_id": district_id,
388
+        "district_name": DISTRICTS[district_id],
389
+        "score_type": score_type,
390
+        "parsed_rows": parsed_rows,
391
+        "db_rows": db_rows,
392
+        "matched": matched,
393
+        "problems": problems,
394
+        "duplicates": duplicates,
395
+        "missing_db": missing_db,
396
+        "updates": updates,
397
+    }
398
+
399
+
400
+def collect_all(cursor, districts, score_types):
401
+    names_by_school_id = load_school_names(cursor)
402
+    collected = {}
403
+    for district_id in districts:
404
+        for score_type in score_types:
405
+            collected[(district_id, score_type)] = collect_one(
406
+                cursor, names_by_school_id, district_id, score_type
407
+            )
408
+    return collected
409
+
410
+
411
+def summarize(data):
412
+    scores = [row["ScoreTotal"] for row in data["updates"]]
413
+    return {
414
+        "district_id": data["district_id"],
415
+        "district": data["district_name"],
416
+        "score_type": data["score_type"],
417
+        "parsed": len(data["parsed_rows"]),
418
+        "db": len(data["db_rows"]),
419
+        "matched_for_update": len(data["updates"]),
420
+        "problems": len(data["problems"]),
421
+        "duplicate_target_rows": len(data["duplicates"]),
422
+        "missing_from_pdf_or_skipped": len(data["missing_db"]),
423
+        "min_score_after_minus_50": min(scores) if scores else None,
424
+        "max_score_after_minus_50": max(scores) if scores else None,
425
+    }
426
+
427
+
428
+def printable_top(top):
429
+    return [
430
+        {
431
+            "score": item[0],
432
+            "id": item[1],
433
+            "name": item[2],
434
+            "matched_name": item[3],
435
+            "matched_variant": item[4],
436
+        }
437
+        for item in top
438
+    ]
439
+
440
+
441
+def problem_to_json(problem):
442
+    row = problem["row"]
443
+    return {
444
+        "type": problem["type"],
445
+        "page": row["page"],
446
+        "row_index": row["row_index"],
447
+        "raw": row["raw"],
448
+        "top": printable_top(problem.get("top", [])),
449
+    }
450
+
451
+
452
+def duplicate_to_json(db_id, rows):
453
+    return {
454
+        "db_id": db_id,
455
+        "rows": [
456
+            {
457
+                "page": row["page"],
458
+                "row_index": row["row_index"],
459
+                "raw": row["raw"],
460
+            }
461
+            for row in rows
462
+        ],
463
+    }
464
+
465
+
466
+def report_data(collected):
467
+    report = []
468
+    for key in sorted(collected):
469
+        data = collected[key]
470
+        report.append(
471
+            {
472
+                "summary": summarize(data),
473
+                "problems": [problem_to_json(problem) for problem in data["problems"]],
474
+                "duplicates": [
475
+                    duplicate_to_json(db_id, rows) for db_id, rows in data["duplicates"].items()
476
+                ],
477
+                "missing_from_pdf_or_skipped": [
478
+                    {
479
+                        "ID": row["ID"],
480
+                        "SchoolOfGraduation": row["SchoolOfGraduation"],
481
+                        "SchoolFullNameJunior": row["SchoolFullNameJunior"],
482
+                        "SchoolTarget": row["SchoolTarget"],
483
+                        "SchoolFullName": row["SchoolFullName"],
484
+                        "PlanNum": row["PlanNum"],
485
+                    }
486
+                    for row in data["missing_db"]
487
+                ],
488
+            }
489
+        )
490
+    return report
491
+
492
+
493
+def write_report(collected):
494
+    with open(REPORT_FILE, "w", encoding="utf-8") as handle:
495
+        json.dump(report_data(collected), handle, ensure_ascii=False, indent=2, default=decimal_to_json)
496
+
497
+
498
+def write_backup(rows):
499
+    with open(BACKUP_FILE, "w", encoding="utf-8") as handle:
500
+        json.dump(rows, handle, ensure_ascii=False, indent=2, default=decimal_to_json)
501
+
502
+
503
+def print_summary(collected):
504
+    for key in sorted(collected):
505
+        summary = summarize(collected[key])
506
+        print(
507
+            summary["district_id"],
508
+            summary["district"],
509
+            summary["score_type"],
510
+            "parsed",
511
+            summary["parsed"],
512
+            "db",
513
+            summary["db"],
514
+            "updates",
515
+            summary["matched_for_update"],
516
+            "problems",
517
+            summary["problems"],
518
+            "dups",
519
+            summary["duplicate_target_rows"],
520
+            "missing",
521
+            summary["missing_from_pdf_or_skipped"],
522
+            "score_range",
523
+            summary["min_score_after_minus_50"],
524
+            summary["max_score_after_minus_50"],
525
+        )
526
+
527
+
528
+def update_scores(cursor, collected):
529
+    backup_rows = []
530
+    attempted = 0
531
+    changed = 0
532
+    for key in sorted(collected):
533
+        data = collected[key]
534
+        db_by_id = {row["ID"]: row for row in data["db_rows"]}
535
+        for update in data["updates"]:
536
+            db_row = db_by_id[update["ID"]]
537
+            if not compare_existing_scores(db_row, update):
538
+                raise RuntimeError(
539
+                    f"{data['district_name']} {data['score_type']} ID {update['ID']} "
540
+                    "already has different score data"
541
+                )
542
+            backup_rows.append(db_row)
543
+            cursor.execute(
544
+                """
545
+                UPDATE MPS_Score
546
+                SET ScoreTotal = %s,
547
+                    Score1 = %s,
548
+                    Score2 = %s,
549
+                    Score3 = %s,
550
+                    Score4 = %s,
551
+                    ScoreTotalDifferenceValue = %s
552
+                WHERE ID = %s
553
+                  AND ScoreYear = %s
554
+                  AND ScoreType = %s
555
+                  AND DistrictID = %s
556
+                """,
557
+                (
558
+                    update["ScoreTotal"],
559
+                    update["Score1"],
560
+                    update["Score2"],
561
+                    update["Score3"],
562
+                    update["Score4"],
563
+                    update["ScoreTotalDifferenceValue"],
564
+                    update["ID"],
565
+                    YEAR,
566
+                    data["score_type"],
567
+                    data["district_id"],
568
+                ),
569
+            )
570
+            attempted += 1
571
+            changed += cursor.rowcount
572
+    write_backup(backup_rows)
573
+    return attempted, changed
574
+
575
+
576
+def verify_database(cursor, collected):
577
+    mismatches = []
578
+    for key in sorted(collected):
579
+        data = collected[key]
580
+        expected = {row["ID"]: row for row in data["updates"]}
581
+        cursor.execute(
582
+            """
583
+            SELECT ID, ScoreTotal, Score1, Score2, Score3, Score4, ScoreTotalDifferenceValue
584
+            FROM MPS_Score
585
+            WHERE ScoreYear = %s
586
+              AND ScoreType = %s
587
+              AND DistrictID = %s
588
+            """,
589
+            (YEAR, data["score_type"], data["district_id"]),
590
+        )
591
+        actual_by_id = {row["ID"]: row for row in cursor.fetchall()}
592
+        for db_id, expected_row in expected.items():
593
+            actual = actual_by_id.get(db_id)
594
+            if not actual:
595
+                mismatches.append((data["district_id"], data["score_type"], db_id, "missing_actual"))
596
+                continue
597
+            for column in SCORE_COLUMNS:
598
+                if actual[column] != expected_row[column]:
599
+                    mismatches.append(
600
+                        (
601
+                            data["district_id"],
602
+                            data["score_type"],
603
+                            db_id,
604
+                            column,
605
+                            actual[column],
606
+                            expected_row[column],
607
+                        )
608
+                    )
609
+    if mismatches:
610
+        raise RuntimeError(f"verification failed: {mismatches[:10]}")
611
+
612
+
613
+def parse_args():
614
+    parser = argparse.ArgumentParser()
615
+    parser.add_argument("--dry-run", action="store_true")
616
+    parser.add_argument("--district", type=int, action="append", choices=sorted(DISTRICTS))
617
+    parser.add_argument("--score-type", choices=SCORE_TYPES, action="append")
618
+    return parser.parse_args()
619
+
620
+
621
+def main():
622
+    args = parse_args()
623
+    districts = args.district or sorted(DISTRICTS)
624
+    score_types = args.score_type or SCORE_TYPES
625
+
626
+    conn = pymysql.connect(**DB_CONFIG)
627
+    try:
628
+        with conn.cursor() as cursor:
629
+            collected = collect_all(cursor, districts, score_types)
630
+            write_report(collected)
631
+            print_summary(collected)
632
+            if args.dry_run:
633
+                conn.rollback()
634
+                print("dry_run_ok")
635
+                print("report", REPORT_FILE)
636
+                return
637
+            attempted, changed = update_scores(cursor, collected)
638
+            verify_database(cursor, collected)
639
+            conn.commit()
640
+            print("attempted_updates", attempted)
641
+            print("changed_rows", changed)
642
+            print("backup", BACKUP_FILE)
643
+            print("report", REPORT_FILE)
644
+    except Exception:
645
+        conn.rollback()
646
+        raise
647
+    finally:
648
+        conn.close()
649
+
650
+
651
+if __name__ == "__main__":
652
+    main()

+ 470 - 0
秒过分数线数据导入/import_mps_score_huangpu_scores_2026.py

@@ -0,0 +1,470 @@
1
+import argparse
2
+import json
3
+import re
4
+from collections import defaultdict
5
+from decimal import Decimal
6
+
7
+import pdfplumber
8
+import pymysql
9
+
10
+
11
+DB_CONFIG = {
12
+    "host": "589ae8e08493d.sh.cdb.myqcloud.com",
13
+    "port": 8124,
14
+    "user": "cdb_outerroot",
15
+    "password": "kylx!@#!QAZ@WSX",
16
+    "database": "kylx365_db",
17
+    "charset": "utf8mb4",
18
+    "connect_timeout": 10,
19
+    "read_timeout": 30,
20
+    "write_timeout": 30,
21
+    "cursorclass": pymysql.cursors.DictCursor,
22
+}
23
+
24
+YEAR = "2026"
25
+PREVIOUS_YEAR = "2025"
26
+DISTRICT_ID = 1
27
+DISTRICT_NAME = "黄浦区"
28
+SCORE_TYPES = ("名额到区", "名额到校")
29
+PDF_PATHS = {
30
+    "名额到区": "/Volumes/程杰外接SD盘/上海中考招生计划/2026/分数线/名额到区/1.pdf",
31
+    "名额到校": "/Volumes/程杰外接SD盘/上海中考招生计划/2026/分数线/名额到校/1.pdf",
32
+}
33
+BACKUP_FILE = "mps_score_huangpu_scores_2026_backup.json"
34
+
35
+SCORE_COLUMNS = [
36
+    "ScoreTotal",
37
+    "Score1",
38
+    "Score2",
39
+    "Score3",
40
+    "Score4",
41
+    "ScoreTotalDifferenceValue",
42
+]
43
+
44
+
45
+def compact(value):
46
+    return re.sub(r"\s+", "", str(value or "").replace("\u3000", ""))
47
+
48
+
49
+def decimal_to_json(value):
50
+    if isinstance(value, Decimal):
51
+        return str(value)
52
+    return value
53
+
54
+
55
+def parse_decimal(value):
56
+    match = re.search(r"\d+(?:\.\d+)?", compact(value))
57
+    return Decimal(match.group(0)) if match else None
58
+
59
+
60
+def is_subsequence(needle, haystack):
61
+    iterator = iter(haystack)
62
+    return all(char in iterator for char in needle)
63
+
64
+
65
+def name_variants(name):
66
+    value = compact(name)
67
+    variants = []
68
+
69
+    def add(item, penalty):
70
+        item = compact(item)
71
+        if item and item not in [variant for variant, _penalty in variants]:
72
+            variants.append((item, penalty))
73
+
74
+    add(value, 0)
75
+    add(value.replace("上海市", "上海"), 80)
76
+    add(re.sub(r"[((].*?[))]", "", value), 500)
77
+    add(re.sub(r"[((].*?[))]", "", value).replace("上海市", "上海"), 580)
78
+    return variants
79
+
80
+
81
+def score_variant(candidate, raw):
82
+    if candidate == raw:
83
+        return 5000 + len(candidate)
84
+    if candidate in raw:
85
+        return 4000 + len(candidate)
86
+    if raw in candidate:
87
+        return 3500 + len(raw)
88
+    if is_subsequence(candidate, raw):
89
+        return 3000 + len(candidate)
90
+    if is_subsequence(raw, candidate):
91
+        return 2500 + len(raw)
92
+    return 0
93
+
94
+
95
+def match_name(raw_value, candidates):
96
+    raw = compact(raw_value)
97
+    scored = []
98
+    for key, name in candidates:
99
+        best_score = max(score_variant(variant, raw) - penalty for variant, penalty in name_variants(name))
100
+        if best_score:
101
+            scored.append((best_score, key, name))
102
+    scored.sort(reverse=True, key=lambda item: item[0])
103
+    if not scored:
104
+        return None, 0, []
105
+    best = scored[0]
106
+    ties = [item for item in scored if item[0] == best[0] and item[1] != best[1]]
107
+    if ties:
108
+        return None, best[0], scored[:5]
109
+    return best[1], best[0], scored[:5]
110
+
111
+
112
+def parse_pdf(score_type, path):
113
+    rows = []
114
+    with pdfplumber.open(path) as pdf:
115
+        for page_number, page in enumerate(pdf.pages, 1):
116
+            for table in page.extract_tables():
117
+                for row_index, row in enumerate(table):
118
+                    if not row or len(row) < 9:
119
+                        continue
120
+                    if any(
121
+                        token in compact(cell)
122
+                        for cell in row[:3]
123
+                        for token in ("录取最低分", "招生学校", "初中学校", "区名称")
124
+                    ):
125
+                        continue
126
+
127
+                    total_with_eval = parse_decimal(row[2])
128
+                    eval_score = parse_decimal(row[4])
129
+                    score1 = parse_decimal(row[5])
130
+                    score2 = parse_decimal(row[6])
131
+                    score3 = parse_decimal(row[7])
132
+                    score4 = parse_decimal(row[8])
133
+                    if None in (total_with_eval, eval_score, score1, score2, score3, score4):
134
+                        continue
135
+                    if eval_score != Decimal("50"):
136
+                        raise ValueError(
137
+                            f"{score_type} page {page_number} row {row_index} "
138
+                            f"has unexpected 综合素质评价 score: {eval_score}"
139
+                        )
140
+
141
+                    parsed = {
142
+                        "page": page_number,
143
+                        "row_index": row_index,
144
+                        "raw": row,
145
+                        "total_with_eval": total_with_eval,
146
+                        "ScoreTotal": total_with_eval - Decimal("50"),
147
+                        "Score1": score1,
148
+                        "Score2": score2,
149
+                        "Score3": score3,
150
+                        "Score4": score4,
151
+                    }
152
+                    if score_type == "名额到区":
153
+                        parsed["high_raw"] = row[1]
154
+                    else:
155
+                        parsed["junior_raw"] = row[0]
156
+                        parsed["high_raw"] = row[1]
157
+                    rows.append(parsed)
158
+    return rows
159
+
160
+
161
+def fetch_existing(cursor, score_type, year):
162
+    cursor.execute(
163
+        """
164
+        SELECT ID, ScoreYear, ScoreType, DistrictID, SchoolOfGraduation,
165
+               SchoolFullNameJunior, SchoolTarget, SchoolFullName, PlanNum,
166
+               ScoreTotal, Score1, Score2, Score3, Score4,
167
+               ScoreTotalDifferenceValue
168
+        FROM MPS_Score
169
+        WHERE ScoreYear = %s
170
+          AND ScoreType = %s
171
+          AND DistrictID = %s
172
+        ORDER BY ID
173
+        """,
174
+        (year, score_type, DISTRICT_ID),
175
+    )
176
+    return cursor.fetchall()
177
+
178
+
179
+def business_key(row, score_type):
180
+    if score_type == "名额到校":
181
+        return (int(row["SchoolOfGraduation"]), str(row["SchoolTarget"]))
182
+    return (str(row["SchoolTarget"]),)
183
+
184
+
185
+def previous_scores_by_key(cursor, score_type):
186
+    rows = fetch_existing(cursor, score_type, PREVIOUS_YEAR)
187
+    return {business_key(row, score_type): row["ScoreTotal"] for row in rows}
188
+
189
+
190
+def attach_matches(score_type, parsed_rows, db_rows):
191
+    problems = []
192
+    matched = []
193
+    used_ids = defaultdict(list)
194
+
195
+    if score_type == "名额到区":
196
+        high_candidates = [(row["ID"], row["SchoolFullName"]) for row in db_rows]
197
+        row_by_id = {row["ID"]: row for row in db_rows}
198
+        for parsed in parsed_rows:
199
+            db_id, match_score, top = match_name(parsed["high_raw"], high_candidates)
200
+            if not db_id or match_score < 2500:
201
+                problems.append({"type": "high_match", "row": parsed, "top": top})
202
+                continue
203
+            parsed["db_row"] = row_by_id[db_id]
204
+            parsed["match_score"] = match_score
205
+            matched.append(parsed)
206
+            used_ids[db_id].append(parsed)
207
+    else:
208
+        junior_candidates = sorted(
209
+            {
210
+                (row["SchoolOfGraduation"], row["SchoolFullNameJunior"])
211
+                for row in db_rows
212
+                if row["SchoolOfGraduation"] is not None
213
+            }
214
+        )
215
+        rows_by_junior = defaultdict(list)
216
+        row_by_id = {row["ID"]: row for row in db_rows}
217
+        for row in db_rows:
218
+            rows_by_junior[int(row["SchoolOfGraduation"])].append((row["ID"], row["SchoolFullName"]))
219
+
220
+        for parsed in parsed_rows:
221
+            junior_id, junior_score, junior_top = match_name(parsed["junior_raw"], junior_candidates)
222
+            if not junior_id or junior_score < 2500:
223
+                problems.append({"type": "junior_match", "row": parsed, "top": junior_top})
224
+                continue
225
+            db_id, high_score, high_top = match_name(parsed["high_raw"], rows_by_junior[junior_id])
226
+            if not db_id or high_score < 2500:
227
+                problems.append({"type": "high_match", "row": parsed, "junior_id": junior_id, "top": high_top})
228
+                continue
229
+            parsed["db_row"] = row_by_id[db_id]
230
+            parsed["junior_match_score"] = junior_score
231
+            parsed["high_match_score"] = high_score
232
+            matched.append(parsed)
233
+            used_ids[db_id].append(parsed)
234
+
235
+    duplicates = {db_id: rows for db_id, rows in used_ids.items() if len(rows) > 1}
236
+    missing_db = [row for row in db_rows if row["ID"] not in used_ids]
237
+    return matched, problems, duplicates, missing_db
238
+
239
+
240
+def build_updates(score_type, matched_rows, previous_scores):
241
+    updates = []
242
+    for parsed in matched_rows:
243
+        db_row = parsed["db_row"]
244
+        key = business_key(db_row, score_type)
245
+        previous = previous_scores.get(key)
246
+        diff = None if previous is None else parsed["ScoreTotal"] - previous
247
+        updates.append(
248
+            {
249
+                "ID": db_row["ID"],
250
+                "SchoolOfGraduation": db_row["SchoolOfGraduation"],
251
+                "SchoolFullNameJunior": db_row["SchoolFullNameJunior"],
252
+                "SchoolTarget": db_row["SchoolTarget"],
253
+                "SchoolFullName": db_row["SchoolFullName"],
254
+                "PlanNum": db_row["PlanNum"],
255
+                "ScoreTotal": parsed["ScoreTotal"],
256
+                "Score1": parsed["Score1"],
257
+                "Score2": parsed["Score2"],
258
+                "Score3": parsed["Score3"],
259
+                "Score4": parsed["Score4"],
260
+                "ScoreTotalDifferenceValue": diff,
261
+                "total_with_eval": parsed["total_with_eval"],
262
+                "page": parsed["page"],
263
+            }
264
+        )
265
+    return updates
266
+
267
+
268
+def compare_existing_scores(db_row, update):
269
+    for column in SCORE_COLUMNS:
270
+        old_value = db_row[column]
271
+        new_value = update[column]
272
+        if old_value in (None, Decimal("0.00")):
273
+            continue
274
+        if new_value != old_value:
275
+            return False
276
+    return True
277
+
278
+
279
+def print_summary(score_type, parsed_rows, db_rows, matched, problems, duplicates, missing_db, updates):
280
+    totals = [row["ScoreTotal"] for row in updates]
281
+    print(
282
+        score_type,
283
+        "parsed",
284
+        len(parsed_rows),
285
+        "db",
286
+        len(db_rows),
287
+        "matched",
288
+        len(matched),
289
+        "missing_db",
290
+        len(missing_db),
291
+        "problems",
292
+        len(problems),
293
+        "duplicates",
294
+        len(duplicates),
295
+    )
296
+    if totals:
297
+        print(score_type, "score_total_after_minus_50", min(totals), max(totals))
298
+    if problems:
299
+        for problem in problems[:10]:
300
+            print("problem", score_type, problem["type"], problem["row"]["raw"], problem.get("top"))
301
+    if duplicates:
302
+        for db_id, rows in list(duplicates.items())[:10]:
303
+            print("duplicate", score_type, db_id, [row["raw"] for row in rows])
304
+    if missing_db:
305
+        for row in missing_db[:20]:
306
+            print(
307
+                "missing_from_pdf",
308
+                score_type,
309
+                row["ID"],
310
+                row["SchoolFullNameJunior"],
311
+                "->",
312
+                row["SchoolFullName"],
313
+                "plan",
314
+                row["PlanNum"],
315
+            )
316
+    for row in updates[:5]:
317
+        print(
318
+            "sample",
319
+            score_type,
320
+            row["ID"],
321
+            row["SchoolFullNameJunior"],
322
+            "->",
323
+            row["SchoolFullName"],
324
+            "pdf_total",
325
+            row["total_with_eval"],
326
+            "stored",
327
+            row["ScoreTotal"],
328
+            "diff",
329
+            row["ScoreTotalDifferenceValue"],
330
+        )
331
+
332
+
333
+def collect_all(cursor):
334
+    collected = {}
335
+    for score_type in SCORE_TYPES:
336
+        parsed_rows = parse_pdf(score_type, PDF_PATHS[score_type])
337
+        db_rows = fetch_existing(cursor, score_type, YEAR)
338
+        previous_scores = previous_scores_by_key(cursor, score_type)
339
+        matched, problems, duplicates, missing_db = attach_matches(score_type, parsed_rows, db_rows)
340
+        updates = build_updates(score_type, matched, previous_scores)
341
+        collected[score_type] = {
342
+            "parsed_rows": parsed_rows,
343
+            "db_rows": db_rows,
344
+            "matched": matched,
345
+            "problems": problems,
346
+            "duplicates": duplicates,
347
+            "missing_db": missing_db,
348
+            "updates": updates,
349
+        }
350
+    return collected
351
+
352
+
353
+def validate(collected):
354
+    failures = []
355
+    for score_type, data in collected.items():
356
+        if data["problems"]:
357
+            failures.append(f"{score_type} has {len(data['problems'])} match problems")
358
+        if data["duplicates"]:
359
+            failures.append(f"{score_type} has {len(data['duplicates'])} duplicate target rows")
360
+        if len(data["updates"]) != len(data["matched"]):
361
+            failures.append(f"{score_type} update/match count mismatch")
362
+    if failures:
363
+        raise RuntimeError("; ".join(failures))
364
+
365
+
366
+def write_backup(rows):
367
+    with open(BACKUP_FILE, "w", encoding="utf-8") as handle:
368
+        json.dump(rows, handle, ensure_ascii=False, indent=2, default=decimal_to_json)
369
+
370
+
371
+def update_scores(cursor, collected):
372
+    backup_rows = []
373
+    update_count = 0
374
+    for score_type, data in collected.items():
375
+        db_by_id = {row["ID"]: row for row in data["db_rows"]}
376
+        for update in data["updates"]:
377
+            db_row = db_by_id[update["ID"]]
378
+            if not compare_existing_scores(db_row, update):
379
+                raise RuntimeError(f"{score_type} ID {update['ID']} already has different score data")
380
+            backup_rows.append(db_row)
381
+            cursor.execute(
382
+                """
383
+                UPDATE MPS_Score
384
+                SET ScoreTotal = %s,
385
+                    Score1 = %s,
386
+                    Score2 = %s,
387
+                    Score3 = %s,
388
+                    Score4 = %s,
389
+                    ScoreTotalDifferenceValue = %s
390
+                WHERE ID = %s
391
+                  AND ScoreYear = %s
392
+                  AND ScoreType = %s
393
+                  AND DistrictID = %s
394
+                """,
395
+                (
396
+                    update["ScoreTotal"],
397
+                    update["Score1"],
398
+                    update["Score2"],
399
+                    update["Score3"],
400
+                    update["Score4"],
401
+                    update["ScoreTotalDifferenceValue"],
402
+                    update["ID"],
403
+                    YEAR,
404
+                    score_type,
405
+                    DISTRICT_ID,
406
+                ),
407
+            )
408
+            update_count += cursor.rowcount
409
+    write_backup(backup_rows)
410
+    return update_count
411
+
412
+
413
+def verify_database(cursor, collected):
414
+    mismatches = []
415
+    for score_type, data in collected.items():
416
+        expected = {row["ID"]: row for row in data["updates"]}
417
+        cursor.execute(
418
+            """
419
+            SELECT ID, ScoreTotal, Score1, Score2, Score3, Score4, ScoreTotalDifferenceValue
420
+            FROM MPS_Score
421
+            WHERE ScoreYear = %s
422
+              AND ScoreType = %s
423
+              AND DistrictID = %s
424
+            """,
425
+            (YEAR, score_type, DISTRICT_ID),
426
+        )
427
+        actual_by_id = {row["ID"]: row for row in cursor.fetchall()}
428
+        for db_id, expected_row in expected.items():
429
+            actual = actual_by_id.get(db_id)
430
+            if not actual:
431
+                mismatches.append((score_type, db_id, "missing_actual"))
432
+                continue
433
+            for column in SCORE_COLUMNS:
434
+                if actual[column] != expected_row[column]:
435
+                    mismatches.append((score_type, db_id, column, actual[column], expected_row[column]))
436
+    if mismatches:
437
+        raise RuntimeError(f"verification failed: {mismatches[:10]}")
438
+
439
+
440
+def main():
441
+    parser = argparse.ArgumentParser()
442
+    parser.add_argument("--dry-run", action="store_true")
443
+    args = parser.parse_args()
444
+
445
+    conn = pymysql.connect(**DB_CONFIG)
446
+    try:
447
+        with conn.cursor() as cursor:
448
+            collected = collect_all(cursor)
449
+            for score_type in SCORE_TYPES:
450
+                data = collected[score_type]
451
+                print_summary(score_type, **data)
452
+            validate(collected)
453
+            if args.dry_run:
454
+                conn.rollback()
455
+                print("dry_run_ok")
456
+                return
457
+            update_count = update_scores(cursor, collected)
458
+            verify_database(cursor, collected)
459
+            conn.commit()
460
+            print("updated", update_count)
461
+            print("backup", BACKUP_FILE)
462
+    except Exception:
463
+        conn.rollback()
464
+        raise
465
+    finally:
466
+        conn.close()
467
+
468
+
469
+if __name__ == "__main__":
470
+    main()

Fichier diff supprimé car celui-ci est trop grand
+ 4592 - 0
秒过分数线数据导入/mps_score_huangpu_scores_2026_backup.json


Fichier diff supprimé car celui-ci est trop grand
+ 78746 - 0
秒过分数线数据导入/mps_score_scores_2026_backup.json


Fichier diff supprimé car celui-ci est trop grand
+ 3665 - 0
秒过分数线数据导入/mps_score_scores_2026_import_report.json


Fichier diff supprimé car celui-ci est trop grand
+ 3408 - 0
秒过分数线数据导入/mps_score_scores_2026_sample_audit.json