Bỏ qua menu, tới nội dung
Thành viên The MONA Group
Thành viên The MONA Group
Sản phẩm và viết theo yêu cầu
1900 636 648Tư vấn miễn phí, 8h–22h
MONA OS 2.0Hệ thống quản trị MONA tự phát triển, nối 7 phần mềm nội bộ về một dữ liệu, có Gấu Quản Trị trả lời câu hỏi điều hành.7 phần mềm1 trục dữ liệu8 AI AgentXem MONA OS hoạt động
Dự án giáo dụcThe IELTS WorkshopPhần mềm quản lý chuỗi 11 cơ sở luyện thi, kèm app cho học viên20238 tháng12 ngườiXem hồ sơ dự án Xem thêm dự án Khoa Y
Dự án bán lẻHình minh hoạ gấu MONA bán trang sức tại quầy kính, cạnh màn hình bán hàng và tồn khoNgọc Thành JewelryPhần mềm quản lý chuỗi cửa hàng trang sức, tích hợp xuất hoá đơn20246 tháng7 ngườiXem hồ sơ dự án
Dự án sản xuấtHình minh hoạ gấu MONA hướng dẫn công nhân học bài trên phần mềm đào tạo trong phòng họp nhà máyPolytex Far Eastern Việt NamPhần mềm đào tạo công nhân viên chạy trong mạng nội bộ nhà máy20234 tháng8 ngườiXem hồ sơ dự án
Dự án nhân sựHình minh hoạ gấu MONA đứng lớp đào tạo cho bác sĩ, điều dưỡng bằng phần mềm học trực tuyếnBệnh viện Hoàn Mỹ Sài GònViệc đào tạo nhân sự chuyển từ Excel sang phần mềm MONA viết riêng20247 tuần13 moduleXem hồ sơ dự án
Sản phẩm có sẵnHình minh hoạ gấu MONA phục vụ bàn, khách quét mã QR trên bàn để gọi mónnhahang.aiGọi món tại bàn bằng mã QR, kết nối bếp, quản lý bàn, định lượng nguyên liệuNhà hàngQuán ănChuỗi ăn uốngXem nhahang.ai
Sản phẩm có sẵnMONA HouseTự lập hoá đơn tiền phòng, gửi mã QR qua Zalo, báo có khi khách chuyển khoảnNhà trọCăn hộ cho thuêXem MONA House
Sản phẩm có sẵnMONA NHTQĐặt hàng Taobao, 1688, Tmall, ví khách hàng và theo dõi vận đơn cho công ty nhập hàngTaobao1688TmallXem MONA NHTQ
Tư vấn miễn phí, 8h–22h mỗi ngày
Kiến thức phần mềm

Gửi lại yêu cầu bị timeout mà không tạo trùng đơn: idempotency key

MONA Software biên soạnCập nhật · Đăng · 12 phút đọc

Gửi lại yêu cầu bị timeout mà không tạo trùng đơn: idempotency key

Một yêu cầu POST (phương thức HTTP dùng để tạo dữ liệu) tạo đơn bị timeout rồi gửi lại sẽ tạo hai đơn nếu server không nhận ra đó là cùng một yêu cầu. Cách sửa: client sinh một khóa duy nhất cho mỗi lần đặt hàng và gửi nó trong header Idempotency-Key. Server lưu khóa vào bảng có chỉ mục UNIQUE, kèm mã băm nội dung yêu cầu và kết quả đã trả. Khi khóa đến lần nữa, server trả lại kết quả đã lưu thay vì tạo đơn mới.

Bài chạy cả quy trình trên một server tự viết bằng Python 3.9.6 và MySQL 8.4.8, gọi bằng curl 8.6.0 ngày 26/10/2026. Client gọi API này có thể là web hoặc app bán hàng. Mọi kết quả bên dưới là đầu ra thật của lần chạy đó. Cách chống trùng ở phía nhận webhook đã có bài riêng: webhook thanh toán là gì và cách tích hợp. Phần gọi cổng thanh toán nói chung nằm ở tích hợp cổng thanh toán vào phần mềm qua API.

Gửi lại một yêu cầu POST bị timeout tạo ra hai đơn

Khi client hết thời gian chờ, nó không biết server đã nhận yêu cầu hay chưa. Brandur Leach, kỹ sư của Stripe (cổng thanh toán quốc tế), mô tả ba chỗ hỏng: kết nối không thiết lập được, server hỏng giữa chừng, hoặc server làm xong nhưng phản hồi không về tới client (bài viết ngày 22/02/2017). Chỉ trường hợp thứ ba gây trùng đơn: đơn đã có, client tưởng chưa.

RFC (tài liệu chuẩn của Internet) 9110 mục 9.2.2 gọi một phương thức là idempotent khi gửi nhiều yêu cầu giống hệt có cùng tác dụng như gửi một yêu cầu. PUT và DELETE thuộc nhóm này, POST thì không, và client không nên tự gửi lại POST trừ khi nó có cách phát hiện yêu cầu gốc chưa được áp dụng. Khóa idempotency là cách đó.

Mục 9.2.2 của RFC 9110 nói POST không idempotent và khi nào được gửi lại tự động
RFC 9110, mục 9.2.2: đoạn cấm client tự gửi lại yêu cầu không idempotent. Nguồn rfc-editor.org, chụp ngày 26/10/2026.

Endpoint /naive/orders chờ 3 giây rồi mới tạo đơn, còn curl chỉ chờ 1 giây (-m 1). Lần gửi lại ngay sau đó không bị chậm:

$ B='{"sku":"AO-THUN-M","qty":1,"amount":250000}'
$ curl -sS -m 1 -X POST 127.0.0.1:5191/naive/orders -H 'X-Lab-Delay: 3' -d "$B"
curl: (28) Operation timed out after 1005 milliseconds with 0 bytes received
$ curl -sS -m 6 -X POST 127.0.0.1:5191/naive/orders -d "$B"
{"order_id": 1}
$ mysql blog_lab_idem -e "SELECT id,sku,qty,amount,created_at FROM orders"
id  sku        qty  amount  created_at
1   AO-THUN-M  1    250000  2026-10-26 10:24:05.847
2   AO-THUN-M  1    250000  2026-10-26 10:24:07.827

Id đảo thứ tự so với lúc gửi: lần gửi đầu bị giữ 3 giây trước khi tạo đơn, còn lần gửi lại chạy ngay, nên lần gửi lại có id 1 (tạo lúc 10:24:05) và lần gửi đầu có id 2 (tạo lúc 10:24:07). Khách bị tính tiền hai lần cho một lần bấm, dù trả bằng hình thức thanh toán online nào.

Client sinh khóa idempotency và gửi lại đúng khóa đó

Khóa là một chuỗi ngẫu nhiên do client sinh một lần cho mỗi ý định đặt hàng. Mọi lần gửi lại của đơn đó dùng đúng khóa và đúng nội dung, kể cả sau khi tiến trình client khởi động lại, vì vậy khóa phải được lưu cùng đơn nháp trước lần gửi đầu. Tài liệu Stripe khuyên dùng UUID (mã định danh 128 bit) phiên bản 4, cho phép khóa dài tới 255 ký tự và cảnh báo không dùng dữ liệu nhạy cảm như email làm khóa. Tài liệu cũng nêu cách thứ hai: lấy khóa từ một đối tượng có sẵn, ví dụ mã giỏ hàng, để chặn bấm đúp.

Trang tài liệu Stripe mô tả khóa idempotency và cách máy chủ trả lại kết quả đã lưu
Trang Idempotent requests của Stripe: ví dụ curl với header Idempotency-Key và quy tắc lưu phản hồi. Nguồn docs.stripe.com, chụp ngày 26/10/2026.

Tên header là Idempotency-Key. Bản nháp draft-ietf-httpapi-idempotency-key-header-08 của nhóm httpapi thuộc IETF (Internet Engineering Task Force, tổ chức chuẩn hóa Internet) là Internet-Draft đang hoạt động, cập nhật ngày 22/10/2026, chưa phải RFC. Bản nháp viết giá trị thành chuỗi có dấu nháy kép, ví dụ Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324", còn ví dụ của Stripe viết khóa không có dấu nháy, nên server chấp nhận cả hai dạng. Khi cùng khóa đến lần nữa, bản nháp yêu cầu server trả kết quả đã hoàn tất, hoặc 409 nếu yêu cầu gốc còn chạy.

Trang Datatracker của bản nháp IETF về tiêu đề Idempotency-Key, phiên bản 08
Datatracker của bản nháp về header Idempotency-Key, phiên bản 08, trạng thái Active Internet-Draft. Nguồn datatracker.ietf.org, chụp ngày 26/10/2026.

Bảng lưu khóa dùng chỉ mục UNIQUE để chỉ một yêu cầu thắng

Chỉ mục UNIQUE là phần làm việc chính: MySQL từ chối chèn khóa thứ hai, kể cả khi hai yêu cầu đến cùng một mili giây. Kiểm tra “khóa đã có chưa” bằng SELECT rồi mới INSERT thì hai yêu cầu cùng thấy “chưa có”. Hai bảng trong lab:

CREATE TABLE orders (
  id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  sku        VARCHAR(32)  NOT NULL,
  qty        INT UNSIGNED NOT NULL,
  amount     INT UNSIGNED NOT NULL,
  created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB;

CREATE TABLE idempotency_keys (
  idem_key      VARCHAR(64) NOT NULL,
  request_hash  CHAR(64)    NOT NULL,
  status        ENUM('processing','done') NOT NULL DEFAULT 'processing',
  response_code SMALLINT    NULL,
  response_body JSON        NULL,
  created_at    TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_idem_key (idem_key)
) ENGINE=InnoDB;

Cột request_hash giữ SHA-256 của nội dung yêu cầu đã sắp xếp khóa JSON, để phát hiện cùng khóa nhưng khác nội dung. Cột response_code và response_body giữ phản hồi đầu tiên để phát lại. Chèn trùng khóa cho ra lỗi 1062, và server dựa vào mã lỗi này để biết mình thua:

INSERT INTO idempotency_keys (idem_key, request_hash) VALUES ('3f6c1e52-9a0d-4b7e-8c21-5d0a7e9b4f10','x');
ERROR 1062 (23000): Duplicate entry '3f6c1e52-9a0d-4b7e-8c21-5d0a7e9b4f10' for key 'idempotency_keys.uq_idem_key'

Có thể thay bằng INSERT ... ON DUPLICATE KEY UPDATE và đọc số dòng bị ảnh hưởng. Tài liệu MySQL 8.4 nói giá trị này là 1 nếu chèn dòng mới, 2 nếu cập nhật dòng cũ, 0 nếu dòng cũ được đặt về đúng giá trị hiện có; lab cho cùng ba số đó:

INSERT ... ('k-new-1','aa') ON DUPLICATE KEY UPDATE idem_key=idem_key;   -- 1 row affected
INSERT ... ('k-new-1','aa') ON DUPLICATE KEY UPDATE idem_key=idem_key;   -- 0 rows affected
INSERT ... ('k-new-1','aa') ON DUPLICATE KEY UPDATE request_hash='bb';   -- 2 rows affected

Cách này gọn hơn, nhưng lần thứ ba ghi đè mã băm của khóa cũ, đúng thứ cần giữ nguyên để so sánh. Lab dùng INSERT thường và bắt lỗi 1062. Tài liệu MySQL cũng khuyên tránh ON DUPLICATE KEY UPDATE trên bảng có từ hai chỉ mục UNIQUE trở lên, vì chỉ một dòng được cập nhật.

Trang tài liệu MySQL 8.4 về INSERT ON DUPLICATE KEY UPDATE và số dòng bị ảnh hưởng
Tài liệu MySQL 8.4 về INSERT … ON DUPLICATE KEY UPDATE, đoạn nêu giá trị 1, 2 và 0 của số dòng bị ảnh hưởng. Nguồn dev.mysql.com, chụp ngày 26/10/2026.

Server trả lời khác nhau cho bốn kiểu yêu cầu lặp

Mỗi tình huống cần một phản hồi riêng, vì client phải biết cần đợi, sửa hay dừng. Bản nháp IETF và tài liệu Stripe đều đưa 409 cho yêu cầu đến khi yêu cầu gốc còn đang chạy; bản nháp đưa 422 cho cùng khóa khác nội dung và 400 cho khóa thiếu. Stripe chỉ nói “báo lỗi” cho trường hợp khác nội dung và không nêu mã. Lab theo bản nháp:

Tình huống Phản hồi của lab Client làm gì
Khóa chưa có 201, tạo đơn, lưu kết quả Dùng kết quả
Khóa đã xong, cùng nội dung 201 kèm Idempotent-Replayed: true, đúng đơn cũ Dùng kết quả, không tạo gì thêm
Khóa đang chạy, cùng nội dung 409 request_in_progress Đợi rồi gửi lại cùng khóa, cùng nội dung
Khóa cũ, nội dung khác 422 key_reused_with_different_body Dừng; muốn đổi đơn thì dùng khóa mới
Thiếu khóa hoặc sai định dạng 400 Sửa yêu cầu

Cảnh báo: mã dưới đây ghép chuỗi vào câu SQL chỉ vì lab chạy cục bộ và khóa đã được kiểm bằng biểu thức chính quy. Không sao chép nguyên khối vào hệ thống thật, vì ghép chuỗi mở đường cho SQL injection. Dùng câu lệnh tham số hóa ngay bên dưới khối mã.

Phần lõi của handler gồm hai bước. Bước 1 giành khóa bằng INSERT, nếu thua thì đọc dòng cũ và chọn một trong ba phản hồi trên. Bước 2 tạo đơn và ghi kết quả vào bảng khóa trong cùng một transaction, do đó không thể có đơn mà thiếu kết quả đã lưu:

# Bước 1: giành khóa. Chỉ mục UNIQUE cho đúng một yêu cầu thắng.
rc, out, err = sql(f"INSERT INTO idempotency_keys (idem_key, request_hash) VALUES ('{key}','{h}');")
if rc != 0:
    if "ERROR 1062" not in err:
        return self.reply(500, {"error": "db"})
    rc, out, err = sql(f"SELECT request_hash, status, IFNULL(response_code,0), "
                       f"IFNULL(CAST(response_body AS CHAR),'-') FROM idempotency_keys WHERE idem_key='{key}';")
    old_hash, status, code, resp = out.split("\t", 3)
    if old_hash != h:
        return self.reply(422, {"error": "key_reused_with_different_body"})
    if status == "processing":
        return self.reply(409, {"error": "request_in_progress"})
    return self.reply(int(code), json.loads(resp), replay=True)

# Bước 2: làm việc, rồi lưu đơn và kết quả trong một transaction.
sql_order = ("START TRANSACTION; "
             "INSERT INTO orders (sku,qty,amount) VALUES (...); SET @oid = LAST_INSERT_ID(); "
             "UPDATE idempotency_keys SET status='done', response_code=201, "
             "response_body=JSON_OBJECT('order_id', @oid) WHERE idem_key='...'; COMMIT; SELECT @oid;")

Đoạn mã trên đã rút gọn: câu INSERT của đơn hàng ghi “…” thay cho các giá trị, và chú thích bằng tiếng Việt do bài thêm vào. Các lệnh curl và đầu ra trong bài cũng rút gọn: bỏ tiêu đề phản hồi trừ Idempotent-Replayed, và các chú thích trong ngoặc là của bài. Số liệu và mã trạng thái giữ nguyên từ lần chạy thật.

Cùng câu giành khóa viết bằng tham số hóa, dạng của thư viện MySQL cho Python. Đoạn này chưa chạy trong lab, nó chỉ cho thấy chỗ đặt dấu thay thế (placeholder) cho giá trị:

cur.execute(
    "INSERT INTO idempotency_keys (idem_key, request_hash) VALUES (%s, %s)",
    (key, request_hash),
)   # chưa chạy trong lab; thư viện gửi giá trị tách khỏi câu SQL

Chạy thử: timeout, gửi lại quá sớm, gửi lại sau khi đơn xong

Cùng kịch bản timeout ở đầu bài, lần này có khóa. Curl đầu bỏ cuộc sau 1 giây, trong khi server còn 2 giây nữa mới tạo đơn. Lần gửi lại ngay sau đó rơi vào lúc yêu cầu gốc còn chạy, do đó nhận 409. Lần gửi lại sau khi đã chờ đủ 3 giây nhận đúng đơn cũ:

$ K='Idempotency-Key: 3f6c1e52-9a0d-4b7e-8c21-5d0a7e9b4f10'
$ curl -sS -m 1 -X POST 127.0.0.1:5191/orders -H "$K" -H 'X-Lab-Delay: 3' -d "$B"
curl: (28) Operation timed out after 1005 milliseconds with 0 bytes received
$ curl -sS -i -m 6 -X POST 127.0.0.1:5191/orders -H "$K" -d "$B"        # gửi lại sau khoảng 1 giây
HTTP/1.0 409 Conflict
{"error": "request_in_progress"}
$ curl -sS -i -m 6 -X POST 127.0.0.1:5191/orders -H "$K" -d "$B"        # gửi lại sau 3 giây
HTTP/1.0 201 Created
Idempotent-Replayed: true
{"order_id": 1}
$ mysql blog_lab_idem -e "SELECT id,sku,qty,amount FROM orders"
id  sku        qty  amount
1   AO-THUN-M  1    250000

Bảng orders chỉ có một dòng. Giả thuyết ban đầu “gửi lại thì nhận đơn cũ” đúng với lần gửi sau, còn lần gửi sớm không nhận được đơn mà nhận 409. Vì vậy vòng lặp gửi lại phải xử lý 409 như “đợi rồi thử lại”, không coi nó là lỗi cuối.

Hai trường hợp còn lại trong lab. Cùng khóa nhưng đổi số lượng từ 1 lên 2 cho 422, và bảng đơn không đổi:

$ curl -sS -i -m 6 -X POST 127.0.0.1:5191/orders -H "$K" -d '{"sku":"AO-THUN-M","qty":2,"amount":500000}'
HTTP/1.0 422 Unprocessable Entity
{"error": "key_reused_with_different_body"}

Hai yêu cầu cùng khóa mới, gửi đồng thời, trong khi server chờ 2 giây. Một yêu cầu thắng và tạo đơn, yêu cầu kia nhận 409. Bên nào thắng phụ thuộc thứ tự đến của hai kết nối: ở lần chạy đầu req1 nhận 409 và req2 tạo đơn, ở lần chạy lại dưới đây hai bên đổi vai.

req2: {"error": "request_in_progress"} http=409
req1: {"order_id": 2} http=201
$ mysql blog_lab_idem -e "SELECT id,sku,qty,amount FROM orders"
1  AO-THUN-M  1  250000      (đơn của khóa 3f6c1e52...)
2  AO-THUN-M  1  250000      (đơn của khóa 7b1d9c04..., chỉ một đơn dù có hai yêu cầu)

Khóa kẹt ở trạng thái processing và thời hạn lưu khóa

Nếu tiến trình server chết giữa bước 1 và bước 2, dòng khóa nằm lại ở processing và mọi lần gửi lại nhận 409 mãi mãi. Lab dựng một dòng như vậy, tuổi 10 phút, với đúng mã băm của yêu cầu:

$ mysql blog_lab_idem -e "SELECT idem_key, status, TIMESTAMPDIFF(SECOND, created_at, NOW()) AS age_s
    FROM idempotency_keys WHERE status='processing' AND created_at < NOW() - INTERVAL 5 MINUTE"
idem_key  status      age_s
stuck-2   processing  600
$ curl -sS -i -m 6 -X POST 127.0.0.1:5191/orders -H 'Idempotency-Key: stuck-2' -d "$B"
HTTP/1.0 409 Conflict
{"error": "request_in_progress"}

Ngưỡng 5 phút trong truy vấn là lựa chọn của lab, không lấy từ nguồn nào. Chọn ngưỡng dài hơn thời gian xử lý dài nhất của endpoint. Dòng quá ngưỡng cần một công việc định kỳ kiểm tra xem đơn đã được tạo chưa (tìm theo khóa), rồi hoặc hoàn tất bản ghi, hoặc xóa dòng để lần gửi lại tạo đơn. Lab dùng hai bước tách rời để có được 409. Cách thứ hai là gộp giành khóa và tạo đơn vào một transaction. Lab đã chạy bản này với hai yêu cầu đồng thời cùng khóa, server giữ 2 giây trước khi tạo đơn:

req1: HTTP/1.0 201 Created {"order_id": 1} http=201 total=2.039439s
req2: HTTP/1.0 201 Created Idempotent-Replayed: true {"order_id": 1} http=201 total=2.063945s
id  sku        qty  amount
1   AO-THUN-M  1    250000

Yêu cầu thứ hai không nhận 409 mà bị giữ khoảng 2 giây trên chỉ mục UNIQUE, rồi nhận đơn cũ. Cách này đánh đổi: không có 409 để client biết cần đợi, và mỗi yêu cầu giữ một kết nối MySQL mở suốt thời gian xử lý. Trường hợp tiến trình chết giữa chừng (transaction tự hoàn tác, không để lại dòng kẹt) chưa được chạy thử.

Thời hạn lưu là quyết định thứ hai. Stripe giữ khóa API v1 tối thiểu 24 giờ và được phép xóa sau đó; dùng lại khóa đã bị xóa thì sinh yêu cầu mới. Stripe cũng khuyên chỉ gửi lại trong 24 giờ đầu, quá hạn thì dừng gửi và đối soát bằng cách lấy đối tượng ra kiểm tra và đọc webhook (với tiền vào tài khoản ngân hàng, xem API ngân hàng cho phần mềm bán hàng). Server chọn thời hạn dài hơn thời gian client còn gửi lại, ghi vào tài liệu API, và dọn bảng theo created_at. Khóa của Stripe còn có thêm một quy tắc: nếu yêu cầu bị từ chối trước khi bắt đầu chạy (sai tham số, bị giới hạn tốc độ) thì kết quả không được lưu và client được gửi lại; lab này không có nhánh đó.

Hỏi đáp

Có cần khóa idempotency cho GET và DELETE không?

Không. RFC 9110 xếp GET và DELETE vào nhóm idempotent, và tài liệu Stripe nói gửi khóa kèm hai phương thức này không có tác dụng. Khóa dành cho POST và PATCH tạo hoặc đổi dữ liệu.

Có thể dùng mã đơn hàng của client làm khóa không?

Được, nếu mã đó sinh một lần cho mỗi ý định đặt hàng và không dùng lại cho đơn khác. Stripe nêu cách lấy khóa từ mã giỏ hàng để chặn bấm đúp. Một mã tăng dần dễ đoán thì kẻ khác đoán được khóa của khách; bản nháp IETF khuyên dùng khóa có độ ngẫu nhiên cao, và server ghép khóa với danh tính client khi tra cứu.

Khóa idempotency có thay được ràng buộc UNIQUE trên mã đơn không?

Không thay, hai thứ bổ sung nhau. Khóa chặn gửi lại cùng một yêu cầu và phát lại đúng phản hồi, còn ràng buộc trên dữ liệu nghiệp vụ, ví dụ một mã giao dịch ngân hàng chỉ khớp một đơn, chặn trùng khi hai yêu cầu khác nhau cùng nhắm vào một sự kiện.

Client đợi bao lâu giữa các lần gửi lại?

Tăng dần theo cấp số nhân và thêm độ lệch ngẫu nhiên. Bài của Brandur Leach mô tả thời gian chờ tăng theo 2^n cộng độ lệch ngẫu nhiên để nhiều client không cùng dồn vào một server đang hồi phục. Gặp 409, đợi rồi gửi lại cùng khóa, cùng nội dung, vì khóa mới sẽ mở một thao tác thứ hai.

Server trả gì khi yêu cầu gốc báo lỗi 500?

Mỗi server tự quyết định, và cần công bố rõ. Stripe API v1 lưu cả phản hồi 500 và phát lại nó khi nhận lại khóa; API v2 của Stripe chạy lại phần việc dở dang. Bản nháp IETF chỉ nói server trả kết quả nhất quán với kết quả đã hoàn tất. Với đơn hàng, an toàn nhất là coi kết quả 500 là chưa rõ và đối soát trước khi tạo thao tác mới, như Stripe khuyên.

Doanh nghiệp cần API đặt hàng hoặc cổng thanh toán chịu được việc client gửi lại có thể nhờ MONA viết theo đúng luồng đơn và đối soát của mình qua dịch vụ viết phần mềm theo yêu cầu. Hotline 1900 636 648.

Đội MONA gặp khách hàng để khảo sát quy trình trước khi làm phần mềm

Kiến thức phần mềm

Gửi MONA quy trình đang chạy để nhận hướng làm phần mềm

Đội MONA Software khảo sát nghiệp vụ, thiết kế cơ sở dữ liệu, lập trình, bàn giao và đi cùng qua giai đoạn vận hành thử.