Đọc số tiền theo cách người Việt gõ: 2,5tr, 500k và 1tr+500k

Đơn vị như tr và tỷ, dấu chấm mang hai nghĩa, phép tính gõ như Excel, giữ con trỏ đúng chỗ, và bàn phím số điện thoại không có phím cộng.

· Đăng ngày · Cập nhật · 7 phút đọc · Về dự án →

Ở Việt Nam chẳng ai gõ tiền điện là "2500000". Người ta viết "2,5tr", "2.5 triệu", "500k", "0,1 tỷ" hay "2.500.000", và trong Excel thì gõ "=250k*3" là chuyện thường. Kế hoạch tài chính, app lập kế hoạch thu chi của mình, gần như toàn là ô nhập tiền, nên nó phải đọc được hết những kiểu đó. Bài này kể về bộ parser nhỏ đứng sau: dấu chấm mang hai nghĩa, phép tính gõ như trong Excel, giữ vị trí con trỏ khi định dạng, và chuyện bàn phím số trên điện thoại không có phím cộng.

Vì sao ô nhập số không chứa nổi số tiền kiểu Việt Nam

<input type="number"> từ chối chữ cái, nên "2,5tr" bị loại ngay, và nó đọc dấu phân cách theo luật của trình duyệt chứ không theo thói quen người gõ. Tiếng Việt lại dùng dấu ngược với tiếng Anh: vi-VN nhóm hàng nghìn bằng dấu chấm và dùng dấu phẩy cho phần thập phân, nên "2.500.000" là hai triệu năm trăm nghìn. Thế nhưng cũng chính những người đó sẽ viết "2.5tr" với dấu chấm là dấu thập phân, vì bàn phím điện thoại và các app tiếng Anh đã quen tay họ như vậy.

Vì thế mọi ô tiền trong app là ô văn bản có inputmode="decimal" (để điện thoại hiện bàn phím số), kèm một parser chuyển chữ thành số khi ô được lưu.

Một con số: số cộng đơn vị (nếu có)

Một con số là chữ số và dấu phân cách, có thể theo sau bởi một đơn vị. Các đơn vị là đúng những gì người ta hay gõ: k, n, nghìn, ngàn cho nghìn; tr, triệu, trieu, m cho triệu; tỷ, tỉ, ty, b cho tỷ.

const m = s.match(/^([\d.,]+)(k|n|nghìn|ngàn|tr|triệu|trieu|m|tỷ|tỉ|ty|b)?$/);

Trước khi so khớp, chuỗi được đưa về chữ thường, bỏ khoảng trắng, bỏ mã, ký hiệu và các cách viết quen thuộc của loại tiền (VND, ₫, vnđ, đ), và bỏ dấu = ở đầu. "100.000đ", "100.000 VND" và "100000" tới parser đều là cùng một chuỗi.

Đơn vị quyết định dấu phân cách mang nghĩa gì:

  • Có đơn vị thì dấu phân cách là dấu thập phân. "2,5tr" và "2.5tr" đều là 2.500.000: dấu phẩy được đổi thành dấu chấm rồi nhân với đơn vị.
  • Không có đơn vị, với loại tiền không có phần lẻ, mọi dấu chấm và dấu phẩy đều là dấu nhóm. "2.500.000", "2,500,000" và "2500000" là cùng một số. Tiền đồng không còn dùng đơn vị lẻ, nên một con số trơn luôn là số nguyên.
  • Với loại tiền có phần lẻ, locale của chính loại tiền đó quyết định: dấu nhóm của nó bị bỏ, dấu thập phân của nó thành dấu chấm. "$2,500.50" và "2.500,50 €" đều được đọc đúng.

Kết quả được làm tròn theo số chữ số lẻ của loại tiền, nên phép tính như "100k/3" được lưu là 33.333 đ chứ không phải 33.333,333.

Luồng quyết định cho một con số được gõ: làm sạch chuỗi nhập, rồi nếu có đơn vị (k, tr, triệu, tỷ, m, b) thì dấu phẩy hay chấm là dấu thập phân và con số được nhân với đơn vị; nếu không, và loại tiền có số lẻ, thì locale của loại tiền đó quyết định cả hai dấu; còn lại mọi dấu chấm, phẩy đều là dấu nhóm.Làm sạch chuỗi nhậpviết thường; bỏ khoảng trắng, đ, ₫, VND và dấu = ở đầuCó đơn vị không?k, tr, triệu, tỷ, m, b…dấu phẩy hay chấm đều làdấu thập phân, nhân đơn vị2,5tr → 2.500.000cókhôngTiền có số lẻ?$ và € có, đồng thì khônglocale của chính loại tiềnquyết định cả hai dấu2.500,50 € → 2500,50cókhôngmọi dấu chấm, phẩy là nhóm2,500,000 → 2.500.000
Đơn vị quyết định nghĩa của dấu phân cách: có đơn vị thì là dấu thập phân, không có đơn vị và là tiền đồng thì chỉ để nhóm chữ số.

Dấu chấm mang hai nghĩa

Sự mập mờ lộ rõ nhất lúc định dạng trong khi gõ. Hiện "2.500.000" khi người dùng gõ "2500000" thì có ích; biến "2.5" thành "25" chỉ vì dấu chấm trông giống dấu nhóm thì không.

Quy tắc app chọn: chỉ định dạng lại khi chuỗi rõ ràng là một số nguyên trơn, tức là chỉ có chữ số, hoặc chữ số chia nhóm ba bằng dấu chấm.

const PLAIN = /^-?(\d+|\d{1,3}(\.\d{3})+)$/;

"2500000" và "2.500.000" khớp nên được chia nhóm. "2.5tr", "0,1 tỷ" và "14.31" thì không, nên được giữ nguyên như đã gõ và chỉ đổi sang con số đầy đủ khi rời khỏi ô. Có một ngoại lệ để việc sửa số dễ chịu hơn: nếu giá trị trước đó là con số do chính bộ định dạng tạo ra, thì xoá một chữ số trong "2.500.000" thành "2.50.000", chuỗi không còn khớp mẫu nhưng vẫn nên được chia nhóm lại.

Điều cách này không giải quyết được là số thập phân trơn với tiền đồng. Không có đơn vị thì mọi dấu chấm đều là dấu nhóm, nên "2.5" được lưu thành 25 đ và "14.31" thành 1.431 đ. Parser không phân biệt được gõ nhầm với một con số thật, nên ai muốn ghi hai triệu rưỡi thì phải gõ cả đơn vị.

Giữ con trỏ ở đúng chỗ

Định dạng lại làm chữ trong ô xê dịch, và cách cập nhật ngây thơ sẽ đẩy con trỏ về cuối, khiến không thể sửa một chữ số ở giữa. Cách sửa là đếm chữ số thay vì đếm ký tự: trước khi định dạng, đếm xem có bao nhiêu chữ số đứng trước con trỏ; sau khi định dạng, đi dọc chuỗi mới và đặt con trỏ sau đúng ngần ấy chữ số. Dấu chấm được chèn thêm không tính, nên con trỏ vẫn đứng ngay sau chữ số vừa gõ.

Phép tính, như trong Excel

Người ta tính toán khi lập ngân sách: "1tr+500k" cho hai khoản, "=250k*3" cho ba món, "(3tr+2tr)/2" cho trung bình. Ô tiền nào cũng nhận kiểu này.

Chuỗi được tách theo toán tử, kể cả những ký hiệu người dùng điện thoại và Excel hay gõ: × và x cho nhân, ÷ cho chia, − (dấu trừ thật) lẫn -. Sau đó một parser đệ quy xuống nhỏ tính giá trị theo đúng thứ tự ưu tiên quen thuộc:

const sum = () => {
  let v = product();
  while (tokens[i] === "+" || tokens[i] === "-") v = tokens[i++] === "+" ? v + product() : v - product();
  return v;
};

product xử lý * và / theo cùng cách, còn factor lo dấu trừ một ngôi, ngoặc và từng con số, mỗi con số được đọc theo quy tắc ở trên, nên "1,5tr*2" trộn được cả đơn vị lẫn phép tính. Cái gì không đọc trọn được thì trả về NaN: thừa token, ngoặc chưa đóng, hay kết quả không hữu hạn như chia cho 0. Khi đó ô giữ nguyên giá trị cũ và một thông báo cho biết nó không đọc được gì, kèm ví dụ những gì nó đọc được, thay vì lưu một con số sai.

Hai chi tiết làm nó dễ dùng:

  • Dấu trừ ở đầu là một con số, không phải phép tính. "-500k" là số âm; "1tr-500k" là phép tính. Bước kiểm tra bỏ một dấu trừ ở đầu trước khi tìm toán tử.
  • Kết quả hiện ngay dưới ô trong lúc gõ, và thứ được lưu là con số chứ không phải biểu thức. Mở lại ô sẽ thấy "1.500.000", đúng thứ phần còn lại của app làm việc cùng.

Bàn phím số trên điện thoại không có phím cộng

Trên iOS, inputmode="decimal" hiện bàn phím chỉ có chữ số và dấu thập phân, không hề có cộng hay trừ. Nên trên thiết bị cảm ứng, nhận biết bằng matchMedia("(pointer: coarse)"), ô tiền đang được chọn sẽ hiện thêm một hàng phím + − × ÷ của riêng nó.

Những phím này phải gõ vào một input do React điều khiển y như người dùng tự bấm. Gán thẳng input.value không kích hoạt onChange của React, nên code đi qua setter gốc rồi phát một sự kiện input:

Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
requestAnimationFrame(() => el.setSelectionRange(caret, caret));

Con trỏ được đặt lại ở frame kế tiếp, sau khi React đã vẽ lại ô.

Các ô này còn dính thêm một tật của iOS: trang tự phóng to vào mọi ô có cỡ chữ dưới 16 px. Cách sửa nằm cùng các chi tiết iPhone khác trong bài giữ cho PWA đã cài trên iPhone dùng ổn qua các lần cập nhật và deploy.

Hiển thị số tiền: để Intl quyết định

Phần hiển thị dùng Intl.NumberFormat thay vì tự viết luật. App hỏi formatToParts xem locale quê nhà của loại tiền viết số 1234,5 thế nào, rồi đọc ra ký hiệu, ký hiệu đứng bên nào, có khoảng trắng không, dấu nhóm, dấu thập phân và số chữ số lẻ. Nhờ vậy cùng một đoạn code cho ra "2.500.000 ₫", "$2,500.50" và "2.500,50 €"; thêm một loại tiền chỉ là thêm một dòng ghi locale của nó. Dạng rút gọn theo ngôn ngữ giao diện: "2,5 tr" trong tiếng Việt và "2.5M" trong tiếng Anh.

Số âm dùng dấu trừ thật "−" thay vì gạch nối, vừa thẳng hàng với dấu "+" vừa được trình đọc màn hình đọc đúng.

Test chốt lại những gì người ta gõ

Parser rất dễ hỏng vì một thay đổi tưởng vô hại, nên test của nó là một bảng các chuỗi nhập thật:

Gõ Đọc thành
5.500.000 5.500.000
5.5tr 5.500.000
5,5 triệu 5.500.000
500k 500.000
0,1 tỷ 100.000.000
-2tr −2.000.000
=250k*3 750.000
abc không phải số
để trống không có giá trị

Tóm tắt

Vấn đề App làm gì
Đơn vị và dấu phân cách kiểu Việt Ô văn bản với inputmode="decimal"; có đơn vị thì dấu là dấu thập phân, không có thì là dấu nhóm
Định dạng trong lúc gõ Chỉ chia nhóm lại số nguyên trơn; còn lại chờ tới khi rời ô
Con trỏ nhảy về cuối Đặt lại sau đúng số chữ số như trước
Phép tính như "1tr+500k" Bộ tách token cộng parser đệ quy xuống; kết quả hiện ngay, lưu con số
Bàn phím số không có phím cộng Hàng phím + − × ÷ trên thiết bị cảm ứng, gõ qua setter gốc
Nhiều loại tiền Ký hiệu, vị trí, các dấu và số chữ số lẻ lấy từ Intl.NumberFormat

Không phần nào ở đây lớn, chỉ vài trăm dòng tính cả test, nhưng đó lại là phần người dùng chạm vào nhiều nhất trong một app lập ngân sách. Một ô tiền hiểu được "1tr+500k" là bớt đi một lý do phải mở máy tính bỏ túi bên cạnh.

Nếu bạn từng làm một ô nhập tiền đọc được "2,5tr" hay "500k" theo cách khác, mình rất muốn được trao đổi. Email của mình ở trang chủ.