الكتابات/tutorial/2026/08
Tutorial5 أغسطس 2026·16 دقيقة

توليد ملف XML للخصم من المورد مطابق لمنظومة تاج، من قيودك مباشرةً

بناء ملف CCT-RS-V2 من قاعدة المحاسبة مباشرةً بلغة TypeScript: تصنيف المبالغ بالمليم، وضبط صيغ المعرّفات، وضمان فرادة المراجع، والفحوص الحسابية، والتحقّق قبل الإيداع — حتى لا يمرّ الملف بجدول بيانات أبدًا.

أغلب ملفات تاج المرفوضة لم تُرفض لأنّ الفريق أساء فهم القاعدة الجبائية، بل لأنّ الملف مرّ بجدول بيانات. يبني هذا الدليل ملف XML من قاعدة البيانات مباشرةً، دون مرحلة وسيطة يمكن أن يعود فيها المبلغ عددًا عشريًا.

الصيغة المستهدفة هي CCT-RS-V2. وسياق الالتزام مشروح في دليل ملف XML؛ وندخل هنا في الشيفرة.

1. صنّف المبالغ بالمليم، ولا شيء غيره

هذا هو القرار البنيوي. فالمخطّط يعرّف المبالغ أعدادًا صحيحة مقرَّبة بالمليم، وأيّ تمثيل عشري في الطريق فرصةٌ لإنتاج 1234.5.

// lib/tej/millimes.ts
 
/**
 * Un montant en millimes. Le dinar compte trois décimales, donc
 * 1 234,500 DT vaut 1_234_500 millimes.
 *
 * Le type nominal empêche de passer un nombre « ordinaire » là où un montant
 * est attendu : c'est le compilateur qui refuse le mélange, pas une revue.
 */
export type Millimes = number & { readonly __brand: 'Millimes' };
 
export function millimes(n: number): Millimes {
  if (!Number.isInteger(n) || n < 0) {
    throw new RangeError(`montant non entier en millimes : ${n}`);
  }
  return n as Millimes;
}
 
/** Convertit un dinar décimal en millimes, en refusant l'imprécision. */
export function fromDinars(d: string | number): Millimes {
  const s = String(d).replace(',', '.').trim();
  if (!/^\d+(\.\d{1,3})?$/.test(s)) {
    throw new RangeError(`montant en dinars invalide : ${d}`);
  }
  const [ent, dec = ''] = s.split('.');
  return millimes(Number(ent) * 1000 + Number(dec.padEnd(3, '0')));
}
 
export const toXml = (m: Millimes): string => String(m);

الدالة fromDinars ترفض منزلة عشرية رابعة بدل أن تقرّبها في صمت: فمبلغٌ تحفظه المحاسبة بأربع منازل يشير إلى خلل سابق، وتقريبه يُخفيه.

2. تحقّق من المعرّفات عند الحدّ

المعرّف الجبائي هو \d{7}[A-Z]. والفحص موضعه الحدّ — لحظة دخول المعطى — لا لحظة التسلسل، حيث يفقد الخطأ سياقه.

// lib/tej/identifiants.ts
const MATRICULE = /^\d{7}[A-Z]$/;
 
export type Beneficiaire =
  | { kind: 'MatriculeFiscal'; value: string }
  | { kind: 'CIN'; value: string }
  | { kind: 'Passeport'; value: string }
  | { kind: 'CarteSejour'; value: string };
 
/**
 * Le schéma impose *exactement un* identifiant par bénéficiaire — un xs:choice.
 * Modéliser cela en union discriminée rend l'invariant impossible à violer,
 * là où un objet à quatre champs optionnels laisse passer zéro ou deux.
 */
export function beneficiaire(b: Beneficiaire): Beneficiaire {
  if (b.kind === 'MatriculeFiscal' && !MATRICULE.test(b.value)) {
    throw new RangeError(`matricule fiscal invalide : ${b.value}`);
  }
  if (!b.value.trim()) throw new RangeError('identifiant vide');
  return b;
}

3. اضمن فرادة المراجع

تكرار Ref_certif_chez_declarant يُسقط الإيداع كلّه. ونادرًا ما ينشأ عن خطأ طباعة: بل عن عدّاد أُعيد من 1، أو تصديرين دُمجا.

// lib/tej/references.ts
 
/**
 * Vérifie l'unicité avant sérialisation et signale *les deux* occurrences.
 * « Référence en double » sans dire laquelle oblige à relire tout le fichier,
 * ce qui est exactement le service que la plateforme rend déjà.
 */
export function assertReferencesUniques(refs: string[]): void {
  const vues = new Map<string, number>();
  const conflits: string[] = [];
  refs.forEach((r, i) => {
    const premier = vues.get(r);
    if (premier !== undefined) conflits.push(`« ${r} » : lignes ${premier + 1} et ${i + 1}`);
    else vues.set(r, i);
  });
  if (conflits.length) {
    throw new Error(`références en double :\n  ${conflits.join('\n  ')}`);
  }
}

4. افحص الحساب قبل الكتابة

// lib/tej/operation.ts
import type { Millimes } from './millimes';
 
export type Operation = {
  montantHT: Millimes;
  montantTVA?: Millimes;
  montantTTC: Millimes;
  montantRS: Millimes;
  montantNetServi: Millimes;
  tauxRS: number;
};
 
export function verifierOperation(op: Operation, ligne: number): string[] {
  const pb: string[] = [];
 
  // Contrôle dur : la plateforme le refuse.
  if (op.montantTTC - op.montantRS !== op.montantNetServi) {
    pb.push(
      `ligne ${ligne} : net servi ${op.montantNetServi} ≠ ${op.montantTTC} − ${op.montantRS}`,
    );
  }
  // Contrôle souple : l'arrondi produit légitimement un millime d'écart.
  const tva = op.montantTVA ?? 0;
  if (op.montantHT + tva !== op.montantTTC) {
    pb.push(`ligne ${ligne} : avertissement, HT + TVA (${op.montantHT + tva}) ≠ TTC (${op.montantTTC})`);
  }
  if (op.tauxRS < 0 || op.tauxRS > 100) {
    pb.push(`ligne ${ligne} : taux de retenue hors bornes (${op.tauxRS})`);
  }
  return pb;
}

والتمييز بين الخطأ والتنبيه مهمّ: فمعاملة فارق التقريب بوصفه مانعًا تُسقط ملفات صالحة تمامًا للإيداع، وينتهي الفريق إلى تجاهل الأداة.

5. التسلسل

// lib/tej/xml.ts
const esc = (s: string) =>
  s.replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[c]!));
 
export function serialiser(d: {
  declarant: string; annee: string; mois: string; certificats: CertificatXml[];
}): string {
  const certs = d.certificats.map((c) => `
      <Certificat>
        <Beneficiaire><IdTaxpayer><${c.idKind}>${esc(c.idValue)}</${c.idKind}></IdTaxpayer></Beneficiaire>
        <DatePayement>${c.datePaiement}</DatePayement>
        <Ref_certif_chez_declarant>${esc(c.reference)}</Ref_certif_chez_declarant>
        <ListeOperations>${c.operations.map((o) => `
          <Operation>
            <MontantHT>${o.montantHT}</MontantHT>
            <TauxRS>${o.tauxRS.toFixed(2)}</TauxRS>
            <MontantTTC>${o.montantTTC}</MontantTTC>
            <MontantRS>${o.montantRS}</MontantRS>
            <MontantNetServi>${o.montantNetServi}</MontantNetServi>
          </Operation>`).join('')}
        </ListeOperations>
      </Certificat>`).join('');
 
  return `<?xml version="1.0" encoding="UTF-8"?>
<DeclarationsRS>
  <Declarant>${esc(d.declarant)}</Declarant>
  <ReferenceDeclaration>
    <ActeDepot>AJOUT</ActeDepot>
    <AnneeDepot>${d.annee}</AnneeDepot>
    <MoisDepot>${d.mois}</MoisDepot>
  </ReferenceDeclaration>
  <AjouterCertificats>${certs}
  </AjouterCertificats>
</DeclarationsRS>`;
}

وتهريب رموز XML ليس زينة: فاسم شركة يحوي & — «بن علي وأولاده» — يُنتج وثيقة مختلّة ترفضها المنظومة قبل أن تقرأ قاعدة واحدة.

6. افحص الناتج

قبل أي إيداع، أعِد قراءة الملف المُنتَج. أداة فحص XML للخصم من المورد تطبّق قواعد كرّاس الشروط وتحدّد الشهادة والحقل؛ وتعمل كلّها داخل المتصفّح، فلا يغادر الملف الجهاز.

وفي التكامل المستمر، يختصر المنطق نفسه في اختبار على مجموعة نماذج:

import { test } from 'node:test';
import assert from 'node:assert/strict';
 
test('le fichier du mois ne contient aucune anomalie bloquante', () => {
  const doc = construireDeclaration(ecrituresDuMois());
  const erreurs = doc.certificats.flatMap((c, i) =>
    c.operations.flatMap((o) => verifierOperation(o, i + 1)),
  ).filter((m) => !m.includes('avertissement'));
  assert.deepEqual(erreurs, []);
});

الخلاصة

  • المليم صنف، لا اصطلاح. فإن أمكن للمبلغ أن يكون عشريًا في موضع من الطريق، فسيكونه يومًا.
  • معرّف واحد لكل مستفيد، يضمنه الصنف. فالمخطّط يعبّر عن xs:choice، والاتحاد المميَّز يجعله غير قابل للتشويه.
  • أشِر إلى موضعي التكرار معًا، وإلّا قدّمت الخدمة العديمة النفع نفسها التي تقدّمها رسالة الرفض.
  • افصل الخطأ عن التنبيه، وإلّا تعلّم الفريق تجاهل الكلّ.
  • لا تدع الملف يمرّ بجدول بيانات. فهناك تُولد الفواصل العشرية.