|
|
---
|
|
|
license: apache-2.0
|
|
|
---
|
|
|
|
|
|
# address - Vietnamese Address Converter
|
|
|
|
|
|
Chuyển đổi địa chỉ Việt Nam từ hệ thống hành chính cũ (63 tỉnh/thành phố, 3 cấp) sang hệ thống mới sau sáp nhập (34 tỉnh/thành phố, 2 cấp), có hiệu lực từ 01/07/2025.
|
|
|
|
|
|
## Technical Report
|
|
|
|
|
|
### 1. Bối cảnh
|
|
|
|
|
|
Nghị quyết của Quốc hội về sáp nhập đơn vị hành chính có hiệu lực ngày 01/07/2025, thay đổi cấu trúc hành chính Việt Nam:
|
|
|
|
|
|
| | Cũ | Mới |
|
|
|
|---|---|---|
|
|
|
| Tỉnh/Thành phố | 63 | 34 |
|
|
|
| Cấp hành chính | Tỉnh > Huyện > Xã | Tỉnh > Xã |
|
|
|
| Tổng số xã/phường | ~10,600 | ~3,300 |
|
|
|
|
|
|
Cấp huyện (quận/huyện/thị xã/thành phố trực thuộc tỉnh) bị loại bỏ hoàn toàn khỏi địa chỉ hành chính. Các xã/phường được sáp nhập, đổi tên, chia tách hoặc giữ nguyên tùy khu vực.
|
|
|
|
|
|
### 2. Kiến trúc hệ thống
|
|
|
|
|
|
```
|
|
|
address/
|
|
|
├── pyproject.toml # Package config (uv + hatchling)
|
|
|
├── data/
|
|
|
│ └── mapping.json # 10,602 bản ghi mapping (4.7 MB)
|
|
|
├── src/
|
|
|
│ ├── __init__.py # Public API
|
|
|
│ ├── models.py # Data models & enums
|
|
|
│ ├── normalizer.py # Chuẩn hóa tiếng Việt
|
|
|
│ ├── parser.py # Phân tích chuỗi địa chỉ
|
|
|
│ ├── converter.py # Logic chuyển đổi chính
|
|
|
│ └── cli.py # CLI (click)
|
|
|
├── scripts/
|
|
|
│ └── build_mapping.py # Tạo mapping.json từ vietnamadminunits
|
|
|
└── tests/
|
|
|
└── test_converter.py # 13 test cases
|
|
|
```
|
|
|
|
|
|
**Nguyên tắc thiết kế**: Package hoạt động standalone — dữ liệu mapping được extract một lần từ `vietnamadminunits` (dev dependency) vào `data/mapping.json`, sau đó runtime chỉ cần `click` là dependency duy nhất.
|
|
|
|
|
|
### 3. Nguồn dữ liệu
|
|
|
|
|
|
Dữ liệu mapping được extract từ package `vietnamadminunits` v1.0.4 (PyPI, MIT license) thông qua script `scripts/build_mapping.py`. Package này cung cấp 3 file JSON chính:
|
|
|
|
|
|
| File | Nội dung | Kích thước |
|
|
|
|---|---|---|
|
|
|
| `converter_2025.json` | Mapping cũ → mới (province + ward) | 596 KB |
|
|
|
| `parser_legacy.json` | Dữ liệu hành chính cũ (63 tỉnh, 3 cấp) | 2.6 MB |
|
|
|
| `parser_from_2025.json` | Dữ liệu hành chính mới (34 tỉnh, 2 cấp) | 957 KB |
|
|
|
|
|
|
**Cấu trúc dữ liệu gốc** (trong `converter_2025.json`):
|
|
|
|
|
|
- `DICT_PROVINCE`: `{new_province_key: [old_province_key_1, old_province_key_2, ...]}` — mapping tỉnh
|
|
|
- `DICT_PROVINCE_WARD_NO_DIVIDED`: `{new_prov: {new_ward: [old_compound_key, ...]}}` — xã không bị chia tách
|
|
|
- `DICT_PROVINCE_WARD_DIVIDED`: `{new_prov: {old_compound_key: [{newWardKey, isDefaultNewWard, ...}]}}` — xã bị chia tách
|
|
|
|
|
|
Compound key format: `{province_key}_{district_key}_{ward_key}` (ví dụ: `thanhphohanoi_quanbadinh_phuongphucxa`).
|
|
|
|
|
|
### 4. Build mapping (`scripts/build_mapping.py`)
|
|
|
|
|
|
Script extract và chuẩn hóa dữ liệu từ 3 file JSON của vietnamadminunits thành một file `data/mapping.json` thống nhất:
|
|
|
|
|
|
```
|
|
|
uv run python scripts/build_mapping.py
|
|
|
```
|
|
|
|
|
|
**Output `mapping.json`** chứa:
|
|
|
|
|
|
| Trường | Mô tả |
|
|
|
|---|---|
|
|
|
| `metadata` | Source, version, effective_date, thống kê |
|
|
|
| `province_mapping` | `{old_key: new_key}` — 63 entries |
|
|
|
| `province_names` | `{key: {name, short, code}}` — 34 tỉnh mới |
|
|
|
| `old_province_names` | `{key: {name, short, code}}` — 63 tỉnh cũ |
|
|
|
| `ward_mapping` | List 10,602 records chi tiết |
|
|
|
|
|
|
**Mỗi ward mapping record** gồm:
|
|
|
|
|
|
```json
|
|
|
{
|
|
|
"old_province": "Thành phố Hà Nội",
|
|
|
"old_province_key": "thanhphohanoi",
|
|
|
"old_district": "Quận Ba Đình",
|
|
|
"old_district_key": "quanbadinh",
|
|
|
"old_ward": "Phường Phúc Xá",
|
|
|
"old_ward_key": "phuongphucxa",
|
|
|
"new_province": "Thành phố Hà Nội",
|
|
|
"new_province_key": "thanhphohanoi",
|
|
|
"new_ward": "Phường Hồng Hà",
|
|
|
"new_ward_key": "phuonghongha",
|
|
|
"mapping_type": "merged"
|
|
|
}
|
|
|
```
|
|
|
|
|
|
**Phân loại mapping type:**
|
|
|
|
|
|
| Type | Số lượng | Mô tả |
|
|
|
|---|---|---|
|
|
|
| `unchanged` | 149 | Xã giữ nguyên tên |
|
|
|
| `renamed` | 92 | Xã đổi tên (1:1) |
|
|
|
| `merged` | 9,328 | Nhiều xã cũ gộp thành 1 xã mới |
|
|
|
| `divided` | 1,033 | 1 xã cũ chia thành nhiều xã mới |
|
|
|
| **Tổng** | **10,602** | |
|
|
|
|
|
|
**Logic phân loại:**
|
|
|
- `unchanged`: Chỉ có 1 compound key cũ trỏ vào ward mới VÀ tên trùng nhau
|
|
|
- `renamed`: Chỉ có 1 compound key cũ trỏ vào ward mới VÀ tên khác nhau
|
|
|
- `merged`: Nhiều compound key cũ cùng trỏ vào 1 ward mới
|
|
|
- `divided`: Từ `DICT_PROVINCE_WARD_DIVIDED`, có `is_default` flag cho mỗi option
|
|
|
|
|
|
### 5. Modules
|
|
|
|
|
|
#### 5.1 `src/models.py` — Data Models
|
|
|
|
|
|
- `MappingType(str, Enum)`: `UNCHANGED`, `RENAMED`, `MERGED`, `DIVIDED`
|
|
|
- `ConversionStatus(str, Enum)`: `SUCCESS`, `PARTIAL`, `NOT_FOUND`
|
|
|
- `AdminUnit(@dataclass)`: `province`, `district`, `ward`, `street` + `to_address()`
|
|
|
- `ConversionResult(@dataclass)`: `original`, `converted`, `status`, `mapping_type`, `old`, `new`, `note`
|
|
|
|
|
|
#### 5.2 `src/normalizer.py` — Chuẩn hóa tiếng Việt
|
|
|
|
|
|
**Ba hàm chính:**
|
|
|
|
|
|
| Hàm | Input | Output | Mô tả |
|
|
|
|---|---|---|---|
|
|
|
| `remove_diacritics()` | `"Phường Phúc Xá"` | `"Phuong Phuc Xa"` | Unicode NFKD decomposition + xử lý đ/Đ |
|
|
|
| `normalize_key()` | `"Quận Ba Đình"` | `"quanbadinh"` | Lowercase + bỏ dấu + bỏ space/punctuation |
|
|
|
| `expand_abbreviations()` | `"P.Phúc Xá, Q.Ba Đình"` | `"phường phúc xá, quận ba đình"` | Mở rộng viết tắt |
|
|
|
|
|
|
**Bảng viết tắt hỗ trợ:**
|
|
|
|
|
|
| Viết tắt | Đầy đủ |
|
|
|
|---|---|
|
|
|
| `TP.`, `T.P.` | Thành phố |
|
|
|
| `P.` | Phường |
|
|
|
| `Q.` | Quận |
|
|
|
| `H.` | Huyện |
|
|
|
| `TX.`, `T.X.` | Thị xã |
|
|
|
| `TT.`, `T.T.` | Thị trấn |
|
|
|
| `X.` | Xã |
|
|
|
|
|
|
#### 5.3 `src/parser.py` — Phân tích địa chỉ
|
|
|
|
|
|
Parse chuỗi địa chỉ theo format `"street, ward, district, province"` bằng phương pháp **right-to-left positional assignment**:
|
|
|
|
|
|
```
|
|
|
"123 Hàng Bông, Phường Phúc Xá, Quận Ba Đình, TP Hà Nội"
|
|
|
↑ ↑ ↑ ↑
|
|
|
street ward district province
|
|
|
(parts[:-3]) (parts[-3]) (parts[-2]) (parts[-1])
|
|
|
```
|
|
|
|
|
|
Xử lý đặc biệt cho địa chỉ 2 phần (`"ward, province"`): kiểm tra prefix phường/xã/thị trấn để phân biệt với district.
|
|
|
|
|
|
#### 5.4 `src/converter.py` — Logic chuyển đổi
|
|
|
|
|
|
**Khởi tạo (lazy load):**
|
|
|
- Load `data/mapping.json` lần đầu khi gọi `convert_address()`
|
|
|
- Build index một lần, cache trong module-level globals
|
|
|
|
|
|
**Index structure:**
|
|
|
|
|
|
| Index | Key | Value | Mục đích |
|
|
|
|---|---|---|---|
|
|
|
| `province` | `old_prov_key` | `new_prov_key` | Tra cứu tỉnh |
|
|
|
| `province_keywords` | `normalized_name` | `old_prov_key` | Fuzzy match tên tỉnh |
|
|
|
| `exact` | `(prov, dist, ward)` | `[records]` | Tra cứu chính xác |
|
|
|
| `ward_only` | `(prov, ward)` | `[records]` | Tra cứu bỏ qua quận/huyện |
|
|
|
|
|
|
**Luồng chuyển đổi (`convert_address`):**
|
|
|
|
|
|
```
|
|
|
Input: "Phường Phúc Xá, Quận Ba Đình, Thành phố Hà Nội"
|
|
|
│
|
|
|
├─ 1. parse_address() → AdminUnit(ward, district, province)
|
|
|
│
|
|
|
├─ 2. Resolve province
|
|
|
│ normalize("Thành phố Hà Nội") → "thanhphohanoi"
|
|
|
│ province_mapping["thanhphohanoi"] → "thanhphohanoi"
|
|
|
│ ❌ Không tìm thấy → NOT_FOUND
|
|
|
│
|
|
|
├─ 3. Tra cứu ward (2-tier matching)
|
|
|
│ ├─ Tier 1: exact("thanhphohanoi", "quanbadinh", "phuongphucxa") ✅
|
|
|
│ └─ Tier 2: ward_only("thanhphohanoi", "phuongphucxa") (fallback)
|
|
|
│
|
|
|
├─ 4. Chọn bản ghi tốt nhất
|
|
|
│ └─ Nếu divided: ưu tiên is_default=true
|
|
|
│
|
|
|
└─ 5. Build result
|
|
|
ConversionResult(converted="Phường Hồng Hà, Thành phố Hà Nội",
|
|
|
status=SUCCESS, mapping_type=MERGED)
|
|
|
```
|
|
|
|
|
|
**Conversion status:**
|
|
|
|
|
|
| Status | Điều kiện |
|
|
|
|---|---|
|
|
|
| `SUCCESS` | Tìm thấy cả province + ward mapping |
|
|
|
| `PARTIAL` | Chỉ tìm thấy province (ward không match) |
|
|
|
| `NOT_FOUND` | Không nhận dạng được province |
|
|
|
|
|
|
#### 5.5 `src/cli.py` — Command Line Interface
|
|
|
|
|
|
Hai commands, sử dụng `click`:
|
|
|
|
|
|
```bash
|
|
|
# Chuyển đổi 1 địa chỉ
|
|
|
address-convert convert "Phường Phúc Xá, Quận Ba Đình, TP Hà Nội"
|
|
|
|
|
|
# Chuyển đổi hàng loạt từ CSV
|
|
|
address-convert batch input.csv output.csv --column address
|
|
|
```
|
|
|
|
|
|
Batch mode đọc CSV, thêm 3 cột vào output: `converted_address`, `conversion_status`, `mapping_type`.
|
|
|
|
|
|
### 6. Test Results
|
|
|
|
|
|
13 test cases, tất cả pass:
|
|
|
|
|
|
```
|
|
|
tests/test_converter.py::TestMergedWard::test_phuc_xa_merged_to_hong_ha PASSED
|
|
|
tests/test_converter.py::TestMergedWard::test_an_binh_can_tho PASSED
|
|
|
tests/test_converter.py::TestUnchangedWard::test_tan_loc_can_tho PASSED
|
|
|
tests/test_converter.py::TestRenamedWard::test_long_hoa_renamed PASSED
|
|
|
tests/test_converter.py::TestDividedWard::test_divided_selects_default PASSED
|
|
|
tests/test_converter.py::TestAbbreviations::test_p_q_tp PASSED
|
|
|
tests/test_converter.py::TestAbbreviations::test_tp_shorthand PASSED
|
|
|
tests/test_converter.py::TestPartialAddress::test_province_only PASSED
|
|
|
tests/test_converter.py::TestPartialAddress::test_unknown_province PASSED
|
|
|
tests/test_converter.py::TestWithStreet::test_street_preserved PASSED
|
|
|
tests/test_converter.py::TestBatchConvert::test_batch PASSED
|
|
|
tests/test_converter.py::TestProvinceMapping::test_ha_noi_stays PASSED
|
|
|
tests/test_converter.py::TestProvinceMapping::test_merged_province PASSED
|
|
|
```
|
|
|
|
|
|
**Coverage theo loại test:**
|
|
|
|
|
|
| Nhóm test | Kiểm tra |
|
|
|
|---|---|
|
|
|
| Merged ward | Nhiều xã cũ → 1 xã mới (Phúc Xá → Hồng Hà) |
|
|
|
| Unchanged ward | Xã giữ nguyên (Tân Lộc) |
|
|
|
| Renamed ward | Xã đổi tên (Long Hòa → Long Tuyền) |
|
|
|
| Divided ward | Xã chia tách, chọn default |
|
|
|
| Abbreviations | `P.`, `Q.`, `TP.` mở rộng đúng |
|
|
|
| Partial address | Chỉ tỉnh, tỉnh không tồn tại |
|
|
|
| Street preserved | Giữ nguyên phần đường khi chuyển đổi |
|
|
|
| Batch convert | Chuyển đổi hàng loạt, mixed results |
|
|
|
| Province mapping | Tỉnh giữ nguyên, tỉnh sáp nhập |
|
|
|
|
|
|
### 7. Danh sách 34 tỉnh/thành phố mới
|
|
|
|
|
|
| Mã | Tên |
|
|
|
|---|---|
|
|
|
| 01 | Thành phố Hà Nội |
|
|
|
| 04 | Tỉnh Cao Bằng |
|
|
|
| 08 | Tỉnh Tuyên Quang |
|
|
|
| 11 | Tỉnh Điện Biên |
|
|
|
| 12 | Tỉnh Lai Châu |
|
|
|
| 14 | Tỉnh Sơn La |
|
|
|
| 15 | Tỉnh Lào Cai |
|
|
|
| 19 | Tỉnh Thái Nguyên |
|
|
|
| 20 | Tỉnh Lạng Sơn |
|
|
|
| 22 | Tỉnh Quảng Ninh |
|
|
|
| 24 | Tỉnh Bắc Ninh |
|
|
|
| 25 | Tỉnh Phú Thọ |
|
|
|
| 31 | Thành phố Hải Phòng |
|
|
|
| 33 | Tỉnh Hưng Yên |
|
|
|
| 37 | Tỉnh Ninh Bình |
|
|
|
| 38 | Tỉnh Thanh Hóa |
|
|
|
| 40 | Tỉnh Nghệ An |
|
|
|
| 42 | Tỉnh Hà Tĩnh |
|
|
|
| 44 | Tỉnh Quảng Trị |
|
|
|
| 46 | Thành phố Huế |
|
|
|
| 48 | Thành phố Đà Nẵng |
|
|
|
| 51 | Tỉnh Quảng Ngãi |
|
|
|
| 52 | Tỉnh Gia Lai |
|
|
|
| 56 | Tỉnh Khánh Hòa |
|
|
|
| 66 | Tỉnh Đắk Lắk |
|
|
|
| 68 | Tỉnh Lâm Đồng |
|
|
|
| 75 | Tỉnh Đồng Nai |
|
|
|
| 79 | Thành phố Hồ Chí Minh |
|
|
|
| 80 | Tỉnh Tây Ninh |
|
|
|
| 82 | Tỉnh Đồng Tháp |
|
|
|
| 86 | Tỉnh Vĩnh Long |
|
|
|
| 91 | Tỉnh An Giang |
|
|
|
| 92 | Thành phố Cần Thơ |
|
|
|
| 96 | Tỉnh Cà Mau |
|
|
|
|
|
|
### 8. Cách sử dụng
|
|
|
|
|
|
#### Python API
|
|
|
|
|
|
```python
|
|
|
from src import convert_address, batch_convert
|
|
|
|
|
|
# Chuyển đổi 1 địa chỉ
|
|
|
result = convert_address("Phường Phúc Xá, Quận Ba Đình, Thành phố Hà Nội")
|
|
|
print(result.converted) # "Phường Hồng Hà, Thành phố Hà Nội"
|
|
|
print(result.status) # ConversionStatus.SUCCESS
|
|
|
print(result.mapping_type) # MappingType.MERGED
|
|
|
|
|
|
# Hỗ trợ viết tắt
|
|
|
result = convert_address("P. Phúc Xá, Q. Ba Đình, TP. Hà Nội")
|
|
|
print(result.converted) # "Phường Hồng Hà, Thành phố Hà Nội"
|
|
|
|
|
|
# Batch
|
|
|
results = batch_convert(["addr1", "addr2", "addr3"])
|
|
|
```
|
|
|
|
|
|
#### CLI
|
|
|
|
|
|
```bash
|
|
|
# Cài đặt
|
|
|
uv sync
|
|
|
|
|
|
# Chuyển đổi 1 địa chỉ
|
|
|
uv run address-convert convert "P. Phúc Xá, Q. Ba Đình, TP Hà Nội"
|
|
|
|
|
|
# Chuyển đổi CSV
|
|
|
uv run address-convert batch input.csv output.csv --column address
|
|
|
```
|
|
|
|
|
|
#### Cập nhật mapping data
|
|
|
|
|
|
```bash
|
|
|
uv run python scripts/build_mapping.py
|
|
|
```
|
|
|
|
|
|
### 9. Hạn chế và hướng phát triển
|
|
|
|
|
|
**Hạn chế hiện tại:**
|
|
|
- Xã bị chia tách (`divided`): chỉ chọn ward mặc định (`is_default`), không hỗ trợ geocoding để chọn chính xác
|
|
|
- Parser dựa vào vị trí (right-to-left), có thể sai với địa chỉ không chuẩn format
|
|
|
- Chưa hỗ trợ input không dấu hoàn toàn (ví dụ: "Phuong Phuc Xa")
|
|
|
|
|
|
**Hướng phát triển:**
|
|
|
- Thêm geocoding cho divided wards (dựa trên tọa độ đường phố)
|
|
|
- Hỗ trợ input không dấu bằng fuzzy matching trên normalized keys
|
|
|
- Thêm REST API endpoint
|
|
|
- Tích hợp với pandas DataFrame cho batch processing lớn
|
|
|
|