Tính CBM và trọng lượng tính cước bằng TypeScript: những trường hợp biên khi làm công cụ cho hàng Trung Quốc về Việt Nam
Nếu bạn từng làm hệ thống cho shop nhập hàng Quảng Châu, Taobao hay 1688, chắc bạn đã gặp câu hỏi này: "Hàng của em có 300 kg, sao lại bị tính cước như 1,3 khối?". Mình làm ở một công ty giao nhận ở Thâm Quyến và vừa viết lại công cụ tính CBM cho website của công ty. Ban đầu mình nghĩ chỉ cần vài phép nhân, nhưng hóa ra có khá nhiều trường hợp biên. Bài này chia sẻ mô hình dữ liệu và những lỗi mình đã gặp, code bằng TypeScript.
1. Ba con số cơ bản
Mỗi loại thùng có kích thước mặt ngoài (cm), số thùng và trọng lượng cả bì của một thùng. Từ đó tính ra:
- Thể tích (CBM, "khối"):
dài × rộng × cao ÷ 1.000.000, nhân với số thùng. - Trọng lượng thể tích (quy đổi):
dài × rộng × cao ÷ hệ số chia. Hàng không thường chia cho 6000, nhiều hãng chuyển phát nhanh chia cho 5000, tức cùng một thùng nhưng "nặng" hơn 20%. - Trọng lượng tính cước hàng không: số lớn hơn giữa trọng lượng cả bì và trọng lượng thể tích.
export const AIR_DIVISOR = 6000
export const COURIER_DIVISOR = 5000
export type Carton = {
length: number // cm
width: number
height: number
quantity: number
weight: number // kg mỗi thùng, 0 nếu chưa nhập
}
export function totals(cartons: Carton[]) {
let count = 0, volume = 0, weight = 0, missingWeight = false
for (const c of cartons) {
count += c.quantity
volume += c.length * c.width * c.height * c.quantity // cộng theo cm³
weight += c.weight * c.quantity
if (!c.weight) missingWeight = true
}
const cbm = volume / 1_000_000 // chỉ chia một lần ở cuối
const volumetric = volume / AIR_DIVISOR
return {
cartons: count,
cbm,
weight,
volumetric,
volumetric5000: volume / COURIER_DIVISOR,
chargeable: Math.max(weight, volumetric),
density: weight && cbm ? weight / cbm : null, // kg mỗi CBM
missingWeight,
}
}
Có hai điểm nhỏ nhưng quan trọng: cộng thể tích theo cm³ rồi mới chia một lần, thay vì cộng các giá trị CBM đã làm tròn của từng thùng; và trọng lượng là trường không bắt buộc. Nhiều người chỉ cần biết số khối, nên thiếu trọng lượng thì đánh dấu missingWeight chứ không báo lỗi.
Ví dụ: 10 thùng 60 × 40 × 40 cm (12 kg/thùng) và 3 thùng 45 × 35 × 30 cm (8 kg/thùng) cho kết quả 1,10175 CBM, 144 kg cả bì, trọng lượng thể tích 183,625 kg (÷6000) hoặc 220,35 kg (÷5000). Đi hàng không, lô 144 kg này bị tính như 183,6 kg.
2. Đường bộ Trung Quốc – Việt Nam: "khối hay ký, cái nào lớn tính cái đó"
Hàng ghép đường biển và đường bộ thường không tính theo trọng lượng thể tích mà theo CBM, kèm một tỷ lệ quy đổi: bao nhiêu kg thì được tính là 1 CBM. Hàng nặng hơn tỷ lệ đó sẽ bị quy đổi trọng lượng ra "khối tính cước". Dân trong nghề gọi là W/M (weight or measure).
Tỷ lệ này mỗi tuyến một khác. Trên tuyến đường bộ từ Quảng Đông về Việt Nam của bọn mình, 1 CBM = 230 kg, tối thiểu 0,5 CBM mỗi lô. Các tuyến khác: đường biển đi Đông Nam Á và châu Phi 500 kg, Indonesia 400 kg, đường bộ đi Thái Lan 350 kg. Vì vậy mình mô tả mỗi tuyến bằng dữ liệu thay vì viết if/else:
type VolumeLane = {
unit: "cbm"
kgPerCbm?: number // hàng nặng: bao nhiêu kg tính là 1 CBM
minimum: number // mức tính cước tối thiểu mỗi lô, CBM
step?: number // làm tròn lên theo bước, ví dụ 0.1 CBM
}
const vnLand: VolumeLane = { unit: "cbm", kgPerCbm: 230, minimum: 0.5 }
function billVolume(lane: VolumeLane, cartons: Carton[]) {
const sum = totals(cartons)
const byWeight = lane.kgPerCbm ? sum.weight / lane.kgPerCbm : 0
let billed = Math.max(sum.cbm, byWeight)
let basis = byWeight > sum.cbm ? "weight" : "volume"
if (billed < lane.minimum) {
billed = lane.minimum
basis = "minimum"
}
if (lane.step) billed = ceilTo(billed, lane.step)
return { billed, basis }
}
Hai ví dụ thực tế:
| Lô hàng | Thể tích đo được | Cách tính | CBM tính cước |
|---|---|---|---|
| 20 thùng 40 × 30 × 30 cm, 25 kg/thùng | 0,72 CBM | 500 kg ÷ 230 ≈ 2,17 (basis: "weight") |
2,17 |
| 4 thùng 50 × 40 × 30 cm, 10 kg/thùng | 0,24 CBM | dưới mức tối thiểu (basis: "minimum") |
0,5 |
Lô đầu tiên bị tính gấp ba lần thể tích đo được, vì hàng nặng (khoảng 694 kg mỗi CBM). Lô thứ hai thì ngược lại: hàng ít nên chạm mức tối thiểu. Trả về cả basis chứ không chỉ con số giúp người dùng hiểu vì sao ra kết quả đó, và giảm hẳn số câu hỏi kiểu "sao lại tính 0,5 khối".
Tuyến này còn có giới hạn theo từng kiện: kiện trên 2 CBM bị phụ thu phí xử lý. Cái này không tính được từ tổng mà phải duyệt từng loại thùng:
const tooBig = cartons.some(
(c) => (c.length * c.width * c.height) / 1_000_000 > 2
)
Ngoài ra, bưu kiện thương mại điện tử lại tính kiểu khác: mỗi vận đơn một kiện, từ 1 kg, tối đa 30 kg, trọng lượng quy đổi = D × R × C ÷ 5000, lấy số lớn hơn giữa trọng lượng thực tế và quy đổi. Cùng là hàng Trung Quốc về Việt Nam nhưng là hai mô hình tính cước khác nhau, nên tách thành hai loại tuyến là hợp lý. (Công cụ hiện chỉ mô hình hóa tuyến đường bộ; bưu kiện TMĐT chưa có trong công cụ.)
3. Lỗi số thực: 0,3 khối bị làm tròn thành 0,4
Tuyến đường biển đi Philippines làm tròn lên theo bước 0,1 CBM. Hàm ceilTo đầu tiên mình viết rất "hiển nhiên":
const ceilTo = (value: number, step: number) =>
Math.ceil(value / step) * step
Rồi mình thử với 0.1 * 3, mà trong JavaScript thì:
0.1 * 3 // 0.30000000000000004
0.30000000000000004 / 0.1 // 3.0000000000000004
Math.ceil(3.0000000000000004) // 4 → làm tròn thành 0,4 CBM thay vì 0,3
Chưa hết, phép nhân ngược lại cũng sinh nhiễu: lô 0,625 CBM trong test ra 0.7000000000000001 chứ không phải 0,7. Cách sửa: trừ một epsilon rất nhỏ trước khi làm tròn lên, rồi làm tròn kết quả về độ chính xác hợp lý:
/** Làm tròn lên theo bước, bỏ qua nhiễu số thực như 0.30000000000000004. */
function ceilTo(value: number, step: number) {
return Math.round(Math.ceil(value / step - 1e-9) * step * 1e6) / 1e6
}
1e-9 nhỏ hơn rất nhiều so với bất kỳ sai số đo đạc nào bằng thước dây, nên nó chỉ "nuốt" nhiễu số thực chứ không bỏ qua 0,0001 CBM thật. Trong test, 5 thùng 50 × 50 × 50 cm (0,625 CBM) phải ra đúng 0,7 CBM.
4. Nhập liệu: dấu phẩy thập phân
Đây là lỗi mà người dùng Việt Nam sẽ gặp ngay: mình gõ 12,5 cho 12,5 kg, còn Number("12,5") trả về NaN. Thay vì hard-code danh sách ngôn ngữ dùng dấu phẩy, có thể hỏi thẳng Intl:
function usesDecimalComma(locale: string) {
return new Intl.NumberFormat(locale)
.formatToParts(0.5)
.some((p) => p.type === "decimal" && p.value === ",")
}
usesDecimalComma("vi") // true
usesDecimalComma("en") // false
function parse(raw: string, decimalComma: boolean) {
let text = raw.normalize("NFKC").trim() // chữ số full-width → ASCII
if (decimalComma) text = text.replace(",", ".")
const value = Number(text)
return /^(\d+\.?\d*|\.\d+)$/.test(text) && value > 0 ? value : null
}
Thêm một mẹo: người dùng hay nhập kích thước theo mm vào ô cm. Thùng 60 × 40 × 40 cm gõ thành "600 × 400 × 400" sẽ ra 96 CBM, gấp 1.000 lần thể tích thật. Giới hạn trên cho mỗi trường (ví dụ 1.500 cm mỗi cạnh) chặn được các lỗi lớn, như cạnh pallet 1,6 m gõ thành 1600, mà không chặn hàng quá khổ thật; lỗi nhỏ hơn như ví dụ trên vẫn lọt qua nhưng ra một thể tích vô lý, dễ nhận ra.
5. Rút ra được gì
- Tách logic tính toán khỏi UI. Toàn bộ phần tính là hàm thuần trong một module TypeScript, không dính React, nên trang web, component và script test dùng chung.
- Mô tả tuyến vận chuyển bằng dữ liệu. Tỷ lệ kg/CBM, mức tối thiểu, bước làm tròn, giới hạn kích thước đều là field. Thêm tuyến mới chỉ là thêm một object.
- Trả về lý do, không chỉ con số.
basis: "weight" | "volume" | "minimum"giúp hiển thị câu giải thích rõ ràng. - Test với những giá trị "xấu" trong hệ nhị phân, như
0.1 * 3,0.7, và dấu phẩy thập phân.
Bạn có thể thử các trường hợp trên (hàng nặng đi đường bộ, lô nhỏ chạm mức tối thiểu, hàng không ÷6000 và ÷5000) trên công cụ tính CBM bản tiếng Việt mà bọn mình đã làm. Có nút điền sẵn ví dụ và hiển thị từng tuyến sẽ tính cước theo khối hay theo ký.
Nếu bạn cũng đang làm hệ thống cho logistics hoặc thương mại điện tử xuyên biên giới, rất mong được nghe các trường hợp biên mà bạn đã gặp trong phần bình luận.
All rights reserved