diff --git a/backend/src/modules/invoices/entities/invoice.entity.ts b/backend/src/modules/invoices/entities/invoice.entity.ts index 338c317..d456ce2 100644 --- a/backend/src/modules/invoices/entities/invoice.entity.ts +++ b/backend/src/modules/invoices/entities/invoice.entity.ts @@ -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; diff --git a/backend/src/modules/invoices/gemini-extractor.service.ts b/backend/src/modules/invoices/gemini-extractor.service.ts index 485e68e..e2abbf8 100644 --- a/backend/src/modules/invoices/gemini-extractor.service.ts +++ b/backend/src/modules/invoices/gemini-extractor.service.ts @@ -28,7 +28,7 @@ export class GeminiExtractorService { } /** - * استخراج البيانات من صورة الفاتورة + * استخراج البيانات من صورة الفاتورة (يدعم فواتير متعددة في ملف واحد) */ async extractInvoiceData(filePath: string, storageRoot: string): Promise { 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'); } } diff --git a/backend/src/modules/invoices/invoice.processor.ts b/backend/src/modules/invoices/invoice.processor.ts index 3267fc0..29e9111 100644 --- a/backend/src/modules/invoices/invoice.processor.ts +++ b/backend/src/modules/invoices/invoice.processor.ts @@ -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('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, }); } } diff --git a/backend/src/modules/validation/tax-validation.service.ts b/backend/src/modules/validation/tax-validation.service.ts index 501d0c6..b0d6e59 100644 --- a/backend/src/modules/validation/tax-validation.service.ts +++ b/backend/src/modules/validation/tax-validation.service.ts @@ -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)})`); } } } diff --git a/frontend/src/pages/invoices/InvoicesPage.tsx b/frontend/src/pages/invoices/InvoicesPage.tsx index 91ae1f0..ea96d4d 100644 --- a/frontend/src/pages/invoices/InvoicesPage.tsx +++ b/frontend/src/pages/invoices/InvoicesPage.tsx @@ -137,7 +137,8 @@ export const InvoicesPage = () => { inv.company?.name?.includes(searchTerm) ); - const StatusBadge = ({ status }: { status: string }) => { + const StatusBadge = ({ invoice }: { invoice: any }) => { + const status = invoice.status; const config: any = { approved: { color: 'text-emerald-700 bg-emerald-50 border-emerald-100', icon: CheckCircle2, label: 'تم التصديق' }, validated: { color: 'text-blue-700 bg-blue-50 border-blue-100', icon: CheckCircle2, label: 'جاهز للإرسال' }, @@ -147,8 +148,16 @@ export const InvoicesPage = () => { validation_failed: { color: 'text-red-700 bg-red-50 border-red-100', icon: AlertCircle, label: 'خطأ في التحقق' }, }; const { color, icon: Icon, label } = config[status] || { color: 'text-slate-500 bg-slate-50', icon: Clock, label: status }; + + const errorTitle = status === 'validation_failed' && invoice.validation_errors + ? invoice.validation_errors.join('\n') + : undefined; + return ( - + {label} @@ -243,7 +252,7 @@ export const InvoicesPage = () => { {Number(inv.grand_total).toLocaleString('en-US', { minimumFractionDigits: 3 })} - +
+ {viewingInvoice.validation_errors && viewingInvoice.validation_errors.length > 0 && ( +
+ +
+

فشل التحقق الضريبي:

+
    + {viewingInvoice.validation_errors.map((err: string, i: number) => ( +
  • {err}
  • + ))} +
+
+
+ )} +
{

الحالة

- +