|
| 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 | +} |
0 commit comments