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 @@ + + + + + Tip Cheltuiala + + + + + + Descriere + + + + + + + Atasamente + + + + + + + + + + + {{ truncateFilename(att.filename) }} + + + + + + + + + + + + {{ truncateFilename(file.name) }} + ({{ formatFileSize(file.size) }}) + + + + + + + + + + + Niciun atasament + + + + + + + + diff --git a/src/modules/data-entry/components/receipts/CompactUploadZone.vue b/src/modules/data-entry/components/receipts/CompactUploadZone.vue new file mode 100644 index 0000000..508a184 --- /dev/null +++ b/src/modules/data-entry/components/receipts/CompactUploadZone.vue @@ -0,0 +1,511 @@ + + + + + + + + + {{ processingMessage }} + {{ processingSubtext }} + + + + + + {{ selectedFile.name }} + ({{ formatFileSize(selectedFile.size) }}) + + + + + + Elibereaza pentru a incarca + Trage poza bonului aici sau click pentru a selecta + + + + + + + + + + + + + {{ error }} + + + + + + + diff --git a/src/modules/data-entry/components/receipts/PaymentCompactFields.vue b/src/modules/data-entry/components/receipts/PaymentCompactFields.vue new file mode 100644 index 0000000..a952379 --- /dev/null +++ b/src/modules/data-entry/components/receipts/PaymentCompactFields.vue @@ -0,0 +1,193 @@ + + + + + + + Card + + + + + + Cash + + + + + + Alte + + + + + + + + Plati ({{ formatAmount(computedTotal) }}) ≠ Total ({{ formatAmount(total) }}) + + + + + + + + + + diff --git a/src/modules/data-entry/components/receipts/PaymentFixedFields.vue b/src/modules/data-entry/components/receipts/PaymentFixedFields.vue new file mode 100644 index 0000000..b6df7f2 --- /dev/null +++ b/src/modules/data-entry/components/receipts/PaymentFixedFields.vue @@ -0,0 +1,247 @@ + + + + + + + + CARD + + + + + + + + + NUMERAR + + + + + + + + + + ALTE + + + + + + + + + + + + + Suma plati ({{ formatAmount(computedTotal) }}) {{ computedTotal > total ? '>' : '<' }} Total ({{ formatAmount(total) }}) + + + + + + + + + diff --git a/src/modules/data-entry/components/receipts/ReceiptFormSection.vue b/src/modules/data-entry/components/receipts/ReceiptFormSection.vue new file mode 100644 index 0000000..e3015e8 --- /dev/null +++ b/src/modules/data-entry/components/receipts/ReceiptFormSection.vue @@ -0,0 +1,148 @@ + + + + {{ title }} + + + + + + + + + + + + + + diff --git a/src/modules/data-entry/components/receipts/SupplierDualField.vue b/src/modules/data-entry/components/receipts/SupplierDualField.vue new file mode 100644 index 0000000..da4c018 --- /dev/null +++ b/src/modules/data-entry/components/receipts/SupplierDualField.vue @@ -0,0 +1,384 @@ + + + + + + + Din OCR + + + + {{ ocrName }} + CUI: {{ ocrCui }} + + + Procesati o imagine pentru extragere automata + + + + + + + + Validat DB + + + + + + + + CUI + + + + + + + + + Validat ({{ supplierSource }}) + + + + Negasit - se va crea automat + + + + + + + + + + + {{ showAddress ? 'Ascunde adresa' : localAddress }} + + + + + + + + + + + diff --git a/src/modules/data-entry/components/receipts/TvaCompactFields.vue b/src/modules/data-entry/components/receipts/TvaCompactFields.vue new file mode 100644 index 0000000..cf1aa04 --- /dev/null +++ b/src/modules/data-entry/components/receipts/TvaCompactFields.vue @@ -0,0 +1,303 @@ + + + + TVA + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/data-entry/components/receipts/TvaFixedFields.vue b/src/modules/data-entry/components/receipts/TvaFixedFields.vue new file mode 100644 index 0000000..f769c0a --- /dev/null +++ b/src/modules/data-entry/components/receipts/TvaFixedFields.vue @@ -0,0 +1,294 @@ + + + + + + + A + + + + + + + + + B + + + + + + + + + C + + + + + + + + + D + + + + + + + + + E + + + + + + + + TOTAL TVA + {{ formatAmount(computedTotal) }} LEI + + + + + + + + diff --git a/src/modules/data-entry/components/receipts/UnifiedReceiptForm.vue b/src/modules/data-entry/components/receipts/UnifiedReceiptForm.vue new file mode 100644 index 0000000..79386ca --- /dev/null +++ b/src/modules/data-entry/components/receipts/UnifiedReceiptForm.vue @@ -0,0 +1,1115 @@ + + + + + + + + + + Furnizor + + + + + + + + + + + + + + + OCR: + {{ ocrData.partner_name }} + ({{ ocrData.cui }}) + + + + + Client: + {{ ocrData.client_name || '—' }} + ({{ ocrData.client_cui }}) + + + + + + + Nr. + + Data + + + + + + + TOTAL + + + + + │ + + + + + Card + + + + Cash + + + + Alte + + + + + + + + + + + Plati ({{ formatAmount(paymentTotal) }}) ≠ Total ({{ formatAmount(form.amount) }}) + + + + + + + + + + + + + + + + + + Atasamente + + + + {{ truncateFilename(att.filename) }} + + + + + {{ truncateFilename(file.name) }} + + + + + + + + + + + + + {{ ocrData.ocr_engine }} + {{ formatTime(ocrData.processing_time_ms) }} + + {{ ocrData.raw_text }} + + + + + + + + + + diff --git a/src/modules/data-entry/utils/receiptConversions.js b/src/modules/data-entry/utils/receiptConversions.js new file mode 100644 index 0000000..16b49ad --- /dev/null +++ b/src/modules/data-entry/utils/receiptConversions.js @@ -0,0 +1,282 @@ +/** + * Conversion utilities for Receipt form data + * + * These functions convert between the API format (dynamic arrays) and + * the UI format (fixed fields for TVA and payments). + */ + +/** + * Default fixed TVA structure for UI + */ +export const getDefaultTva = () => ({ + A: { percent: 19, amount: 0 }, + B: { percent: 9, amount: 0 }, + C: { percent: 5, amount: 0 }, + D: { percent: 0, amount: 0 }, + E: { percent: null, amount: 0 }, +}) + +/** + * Default fixed payments structure for UI + */ +export const getDefaultPayments = () => ({ + CARD: 0, + NUMERAR: 0, + ALTE: { amount: 0, type: null }, +}) + +/** + * Convert API tva_entries/tva_breakdown array → UI fixed fields + * + * @param {Array} entries - Array of {code, percent, amount} + * @returns {Object} Fixed TVA fields {A, B, C, D, E} + */ +export const apiToUiTva = (entries) => { + const ui = getDefaultTva() + + if (!entries || !Array.isArray(entries)) { + return ui + } + + entries.forEach(entry => { + const code = entry.code?.toUpperCase() + if (code && ui[code]) { + ui[code] = { + percent: entry.percent ?? ui[code].percent, + amount: parseFloat(entry.amount) || 0 + } + } + }) + + return ui +} + +/** + * Convert UI fixed TVA fields → API tva_breakdown array + * Only includes entries with amount > 0 + * + * @param {Object} tvaUi - Fixed TVA fields {A, B, C, D, E} + * @returns {Array} Array of {code, percent, amount} or null if empty + */ +export const uiToApiTva = (tvaUi) => { + if (!tvaUi) return null + + const entries = Object.entries(tvaUi) + .filter(([_, v]) => v.amount && v.amount > 0) + .map(([code, v]) => ({ + code, + percent: v.percent ?? 0, + amount: parseFloat(v.amount) || 0 + })) + + return entries.length > 0 ? entries : null +} + +/** + * Calculate total TVA from UI fixed fields + * + * @param {Object} tvaUi - Fixed TVA fields {A, B, C, D, E} + * @returns {number} Total TVA amount + */ +export const calculateTvaTotal = (tvaUi) => { + if (!tvaUi) return 0 + + return Object.values(tvaUi) + .reduce((sum, v) => sum + (parseFloat(v.amount) || 0), 0) +} + +/** + * Convert API payment_methods array → UI fixed fields + * + * @param {Array} methods - Array of {method, amount} + * @returns {Object} Fixed payment fields {CARD, NUMERAR, ALTE} + */ +export const apiToUiPayments = (methods) => { + const ui = getDefaultPayments() + + if (!methods || !Array.isArray(methods)) { + return ui + } + + methods.forEach(pm => { + const method = pm.method?.toUpperCase() + const amount = parseFloat(pm.amount) || 0 + + if (method === 'CARD') { + ui.CARD = amount + } else if (method === 'NUMERAR' || method === 'CASH') { + ui.NUMERAR = amount + } else if (method) { + // Other payment types go to ALTE + ui.ALTE.amount += amount + // Try to determine type from method name + if (method.includes('TICH') || method.includes('MASA')) { + ui.ALTE.type = 'tichete_masa' + } else if (method.includes('VOUCHER')) { + ui.ALTE.type = 'voucher' + } else if (method.includes('CREDIT')) { + ui.ALTE.type = 'credit_magazin' + } + } + }) + + return ui +} + +/** + * Convert UI fixed payment fields → API payment_methods array + * Only includes entries with amount > 0 + * + * @param {Object} paymentsUi - Fixed payment fields {CARD, NUMERAR, ALTE} + * @returns {Array} Array of {method, amount} or null if empty + */ +export const uiToApiPayments = (paymentsUi) => { + if (!paymentsUi) return null + + const methods = [] + + if (paymentsUi.CARD && paymentsUi.CARD > 0) { + methods.push({ method: 'CARD', amount: paymentsUi.CARD }) + } + + if (paymentsUi.NUMERAR && paymentsUi.NUMERAR > 0) { + methods.push({ method: 'NUMERAR', amount: paymentsUi.NUMERAR }) + } + + if (paymentsUi.ALTE?.amount && paymentsUi.ALTE.amount > 0) { + // Map type to method name + let methodName = 'ALTE' + if (paymentsUi.ALTE.type === 'tichete_masa') { + methodName = 'TICHETE_MASA' + } else if (paymentsUi.ALTE.type === 'voucher') { + methodName = 'VOUCHER' + } else if (paymentsUi.ALTE.type === 'credit_magazin') { + methodName = 'CREDIT_MAGAZIN' + } + methods.push({ method: methodName, amount: paymentsUi.ALTE.amount }) + } + + return methods.length > 0 ? methods : null +} + +/** + * Calculate total payments from UI fixed fields + * + * @param {Object} paymentsUi - Fixed payment fields {CARD, NUMERAR, ALTE} + * @returns {number} Total payment amount + */ +export const calculatePaymentsTotal = (paymentsUi) => { + if (!paymentsUi) return 0 + + return ( + (paymentsUi.CARD || 0) + + (paymentsUi.NUMERAR || 0) + + (paymentsUi.ALTE?.amount || 0) + ) +} + +/** + * Get default form state for unified receipt form + */ +export const getDefaultUnifiedFormState = () => ({ + receipt_type: 'bon_fiscal', + receipt_date: new Date(), + receipt_number: '', + + // Supplier (DB validated) + partner_name: null, + cui: '', + vendor_address: '', + + // Total + amount: null, + + // TVA (5 fixed fields) + tva: getDefaultTva(), + + // Payments (3 fixed fields) + payments: getDefaultPayments(), + + // Auxiliary + expense_type_code: null, + description: '', + + // Metadata + ocr_raw_text: '', + items_count: null, + company_id: null, +}) + +/** + * Convert legacy form state to unified form state + * + * @param {Object} legacyForm - Old form format + * @returns {Object} Unified form format + */ +export const legacyToUnifiedForm = (legacyForm) => { + return { + receipt_type: legacyForm.receipt_type || 'bon_fiscal', + receipt_date: legacyForm.receipt_date instanceof Date + ? legacyForm.receipt_date + : new Date(legacyForm.receipt_date), + receipt_number: legacyForm.receipt_number || '', + + partner_name: legacyForm.partner_name || null, + cui: legacyForm.cui || '', + vendor_address: legacyForm.vendor_address || '', + + amount: parseFloat(legacyForm.amount) || null, + + tva: apiToUiTva(legacyForm.tva_breakdown), + payments: apiToUiPayments(legacyForm.payment_methods), + + expense_type_code: legacyForm.expense_type_code || null, + description: legacyForm.description || '', + + ocr_raw_text: legacyForm.ocr_raw_text || '', + items_count: legacyForm.items_count || null, + company_id: legacyForm.company_id || null, + } +} + +/** + * Convert unified form state to API payload + * + * @param {Object} unifiedForm - Unified form format + * @returns {Object} API payload format + */ +export const unifiedFormToApiPayload = (unifiedForm) => { + return { + receipt_type: unifiedForm.receipt_type, + direction: 'cheltuiala', // Always expense (no more income) + receipt_date: unifiedForm.receipt_date instanceof Date + ? unifiedForm.receipt_date.toISOString().split('T')[0] + : unifiedForm.receipt_date, + receipt_number: unifiedForm.receipt_number || null, + + partner_name: typeof unifiedForm.partner_name === 'string' + ? unifiedForm.partner_name + : unifiedForm.partner_name?.name || null, + cui: unifiedForm.cui || null, + vendor_address: unifiedForm.vendor_address || null, + + amount: unifiedForm.amount || 0, + + tva_breakdown: uiToApiTva(unifiedForm.tva), + tva_total: calculateTvaTotal(unifiedForm.tva) || null, + payment_methods: uiToApiPayments(unifiedForm.payments), + + expense_type_code: unifiedForm.expense_type_code || null, + description: unifiedForm.description || null, + + ocr_raw_text: unifiedForm.ocr_raw_text || null, + items_count: unifiedForm.items_count || null, + company_id: unifiedForm.company_id, + + // Legacy fields (removed but kept for backwards compat) + payment_mode: null, + cash_register_id: null, + cash_register_name: null, + cash_register_account: null, + } +} diff --git a/src/modules/data-entry/views/receipts/ReceiptCreateUnifiedView.vue b/src/modules/data-entry/views/receipts/ReceiptCreateUnifiedView.vue new file mode 100644 index 0000000..413d781 --- /dev/null +++ b/src/modules/data-entry/views/receipts/ReceiptCreateUnifiedView.vue @@ -0,0 +1,871 @@ + + + + + + + + + {{ modeTitle }} + + + + + + + + + + + + + + + + + + + + + + + Motiv respingere: {{ receipt.rejection_reason }} + + Respins de {{ receipt.reviewed_by }} la {{ formatDateTime(receipt.reviewed_at) }} + + + + + + + + + {{ error }} + + + + + + + Introduceti motivul respingerii: + + + + + + + + + + + + Furnizorul nu a fost gasit. Creati un furnizor local? + + Nume + + + + CUI + + + + Adresa + + + + + + + + + + + + + + diff --git a/src/router/index.js b/src/router/index.js index d54099d..2d8ea84 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -70,19 +70,25 @@ const routes = [ { path: 'create', name: 'ReceiptCreate', - component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'), + component: () => import('@data-entry/views/receipts/ReceiptCreateUnifiedView.vue'), meta: { requiresAuth: true, title: 'Bon Nou - ROA2WEB' } }, + { + path: 'create-old', + name: 'ReceiptCreateOld', + component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'), + meta: { requiresAuth: true, title: 'Bon Nou (Legacy) - ROA2WEB' } + }, { path: ':id', name: 'ReceiptDetail', - component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'), + component: () => import('@data-entry/views/receipts/ReceiptCreateUnifiedView.vue'), meta: { requiresAuth: true, title: 'Detalii Bon - ROA2WEB' } }, { path: ':id/edit', name: 'ReceiptEdit', - component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'), + component: () => import('@data-entry/views/receipts/ReceiptCreateUnifiedView.vue'), meta: { requiresAuth: true, title: 'Editare Bon - ROA2WEB' } }, {
{{ ocrData.raw_text }}
Introduceti motivul respingerii:
Furnizorul nu a fost gasit. Creati un furnizor local?