Bài viết/Developer

Xây máy tính giá trang sức vàng bằng JavaScript

Xây máy tính giá trang sức từ giá vàng trực tiếp theo gram và tuổi vàng, với phép tính thập phân chính xác, cache, biên lợi nhuận và giới hạn định giá rõ ràng.

Developer

Tiệm vàng, cửa hàng cầm đồ và người bán thương mại điện tử đều cần một công cụ nhỏ giống nhau: nhập trọng lượng và tuổi vàng để nhận giá trị vật liệu. Bài này xây công cụ đó từ đầu đến cuối bằng JavaScript thuần, từ giá trực tiếp theo gram đến giao diện trong trình duyệt.

Máy tính này ước tính gì và không tính gì

Kết quả là giá trị vật liệu: giá trị phần vàng trong món đồ theo giá spot hiện tại, trước khi cộng các chi phí khác. Đây không phải kết quả thẩm định, giá bán lại được bảo đảm hay số tiền người mua chắc chắn trả.

Các phần không được tính:

  • Đá quý, men hoặc vật liệu không phải vàng
  • Công chế tác và thiết kế
  • Thuế và lệ phí, vốn khác nhau theo quốc gia và loại giao dịch
  • Chênh lệch mua bán của cửa hàng so với giá spot
  • Tình trạng món đồ như vết xước, đá bị mất hoặc nét khắc bị mòn
  • Mức chiết khấu mua lại để bảo vệ biên lợi nhuận của cửa hàng

Mỗi kết quả nên đi kèm dòng giải thích này. Hãy đặt phần giới hạn ngay trong giao diện, không chỉ trong tài liệu.

Bước 1: Lấy tám mức giá theo gram

Endpoint /v1/carat trả về giá theo gram cho tám độ tinh khiết phổ biến trong một lượt gọi và không cần API key:

GET https://api.goldprice.dev/v1/carat?currency=USD

Một phản hồi thực tế có dạng:

{
  "currency": "USD",
  "timestamp": "2026-07-16T18:12:06.394451Z",
  "price_gram_24k": "128.24",
  "price_gram_22k": "117.55",
  "price_gram_21k": "112.21",
  "price_gram_20k": "106.86",
  "price_gram_18k": "96.18",
  "price_gram_16k": "85.49",
  "price_gram_14k": "74.80",
  "price_gram_10k": "53.43"
}

Hai chi tiết quan trọng cho đoạn mã bên dưới. Mỗi trường giá là một chuỗi, không phải số, để giữ nguyên các chữ số thập phân sau vòng chuyển đổi JSON. Tham số currency chấp nhận mọi đồng tiền trong 31 đồng tiền API hỗ trợ, nên cùng endpoint có thể dùng cho USD, EUR, INR hoặc IDR bằng cách đổi một query parameter.

Bước 2: Cache 60 giây ở máy chủ

Dữ liệu nguồn cập nhật theo nhịp tương tự, vì vậy gọi lại ở mỗi lượt tải trang chỉ làm tăng độ trễ. Đặt một máy chủ Node nhỏ với cache trong bộ nhớ ở phía trước:

import { createServer } from "node:http";

const UPSTREAM = "https://api.goldprice.dev/v1/carat";
const TTL_MS = 60_000;

const cache = new Map(); // currency -> { data, fetchedAt }

async function fetchCarat(currency) {
  const res = await fetch(`${UPSTREAM}?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`upstream ${res.status}`);
  }
  const data = await res.json();
  if (typeof data.price_gram_24k !== "string") {
    throw new Error("malformed upstream response");
  }
  return data;
}

async function getCarat(currency) {
  const cached = cache.get(currency);
  const now = Date.now();

  if (cached && now - cached.fetchedAt < TTL_MS) {
    return cached.data;
  }

  try {
    const data = await fetchCarat(currency);
    cache.set(currency, { data, fetchedAt: now });
    return data;
  } catch (err) {
    if (cached) {
      return cached.data; // stale-if-error: serve last known value
    }
    throw err;
  }
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname !== "/api/carat") {
    res.writeHead(404).end("not found");
    return;
  }
  const currency = (url.searchParams.get("currency") || "USD").toUpperCase();

  try {
    const data = await getCarat(currency);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(data));
  } catch (err) {
    res.writeHead(502, { "content-type": "application/json" }).end(
      JSON.stringify({ error: "carat_unavailable" })
    );
  }
});

server.listen(3001);

Đây là node:http thuần, không cần framework cho một endpoint. Xử lý lỗi quan trọng không kém đường chạy thành công: phản hồi khác 200 hoặc body sai định dạng sẽ ném lỗi, và getCarat quay về giá trị cache gần nhất nếu có. Chỉ khi hoàn toàn không có cache, client mới nhận lỗi 502.

Không gọi api.goldprice.dev trực tiếp từ JavaScript trong trình duyệt. API không mở CORS cho trình duyệt, nên fetch phía client sẽ thất bại. Hãy chuyển mọi yêu cầu qua endpoint phía máy chủ của bạn. Cách này cũng giúp mọi người dùng chung một cache thay vì mỗi tab trình duyệt giữ một bản riêng.

Bước 3: Giá trị vật liệu bằng trọng lượng nhân giá theo gram

Công thức chính chỉ có một phép nhân: weight_grams × per_gram_karat_price. Hai toán hạng cần giữ số thập phân chính xác vì phép tính dấu phẩy động với tiền có thể sai lệch:

console.log(0.1 + 0.2); // 0.30000000000000004

API đã trả về chuỗi thập phân chính xác. Hãy giữ giá trị dưới dạng số nguyên theo đơn vị tiền nhỏ nhất thay vì chuyển sang Number rồi chuyển ngược:

function toMinorUnits(decimalString, decimals = 2) {
  const [whole, frac = ""] = decimalString.split(".");
  const paddedFrac = (frac + "0".repeat(decimals)).slice(0, decimals);
  const sign = whole.startsWith("-") ? -1n : 1n;
  const wholeAbs = whole.replace("-", "");
  return sign * (BigInt(wholeAbs) * 10n ** BigInt(decimals) + BigInt(paddedFrac || "0"));
}

function fromMinorUnits(minorUnits, decimals = 2) {
  const negative = minorUnits < 0n;
  const abs = negative ? -minorUnits : minorUnits;
  const divisor = 10n ** BigInt(decimals);
  const whole = abs / divisor;
  const frac = (abs % divisor).toString().padStart(decimals, "0");
  return `${negative ? "-" : ""}${whole}.${frac}`;
}

function materialValue(weightGrams, perGramPriceString) {
  const priceMinor = toMinorUnits(perGramPriceString, 2); // cents
  const weightMilligrams = BigInt(Math.round(weightGrams * 1000)); // 3dp precision
  const totalMinor = (priceMinor * weightMilligrams) / 1000n;
  return fromMinorUnits(totalMinor, 2);
}

Không cần thư viện số lớn, chỉ dùng BigInt có sẵn trong các runtime JavaScript hiện nay. Trọng lượng ban đầu là số thực vì cân kim hoàn thường đọc đến 0,01g hoặc 0,001g. Đoạn mã chuyển nó thành số nguyên miligram trước khi nhân, rồi chia ở bước cuối để giữ độ chính xác của giá trị trung gian. Phép chia số nguyên sẽ cắt phần dư thay vì làm tròn, vì vậy hãy chọn quy tắc làm tròn rõ ràng theo đơn vị tiền tệ trước khi đưa vào sản xuất.

Bước 4: Tách riêng biên lợi nhuận và tiền công

Giá trị vật liệu ở bước 3 được suy ra từ giá spot, chưa phải báo giá của cửa hàng. Cần thêm hai đầu vào dưới dạng hai dòng riêng, không trộn vào giá spot:

  • Biên lợi nhuận: phần cộng vào giá bán hoặc chiết khấu phía mua, thường theo tỷ lệ phần trăm
  • Tiền công: chi phí biến vàng nguyên liệu thành thành phẩm, thường là phí cố định hoặc phần trăm giá trị vật liệu
function quoteBreakdown(materialValueMinor, marginPercent, makingChargeMinor) {
  const marginMinor = (materialValueMinor * BigInt(Math.round(marginPercent * 100))) / 10000n;
  return {
    material: materialValueMinor,
    margin: marginMinor,
    makingCharge: makingChargeMinor,
    total: materialValueMinor + marginMinor + makingChargeMinor,
  };
}

Ba trường riêng giúp phép tính có thể kiểm tra được. Khách hàng hoặc cơ quan quản lý có thể thấy giá trị vật liệu từ giá spot trên một dòng, tách khỏi phần cửa hàng tự cộng thêm.

Bước 5: Hiển thị dấu thời gian, tiền tệ và giới hạn

Phía trình duyệt gọi máy chủ của bạn, không gọi API nguồn:

async function loadCarat(currency) {
  const res = await fetch(`/api/carat?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`carat request failed: ${res.status}`);
  }
  return res.json();
}

function estimateValue(weightGrams, karat) {
  const karatKey = `price_gram_${karat}k`;

  return loadCarat("USD").then((data) => {
    const perGram = data[karatKey];
    if (!perGram) {
      throw new Error(`unsupported karat: ${karat}`);
    }
    return {
      material: materialValue(weightGrams, perGram),
      timestamp: data.timestamp,
      currency: data.currency,
    };
  });
}

document.getElementById("calc-btn").addEventListener("click", () => {
  const grams = Number(document.getElementById("weight").value);
  const karat = Number(document.getElementById("karat").value);

  estimateValue(grams, karat)
    .then((result) => {
      document.getElementById("result").textContent =
        `${result.material} ${result.currency} as of ${result.timestamp}`;
    })
    .catch((err) => {
      document.getElementById("result").textContent =
        "Price unavailable right now. Try again shortly.";
      console.error(err);
    });
});

Mỗi kết quả trên giao diện cần hiển thị dấu thời gian và tiền tệ. Bên dưới cần có câu cố định: đây là ước tính giá trị vật liệu, không phải thẩm định, và chưa gồm đá, công chế tác, thuế cùng tình trạng món đồ. Đặt câu này trực tiếp trong template, không giấu trong tooltip.

Bước 6: Xử lý lỗi lấy dữ liệu và dữ liệu sai định dạng

Lỗi ở phía máy chủ có ba dạng và đoạn mã trên xử lý cả ba:

  • Yêu cầu đến API nguồn thất bại do mạng hoặc timeout: fetchCarat bắt lỗi và quay về cache
  • Phản hồi có status khác 200: kiểm tra rõ ràng và xử lý như lỗi mạng
  • Body thiếu trường hoặc sai kiểu: kiểm tra typeof data.price_gram_24k !== "string" chặn dữ liệu sai trước khi cache hoặc trả về

Khi có cache hợp lệ, người dùng vẫn nhận giá trị tốt gần nhất cùng dấu thời gian thật. Khi không có cache dùng được, client nhận trạng thái lỗi rõ ràng thay vì phép tính sai hoặc số 0 im lặng.

Giấy phép

Endpoint ẩn danh trong bài phù hợp cho phát triển và thử nghiệm. Dùng dữ liệu API thô trong sản phẩm thương mại hoặc hoạt động tạo doanh thu cần giấy phép gói Pro trở lên. Nếu bạn chỉ cần hiển thị giá trực tiếp trên trang mà không tính toán từ dữ liệu thô, widget nhúng chính thức được dùng miễn phí cho hiển thị thương mại và không cần API key.

Bài đọc liên quan

hướng dẫn liên quan

goldprice.dev

Giá vàng trực tiếp, dữ liệu OHLC lịch sử và tổng hợp nhiều nguồn qua REST và SSE.