简介:面向地理信息系统开发、物流配送与地图服务等场景整理的一份全国行政区划邮编与经纬度坐标数据集,覆盖省、市、区、县、乡、镇、街道各级区划,合计约46090条记录,可支撑地址解析、配送路径优化、区域市场细分、人口分布研究以及灾害定位等实际需求。压缩包内共1个文件,为单个 SQL 脚本,体积约942KB,导入后即可按表结构查询各级区划名称、邮政编码及对应经纬度字段,便于与业务库关联或做二次清洗。已有796人学习下载,反映出中小型 GIS 项目与数据教学场景对基础地理数据的持续需求。数据以结构化表形式组织,每行对应一个行政区域,字段边界清晰,读者可直接用 SQL 完成按省份聚合、坐标范围筛选、邮编反查等操作,也可作为地图打点与地址库校验的底表,省去零散采集、逐级核对与坐标纠偏的重复投入。
1. 从一条对不上的收货地址说起:五级区划 + 邮编 + 坐标这张表为什么值得自己建
前阵子帮一个做同城配送的团队查下单失败,日志里约 3% 的订单卡在地址解析这一步:用户填的是「甲省乙市丙区丁街道」,系统返回「未匹配到网点」。再查下去,问题不在解析算法,而在底层那张表:区划快照还是三年前的,两个县已经撤县设区,邮编字段一大半是空的,门店坐标是运营在地图上手工点选的,实测偏了一公里多,骑手按导航直接跑进隔壁小区。
「全国省、市、区、县、乡、镇、街道邮编及经纬度坐标」这件事,真正难的地方从来不是找一份数据下载下来,而是把四层行政区划、一层村居、一套六位邮编、一套坐标系,拼成一张能支撑地址解析、网点匹配、运费计算、报表归集的可信底表。它决定了你后面所有按行政区聚合的数字对不对得上。适合做本地生活、物流仓储、零售选址、政务数据归集的后端和数据同学,也适合需要按行政区口径出报表的分析岗。
2. 五级区划表怎么设计:12 位代码做主键、邮编单独挂表
绝大多数人的第一版都是把一份现成的表格导入数据库,字段对齐、类型对上、能查出来,就觉得完事了。真正出问题是在三个月后:业务方要按街道统计单量,你发现街道这一级根本没有稳定主键;财务要按县归集,你发现历史订单里的县名和现在的县名对不上。表设计阶段的偷懒,后面要用几倍的清洗成本还回来。
2.1 只用 12 位区划代码做主键,名称永远当别名
区划代码是分层拼出来的:前 2 位省级,第 3-4 位市级,第 5-6 位县级,第 7-9 位乡镇街道,第 10-12 位村/社区。实际拿到的数据长度经常是混的——省级表给 2 位、市级表给 4 位、县级 6 位、乡镇 9 位、村居 12 位。入库第一件事就是统一右补零到 12 位,之后所有连接、比较、前缀查询全部在这个 12 位口径上做。
名称绝对不能当主键,也不能当连接键。原因很实在:全国叫「城关镇」的乡镇能凑出几十个,叫「朝阳区」的区不止一个,重复率高得离谱;而且区划名称本身会变,撤县设区、乡改镇、街道合并,名称一变,历史数据里的外键全断。代码相对稳定,即使区划调整,通常也是「旧代码失效 + 新代码启用」,而不是原地改代码。
CREATE TABLE ad_div ( code CHAR(12) PRIMARY KEY, -- 统一右补零到 12 位 parent_code CHAR(12) NOT NULL, -- 上级代码,省级填 '000000000000' level SMALLINT NOT NULL, -- 1省 2市 3县 4乡 5村居 name VARCHAR(64) NOT NULL, -- 标准全称 short_name VARCHAR(64), -- 去掉省/市/县/区/镇后缀的简称 is_placeholder BOOLEAN DEFAULT FALSE, -- 是否为"市辖区"这类占位节点 valid_from DATE NOT NULL, -- 生效日期(快照口径) valid_to DATE, -- 失效日期,NULL 表示现行 lng NUMERIC(10,6), -- 内点经度,WGS84 lat NUMERIC(10,6), -- 内点纬度,WGS84 geom GEOMETRY(MultiPolygon, 4326) -- 面边界,仅到乡镇街道级 ); CREATE INDEX idx_ad_div_parent ON ad_div (parent_code); CREATE INDEX idx_ad_div_level ON ad_div (level) WHERE valid_to IS NULL; CREATE INDEX idx_ad_div_geom ON ad_div USING GIST (geom);is_placeholder这个字段是被坑出来的。市级节点里有一类叫「市辖区」的占位记录,它不是一个真实的地理实体,只是为了让层级完整而存在;如果你把它当成一个真实的市去做聚合,统计出来的数字会莫名其妙多一层。同理还有个「县」占位。这类节点在做报表时必须过滤掉,但在做树形结构展示时又必须保留,所以只标记、不删除。
valid_to是双时态设计的核心。同一份数据里会同时存在「现行的丙区」和「三年前的丙县」,两个 code 不同、valid_to一空一有值。业务查询默认带valid_to IS NULL,历史对账则按订单发生日期落在valid_from和valid_to之间去匹配。没有这个字段,撤县设区那一年你所有同比数据都是错的。
2.2 层级归属:邻接表存事实,路径字段存索引
层级关系的存法无非两种。邻接表就是上面那个parent_code,只存父节点,结构最干净,改层级时只动一行;路径枚举是把整条路径拼成字符串存在一列里,比如100000000000/100100000000/100101000000,查子树时一个LIKE '1001%'就搞定,不用递归。
只存邻接表,查「某市下所有区县的所有街道」要写递归 CTE,数据量一大就慢;只用路径,一旦中间某一级发生调整(比如某个县从甲市划到乙市),整棵子树的路径串都要重写,维护成本高。我一般的做法是两个都存:parent_code作为事实来源保证正确性,另外加一个物化的path字段专门扛查询。
-- 用递归 CTE 从省级一直展开到街道,顺便把路径拼出来 WITH RECURSIVE tree AS ( SELECT code, parent_code, level, name, code::text AS path FROM ad_div WHERE code = '100000000000' -- 起点:某个省 UNION ALL SELECT c.code, c.parent_code, c.level, c.name, t.path || '/' || c.code FROM ad_div c JOIN tree t ON c.parent_code = t.code WHERE c.valid_to IS NULL -- 只展开现行区划 ) SELECT level, code, name, path FROM tree WHERE level <= 4 ORDER BY path;这段的关键参数是level <= 4:只展开到乡镇街道,村居那一级动辄几十万行,绝大多数业务用不上,展开进去只会把结果集撑爆。path用code拼接而不是用名称,是因为code定长,排序天然就是层级顺序,拿来做前端树形组件的数据源无需二次排序。
有一个反直觉的点要提醒:code LIKE '1001%'这种前缀查询,看起来能直接代替递归,但遇到省直辖的县级行政单位就会断——它的市级两位并不是常规的递增序列,前缀规则不成立。所以前缀只能当索引用,判定层级归属还是要老老实实看parent_code和level。
2.3 邮编是投递局维度,不能当行政区划的属性
新手最容易犯的错,是给区划表加一列zip_code,然后填一个值进去。跑起来没报错,等业务方拿用户填的六位邮编来做校验,全线崩。
六位邮编的构成是:前 2 位对应邮区(大致对齐省级),第 3 位细化到邮区内的分拣中心,第 4 位对应县市级投递范围,后 2 位是具体的投递局。它的划分依据是邮政网络的投递路径,不是行政区划边界。所以现实里是一对多的:一个县级单位往往对应好几个邮编,尤其面积大的县;反过来,一个邮编也可能横跨两个乡镇,乡镇合并之后这种情况特别多。
正确做法是单独一张映射表,把邮编挂在它能覆盖到的最细层级上,并标注优先级和来源。
CREATE TABLE ad_zip ( zip CHAR(6) NOT NULL, code CHAR(12) NOT NULL, -- 绑定到 9 位乡镇街道或 12 位村居 priority SMALLINT DEFAULT 9, -- 数字越小越优先:1村居 5乡镇 9县级兜底 source VARCHAR(32), -- 数据来源标记,便于回溯 PRIMARY KEY (zip, code) );查询时的策略是「由细到粗、取到即止」:先按村居级精确匹配,命中就直接用;没命中退到乡镇街道级;还没有就退到县级,返回该县下出现频次最高的邮编作为建议值。priority字段就是给这个回退逻辑用的,别指望调用方自己判断。
3. 数据从哪来、怎么搓到一起:三源交叉与乡镇街道补全
区划数据不是一个来源能搞定的。每年都有一批区划调整公告,任何一份公开数据都有滞后;同时坐标和边界又不可能从代码表里拿。实际干下来,比较稳的组合是三类源头交叉:代码快照做骨架,地理数据做血肉,自有业务样本做校验和别名。
3.1 三类数据源的取舍:代码表定骨、地图数据补形、地址样本补别名
第一类是年度区划代码快照,通常是民政或统计口径发布的表格。它的优势是权威、层级完整、代码稳定,缺点也很明确:只有名称没有坐标,字段极简,而且更新频率是年度的,年中调整得等到下一年才反映出来。这张表在整条链路里定的是主键和父子关系,不能动摇。
第二类是地图服务提供的行政区划边界和逆地理编码接口。它有面、有坐标、能算距离,但名称口径和代码表经常对不上——同一地方,一边叫「某某自治县」,一边叫「某某县」;边界数据做过抽稀简化,沿海和一些复杂边界的形状会失真。它负责补geom和lng/lat,不能负责定主键。
第三类是自有业务里沉淀的地址样本,比如历史订单、门店登记、用户填写的收货地址。这东西看着脏,价值却最高:它反映真实书写习惯,能挖出「某某开发区」「某某新区」这类不在标准区划表里但用户天天在用的别名,也能反查覆盖率——你那三十多万条区划记录里,到底有多少是真的被业务用到的。
三类数据合起来的原则只有一句:连接只认代码,名称只用来比对和建别名。用名称做 join 是这类项目里最经典的翻车点,merge 之后行数从三十万涨到四十多万,还查不出哪条错了。
3.2 用 Python 把三种格式拉齐成一张宽表
原始数据通常是三种形态:代码表是 CSV 或 XLSX,边界是 GeoJSON 或 Shapefile,地址样本是数据库导出的文本。第一步统一成 DataFrame,第二步把代码列统一成 12 位字符串,第三步按代码左连接。
import re import pandas as pd def norm_name(s: str) -> str: """全角转半角、去空白、统一括号;只用于比对,不覆盖原值""" if pd.isna(s): return "" s = str(s).strip() s = s.translate(str.maketrans("0123456789()", "0123456789()")) s = re.sub(r"\s+", "", s) return s # 三份源:区划代码表、地图边界属性表、业务地址样本 div = pd.read_csv("div_code_snapshot.csv", dtype={"code": str}) geo = pd.read_csv("geo_admin_attrs.csv", dtype={"adcode": str}) addr = pd.read_csv("addr_samples.csv", dtype=str) # 关键一:统一右补零到 12 位,杜绝 6/9/12 位混用 div["code"] = div["code"].str.strip().str.ljust(12, "0") geo["adcode"] = geo["adcode"].str.strip().str.ljust(12, "0") # 关键二:只按 code 连接,并强制 1:1,行数一膨胀立刻报错 wide = div.merge( geo[["adcode", "lng", "lat", "boundary_wkt"]], left_on="code", right_on="adcode", how="left", validate="1:1", indicator=True ) # 关键三:统计坐标覆盖率,低于阈值说明地图源的口径对不上 hit = (wide["_merge"] == "both").mean() print(f"坐标命中率: {hit:.2%}") assert hit > 0.95, "命中率过低,先检查 adcode 口径而不是继续往下跑"逻辑说明几个点。ljust(12, "0")而不是zfill,是因为区划代码的高位有实际含义,补零只能补在低位,zfill补在左边会把1001变成000000001001,语义直接反了。validate="1:1"是这套流程里最值钱的参数,它会在连接键重复时直接抛异常,而不是默默做笛卡尔积——少了它,你很可能拿着一份膨胀了三倍的表跑完整条链路才发现不对。
最后那个assert是血泪经验的产物。坐标命中率低于 95%,八成不是数据缺失,而是两个源的编码口径不同:一边是完整的 12 位,一边只给到 6 位县级。这时候正确的做法是把两边都截断到 6 位再连接,补完县级坐标后再用乡镇级的边界去切。
# 口径不一致时的降级连接:截到 6 位县级对齐 div["code6"] = div["code"].str[:6] geo["code6"] = geo["adcode"].str[:6] county_geo = (geo.groupby("code6")[["lng", "lat"]] .first().reset_index()) # 一个县只取一个代表点 wide = wide.merge(county_geo, on="code6", how="left", suffixes=("", "_county")) wide["lng"] = wide["lng"].fillna(wide["lng_county"]) wide["lat"] = wide["lat"].fillna(wide["lat_county"])groupby().first()这里取的是该县下第一条记录,虽然粗糙但可控:县级代表点本来就只是个兜底值,精度要求不高,真要精确到乡镇还得靠后面的面数据。别在这上面花太多时间调优,收益极低。
3.3 乡镇街道这一级怎么补全:从村居代码回滚最稳
乡镇街道是整个表里最容易缺的一层。很多公开数据只到县级,6 位代码一给,往下就没了。但这一层恰恰是业务最需要的——配送网点、社区网格、门店归属,基本都落在街道乡镇这一级。
最可靠的办法是「从下往上回滚」:只要手上有村居级(12 位)的数据,直接code[:9]去重,就能得到完整的乡镇街道清单,不需要另外找源。因为 12 位代码的第 7-9 位本来就是乡镇街道编码,code[:9]天然就是它的主键。
village = pd.read_csv("village_level.csv", dtype={"code": str}) village["code"] = village["code"].str.ljust(12, "0") town = (village.assign(code9=village["code"].str[:9]) .groupby("code9") .agg(name=("town_name", "first"), parent_code=("code", lambda s: s.iloc[0][:6].ljust(12, "0"))) .reset_index() .rename(columns={"code9": "code"})) town["level"] = 4 print(f"回滚出乡镇街道 {len(town)} 条")parent_code取的是任意一个子节点前 6 位再补零,因为同一个乡镇下所有村居的前 6 位必然相同,取第一条就够了。这比去县级表里按名称模糊匹配靠谱得多——名称匹配遇到重名就得人工核,几百条人工核一天就没了。
如果连村居级数据都没有,只剩三条退路:地图 POI 按坐标反查所属街道、邮政网点地址反推、业务地址样本做文本聚类后人工确认。三条路都有误差,我的习惯是先用第一条把覆盖率拉到 90% 以上,剩下的 10% 挂着不动,等业务方真正用到那一片时再单独处理,不做无谓的全量死磕。
4. 经纬度落到乡镇街道:质心、面边界与坐标系转换
坐标这一块,坑比区划本身还密。同一批数据里,坐标可能来自 GPS 采集、地图点选、接口返回三种渠道,背后对应三种不同的坐标系,混在一起用,整体偏移三五百米是常态,而且偏得还很一致——所有点都往同一个方向漂,肉眼看地图根本发现不了。
4.1 质心、内点还是面:三种几何精度各自适合什么场景
给一个行政区配坐标,至少有三种选择。质心是几何中心,算法简单,但有个致命问题:对于形状不规则的区域,质心可能落在区域外面。一个 C 形的区、一个有飞地的街道,质心掉到隔壁行政区的正中,这种事在沿海和矿区特别常见。
内点(point on surface)保证落在多边形内部,算法会从质心出发找一个真正在面内的点。它比质心靠谱得多,成本也就多一次计算,是我默认给乡镇街道配代表点时用的方案。缺点是它不保证位置「居中好看」,有时候会贴到边界上,前端展示时略显别扭,但业务正确性优先。
面边界精度最高,能直接做点在面内判定和面积计算,代价是数据量。全国乡镇街道级的面数据,抽稀后几十到上百兆是常态,直接塞进业务库并不合适。我的分层策略是:省级和市级存面,用于大范围筛选和地图渲染;县级存内点加面;乡镇街道存内点,面数据单独放一张几何表,只在需要做点在面内校验时才查。
飞地和插花地是这块最容易被忽略的细节。某个区在另一个区内部有一小块地,这种行政上的历史遗留非常普遍。质心法在这里必错,只有面数据能救。如果你的业务涉及网格化管理或者社区归属判定,乡镇街道这一级的面数据不能省。
4.2 WGS84、GCJ-02、BD-09:坐标转换写错一步就差五百米
三个坐标系的关系要理清楚。WGS84 是 GPS 原始的球面坐标,国际通用;GCJ-02 是在 WGS84 基础上做过非线性偏移的坐标,国内主流地图服务返回的多是这一种;BD-09 又是在 GCJ-02 上再叠一层偏移。三者之间偏几百米,且偏移量随经纬度变化,不是一个固定常数能补的。
工程上的铁律是:入库统一 WGS84,出图前统一转换。数据库里只存一种,谁要用谁转,绝不混存。混存之后你永远不知道某条记录的坐标到底是哪个系的,排查一次得半天。
import math A = 6378245.0 # 椭球长半轴 EE = 0.00669342162296594323 # 第一偏心率平方 def _tlat(x, y): r = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*math.sqrt(abs(x)) r += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 r += (20.0*math.sin(y*math.pi) + 40.0*math.sin(y/3.0*math.pi)) * 2.0/3.0 r += (160.0*math.sin(y/12.0*math.pi) + 320*math.sin(y*math.pi/30.0)) * 2.0/3.0 return r def _tlng(x, y): r = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*math.sqrt(abs(x)) r += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 r += (20.0*math.sin(x*math.pi) + 40.0*math.sin(x/3.0*math.pi)) * 2.0/3.0 r += (150.0*math.sin(x/12.0*math.pi) + 300.0*math.sin(x/30.0)*math.pi) * 2.0/3.0 return r def wgs84_to_gcj02(lng, lat): """WGS84 -> GCJ-02,用于把库里的坐标投到国内底图上""" if not (72.004 <= lng <= 137.8347 and 0.8293 <= lat <= 55.8271): return lng, lat # 范围外不偏移 dlat = _tlat(lng - 105.0, lat - 35.0) dlng = _tlng(lng - 105.0, lat - 35.0) rad = lat / 180.0 * math.pi magic = 1 - EE * math.sin(rad) ** 2 sq = math.sqrt(magic) dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sq) * math.pi) dlng = (dlng * 180.0) / (A / sq * math.cos(rad) * math.pi) return lng + dlng, lat + dlat def gcj02_to_wgs84(lng, lat, tol=1e-7, max_iter=10): """反向没有解析解,用迭代逼近;tol 单位是度,1e-7 约等于 1 厘米""" wlng, wlat = lng, lat for _ in range(max_iter): glng, glat = wgs84_to_gcj02(wlng, wlat) dlng, dlat = glng - lng, glat - lat if abs(dlng) < tol and abs(dlat) < tol: break wlng -= dlng wlat -= dlat return wlng, wlat参数说明:tol设到1e-7度,换算下来大约一厘米,对配送场景够用了;max_iter一般 3 到 4 次就收敛,设 10 只是保险。范围判定那段不能删——境外坐标做这套偏移会算飞,历史上有人拿它处理海外门店坐标,结果全落到海里。
BD-09 与 GCJ-02 之间的换算是另一组常数,量级小得多,通常在地图 SDK 里已经处理掉,除非你要直接对接某家地图的瓦片服务,否则不用单独实现。
4.3 用 PostGIS 做点在面内校验和最近邻兜底
有了面数据,最实用的一个能力是「给一个坐标,告诉我它属于哪个街道」。这比任何文本地址解析都准,因为地址可以写错、可以写简称,坐标不会撒谎。
-- 点在面内:判断坐标落在哪个乡镇街道 SELECT code, name FROM ad_div WHERE level = 4 AND valid_to IS NULL AND ST_Contains(geom, ST_SetSRID(ST_Point(:lng, :lat), 4326)) LIMIT 1;ST_Contains走的是 GiST 索引,单点查询在几十万面的表上通常几毫秒到几十毫秒。注意必须先用ST_SetSRID(..., 4326)把点显式标成 WGS84,否则 PostGIS 会拿 SRID 0 去做比较,直接报错或者给出荒谬结果。
但ST_Contains有个必须兜底的情况:坐标刚好落在边界上、或者落在两个面的缝隙里(边界数据抽稀后缝隙很常见),会返回空。这时候不能直接判失败,要退到最近邻。
-- 最近邻兜底:取距离最近的两个乡镇,附带米级距离 SELECT d.code, d.name, ST_Distance(d.geom::geography, p.pt::geography) AS dist_m FROM ad_div d, (SELECT ST_SetSRID(ST_Point(:lng, :lat), 4326) AS pt) p WHERE d.level = 4 AND d.valid_to IS NULL ORDER BY d.geom <-> p.pt -- 走 GiST 索引的 KNN 排序 LIMIT 2;<->是 PostGIS 的距离操作符,能直接利用 GiST 索引做 K 近邻排序,比写ORDER BY ST_Distance(...)快一两个数量级。但<->返回的是球面度数,不能直接当米用,所以要在 SELECT 里再套一层::geography转成米。业务侧的判定规则一般是:最近距离小于 500 米就认为归属可信,500 到 2000 米标记为「疑似」进人工复核队列,超过 2000 米直接判为坐标有问题。
5. 避坑:五级地址表落地最常见的 5 个翻车点
5.1 用名称做连接,行数悄悄翻倍
现象:两份表 merge 之后从 32 万条变成 41 万条,代码不报错,下游报表数字全错。 原因:乡镇街道名称不唯一,全国叫「城关镇」「新华街道」的重名单位极多,名称 join 天然产生笛卡尔积。 解决:所有连接只准用代码列,并且强制加validate="1:1"或validate="m:1";确实需要按名称匹配的,单独建一张别名表,一条 code 对多条别名,然后按 code 回连。
5.2 坐标系搞混,门店整体漂移三百米
现象:门店在地图上显示的位置在马路对面小区,所有门店偏的方向还都一样。 原因:底图用 GCJ-02,入库数据是 WGS84,中间少了一步转换;或者一部分数据转了、一部分没转。 解决:入库统一 WGS84 并显式写进字段注释;写一个自检脚本,随机抽 100 个点,转成 GCJ-02 后和底图做叠加,偏移量超过 50 米就告警。这个脚本我一般放在数据流水线的最后一步,跑一次几秒钟,能省掉无数次线下核店。
5.3 把邮编当区划属性,一个区查出七个邮编
现象:给区划表加了zip_code列,收货地址邮编校验大面积失败。 原因:邮编是投递局维度,一个县级单位对应多个邮编,一个邮编也可能跨乡镇。 解决:拆成独立的映射表,按「村居 > 乡镇 > 县级」优先级回退,别在区划表上硬塞一列。
5.4 只存现行快照,跨年报表对不上
现象:去年报表里某县贡献了三成单量,今年同样的查询里这个县消失了,数字全跑到市辖区名下。 原因:只保存了现行区划,撤县设区之后旧代码被物理删除或覆盖。 解决:上双时态,valid_from/valid_to一个都不能少;订单落库时同时记录「下单时的区划代码」和「下单时间」,做历史归集时按订单时间反查当时有效的区划,而不是用现在的表去 join。
5.5 边界抽稀过度,沿海的点判不出归属
现象:ST_Contains对沿海街道的一部分坐标返回空,明明地址就在那里。 原因:公开边界数据做过抽稀,海岸线和复杂边界被简化,实际陆地落到了简化后的多边形外面。 解决:面数据在入库前统一做一次ST_Buffer(geom, 0.0005)的小幅外扩(约 50 米),再用ST_Contains;同时保留最近邻兜底逻辑。外扩会有轻微的归属误判,但对配送这种场景,误判到隔壁街道远比判不出归属要好处理。
6. 增量对账:一份能自动发现「撤县设区」的自检脚本
区划数据不是一次性工程。每年都有一批调整,你不可能靠人盯着公告一条条改。我的做法是:每年拉一次全量快照存成带日期后缀的版本,每月跑一次 diff,把变更分成四类落表,人工只需要审核「父级变更」这一类,其余自动合并。
| 变更类型 | 判定条件 | 处理动作 |
|---|---|---|
| 新增 | 新快照有、旧快照无的 code | 自动入库,valid_from设为快照日期 |
| 撤销 | 旧快照有、新快照无的 code | 不删除,回填valid_to |
| 改名 | code 相同但name不同 | 自动更新name,旧名写入别名表 |
| 父级变更 | code 相同但parent_code不同 | 人工审核,涉及跨市划转,子树路径要重算 |
import pandas as pd def diff_snapshot(old: pd.DataFrame, new: pd.DataFrame) -> dict: o = old.set_index("code") n = new.set_index("code") common = o.index.intersection(n.index) return { "added": n.index.difference(o.index).tolist(), "removed": o.index.difference(n.index).tolist(), "renamed": common[o.loc[common, "name"] != n.loc[common, "name"]].tolist(), "moved": common[o.loc[common, "parent_code"] != n.loc[common, "parent_code"]].tolist(), } res = diff_snapshot(snap_2024, snap_2025) print({k: len(v) for k, v in res.items()}) # 典型输出:{'added': 214, 'removed': 87, 'renamed': 156, 'moved': 12}removed这一类的处理方式最值得说一句:绝对不要物理删除。区划撤销之后,历史订单、历史报表、外部系统里存的老代码仍然指向它。物理删了,那些数据就变成孤儿,做同比的时候你连「这个代码以前叫什么」都查不到。正确做法是回填valid_to,让它留在表里但不再参与现行查询。
moved的数量通常最小,但杀伤力最大。跨市划转会导致整棵子树的路径串失效,必须重算path字段,还要检查有没有外部系统缓存了旧的层级关系。我吃过一次亏:一个县级单位划到隔壁市,代码没变、名称没变,只有parent_code变了,脚本没有监控这个字段,结果按市聚合的报表整整错了一个季度,直到业务方拿着两个数字对不上来问才发现。从那以后,moved这类变更我一律打上告警,宁可多响几次也不要静默通过。
顺带提一个习惯:每次跑完 diff,把结果连同快照日期一起写进一张变更日志表,保留至少三年。等到某天有人问「为什么去年这个区的数字和今年对不上」,你翻日志五分钟就能给出答案,而不是从头再推一遍。这套东西做扎实了,后面所有的地址解析、网点匹配、区域报表都省心。希望帮到你。
本文还有配套的精品资源,点击获取