چرا در نرمافزار حسابداری نباید با Float حساب کرد؟ (و بهجایش چه کنیم)
خطای پنهان اعداد اعشاری IEEE 754 در نرمافزار حسابداری و فینتک، اینکه چرا ریال صحیح هم شما را نجات نمیدهد، و چهار قاعدهای که در سامانه ۲حساب برای دقت ریالی رعایت کردهام.
اگر در کنسول مرورگر بنویسید 0.1 + 0.2، جواب 0.30000000000000004 است، نه 0.3. در یک ماشینحساب این تفاوت مهم نیست، اما در نرمافزار حسابداری یعنی ترازی که با یک عدد بسیار کوچک صفر نمیشود، گزارشی که با گزارش دیگر یک ریال اختلاف دارد و حسابداری که چند ساعت دنبال خطایی میگردد که در دفترها نیست، در کد است. وقتی سامانه حسابداری ۲حساب را میساختم، از روز اول یک قاعده گذاشتم: هیچ مبلغی، هیچجا، با عدد اعشاری معمولی حساب نمیشود. این مقاله توضیح میدهد چرا، و این قاعده در عمل چه شکلی دارد.
دقیقاً چه چیزی خراب میشود؟
تقریباً همه زبانها اعداد اعشاری را طبق استاندارد IEEE 754 و در مبنای دو نگه میدارند. مشکل اینجاست که بسیاری از کسرهای ساده مبنای ده، مثل ۰٫۱، در مبنای دو نمایش متناهی ندارند؛ درست مثل یکسوم که در مبنای ده ۰٫۳۳۳… میشود و هیچوقت تمام نمیشود. پس عدد ذخیرهشده فقط «نزدیک» به ۰٫۱ است.
این خطای کوچک در سه جا خودش را نشان میدهد. اول در مقایسه: شرطی مثل «اگر جمع بدهکار برابر جمع بستانکار است» با === گاهی false میشود، در حالی که روی کاغذ سند تراز است. دوم در جمعهای طولانی: وقتی هزاران ردیف فاکتور را جمع میزنید، خطاها روی هم جمع میشوند و بعد از گرد کردن، جمع ستون با جمع گزارش دیگر یک واحد اختلاف پیدا میکند. سوم در گرد کردن: عددی که باید ۲٫۵ باشد ممکن است در حافظه ۲٫۴۹۹۹۹۹۹ باشد و بهجای بالا، پایین گرد شود.
بدترین بخش این است که این خطاها در تستهای ساده دیده نمیشوند. با چند عدد گرد و تمیز همهچیز درست است و مشکل وقتی بیرون میزند که داده واقعی و حجم واقعی وارد سیستم شده.
0.1 + 0.2; // 0.30000000000000004
1.1 * 3; // 3.3000000000000003
(1.005).toFixed(2); // "1.00" نه "1.01"
0.1 + 0.2 === 0.3; // false«ما که با ریال کار میکنیم و اعشار نداریم»
این رایجترین جوابی است که میشنوم. درست است که مبلغ فاکتور در ایران معمولاً عدد صحیح ریالی است و اعداد صحیح تا حدود ۹ میلیون میلیارد در جاوااسکریپت دقیق میمانند. اما اعشار از جاهای دیگر وارد حساب میشود:
۱. درصدها: تخفیف ۷٫۵ درصدی، کارمزد، و درصد مالیات بر ارزش افزوده روی مبلغی که بر ۱۰۰ بخشپذیر نیست.
۲. مقدارهای اعشاری: کالایی که با کیلو، متر یا لیتر فروخته میشود؛ ۲٫۳۵ کیلو ضرب در قیمت واحد.
۳. نرخ ارز: در سیستم چندارزی، نرخ تبدیل تقریباً همیشه اعشار دارد.
۴. بهای تمامشده: میانگین موزون یا تقسیم هزینه حمل بین اقلام یک خرید، به عددی میرسد که صحیح نیست.
پس حتی در سیستمی که خروجیاش ریال صحیح است، محاسبات میانی اعشار دارند. سؤال این نیست که اعشار داریم یا نه؛ این است که اعشار را دقیق نگه میداریم یا تقریبی.
قاعده ۱: ذخیره در دیتابیس با NUMERIC
اولین جا دیتابیس است. ستون مبلغ نباید از نوع FLOAT یا REAL یا DOUBLE PRECISION باشد. در PostgreSQL نوع NUMERIC (و در MySQL نوع DECIMAL) عدد را در مبنای ده و دقیق نگه میدارد. دقت و مقیاس را آگاهانه انتخاب کنید: برای مبلغ ریالی، مقیاس صفر کافی است؛ برای مقدار کالا یا نرخ ارز، چند رقم اعشار لازم است.
در ۲حساب مبالغ، مقادیر و نرخها هر کدام نوع NUMERIC مخصوص خودشان را دارند. این کار جلوی خطای بیصدای دیتابیس را میگیرد: اگر عددی بیشتر از ظرفیت ستون باشد، خطا میگیرید، نه اینکه بیصدا گرد شود.
CREATE TABLE invoice_lines (
id BIGSERIAL PRIMARY KEY,
invoice_id BIGINT NOT NULL REFERENCES invoices(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity NUMERIC(14, 3) NOT NULL CHECK (quantity > 0), -- کیلو و متر هم
unit_price NUMERIC(20, 0) NOT NULL CHECK (unit_price >= 0), -- ریال
discount NUMERIC(20, 0) NOT NULL DEFAULT 0,
tax NUMERIC(20, 0) NOT NULL DEFAULT 0,
line_total NUMERIC(20, 0) NOT NULL
);نکات کلیدی
- ستونهای مالی: NUMERIC یا DECIMAL، هرگز FLOAT.
- مقیاس هر ستون را بر اساس معنایش انتخاب کنید (مبلغ، مقدار، نرخ).
- قید CHECK برای مقادیر غیرمنفی، تا داده خراب اصلاً وارد نشود.
قاعده ۲: در مسیر انتقال، عدد را رشته نگه دارید
دیتابیس درست است، اما داده باید از API به مرورگر برسد. JSON نوع «عدد دقیق» ندارد و هر عددی که JSON.parse بخواند به double جاوااسکریپت تبدیل میشود. اینجا دومین تله پنهان است.
درایور pg در Node.js بهطور پیشفرض NUMERIC را بهصورت رشته برمیگرداند، دقیقاً برای اینکه دقت از بین نرود. خطای رایج این است که برنامهنویس برای راحتی Number(row.amount) یا parseFloat مینویسد و تمام زحمت دیتابیس را دور میریزد. در ۲حساب مبالغ در API بهصورت رشته رفتوآمد میکنند و فقط در دو جا به عدد دقیق تبدیل میشوند: موقع محاسبه با کتابخانه Decimal و موقع نمایش.
نکات کلیدی
- خروجی NUMERIC از دیتابیس را با Number یا parseFloat تبدیل نکنید.
- در API مبلغ را رشته بفرستید و بگیرید و در ورودی با الگوی عددی اعتبارسنجی کنید.
- قالببندی نمایش (جداکننده هزارگان، ارقام فارسی) فقط در لایه نمایش.
قاعده ۳: محاسبه با decimal.js و گرد کردن در نقطه تعریفشده
برای محاسبه از کتابخانهای مثل decimal.js استفاده میکنم که حساب را در مبنای ده و با دقت دلخواه انجام میدهد. اما کتابخانه بهتنهایی کافی نیست؛ دو تصمیم باید صریح گرفته شود.
اول، روش گرد کردن. گرد کردن معمولی (نیم به بالا) با گرد کردن بانکی (نیم به نزدیکترین زوج) نتیجه متفاوت میدهد و در جمع هزاران ردیف، این تفاوت دیده میشود. این تصمیم مالی است، نه برنامهنویسی؛ باید با حسابدار مجموعه گرفته شود و بعد در کد ثابت بماند.
دوم، نقطه گرد کردن. مالیات را روی هر ردیف حساب و گرد میکنید، یا روی جمع فاکتور؟ این دو در بسیاری از فاکتورها یک ریال اختلاف دارند. هر کدام را انتخاب کنید، باید همهجای سیستم (فاکتور، گزارش، سند) همان را استفاده کنند. بیشتر اختلافهای «یک ریالی» که دیدهام از همین ناهماهنگی بوده، نه از خود Float.
import Decimal from 'decimal.js';
Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });
/** یک ردیف فاکتور؛ همه ورودیها رشتهاند تا دقت از دیتابیس تا اینجا حفظ شود. */
export function lineTotal(q: string, unitPrice: string, discountPct: string, taxPct: string) {
const gross = new Decimal(q).mul(unitPrice);
const discount = gross.mul(discountPct).div(100).toDecimalPlaces(0); // گرد کردن: ریال
const net = gross.minus(discount).toDecimalPlaces(0);
const tax = net.mul(taxPct).div(100).toDecimalPlaces(0); // مالیات روی هر ردیف
return {
gross: gross.toFixed(0),
discount: discount.toFixed(0),
tax: tax.toFixed(0),
total: net.plus(tax).toFixed(0),
};
}قاعده ۴: هیچ سندی بدون تراز دقیق ثبت نشود
در حسابداری دوطرفه، جمع بدهکار هر سند باید دقیقاً برابر جمع بستانکار باشد. این کنترل را نباید به گزارش تراز آخر ماه سپرد؛ باید در لحظه ثبت، پیش از نوشتن در دیتابیس، انجام شود و سند ناتراز اصلاً ثبت نشود. با Decimal این مقایسه دقیق است و مشکل 0.30000000000000004 پیش نمیآید.
کنترل دوم در خود دیتابیس است: ثبت سند و ردیفهایش داخل یک تراکنش انجام میشود تا هیچوقت سندی با نیمی از ردیفها باقی نماند. اگر به هر دلیلی یکی از ردیفها ثبت نشود، کل سند برمیگردد.
import Decimal from 'decimal.js';
type Row = { account: string; debit: string; credit: string };
export function assertBalanced(rows: Row[]) {
const debit = rows.reduce((s, r) => s.plus(r.debit || 0), new Decimal(0));
const credit = rows.reduce((s, r) => s.plus(r.credit || 0), new Decimal(0));
if (rows.some((r) => new Decimal(r.debit || 0).gt(0) && new Decimal(r.credit || 0).gt(0))) {
throw new Error('هر ردیف فقط بدهکار یا فقط بستانکار است.');
}
if (!debit.eq(credit)) {
throw new Error(`سند تراز نیست: بدهکار ${debit.toFixed()} ≠ بستانکار ${credit.toFixed()}`);
}
}جایگزین سادهتر: عدد صحیح در کوچکترین واحد
راه دیگری هم هست که در سیستمهای پرداخت رایج است: همه مبالغ را بهصورت عدد صحیح در کوچکترین واحد پول (مثلاً ریال، یا سنت برای دلار) در ستون BIGINT نگه دارید. جمع و تفریق اعداد صحیح همیشه دقیق است.
این روش برای کیف پول یا درگاه پرداخت که فقط مبلغ جابهجا میکند خوب است. اما وقتی ضرب و تقسیم وارد میشود (درصد، نرخ ارز، مقدار اعشاری، میانگین بهای تمامشده) دوباره به عدد غیرصحیح میرسید و باید درباره گرد کردن تصمیم بگیرید. برای نرمافزار حسابداری و انبارداری، NUMERIC بههمراه Decimal را ترجیح میدهم چون هم محاسبات میانی دقیق میماند و هم خوانایی داده در دیتابیس حفظ میشود.
چکلیست برای نرمافزار مالی خودتان
اگر نرمافزار حسابداری یا فروش دارید و گاهی گزارشها با هم یک ریال اختلاف دارند، این موارد را به ترتیب بررسی کنید:
پیادهسازی واقعی این قاعدهها را در سامانه حسابداری ۲حساب میتوانید ببینید. همین دقت در محاسبه بهای تمامشده هم لازم است که در کاردکس کالا و روش FIFO توضیح دادهام.
نکات کلیدی
- نوع ستونهای مبلغ در دیتابیس FLOAT یا DOUBLE نباشد.
- هیچجای کد مبلغ را با Number یا parseFloat تبدیل نکند.
- همه محاسبات مالی با کتابخانه Decimal انجام شود.
- روش و نقطه گرد کردن یکجا تعریف شده و همه بخشها از همان استفاده کنند.
- تراز سند پیش از ثبت و داخل تراکنش کنترل شود.
جمعبندی
Float برای محاسبات علمی ساخته شده، نه برای پول. در نرمافزار حسابداری، حتی اگر مبالغ ریال صحیح باشند، درصدها، مقدارهای اعشاری و نرخ ارز اعشار وارد محاسبه میکنند. چهار قاعده کافی است: NUMERIC در دیتابیس، رشته در API، decimal.js با روش و نقطه گرد کردن صریح در محاسبه، و کنترل تراز هر سند پیش از ثبت. اگر نرمافزار حسابداری اختصاصی لازم دارید که از روز اول با همین دقت ساخته شود، صفحه طراحی نرمافزار حسابداری و انبارداری را ببینید.