🚀 Multi-invoice extraction and detailed validation feedback

This commit is contained in:
Hamza-Ayed
2026-04-19 16:13:09 +03:00
parent 6b9ce6e95b
commit 2e2d76c0a8
5 changed files with 121 additions and 55 deletions

View File

@@ -107,6 +107,12 @@ export class Invoice {
@Column({ type: 'text', nullable: true })
original_file_path?: string;
@Column({ type: 'varchar', length: 20, default: 'simplified' })
invoice_category!: string;
@Column({ type: 'jsonb', nullable: true })
validation_errors?: string[];
@Column({ type: 'decimal', precision: 4, scale: 3, nullable: true })
ai_confidence_score?: number;

View File

@@ -28,7 +28,7 @@ export class GeminiExtractorService {
}
/**
* استخراج البيانات من صورة الفاتورة
* استخراج البيانات من صورة الفاتورة (يدعم فواتير متعددة في ملف واحد)
*/
async extractInvoiceData(filePath: string, storageRoot: string): Promise<any> {
try {
@@ -36,40 +36,50 @@ export class GeminiExtractorService {
const fileData = fs.readFileSync(fullPath);
const prompt = `
You are a Jordanian tax expert. Extract all details from this invoice image for the Jordan National Invoicing System (JoFotara / JoInvoice).
The output MUST be a strict JSON object following this schema:
You are a Jordanian tax expert. Extract all details from this file (image or PDF).
The file may contain ONE or MULTIPLE distinct invoices.
Extract EACH invoice separately.
The output MUST be a strict JSON object with this schema:
{
"invoice_number": "string",
"invoice_date": "YYYY-MM-DD",
"invoice_type": "cash" | "credit",
"supplier_name": "string",
"supplier_tin": "string (10 digits)",
"buyer_name": "string (optional)",
"buyer_tin": "string (optional)",
"subtotal": number (before discount and tax),
"discount_total": number (total discount),
"tax_amount": number (total tax),
"grand_total": number (final amount),
"currency_code": "JOD",
"lines": [
"invoices": [
{
"line_number": number,
"description": "string",
"quantity": number,
"unit_price": number,
"discount": number,
"tax_rate": number (e.g. 0.16 for 16%),
"line_total": number (quantity * unit_price - discount)
"invoice_number": "string",
"invoice_date": "YYYY-MM-DD",
"invoice_type": "cash" | "credit",
"invoice_category": "standard" | "simplified",
"supplier_name": "string",
"supplier_tin": "string (10 digits)",
"buyer_name": "string (optional)",
"buyer_tin": "string (optional)",
"subtotal": number (before discount and tax),
"discount_total": number (total discount),
"tax_amount": number (total tax),
"grand_total": number (final amount),
"currency_code": "JOD",
"lines": [
{
"line_number": number,
"description": "string",
"quantity": number,
"unit_price": number,
"discount": number,
"tax_rate": number (e.g. 0.16 for 16%),
"line_total": number (quantity * unit_price - discount)
}
]
}
]
}
Pay close attention to Jordanian Tax Rules:
- Standard Rate: 0.16 (16%)
- Reduced Rate: 0.04 (4%)
- Zero Rate: 0.00 (0%)
- Exempt: null or 0 with explanation.
JoFotara Specific Rules:
- invoice_category: "simplified" if the buyer is a regular person (no TIN), "standard" if B2B (buyer has TIN).
- Prices in Jordan (JOD) often have 3 decimal places (e.g. 2.800).
- Standard VAT Rate: 0.16 (16%)
- The formula MUST hold: Subtotal - Discount + Tax = Grand Total.
Return ONLY the JSON. No markdown formatting. If a field is missing, return null.
- If multiple invoices are in the PDF, ensure the "invoices" array contains all of them.
Return ONLY the JSON. No markdown formatting.
`;
// Detect MIME type based on extension
@@ -93,9 +103,10 @@ export class GeminiExtractorService {
// Clean up markdown if any
const cleanedJson = responseText.replace(/```json|```/g, '').trim();
return JSON.parse(cleanedJson);
const data = JSON.parse(cleanedJson);
return data.invoices || [];
} catch (error: any) {
this.logger.error(`AI Extraction failed: ${error.message}`);
this.logger.error(\`AI Extraction failed: \${error.message}\`);
throw new InternalServerErrorException('AI Extraction failed');
}
}

View File

@@ -52,25 +52,44 @@ export class InvoiceProcessor {
* الخطوة الأولى: استخراج البيانات باستخدام AI
*/
@Process('extract-data')
async handleExtraction(job: Job<{ invoiceId: string; filePath: string; tenantId: string }>) {
const { invoiceId, filePath } = job.data;
async handleExtraction(job: Job<{ invoiceId: string; filePath: string; tenantId: string; companyId: string }>) {
const { invoiceId, filePath, tenantId, companyId } = job.data;
const storageRoot = this.configService.get<string>('STORAGE_PATH', './uploads');
try {
// 1. Update status to EXTRACTING
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.EXTRACTING });
// 2. Extract data via Gemini
const data = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
// 2. Extract data via Gemini (Now returns an array)
const invoicesData = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
// 3. Save extracted data in a transaction
await this.saveExtractedData(invoiceId, data);
if (!invoicesData || invoicesData.length === 0) {
throw new Error('No invoices found in file');
}
this.logger.log(`Extraction successful for invoice ${invoiceId}`);
// 3. Process the first invoice (updates the current record)
await this.saveExtractedData(invoiceId, invoicesData[0]);
// 4. If multiple invoices found, create new records for others
if (invoicesData.length > 1) {
this.logger.log(`Found ${invoicesData.length} invoices in file ${filePath}. Creating additional records...`);
for (let i = 1; i < invoicesData.length; i++) {
const newInvoice = this.invoiceRepository.create({
tenant_id: tenantId,
company_id: companyId,
original_file_path: filePath,
status: InvoiceStatus.EXTRACTING,
});
const savedNew = await this.invoiceRepository.save(newInvoice);
await this.saveExtractedData(savedNew.id, invoicesData[i]);
}
}
this.logger.log(`Extraction successful for invoice(s) in ${filePath}`);
} catch (error) {
await this.invoiceRepository.update(invoiceId, {
status: InvoiceStatus.VALIDATION_FAILED,
// Optional: Save error message in a notes column
});
throw error;
}
@@ -90,6 +109,7 @@ export class InvoiceProcessor {
invoice_number: data.invoice_number,
invoice_date: data.invoice_date,
invoice_type: data.invoice_type || 'cash',
invoice_category: data.invoice_category || 'simplified',
supplier_name: data.supplier_name,
supplier_tin: data.supplier_tin,
buyer_name: data.buyer_name,
@@ -149,11 +169,14 @@ export class InvoiceProcessor {
const result = this.taxValidation.validateInvoice(invoice);
if (result.isValid) {
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.VALIDATED });
await this.invoiceRepository.update(invoiceId, {
status: InvoiceStatus.VALIDATED,
validation_errors: null,
});
} else {
await this.invoiceRepository.update(invoiceId, {
status: InvoiceStatus.VALIDATION_FAILED,
// Optional: Save detailed error list
validation_errors: result.errors,
});
}
}

View File

@@ -69,14 +69,14 @@ export class TaxValidationService {
0,
);
if (Math.abs(calculatedSubtotal - Number(invoice.subtotal)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 001: مجموع البنود (${calculatedSubtotal}) لا يطابق المجموع الفرعي المسجل (${invoice.subtotal})`);
const diff = Math.abs(calculatedSubtotal - Number(invoice.subtotal));
if (diff > this.PRECISION) {
errors.push(`خطأ في القاعدة 001: مجموع البنود (${calculatedSubtotal.toFixed(3)}) لا يطابق المجموع الفرعي المسجل (${Number(invoice.subtotal).toFixed(3)})`);
}
}
/**
* Rule 002: TaxAmount = (Subtotal - Discount) * TaxRate
* ملاحظة: يجب التحقق لكل بند بشكل منفصل أو للمجموع حسب نوع الفاتورة
* Rule 002: TaxAmount = Σ (LineBeforeTax * TaxRate)
*/
private checkRule002(invoice: Invoice, errors: string[]) {
const calculatedTax = invoice.lines.reduce(
@@ -87,8 +87,9 @@ export class TaxValidationService {
0,
);
if (Math.abs(calculatedTax - Number(invoice.tax_amount)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 002: قيمة الضريبة المحسوبة (${calculatedTax.toFixed(3)}) لا تطابق القيمة المسجلة (${invoice.tax_amount})`);
const diff = Math.abs(calculatedTax - Number(invoice.tax_amount));
if (diff > this.PRECISION) {
errors.push(`خطأ في القاعدة 002: قيمة الضريبة المحسوبة (${calculatedTax.toFixed(3)}) لا تطابق القيمة المسجلة (${Number(invoice.tax_amount).toFixed(3)})`);
}
}
@@ -101,8 +102,9 @@ export class TaxValidationService {
0,
);
if (Math.abs(totalLineDiscounts - Number(invoice.discount_total)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 003: مجموع خصومات البنود (${totalLineDiscounts}) لا يطابق إجمالي الخصم (${invoice.discount_total})`);
const diff = Math.abs(totalLineDiscounts - Number(invoice.discount_total));
if (diff > this.PRECISION) {
errors.push(`خطأ في القاعدة 003: مجموع خصومات البنود (${totalLineDiscounts.toFixed(3)}) لا يطابق إجمالي الخصم (${Number(invoice.discount_total).toFixed(3)})`);
}
}
@@ -112,8 +114,9 @@ export class TaxValidationService {
private checkRule004(invoice: Invoice, errors: string[]) {
const calculatedGrandTotal = Number(invoice.subtotal) - Number(invoice.discount_total) + Number(invoice.tax_amount);
if (Math.abs(calculatedGrandTotal - Number(invoice.grand_total)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 004: المجموع النهائي المحسوب (${calculatedGrandTotal.toFixed(3)}) لا يطابق المسجل (${invoice.grand_total})`);
const diff = Math.abs(calculatedGrandTotal - Number(invoice.grand_total));
if (diff > this.PRECISION) {
errors.push(`خطأ في القاعدة 004: المجموع النهائي المحسوب (${calculatedGrandTotal.toFixed(3)}) لا يطابق المسجل (${Number(invoice.grand_total).toFixed(3)})`);
}
}
}