📍 ที่อยู่ — Address Hierarchy
เอกสารสเปคสำหรับทีมพัฒนา Frontend + Backend · หน้าจัดการข้อมูล ที่อยู่ตามทะเบียนราษฎร์ไทย 4 ระดับ (จังหวัด · อำเภอ/เขต · ตำบล/แขวง · รหัสไปรษณีย์) · อ้างอิงมาตรฐาน TIS-1099 + DOPA · ใช้เป็น base reference table สำหรับ Company Info · User Profile · WBS · Project · เฉพาะ Platform Admin เท่านั้น
📑Table of Contents
📋01Overview & Scope
หน้า "ที่อยู่" (Address Hierarchy Settings) เป็นหน้าผู้ดูแลระบบสำหรับจัดการข้อมูลทะเบียนราษฎร์ไทย 4 ระดับ · ใช้เป็น base table สำหรับฟิลด์ที่อยู่ในทุกหน้าของระบบ (Company Info · User · WBS · Project · Invoice)
🎯 วัตถุประสงค์ (Purpose)
- จัดการ Tree 4 ระดับ: จังหวัด (Province) → อำเภอ/เขต (District) → ตำบล/แขวง (Subdistrict) → รหัสไปรษณีย์ (Postal Code)
- เพิ่ม · แก้ไข · ลบ · เปิด/ปิด active · ค้นหา (พิมพ์ไทย + EN + postal code)
- นำเข้า CSV จาก DOPA (กรมการปกครอง) · ตามมาตรฐาน TIS-1099 · annual update
- ส่งออก CSV / Excel / JSON สำหรับ backup + integration
- ดูสถิติ count แต่ละระดับ (77 · 928 · 7,400+ · 5,400+)
- รองรับ ภาษาคู่ TH (default) + EN (Romanization ตาม Royal Thai General System of Transcription)
- Bulk update postal code (สรรพากร update postal ใหม่)
📐 Scope
| หัวข้อ | Scope | Out of Scope |
|---|---|---|
| CRUD Address | 4 ระดับ · TIS-1099 codes · TH + EN names · postal · active flag | Auto-sync จาก DOPA API (Phase 2) |
| Tree management | Expand/collapse · search filter · highlight match | Drag-drop reorder (alphabetical fixed) |
| Import/Export | CSV (UTF-8 BOM) · Excel · JSON · ETL preview | XLSX with formulas · external integrations |
| Validation | TIS-1099 code format · postal 5 digits · uniqueness | Address verification API (Phase 2) |
| Multilingual | TH + EN · auto-suggest EN จาก TH (Royal Romanization library) | Chinese · Burmese · Lao |
| ผู้ใช้งาน | Platform Admin เท่านั้น · ระดับ system | — |
👥 ผู้มีสิทธิ์ (per Role&Privilege.docx · "ที่อยู่")
| บทบาท | R | C | E | D | หมายเหตุ |
|---|---|---|---|---|---|
| Platform Admin | ✅ | ✅ | ✅ | ✅ | เห็น/แก้ system-wide · base table shared ทุก org |
| Organize Admin | — | — | — | — | ไม่เห็นเมนู (typeahead ใช้ได้ผ่าน /addresses/subdistricts) |
| PM · QC Mgr · QC Insp · OP/Sub · CEO | — | — | — | — | ไม่เห็นเมนู |
🏛️ Thai Address Standards Reference
| Standard | Description | Used In |
|---|---|---|
| TIS-1099-2563 | มอก. รหัสมาตรฐานการแบ่งเขตการปกครองไทย · 6-digit (PP-AA-SS) | subdistrict_id · pk |
| DOPA / กรมการปกครอง | ข้อมูลทะเบียนราษฎร์ที่ official · ปรับปรุงรายปี | Source for CSV import |
| Royal Romanization | ระบบถอดอักษรไทยเป็นโรมัน (RTGS) | EN names auto-suggest |
| 5-digit Postal | ไปรษณีย์ไทย · PP-XXX format | postal_code field |
⚙️02Tech Stack
อ้างอิงจาก ConstructQ_SystemArchitecture_v2.0.html
⚛️ Frontend (Web)
| Layer | Library | การใช้งานในหน้านี้ |
|---|---|---|
| Framework | Next.js 14 (App Router · RSC) | Route /setting/master/address |
| UI Library | Ant Design 5.x | Tree · Form · Input · Drawer · Switch · Upload · Modal · Tag |
| Styling | Tailwind CSS 3.x | Split layout (tree L + detail R) · responsive · token-based |
| Data Fetching | SWR 2.x | useSWR + virtualization for large tree |
| State | Zustand 4 | addressStore · {tree, selected, filter, dirty} |
| Form | React Hook Form + Zod | schema validation |
| Virtualization | react-window | render 7,400+ rows · ≤ 100 DOM nodes |
| Excel I/O | SheetJS (xlsx) | read CSV/XLSX → preview rows ก่อน import |
| Romanization | th-romanize (custom) | auto-suggest EN จาก TH ตาม RTGS |
| i18n | next-intl | TH default · EN toggle |
| Auth | next-auth + Keycloak | JWT · role check platform_admin เท่านั้น |
⚙️ Backend (API)
| Layer | Library | การใช้งาน |
|---|---|---|
| Runtime | Go 1.22 | Service cmd/api/main.go |
| Router | go-chi/chi v5 | Route group r.Route("/api/v1/addresses", ...) |
| DB Driver | jackc/pgx v5 | CopyFrom สำหรับ bulk import 7,400 rows ใน < 2s |
| Validation | validator/v10 + custom | TIS-1099 code · postal regex |
| JWT | golang-jwt/jwt v5 | verify + role enforcement |
| Excel I/O | xuri/excelize v2 | CSV/XLSX read + write · UTF-8 BOM |
| Logger | rs/zerolog | JSON · request_id + user_id |
| Event Bus | NATS | publish address.changed → invalidate caches |
| Cron | robfig/cron v3 | nightly: VACUUM + ANALYZE · reindex GIN |
🐘 Data Layer
| Store | Tech | การใช้งาน |
|---|---|---|
| Primary | PostgreSQL 15+ | 1 table: th_addresses · ~7,400 rows · stable |
| Search | PostgreSQL pg_trgm | GIN index บน subdistrict_th · district_th · province_th |
| Cache | Redis 7 | addr:tree:all TTL 24h · addr:typeahead:{q} TTL 1h |
| Cache CDN | Cloudflare CDN | GET /api/v1/addresses?format=public · static JSON · max-age 24h |
| Audit | Trigger immutable | audit_logs · ห้าม UPDATE/DELETE |
| Backup | Daily PG dump | MinIO archive bucket · retention 90d |
🎨03UI Layout & Components
ภาพรวมหน้าจอ + Component หลัก พร้อมตำแหน่ง
🧩 Component Tree
<AddressPage> // app/setting/master/address/page.tsx (RSC)
<Breadcrumb />
<PageHeader title="ที่อยู่" />
<ActionBar> // §5.1
<ImportButton onClick={openImportModal} />
<ExportButton onClick={exportAddress} />
<AddProvinceButton onClick={addProvince} />
</ActionBar>
<HierarchySchema /> // Visual: Province → District → Subdistrict → Postal
<StatsBar> // §5.2 — 4 stat cards
<StatCard ico="🏛️" label="จังหวัด" />
<StatCard ico="🏘️" label="อำเภอ/เขต" />
<StatCard ico="📌" label="ตำบล/แขวง" />
<StatCard ico="📮" label="รหัสไปรษณีย์" />
</StatsBar>
<SplitLayout>
<TreePanel> // §5.3 · L · 380px
<SearchInput onInput={filterTree} />
<TreeVirtualized> // react-window
<ProvinceNode> <DistrictNode> <SubdistrictNode> </...>
</TreeVirtualized>
</TreePanel>
<DetailPanel> // §5.4 · R · flex 1
<EmptyState /> // ถ้ายังไม่ได้เลือก
<AddressForm> // RHF + Zod
<CodeField readonly />
<ThNameField />
<EnNameField + RomanizeBtn />
<PostalField /> // เฉพาะ subdistrict
<ActiveSwitch />
</AddressForm>
<ActionBar>
<SaveBtn /> <DeleteBtn /> <ResetBtn />
</ActionBar>
</DetailPanel>
</SplitLayout>
<ImportModal> // §5.7 — drag-drop + preview
<FileDropzone />
<PreviewTable /> // first 20 rows + validation errors
<ImportProgress />
</ImportModal>
<DeleteConfirmModal /> // §5.8 — cascade warning
</AddressPage>
📐 Layout Grid (Desktop ≥1280px)
| Zone | Width | Component |
|---|---|---|
| Header | 100% | Title + ActionBar (right-aligned) |
| Hierarchy schema | 100% | Pills + arrows · 4 levels visual |
| Stats | 100% · 4 cols | 4 × StatCard |
| Tree panel | 380px (fixed) | Search + Virtualized tree · max height 600px |
| Detail panel | flex 1 | Form 2-cols + ActionBar |
| Import Modal | 720px | Dropzone + Preview table + Progress |
📥04Base Loads (Initial)
ข้อมูลที่ต้องโหลดเมื่อเปิดหน้าครั้งแรก
🔄 RSC Initial Fetch
// app/setting/master/address/page.tsx
import { getAddressTree, getAddressStats } from "@/lib/api/addresses";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function AddressPage() {
const session = await auth();
if (!session) redirect("/login");
if (session.user.role !== "platform_admin") redirect("/403?reason=address");
// Parallel fetch — RSC streams
const [tree, stats] = await Promise.all([
getAddressTree(), // GET /api/v1/addresses/tree (province-only · lazy load children)
getAddressStats(), // GET /api/v1/addresses/stats
]);
return <AddressClient initialTree={tree} initialStats={stats} />;
}
📊 API Calls on Mount
| # | Endpoint | Purpose | Cache |
|---|---|---|---|
| 1 | GET /api/v1/addresses/tree | โหลด province list ทั้งหมด (77 records) · ไม่รวม children | Redis 24h · SWR |
| 2 | GET /api/v1/addresses/stats | 4 counters: provinces · districts · subdistricts · unique postal | Redis 24h · count materialized |
| 3 | GET /api/v1/addresses/tree?parent={province_id} | Lazy load districts ของ province · เมื่อ user expand | Redis 24h · per-province |
| 4 | GET /api/v1/addresses/tree?parent={district_id} | Lazy load subdistricts ของ district | Redis 24h |
📥 Sample Response — /api/v1/addresses/tree (provinces)
{
"data": [
{
"id": "50", // TIS-1099 2-digit province code
"type": "province",
"code": "50",
"name_th": "เชียงใหม่",
"name_en": "Chiang Mai",
"active": true,
"child_count": 25, // districts count
"version": 1,
"updated_at": "2025-01-15T00:00:00Z"
},
{
"id": "40",
"type": "province",
"code": "40",
"name_th": "ขอนแก่น",
"name_en": "Khon Kaen",
"active": true,
"child_count": 26,
"version": 1
},
/* ... 77 provinces ... */
],
"meta": { "total": 77 }
}
⚡ Client Hydration (SWR + Virtualization)
"use client";
import useSWR from "swr";
import { FixedSizeList } from "react-window";
export default function AddressClient({ initialTree, initialStats }) {
const { data: tree, mutate: mutateTree } = useSWR("/api/v1/addresses/tree", fetcher,
{ fallbackData: initialTree, dedupingInterval: 86_400_000 }); // 24h
const { data: stats } = useSWR("/api/v1/addresses/stats", fetcher,
{ fallbackData: initialStats, dedupingInterval: 86_400_000 });
// Flatten tree to array for react-window (compute only expanded + filter)
const visibleRows = useMemo(() => flattenVisible(tree, expanded, filter), [tree, expanded, filter]);
return (
<FixedSizeList
height={600} itemCount={visibleRows.length} itemSize={32}>
{({ index, style }) => (
<TreeRow style={style} item={visibleRows[index]} onExpand={lazyLoad} />
)}
</FixedSizeList>
);
}
async function lazyLoad(parentId: string) {
const children = await api.get(`/api/v1/addresses/tree?parent=${parentId}`);
// merge into tree state
}
🎯05Component → API Mapping
รายละเอียดทุก action ที่กดบนหน้าจอ + API ที่เรียก + UI behavior
5.1 ActionBar ปุ่ม "➕ เพิ่มจังหวัด"
🎬 Behavior
- กด → เปิด DetailPanel mode="create-province" · clear form
- Code field editable (province 2-digit)
- กดบันทึก → POST /addresses · type=province
- หลังสร้างสำเร็จ → append ใน tree · scroll to + auto-select
{type, code, name_th, name_en, parent_id?, postal_code?} · Idempotency-Key required5.2 StatsBar — 4 Counters
🎬 Behavior
- โหลดจาก /addresses/stats · update เมื่อ create/delete
- SWR mutate ใน optimistic — เพิ่ม/ลด counter ทันที
- Re-fetch หลัง CSV import เสร็จ
{provinces, districts, subdistricts, postal_codes} · materialized count · cached 24h5.3 TreePanel — Search + Virtualized Tree
🎬 Behavior
- Click row → highlight + load DetailPanel
- Click chevron ▶/▼ → expand/collapse
- Expand province → lazy GET children (lazy load · 50ms shimmer)
- react-window virtualize · render < 100 DOM rows แม้ tree มี 7,400
- Search input → debounce 200ms → filter ทั้ง 3 levels (TH + EN + postal)
- เมื่อ filter match → auto-expand parent + highlight match yellow
// TreePanel.tsx
const onSearchInput = useDebouncedCallback((q: string) => {
if (q.length < 2) {
addressStore.setFilter(null);
return;
}
// Server-side fuzzy search for results > current tree memory
api.get(`/api/v1/addresses/search?q=${encodeURIComponent(q)}&limit=50`)
.then(matches => {
// Auto-expand ancestors of matches
const ancestorIds = new Set();
matches.forEach(m => {
if (m.province_id) ancestorIds.add(m.province_id);
if (m.district_id) ancestorIds.add(m.district_id);
});
addressStore.setFilter({ q, matches, ancestorIds });
ancestorIds.forEach(id => lazyLoadIfMissing(id));
});
}, 200);
5.4 DetailPanel — แก้ไขข้อมูล
🎬 Behavior
- เลือก row ใน tree → GET /addresses/:id · populate form
- Code field always read-only (TIS-1099 immutable)
- name_th input → onBlur → call Romanizer · suggest EN ใน input (highlight)
- Postal field — เฉพาะ subdistrict level
- Active switch → ทันที update (optimistic)
- กด 💾 → Zod validate → PATCH · If-Match version
- กด ↺ Reset → revert local changes
- กด 🗑️ ลบ → DeleteConfirmModal (cascade warning)
{name_th?, name_en?, postal_code?, active?}5.5 ปุ่ม "🔄 Auto Romanize"
🎬 Behavior
- กดหรือ name_th onBlur → ส่งไป /addresses/romanize
- Server ใช้
th-romanizelibrary (RTGS standard) - คืน suggestion · client เติมใน name_en field
- User edit ทับได้ — ระบบจำว่ามี
name_en_overridden=true
{romanized}5.6 Switch "สถานะ Active"
🎬 Behavior
- Toggle → optimistic update + PATCH ทันที
- ปิด active บน province → confirm modal: "ปิดทั้งจังหวัดจะทำให้ทุก district/subdistrict ภายในใช้ไม่ได้ใน dropdown ต่างๆ"
- Subdistrict ที่
active=false→ ยังคงอยู่ในระบบเก่า · ไม่ปรากฏใน typeahead ใหม่ - Active flag = soft hide (ไม่ใช่ delete)
5.7 ปุ่ม "📥 นำเข้า CSV"
🎬 Behavior
- เปิด ImportModal · drag-drop หรือ click upload
- SheetJS ฝั่ง browser อ่านไฟล์ → preview 20 rows แรก
- Validate ทุก row ฝั่ง client ก่อนส่ง — แสดง errors ใน preview table
- ถ้า valid ≥ 1 row → enable "นำเข้า" button
- กด "นำเข้า" → POST multipart · backend ใช้
pgx.CopyFrombulk insert - Progress bar แสดง % · ETA
- สำเร็จ → refresh tree + stats · audit log "addresses.imported"
- Mode: append (insert ใหม่ · skip duplicate) หรือ upsert (update existing by code)
{file, mode=append|upsert, dry_run?} · response: {inserted, updated, skipped, errors[]} · streaming JSONL for progress// ImportModal.tsx
const onFileSelect = async (file: File) => {
// 1) Parse client-side (SheetJS)
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const rows = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
// 2) Validate client-side (Zod)
const errors = [];
const validRows = [];
rows.forEach((row, idx) => {
const result = addressCsvSchema.safeParse(row);
if (result.success) validRows.push(result.data);
else errors.push({ line: idx + 2, ...result.error.flatten() });
});
setPreview({ total: rows.length, valid: validRows.length, errors });
};
const onImport = async () => {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('mode', mode);
// 3) Stream upload + progress
const response = await fetch('/api/v1/addresses/import', {
method: 'POST', body: formData,
headers: { 'Idempotency-Key': uuidv4() },
});
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const progress = JSON.parse(new TextDecoder().decode(value));
setProgress(progress.percent); // 0-100
}
mutate('/api/v1/addresses/tree');
mutate('/api/v1/addresses/stats');
message.success('นำเข้าเรียบร้อย');
};
5.8 ปุ่ม "📤 ส่งออก"
🎬 Behavior
- Modal เลือก format (CSV · XLSX · JSON) + filter (active only · all · provinces only)
- Stream download · Content-Disposition: attachment
- UTF-8 BOM สำหรับ Excel ภาษาไทย
- Filename:
addresses_2026-06-03.csv
5.9 ปุ่ม "🗑️ ลบ"
🎬 Behavior
- Modal warning — ระบุจำนวน children ที่จะถูก cascade
- ตรวจการใช้งาน — ถ้ามี
organizations.address.subdistrict_idใช้อยู่ → 409 + แสดง count + ปุ่ม "ดูที่ใช้งาน" - Soft delete ·
deleted_at = NOW()· cascade children - Audit log + NATS broadcast cache invalidation
5.10 Cache Invalidation Broadcast
🎬 Behavior
- ทุก mutation (create/update/delete/import) → backend publish NATS
address.changed - Subscribers: Cache service (Redis flush) · CDN purge (Cloudflare) · Frontend WS (revalidate SWR)
- Frontend ของผู้ใช้อื่น (กำลังใช้หน้า Company Info) → typeahead cache invalidate
🔌06API Reference
| # | Method | Path | Description | Auth |
|---|---|---|---|---|
| 1 | GET | /api/v1/addresses/tree | List provinces (or children of parent) | JWT |
| 2 | GET | /api/v1/addresses/{id} | Single address with hierarchy | JWT |
| 3 | GET | /api/v1/addresses/stats | 4 counters | JWT |
| 4 | GET | /api/v1/addresses/search | Fuzzy search · pg_trgm · all levels | JWT |
| 5 | GET | /api/v1/addresses/subdistricts | Public typeahead · used by Company Info etc. | JWT |
| 6 | GET | /api/v1/addresses/postal/{postal} | Reverse: postal → subdistricts list | JWT |
| 7 | GET | /api/v1/addresses/romanize | TH → EN romanize · RTGS standard | JWT |
| 8 | GET | /api/v1/addresses/{id}/references | FK references count + samples | Admin |
| 9 | POST | /api/v1/addresses | Create · Idempotency-Key | Admin |
| 10 | PATCH | /api/v1/addresses/{id} | Update · If-Match version | Admin |
| 11 | DELETE | /api/v1/addresses/{id} | Soft delete · cascade param · 409 if FK | Admin |
| 12 | POST | /api/v1/addresses/import | Bulk import CSV/XLSX · streaming progress | Admin |
| 13 | GET | /api/v1/addresses/export | Stream CSV/XLSX/JSON · UTF-8 BOM | Admin |
| 14 | POST | /api/v1/addresses/bulk-update-postal | Mass postal update · admin only (rare) | Admin |
📦 Go Struct — Address
type Address struct {
ID string `json:"id"` // TIS-1099 code (2/4/6 digits)
Type string `json:"type" validate:"required,oneof=province district subdistrict"`
Code string `json:"code" validate:"required,numeric,len=2|len=4|len=6"`
ParentID *string `json:"parent_id"` // null for province
NameTh string `json:"name_th" validate:"required,max=100"`
NameEn string `json:"name_en" validate:"required,max=100,ascii"`
NameEnAuto bool `json:"name_en_auto"` // true = generated by romanize · user can override
PostalCode *string `json:"postal_code" validate:"omitempty,len=5,numeric"`
Active bool `json:"active" validate:"-"`
ChildCount int `json:"child_count"` // computed
Version int `json:"version"` // optimistic lock
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
// Validation:
// - province: code = 2 digits · parent_id = null · postal_code = null
// - district: code = 4 digits · parent_id = province (2-digit) · postal_code = null
// - subdistrict: code = 6 digits · parent_id = district (4-digit) · postal_code = 5 digits required
🐘 DB Schema
CREATE TABLE th_addresses (
id VARCHAR(6) PRIMARY KEY, -- TIS-1099 code (2 · 4 · 6 digits)
type VARCHAR(16) NOT NULL CHECK (type IN ('province','district','subdistrict')),
code VARCHAR(6) NOT NULL,
parent_id VARCHAR(6) REFERENCES th_addresses(id),
name_th VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NOT NULL,
name_en_auto BOOLEAN NOT NULL DEFAULT true,
postal_code CHAR(5),
active BOOLEAN NOT NULL DEFAULT true,
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CHECK (
(type = 'province' AND char_length(code) = 2 AND parent_id IS NULL AND postal_code IS NULL) OR
(type = 'district' AND char_length(code) = 4 AND parent_id IS NOT NULL AND char_length(parent_id) = 2 AND postal_code IS NULL) OR
(type = 'subdistrict' AND char_length(code) = 6 AND parent_id IS NOT NULL AND char_length(parent_id) = 4 AND postal_code IS NOT NULL)
)
);
-- GIN trigram indexes for fuzzy search
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_addr_name_th_trgm ON th_addresses USING GIN (name_th gin_trgm_ops) WHERE deleted_at IS NULL;
CREATE INDEX idx_addr_name_en_trgm ON th_addresses USING GIN (name_en gin_trgm_ops) WHERE deleted_at IS NULL;
CREATE INDEX idx_addr_postal ON th_addresses (postal_code) WHERE deleted_at IS NULL;
CREATE INDEX idx_addr_parent ON th_addresses (parent_id, active) WHERE deleted_at IS NULL;
-- Materialized count
CREATE MATERIALIZED VIEW mv_address_stats AS
SELECT
(SELECT COUNT(*) FROM th_addresses WHERE type='province' AND deleted_at IS NULL AND active) AS provinces,
(SELECT COUNT(*) FROM th_addresses WHERE type='district' AND deleted_at IS NULL AND active) AS districts,
(SELECT COUNT(*) FROM th_addresses WHERE type='subdistrict' AND deleted_at IS NULL AND active) AS subdistricts,
(SELECT COUNT(DISTINCT postal_code) FROM th_addresses WHERE postal_code IS NOT NULL AND deleted_at IS NULL) AS postal_codes,
NOW() AS snapshot_at;
📊07Data Flow Diagram (DFD)
การไหลของข้อมูลในกระบวนการ CRUD · import · cache invalidation
Address Page] Tree[🌲 react-window
Virtualized Tree] Form[📝 RHF + Zod] Store[(🗄️ Zustand
addressStore)] API[⚙️ Go-Chi API
/api/v1/addresses/*] Auth{🛡️ Keycloak
JWT verify} Validator[✅ validator/v10
TIS-1099] PG[(🐘 PostgreSQL
th_addresses
pg_trgm GIN)] Redis[(⚡ Redis
addr:tree:* TTL 24h)] CDN[(☁️ Cloudflare CDN
public JSON)] MinIO[(📦 MinIO
backup archives)] Sheet[📊 SheetJS
client parse] Roman[🔤 th-romanize
RTGS] NATS[/📡 NATS
address.changed/] OtherFE[🌐 Other clients
Company Info · User · etc.] Audit[(📋 audit_logs)] User -->|view| UI UI -->|expand node| API API --> Auth Auth -->|role=platform_admin| Validator API --> Redis Redis -.->|miss| PG PG --> Redis Redis --> API API --> UI UI --> Tree User -->|edit| Form Form -->|onBlur TH| API API --> Roman Roman --> API API --> Form Form -->|save| API Validator -->|valid| PG PG --> Audit API --> NATS NATS --> Redis NATS --> CDN NATS --> OtherFE User -->|import CSV| Sheet Sheet -->|preview| UI UI -->|valid file| API API -->|CopyFrom| PG PG --> MinIO User -->|export| API API --> CDN CDN --> User
🔄 ขั้นตอนสำคัญ
- Tree load: Lazy load per level · Redis 24h cache · 1 query แต่ละ parent
- Edit: name_th → Romanize TH→EN (RTGS) → fill EN field
- Save: Validator (TIS-1099 + uniqueness) → PG UPDATE + Audit + NATS
- CSV Import: Client SheetJS parse + validate → server CopyFrom (bulk) → NATS broadcast
- Cache invalidation: NATS event → Redis flush + Cloudflare purge + other clients revalidate
- Export: Stream from PG · CDN edge cache · serve world-wide
🔄08State Machine
8.1 Address Lifecycle
8.2 CSV Import Lifecycle
8.3 Tree Node Lazy Load
↕️09Sequence Diagrams
9.1 Lazy Load Tree + Cache Hit
9.2 Edit + Romanize Auto-suggest
9.3 CSV Import with Streaming Progress
9.4 Delete with Cascade Check
✅10Form Validation
Frontend (Zod) + Backend (validator/v10 + custom Thai validators)
🧪 Zod Schema
// schemas/address.ts
import { z } from "zod";
const provinceCode = z.string().regex(/^[0-9]{2}$/, "รหัส 2 หลัก");
const districtCode = z.string().regex(/^[0-9]{4}$/, "รหัส 4 หลัก");
const subdistrictCode = z.string().regex(/^[0-9]{6}$/, "รหัส 6 หลัก");
const postalCode = z.string().regex(/^[0-9]{5}$/, "รหัสไปรษณีย์ 5 หลัก");
export const addressSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("province"),
code: provinceCode,
parent_id: z.null(),
name_th: z.string().min(2).max(100).regex(/^[-\s.]+$/, "ภาษาไทยเท่านั้น"),
name_en: z.string().min(2).max(100).regex(/^[A-Za-z\s.'-]+$/, "อังกฤษเท่านั้น"),
postal_code: z.null(),
active: z.boolean(),
}),
z.object({
type: z.literal("district"),
code: districtCode,
parent_id: provinceCode, // FK province
name_th: z.string().min(2).max(100).regex(/^[-\s.]+$/),
name_en: z.string().min(2).max(100).regex(/^[A-Za-z\s.'-]+$/),
postal_code: z.null(),
active: z.boolean(),
}).refine(d => d.code.startsWith(d.parent_id), {
message: "รหัส district 4 หลักต้องขึ้นต้นด้วยรหัส province 2 หลัก",
path: ["code"]
}),
z.object({
type: z.literal("subdistrict"),
code: subdistrictCode,
parent_id: districtCode, // FK district
name_th: z.string().min(2).max(100).regex(/^[-\s.]+$/),
name_en: z.string().min(2).max(100).regex(/^[A-Za-z\s.'-]+$/),
postal_code: postalCode, // required
active: z.boolean(),
}).refine(d => d.code.startsWith(d.parent_id), {
message: "รหัส subdistrict 6 หลักต้องขึ้นต้นด้วยรหัส district 4 หลัก",
path: ["code"]
}),
]);
// CSV row schema (more lenient)
export const addressCsvSchema = z.object({
type: z.enum(["province","district","subdistrict"]),
code: z.string().regex(/^[0-9]{2,6}$/),
parent_id: z.string().regex(/^[0-9]{2,4}$/).optional(),
name_th: z.string().min(1).max(100),
name_en: z.string().min(1).max(100),
postal_code: z.string().regex(/^[0-9]{5}$/).optional(),
active: z.coerce.boolean().default(true),
});
🔍 Field-by-Field Rules
| Field | Required | Rule | Error Message (TH) |
|---|---|---|---|
type | ✅ | enum [province · district · subdistrict] | — |
code | ✅ | 2/4/6 digits ตาม type · ขึ้นต้นด้วย parent code · TIS-1099 | "รหัสไม่ตรงกับ TIS-1099" |
parent_id | ⚠️ conditional | FK th_addresses · null for province · required for district/subdistrict | "กรุณาเลือก parent" |
name_th | ✅ | 2–100 chars · Thai chars only (Unicode 0E00–0E7F) | "ใช้ภาษาไทยเท่านั้น" |
name_en | ✅ | 2–100 chars · ASCII A-Z space . ' - | "ใช้ภาษาอังกฤษเท่านั้น" |
postal_code | ⚠️ conditional | 5 digits · required for subdistrict · null for others | "รหัสไปรษณีย์ 5 หลัก" |
active | ✅ | boolean | — |
| Code uniqueness | — | async check ผ่าน GET /addresses/check-code | "รหัสซ้ำในระบบ" |
| Province name uniqueness | — | name_th unique ทั่ว province | "ชื่อจังหวัดซ้ำ" |
📏 Custom Backend Validators
// validator/v10 custom registrations
validate.RegisterValidation("tis1099_code", func(fl validator.FieldLevel) bool {
code := fl.Field().String()
parent := fl.Parent().FieldByName("ParentID").Interface().(*string)
t := fl.Parent().FieldByName("Type").String()
switch t {
case "province":
return len(code) == 2 && parent == nil
case "district":
return len(code) == 4 && parent != nil && len(*parent) == 2 && strings.HasPrefix(code, *parent)
case "subdistrict":
return len(code) == 6 && parent != nil && len(*parent) == 4 && strings.HasPrefix(code, *parent)
}
return false
})
validate.RegisterValidation("thai_chars", func(fl validator.FieldLevel) bool {
text := fl.Field().String()
return regexp.MustCompile(`^[-\s.]+$`).MatchString(text)
})
validate.RegisterValidation("rtgs_english", func(fl validator.FieldLevel) bool {
text := fl.Field().String()
return regexp.MustCompile(`^[A-Za-z\s.'\-]+$`).MatchString(text)
})
🛡️11RBAC & Permissions
per Role&Privilege.docx · "ที่อยู่" = R/C/E/D เฉพาะ Platform Admin
| Action | Endpoint | Platform Admin | All Others |
|---|---|---|---|
| View tree | GET /tree | ✅ full · including inactive | ❌ no menu access |
| Public typeahead | GET /subdistricts?q= | ✅ | ✅ active only · used by Company Info typeahead |
| Public postal lookup | GET /postal/{postal} | ✅ | ✅ active only |
| Romanize | GET /romanize | ✅ | ❌ |
| Create / Update / Delete | POST · PATCH · DELETE | ✅ | ❌ 403 |
| Import CSV | POST /import | ✅ | ❌ |
| Export | GET /export | ✅ | ❌ |
| References lookup | GET /:id/references | ✅ | ❌ |
🛡️ Middleware Pattern
r.Route("/api/v1/addresses", func(r chi.Router) {
r.Use(auth.RequireJWT)
// Public read — accessible to all authenticated users (used by Company Info typeahead etc.)
r.Get("/subdistricts", handler.SearchSubdistricts) // filters active only for non-admins
r.Get("/postal/{postal}", handler.LookupByPostal)
// Admin-only routes
r.Group(func(r chi.Router) {
r.Use(rbac.RequireRole("platform_admin"))
r.Get("/tree", handler.GetTree) // includes inactive
r.Get("/{id}", handler.GetAddress)
r.Get("/stats", handler.GetStats)
r.Get("/search", handler.Search)
r.Get("/romanize", handler.Romanize)
r.Get("/{id}/references", handler.GetReferences)
r.Post("/", handler.CreateAddress)
r.Patch("/{id}", handler.UpdateAddress)
r.Delete("/{id}", handler.DeleteAddress)
r.Post("/import", handler.ImportAddresses)
r.Get("/export", handler.ExportAddresses)
r.Post("/bulk-update-postal", handler.BulkUpdatePostal)
})
})
🚪 UI Guard
// app/setting/master/address/page.tsx
export default async function AddressPage() {
const session = await auth();
if (!session) redirect("/login");
if (session.user.role !== "platform_admin") redirect("/403?reason=address");
// ...
}
/subdistricts และ /postal/:p เปิดให้ทุก role ใช้ (สำหรับ Company Info typeahead) · เฉพาะ admin จะเห็น tree management UI⚡12Performance
🎯 Targets
| Metric | Target | Measure At |
|---|---|---|
| TTFB (RSC) | ≤ 200ms p95 | Vercel |
| LCP | ≤ 1.5s | Web Vitals |
| GET /tree (77 provinces) | ≤ 30ms cached · ≤ 80ms cold | Backend trace |
| GET /tree?parent=X (lazy) | ≤ 50ms (Redis hit) | Backend trace |
| Typeahead /subdistricts | ≤ 80ms p95 (pg_trgm GIN) | Backend trace |
| CSV Import 7,400 rows | ≤ 8s (pgx.CopyFrom) | Backend trace |
| Romanize call | ≤ 20ms (in-memory dict) | Backend trace |
| Tree scroll (7,400 visible) | ≥ 60 fps · react-window virt | Lighthouse |
⚡ Optimization Strategies
- Lazy load tree — 77 provinces เริ่มต้น · children load on expand
- react-window virtualization — render < 100 DOM rows
- Redis cache 24h — address มี mutation rate ต่ำมาก (annual update)
- pg_trgm GIN index — fuzzy search ≤ 80ms ที่ 7,400 records
- pgx.CopyFrom — bulk import 7,400 rows ใน < 8s (vs INSERT loop ~5min)
- Cloudflare CDN cache — /export response cached at edge worldwide
- Materialized view — stats counts ไม่ต้องนับใหม่ทุกครั้ง
- SWR dedupingInterval 24h — stable data ไม่ revalidate บ่อย
- Romanize in-memory dict — load Thai dict (~50k entries) ที่ startup · O(1) lookup
🔒13Security & Compliance
🛡️ Threat Model
| Threat | Mitigation | |
|---|---|---|
| Unauthorized mutation | JWT + role=platform_admin enforce · 403 returned | |
| Data leak via search | Read endpoints filter active=true for non-admin · admin sees all | |
| CSV bomb (DoS via large file) | Content-Length ≤ 5MB · row count limit 100,000 · streaming parse with backpressure | |
| CSV injection (formula attack) | SheetJS sanitize · prefix dangerous cells =, +, -, @ with apostrophe | |
| SQL Injection | pgx parameterized 100% · ไม่ใช้ string concat | |
| XSS (name_th display) | React auto-escape · CSP · ไม่ใช้ dangerouslySetInnerHTML | |
| Race condition (concurrent edit) | Optimistic lock via version + If-Match | |
| Cascade delete abuse | References check (orgs/users/projects) ก่อน cascade · 409 if FK | |
| Audit tampering | audit_logs trigger ปฏิเสธ UPDATE/DELETE |
📋 Audit Events
| Event | Action | changes (JSONB) |
|---|---|---|
| address.created | POST /addresses | {type, code, name_th, name_en} |
| address.updated | PATCH /addresses/:id | diff fields (no PII concern) |
| address.deleted | DELETE /addresses/:id | {cascade_count} |
| address.restored | POST /addresses/:id/restore | NULL |
| addresses.imported | POST /addresses/import | {file_name, rows_inserted, rows_updated, rows_failed} |
| addresses.exported | GET /addresses/export | {format, filter, row_count} |
| bulk_postal.updated | POST /bulk-update-postal | {affected_count, from_postal, to_postal} |
🇹🇭 Thai Compliance Reference
| Standard | Compliance Action |
|---|---|
| TIS-1099-2563 | code format · 2/4/6-digit hierarchy enforced ใน DB CHECK constraint |
| DOPA Annual Update | Import CSV pattern · admin manually update รายปี (Phase 2: auto-sync) |
| Royal Romanization | name_en auto-suggest ตาม RTGS · ผู้ดูแลตรวจสอบ |
| Postal Code 5-digit | regex enforced · มาตรฐานไปรษณีย์ไทย |
| PDPA | Address คือ public data (กรมการปกครอง) ไม่ใช่ PII · ไม่ต้อง consent |
🔐 Sensitive Operations
- Bulk delete — ตรวจ references ก่อนทุกครั้ง · cascade requires explicit flag
- CSV import upsert — overwrite mode = require additional confirmation modal
- Bulk postal update — เฉพาะ admin · audit log mandatory · email notify backup admin
- Rate limit — POST/PATCH/DELETE: 60/min per admin
🧪14Testing & Acceptance
🎯 Acceptance Criteria
| # | Given / When / Then |
|---|---|
| AC-01 | G: Platform Admin · W: เปิด /setting/master/address · T: เห็น stats 77/928/7400+/5400+ · tree 77 provinces · TTFB ≤ 200ms |
| AC-02 | G: Non-admin · W: เปิด URL ตรง · T: redirect /403 |
| AC-03 | G: Click ▶ on province · W: expand · T: GET /tree?parent=50 · districts โหลด ≤ 50ms (cache hit) |
| AC-04 | G: Tree filter "ทุ่ง" · W: รอ 200ms · T: ค้น pg_trgm · auto-expand ancestors · highlight match |
| AC-05 | G: Select subdistrict · W: click · T: GET /addresses/500101 · form populate |
| AC-06 | G: Edit name_th · W: blur · T: GET /romanize · suggest EN ใน input |
| AC-07 | G: Subdistrict code ไม่ขึ้นต้นด้วย district code · W: save · T: Zod error "รหัส 6 หลักต้องขึ้นต้นด้วย 4 หลักของ parent" |
| AC-08 | G: Postal 4 digits · W: save subdistrict · T: error "รหัสไปรษณีย์ 5 หลัก" |
| AC-09 | G: Toggle active=false on province · W: toggle · T: confirm modal "ปิดทั้งจังหวัด" |
| AC-10 | G: 2 admins แก้พร้อมกัน · W: save คนที่สอง · T: 412 + prompt refresh |
| AC-11 | G: Drop CSV 7400 rows · W: parse · T: SheetJS อ่าน ≤ 2s · preview 20 rows · validate errors |
| AC-12 | G: Import CSV upsert · W: click นำเข้า · T: pgx.CopyFrom · streaming progress · เสร็จ ≤ 8s · NATS broadcast |
| AC-13 | G: Delete province with 25 children · W: delete · T: references check · confirm cascade · soft delete 225 rows |
| AC-14 | G: Delete province + orgs ใช้อยู่ · W: delete · T: 409 + แสดง references count · ปุ่ม "ดูที่ใช้งาน" |
| AC-15 | G: Export CSV active_only · W: click · T: file UTF-8 BOM · row count ตรง stats |
| AC-16 | G: Mutation event · W: NATS publish · T: Redis flush + Cloudflare purge · other clients see update ภายใน 5s |
| AC-17 | G: Typeahead from Company Info (non-admin) · W: search "ทุ่ง" · T: GET /subdistricts · เห็นเฉพาะ active=true |
| AC-18 | G: Tree scroll 7,400 rows expanded · W: scroll · T: ≥ 60 fps · react-window virtualization works |
🧪 Test Plan
| Layer | Tool | Coverage |
|---|---|---|
| Frontend Unit | Vitest + Testing Library | ≥ 85% · Zod schemas · TreeRow · DetailPanel · ImportModal |
| Frontend E2E | Playwright | 18 ACs · multi-tab edit race |
| Backend Unit | Go testing + testify | ≥ 90% · handler · romanize · TIS-1099 validators |
| Backend Integration | dockertest + PG (pg_trgm) + Redis + NATS | real flow · CopyFrom · cache invalidation |
| TIS-1099 validator | property-based (gopter) | 1000 random codes · valid + invalid |
| Romanize | fixture test · DOPA official names | 1000 sample names · ≥ 95% match |
| CSV Import | real DOPA file (7,400 rows) | full upsert + dry-run + error handling |
| Performance | k6 + DevTools | tree scroll 60fps · typeahead p95 ≤ 80ms |
| Security | OWASP ZAP + manual | RBAC enforce · CSV injection · CSV bomb |
📋 Definition of Done
- ✅ ผ่าน AC-01 ถึง AC-18
- ✅ Coverage ≥ 85% FE / ≥ 90% BE
- ✅ Lighthouse ≥ 90 (Performance + A11y)
- ✅ DOPA CSV 7,400 rows import ≤ 8s
- ✅ pg_trgm typeahead p95 ≤ 80ms ที่ 500 RPS
- ✅ Romanize ≥ 95% match DOPA official names
- ✅ NATS broadcast → Cloudflare purge tested end-to-end
- ✅ Audit log ครบ 7 events
- ✅ Tree virtualization smooth 60fps
- ✅ TIS-1099 validator property-tested (1000 random)
address.changed agreed กับ Cache team · Cloudflare API key for purge ready