Phần mềm kho tính giá vốn bình quân gia quyền đúng khi mỗi mặt hàng lưu số lượng tồn và tổng giá trị tồn bằng đồng nguyên, còn đơn giá luôn bằng giá trị chia số lượng và không được lưu dạng đã làm tròn. Phiếu xuất làm tròn đúng một lần, phiếu trả nhà cung cấp trừ theo giá nhập gốc, hàng khách trả cộng theo giá vốn của phiếu bán, và với bình quân sau mỗi lần nhập, phiếu nhập ghi lùi ngày (vào máy muộn nhưng mang ngày cũ) buộc tính lại mọi phiếu của mặt hàng từ ngày đó. Căn cứ là Chuẩn mực kế toán số 02 (VAS 02) đoạn 15, cho tính bình quân theo kỳ hoặc sau mỗi lần nhập, và Thông tư 99/2025/TT-BTC, có hiệu lực từ 01/01/2026 và thay Thông tư 200/2014/TT-BTC (tra ngày 03/11/2026); các con số trong bài là kết quả chạy mã Python 3.9 với module decimal.
Ví dụ xuyên suốt là một mặt hàng với sổ tháng 10 gồm 7 phiếu: 3 phiếu xuất, 2 phiếu nhập, 1 phiếu trả hàng nhà cung cấp và 1 phiếu khách trả hàng. Một phiếu nhập 100 cái, ghi bổ sung ngày 28/10 nhưng thuộc ngày 06/10, làm đổi giá trị của 4 trong 7 dòng đã có và kéo tổng giá vốn tháng xuống 214.815 đồng.
Quy định cho phép những cách tính nào và Thông tư 99 đổi gì
VAS 02, ban hành theo Quyết định 149/2001/QĐ-BTC ngày 31/12/2001, liệt kê bốn phương pháp ở đoạn 13: giá đích danh, bình quân gia quyền, nhập trước xuất trước và nhập sau xuất trước. Đoạn 15 định nghĩa bình quân gia quyền là giá trị trung bình của hàng tồn đầu kỳ và hàng nhập trong kỳ, tính theo thời kỳ hoặc mỗi khi nhập một lô, “phụ thuộc vào tình hình của doanh nghiệp”. Bài tính hai cách của đoạn 15 và nhập trước xuất trước (FIFO), bỏ qua đích danh (đoạn 14 cho doanh nghiệp có ít loại mặt hàng hoặc mặt hàng ổn định và nhận diện được) và nhập sau xuất trước. Khái niệm hàng tồn kho và ba phương pháp phổ biến đã có ở bài Inventory là gì.

VAS 02 không có đoạn riêng cho hàng trả lại. Đoạn 06 trừ chiết khấu thương mại và giảm giá hàng mua sai quy cách, phẩm chất khỏi chi phí mua, và đoạn 24 ghi giá gốc hàng đã bán vào chi phí cùng kỳ với doanh thu của hàng đó. Theo cách hiểu của MONA, hai đoạn này dẫn tới hai quy tắc lập trình ở các phần sau: hàng trả nhà cung cấp rời kho theo giá nhập gốc, và hàng khách trả quay về kho theo giá vốn đã ghi lúc bán.
Thông tư 99/2025/TT-BTC ký ngày 27/10/2025, theo Điều 31 khoản 1 có hiệu lực từ 01/01/2026, áp dụng cho năm tài chính bắt đầu từ hoặc sau ngày đó và thay Thông tư 200/2014/TT-BTC, trừ các nội dung kế toán cổ phần hóa doanh nghiệp nhà nước ở khoản 2. Khoản 3 cho doanh nghiệp nhỏ và vừa quyền chọn áp dụng Thông tư 99. Xem thêm phần mềm kế toán và ERP (hệ thống quản trị doanh nghiệp) cần tính năng gì theo Thông tư 99.

Bản PDF thông tư tải được ngày 03/11/2026 chỉ gồm phần thân 31 điều. Hướng dẫn tài khoản hàng tồn kho (152, 156) nằm ở Phụ lục II, và MONA chưa đọc được phụ lục đó từ văn bản gốc. Bản tóm tắt của KPMG Việt Nam (tra ngày 03/11/2026) nêu Thông tư 99 giới thiệu phương pháp chi phí tiêu chuẩn và bỏ hai khái niệm kê khai thường xuyên, kiểm kê định kỳ (hai cách ghi sổ hàng tồn kho: ghi theo từng phiếu hoặc chỉ cập nhật khi kiểm kê). Hai điểm này chưa được đối chiếu với phụ lục. Kế toán trưởng cần xác nhận hai điểm đó trước khi dùng làm yêu cầu của phần mềm.
Ba cách tính của bài khác nhau ở thời điểm biết giá vốn của một phiếu xuất:
| Cách tính | Căn cứ trong VAS 02 | Đơn giá xuất lấy từ | Biết giá vốn phiếu xuất khi nào |
|---|---|---|---|
| Bình quân sau mỗi lần nhập | Đoạn 15, “mỗi khi nhập một lô” | Giá trị tồn chia số lượng tồn ngay trước phiếu xuất | Ngay khi lập phiếu, nhưng đổi nếu có phiếu nhập ghi lùi |
| Bình quân cả kỳ | Đoạn 15, “theo thời kỳ” | Giá trị đầu kỳ cộng nhập trong kỳ, chia số lượng tương ứng | Khi khóa kỳ |
| Nhập trước xuất trước (FIFO) | Đoạn 13(c) và 16 | Các lô nhập cũ nhất còn hàng | Ngay khi lập phiếu, nhưng đổi nếu có lô ghi lùi |
Lưu giá trị bằng đồng nguyên và làm tròn bằng Decimal
Giá trị tồn là số nguyên đồng, số lượng là số thập phân, và mọi phép tính dùng Decimal thay cho float. Tài liệu Python nêu số thập phân biểu diễn chính xác, trong khi nhị phân không biểu diễn chính xác 1,1 và 2,2: 1.1 + 2.2 ra 3.3000000000000003. Ngữ cảnh mặc định của Decimal làm tròn half-even (ROUND_HALF_EVEN, độ chính xác 28 chữ số). Số 2,5 thành 2, giống hàm round() có sẵn của Python.

Mã chọn ROUND_HALF_UP, tức 0,5 đồng làm tròn lên. Đây là lựa chọn của người viết mã, vì hai văn bản MONA đã đọc không quy định kiểu làm tròn đồng. Kế toán cần chốt kiểu làm tròn rồi ghi vào quy chế. Mã gồm hàm dong, kiểu phiếu Ev (một dòng của sổ kho) và bốn loại phiếu:
# gia_von.py - inventory costing for one item. Python 3.9, standard library only.
from dataclasses import dataclass
from decimal import Decimal as D, ROUND_HALF_UP
from typing import Dict, List, Optional, Tuple
RECEIPT, ISSUE, PURCHASE_RETURN, SALES_RETURN = (
"RECEIPT", "ISSUE", "PURCHASE_RETURN", "SALES_RETURN")
def dong(x: D) -> D:
"""Round to a whole dong; 0.5 goes up (Decimal's default is half-even)."""
return x.quantize(D(1), rounding=ROUND_HALF_UP)
@dataclass(frozen=True)
class Ev:
id: str
day: str # ISO date, sorts correctly as text
kind: str
qty: D
unit: Optional[D] = None # unit cost, receipts only
ref: Optional[str] = None # receipt id (purchase return) or issue id (sales return)
Row = Tuple[Ev, D, D, D] # event, amount in dong, qty after, value after
Kết quả chạy của ba phép làm tròn trên cùng số 2,5 và một số đồng lẻ:
round(2.5) = 2 | round(3.5) = 4
half-even 2.5 -> 2 | half-up 2.5 -> 3 | half-up 6249333.333 -> 6249333
Cách lưu dữ liệu MONA đề xuất, chưa chạy thử trên cơ sở dữ liệu: bảng tồn mỗi mặt hàng giữ số lượng (kiểu thập phân, ví dụ ba chữ số lẻ cho hàng cân đo) và tổng giá trị (số nguyên đồng); sổ kho chỉ thêm dòng, mỗi dòng ghi số tiền đã làm tròn của chính phiếu đó. Phương án ngược lại là lưu cột đơn giá bình quân làm tròn đến đồng, đọc nhanh và dễ nhìn trên màn hình kho, nhưng sai số cộng dồn như phần cuối bài đo. Nếu báo cáo cần hiện đơn giá, tính giá trị chia số lượng lúc đọc và chỉ làm tròn để hiển thị.
Bình quân sau mỗi lần nhập, từng bước trên sổ tháng 10
Cách tính này giữ hai biến qty và value. Mỗi loại phiếu ghi số tiền theo quy tắc riêng:
| Loại phiếu | Số tiền (đồng) | Tồn kho đổi |
|---|---|---|
| RECEIPT (nhập) | Số lượng nhân giá nhập, làm tròn | Cộng số lượng và số tiền |
| ISSUE (xuất) | Giá trị tồn nhân số lượng xuất chia số lượng tồn, làm tròn một lần; xuất hết thì lấy nốt giá trị còn lại | Trừ số lượng và số tiền |
| PURCHASE_RETURN (trả nhà cung cấp) | Số tiền của phiếu nhập gốc nhân số lượng trả chia số lượng phiếu nhập gốc | Trừ số lượng và số tiền |
| SALES_RETURN (khách trả hàng) | Số tiền của phiếu bán gốc nhân số lượng trả chia số lượng phiếu bán gốc | Cộng số lượng và số tiền |
Hàm moving_average (bình quân sau mỗi lần nhập) sắp phiếu theo ngày (thứ tự nhập được giữ khi trùng ngày), từ chối phiếu xuất vượt tồn và phiếu trả vượt số đã nhập hoặc đã bán:
def moving_average(events: List[Ev], open_qty: D, open_value: D) -> List[Row]:
"""Average recalculated after every movement. Value is the source of truth;
the unit cost is always value / qty and is never stored rounded."""
qty, value = D(open_qty), D(open_value)
amount: Dict[str, D] = {}
qty_of: Dict[str, D] = {}
returned: Dict[str, D] = {}
rows: List[Row] = []
for e in sorted(events, key=lambda e: e.day): # stable: same-day order is kept
if e.kind == RECEIPT:
a = dong(e.qty * e.unit)
qty += e.qty
value += a
elif e.kind == ISSUE:
if e.qty > qty:
raise ValueError(f"{e.id}: issue {e.qty} but stock is {qty}")
a = value if e.qty == qty else dong(value * e.qty / qty)
qty -= e.qty
value -= a
elif e.kind == PURCHASE_RETURN: # supplier credits the ORIGINAL price
returned[e.ref] = returned.get(e.ref, D(0)) + e.qty
if returned[e.ref] > qty_of[e.ref] or e.qty > qty:
raise ValueError(f"{e.id}: more returned than received or in stock")
a = dong(amount[e.ref] * e.qty / qty_of[e.ref])
qty -= e.qty
value -= a
else: # SALES_RETURN: back at the cost of the sale
returned[e.ref] = returned.get(e.ref, D(0)) + e.qty
if returned[e.ref] > qty_of[e.ref]:
raise ValueError(f"{e.id}: more returned than sold")
a = dong(amount[e.ref] * e.qty / qty_of[e.ref])
qty += e.qty
value += a
amount[e.id], qty_of[e.id] = a, e.qty
rows.append((e, a, qty, value))
return rows
Sổ tháng 10 mở đầu bằng 100 cái giá 50.000 đồng (5.000.000 đồng). Phiếu R1 nhập 170 cái giá 53.300, R2 nhập 200 cái giá 55.900, PR1 trả nhà cung cấp 20 cái của R2, S1, S2, S3 xuất 120, 200 và 90 cái, SR1 là khách trả 10 cái của S1:
import random
from decimal import Decimal as D, ROUND_HALF_EVEN
from gia_von import (Ev, RECEIPT, ISSUE, PURCHASE_RETURN, SALES_RETURN, dong,
moving_average, periodic_average, fifo, naive_rounded_average)
def vn(x) -> str:
return f"{int(x):,}".replace(",", ".")
OPEN_QTY, OPEN_VALUE = D(100), D(5_000_000) # 100 units at 50,000
LEDGER = [
Ev("R1", "2026-10-03", RECEIPT, D(170), D(53_300)),
Ev("S1", "2026-10-08", ISSUE, D(120)),
Ev("R2", "2026-10-12", RECEIPT, D(200), D(55_900)),
Ev("PR1", "2026-10-15", PURCHASE_RETURN, D(20), ref="R2"),
Ev("S2", "2026-10-18", ISSUE, D(200)),
Ev("SR1", "2026-10-20", SALES_RETURN, D(10), ref="S1"),
Ev("S3", "2026-10-25", ISSUE, D(90)),
]
Kết quả chạy, các cột lần lượt là mã phiếu, ngày, loại, số lượng, số tiền của phiếu, tồn lượng, tồn giá trị và đơn giá bình quân sau phiếu:
opening: 100 x 50.000 = 5.000.000
id ngay loai sl so tien ton sl ton tien don gia TB
R1 10-03 RECEIPT 170 9.061.000 270 14.061.000 52077.7778
S1 10-08 ISSUE 120 6.249.333 150 7.811.667 52077.7800
R2 10-12 RECEIPT 200 11.180.000 350 18.991.667 54261.9057
PR1 10-15 PURCHASE_RETURN 20 1.118.000 330 17.873.667 54162.6273
S2 10-18 ISSUE 200 10.832.525 130 7.041.142 54162.6308
SR1 10-20 SALES_RETURN 10 520.778 140 7.561.920 54013.7143
S3 10-25 ISSUE 90 4.861.234 50 2.700.686 54013.7200
check: opening + receipts - purchase return + sales return - issues = closing: 5.000.000 + 20.241.000 - 1.118.000 + 520.778 - 21.943.092 = 2.700.686
Phiếu S1 xuất 120 cái từ tồn 270 cái giá trị 14.061.000 đồng, nên giá vốn là 6.249.333,33 và được làm tròn thành 6.249.333 đồng. Đơn giá bình quân sau phiếu xuất nhích từ 52.077,7778 lên 52.077,7800, vì phần 0,33 đồng bị cắt khỏi giá trị còn 7.811.667 đồng. Phiếu xuất không đổi đơn giá ở mức đồng, chỉ đổi ở chữ số thập phân thứ ba trở đi. Không đồng nào lạc đi, theo phép kiểm cuối kết quả: đầu kỳ cộng nhập, trừ trả nhà cung cấp, cộng khách trả, trừ xuất ra đúng 2.700.686 đồng tồn cuối.
Trả hàng nhà cung cấp và khách trả hàng đổi đơn giá thế nào
Phiếu PR1 trả 20 cái của lô R2 (giá 55.900) nên rời kho với 1.118.000 đồng, và đơn giá bình quân giảm từ 54.261,9057 xuống 54.162,6273 vì lô bị trả đắt hơn mức trung bình. Phiếu SR1 nhận lại 10 trong 120 cái của S1 với 520.778 đồng, bằng đúng một phần mười hai giá vốn 6.249.333 đồng của S1.
Nếu hai phiếu này đi theo giá bình quân đang chạy, sổ kho lệch khỏi chứng từ gốc. Kết quả chạy với cùng sổ:
PR1: at the original price 1.118.000 | at the running average 1.085.238 | gap -32.762
SR1: at the original price 520.778 | at the running average 541.626 | gap 20.848
Nhà cung cấp ghi có theo giá hóa đơn gốc 1.118.000 đồng mà kho trừ 1.085.238 đồng thì 32.762 đồng nằm lơ lửng giữa sổ kho và công nợ phải trả. Khách trả hàng theo giá bình quân đang chạy cho giá vốn hoàn lại 541.626 đồng, cao hơn giá vốn đã ghi lúc bán 20.848 đồng, nên doanh thu bị trả lại và giá vốn được hoàn không còn khớp nhau, trái với tinh thần đoạn 24 của VAS 02 theo cách hiểu của MONA.
Các trường hợp phần mềm phải từ chối, kèm kết quả chạy:
Ba sổ thử, mỗi sổ trên tồn đầu kỳ 100 cái giá trị 5.000.000 đồng:
for name, evs, fn in [
("issue above stock", [Ev("X", "2026-10-01", ISSUE, D(101))], moving_average),
("return more than received", [Ev("A", "2026-10-01", RECEIPT, D(5), D(10)),
Ev("B", "2026-10-02", PURCHASE_RETURN, D(6), ref="A")], moving_average),
("return of a lot already sold (FIFO)", [Ev("A", "2026-10-01", RECEIPT, D(5), D(10)),
Ev("B", "2026-10-02", ISSUE, D(103)),
Ev("C", "2026-10-03", PURCHASE_RETURN, D(5), ref="A")], fifo),
]:
try:
fn(evs, OPEN_QTY, OPEN_VALUE)
raise AssertionError("should have refused: " + name)
except ValueError as err:
print(f"{name}: ValueError: {err}")
Kết quả chạy:
issue above stock: ValueError: X: issue 101 but stock is 100
return more than received: ValueError: B: more returned than received or in stock
return of a lot already sold (FIFO): ValueError: C: lot A no longer holds 5
Bình quân gia quyền không theo dõi lô. Phiếu trả nhà cung cấp chỉ bị chặn khi vượt số lượng của phiếu nhập gốc hoặc vượt tồn. Hàng của lô đó đã bán hết vẫn có thể bị trả, miễn là kho còn hàng cùng mã từ lô khác, và lỗi này không bị phát hiện. FIFO thì phát hiện được, vì mỗi lô là một lớp có số lượng riêng. Doanh nghiệp hay trả hàng theo từng lô cần đưa quy tắc này vào bước chọn phương pháp.
Bình quân cả kỳ và FIFO trên cùng một sổ
Bình quân cả kỳ gom nhập và trả nhà cung cấp của cả tháng vào một nhóm, chia ra một đơn giá, rồi áp cho mọi phiếu xuất. Số tiền làm tròn từng phiếu có thể lệch tổng vài đồng. Phiếu xuất cuối cùng của danh sách (đã xếp theo ngày) nhận phần chênh:
def periodic_average(events: List[Ev], open_qty: D, open_value: D):
"""One price for the whole period, known only when the period closes."""
pool_qty, pool_val = D(open_qty), D(open_value)
amount: Dict[str, D] = {}
for e in events:
if e.kind == RECEIPT:
amount[e.id] = dong(e.qty * e.unit)
pool_qty += e.qty
pool_val += amount[e.id]
for e in events:
if e.kind == PURCHASE_RETURN:
rec = next(x for x in events if x.id == e.ref)
pool_qty -= e.qty
pool_val -= dong(amount[e.ref] * e.qty / rec.qty)
net = sum((e.qty for e in events if e.kind == ISSUE), D(0)) \
- sum((e.qty for e in events if e.kind == SALES_RETURN), D(0))
cogs = pool_val if net == pool_qty else dong(pool_val * net / pool_qty)
lines = {} # cost per issue / sales return line
for e in events:
if e.kind in (ISSUE, SALES_RETURN):
part = dong(pool_val * e.qty / pool_qty)
lines[e.id] = part if e.kind == ISSUE else -part
last = [e.id for e in events if e.kind == ISSUE][-1]
lines[last] += cogs - sum(lines.values()) # last issue absorbs the rounding gap
return {"price": pool_val / pool_qty, "cogs": cogs, "lines": lines,
"closing_qty": pool_qty - net, "closing_value": pool_val - cogs}
FIFO giữ danh sách lô, mỗi lô có số lượng và giá trị còn lại. Phiếu xuất lấy từ lô cũ nhất. Phiếu khách trả hàng tạo lô mới ở cuối hàng với giá vốn của phiếu bán; chỗ đặt hàng trả lại (cuối hàng hay về lô gốc) là quyết định chính sách của doanh nghiệp, mã chọn cuối hàng:
def fifo(events: List[Ev], open_qty: D, open_value: D) -> List[Row]:
"""Layers [qty left, value left, source id]; the oldest layer is consumed first.
A sales return becomes a new layer at the cost of the sale, placed at the end."""
layers = [[D(open_qty), D(open_value), "OPEN"]]
amount: Dict[str, D] = {}
qty_of: Dict[str, D] = {}
rows: List[Row] = []
def take(layer, q: D) -> D:
v = layer[1] if q == layer[0] else dong(layer[1] * q / layer[0])
layer[0] -= q
layer[1] -= v
return v
for e in sorted(events, key=lambda e: e.day):
if e.kind == RECEIPT:
a = dong(e.qty * e.unit)
layers.append([e.qty, a, e.id])
elif e.kind == ISSUE:
if e.qty > sum(L[0] for L in layers):
raise ValueError(f"{e.id}: issue {e.qty} but stock is less")
need, a = e.qty, D(0)
while need > 0:
L = layers[0]
q = min(L[0], need)
a += take(L, q)
need -= q
if L[0] == 0:
layers.pop(0)
elif e.kind == PURCHASE_RETURN:
L = next((x for x in layers if x[2] == e.ref and x[0] >= e.qty), None)
if L is None:
raise ValueError(f"{e.id}: lot {e.ref} no longer holds {e.qty}")
a = take(L, e.qty)
else:
a = dong(amount[e.ref] * e.qty / qty_of[e.ref])
layers.append([e.qty, a, e.id])
amount[e.id], qty_of[e.id] = a, e.qty
rows.append((e, a, sum(L[0] for L in layers), sum(L[1] for L in layers)))
return rows
Kết quả của bình quân cả kỳ trên sổ tháng 10: đơn giá của tháng, cogs (giá vốn thuần của tháng, tức tổng giá vốn phiếu xuất trừ giá vốn hàng khách trả), tồn cuối và giá vốn từng dòng:
price 53606.6667 | cogs 21.442.667 | closing 50 2.680.333
lines: {'S1': '6.432.800', 'S2': '10.721.333', 'SR1': '-536.067', 'S3': '4.824.601'}
Ba cách đặt cạnh nhau trên đúng 7 phiếu, với giá vốn thuần là tổng giá vốn phiếu xuất trừ giá vốn hàng khách trả:
method cogs closing qty closing value
moving average 21.422.314 50 2.700.686
periodic average 21.442.667 50 2.680.333
FIFO 21.381.500 50 2.741.500
Tồn cuối đều là 50 cái, nhưng giá trị tồn chênh nhau tới 61.167 đồng giữa FIFO (2.741.500) và bình quân cả kỳ (2.680.333). Giá nhập trong sổ này tăng từ 50.000 lên 53.300 rồi 55.900, nên FIFO xuất hàng rẻ trước và giữ lại hàng đắt, giá vốn thấp nhất và tồn cuối cao nhất. Giá giảm thì thứ tự đảo lại. Các con số này của sổ giả lập do MONA đặt, không đại diện cho doanh nghiệp nào.
Về chọn cách tính, MONA nhìn thế này. Hàng nhập đều đặn với giá gần nhau và cần giá vốn ngay trên báo cáo bán hàng: bình quân sau mỗi lần nhập, trả bằng việc phải tính lại khi có phiếu ghi lùi. Doanh nghiệp chỉ cần giá vốn khi chốt tháng: bình quân cả kỳ, trả bằng việc báo cáo lãi lỗ trong tháng chỉ có giá vốn tạm. Hàng có hạn dùng hoặc cần truy từng lô: FIFO, trả bằng việc phải lưu từng lớp và có quy tắc riêng cho hàng trả. Khi chọn, đoạn 27(a) của VAS 02 yêu cầu báo cáo tài chính nêu phương pháp đã dùng.
Phiếu nhập đến sau phiếu bán buộc tính lại những dòng nào
Lô 100 cái giá 51.000 đồng về kho ngày 06/10 nhưng kế toán chỉ nhập vào phần mềm ngày 28/10. Lô này đứng trước phiếu S1 ngày 08/10, nên đơn giá bình quân từ 06/10 trở đi khác, và mọi phiếu xuất cùng phiếu trả hàng phụ thuộc phiếu xuất phải tính lại. Hàm không cần sửa gì: chỉ thêm phiếu vào sổ rồi chạy lại từ đầu, vì hàm sắp theo ngày.
LATE = Ev("R0", "2026-10-06", RECEIPT, D(100), D(51_000)) # entered on 28/10
ALL = LEDGER + [LATE]
mv2 = moving_average(ALL, OPEN_QTY, OPEN_VALUE)
Kết quả chạy so từng dòng trước và sau (cột lệch là số tiền sau trừ số tiền trước), kèm sổ đầy đủ sau khi chèn phiếu:
id ngay truoc sau lech
R1 10-03 9.061.000 9.061.000 0
S1 10-08 6.249.333 6.214.378 -34.955
R2 10-12 11.180.000 11.180.000 0
PR1 10-15 1.118.000 1.118.000 0
S2 10-18 10.832.525 10.701.685 -130.840
SR1 10-20 520.778 517.865 -2.913
S3 10-25 4.861.234 4.809.301 -51.933
net cogs of the month: 21.422.314 -> 21.207.499 | change -214.815
entries whose amount changed: 4 of 7 | closing value 2.700.686 -> 8.015.501
moving average after the late receipt
id ngay loai sl so tien ton sl ton tien don gia TB
R1 10-03 RECEIPT 170 9.061.000 270 14.061.000 52077.7778
R0 10-06 RECEIPT 100 5.100.000 370 19.161.000 51786.4865
S1 10-08 ISSUE 120 6.214.378 250 12.946.622 51786.4880
R2 10-12 RECEIPT 200 11.180.000 450 24.126.622 53614.7156
PR1 10-15 PURCHASE_RETURN 20 1.118.000 430 23.008.622 53508.4233
S2 10-18 ISSUE 200 10.701.685 230 12.306.937 53508.4217
SR1 10-20 SALES_RETURN 10 517.865 240 12.824.802 53436.6750
S3 10-25 ISSUE 90 4.809.301 150 8.015.501 53436.6733
periodic after the late receipt: price 53132.7273 cogs 21.253.091 closing 7.969.909 | one new price: every issue line of the month takes it
FIFO after the late receipt: cogs 20.891.500 closing 8.331.500
Bốn trong bảy dòng đổi giá trị: S1 giảm 34.955 đồng, S2 giảm 130.840, S3 giảm 51.933 và phiếu khách trả SR1 giảm 2.913, vì nó bám theo giá vốn mới của S1. Giá vốn thuần của tháng từ 21.422.314 xuống 21.207.499 đồng, tức giảm 214.815 đồng. Giá trị tồn cuối tăng lên 8.015.501 đồng chủ yếu vì tồn cuối nay là 150 cái chứ không còn 50 cái.
Với bình quân cả kỳ, cùng phiếu ghi lùi đổi đơn giá tháng từ 53.606,6667 xuống 53.132,7273, nên mọi dòng xuất của tháng nhận đơn giá mới. Tháng còn mở thì chưa dòng nào đã chốt, tháng đã khóa sổ (không cho sửa số) thì cả tháng phải viết lại. Với FIFO, lô ghi lùi đổi lớp mà từng phiếu xuất lấy: giá vốn thuần là 20.891.500 đồng và tồn cuối 8.331.500 đồng. Cả ba cách đều tính lại. Khác nhau ở phạm vi: bình quân sau mỗi lần nhập kéo theo mọi phiếu từ ngày ghi lùi trở đi, kể cả phiếu nằm ở kỳ đã khóa.
Phần mềm kho làm bốn việc khi nhận phiếu ghi lùi. Một, tính lại riêng mặt hàng đó, từ ngày sớm nhất bị ảnh hưởng. Hai, giữ số tiền cũ và mới của từng dòng đổi trong bảng lưu vết, vì Điều 28 điểm b Thông tư 99 yêu cầu phần mềm kế toán khi sửa phải lưu lại dấu vết nội dung đã ghi sổ theo trình tự thời gian; MONA áp yêu cầu đó vào việc tính lại giá vốn. Ba, chặn hai người sửa cùng một mặt hàng trong lúc tính lại (xem cách khóa dòng tồn trong MySQL). Bốn, hỏi kế toán trưởng phiếu ghi lùi chạm kỳ đã khóa thì viết lại giá vốn kỳ đó hay hạch toán một khoản điều chỉnh vào kỳ hiện tại; MONA không dẫn điều luật nào cho lựa chọn này nên để chính sách nội bộ quyết định.

Chi phí tính lại tăng theo số phiếu sau ngày ghi lùi. Tài liệu FIFO and Moving Average của ERPNext (tra ngày 03/11/2026) nêu phiếu nhập ghi lùi buộc tính lại sổ kho phía sau và việc này có thể chậm khi lịch sử dài. MONA đề xuất chạy việc đó nền, theo từng mặt hàng. Ngày chốt sổ của đơn lập lúc 23:30 cũng gây ra loại lệch kỳ tương tự, xem múi giờ và ngày chốt sổ.
Một trường hợp mã chưa xử lý: bán khi kho chưa đủ hàng vì phiếu nhập chưa vào máy. Hàm hiện từ chối phiếu xuất vượt tồn (kết quả chạy ở phần trước). Doanh nghiệp cho phép bán khi tồn kho âm cần thêm quy tắc tính giá vốn tạm bằng đơn giá bình quân cuối cùng và định giá lại khi phiếu nhập về; đoạn đó chưa có trong mã của bài.
Làm tròn đến đồng làm lệch giá trị tồn kho bao nhiêu
Hàm sau viết cách lưu đơn giá làm tròn đến đồng để so sánh. Hàm giữ book là nhập trừ giá vốn xuất đã ghi, và shelf là số lượng nhân đơn giá làm tròn. Ở hệ thống chính xác, hai số này bằng nhau:
def naive_rounded_average(events: List[Ev], open_qty: D, open_price: D):
"""What many spreadsheets and tables do: keep the average as a rounded dong
price. Returns the books (receipts minus issue costs) and the shelf value
(qty x price); in an exact system the two are equal.
Receipts and issues only; returns are left out to keep the model short."""
qty, price = D(open_qty), D(open_price)
book = qty * price
issue_price: Dict[str, D] = {}
for e in sorted(events, key=lambda e: e.day):
if e.kind == RECEIPT:
book += dong(e.qty * e.unit)
price = dong((qty * price + e.qty * e.unit) / (qty + e.qty))
qty += e.qty
elif e.kind == ISSUE:
issue_price[e.id] = price
book -= e.qty * price
qty -= e.qty
return {"qty": qty, "price": price, "book": book, "shelf": qty * price}
Phép thử tạo 2.000 sổ ngẫu nhiên (hạt giống 20261103), mỗi sổ 6 đến 40 phiếu nhập xuất của một mặt hàng với đơn giá có phần lẻ, và bán hết hàng ở cuối. Với cách giữ giá trị, vòng lặp khẳng định tồn bằng 0 thì giá trị bằng 0 và nhập trừ xuất khớp từng đồng:
random.seed(20261103)
def random_ledger():
evs, stock, day = [], 0, 1
for i in range(random.randint(6, 40)):
day += 1
if stock == 0 or random.random() < 0.4:
q = random.randint(1, 300)
evs.append(Ev(f"R{i}", f"2026-01-{day:02d}" if day < 29 else f"2026-02-{day - 28:02d}",
RECEIPT, D(q), D(random.randint(10_000, 99_999)) + D(random.randint(0, 99)) / 100))
stock += q
else:
q = random.randint(1, stock)
evs.append(Ev(f"S{i}", f"2026-01-{day:02d}" if day < 29 else f"2026-02-{day - 28:02d}", ISSUE, D(q)))
stock -= q
if stock:
evs.append(Ev("SZ", "2026-03-01", ISSUE, D(stock))) # sell everything left
return evs
worst_naive, residue_naive, exact_ok, residues = D(0), 0, 0, []
for _ in range(2000):
evs = random_ledger()
rows = moving_average(evs, D(0), D(0))
rec_v = sum(dong(e.qty * e.unit) for e in evs if e.kind == RECEIPT)
iss_v = sum(a for e, a, q, v in rows if e.kind == ISSUE)
assert rec_v - iss_v == rows[-1][3] == 0 and rows[-1][2] == 0 # exact: stock at zero is worth zero
exact_ok += 1
nv = naive_rounded_average(evs, D(0), D(0))
worst_naive = max(worst_naive, abs(nv["book"]))
residue_naive += nv["book"] != 0
residues.append(abs(nv["book"]))
print("exact method: 2000 of 2000 ledgers end at stock 0 and value 0 (asserted)", exact_ok)
print("rounded-price method: ledgers ending with a non-zero value at stock 0:", residue_naive,
"| worst residue (dong):", vn(worst_naive))
residues.sort()
print("rounded-price method: median residue (dong):", vn(residues[len(residues) // 2]))
Kết quả chạy:
exact method: 2000 of 2000 ledgers end at stock 0 and value 0 (asserted) 2000
rounded-price method: ledgers ending with a non-zero value at stock 0: 1996 | worst residue (dong): 2.122
rounded-price method: median residue (dong): 186
Cả 2.000 sổ giữ giá trị khớp từng đồng. Cách lưu đơn giá làm tròn để lại giá trị dư ở 1.996 sổ, trung vị 186 đồng và cao nhất 2.122 đồng khi kho đã hết hàng. Phần dư là giá vốn ghi thừa hoặc thiếu so với tiền nhập. Phần dư không tự mất đi. Quy tắc làm cho cách giữ giá trị khớp nằm ở một dòng của hàm xuất: phiếu xuất hết số lượng tồn thì lấy toàn bộ giá trị còn lại thay vì nhân với đơn giá.
Giới hạn của phép thử: sổ ngẫu nhiên, chỉ có phiếu nhập và xuất, không có phiếu trả; số 186 và 2.122 đồng là kết quả của giả lập này và không phải số đo của một doanh nghiệp. Phép thử chỉ cho thấy cơ chế sai số cộng dồn, và độ lớn trong thực tế còn tùy giá và số phiếu. Kế toán dùng ba phép kiểm sau để soát phần mềm kho, mỗi phép chạy được ngay trên bảng sổ kho:
- Cộng giá trị đầu kỳ, nhập và khách trả, trừ giá trị trả nhà cung cấp và giá trị xuất, phải bằng giá trị cuối kỳ, chính xác từng đồng.
- Số lượng tồn bằng 0 thì giá trị tồn phải bằng 0.
- Giá vốn từng phiếu xuất của tháng, cộng lại, phải bằng giá vốn thuần của tháng do cách tính cả kỳ báo (với cách cả kỳ, một phiếu nhận phần chênh làm tròn).
Hỏi đáp
Bình quân gia quyền tính theo tháng hay sau mỗi lần nhập?
Cả hai đều nằm trong đoạn 15 của VAS 02, và doanh nghiệp chọn theo tình hình của mình. Hai cách cho giá vốn khác nhau trên cùng sổ (21.422.314 đồng so với 21.442.667 đồng ở ví dụ tháng 10), nên cách đã chọn cần ghi vào thuyết minh theo đoạn 27(a), xem phần so sánh ba cách ở trên.
Khách trả hàng thì tính giá vốn theo giá nào?
Theo giá vốn đã ghi trên phiếu bán gốc, chia theo số lượng trả, không theo đơn giá bình quân đang chạy: ở ví dụ là 520.778 đồng thay vì 541.626 đồng, lệch 20.848 đồng. Đơn giá bình quân đang chạy chỉ dùng cho phiếu xuất mới.
Trả hàng nhà cung cấp có làm đổi giá bình quân không?
Có. Lô bị trả có giá khác mức trung bình thì đơn giá đổi: ví dụ trả 20 cái giá 55.900 đồng làm đơn giá giảm từ 54.261,9057 xuống 54.162,6273, và số tiền trừ phải bằng giá hóa đơn gốc để khớp công nợ phải trả.
Nhập kho bù ngày cũ có phải tính lại cả tháng không?
Với bình quân sau mỗi lần nhập, phải tính lại mọi phiếu của mặt hàng từ ngày ghi lùi trở đi: 4 trong 7 dòng đổi ở ví dụ. Với bình quân cả kỳ, chỉ cần tính lại một đơn giá của kỳ chứa phiếu ghi lùi, nếu kỳ đó chưa khóa.
Dùng float cho giá vốn được không?
Không: float nhị phân không biểu diễn chính xác nhiều số thập phân (1,1 + 2,2 ra 3,3000000000000003 theo tài liệu Python), và hàm round() có sẵn làm tròn 2,5 thành 2. Dùng Decimal với ROUND_HALF_UP và lưu giá trị bằng số nguyên đồng.
Phiếu nhập, trả hàng và ngày ghi sổ của kho mỗi doanh nghiệp một kiểu. Phép tính phải khớp quy trình đó trước khi viết mã. MONA viết phần mềm kho theo quy trình nhập xuất đang chạy; mô tả nằm ở phần mềm quản lý sản xuất, kho và vận tải, danh sách công cụ có ở 10 phần mềm quản lý kho, và khách cần tính giá vốn theo một cách chưa có sẵn có thể gọi hotline 1900 636 648. Chứng từ đi kèm có trong bài mẫu phiếu nhập kho, cách đọc kết quả có ở bài giá vốn hàng bán và vòng quay hàng tồn kho.




















































