Skip to content

Commit ca38696

Browse files
committed
Add building identity normalization and matching policy
1 parent 396eb4a commit ca38696

5 files changed

Lines changed: 501 additions & 1 deletion

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildBuildingIdentityIndex, buildingIdentityFromProperties, buildingRecordMatchState, matchBuildingIdentity, normalizeBuildingAddress, normalizeBuildingIdentity } from "./buildingIdentity";
3+
4+
describe("building identity normalization", () => {
5+
it("normalizes identifier formatting without converting identifiers to numbers", () => {
6+
expect(normalizeBuildingIdentity(" ab-001_02 ")).toBe("AB00102");
7+
expect(normalizeBuildingIdentity("0002911010100100010000")).toBe("0002911010100100010000");
8+
expect(normalizeBuildingAddress(" 광주광역시 동구 1-1 ")).toBe("광주광역시 동구 1-1");
9+
});
10+
11+
it("extracts known aliases while retaining source values as strings", () => {
12+
expect(buildingIdentityFromProperties({ BD_MGT_SN: "A-1", PNU: "0001", 주소: "광주 동구 1-1", GID: 12 }, "source-1")).toMatchObject({ sourceRecordId: "source-1", buildingManagementNo: "A-1", pnu: "0001", address: "광주 동구 1-1", gid: 12 });
13+
});
14+
});
15+
16+
describe("building identity matching", () => {
17+
const masters = [
18+
{ masterBuildingId: "master-a", buildingManagementNo: "A-1", pnu: "0001", address: "광주 동구 1-1" },
19+
{ masterBuildingId: "master-b", buildingManagementNo: "B-1", pnu: "0002", address: "광주 동구 2-1" },
20+
];
21+
22+
it("matches one master by a strong identifier and records the evidence", () => {
23+
const result = matchBuildingIdentity({ sourceRecordId: "source-a", buildingManagementNo: " a 1 " }, masters);
24+
expect(result).toMatchObject({ masterBuildingId: "master-a", status: "matched", confidence: "exact", matchedFields: ["buildingManagementNo"] });
25+
expect(result.matchEvidence[0]).toMatchObject({ field: "buildingManagementNo", value: "A1", masterBuildingIds: ["master-a"] });
26+
expect(buildingRecordMatchState(result)).toEqual({ matchStatus: "matched", matchConfidence: "exact" });
27+
});
28+
29+
it("does not promote an address-only match to a confirmed building", () => {
30+
const result = matchBuildingIdentity({ sourceRecordId: "source-a", address: "광주 동구 1-1" }, masters);
31+
expect(result).toMatchObject({ masterBuildingId: "master-a", status: "candidate", confidence: "candidate" });
32+
expect(result.notes.join(" ")).toContain("geometry");
33+
});
34+
35+
it("returns unmatched when no identifier or address is found", () => {
36+
const result = matchBuildingIdentity({ sourceRecordId: "source-x", pnu: "9999", address: "광주 동구 없음" }, masters);
37+
expect(result).toMatchObject({ status: "unmatched", confidence: "unknown", conflictRecordIds: [] });
38+
expect(result.unmatchedFields).toContain("pnu");
39+
});
40+
41+
it("returns conflict when identifiers point to different master buildings", () => {
42+
const result = matchBuildingIdentity({ sourceRecordId: "source-conflict", buildingManagementNo: "A-1", pnu: "0002" }, masters);
43+
expect(result).toMatchObject({ status: "conflict", confidence: "unknown", conflictRecordIds: ["master-a", "master-b"] });
44+
expect(result.matchedFields).toEqual(["buildingManagementNo", "pnu"]);
45+
});
46+
47+
it("treats an ambiguous address as a conflict rather than selecting arbitrarily", () => {
48+
const ambiguous = [...masters, { masterBuildingId: "master-c", address: "광주 동구 1-1" }];
49+
const result = matchBuildingIdentity({ sourceRecordId: "source-ambiguous", address: "광주 동구 1-1" }, ambiguous);
50+
expect(result).toMatchObject({ status: "conflict", confidence: "candidate", conflictRecordIds: ["master-a", "master-c"] });
51+
});
52+
53+
it("reuses a prebuilt index for repeated source matching", () => {
54+
const index = buildBuildingIdentityIndex(masters);
55+
expect(matchBuildingIdentity({ buildingManagementNo: "B-1" }, masters, index).masterBuildingId).toBe("master-b");
56+
});
57+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
export type BuildingIdentityField = "buildingManagementNo" | "bldrgstPk" | "ufid" | "pnu" | "gid" | "featureId";
2+
3+
export type BuildingIdentityInput = {
4+
sourceRecordId?: string;
5+
buildingManagementNo?: unknown;
6+
bldrgstPk?: unknown;
7+
ufid?: unknown;
8+
pnu?: unknown;
9+
gid?: unknown;
10+
featureId?: unknown;
11+
address?: unknown;
12+
};
13+
14+
export type MasterBuildingIdentity = BuildingIdentityInput & { masterBuildingId: string };
15+
export type BuildingMatchStatus = "matched" | "candidate" | "unmatched" | "conflict";
16+
export type BuildingMatchConfidence = "unknown" | "candidate" | "partial" | "strong" | "exact";
17+
18+
export type BuildingMatchEvidence = {
19+
field: BuildingIdentityField | "address";
20+
value: string;
21+
masterBuildingIds: string[];
22+
};
23+
24+
export type BuildingMatchDecision = {
25+
sourceRecordId?: string;
26+
masterBuildingId?: string;
27+
status: BuildingMatchStatus;
28+
confidence: BuildingMatchConfidence;
29+
matchedFields: BuildingIdentityField[];
30+
matchEvidence: BuildingMatchEvidence[];
31+
conflictRecordIds: string[];
32+
unmatchedFields: BuildingIdentityField[];
33+
notes: string[];
34+
};
35+
36+
export const buildingIdentityPriority: BuildingIdentityField[] = ["buildingManagementNo", "bldrgstPk", "ufid", "pnu", "gid", "featureId"];
37+
const exactIdentityFields: BuildingIdentityField[] = ["buildingManagementNo", "bldrgstPk", "ufid"];
38+
39+
function normalizedKey(value: unknown) {
40+
if (value === undefined || value === null) return "";
41+
return String(value).trim().toUpperCase().replace(/[\s_-]/g, "");
42+
}
43+
44+
export function normalizeBuildingIdentity(value: unknown) {
45+
return normalizedKey(value);
46+
}
47+
48+
export function normalizeBuildingAddress(value: unknown) {
49+
if (value === undefined || value === null) return "";
50+
return String(value).trim().replace(/\s+/g, " ");
51+
}
52+
53+
export function buildingIdentityFromProperties(properties: Record<string, unknown>, sourceRecordId?: string): BuildingIdentityInput {
54+
const aliases: Record<BuildingIdentityField | "address", string[]> = {
55+
buildingManagementNo: ["buildingManagementNo", "bd_mgt_sn", "bld_mng_no", "bldg_mng_no", "building_management_no", "건축물대장관리번호"],
56+
bldrgstPk: ["bldrgst_pk", "bldrgstPk", "건축물대장pk"],
57+
ufid: ["ufid", "UFID"],
58+
pnu: ["pnu", "PNU"],
59+
gid: ["gid", "GID"],
60+
featureId: ["featureId", "feature_id", "id"],
61+
address: ["address", "addr", "jibun_addr", "road_addr", "주소", "소재지"],
62+
};
63+
const normalizedProperties = Object.entries(properties).map(([key, value]) => [key.toLowerCase().replace(/[\s_-]/g, ""), value] as const);
64+
const read = (field: keyof typeof aliases) => {
65+
const keys = aliases[field].map(key => key.toLowerCase().replace(/[\s_-]/g, ""));
66+
return normalizedProperties.find(([key, value]) => keys.includes(key) && String(value ?? "").trim() !== "")?.[1];
67+
};
68+
return { sourceRecordId, buildingManagementNo: read("buildingManagementNo"), bldrgstPk: read("bldrgstPk"), ufid: read("ufid"), pnu: read("pnu"), gid: read("gid"), featureId: read("featureId"), address: read("address") };
69+
}
70+
71+
export function buildBuildingIdentityIndex(records: MasterBuildingIdentity[]) {
72+
const identityIndex = new Map<string, Set<string>>();
73+
const addressIndex = new Map<string, Set<string>>();
74+
records.forEach(record => {
75+
buildingIdentityPriority.forEach(field => {
76+
const value = normalizeBuildingIdentity(record[field]);
77+
if (!value) return;
78+
const key = `${field}:${value}`;
79+
const ids = identityIndex.get(key) ?? new Set<string>();
80+
ids.add(record.masterBuildingId);
81+
identityIndex.set(key, ids);
82+
});
83+
const address = normalizeBuildingAddress(record.address);
84+
if (address) {
85+
const ids = addressIndex.get(address) ?? new Set<string>();
86+
ids.add(record.masterBuildingId);
87+
addressIndex.set(address, ids);
88+
}
89+
});
90+
return { identityIndex, addressIndex };
91+
}
92+
93+
export function buildingRecordMatchState(decision: BuildingMatchDecision) {
94+
return { matchStatus: decision.status === "matched" ? "matched" as const : decision.status, matchConfidence: decision.confidence };
95+
}
96+
97+
export function matchBuildingIdentity(source: BuildingIdentityInput, masters: MasterBuildingIdentity[], indexes = buildBuildingIdentityIndex(masters)): BuildingMatchDecision {
98+
const sourceFields = buildingIdentityPriority.filter(field => normalizeBuildingIdentity(source[field]));
99+
const matchedByMaster = new Map<string, BuildingIdentityField[]>();
100+
const evidence: BuildingMatchEvidence[] = [];
101+
sourceFields.forEach(field => {
102+
const value = normalizeBuildingIdentity(source[field]);
103+
const ids = Array.from(indexes.identityIndex.get(`${field}:${value}`) ?? []);
104+
if (!ids.length) return;
105+
ids.forEach(id => matchedByMaster.set(id, [...(matchedByMaster.get(id) ?? []), field]));
106+
evidence.push({ field, value, masterBuildingIds: ids });
107+
});
108+
const matchedIds = Array.from(matchedByMaster.keys());
109+
const unmatchedFields = sourceFields.filter(field => !evidence.some(item => item.field === field));
110+
if (matchedIds.length > 1) {
111+
return { sourceRecordId: source.sourceRecordId, status: "conflict", confidence: "unknown", matchedFields: Array.from(new Set(matchedIds.flatMap(id => matchedByMaster.get(id) ?? []))), matchEvidence: evidence, conflictRecordIds: matchedIds, unmatchedFields, notes: ["서로 다른 식별자가 여러 master 건축물에 연결됩니다.", "어느 건축물로도 자동 확정하지 않았습니다."] };
112+
}
113+
if (matchedIds.length === 1) {
114+
const matchedFields = matchedByMaster.get(matchedIds[0]) ?? [];
115+
const exact = matchedFields.some(field => exactIdentityFields.includes(field));
116+
const confidence: BuildingMatchConfidence = exact ? "exact" : matchedFields.length >= 2 ? "strong" : "partial";
117+
return { sourceRecordId: source.sourceRecordId, masterBuildingId: matchedIds[0], status: "matched", confidence, matchedFields, matchEvidence: evidence, conflictRecordIds: [], unmatchedFields, notes: exact ? ["강한 건축물 식별자가 하나의 master에 정확히 일치합니다."] : ["보조 식별자로 일치했으며 주 식별자 확인이 필요합니다."] };
118+
}
119+
const address = normalizeBuildingAddress(source.address);
120+
const addressIds = address ? Array.from(indexes.addressIndex.get(address) ?? []) : [];
121+
if (addressIds.length > 1) {
122+
return { sourceRecordId: source.sourceRecordId, status: "conflict", confidence: "candidate", matchedFields: [], matchEvidence: [{ field: "address", value: address, masterBuildingIds: addressIds }], conflictRecordIds: addressIds, unmatchedFields, notes: ["동일 주소에 여러 master 건축물 후보가 있어 주소만으로 확정할 수 없습니다."] };
123+
}
124+
if (addressIds.length === 1) {
125+
return { sourceRecordId: source.sourceRecordId, masterBuildingId: addressIds[0], status: "candidate", confidence: "candidate", matchedFields: [], matchEvidence: [{ field: "address", value: address, masterBuildingIds: addressIds }], conflictRecordIds: [], unmatchedFields, notes: ["주소만 일치한 후보입니다.", "geometry 또는 주 식별자 검증 전에는 확정하지 않습니다."] };
126+
}
127+
return { sourceRecordId: source.sourceRecordId, status: "unmatched", confidence: "unknown", matchedFields: [], matchEvidence: [], conflictRecordIds: [], unmatchedFields, notes: ["일치하는 master 건축물을 찾지 못했습니다."] };
128+
}

development/TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
- [-] B-01. 현재 건축물 자료원과 QGIS 처리 방식 조사
2020
- [x] B-02. 거시·중간·대지·미시 조사 범위와 자료량 정책 확정
2121
- [x] B-03. 원본·정규화·현장관찰·분석결과 데이터 구조 확정
22-
- [ ] B-04. 건축물 식별자와 자료 간 결합 정책 확정
22+
- [x] B-04. 건축물 식별자와 자료 간 결합 정책 확정
2323

2424
## C. 주변 건축물 데이터 연결·정규화
2525

@@ -76,3 +76,4 @@
7676
| 2026-08-29 | B-01 현재 건축물 자료원·QGIS 처리 방식 조사 | 완료 | 감사 문서 작성·검증 완료 | B-02·B-03에서 결합·범위 정책 설계 |
7777
| 2026-08-29 | B-02 거시·중간·대지·미시 조사 범위와 자료량 정책 | 완료 | 범위 정책 함수·필드 정책·테스트 통과 | B-03 데이터 구조에서 적용 |
7878
| 2026-08-29 | B-03 원본·정규화·현장관찰·분석결과 데이터 구조 | 완료 | 데이터 타입·프로젝트 연결·역호환 테스트 통과 | B-04 식별자 결합에서 적용 |
79+
| 2026-08-29 | B-04 건축물 식별자와 자료 간 결합 정책 | 완료 | 정규화·인덱스·일치·후보·충돌 테스트 통과 | C 단계 실제 원자료에서 적용 |
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# 건축물 식별자·자료 결합 정책
2+
3+
작성일: 2026-08-29
4+
상위 작업: `development/tasks/B-04-building-identity-matching.md`
5+
6+
## 1. 원칙
7+
8+
서로 다른 자료의 feature를 하나의 건축물로 결합할 때, 주소·geometry·용도만으로 확정하지 않는다. 강한 식별자와 보조 식별자를 구분하고, 결합 근거와 충돌을 함께 저장한다.
9+
10+
```text
11+
원본 feature
12+
→ 식별자 정규화
13+
→ master index 조회
14+
→ 일치·후보·미결합·충돌 판정
15+
→ matchEvidence 저장
16+
```
17+
18+
## 2. 식별자 우선순위
19+
20+
```text
21+
1. buildingManagementNo
22+
bd_mgt_sn, bld_mng_no, bldg_mng_no
23+
24+
2. bldrgstPk
25+
bldrgst_pk, 건축물대장 PK
26+
27+
3. ufid
28+
29+
4. pnu
30+
31+
5. gid 또는 featureId
32+
```
33+
34+
주소는 별도 후보 검색에만 사용한다.
35+
36+
## 3. 정규화
37+
38+
### 식별자
39+
40+
```text
41+
문자열 변환
42+
앞뒤 공백 제거
43+
대문자 통일
44+
공백·하이픈·밑줄 제거
45+
빈 값은 식별자로 사용하지 않음
46+
```
47+
48+
PNU는 숫자로 변환하지 않는다. 앞자리 0이 사라지지 않도록 문자열로 보존한다.
49+
50+
### 주소
51+
52+
```text
53+
앞뒤 공백 제거
54+
연속 공백을 하나로 통합
55+
원본 주소와 비교용 주소를 분리
56+
```
57+
58+
현재 주소 비교는 기본적인 공백 정규화만 수행한다. 도로명·지번 주소 변환은 후속 데이터 연결 작업에서 별도 검토한다.
59+
60+
## 4. 판정 기준
61+
62+
| 상태 | 조건 | 해석 |
63+
|---|---|---|
64+
| `matched / exact` | 관리번호·건축물대장 PK·UFID 중 하나가 하나의 master에 일치 | 자동 결합 가능 |
65+
| `matched / partial` | PNU·GID·feature ID 하나만 일치 | 보조 식별자 확인 필요 |
66+
| `matched / strong` | 보조 식별자 2개 이상이 하나의 master에 일치 | 강한 후보지만 원자료 확인 필요 |
67+
| `candidate / candidate` | 주소만 하나의 master에 일치 | geometry·주 식별자 검증 전 확정 금지 |
68+
| `unmatched / unknown` | 일치하는 식별자·주소가 없음 | 새 master 또는 자료 공백 |
69+
| `conflict` | 식별자가 서로 다른 master에 연결되거나 주소 후보가 여러 개 | 어느 값도 자동 확정 금지 |
70+
71+
## 5. 충돌 처리
72+
73+
다음 사례는 `conflict`로 저장한다.
74+
75+
```text
76+
source 관리번호 → master A
77+
source PNU → master B
78+
79+
같은 관리번호 → master A와 master B
80+
81+
동일 주소 → master A와 master B
82+
```
83+
84+
충돌 시 보존하는 값:
85+
86+
```text
87+
conflictRecordIds
88+
conflictingFields
89+
matchEvidence
90+
unmatchedFields
91+
notes
92+
```
93+
94+
## 6. 구현 파일
95+
96+
```text
97+
client/src/static/buildingIdentity.ts
98+
client/src/static/buildingIdentity.test.ts
99+
```
100+
101+
제공 함수:
102+
103+
```text
104+
normalizeBuildingIdentity()
105+
normalizeBuildingAddress()
106+
buildingIdentityFromProperties()
107+
buildBuildingIdentityIndex()
108+
matchBuildingIdentity()
109+
buildingRecordMatchState()
110+
```
111+
112+
`buildingIdentityFromProperties()`는 VWorld에서 사용될 수 있는 필드 별칭을 공통 식별자 구조로 변환한다.
113+
114+
## 7. 현재 결합의 한계
115+
116+
이번 단계는 결합 정책과 판정 함수만 구현했다.
117+
118+
아직 다음은 하지 않았다.
119+
120+
```text
121+
실제 API 응답별 필드 확정
122+
geometry overlap 기반 재검증
123+
건축HUB PK 변환
124+
주소 표준화
125+
용도·층수·면적 값 병합
126+
실제 building_master 생성
127+
```
128+
129+
따라서 현재 `exact`라도 실제 자료원에서 동일 식별자의 의미를 확인하는 C 단계가 필요하다.
130+
131+
## 8. 다음 단계 연결
132+
133+
```text
134+
C-01 VWorld footprint 원자료 수집·품질검사
135+
C-02 용도별건물정보·건축HUB 속성자료 연결
136+
C-05 건축물별 통합 마스터와 결합 신뢰도 생성
137+
```
138+
139+
이번 정책은 이후 실제 응답에서 확인한 필드명과 식별자 의미를 반영해 확장한다.

0 commit comments

Comments
 (0)