Skip to content

Review Step: Check2FA và Ward Logic

Mục tiêu bài học

  • Hiểu review step trong eKYC
  • Nắm check2fa logic và ward search
  • Biết cách xử lý missing fields (province, ward, address)
  • Học auto-fill logicprovince code mapping

Tổng quan bước xem lại

Xem lại là bước cuối trước khi gửi eKYC. Người dùng:

  1. Xem lại thông tin OCR
  2. Xem kết quả xác thực khuôn mặt
  3. Kiểm tra địa chỉ (check2fa + ward)
  4. Điền missing fields (nếu thiếu)
  5. Gửi

Check2FA Logic - Flow Tổng quan

Khi nào cần kiểm tra?

typescript
// Kiểm tra khi VÀO bước xem lại (không phải sau khi gửi)
watch(currentStep, async (newStep) => {
  if (newStep === "review") {
    await loadProvinceList(); // Load trước để map province name → code
    await check2faAndWard();
  }
});

State Management

typescript
// Theo dõi các trường còn thiếu
const missingFields = reactive({
  province: false,
  ward: false,
  address: false,
});

// Giá trị input cho các trường còn thiếu
const missingFieldsInput = reactive({
  province: "", // Lưu mã tỉnh (code)
  ward: "", // Lưu tên phường/xã
  address: "", // Lưu chi tiết địa chỉ
});

Flow Logic (Tóm tắt)

1. Gọi check2fa API

2. Phát hiện missing fields (province, ward, address)

3. Nếu thiếu ward → Tìm kiếm ward từ địa chỉ OCR

4. Nếu tìm thấy ward:
   - Tự động điền ward, province, address
   - Kiểm tra ward mới sau sáp nhập
   - Nếu có 1 kết quả → Tự động chọn
   - Nếu có nhiều kết quả → Hiển thị dropdown

5. Hiển thị UI cho missing fields

6. User xem lại và chỉnh sửa (nếu cần)

7. Submit với missing fields

Phát hiện Missing Fields

Logic Phát hiện

Hệ thống kiểm tra từng trường trong response của check2fa API:

typescript
// Kiểm tra từng trường
const hasProvince =
  data.province &&
  typeof data.province === "string" &&
  data.province.trim() !== "";
const hasWard =
  data.ward && typeof data.ward === "string" && data.ward.trim() !== "";
const hasAddress =
  data.address &&
  typeof data.address === "string" &&
  data.address.trim() !== "";

// Cập nhật flags
missingFields.province = !hasProvince;
missingFields.ward = !hasWard;
missingFields.address = !hasAddress;

Ví dụ Response

Trường hợp 1: Đầy đủ

json
{ "province": "HNI", "ward": "Phường Cầu Giấy", "address": "247 Cầu Giấy" }

missingFields = { province: false, ward: false, address: false }

Trường hợp 2: Thiếu tất cả

json
{ "province": "", "ward": "", "address": "" }

missingFields = { province: true, ward: true, address: true }


Tự động điền Missing Fields

Thứ tự ưu tiên

  1. Từ Ward Search Result (nếu có):

    • ward → từ firstWard.wardName
    • province → từ firstWard.provinceName (convert sang code)
    • address → từ extractAddressDetail() (phần trước ward name)
  2. Từ New Ward Options (nếu có):

    • ward → từ newWard.newWard
    • province → từ newWard.newProvince (convert sang code)
    • address → từ extractAddressDetail() với oldWard hoặc newWard
  3. Từ Check2FA Data (nếu có):

    • Khởi tạo missingFieldsInput với giá trị hiện có
    • Convert province name → code nếu cần

Ví dụ Auto-fill

typescript
// Sau khi tìm thấy ward từ API
if (wardData.found && wardData.wards.length > 0) {
  const firstWard = wardData.wards[0];

  // Auto-fill ward
  if (missingFields.ward) {
    missingFieldsInput.ward = firstWard.wardName;
  }

  // Auto-fill province (convert name → code)
  if (missingFields.province) {
    const provinceCode = getProvinceCode(firstWard.provinceName);
    missingFieldsInput.province = provinceCode || firstWard.provinceName;
  }

  // Auto-fill address detail
  if (missingFields.address && frontOcrData.value?.address) {
    const addressDetail = extractAddressDetail(
      frontOcrData.value.address,
      firstWard.wardName,
      firstWard.districtName
    );
    if (addressDetail) {
      missingFieldsInput.address = addressDetail;
    }
  }
}

Province Code Mapping

Vấn đề

API check2fa có thể trả về province name (ví dụ: "Thành phố Hà Nội") hoặc code (ví dụ: "HNI"). Nhưng API updateInfoSso yêu cầu province code.

Giải pháp

Sử dụng getProvinceCode() để convert:

typescript
function getProvinceCode(provinceName: string): string | null {
  if (!provinceName || !provinceList.value.length) return null;

  const normalized = (text: string) =>
    text
      .toLowerCase()
      .normalize("NFD")
      .replace(/[\u0300-\u036f]/g, "")
      .trim();

  const normalizedName = normalized(provinceName);

  // 1. Exact match
  const exact = provinceList.value.find(
    (p) => normalized(p.name) === normalizedName
  );
  if (exact) return exact.value;

  // 2. Fuzzy match (contains)
  const fuzzy = provinceList.value.find(
    (p) =>
      normalized(p.name).includes(normalizedName) ||
      normalizedName.includes(normalized(p.name))
  );
  if (fuzzy) return fuzzy.value;

  return null;
}

Province Input Display

UI hiển thị "Tên (Code)" nhưng lưu code:

typescript
const provinceInputDisplay = computed({
  get: () => {
    if (!missingFieldsInput.province) return "";
    return getProvinceDisplayText(missingFieldsInput.province); // "Thành phố Hà Nội (HNI)"
  },
  set: (value: string) => {
    // Extract code from "Name (Code)" format
    const match = value.match(/\(([^)]+)\)$/);
    if (match && match[1]) {
      missingFieldsInput.province = match[1].trim(); // Lưu "HNI"
    } else {
      // Try to get code from name
      const provinceCode = getProvinceCode(value);
      missingFieldsInput.province = provinceCode || value.trim();
    }
  },
});

Extract Address Detail

Mục đích

Trích xuất phần "Chi tiết địa chỉ" (số nhà, tên đường) từ địa chỉ đầy đủ bằng cách loại bỏ phần phường/xã.

Logic (Tóm tắt)

  1. Normalize text (remove diacritics, lowercase)
  2. Tìm ward name trong địa chỉ (thử nhiều patterns: với/không prefix)
  3. Verify district xuất hiện sau ward (nếu có)
  4. Extract phần trước ward name
  5. Remove trailing separators

Ví dụ

typescript
const fullAddress = "247 Cầu Giấy, Phường Cầu Giấy, Quận Cầu Giấy";
const wardName = "Phường Cầu Giấy";

const addressDetail = extractAddressDetail(fullAddress, wardName);
// → "247 Cầu Giấy"

Country Detection (isVietnam)

Logic

Không có trường country trong OCR data. Xác định dựa trên kết quả ward API:

  • isVietnam = true: Tìm thấy ward → Hiển thị ward dropdown
  • isVietnam = false: Không tìm thấy → Hiển thị manual input
  • isVietnam = null: Chưa xác định → Hiển thị loading/default

Ví dụ

typescript
// Sau khi gọi findWardByAddress
if (wardData.found && wardData.wards.length > 0) {
  isVietnam.value = true; // Tìm thấy ward → Việt Nam
} else {
  isVietnam.value = false; // Không tìm thấy → Không phải Việt Nam
}

Ward Search Flow

Flow Diagram

Enter Review Step

Load Province List

Call check2fa()

Detect Missing Fields

┌─────────────────┐
│ Có địa chỉ OCR? │
└────────┬────────┘

    ┌────┴────┐
    │         │
   Yes       No
    │         │
    ↓         ↓
findWardBy  Thử với ward
Address()   input thủ công
    │         │
    └────┬────┘

    ┌────┴────┐
    │ Tìm thấy?│
    └────┬────┘

    ┌────┴────┐
    │         │
   Yes       No
    │         │
    ↓         ↓
Auto-fill  isVietnam
Missing    = false
Fields     (manual input)

checkNewVnProvinceWard()

┌──────────────┐
│ Có kết quả? │
└──────┬───────┘

  ┌────┴────┐
  │         │
1 kết quả  Nhiều kết quả
  │         │
  ↓         ↓
Auto-select Dropdown
+ Auto-fill để chọn

UI Hiển thị Missing Fields

Conditional Rendering

UI hiển thị dựa trên:

  • missingFields flags (province, ward, address)
  • isVietnam status (true/false/null)
  • newWardOptions (có ward options từ API không)

Template (Tóm tắt)

vue
<!-- Missing Fields Notice -->
<div v-if="missingFields.province || missingFields.ward || missingFields.address">
  <!-- Warning Header -->
  <div class="warning-header">
    <Lucide icon="AlertTriangle" />
    <h3>{{ isVietnam === true ? 'Thiếu thông tin địa chỉ' : 'Không phải Việt Nam' }}</h3>
  </div>

  <!-- Province Input -->
  <div v-if="missingFields.province">
    <label>Tỉnh/Thành phố *</label>
    <input v-model="provinceInputDisplay" />
    <!-- Hiển thị "Tên (Code)", lưu code -->
  </div>

  <!-- Ward Dropdown/Input -->
  <div v-if="missingFields.ward">
    <label>Phường/Xã *</label>
    <!-- Dropdown nếu có newWardOptions -->
    <a-select v-if="isVietnam === true && newWardOptions.length > 0"
              v-model:value="selectedNewWard"
              :options="newWardOptions.map(opt => ({
                value: opt.newWard,
                label: opt.newWard
              }))" />
    <!-- Text input nếu không có options -->
    <input v-else v-model="missingFieldsInput.ward" />
  </div>

  <!-- Address Input -->
  <div v-if="missingFields.address">
    <label>Chi tiết địa chỉ *</label>
    <input v-model="missingFieldsInput.address" />
  </div>
</div>

Handler cho Ward Selection

typescript
function handleWardSelection() {
  const selected = newWardOptions.value.find(
    (opt) => opt.newWard === selectedNewWard.value
  );

  if (selected) {
    // Điền ward
    if (missingFields.ward) {
      missingFieldsInput.ward = selected.newWard;
    }

    // Điền province (convert name → code)
    if (missingFields.province) {
      const provinceCode = getProvinceCode(selected.newProvince);
      missingFieldsInput.province = provinceCode || selected.newProvince;
    }

    // Tự động điền address detail
    if (frontOcrData.value?.address) {
      let addressDetail = "";
      if (selected.oldWard) {
        addressDetail = extractAddressDetail(
          frontOcrData.value.address,
          selected.oldWard
        );
      }
      if (!addressDetail && selected.newWard) {
        addressDetail = extractAddressDetail(
          frontOcrData.value.address,
          selected.newWard
        );
      }
      if (addressDetail && missingFields.address) {
        missingFieldsInput.address = addressDetail;
      }
    }
  }
}

Khi nào cần?

  • Tự động tìm phường/xã không chính xác
  • Người dùng muốn tìm phường/xã khác
  • Không tìm thấy phường/xã tự động

Implementation (Tóm tắt)

typescript
// Mở modal tìm kiếm thủ công
function openManualWardSearchModal() {
  showManualWardSearchModal.value = true;
  manualWardSearchQuery.value = "";
}

// Thực hiện tìm kiếm
async function performManualWardSearch() {
  const response = await customerService.checkNewVnProvinceWard({
    query: manualWardSearchQuery.value.trim(),
  });
  // ... xử lý response
}

// Chọn ward từ kết quả
function selectWardFromManualSearch(ward: INewWardOption) {
  if (missingFields.ward) missingFieldsInput.ward = ward.newWard;
  if (missingFields.province) {
    const provinceCode = getProvinceCode(ward.newProvince);
    missingFieldsInput.province = provinceCode || ward.newProvince;
  }
  // Auto-fill address detail nếu có thể
  closeManualWardSearchModal();
}

Submit với Missing Fields

Validation

typescript
function validateBeforeSubmit(): { valid: boolean; errors: string[] } {
  const errors: string[] = [];

  if (missingFields.province && !missingFieldsInput.province.trim()) {
    errors.push("Tỉnh/Thành phố là bắt buộc");
  }
  if (missingFields.ward && !missingFieldsInput.ward.trim()) {
    errors.push("Phường/Xã là bắt buộc");
  }
  if (missingFields.address && !missingFieldsInput.address.trim()) {
    errors.push("Chi tiết địa chỉ là bắt buộc");
  }

  return { valid: errors.length === 0, errors };
}

Mapping vào updateInfoSso

typescript
// Sau khi face verification thành công
if (isSamePerson(faceVerificationData.value)) {
  const updateData: IUpdateInfoSsoRequest = {};

  // Address: combine detail + ward
  let fullAddress =
    missingFields.address && missingFieldsInput.address.trim()
      ? missingFieldsInput.address.trim()
      : mergedFrontOcrData.address || "";

  const wardName = missingFields.ward
    ? missingFieldsInput.ward.trim()
    : mergedFrontOcrData.precinct || "";

  if (fullAddress && wardName && !fullAddress.includes(wardName)) {
    fullAddress = `${fullAddress}, ${wardName}`;
  }
  updateData.address = fullAddress;

  // Ward
  updateData.ward = missingFields.ward
    ? missingFieldsInput.ward.trim()
    : mergedFrontOcrData.precinct;

  // Province (luôn dùng code)
  updateData.province = missingFields.province
    ? missingFieldsInput.province.trim() // Đã là code
    : getProvinceCode(mergedFrontOcrData.province) ||
      mergedFrontOcrData.province;

  // Country, Gender, BirthDay, ID Number, Phone...
  updateData.country = "VN";
  // ... mapping các trường khác

  await customerService.updateInfoSso(updateData);
}

Edge Cases

1. Không có địa chỉ từ OCR

typescript
if (!address || !address.trim()) {
  isVietnam.value = null; // Chưa xác định

  // Nếu ward bị thiếu NHƯNG user đã nhập ward name thủ công
  if (missingFields.ward && missingFieldsInput.ward?.trim()) {
    // Thử gọi checkNewVnProvinceWard trực tiếp
    const newWardResponse = await customerService.checkNewVnProvinceWard({
      query: missingFieldsInput.ward.trim(),
    });
    // ... xử lý response
  }
}

2. Ward Search không tìm thấy

typescript
if (!wardData?.found || !wardData.wards?.length) {
  isVietnam.value = false; // Không phải Việt Nam
  // Hiển thị UI cho non-Vietnam (manual input)
}

3. Multiple Ward Options

typescript
if (newWardOptions.value.length > 1) {
  // Hiển thị dropdown để user chọn
  // Không tự động điền
}

4. Province Code không tìm thấy

typescript
const provinceCode = getProvinceCode(provinceName);
if (provinceCode) {
  missingFieldsInput.province = provinceCode;
} else {
  // Fallback: dùng province name
  missingFieldsInput.province = provinceName;
}

Tổng kết

Flow Hoàn chỉnh

1. User vào Review Step
2. Load Province List
3. Gọi check2fa API
4. Phát hiện Missing Fields
5. Tìm kiếm Ward (nếu thiếu)
6. Tự động điền Missing Fields
7. Kiểm tra Ward mới sau sáp nhập
8. Hiển thị UI cho Missing Fields
9. User xem lại và chỉnh sửa
10. Validate trước khi submit
11. Submit eKYC
12. Auto-update User Info (updateInfoSso)

Key Points

  1. Missing Fields Detection: Phát hiện tự động từ check2fa response
  2. Auto-fill Logic: Ưu tiên từ Ward Search → New Ward Options → Check2FA Data
  3. Province Code: Hiển thị "Tên (Code)", lưu code
  4. Country Detection: Dựa trên kết quả ward API
  5. Edge Cases: Xử lý tất cả trường hợp (không có OCR, không tìm thấy ward, multiple options, etc.)

Bài học tiếp theo

I18n và UX Patterns

Internal documentation for iNET Portal