372 اختبارًا ناجحًا، ولا فاتورة بيع واحدة تصل إلى الدفتر
كيف أُبقي وحدي نظامًا محاسبيًا من 322 ألف سطر صحيحًا: بوابة حفظ واحدة، وقيود مالية لا تُعدَّل، وسجل تدقيق ببصمات متسلسلة، واختبارات على قواعد حقيقية، وتدقيق كشف ما فاتها.
فوترة محطات الكهرباء دون اتصال: معرّف واحد يرافق الدفعة من الهاتف إلى الإيصال، ولماذا لا تُجمع إجماليات الفواتير، وطابور يعرف ما يعيده، وخطأ 500 صنع حلقة لا تنتهي.
لماذا تُفسد طابعات ESC/POS الحروف العربية، وكيف تطبعها صورةً أحادية البت بعرض الرأس الحقيقي، وخلل الأبعاد الذي حوّل إيصالًا طوله 12 سم إلى رولٍ كامل ملقى على الأرض.
ست طبقات تعمل في الإنتاج، كل واحدة مبنية ليوم تسقط فيه الطبقة التي فوقها: RLS مُجبَر، وحسابات لا تتجاوزه، وحاويات مقفلة، وشهادات للهواتف، ونسخة احتياطية تُستعاد كل أسبوع.
في 24 سبتمبر 2026 جلست أدقّق نظامي المحاسبي بقاعدة واحدة: قراءة الكود ليست تشغيلًا للاختبار. نجح في الحزمة 372 اختبارًا من 373. وأول ما وجده التدقيق كان أسوأ أنواع العيوب، ذلك الذي لا يتعطل معه شيء. فبالإعدادات الافتراضية، لم يكن بوسع أي فاتورة بيع أن تصل إلى الدفتر.
أرصدة العملاء صحيحة. المخزون صحيح. درج النقد صحيح. وحده ميزان المراجعة كان يعرف: لا مبيعات فيه على الإطلاق، بينما تُرحَّل كل فاتورة شراء فورًا. على الورق، منشأة تشتري ولا تبيع.
FinyX-ERP نظام عربي للمحاسبة ونقاط البيع أصمّمه وأبنيه وأبيعه وحدي. في هذا المقال الآلية التي تُبقيه صحيحًا دون فريق، والسبب الدقيق الذي جعل 372 اختبارًا ناجحًا تعجز عن رؤية ذلك العيب. وحين تنتهي منه ستكون معك بوابة حفظ تنقلها إلى أي تطبيق EF Core، وسجل تدقيق يكتشف التعديل الذي يجري على ملف القاعدة مباشرة، وقائمة فحص للعيوب التي لا تراها اختباراتك بحكم تصميمها.
بدأ FinyX برنامجًا محاسبيًا لويندوز. واليوم يضم المستودع نفسه كاشيرًا وبرنامج إدارة بـ WPF، وخادم ASP.NET Core لكل مطعم، ومتجر ويب بـ Next.js، وثلاثة تطبيقات جوال من كود Expo واحد. ولا يستطيع شخص واحد أن يتذكر كل قاعدة في كل هذه الأماكن، لذلك يجب ألا يعتمد التصميم على تذكّر أحد.
تطبيق سطح المكتب وخادم المطعم يترجمان ملفات المصدر نفسها في Domain وApplication وInfrastructure. وكل خدمة أعمال تُسجَّل في دالة واحدة هي AddFinyXBusinessServices()، ويستدعيها المضيفان كلاهما. فالقاعدة التي تُكتب مرة تعمل في الاثنين.
هذا يحسم أين تُكتب القاعدة. أما السؤال الأصعب فهو أين تُفرض. وجوابي: في المكان الوحيد الذي يجب أن يمر به كل تغيير على البيانات.
يعامل FinyX كل حدث تجاري كمعاملة واحدة في قاعدة البيانات: الفاتورة وحركات مخزونها وقيدها المحاسبي تُحفظ معًا أو لا يُحفظ منها شيء. وكل حفظ يمر بوحدة العمل (unit of work)، ووحدة العمل ترفض كل ما يعيد كتابة التاريخ:
csharp
public async Task<int> SaveChangesAsync(CancellationToken ct = default){ EnsureAppendOnlyAccountingAndAuditRecords(); // القيود وسطورها والدفعات وسندات الصندوق والتدقيق: إضافة فقط EnsurePostedDocumentsAreImmutable(); // الفاتورة المرحّلة تُصحَّح بمستند جديد await EnsureNoClosedFinancialPeriodWritesAsync(ct); var audits = BusinessAuditCapture.Capture(_context, _operationId, _user.CurrentUserKey); await using var tx = await _context.Database.BeginTransactionAsync(ct); var changed = await _context.SaveChangesAsync(ct); var records = audits.Select(audit => audit.Materialize()).ToArray(); AuditChain.Link(records, await AuditChain.ReadTailHashAsync(_context, ct)); _context.BusinessAuditRecords.AddRange(records); await _context.SaveChangesAsync(ct); await tx.CommitAsync(ct); return changed;}
هذا مختصر. فالدالة الحقيقية تنضم إلى معاملة خارجية إن وُجدت، ولا تطلب من الشاشات المفتوحة أن تعيد القراءة إلا بعد الالتزام (commit)، فلا تقرأ شاشةٌ كتابةً قد يُتراجع عنها.
ثلاثة فحوص، وثلاثة وعود.
للإضافة فقط. سجلات التدقيق والقيود اليومية وسطورها والدفعات وسندات الصندوق لا تُعدَّل ولا تُحذف عبر البوابة أبدًا. الخطأ يُصحَّح بقيد عكسي، ويبقى الاثنان في السجل.
المرحَّل ثابت. الفاتورة أو المرتجع أو المصروف لا يتغير بعد ترحيله. وتصحيحه يكون بمستند جديد.
الفترة المقفلة تبقى مقفلة. فوق هذا الفحص تعليق يشرح لماذا يعيش هنا، ومعناه: صحة النظام لا يجوز أن تعتمد على أن تتذكر كل خدمة حالية أو قادمة أن تستدعيه. والفحص يقرأ التاريخ الأصلي للسطر المعدَّل وتاريخه الجديد معًا، فلا يُنقل قيد تاريخي إلى خارج فترة مقفلة ولا إلى داخلها. ويقفل كذلك كل ما يسبق نهاية فترة مقفلة، بما في ذلك تاريخ قبل أول فترة أو في فجوة بين فترتين. فالرصيد الافتتاحي التالي هو مجموع كل ما قبله، والسطر الذي يتسلل إلى فجوة يغيّر رقمًا وقّع عليه أحدهم من قبل.
الرفضإعادة كتابة المال، والمستندات المرحّلة، والفترات المقفلة
حفظ التغييرداخل معاملة واحدة
ربط التدقيقكل سجل يحمل بصمة السجل الذي قبله
الالتزامالتغيير ودليله معًا، أو لا شيء
بوابة واحدة لكل حفظ. وأي فشل يتراجع عن التغيير وسجل تدقيقه معًا.
قد تخطئ البوابة في الاتجاه المعاكس أيضًا، وبالصمت نفسه. أرشفة فاتورة البيع كانت تشترط أن تكون مرحّلة، ثم يرفض فحص المستندات المرحّلة الحفظ لأنها مرحّلة. فلم تنجح الأرشفة مرة واحدة. وعدّاد الطباعة كان يفشل بالطريقة نفسها بعد كل طباعة لفاتورة مرحّلة، فيبقى صفرًا، وتُقرأ الفاتورة «لم تُطبع قط».
الإصلاح قائمة سماح (allow-list) مغلقة. أربعة حقول أرشفة فقط يجوز أن تتغير على فاتورة مرحّلة:
كان سجل التدقيق عندي يعد بشيء واحد: لا يمكن تعديله من داخل التطبيق. وهذا وعد ضعيف. فمن يفتح ملف SQLite بأداة قواعد بيانات يستطيع أن يحذف عملية بيع وسجل تدقيقها معًا، ولا يبقى ما يدل على شيء.
لذلك صار كل سجل تدقيق يحمل بصمة SHA-256 لمحتواه هو مع بصمة السجل الذي قبله. غيّر حرفًا واحدًا فتتوقف بصمته عن المطابقة. احذف سجلًا فيشير الذي بعده إلى بصمة لم تعد موجودة.
csharp
public static string ComputeEntryHash(BusinessAuditRecord record, string previousHash){ var payload = string.Join("\u001F", // فاصل لا تحويه أي قيمة record.OperationId.ToString("N"), record.OccurredAtUtc.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture), record.UserKey, record.Action, record.EntityType, record.EntityKey, record.ChangedProperties, record.BeforeJson, record.AfterJson, record.ApplicationVersion, record.MachineName, previousHash); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();}
اثنا عشر سطرًا، وفيها أربعة فخاخ.
أوقات تعود مختلفة. يعمل FinyX على SQLite أو SQL Server أو PostgreSQL. وSQLite يعيد DateTime بلا Kind، وPostgreSQL يقرّبه إلى الميكروثانية. فلو بصمتَ القيمة كاملة لفشل التحقق من سجل كُتب على SQLite بمجرد انتقال البيانات إلى PostgreSQL. لذلك تأخذ البصمة الوقت بالميلي ثانية بلا لاحقة نوع، فهذه الدقة وحدها تعود سليمة من كل مزوّد.
النص المبصوم صيغة تخزين. ترتيب الحقول والفاصل بينها ثابتان إلى الأبد. غيّر أحدهما فتصير كل بصمة كُتبت من قبل غير قابلة للتحقق، لذلك عامِل أي تغيير هناك معاملة ترحيل للبيانات.
كاتبان وذيل واحد. على خادم مشترك قد يحفظ جهازا كاشير في اللحظة نفسها، فيربط كلاهما سجله بالسجل السابق نفسه. وهذا عمل سليم تمامًا. والفاحص الذي يشترط خطًا مستقيمًا واحدًا سيتهم العميل كل يوم، والإنذار الذي يرنّ كل يوم يُطفأ. لذلك يتجاهل التحقق الترتيب ويفحص ثلاثة أشياء: كل بصمة تطابق محتواها، وكل بصمة سابقة تشير إلى سجل موجود، وللسلسلة بداية واحدة فقط.
الذيل بلا شاهد. احذف آخر سجل، فلن يشير إليه أحد بعده. والسلسلة وحدها لا ترى ذلك، لذلك يُحفظ عدد السجلات وآخر بصمة خارج قاعدة البيانات أيضًا. والسجل الذي قصُر يظهر بنقصان العدد.
والترتيب داخل البوابة مهم كذلك. تُلتقط قيم «قبل» قبل الحفظ الأول، ما دام EF Core يحتفظ بالقيم الأصلية. وتُقرأ قيم «بعد» والمفاتيح الأساسية الجديدة بعده. وتُقرأ بصمة الذيل داخل المعاملة نفسها، فالذيل الذي أربط به هو الذيل الذي يُلتزم به. ولأن الحفظين يتشاركان المعاملة، لا يُلتزم بتغيير تجاري دون دليله.
والاختبار يفعل تمامًا ما يفعله العابث: يكتب في ملف القاعدة بـ SQL خام، من خارج التطبيق:
csharp
// تعديل سجل من خارج التطبيق: يُكتشفExecuteOutsideTheSystem(db, "UPDATE BusinessAuditRecords SET UserKey = 'someone.else' WHERE Id = (SELECT MIN(Id) FROM BusinessAuditRecords);");Require(!Verify(db).IsIntact, "An edited audit record went unnoticed.");// إعادة القيمة: تعود السلسلة سليمة، فالفحص لا يحمل ضغينةExecuteOutsideTheSystem(db, "UPDATE BusinessAuditRecords SET UserKey = 'audit.chain.tests' WHERE Id = (SELECT MIN(Id) FROM BusinessAuditRecords);");Require(Verify(db).IsIntact, "Restoring the original value left the trail broken.");// حذف سجل من الوسط: يُكتشفExecuteOutsideTheSystem(db, "DELETE FROM BusinessAuditRecords WHERE Id = (SELECT MIN(Id) + 1 FROM BusinessAuditRecords);");Require(!Verify(db).IsIntact, "A deleted audit record went unnoticed.");
وعلى PostgreSQL تحرس المشغّلات (triggers) أحد عشر جدولًا أيضًا، فيبقى القيد المرحّل وسجل التدقيق ثابتين حتى أمام من يملك أداة إدارة. واختبار آلي يقارن هذه القائمة بقائمة SQL Server، فلا يصير نقل عميل إلى خادم تخفيفًا للحماية.
لا يستعمل FinyX مكتبة xUnit، فاختباراته برنامج تنفيذي واحد. كل اختبار دالة اسمها جملة إنجليزية تقول الوعد الذي يحفظه، والاختبارات التي تلمس البيانات تعمل على قاعدة SQLite حقيقية في مجلد مؤقت خاص بها. ويطبع كل اختبار سطر METRIC بالأرقام التي أثبتها، ثم PASS أو FAIL أو SKIP:
سطر METRIC يحوّل الاختبار الأخضر إلى دليل، فتقرأ ما أثبته دون أن تفتح الكود. وSKIP موجود لسبب واحد: الاختبار الذي لا تسمح البيئة بتشغيله يُعلَن ويُعدّ، ولا يُحسب فشلًا أبدًا، لأن الأحمر الذي لا يعني شيئًا يعلّمك أن تتجاهل الأحمر. وسكربت الإصدار يشغّل حزمة سطح المكتب كاملة قبل أن ينشر أي شيء، ويتوقف إن فشل أي اختبار.
ومع كل ذلك، قالت الحزمة 372 من 373. وهذا ما لم تستطع أن تراه.
شاشة المبيعات تعرض عدد الفواتير المنتظرة للترحيل، ومعه زر يرحّلها. وفي نموذج العرض (view model) كانت تلك القائمة تشترط أن تكون كل فاتورة معتمدة. والاعتماد سياسة اسمها SalesRequireApprovalBeforePosting، وهي مطفأة عند الشحن. فبالإعدادات الافتراضية لم تكن أي فاتورة تصل إلى تلك القائمة، ولم يجد الزر ما يرحّله.
لماذا لم ينتبه أحد؟ لأن كل ما يراجعه الكاشير والمالك كل يوم كان صحيحًا. أرصدة العملاء والمخزون ودرج النقد تحسب المستندات غير المرحّلة عمدًا، لتبقى صحيحة قبل أن يرحّل المحاسب. وحدها التقارير التي تقرأ الدفتر نفسه، ميزان المراجعة وقائمة الدخل، كانت تخلو من كل المبيعات.
ولماذا لم تنتبه 372 اختبارًا؟ هنا الجزء الذي يستحق أن تسرقه. الخدمة كانت تملك الدالة الصحيحة من قبل: GetUnpostedSaleInvoicesAsync. كانت مختبَرة. ولم يكن أحد يستدعيها. الشاشة كانت تبني قائمتها بنفسها من الصفوف التي تعرضها في تلك اللحظة، وبنسختها الخاصة من القاعدة. فالاختبارات كانت تثبت صحة دالة لا يشغّلها المنتج أصلًا.
وللخطأ نفسه عَرَض ثانٍ. فلأن القائمة تأتي من الصفوف الظاهرة، كانت تتغير بتغيّر مرشّح التاريخ في الشاشة. وإذا اختير نطاق «اليوم»، لم يكن ممكنًا ترحيل فاتورة الأمس أصلًا.
شاشة المبيعات (ما كان يعمل)الخدمة (ما كان مختبَرًا)
الاعتماد مشروط دائمًاالاعتماد مشروط حين تطلبه السياسة فقط
تُبنى من الصفوف المعروضةتُقرأ من قاعدة البيانات
تتغير بمرشّح التاريخكل فاتورة غير مرحّلة
فارغة بالإعدادات الافتراضيةصحيحة، ولا أحد يستدعيها
قاعدة واحدة في مكانين. الاختبارات غطّت العمود الثاني، ولم يكن يستدعيه أحد.
انتقلت القاعدة إلى الخدمة، والخدمة الآن تقرأ السياسة نفسها التي يقرؤها الترحيل، والشاشة تأخذ قائمتها من الخدمة:
ووجد التدقيق نفسه أن الضغط على «تعديل» لفاتورة مرحّلة كان يمحو قيدها أولًا ثم يفتح المحرر. فمن ضغط «إلغاء» بعدها أخرج الفاتورة من الدفاتر: الإيراد والضريبة والتكلفة والذمة تختفي، والبضاعة تبقى خارج المخزن، والفاتورة تبقى في القائمة. ولا رسالة واحدة.
الآن يُفتح المحرر أولًا. وإن أُغلق دون تأكيد، لا يُمسّ الدفتر إطلاقًا. ولا يُفكّ ترحيل الفاتورة إلا بعد التأكيد، قبيل تطبيق التعديل مباشرة، وأي فشل بعد ذلك يعيد الترحيل. وإن فشلت الإعادة نفسها، تظهر نافذة تسمّي الفاتورة وتقول إن قيدها غائب عن الدفتر.
كيف تختبر قرارًا يعيش خلف نافذة حوار، في حزمة لا شاشة فيها؟ تختبر ترتيبه في المصدر:
csharp
var body = MethodBody(File.ReadAllText(salesViewModelPath), "private async Task EditSelectedInvoiceAsync()");var dialogGuard = body.IndexOf("editor.ShowDialog() != true", StringComparison.Ordinal);var unpost = body.IndexOf("UnpostSaleInvoiceAsync", StringComparison.Ordinal);Require(dialogGuard >= 0, "The editor is no longer opened before the invoice is amended.");Require(unpost > dialogGuard, "The ledger is stripped before the editor is confirmed.");Require(body.Contains("RestorePostingAfterAbandonedEditAsync"), "Nothing restores the entry when the amend fails after the invoice was unposted.");
فكّ ترحيل فاتورة يتطلب حذف قيدها، والبوابة تمنع ذلك. لذلك كان لفكّ الترحيل والحذف الإداري طريق خاص بهما: SQL خام مع تعليق مشغّلات القاعدة. هذا الطريق لم يمر بوحدة العمل قط، فلم يلتقِ بفحص الفترة المقفلة. وكان ممكنًا عبره أن يُمحى قيد من شهر أُقفل.
الإصلاح يُجري فحص الفترة قبل فتح الاتصال، وقبل تعليق أي مشغّل. فالطلب الذي يمسّ فترة مقفلة يُرفض قبل أن يُلمس صف واحد.
كل طريق يلتفّ حول البوابة يجب أن يحمل فحوصها معه. وأسرع طريقة لتجد طرقك أنت: ابحث في الكود عن SQL خام.
الاختبار الوحيد الفاشل من الـ 373 كان يفشل في كل تشغيل عادي. كان يحتاج قاعدة بيانات يجهّزها سكربت منفصل مسبقًا، فهو أحمر دائمًا عند كل من سواه. والفشل الدائم في قائمة خضراء يعلّم قارئها أن يتجاوزه، فيتجاوز معه أول فشل حقيقي. الآن يبني الاختبار قاعدته بنفسه في مجلد مؤقت جديد ويحذفه بعد انتهائه. وبعد الإصلاحات نجحت الحزمة 376 من 376.
وبعد أقل من شهر صار لسطح المكتب 475 اختبارًا.
سطح المكتب (WPF)475
الخادم (ASP.NET Core)30
الويب16
مكتبة التكاملات10
531 اختبارًا في أربع مجموعات اليوم. ويوم التدقيق كان لسطح المكتب 373 اختبارًا.
اربط سجلات التدقيق بسلسلة بصمات: ترتيب ثابت للحقول، وفاصل لا تحويه أي قيمة، ودقة وقت تعيدها كل قاعدة بيانات، وتحقق يتسامح مع الكتّاب المتزامنين، وشاهد على الذيل خارج القاعدة.
في الاختبارات، اكتب في ملف القاعدة من خارج التطبيق، وأثبت أنك تلاحظ.
ابحث عن SQL خام. كل طريق يلتف حول البوابة يجب أن يكرر فحوصها.
ابحث عن دوال خدمات مختبَرة لا يستدعيها شيء.
لا تقبل اختبارًا أحمر دائمًا. أعلن التخطي بصوت عالٍ، وعُدّ مرات التخطي.
دقّق بقاعدة واحدة: قراءة الكود ليست تشغيلًا للاختبار، ونجاح البناء لا يثبت شيئًا عن صحة المحاسبة.
لو بدأت FinyX من جديد غدًا لكتبت بوابة الحفظ قبل أول شاشة. فأولى ملاحظات التدقيق كانت قاعدتين تعيشان في شاشة، وطريق SQL خام يلتف حول البوابة. ضع القاعدة حيث تتغير البيانات، ثم اكتب الاختبار الذي يثبت أن الشاشة تستعملها.
وهو الانضباط نفسه الذي أبني به أنظمة الأعمال لعملائي.
التعليقات
شارك في النقاش
سجّل الدخول بحساب Google لتعجب بالمقالات وتعلّق عليها. ضغطة واحدة تكفي.