Преглед на файлове

1-15志愿导入三个区

chengjie преди 4 дни
родител
ревизия
c54d6c5f94

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

@@ -94,7 +94,7 @@ export async function GetMPSDistrictPersonNum(ctx) {
94 94
                     list[i].PlanNum = Number(list[i].PlanNum);
95 95
                 }
96 96
 
97
-                if (arr[1]) {
97
+                if (list.length>6) {
98 98
                     arr.push({ "Name": "能上" + list[1].SchoolType2Short, "Year1": list[1].PlanNum, "Year2": list[1].PlanNum - list[0].PlanNum });
99 99
                     arr.push({ "Name": "能上" + list[5].SchoolType2Short, "Year1": list[5].PlanNum + list[3].PlanNum, "Year2": list[5].PlanNum + list[3].PlanNum - list[4].PlanNum - list[2].PlanNum });
100 100
                     arr.push({ "Name": "能上" + list[7].SchoolType2Short, "Year1": list[7].PlanNum, "Year2": list[7].PlanNum - list[6].PlanNum });

+ 38 - 4
秒过分数线数据导入/README.md

@@ -1,7 +1,7 @@
1 1
 # 上海中考招生计划与成绩导入说明
2 2
 
3 3
 本文档记录本项目每年从 PDF/图片整理上海中考招生计划、成绩,并导入 MySQL 表 `kylx365_db.MPS_Score` 的需求、步骤和注意事项。  
4
-当前已完成 2026 年“计划”中的 1、2、3:自主招生、名额到区、名额到校。
4
+当前已完成 2026 年“计划”中的自主招生、名额到区、名额到校,以及黄浦、徐汇、宝山 1-15 志愿
5 5
 
6 6
 ## 年度工作范围
7 7
 
@@ -26,7 +26,7 @@
26 26
 - 计划/自主招生:已导入
27 27
 - 计划/名额到区:已导入
28 28
 - 计划/名额到校:已导入
29
-- 计划/1-15 志愿:待官方文件发布后导入
29
+- 计划/1-15 志愿:黄浦、徐汇、宝山已导入,其他区待文件
30 30
 - 成绩四类:预计 7 月中旬后导入
31 31
 
32 32
 ## 数据库与核心表
@@ -121,6 +121,15 @@ WHERE ScoreYear = '2025'
121 121
 - `SchoolTargetRemark = ""`
122 122
 - 维度是“区 + 高中”。
123 123
 
124
+1-15 志愿额外规则:
125
+
126
+- 每个区的数据由“本区高中”和“外区高中”共同组成。
127
+- `DistrictID` 表示招生计划所属区,不是高中所在区;例如黄浦区表中的外区高中仍写 `DistrictID = 1`。
128
+- 维度是“招生区 + 高中 + SchoolTargetRemark”。
129
+- `SchoolOfGraduation = NULL`、`SchoolFullNameJunior = NULL`。
130
+- 艺术班等特殊计划沿用上一年 `SchoolTargetRemark` 和 `SchoolTargetRemark2`。
131
+- 图片没有学校代码时,必须用学校简称逐一匹配 `MPS_School`,并在写入前校验高中 ID 与全称。
132
+
124 133
 ## 总体操作流程
125 134
 
126 135
 每一类数据都按以下流程做:
@@ -251,6 +260,17 @@ WHERE ScoreYear = '2025'
251 260
 - 会跳过数据库中已存在的 `DistrictID + SchoolOfGraduation + SchoolTarget` 组合。
252 261
 - 会刷新 `mps_score_school_quota_2026_problems.json`,只保留仍未解决的问题。
253 262
 
263
+1-15 志愿:
264
+
265
+- `import_mps_score_1_15_2026.py`
266
+- 当前包含黄浦、徐汇、宝山两张图片的人工结构化数据。
267
+- 支持 `--dry-run`,写入前校验学校 ID、全称、行数、计划数和学校唯一性。
268
+- 使用 `--district 1`、`--district 2`、`--district 9` 选择区,可重复传入处理多个区。
269
+- 如果所选区已有 2026 数据会拒绝再次插入。
270
+- 写入后在同一事务内按学校、备注、计划数、同比差值逐行集合校验,失败自动回滚。
271
+- 黄浦区第二张图片、徐汇区第二张图片标题写成“2025 年”,但计划数与新增学校均为 2026 数据,按 2026 导入。
272
+- 某些上一年学校在本年不再出现时,不额外插入 `PlanNum = 0` 的记录。因此当前行的 `SUM(PlanNumDifferenceValue)` 可能与两年全区总计划差不同。
273
+
254 274
 2026 一次性补充脚本:
255 275
 
256 276
 - `import_mps_score_quota_manual_2026.py`:用于 2026 名额到区图片/OCR特殊区的手工补录,不是新年份通用入口。
@@ -294,6 +314,20 @@ WHERE ScoreYear = '2025'
294 314
 - 计划数合计 12887
295 315
 - 问题清单 `mps_score_school_quota_2026_problems.json` 已清空
296 316
 
317
+计划/1-15 志愿:
318
+
319
+- 黄浦、徐汇、宝山已导入
320
+- `ScoreYear = 2026`
321
+- `ScoreType = 1-15志愿`
322
+- 已导入 194 行
323
+- 计划数合计 10673
324
+- 黄浦区:67 行、1512 人;本区 1365、外区 147
325
+- 徐汇区:68 行、3839 人;本区 3529、外区 310
326
+- 宝山区:59 行、5322 人;本区 5091、外区 231
327
+- 相比 2025 年全区总计划:黄浦净增 40、徐汇净增 377、宝山净增 462
328
+- 重复业务 key 为 0
329
+- 其他 13 个区待图片文件
330
+
297 331
 
298 332
 修正记录:
299 333
 
@@ -386,8 +420,8 @@ WHERE SchoolType1 = '初中'
386 420
 
387 421
 计划/1-15 志愿:
388 422
 
389
-- 等 2026 官方文件发布后处理
390
-- 需要先研究 2025 的 PDF 与数据库写入形态,再决定 `ScoreType`、字段、维度和差值计算
423
+- 黄浦、徐汇、宝山已完成
424
+- 等其他区 2026 图片文件后继续处理
391 425
 
392 426
 成绩导入:
393 427
 

+ 557 - 0
秒过分数线数据导入/import_mps_score_1_15_2026.py

@@ -0,0 +1,557 @@
1
+import argparse
2
+import sys
3
+
4
+sys.path.insert(0, "/private/tmp/codex_mysql_driver")
5
+import pymysql  # noqa: E402
6
+
7
+
8
+DB_CONFIG = {
9
+    "host": "589ae8e08493d.sh.cdb.myqcloud.com",
10
+    "port": 8124,
11
+    "user": "cdb_outerroot",
12
+    "password": "kylx!@#!QAZ@WSX",
13
+    "database": "kylx365_db",
14
+    "charset": "utf8mb4",
15
+    "connect_timeout": 10,
16
+    "read_timeout": 30,
17
+    "write_timeout": 30,
18
+}
19
+
20
+YEAR = "2026"
21
+PREVIOUS_YEAR = "2025"
22
+SCORE_TYPE = "1-15志愿"
23
+
24
+INSERT_COLUMNS = [
25
+    "ScoreYear",
26
+    "ScoreType",
27
+    "DistrictID",
28
+    "SchoolOfGraduation",
29
+    "SchoolFullNameJunior",
30
+    "SchoolTarget",
31
+    "SchoolFullName",
32
+    "SchoolTargetRemark",
33
+    "PlanNum",
34
+    "ScoreTotal",
35
+    "Score1",
36
+    "Score2",
37
+    "Score3",
38
+    "Score4",
39
+    "SchoolTargetRemark2",
40
+    "PlanNumDifferenceValue",
41
+    "ScoreTotalDifferenceValue",
42
+    "OrderID",
43
+    "SchoolNumber",
44
+    "SchoolNumber2",
45
+    "SchoolOfGraduation1",
46
+]
47
+
48
+DISTRICT_DATA = {
49
+    1: {
50
+        "name": "黄浦区",
51
+        "local": [
52
+            (7, "上海市格致中学", 61),
53
+            (14, "上海市格致中学(奉贤校区)", 11),
54
+            (1021, "同济大学科技中学", 5),
55
+            (8, "上海市大同中学", 56),
56
+            (9, "上海市向明中学", 52),
57
+            (15, "上海市向明中学(浦江校区)", 7),
58
+            (10, "上海外国语大学附属大境中学", 65),
59
+            (11, "上海市光明中学", 61),
60
+            (12, "上海市敬业中学", 65),
61
+            (13, "上海市卢湾高级中学", 64),
62
+            (74, "上海市第八中学", 160),
63
+            (76, "上海市第十中学", 144),
64
+            (77, "上海理工大学附属储能中学", 144),
65
+            (78, "上海市金陵中学", 144),
66
+            (79, "上海市市南中学", 144),
67
+            (80, "上海音乐学院附属黄浦比乐中学", 144),
68
+            (81, "上海市同济黄浦设计创意中学", 38),
69
+        ],
70
+        "outside": [
71
+            (1, "上海市上海中学", 1),
72
+            (4, "华东师范大学第二附属中学", 1),
73
+            (3, "复旦大学附属中学", 1),
74
+            (2, "上海交通大学附属中学", 2),
75
+            (87, "上海市民办西南高级中学", 2),
76
+            (105, "上海市久隆模范中学", 1),
77
+            (108, "上海田家炳中学", 2),
78
+            (111, "上海市民办扬波中学", 2),
79
+            (113, "上海市民办风范中学", 2),
80
+            (100, "上海戏剧学院附属高级中学", 6),
81
+            (122, "上海安生学校", 7),
82
+            (117, "上海音乐学院附属安师实验中学", 3),
83
+            (156, "上海市民办燎原双语高级中学", 4),
84
+            (157, "上海闵行区诺达双语学校", 2),
85
+            (162, "上海闵行区民办德闳学校", 1),
86
+            (851, "上海民办行中中学", 2),
87
+            (177, "上海存志高级中学", 1),
88
+            (178, "上海宝山区民办维尚高级中学", 2),
89
+            (179, "上海创艺高级中学", 5),
90
+            (180, "上海市宝山华曜高级中学", 3),
91
+            (175, "上海市同洲模范学校", 1),
92
+            (852, "上海金瑞学校", 2),
93
+            (176, "上海宝山区世外学校", 1),
94
+            (188, "上海市民办远东学校", 2),
95
+            (189, "上海华旭双语学校", 3),
96
+            (190, "上海嘉定区民办华盛怀少学校", 5),
97
+            (218, "上海市浦东新区民办浦实高级中学", 2),
98
+            (220, "上海市民办丰华高级中学", 1),
99
+            (223, "民办上海工商外国语职业学院附属中学", 1),
100
+            (228, "上海市民办金苹果学校", 1),
101
+            (229, "上海浦东新区民办东鼎外国语学校", 1),
102
+            (230, "上海市民办尚德实验学校", 7),
103
+            (243, "上海市民办交大南洋中学", 9),
104
+            (246, "上海市民办永昌中学", 8),
105
+            (244, "上海金山区世外学校", 3),
106
+            (859, "上海市松江区科德高级中学", 1),
107
+            (1034, "上海市松江区励滕高级中学", 1),
108
+            (860, "上海领科双语学校", 1),
109
+            (251, "上海市西外外国语学校", 10),
110
+            (254, "上海赫贤学校", 2),
111
+            (995, "上海松江区爱菊学校", 1),
112
+            (863, "上海青浦区世外高级中学", 1),
113
+            (862, "上海青浦区宏润博源高级中学", 1),
114
+            (259, "上海宋庆龄学校", 2),
115
+            (260, "上海青浦区协和双语学校", 4),
116
+            (267, "上海美达菲双语高级中学", 6),
117
+            (266, "上海奉贤区博华高级中学", 3),
118
+            (268, "上海市崇明区城桥中学", 4),
119
+            (271, "上海市崇明区堡镇中学", 1),
120
+            (272, "上海民办民一中学", 12),
121
+        ],
122
+        "expected_local_total": 1365,
123
+        "expected_outside_total": 147,
124
+    },
125
+    2: {
126
+        "name": "徐汇区",
127
+        "local": [
128
+            (17, "上海市南洋模范中学", 100),
129
+            (18, "上海市位育中学", 108),
130
+            (985, "上海市位育附属徐汇科技实验中学", 172),
131
+            (19, "上海市南洋中学", 96),
132
+            (16, "上海市第二中学", 64),
133
+            (20, "上海市第二中学(梅陇校区)", 20),
134
+            (21, "复旦大学附属中学徐汇分校", 40),
135
+            (87, "上海市民办西南高级中学", 202),
136
+            (83, "上海市中国中学", 376),
137
+            (84, "上海市第五十四中学", 352),
138
+            (82, "上海市徐汇中学", 309),
139
+            (85, "上海市第四中学", 376),
140
+            (88, "上海市零陵中学", 188),
141
+            (89, "华东理工大学附属中学", 282),
142
+            (86, "上海市西南位育中学", 264),
143
+            (90, "上海市西南模范中学", 180),
144
+            (1022, "上海民办位育中学", 70),
145
+            (91, "上海市紫竹园中学", 170),
146
+            (92, "上海市徐汇区董恒甫高级中学", 160),
147
+        ],
148
+        "outside": [
149
+            (1, "上海市上海中学", 7),
150
+            (4, "华东师范大学第二附属中学", 1),
151
+            (3, "复旦大学附属中学", 1),
152
+            (2, "上海交通大学附属中学", 2),
153
+            (5, "上海师范大学附属中学", 2),
154
+            (81, "上海市同济黄浦设计创意中学", 4),
155
+            (105, "上海市久隆模范中学", 1),
156
+            (111, "上海市民办扬波中学", 6),
157
+            (113, "上海市民办风范中学", 3),
158
+            (100, "上海戏剧学院附属高级中学", 12),
159
+            (122, "上海安生学校", 8),
160
+            (117, "上海音乐学院附属安师实验中学", 3),
161
+            (148, "上海市文来中学", 1),
162
+            (156, "上海市民办燎原双语高级中学", 8),
163
+            (157, "上海闵行区诺达双语学校", 2),
164
+            (162, "上海闵行区民办德闳学校", 1),
165
+            (851, "上海民办行中中学", 10),
166
+            (177, "上海存志高级中学", 2),
167
+            (178, "上海宝山区民办维尚高级中学", 4),
168
+            (179, "上海创艺高级中学", 13),
169
+            (180, "上海市宝山华曜高级中学", 1),
170
+            (852, "上海金瑞学校", 5),
171
+            (175, "上海市同洲模范学校", 5),
172
+            (188, "上海市民办远东学校", 2),
173
+            (189, "上海华旭双语学校", 8),
174
+            (190, "上海嘉定区民办华盛怀少学校", 9),
175
+            (229, "上海浦东新区民办东鼎外国语学校", 5),
176
+            (218, "上海市浦东新区民办浦实高级中学", 2),
177
+            (228, "上海市民办金苹果学校", 6),
178
+            (230, "上海市民办尚德实验学校", 1),
179
+            (223, "民办上海工商外国语职业学院附属中学", 1),
180
+            (243, "上海市民办交大南洋中学", 25),
181
+            (244, "上海金山区世外学校", 12),
182
+            (859, "上海市松江区科德高级中学", 1),
183
+            (1034, "上海市松江区励滕高级中学", 2),
184
+            (860, "上海领科双语学校", 1),
185
+            (251, "上海市西外外国语学校", 15),
186
+            (254, "上海赫贤学校", 4),
187
+            (995, "上海松江区爱菊学校", 4),
188
+            (863, "上海青浦区世外高级中学", 4),
189
+            (862, "上海青浦区宏润博源高级中学", 8),
190
+            (259, "上海宋庆龄学校", 3),
191
+            (260, "上海青浦区协和双语学校", 2),
192
+            (267, "上海美达菲双语高级中学", 3),
193
+            (266, "上海奉贤区博华高级中学", 20),
194
+            (268, "上海市崇明区城桥中学", 10),
195
+            (271, "上海市崇明区堡镇中学", 27),
196
+            (272, "上海民办民一中学", 30),
197
+            (1039, "上海新纪元双语学校", 3),
198
+        ],
199
+        "expected_local_total": 3529,
200
+        "expected_outside_total": 310,
201
+    },
202
+    9: {
203
+        "name": "宝山区",
204
+        "local": [
205
+            (47, "上海市行知中学", 137),
206
+            (48, "上海大学附属中学", 137),
207
+            (49, "上海市吴淞中学", 119),
208
+            (50, "上海师范大学附属中学宝山分校", 66),
209
+            (51, "华东师范大学第二附属中学(宝山校区)", 86),
210
+            (167, "上海师范大学附属宝山罗店中学", 544),
211
+            (168, "上海市宝山中学", 620),
212
+            (169, "上海市通河中学", 355),
213
+            (172, "上海市淞浦中学", 336),
214
+            (170, "上海市顾村中学", 616),
215
+            (171, "上海市行知实验中学", 355),
216
+            (173, "上海市高境第一中学", 352),
217
+            (174, "上海市宝山区海滨中学", 252),
218
+            (851, "上海民办行中中学", 222),
219
+            (177, "上海存志高级中学", 84),
220
+            (178, "上海宝山区民办维尚高级中学", 9),
221
+            (179, "上海创艺高级中学", 104),
222
+            (180, "上海市宝山华曜高级中学", 190),
223
+            (175, "上海市同洲模范学校", 370),
224
+            (1028, "上海民办至德实验学校", 72),
225
+            (852, "上海金瑞学校", 43),
226
+            (176, "上海宝山区世外学校", 22),
227
+        ],
228
+        "outside": [
229
+            (1, "上海市上海中学", 1),
230
+            (4, "华东师范大学第二附属中学", 1),
231
+            (3, "复旦大学附属中学", 1),
232
+            (2, "上海交通大学附属中学", 1),
233
+            (81, "上海市同济黄浦设计创意中学", 3),
234
+            (108, "上海田家炳中学", 18),
235
+            (105, "上海市久隆模范中学", 3),
236
+            (100, "上海戏剧学院附属高级中学", 6),
237
+            (111, "上海市民办扬波中学", 24),
238
+            (113, "上海市民办风范中学", 28),
239
+            (117, "上海音乐学院附属安师实验中学", 2),
240
+            (122, "上海安生学校", 8),
241
+            (156, "上海市民办燎原双语高级中学", 2),
242
+            (190, "上海嘉定区民办华盛怀少学校", 4),
243
+            (189, "上海华旭双语学校", 18),
244
+            (188, "上海市民办远东学校", 5),
245
+            (230, "上海市民办尚德实验学校", 5),
246
+            (229, "上海浦东新区民办东鼎外国语学校", 2),
247
+            (223, "民办上海工商外国语职业学院附属中学", 2),
248
+            (220, "上海市民办丰华高级中学", 5),
249
+            (244, "上海金山区世外学校", 2),
250
+            (243, "上海市民办交大南洋中学", 25),
251
+            (246, "上海市民办永昌中学", 8),
252
+            (860, "上海领科双语学校", 2),
253
+            (254, "上海赫贤学校", 2),
254
+            (251, "上海市西外外国语学校", 5),
255
+            (995, "上海松江区爱菊学校", 4),
256
+            (1034, "上海市松江区励滕高级中学", 2),
257
+            (863, "上海青浦区世外高级中学", 3),
258
+            (862, "上海青浦区宏润博源高级中学", 1),
259
+            (260, "上海青浦区协和双语学校", 1),
260
+            (266, "上海奉贤区博华高级中学", 1),
261
+            (267, "上海美达菲双语高级中学", 1),
262
+            (268, "上海市崇明区城桥中学", 5),
263
+            (271, "上海市崇明区堡镇中学", 8),
264
+            (1039, "上海新纪元双语学校", 6),
265
+            (272, "上海民办民一中学", 16),
266
+        ],
267
+        "expected_local_total": 5091,
268
+        "expected_outside_total": 231,
269
+    },
270
+}
271
+
272
+SPECIAL_REMARKS = {
273
+    100: ("(艺术班)", "只招收经学校艺术特长测试合格的学生。"),
274
+    117: ("(艺术班)", "只招收经学校艺术特长测试合格的学生。"),
275
+}
276
+
277
+
278
+def connect():
279
+    return pymysql.connect(
280
+        **DB_CONFIG,
281
+        cursorclass=pymysql.cursors.DictCursor,
282
+        autocommit=False,
283
+    )
284
+
285
+
286
+def validate_source_data(district_id, data):
287
+    local_total = sum(plan_num for _, _, plan_num in data["local"])
288
+    outside_total = sum(plan_num for _, _, plan_num in data["outside"])
289
+    all_ids = [school_id for school_id, _, _ in data["local"] + data["outside"]]
290
+
291
+    if local_total != data["expected_local_total"]:
292
+        raise ValueError(
293
+            f"{data['name']} local total: expected "
294
+            f"{data['expected_local_total']}, got {local_total}"
295
+        )
296
+    if outside_total != data["expected_outside_total"]:
297
+        raise ValueError(
298
+            f"{data['name']} outside total: expected "
299
+            f"{data['expected_outside_total']}, got {outside_total}"
300
+        )
301
+    if len(all_ids) != len(set(all_ids)):
302
+        raise ValueError(f"{data['name']} contains duplicate school IDs")
303
+    if district_id < 1 or district_id > 16:
304
+        raise ValueError(f"invalid DistrictID: {district_id}")
305
+
306
+
307
+def load_and_validate_schools(cursor, data):
308
+    source = data["local"] + data["outside"]
309
+    expected = {school_id: school_name for school_id, school_name, _ in source}
310
+    placeholders = ", ".join(["%s"] * len(expected))
311
+    cursor.execute(
312
+        f"""
313
+        SELECT ID, SchoolFullName, SchoolType1
314
+        FROM MPS_School
315
+        WHERE ID IN ({placeholders})
316
+        """,
317
+        tuple(expected),
318
+    )
319
+    schools = {int(row["ID"]): row for row in cursor.fetchall()}
320
+
321
+    problems = []
322
+    for school_id, school_name in expected.items():
323
+        school = schools.get(school_id)
324
+        if not school:
325
+            problems.append(f"missing school ID {school_id}: {school_name}")
326
+        elif school["SchoolType1"] != "高中":
327
+            problems.append(f"school ID {school_id} is not 高中")
328
+        elif school["SchoolFullName"] != school_name:
329
+            problems.append(
330
+                f"school ID {school_id} name mismatch: "
331
+                f"expected {school_name}, got {school['SchoolFullName']}"
332
+            )
333
+    if problems:
334
+        raise ValueError("; ".join(problems))
335
+    return schools
336
+
337
+
338
+def load_previous_plan_nums(cursor, district_id):
339
+    cursor.execute(
340
+        """
341
+        SELECT SchoolTarget, SchoolTargetRemark, PlanNum
342
+        FROM MPS_Score
343
+        WHERE ScoreYear = %s
344
+          AND ScoreType = %s
345
+          AND DistrictID = %s
346
+        """,
347
+        (PREVIOUS_YEAR, SCORE_TYPE, district_id),
348
+    )
349
+    return {
350
+        (int(row["SchoolTarget"]), row["SchoolTargetRemark"] or ""): int(
351
+            row["PlanNum"] or 0
352
+        )
353
+        for row in cursor.fetchall()
354
+    }
355
+
356
+
357
+def build_records(district_id, data, schools, previous_plan_nums):
358
+    records = []
359
+    for school_id, _, plan_num in data["local"] + data["outside"]:
360
+        remark, remark2 = SPECIAL_REMARKS.get(school_id, ("", None))
361
+        previous = previous_plan_nums.get((school_id, remark), 0)
362
+        records.append(
363
+            {
364
+                "ScoreYear": YEAR,
365
+                "ScoreType": SCORE_TYPE,
366
+                "DistrictID": district_id,
367
+                "SchoolOfGraduation": None,
368
+                "SchoolFullNameJunior": None,
369
+                "SchoolTarget": str(school_id),
370
+                "SchoolFullName": schools[school_id]["SchoolFullName"],
371
+                "SchoolTargetRemark": remark,
372
+                "PlanNum": plan_num,
373
+                "ScoreTotal": 0,
374
+                "Score1": 0,
375
+                "Score2": 0,
376
+                "Score3": 0,
377
+                "Score4": 0,
378
+                "SchoolTargetRemark2": remark2,
379
+                "PlanNumDifferenceValue": plan_num - previous,
380
+                "ScoreTotalDifferenceValue": 0,
381
+                "OrderID": 0,
382
+                "SchoolNumber": "",
383
+                "SchoolNumber2": "",
384
+                "SchoolOfGraduation1": "0",
385
+            }
386
+        )
387
+    return records
388
+
389
+
390
+def ensure_not_imported(cursor, district_id, district_name):
391
+    cursor.execute(
392
+        """
393
+        SELECT COUNT(*) AS count, COALESCE(SUM(PlanNum), 0) AS total
394
+        FROM MPS_Score
395
+        WHERE ScoreYear = %s
396
+          AND ScoreType = %s
397
+          AND DistrictID = %s
398
+        """,
399
+        (YEAR, SCORE_TYPE, district_id),
400
+    )
401
+    existing = cursor.fetchone()
402
+    if existing["count"]:
403
+        raise RuntimeError(
404
+            f"{YEAR} {district_name} {SCORE_TYPE} already has "
405
+            f"{existing['count']} rows / {existing['total']} plans"
406
+        )
407
+
408
+
409
+def insert_records(cursor, records):
410
+    placeholders = ", ".join(["%s"] * len(INSERT_COLUMNS))
411
+    columns = ", ".join(INSERT_COLUMNS)
412
+    sql = f"INSERT INTO MPS_Score ({columns}) VALUES ({placeholders})"
413
+    cursor.executemany(
414
+        sql,
415
+        [[record[column] for column in INSERT_COLUMNS] for record in records],
416
+    )
417
+
418
+
419
+def validate_inserted_records(cursor, district_id, data, expected_records):
420
+    cursor.execute(
421
+        """
422
+        SELECT SchoolTarget, SchoolFullName, SchoolTargetRemark, PlanNum,
423
+               PlanNumDifferenceValue
424
+        FROM MPS_Score
425
+        WHERE ScoreYear = %s
426
+          AND ScoreType = %s
427
+          AND DistrictID = %s
428
+        """,
429
+        (YEAR, SCORE_TYPE, district_id),
430
+    )
431
+    actual_rows = cursor.fetchall()
432
+    actual = {
433
+        (
434
+            int(row["SchoolTarget"]),
435
+            row["SchoolFullName"],
436
+            row["SchoolTargetRemark"] or "",
437
+            int(row["PlanNum"] or 0),
438
+            int(row["PlanNumDifferenceValue"] or 0),
439
+        )
440
+        for row in actual_rows
441
+    }
442
+    expected = {
443
+        (
444
+            int(row["SchoolTarget"]),
445
+            row["SchoolFullName"],
446
+            row["SchoolTargetRemark"] or "",
447
+            int(row["PlanNum"]),
448
+            int(row["PlanNumDifferenceValue"]),
449
+        )
450
+        for row in expected_records
451
+    }
452
+    expected_total = data["expected_local_total"] + data["expected_outside_total"]
453
+
454
+    if len(actual_rows) != len(expected_records):
455
+        raise ValueError(
456
+            f"{data['name']} inserted row count: expected "
457
+            f"{len(expected_records)}, got {len(actual_rows)}"
458
+        )
459
+    if sum(int(row["PlanNum"] or 0) for row in actual_rows) != expected_total:
460
+        raise ValueError(f"{data['name']} inserted plan total does not match images")
461
+    if actual != expected:
462
+        raise ValueError(
463
+            f"{data['name']} inserted detail mismatch: "
464
+            f"missing={expected - actual}, extra={actual - expected}"
465
+        )
466
+
467
+
468
+def print_summary(district_id, data, records):
469
+    print(
470
+        "ready",
471
+        district_id,
472
+        data["name"],
473
+        "rows",
474
+        len(records),
475
+        "plan",
476
+        sum(row["PlanNum"] for row in records),
477
+        "difference",
478
+        sum(row["PlanNumDifferenceValue"] for row in records),
479
+    )
480
+    print(
481
+        "local",
482
+        "rows",
483
+        len(data["local"]),
484
+        "plan",
485
+        sum(plan_num for _, _, plan_num in data["local"]),
486
+    )
487
+    print(
488
+        "outside",
489
+        "rows",
490
+        len(data["outside"]),
491
+        "plan",
492
+        sum(plan_num for _, _, plan_num in data["outside"]),
493
+    )
494
+    for row in records:
495
+        print(
496
+            row["SchoolTarget"],
497
+            row["SchoolFullName"],
498
+            row["PlanNum"],
499
+            row["PlanNumDifferenceValue"],
500
+            row["SchoolTargetRemark"],
501
+        )
502
+
503
+
504
+def process_district(cursor, district_id, dry_run):
505
+    data = DISTRICT_DATA[district_id]
506
+    validate_source_data(district_id, data)
507
+    ensure_not_imported(cursor, district_id, data["name"])
508
+    schools = load_and_validate_schools(cursor, data)
509
+    previous_plan_nums = load_previous_plan_nums(cursor, district_id)
510
+    records = build_records(district_id, data, schools, previous_plan_nums)
511
+    print_summary(district_id, data, records)
512
+
513
+    if not dry_run:
514
+        insert_records(cursor, records)
515
+        validate_inserted_records(cursor, district_id, data, records)
516
+    return len(records)
517
+
518
+
519
+def main():
520
+    parser = argparse.ArgumentParser()
521
+    parser.add_argument(
522
+        "--district",
523
+        type=int,
524
+        action="append",
525
+        choices=sorted(DISTRICT_DATA),
526
+        help="DistrictID to process; may be provided more than once",
527
+    )
528
+    parser.add_argument(
529
+        "--dry-run",
530
+        action="store_true",
531
+        help="validate and print records without inserting them",
532
+    )
533
+    args = parser.parse_args()
534
+    district_ids = args.district or sorted(DISTRICT_DATA)
535
+
536
+    connection = connect()
537
+    try:
538
+        with connection.cursor() as cursor:
539
+            inserted = 0
540
+            for district_id in district_ids:
541
+                inserted += process_district(cursor, district_id, args.dry_run)
542
+
543
+            if args.dry_run:
544
+                connection.rollback()
545
+                print("dry-run complete; no data inserted")
546
+            else:
547
+                connection.commit()
548
+                print(f"inserted {inserted} rows")
549
+    except Exception:
550
+        connection.rollback()
551
+        raise
552
+    finally:
553
+        connection.close()
554
+
555
+
556
+if __name__ == "__main__":
557
+    main()

+ 2 - 1
秒过分数线数据导入/需求.md

@@ -98,7 +98,8 @@ SELECT * FROM kylx365_db.MPS_Score where ScoreYear='2025' and ScoreType='名额
98 98
 
99 99
 一共16个区。
100 100
 
101
-——————————————————————————现在需要你帮我处理秒过分数线的1-15志愿数据
101
+——————————————————————————
102
+现在需要你帮我处理秒过分数线的1-15志愿数据
102 103
 
103 104
 之前所有的需求与处理过程看下面
104 105
 /Users/chengjie/Documents/git/miaoguo_system_server/秒过分数线数据导入/需求.md