From b4a226409c7c9cb5ed96515e1461bb88a18193fa Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 8 Jan 2026 21:48:37 +0000 Subject: [PATCH] feat(data-entry): Add unified receipt form with OCR confidence tracking New unified receipt creation system with: - UnifiedReceiptForm component with inline OCR preview and confidence indicators - Compact upload zone with drag-drop and camera support - TVA and Payment fields with dynamic add/remove - Supplier dual-field with autocomplete and OCR hint - Receipt form sections with collapsible auxiliary data Backend OCR improvements: - Add confidence_tva and confidence_payment to extraction results - Update TVA extraction to return confidence scores - Include TVA (15%) and payment (10%) in overall_confidence calculation Also includes: - CSS design system rules documentation - Port check helper function for service scripts - Expanded design tokens documentation in CLAUDE.md Co-Authored-By: Claude Opus 4.5 --- .claude/rules/css-design-system.md | 38 + .claude/settings.json | 2 +- CLAUDE.md | 32 +- backend/modules/data_entry/routers/ocr.py | 2 + backend/modules/data_entry/schemas/ocr.py | 2 + .../services/ocr/ocr_worker_process.py | 18 +- .../data_entry/services/ocr/profiles/base.py | 73 +- .../data_entry/services/ocr_extractor.py | 68 +- scripts/service-helpers.sh | 15 + .../components/receipts/AuxiliarySection.vue | 321 +++++ .../components/receipts/CompactUploadZone.vue | 511 ++++++++ .../receipts/PaymentCompactFields.vue | 193 +++ .../receipts/PaymentFixedFields.vue | 247 ++++ .../receipts/ReceiptFormSection.vue | 148 +++ .../components/receipts/SupplierDualField.vue | 384 ++++++ .../components/receipts/TvaCompactFields.vue | 303 +++++ .../components/receipts/TvaFixedFields.vue | 294 +++++ .../receipts/UnifiedReceiptForm.vue | 1115 +++++++++++++++++ .../data-entry/utils/receiptConversions.js | 282 +++++ .../receipts/ReceiptCreateUnifiedView.vue | 871 +++++++++++++ src/router/index.js | 12 +- 21 files changed, 4876 insertions(+), 55 deletions(-) create mode 100644 src/modules/data-entry/components/receipts/AuxiliarySection.vue create mode 100644 src/modules/data-entry/components/receipts/CompactUploadZone.vue create mode 100644 src/modules/data-entry/components/receipts/PaymentCompactFields.vue create mode 100644 src/modules/data-entry/components/receipts/PaymentFixedFields.vue create mode 100644 src/modules/data-entry/components/receipts/ReceiptFormSection.vue create mode 100644 src/modules/data-entry/components/receipts/SupplierDualField.vue create mode 100644 src/modules/data-entry/components/receipts/TvaCompactFields.vue create mode 100644 src/modules/data-entry/components/receipts/TvaFixedFields.vue create mode 100644 src/modules/data-entry/components/receipts/UnifiedReceiptForm.vue create mode 100644 src/modules/data-entry/utils/receiptConversions.js create mode 100644 src/modules/data-entry/views/receipts/ReceiptCreateUnifiedView.vue diff --git a/.claude/rules/css-design-system.md b/.claude/rules/css-design-system.md index 934cd14..3524e04 100644 --- a/.claude/rules/css-design-system.md +++ b/.claude/rules/css-design-system.md @@ -96,3 +96,41 @@ color: var(--red-600); /* Error text */ - Use hardcoded backgrounds like `#ffffff` or `white` (use `var(--surface-card)`) - Create scoped CSS for patterns that already exist in shared files - **Skip theme testing** - ALWAYS test cu toggle (auto/light/dark) + DevTools dark mode + +## PrimeVue Component Patterns + +### Dropdown în Forme Compacte +**OBLIGATORIU:** Folosește clasa `dropdown-borderless` pentru toate dropdown-urile în forme compacte/inline: + +```html + +``` + +Clasa este definită global (în `ReceiptCreateView.vue` non-scoped) și elimină border, background și box-shadow pentru un look minimalist. + +### Entry Chips / Inline Labels (Dark Mode) +Pentru elemente custom care afișează date inline (chips, tags, badges): + +```css +/* ✅ CORECT - folosește semantic tokens */ +.entry-chip { + background: var(--surface-hover); + color: var(--text-color); +} + +[data-theme="dark"] .entry-chip { + background: var(--surface-100); +} + +/* ❌ GREȘIT - culori hardcodate */ +.entry-chip { + background: #f1f5f9; /* NU! */ + color: #111827; /* NU! */ +} +``` + +### Reguli Dark Mode pentru Componente Custom +1. **Text:** Folosește `var(--text-color)` sau `var(--text-color-secondary)` - se adaptează automat +2. **Background:** Folosește `var(--surface-hover)` pentru hover/chips în light mode +3. **Dark override:** Folosește `[data-theme="dark"]` cu `var(--surface-100)` sau `var(--surface-200)` +4. **NU hardcoda:** Niciodată `#d1d5db`, `#f9fafb`, `#334155` direct în CSS diff --git a/.claude/settings.json b/.claude/settings.json index 67f17a4..1c4f1c1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,6 @@ { "statusLine": { "type": "command", - "command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.display_name // \"Unknown\"'); usage=$(echo \"$input\" | jq '.context_window.current_usage'); if [ \"$usage\" != \"null\" ]; then current=$(echo \"$usage\" | jq '.input_tokens + .cache_creation_input_tokens + .cache_read_input_tokens'); size=$(echo \"$input\" | jq '.context_window.context_window_size'); pct=$((current * 100 / size)); ctx=$(printf '%d%% ctx' \"$pct\"); else ctx='0% ctx'; fi; branch=$(git -c core.useBuiltinFSMonitor=false branch --show-current 2>/dev/null || echo 'no-git'); cwd=$(basename \"$(pwd)\"); printf '\\033[36m%s\\033[0m | \\033[33m%s\\033[0m | \\033[32m%s\\033[0m | \\033[35m%s\\033[0m' \"$model\" \"$ctx\" \"$branch\" \"$cwd\"" + "command": "/home/claude/.claude/statusline.sh" } } diff --git a/CLAUDE.md b/CLAUDE.md index a47b669..fd9aad0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,11 +35,24 @@ See `docs/ARCHITECTURE-DECISIONS.md` for: ### Starting Services ```bash -./start-prod.sh # Backend :8001 + Frontend :3000 +./start-prod.sh # Backend :8000 + Frontend :3000 (PROD) +./start-prod.sh stop # Stop all services ./ssh-tunnel-prod.sh # Oracle DB tunnel (REQUIRED on Linux) ./status.sh # Check services ``` +### Playwright Testing +```bash +# Pentru testare UI cu Playwright: +./start-test.sh # Pornește în mod TEST +./start-test.sh stop # Oprește serverele + +# Credențiale TEST: +# User: MARIUS M +# Pass: 123 +# Firma: MARIUSM AUTO +``` + ### Database - **Schema**: `CONTAFIN_ORACLE` - **Connection**: SSH tunnel required (Linux dev) @@ -49,10 +62,25 @@ See `docs/ARCHITECTURE-DECISIONS.md` for: ## Key Rules (ALWAYS FOLLOW) +### Git Commits +- **NU da commit automat!** Întreabă utilizatorul ÎNAINTE de a face commit +- Arată ce fișiere s-au modificat (`git status`) și propune mesajul de commit +- Așteaptă confirmarea explicită înainte de `git commit` + ### Frontend Development **Before writing CSS**: Read `docs/ONBOARDING_CSS.md` (5 min) -- Use design tokens: `var(--color-primary)` not `#2563eb` +#### CSS Design Tokens - OBLIGATORIU ⚠️ +- **CITEȘTE ÎNTÂI**: `docs/DESIGN_TOKENS.md` înainte de a scrie CSS +- Folosește **DOAR** design tokens, NICIODATĂ valori hardcodate: + - Spacing: `var(--space-xs)` (4px), `var(--space-sm)` (8px), `var(--space-md)` (16px) + - Font weight: `var(--font-medium)` (500), `var(--font-semibold)` (600), `var(--font-bold)` (700) + - Colors: `var(--green-50)`, `var(--blue-50)`, `var(--surface-card)`, etc. + - Radius: `var(--radius-sm)` (4px), `var(--radius-md)` (8px) +- ❌ GREȘIT: `padding: 8px`, `font-weight: 500`, `background: #f0fdf4` +- ✅ CORECT: `padding: var(--space-sm)`, `font-weight: var(--font-medium)`, `background: var(--green-50)` + +#### Alte reguli CSS - Use shared CSS from `src/assets/css/` - NEVER duplicate - Check `docs/CSS_PATTERNS.md` for existing patterns - **Theme toggle**: App has 3 modes (auto/light/dark) - test BOTH themes! diff --git a/backend/modules/data_entry/routers/ocr.py b/backend/modules/data_entry/routers/ocr.py index 4965182..933f3e1 100644 --- a/backend/modules/data_entry/routers/ocr.py +++ b/backend/modules/data_entry/routers/ocr.py @@ -618,6 +618,8 @@ def _dict_to_extraction_data(data: dict) -> ExtractionData: confidence_date=data.get('confidence_date', 0.0), confidence_vendor=data.get('confidence_vendor', 0.0), confidence_client=data.get('confidence_client', 0.0), + confidence_tva=data.get('confidence_tva', 0.0), + confidence_payment=data.get('confidence_payment', 0.0), overall_confidence=data.get('overall_confidence', 0.0), raw_text=data.get('raw_text', ''), raw_texts=data.get('raw_texts', []), diff --git a/backend/modules/data_entry/schemas/ocr.py b/backend/modules/data_entry/schemas/ocr.py index f5c80b3..352c514 100644 --- a/backend/modules/data_entry/schemas/ocr.py +++ b/backend/modules/data_entry/schemas/ocr.py @@ -60,6 +60,8 @@ class ExtractionData(BaseModel): confidence_date: float = Field(default=0.0, ge=0, le=1, description="Date extraction confidence") confidence_vendor: float = Field(default=0.0, ge=0, le=1, description="Vendor extraction confidence") confidence_client: float = Field(default=0.0, ge=0, le=1, description="Client extraction confidence") + confidence_tva: float = Field(default=0.0, ge=0, le=1, description="TVA extraction confidence") + confidence_payment: float = Field(default=0.0, ge=0, le=1, description="Payment extraction confidence") overall_confidence: float = Field(default=0.0, ge=0, le=1, description="Overall confidence score") raw_text: str = Field(default="", description="Raw OCR text (primary)") raw_texts: List[str] = Field(default=[], description="Raw OCR texts from all engine passes (for analysis)") diff --git a/backend/modules/data_entry/services/ocr/ocr_worker_process.py b/backend/modules/data_entry/services/ocr/ocr_worker_process.py index 3479996..e557d8a 100644 --- a/backend/modules/data_entry/services/ocr/ocr_worker_process.py +++ b/backend/modules/data_entry/services/ocr/ocr_worker_process.py @@ -901,12 +901,15 @@ def _merge_extractions(primary, secondary): # Always use primary (docTR) - higher quality OCR result.tva_entries = primary.tva_entries result.tva_total = primary.tva_total + result.confidence_tva = getattr(primary, 'confidence_tva', 0.0) elif primary.tva_entries: result.tva_entries = primary.tva_entries result.tva_total = primary.tva_total + result.confidence_tva = getattr(primary, 'confidence_tva', 0.0) elif secondary.tva_entries: result.tva_entries = secondary.tva_entries result.tva_total = secondary.tva_total + result.confidence_tva = getattr(secondary, 'confidence_tva', 0.0) # Other fields - prefer primary result.receipt_number = primary.receipt_number or secondary.receipt_number @@ -915,7 +918,13 @@ def _merge_extractions(primary, secondary): result.partner_name = primary.partner_name or secondary.partner_name result.address = primary.address or secondary.address result.items_count = primary.items_count or secondary.items_count - result.payment_methods = primary.payment_methods or secondary.payment_methods + # Payment methods with confidence + if primary.payment_methods: + result.payment_methods = primary.payment_methods + result.confidence_payment = getattr(primary, 'confidence_payment', 0.0) + elif secondary.payment_methods: + result.payment_methods = secondary.payment_methods + result.confidence_payment = getattr(secondary, 'confidence_payment', 0.0) result.suggested_payment_mode = getattr(primary, 'suggested_payment_mode', None) or getattr(secondary, 'suggested_payment_mode', None) # Client fields @@ -970,6 +979,11 @@ def _complement_extraction(primary, secondary): if not primary.tva_entries and secondary.tva_entries: primary.tva_entries = secondary.tva_entries primary.tva_total = secondary.tva_total + primary.confidence_tva = getattr(secondary, 'confidence_tva', 0.0) + + if not getattr(primary, 'payment_methods', None) and getattr(secondary, 'payment_methods', None): + primary.payment_methods = secondary.payment_methods + primary.confidence_payment = getattr(secondary, 'confidence_payment', 0.0) if not primary.receipt_number and secondary.receipt_number: primary.receipt_number = secondary.receipt_number @@ -1024,6 +1038,8 @@ def _extraction_to_dict(extraction) -> dict: "confidence_date": extraction.confidence_date, "confidence_vendor": extraction.confidence_vendor, "confidence_client": getattr(extraction, 'confidence_client', 0.0), + "confidence_tva": getattr(extraction, 'confidence_tva', 0.0), + "confidence_payment": getattr(extraction, 'confidence_payment', 0.0), "overall_confidence": extraction.overall_confidence, "raw_text": extraction.raw_text, "ocr_engine": extraction.ocr_engine, diff --git a/backend/modules/data_entry/services/ocr/profiles/base.py b/backend/modules/data_entry/services/ocr/profiles/base.py index ee86c86..69ed9c2 100644 --- a/backend/modules/data_entry/services/ocr/profiles/base.py +++ b/backend/modules/data_entry/services/ocr/profiles/base.py @@ -13,8 +13,9 @@ Usage: CUI_LIST = ["22891860"] NAME_PATTERNS = ["LIDL", "LDL"] - def extract_tva_entries(self, text: str) -> List[dict]: + def extract_tva_entries(self, text: str) -> Tuple[List[dict], float]: # Custom Lidl TVA extraction logic + # Returns (entries_list, confidence_score) ... """ @@ -331,7 +332,7 @@ class BaseStoreProfile(ABC): # Extraction methods - override in subclasses as needed # ------------------------------------------------------------------------- - def extract_tva_entries(self, text: str) -> List[dict]: + def extract_tva_entries(self, text: str) -> Tuple[List[dict], float]: """ Extract TVA entries from receipt text - GENERIC implementation. @@ -346,37 +347,53 @@ class BaseStoreProfile(ABC): text: Raw OCR text from receipt Returns: - List of dicts with keys: code, percent, amount + Tuple of (List of dicts with keys: code, percent, amount, confidence float) """ entries = [] + max_confidence = 0.0 text_upper = text.upper() # Step 1: Check for known non-VAT payer (by class flag or text detection) if self.IS_NON_VAT_PAYER or self._is_non_vat_payer(text_upper): - return [] # No TVA entries for non-VAT payers + return ([], 0.0) # No TVA entries for non-VAT payers # Step 2: Normalize OCR spaces in numbers normalized = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text_upper) lines = normalized.split('\n') - # Step 3: Try all formats, collect candidates + # Step 3: Try all formats, collect candidates with confidence candidates = [] # Try inline multi-rate (Lidl-style) - candidates.extend(self._try_tva_inline(normalized)) + inline_entries, inline_conf = self._try_tva_inline(normalized) + candidates.extend(inline_entries) + if inline_conf > max_confidence: + max_confidence = inline_conf # Try reversed format (Stepout-style) - candidates.extend(self._try_tva_reversed(normalized, lines)) + reversed_entries, reversed_conf = self._try_tva_reversed(normalized, lines) + candidates.extend(reversed_entries) + if reversed_conf > max_confidence: + max_confidence = reversed_conf # Try multiline format (Brick/Electrobering) - candidates.extend(self._try_tva_multiline(normalized, lines)) + multiline_entries, multiline_conf = self._try_tva_multiline(normalized, lines) + candidates.extend(multiline_entries) + if multiline_conf > max_confidence: + max_confidence = multiline_conf # Try table format (OMV-style) - candidates.extend(self._try_tva_table(normalized)) + table_entries, table_conf = self._try_tva_table(normalized) + candidates.extend(table_entries) + if table_conf > max_confidence: + max_confidence = table_conf # Try standard/fallback patterns if not candidates: - candidates.extend(self._try_tva_standard(normalized)) + standard_entries, standard_conf = self._try_tva_standard(normalized) + candidates.extend(standard_entries) + if standard_conf > max_confidence: + max_confidence = standard_conf # Step 4: Deduplicate and return seen = set() @@ -386,7 +403,7 @@ class BaseStoreProfile(ABC): entries.append(entry) seen.add(key) - return entries + return (entries, max_confidence if entries else 0.0) def _is_non_vat_payer(self, text: str) -> bool: """Check if receipt is from non-VAT payer.""" @@ -395,9 +412,10 @@ class BaseStoreProfile(ABC): return True return False - def _try_tva_inline(self, text: str) -> List[dict]: + def _try_tva_inline(self, text: str) -> Tuple[List[dict], float]: """Try Lidl-style inline format: 'TVA A 21,00% 7.71'""" entries = [] + max_confidence = 0.0 # Pattern: "TVA A 21,00% 7.71" or "TVA B 11,00% 2.13" for pattern, confidence, fmt in self.TVA_PATTERNS: if fmt != 'inline': @@ -415,13 +433,16 @@ class BaseStoreProfile(ABC): 'percent': percent, 'amount': amount }) + if confidence > max_confidence: + max_confidence = confidence except (ValueError, InvalidOperation, IndexError): continue - return entries + return (entries, max_confidence) - def _try_tva_reversed(self, text: str, lines: List[str]) -> List[dict]: + def _try_tva_reversed(self, text: str, lines: List[str]) -> Tuple[List[dict], float]: """Try Stepout-style reversed format: '5.00% TUA*B 2.00' (rate BEFORE TVA marker)""" entries = [] + confidence = 0.97 # Default confidence for reversed format # Pattern: "5.00% TUA*B 2.00" - procent BEFORE TVA, amount same line or next for i, line in enumerate(lines): # Try pattern with amount on SAME line: "5.00% TUA*B 2.00" @@ -462,11 +483,12 @@ class BaseStoreProfile(ABC): }) except (ValueError, InvalidOperation, IndexError): continue - return entries + return (entries, confidence if entries else 0.0) - def _try_tva_multiline(self, text: str, lines: List[str]) -> List[dict]: + def _try_tva_multiline(self, text: str, lines: List[str]) -> Tuple[List[dict], float]: """Try multiline format: 'TOTAL TVA A - 19%' + amount on next line""" entries = [] + confidence = 0.95 # Default confidence for multiline format # Pattern: "TOTAL TVA A - 19%" or "TOTAL TVA A 19%" on one line, amount on next multiline_patterns = [ r'TOTAL\s+TVA\s*([A-D])\s*[-\s]+(\d{1,2})\s*%', @@ -491,14 +513,15 @@ class BaseStoreProfile(ABC): 'percent': percent, 'amount': amount }) - return entries + return (entries, confidence) except (ValueError, InvalidOperation, IndexError): continue - return entries + return (entries, 0.0) - def _try_tva_table(self, text: str) -> List[dict]: + def _try_tva_table(self, text: str) -> Tuple[List[dict], float]: """Try OMV-style table format: 'A-21,00% 285,66 49,58'""" entries = [] + confidence = 0.96 # Default confidence for table format # Pattern: "A-21,00% 285,66 49,58" (code-percent base_amount tva_amount) table_pattern = r'([A-D])\s*[-:]\s*(\d{1,2})[.,\s]*\d{0,2}\s*%\s+([\d.,\s]+)\s+([\d.,\s]+)' for match in re.finditer(table_pattern, text, re.IGNORECASE): @@ -530,13 +553,15 @@ class BaseStoreProfile(ABC): 'percent': 19, # Default rate 'amount': amount }) + confidence = 0.90 # Lower confidence for fallback except (ValueError, InvalidOperation): pass - return entries + return (entries, confidence if entries else 0.0) - def _try_tva_standard(self, text: str) -> List[dict]: + def _try_tva_standard(self, text: str) -> Tuple[List[dict], float]: """Try standard TVA patterns as fallback""" entries = [] + matched_confidence = 0.0 standard_fmts = ['standard', 'bon', 'percent', 'coded', 'fallback', 'books'] for pattern, confidence, fmt in self.TVA_PATTERNS: if fmt not in standard_fmts: @@ -563,7 +588,7 @@ class BaseStoreProfile(ABC): 'percent': percent, 'amount': amount }) - return entries + return (entries, confidence) elif len(groups) == 1: # Just amount amount = self._parse_decimal(self._clean_ocr_number(groups[0])) @@ -573,10 +598,10 @@ class BaseStoreProfile(ABC): 'percent': 19, 'amount': amount }) - return entries + return (entries, confidence) except (ValueError, InvalidOperation, IndexError): continue - return entries + return (entries, matched_confidence) def _clean_ocr_number(self, value: str) -> str: """Remove OCR spaces from numbers (e.g., '55, 22' -> '55,22').""" diff --git a/backend/modules/data_entry/services/ocr_extractor.py b/backend/modules/data_entry/services/ocr_extractor.py index 97a8f60..46bda79 100644 --- a/backend/modules/data_entry/services/ocr_extractor.py +++ b/backend/modules/data_entry/services/ocr_extractor.py @@ -38,6 +38,8 @@ class ExtractionResult: confidence_date: float = 0.0 confidence_vendor: float = 0.0 confidence_client: float = 0.0 + confidence_tva: float = 0.0 + confidence_payment: float = 0.0 raw_text: str = "" ocr_engine: str = "" # OCR engine used: paddleocr or tesseract processing_time_ms: int = 0 # Processing time in milliseconds @@ -51,12 +53,20 @@ class ExtractionResult: @property def overall_confidence(self) -> float: - """Calculate weighted overall confidence score.""" - weights = {'amount': 0.4, 'date': 0.3, 'vendor': 0.3} + """Calculate weighted overall confidence score including TVA and payment.""" + weights = { + 'amount': 0.35, # Most important - receipt total + 'date': 0.20, # Receipt date + 'vendor': 0.20, # Vendor identification + 'tva': 0.15, # TVA extraction accuracy + 'payment': 0.10 # Payment method detection + } return round( self.confidence_amount * weights['amount'] + self.confidence_date * weights['date'] + - self.confidence_vendor * weights['vendor'], + self.confidence_vendor * weights['vendor'] + + self.confidence_tva * weights['tva'] + + self.confidence_payment * weights['payment'], 2 ) @@ -468,9 +478,15 @@ class ReceiptExtractor: result.amount, result.confidence_amount = store_profile.extract_total(text_upper) result.receipt_date, result.confidence_date = store_profile.extract_date(text_upper) result.receipt_number, _ = store_profile.extract_receipt_number(text_upper) - result.tva_entries = store_profile.extract_tva_entries(text_upper) - result.tva_total = sum(e['amount'] for e in result.tva_entries) if result.tva_entries else None + result.tva_entries, result.confidence_tva = store_profile.extract_tva_entries(text_upper) + result.tva_total = sum((e['amount'] for e in result.tva_entries), Decimal(0)) if result.tva_entries else None result.payment_methods = store_profile.extract_payment_methods(text_upper) + # Calculate payment confidence from individual payment method confidences + if result.payment_methods: + payment_confidences = [pm.get('confidence', 0.0) for pm in result.payment_methods] + result.confidence_payment = max(payment_confidences) if payment_confidences else 0.0 + else: + result.confidence_payment = 0.0 # Client data extraction via profile (CUI + name) profile_client_cui, cui_confidence = store_profile.extract_client_cui(text_upper) @@ -502,8 +518,8 @@ class ReceiptExtractor: result.amount, result.confidence_amount = self._extract_amount(text_upper) result.receipt_date, result.confidence_date = self._extract_date(text_upper) result.receipt_number, _ = self._extract_number(text_upper) - result.tva_entries, result.tva_total = self._extract_tva_entries(text_upper) - result.payment_methods = self._extract_payment_methods(text_upper) + result.tva_entries, result.tva_total, result.confidence_tva = self._extract_tva_entries(text_upper) + result.payment_methods, result.confidence_payment = self._extract_payment_methods(text_upper) # Generic client extraction client_name, client_cui, client_address, confidence = self._extract_client_data(text_upper, text) @@ -1289,7 +1305,7 @@ class ReceiptExtractor: self, candidates: List[tuple], tva_bon_total: Optional[Decimal] - ) -> Tuple[List[dict], Optional[Decimal]]: + ) -> Tuple[List[dict], Optional[Decimal], float]: """ Select the best TVA candidate from collected candidates. @@ -1303,10 +1319,10 @@ class ReceiptExtractor: tva_bon_total: Authoritative TOTAL TVA BON value (if extracted) Returns: - (best_entries, best_sum) + (best_entries, best_sum, confidence) """ if not candidates: - return [], None + return [], None, 0.0 # Score each candidate scored = [] @@ -1334,9 +1350,9 @@ class ReceiptExtractor: best = scored[0] print(f"[TVA Select] Winner: {best[1]} (score={best[0]:.1f})", flush=True) - return best[3], best[4] + return best[3], best[4], best[2] # entries, sum, confidence - def _extract_tva_entries(self, text: str) -> Tuple[List[dict], Optional[Decimal]]: + def _extract_tva_entries(self, text: str) -> Tuple[List[dict], Optional[Decimal], float]: """ Extract multiple TVA (VAT) entries from text. Romanian receipts can have multiple TVA rates (A=19%, B=9%, C=5%, D=0%). @@ -1345,11 +1361,12 @@ class ReceiptExtractor: - Try ALL patterns and collect candidates - Select best candidate based on matching TOTAL TVA BON - Returns (tva_entries, tva_total) where tva_entries is a list of: + Returns (tva_entries, tva_total, confidence) where tva_entries is a list of: {'code': 'A', 'percent': 19, 'amount': Decimal('15.20')} """ tva_entries = [] seen_entries = set() # To avoid duplicates + confidence = 0.0 # Track extraction confidence # Check for non-VAT payer (NEPLATITOR DE TVA) - TVA = 0 # OCR variants: NEPLATTOR, NEPLATITOR, NEPLATOR, NEPLATTOR, ANEPLATHTOR, MEPLATITOR, etc. @@ -1366,8 +1383,8 @@ class ReceiptExtractor: ] for pattern in non_vat_patterns: if re.search(pattern, text, re.IGNORECASE): - # Non-VAT payer - return TVA = 0 - return [{'code': 'D', 'percent': 0, 'amount': Decimal('0.00')}], Decimal('0.00') + # Non-VAT payer - return TVA = 0, high confidence + return [{'code': 'D', 'percent': 0, 'amount': Decimal('0.00')}], Decimal('0.00'), 0.95 # Normalize spaces in numbers first (OCR may produce "32. 31" or "49, 58") normalized_text = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text) @@ -1717,7 +1734,7 @@ class ReceiptExtractor: # === CANDIDATE SELECTION === # Select best candidate using TOTAL TVA BON as authoritative reference if all_candidates: - best_entries, best_sum = self._select_best_tva_candidate(all_candidates, tva_bon_total) + best_entries, best_sum, confidence = self._select_best_tva_candidate(all_candidates, tva_bon_total) if best_entries: tva_entries = best_entries entries_sum = best_sum @@ -1725,7 +1742,7 @@ class ReceiptExtractor: # Calculate sum from entries (if not set by candidate selection) entries_sum = None if tva_entries: - entries_sum = sum(entry['amount'] for entry in tva_entries) + entries_sum = sum((entry['amount'] for entry in tva_entries), Decimal(0)) # Validate and correct TVA values tva_entries, tva_total = self._validate_and_correct_tva( @@ -1735,7 +1752,7 @@ class ReceiptExtractor: # Sort by code (A, B, C, D) tva_entries.sort(key=lambda x: x.get('code', 'Z')) - return tva_entries, tva_total + return tva_entries, tva_total, confidence if tva_entries else 0.0 def _get_tva_code_from_percent(self, percent: int) -> str: """Map TVA percentage to standard Romanian code. @@ -1843,7 +1860,7 @@ class ReceiptExtractor: tva_entries = corrected_entries # Recalculate sum after corrections - entries_sum = sum(entry['amount'] for entry in tva_entries) if tva_entries else None + entries_sum = sum((entry['amount'] for entry in tva_entries), Decimal(0)) if tva_entries else None # Validate sum against TOTAL TVA BON if tva_bon_total and entries_sum: @@ -1876,7 +1893,7 @@ class ReceiptExtractor: seen.add(key) unique_entries.append(entry) tva_entries = unique_entries - entries_sum = sum(e['amount'] for e in tva_entries) + entries_sum = sum((e['amount'] for e in tva_entries), Decimal(0)) # Final total tva_total = entries_sum if entries_sum else tva_bon_total @@ -2032,15 +2049,16 @@ class ReceiptExtractor: return None - def _extract_payment_methods(self, text: str) -> List[dict]: + def _extract_payment_methods(self, text: str) -> Tuple[List[dict], float]: """ Extract payment methods (CARD/NUMERAR) from receipt. These appear after TOTAL LEI and before TOTAL TVA section. - Returns list of: {'method': 'CARD'/'NUMERAR', 'amount': Decimal} + Returns tuple of: (list of {'method': 'CARD'/'NUMERAR', 'amount': Decimal}, confidence) """ payment_methods = [] seen_methods = set() + max_confidence = 0.0 # Normalize spaces in numbers normalized_text = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text) @@ -2071,13 +2089,15 @@ class ReceiptExtractor: 'amount': amount }) seen_methods.add(method) - print(f"[Payment] Found {method}: {amount} (pattern matched)", flush=True) + if confidence > max_confidence: + max_confidence = confidence + print(f"[Payment] Found {method}: {amount} (pattern matched, conf={confidence})", flush=True) elif amount >= self.MAX_REASONABLE_PAYMENT: print(f"[Payment] Rejected unreasonable amount {amount} for {method} (likely OCR error)", flush=True) except (InvalidOperation, ValueError): continue - return payment_methods + return payment_methods, max_confidence if payment_methods else 0.0 def _validate_payment_methods( self, payment_methods: List[dict], total: Optional[Decimal] diff --git a/scripts/service-helpers.sh b/scripts/service-helpers.sh index 48c418f..91f5a66 100644 --- a/scripts/service-helpers.sh +++ b/scripts/service-helpers.sh @@ -134,6 +134,21 @@ check_port_available() { fi } +# Check if port is in use (opposite of check_port_available) +# Usage: check_port_in_use +# Returns 0 if port IS IN USE, 1 if FREE +check_port_in_use() { + local port=$1 + + if netstat -tuln 2>/dev/null | grep -q ":${port} " || \ + ss -tuln 2>/dev/null | grep -q ":${port} " || \ + lsof -ti:${port} &>/dev/null; then + return 0 # Port is in use + else + return 1 # Port is free + fi +} + # Display a nice header # Usage: print_header "Title" print_header() { diff --git a/src/modules/data-entry/components/receipts/AuxiliarySection.vue b/src/modules/data-entry/components/receipts/AuxiliarySection.vue new file mode 100644 index 0000000..56df7c5 --- /dev/null +++ b/src/modules/data-entry/components/receipts/AuxiliarySection.vue @@ -0,0 +1,321 @@ +