🚀 Multi-invoice extraction and detailed validation feedback
This commit is contained in:
@@ -107,6 +107,12 @@ export class Invoice {
|
|||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
original_file_path?: string;
|
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 })
|
@Column({ type: 'decimal', precision: 4, scale: 3, nullable: true })
|
||||||
ai_confidence_score?: number;
|
ai_confidence_score?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class GeminiExtractorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* استخراج البيانات من صورة الفاتورة
|
* استخراج البيانات من صورة الفاتورة (يدعم فواتير متعددة في ملف واحد)
|
||||||
*/
|
*/
|
||||||
async extractInvoiceData(filePath: string, storageRoot: string): Promise<any> {
|
async extractInvoiceData(filePath: string, storageRoot: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
@@ -36,40 +36,50 @@ export class GeminiExtractorService {
|
|||||||
const fileData = fs.readFileSync(fullPath);
|
const fileData = fs.readFileSync(fullPath);
|
||||||
|
|
||||||
const prompt = `
|
const prompt = `
|
||||||
You are a Jordanian tax expert. Extract all details from this invoice image for the Jordan National Invoicing System (JoFotara / JoInvoice).
|
You are a Jordanian tax expert. Extract all details from this file (image or PDF).
|
||||||
The output MUST be a strict JSON object following this schema:
|
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",
|
"invoices": [
|
||||||
"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": [
|
|
||||||
{
|
{
|
||||||
"line_number": number,
|
"invoice_number": "string",
|
||||||
"description": "string",
|
"invoice_date": "YYYY-MM-DD",
|
||||||
"quantity": number,
|
"invoice_type": "cash" | "credit",
|
||||||
"unit_price": number,
|
"invoice_category": "standard" | "simplified",
|
||||||
"discount": number,
|
"supplier_name": "string",
|
||||||
"tax_rate": number (e.g. 0.16 for 16%),
|
"supplier_tin": "string (10 digits)",
|
||||||
"line_total": number (quantity * unit_price - discount)
|
"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%)
|
JoFotara Specific Rules:
|
||||||
- Reduced Rate: 0.04 (4%)
|
- invoice_category: "simplified" if the buyer is a regular person (no TIN), "standard" if B2B (buyer has TIN).
|
||||||
- Zero Rate: 0.00 (0%)
|
- Prices in Jordan (JOD) often have 3 decimal places (e.g. 2.800).
|
||||||
- Exempt: null or 0 with explanation.
|
- Standard VAT Rate: 0.16 (16%)
|
||||||
- The formula MUST hold: Subtotal - Discount + Tax = Grand Total.
|
- 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
|
// Detect MIME type based on extension
|
||||||
@@ -93,9 +103,10 @@ export class GeminiExtractorService {
|
|||||||
// Clean up markdown if any
|
// Clean up markdown if any
|
||||||
const cleanedJson = responseText.replace(/```json|```/g, '').trim();
|
const cleanedJson = responseText.replace(/```json|```/g, '').trim();
|
||||||
|
|
||||||
return JSON.parse(cleanedJson);
|
const data = JSON.parse(cleanedJson);
|
||||||
|
return data.invoices || [];
|
||||||
} catch (error: any) {
|
} 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');
|
throw new InternalServerErrorException('AI Extraction failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,25 +52,44 @@ export class InvoiceProcessor {
|
|||||||
* الخطوة الأولى: استخراج البيانات باستخدام AI
|
* الخطوة الأولى: استخراج البيانات باستخدام AI
|
||||||
*/
|
*/
|
||||||
@Process('extract-data')
|
@Process('extract-data')
|
||||||
async handleExtraction(job: Job<{ invoiceId: string; filePath: string; tenantId: string }>) {
|
async handleExtraction(job: Job<{ invoiceId: string; filePath: string; tenantId: string; companyId: string }>) {
|
||||||
const { invoiceId, filePath } = job.data;
|
const { invoiceId, filePath, tenantId, companyId } = job.data;
|
||||||
const storageRoot = this.configService.get<string>('STORAGE_PATH', './uploads');
|
const storageRoot = this.configService.get<string>('STORAGE_PATH', './uploads');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Update status to EXTRACTING
|
// 1. Update status to EXTRACTING
|
||||||
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.EXTRACTING });
|
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.EXTRACTING });
|
||||||
|
|
||||||
// 2. Extract data via Gemini
|
// 2. Extract data via Gemini (Now returns an array)
|
||||||
const data = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
|
const invoicesData = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
|
||||||
|
|
||||||
// 3. Save extracted data in a transaction
|
if (!invoicesData || invoicesData.length === 0) {
|
||||||
await this.saveExtractedData(invoiceId, data);
|
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) {
|
} catch (error) {
|
||||||
await this.invoiceRepository.update(invoiceId, {
|
await this.invoiceRepository.update(invoiceId, {
|
||||||
status: InvoiceStatus.VALIDATION_FAILED,
|
status: InvoiceStatus.VALIDATION_FAILED,
|
||||||
// Optional: Save error message in a notes column
|
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -90,6 +109,7 @@ export class InvoiceProcessor {
|
|||||||
invoice_number: data.invoice_number,
|
invoice_number: data.invoice_number,
|
||||||
invoice_date: data.invoice_date,
|
invoice_date: data.invoice_date,
|
||||||
invoice_type: data.invoice_type || 'cash',
|
invoice_type: data.invoice_type || 'cash',
|
||||||
|
invoice_category: data.invoice_category || 'simplified',
|
||||||
supplier_name: data.supplier_name,
|
supplier_name: data.supplier_name,
|
||||||
supplier_tin: data.supplier_tin,
|
supplier_tin: data.supplier_tin,
|
||||||
buyer_name: data.buyer_name,
|
buyer_name: data.buyer_name,
|
||||||
@@ -149,11 +169,14 @@ export class InvoiceProcessor {
|
|||||||
const result = this.taxValidation.validateInvoice(invoice);
|
const result = this.taxValidation.validateInvoice(invoice);
|
||||||
|
|
||||||
if (result.isValid) {
|
if (result.isValid) {
|
||||||
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.VALIDATED });
|
await this.invoiceRepository.update(invoiceId, {
|
||||||
|
status: InvoiceStatus.VALIDATED,
|
||||||
|
validation_errors: null,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.invoiceRepository.update(invoiceId, {
|
await this.invoiceRepository.update(invoiceId, {
|
||||||
status: InvoiceStatus.VALIDATION_FAILED,
|
status: InvoiceStatus.VALIDATION_FAILED,
|
||||||
// Optional: Save detailed error list
|
validation_errors: result.errors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,14 +69,14 @@ export class TaxValidationService {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Math.abs(calculatedSubtotal - Number(invoice.subtotal)) > this.PRECISION) {
|
const diff = Math.abs(calculatedSubtotal - Number(invoice.subtotal));
|
||||||
errors.push(`خطأ في القاعدة 001: مجموع البنود (${calculatedSubtotal}) لا يطابق المجموع الفرعي المسجل (${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[]) {
|
private checkRule002(invoice: Invoice, errors: string[]) {
|
||||||
const calculatedTax = invoice.lines.reduce(
|
const calculatedTax = invoice.lines.reduce(
|
||||||
@@ -87,8 +87,9 @@ export class TaxValidationService {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Math.abs(calculatedTax - Number(invoice.tax_amount)) > this.PRECISION) {
|
const diff = Math.abs(calculatedTax - Number(invoice.tax_amount));
|
||||||
errors.push(`خطأ في القاعدة 002: قيمة الضريبة المحسوبة (${calculatedTax.toFixed(3)}) لا تطابق القيمة المسجلة (${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,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Math.abs(totalLineDiscounts - Number(invoice.discount_total)) > this.PRECISION) {
|
const diff = Math.abs(totalLineDiscounts - Number(invoice.discount_total));
|
||||||
errors.push(`خطأ في القاعدة 003: مجموع خصومات البنود (${totalLineDiscounts}) لا يطابق إجمالي الخصم (${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[]) {
|
private checkRule004(invoice: Invoice, errors: string[]) {
|
||||||
const calculatedGrandTotal = Number(invoice.subtotal) - Number(invoice.discount_total) + Number(invoice.tax_amount);
|
const calculatedGrandTotal = Number(invoice.subtotal) - Number(invoice.discount_total) + Number(invoice.tax_amount);
|
||||||
|
|
||||||
if (Math.abs(calculatedGrandTotal - Number(invoice.grand_total)) > this.PRECISION) {
|
const diff = Math.abs(calculatedGrandTotal - Number(invoice.grand_total));
|
||||||
errors.push(`خطأ في القاعدة 004: المجموع النهائي المحسوب (${calculatedGrandTotal.toFixed(3)}) لا يطابق المسجل (${invoice.grand_total})`);
|
if (diff > this.PRECISION) {
|
||||||
|
errors.push(`خطأ في القاعدة 004: المجموع النهائي المحسوب (${calculatedGrandTotal.toFixed(3)}) لا يطابق المسجل (${Number(invoice.grand_total).toFixed(3)})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ export const InvoicesPage = () => {
|
|||||||
inv.company?.name?.includes(searchTerm)
|
inv.company?.name?.includes(searchTerm)
|
||||||
);
|
);
|
||||||
|
|
||||||
const StatusBadge = ({ status }: { status: string }) => {
|
const StatusBadge = ({ invoice }: { invoice: any }) => {
|
||||||
|
const status = invoice.status;
|
||||||
const config: any = {
|
const config: any = {
|
||||||
approved: { color: 'text-emerald-700 bg-emerald-50 border-emerald-100', icon: CheckCircle2, label: 'تم التصديق' },
|
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: 'جاهز للإرسال' },
|
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: 'خطأ في التحقق' },
|
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 { 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 (
|
return (
|
||||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold border ${color} uppercase tracking-tight`}>
|
<span
|
||||||
|
title={errorTitle}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold border ${color} uppercase tracking-tight cursor-help`}
|
||||||
|
>
|
||||||
<Icon className="w-3 h-3" />
|
<Icon className="w-3 h-3" />
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
@@ -243,7 +252,7 @@ export const InvoicesPage = () => {
|
|||||||
<td className="px-6 py-4 font-mono font-bold text-slate-800 text-left">
|
<td className="px-6 py-4 font-mono font-bold text-slate-800 text-left">
|
||||||
{Number(inv.grand_total).toLocaleString('en-US', { minimumFractionDigits: 3 })}
|
{Number(inv.grand_total).toLocaleString('en-US', { minimumFractionDigits: 3 })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4"><StatusBadge status={inv.status} /></td>
|
<td className="px-6 py-4"><StatusBadge invoice={inv} /></td>
|
||||||
<td className="px-6 py-4 text-center relative">
|
<td className="px-6 py-4 text-center relative">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -437,6 +446,20 @@ export const InvoicesPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{viewingInvoice.validation_errors && viewingInvoice.validation_errors.length > 0 && (
|
||||||
|
<div className="px-8 py-4 bg-red-50 border-b border-red-100 flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-red-900 mb-1">فشل التحقق الضريبي:</h4>
|
||||||
|
<ul className="text-xs text-red-700 space-y-1 list-disc list-inside">
|
||||||
|
{viewingInvoice.validation_errors.map((err: string, i: number) => (
|
||||||
|
<li key={i}>{err}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto bg-slate-100 p-8 flex justify-center items-start">
|
<div className="flex-1 overflow-auto bg-slate-100 p-8 flex justify-center items-start">
|
||||||
<div className="bg-white shadow-2xl rounded-sm overflow-hidden max-w-full">
|
<div className="bg-white shadow-2xl rounded-sm overflow-hidden max-w-full">
|
||||||
<img
|
<img
|
||||||
@@ -471,7 +494,7 @@ export const InvoicesPage = () => {
|
|||||||
<div className="w-px h-10 bg-slate-200" />
|
<div className="w-px h-10 bg-slate-200" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] uppercase tracking-widest text-slate-400 font-bold mb-1">الحالة</p>
|
<p className="text-[10px] uppercase tracking-widest text-slate-400 font-bold mb-1">الحالة</p>
|
||||||
<StatusBadge status={viewingInvoice.status} />
|
<StatusBadge invoice={viewingInvoice} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user