feat(data-entry): Bulk Receipt Upload cu Mobile UX Android Nativ
## Funcționalități Principale ### Bulk Upload & Processing - Drag & drop pentru upload bonuri multiple oriunde pe pagină - Batch processing cu job queue și worker pool - Real-time updates via SSE (Server-Sent Events) cu fallback polling - Duplicate detection via SHA-256 file hash - Auto-retry pentru job-uri failed - Cancel individual jobs sau batch complet ### Mobile UX - Android Native Style - Top bar fixă cu hamburger, titlu centrat, acțiuni (search/filter) - Bottom navigation cu 4 tab-uri (Bonuri, Upload, Rapoarte, Setări) - FAB (Floating Action Button) cu hide/show on scroll - Filter chips orizontal scrollabile - Selecție multiplă prin long-press (500ms) - Select All + Bulk Delete cu confirmare - Layout Android pentru Create/Edit/View bon (Gmail compose style) ### Bug Fixes - Refresh individual via SSE în loc de refresh total pagină - Bonurile cu eroare OCR rămân vizibile pentru editare manuală - Afișare nume fișier original pentru toate bonurile - Upload stabil pe mobil (fix race condition File API) - Păstrare ordine bonuri la refresh (nu se reordonează) ### Backend - SSE endpoint pentru status updates real-time - Bulk delete endpoint cu partial success - Auto-cleanup bonuri failed după 7 zile - Batch model cu tracking complet ### Testing - E2E tests cu Playwright - Unit tests pentru bulk upload, auto-create, cleanup ## Commits Squashed: 43 user stories (US-001 → US-043) ## Branch: ralph/bulk-receipt-upload ## Timp dezvoltare: ~3 zile (Ralph autonomous) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -140,3 +140,20 @@ const getStorageKey = () => {
|
||||
@2025-12-24 #vue #watch #reactive | inferred:high
|
||||
**P**: Period dropdown stayed on placeholder - handleCompanyChanged() existed but periods never loaded.
|
||||
**S**: Add Vue watch() on companyStore.selectedCompany with { immediate: true } to handle both initial load and changes.
|
||||
|
||||
## G: Mobile File Input Reset Causes Page Reload/Crash
|
||||
@2026-01-10 #mobile #file-upload #async #chrome-android | explicit:high
|
||||
**P**: On Chrome Mobile (Android/iOS), selecting multiple files in bulk upload caused page reload before "Process" button could be clicked. Files disappeared.
|
||||
**C**: Race condition - `onFilesSelected` called async `handleFiles()` (which clones files with `arrayBuffer()`) but immediately reset `fileInput.value = ''` without waiting. On mobile browsers, resetting input invalidates File object references while `arrayBuffer()` is still reading them.
|
||||
**S**: Make event handler async and await `handleFiles()` before resetting input:
|
||||
```javascript
|
||||
const onFilesSelected = async (event) => {
|
||||
const files = event.target?.files
|
||||
if (files?.length > 0) {
|
||||
await handleFiles(Array.from(files)) // Wait for cloning!
|
||||
}
|
||||
// Reset AFTER handleFiles completes
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
```
|
||||
**Applied in**: `src/modules/data-entry/components/bulk/BulkUploadZone.vue`
|
||||
|
||||
@@ -58,6 +58,7 @@ logger = logging.getLogger(__name__)
|
||||
# Global variables for background tasks
|
||||
telegram_bot_task = None
|
||||
ocr_job_worker_running = False
|
||||
cleanup_task_running = False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -160,6 +161,33 @@ async def init_ocr_job_worker():
|
||||
ocr_job_worker_running = False
|
||||
|
||||
|
||||
async def init_cleanup_task():
|
||||
"""Initialize the cleanup background task for expired failed receipts (US-008).
|
||||
|
||||
Runs cleanup at startup and then every 24 hours:
|
||||
- Finds receipts with processing_status='failed' older than 7 days
|
||||
- Deletes the receipts and their attachment files from storage
|
||||
"""
|
||||
global cleanup_task_running
|
||||
|
||||
logger.info("[CLEANUP] Initializing cleanup background task...")
|
||||
try:
|
||||
from backend.modules.data_entry.services.cleanup_service import start_cleanup_task
|
||||
from backend.modules.data_entry.db.database import get_session
|
||||
|
||||
success = await start_cleanup_task(get_session)
|
||||
cleanup_task_running = success
|
||||
|
||||
if success:
|
||||
logger.info("[CLEANUP] ✅ Cleanup task started (runs daily)")
|
||||
else:
|
||||
logger.warning("[CLEANUP] ⚠️ Cleanup task failed to start")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[CLEANUP] ⚠️ Cleanup task init failed: {e}")
|
||||
cleanup_task_running = False
|
||||
|
||||
|
||||
async def run_telegram_bot():
|
||||
"""Run Telegram bot as background task."""
|
||||
logger.info("[TELEGRAM] Starting bot...")
|
||||
@@ -270,7 +298,10 @@ async def startup_event():
|
||||
# Step 3: Initialize OCR job worker (with persistent PaddleOCR)
|
||||
await init_ocr_job_worker()
|
||||
|
||||
# Step 4: Start Telegram bot as background task
|
||||
# Step 4: Initialize cleanup task for expired failed receipts (US-008)
|
||||
await init_cleanup_task()
|
||||
|
||||
# Step 5: Start Telegram bot as background task
|
||||
if settings.telegram_bot_token:
|
||||
telegram_bot_task = asyncio.create_task(run_telegram_bot())
|
||||
logger.info("[STARTUP] ✅ Telegram bot task created")
|
||||
@@ -290,13 +321,24 @@ async def startup_event():
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Application shutdown - Cleanup resources."""
|
||||
global telegram_bot_task, ocr_job_worker_running
|
||||
global telegram_bot_task, ocr_job_worker_running, cleanup_task_running
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("[SHUTDOWN] Stopping ROA2WEB Unified Backend...")
|
||||
logger.info("=" * 80)
|
||||
|
||||
try:
|
||||
# Stop cleanup task (US-008)
|
||||
if cleanup_task_running:
|
||||
logger.info("[SHUTDOWN] Stopping cleanup task...")
|
||||
try:
|
||||
from backend.modules.data_entry.services.cleanup_service import stop_cleanup_task
|
||||
await stop_cleanup_task()
|
||||
cleanup_task_running = False
|
||||
logger.info("[SHUTDOWN] Cleanup task stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"[SHUTDOWN] Cleanup task error: {e}")
|
||||
|
||||
# Stop OCR job worker
|
||||
if ocr_job_worker_running:
|
||||
logger.info("[SHUTDOWN] Stopping OCR job worker...")
|
||||
|
||||
@@ -147,13 +147,33 @@ class ReceiptCRUD:
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk upload filters (US-012)
|
||||
# US-005: Support comma-separated values for processing_status filter (e.g., "pending,processing")
|
||||
if filters.processing_status:
|
||||
statuses = [s.strip() for s in filters.processing_status.split(",")]
|
||||
if len(statuses) == 1:
|
||||
query = query.where(Receipt.processing_status == statuses[0])
|
||||
else:
|
||||
query = query.where(Receipt.processing_status.in_(statuses))
|
||||
|
||||
if filters.batch_id:
|
||||
query = query.where(Receipt.batch_id == filters.batch_id)
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Apply pagination and ordering
|
||||
# Apply ordering based on sort_by parameter (US-012)
|
||||
if filters.sort_by == "processing_started_at":
|
||||
query = query.order_by(Receipt.processing_started_at.desc())
|
||||
elif filters.sort_by == "processing_started_at_asc":
|
||||
query = query.order_by(Receipt.processing_started_at.asc())
|
||||
else:
|
||||
# Default ordering
|
||||
query = query.order_by(Receipt.created_at.desc())
|
||||
|
||||
# Apply pagination
|
||||
offset = (filters.page - 1) * filters.page_size
|
||||
query = query.offset(offset).limit(filters.page_size)
|
||||
|
||||
@@ -163,6 +183,61 @@ class ReceiptCRUD:
|
||||
|
||||
return list(receipts), total
|
||||
|
||||
@staticmethod
|
||||
async def get_processing_stats(
|
||||
session: AsyncSession,
|
||||
company_id: Optional[int] = None,
|
||||
batch_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Get processing status counts for bulk uploaded receipts (US-012)."""
|
||||
# Build base query for counting by processing_status
|
||||
base_conditions = []
|
||||
|
||||
if company_id:
|
||||
base_conditions.append(Receipt.company_id == company_id)
|
||||
|
||||
if batch_id:
|
||||
base_conditions.append(Receipt.batch_id == batch_id)
|
||||
|
||||
# Only count receipts that have a processing_status (bulk uploads)
|
||||
base_conditions.append(Receipt.processing_status.isnot(None))
|
||||
|
||||
query = select(
|
||||
Receipt.processing_status,
|
||||
func.count(Receipt.id).label("count")
|
||||
)
|
||||
|
||||
for condition in base_conditions:
|
||||
query = query.where(condition)
|
||||
|
||||
query = query.group_by(Receipt.processing_status)
|
||||
|
||||
result = await session.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Initialize stats
|
||||
stats = {
|
||||
"pending_count": 0,
|
||||
"processing_count": 0,
|
||||
"completed_count": 0,
|
||||
"failed_count": 0,
|
||||
}
|
||||
|
||||
# Map results
|
||||
for row in rows:
|
||||
status = row.processing_status
|
||||
count = row.count
|
||||
if status == "pending":
|
||||
stats["pending_count"] = count
|
||||
elif status == "processing":
|
||||
stats["processing_count"] = count
|
||||
elif status == "completed":
|
||||
stats["completed_count"] = count
|
||||
elif status == "failed":
|
||||
stats["failed_count"] = count
|
||||
|
||||
return stats
|
||||
|
||||
@staticmethod
|
||||
async def get_pending_review(
|
||||
session: AsyncSession,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Database models
|
||||
from .receipt import Receipt, ReceiptAttachment, ReceiptStatus, ReceiptType, ReceiptDirection
|
||||
from .receipt import Receipt, ReceiptAttachment, ReceiptStatus, ReceiptType, ReceiptDirection, ProcessingStatus
|
||||
from .accounting_entry import AccountingEntry, EntryType
|
||||
from .nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister
|
||||
from .ocr_settings import UserOCRPreference, OCRJobMetrics, OCRMetricsSummary, OCREngine
|
||||
from .batch import BatchUpload, BatchJob, BatchStatus
|
||||
|
||||
__all__ = [
|
||||
"Receipt",
|
||||
@@ -10,6 +11,7 @@ __all__ = [
|
||||
"ReceiptStatus",
|
||||
"ReceiptType",
|
||||
"ReceiptDirection",
|
||||
"ProcessingStatus",
|
||||
"AccountingEntry",
|
||||
"EntryType",
|
||||
"SyncedSupplier",
|
||||
@@ -20,4 +22,8 @@ __all__ = [
|
||||
"OCRJobMetrics",
|
||||
"OCRMetricsSummary",
|
||||
"OCREngine",
|
||||
# Batch Upload
|
||||
"BatchUpload",
|
||||
"BatchJob",
|
||||
"BatchStatus",
|
||||
]
|
||||
|
||||
64
backend/modules/data_entry/db/models/batch.py
Normal file
64
backend/modules/data_entry/db/models/batch.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""BatchUpload and BatchJob SQLModel models for bulk receipt processing."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class BatchStatus(str, Enum):
|
||||
"""Status of a batch upload."""
|
||||
PENDING = "pending" # Batch created, jobs queued
|
||||
PROCESSING = "processing" # At least one job is processing
|
||||
COMPLETED = "completed" # All jobs completed (success or failed)
|
||||
FAILED = "failed" # Batch-level failure (e.g., all jobs failed)
|
||||
|
||||
|
||||
class BatchUpload(SQLModel, table=True):
|
||||
"""
|
||||
Batch upload record for grouping multiple OCR jobs.
|
||||
|
||||
Tracks overall progress and status of a bulk upload operation.
|
||||
"""
|
||||
|
||||
__tablename__ = "batch_uploads"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
||||
# User info
|
||||
user_id: str = Field(max_length=100, index=True) # Username who created the batch
|
||||
company_id: int = Field(index=True) # Company ID for receipt creation
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Status tracking
|
||||
status: BatchStatus = Field(default=BatchStatus.PENDING)
|
||||
total_files: int = Field(default=0)
|
||||
|
||||
|
||||
class BatchJob(SQLModel, table=True):
|
||||
"""
|
||||
Junction table linking batch_uploads to ocr_jobs.
|
||||
|
||||
Each record represents one file in a batch, linking to its OCR job.
|
||||
Also stores the receipt_id once the job completes and auto-creates a receipt.
|
||||
"""
|
||||
|
||||
__tablename__ = "batch_jobs"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
||||
# Foreign keys
|
||||
batch_id: int = Field(foreign_key="batch_uploads.id", index=True)
|
||||
job_id: str = Field(max_length=36, index=True) # UUID from ocr_jobs table
|
||||
|
||||
# Original filename for display
|
||||
filename: str = Field(max_length=255)
|
||||
|
||||
# Receipt reference (set after auto-create)
|
||||
receipt_id: Optional[int] = Field(default=None, foreign_key="receipts.id")
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
@@ -36,6 +36,14 @@ class PaymentMode(str, Enum):
|
||||
AVANS_DECONTARE = "avans_decontare" # Decont angajat (542)
|
||||
|
||||
|
||||
class ProcessingStatus(str, Enum):
|
||||
"""Processing status for bulk uploaded receipts."""
|
||||
PENDING = "pending" # Waiting in queue
|
||||
PROCESSING = "processing" # Currently being processed by OCR
|
||||
COMPLETED = "completed" # Successfully processed
|
||||
FAILED = "failed" # Processing failed with error
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .accounting_entry import AccountingEntry
|
||||
|
||||
@@ -96,6 +104,14 @@ class Receipt(SQLModel, table=True):
|
||||
oracle_act_id: Optional[int] = Field(default=None)
|
||||
oracle_error: Optional[str] = Field(default=None, max_length=500)
|
||||
|
||||
# Bulk upload batch tracking
|
||||
batch_id: Optional[str] = Field(default=None, max_length=50, index=True)
|
||||
processing_status: Optional[str] = Field(default=None, max_length=20, index=True) # ProcessingStatus enum value
|
||||
processing_error: Optional[str] = Field(default=None) # Full error message text
|
||||
file_hash: Optional[str] = Field(default=None, max_length=64, index=True) # SHA-256 hash for duplicate detection
|
||||
processing_started_at: Optional[datetime] = Field(default=None)
|
||||
processing_completed_at: Optional[datetime] = Field(default=None)
|
||||
|
||||
# Relationships
|
||||
attachments: List["ReceiptAttachment"] = Relationship(
|
||||
back_populates="receipt",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Add company_id to batch_uploads table.
|
||||
|
||||
Revision ID: 20260109_batch_company
|
||||
Revises: 20251231_add_original_filename_to_metrics
|
||||
Create Date: 2026-01-09
|
||||
|
||||
This migration adds the company_id column to batch_uploads to support
|
||||
automatic receipt creation during bulk upload processing.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20260109_batch_company'
|
||||
down_revision = None # Will be auto-detected
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add company_id column to batch_uploads table."""
|
||||
# Check if column already exists (SQLModel may have created it)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Check if batch_uploads table exists
|
||||
if 'batch_uploads' in inspector.get_table_names():
|
||||
columns = [col['name'] for col in inspector.get_columns('batch_uploads')]
|
||||
if 'company_id' not in columns:
|
||||
op.add_column(
|
||||
'batch_uploads',
|
||||
sa.Column('company_id', sa.Integer(), nullable=True)
|
||||
)
|
||||
# Create index for company_id
|
||||
op.create_index(
|
||||
'ix_batch_uploads_company_id',
|
||||
'batch_uploads',
|
||||
['company_id'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove company_id column from batch_uploads table."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if 'batch_uploads' in inspector.get_table_names():
|
||||
columns = [col['name'] for col in inspector.get_columns('batch_uploads')]
|
||||
if 'company_id' in columns:
|
||||
op.drop_index('ix_batch_uploads_company_id', table_name='batch_uploads')
|
||||
op.drop_column('batch_uploads', 'company_id')
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Add batch processing fields to receipts table.
|
||||
|
||||
Revision ID: add_batch_processing_fields
|
||||
Revises: add_original_filename_to_metrics
|
||||
Create Date: 2026-01-11
|
||||
|
||||
Adds fields for bulk upload batch tracking:
|
||||
- batch_id: UUID string for grouping receipts from same upload
|
||||
- processing_status: enum (pending/processing/completed/failed)
|
||||
- processing_error: full error message text
|
||||
- file_hash: SHA-256 hash for duplicate detection
|
||||
- processing_started_at: when OCR processing started
|
||||
- processing_completed_at: when OCR processing completed
|
||||
|
||||
Also creates indexes for efficient querying.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# Revision identifiers
|
||||
revision = 'add_batch_processing_fields'
|
||||
down_revision = 'add_original_filename_to_metrics'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add batch processing columns to receipts table."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Get existing columns
|
||||
columns = [col['name'] for col in inspector.get_columns('receipts')]
|
||||
|
||||
# Add batch_id column with index
|
||||
if 'batch_id' not in columns:
|
||||
op.add_column(
|
||||
'receipts',
|
||||
sa.Column('batch_id', sa.String(length=50), nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
'ix_receipts_batch_id',
|
||||
'receipts',
|
||||
['batch_id'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
# Add processing_status column with index
|
||||
if 'processing_status' not in columns:
|
||||
op.add_column(
|
||||
'receipts',
|
||||
sa.Column('processing_status', sa.String(length=20), nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
'ix_receipts_processing_status',
|
||||
'receipts',
|
||||
['processing_status'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
# Add processing_error column (TEXT for full error messages)
|
||||
if 'processing_error' not in columns:
|
||||
op.add_column(
|
||||
'receipts',
|
||||
sa.Column('processing_error', sa.Text(), nullable=True)
|
||||
)
|
||||
|
||||
# Add file_hash column with index for duplicate detection
|
||||
if 'file_hash' not in columns:
|
||||
op.add_column(
|
||||
'receipts',
|
||||
sa.Column('file_hash', sa.String(length=64), nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
'ix_receipts_file_hash',
|
||||
'receipts',
|
||||
['file_hash'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
# Add processing_started_at column
|
||||
if 'processing_started_at' not in columns:
|
||||
op.add_column(
|
||||
'receipts',
|
||||
sa.Column('processing_started_at', sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
# Add processing_completed_at column
|
||||
if 'processing_completed_at' not in columns:
|
||||
op.add_column(
|
||||
'receipts',
|
||||
sa.Column('processing_completed_at', sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove batch processing columns from receipts table."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
columns = [col['name'] for col in inspector.get_columns('receipts')]
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes('receipts')]
|
||||
|
||||
# Remove indexes first (SQLite batch mode)
|
||||
if 'ix_receipts_batch_id' in indexes:
|
||||
op.drop_index('ix_receipts_batch_id', table_name='receipts')
|
||||
if 'ix_receipts_processing_status' in indexes:
|
||||
op.drop_index('ix_receipts_processing_status', table_name='receipts')
|
||||
if 'ix_receipts_file_hash' in indexes:
|
||||
op.drop_index('ix_receipts_file_hash', table_name='receipts')
|
||||
|
||||
# Remove columns (in reverse order of addition)
|
||||
if 'processing_completed_at' in columns:
|
||||
op.drop_column('receipts', 'processing_completed_at')
|
||||
if 'processing_started_at' in columns:
|
||||
op.drop_column('receipts', 'processing_started_at')
|
||||
if 'file_hash' in columns:
|
||||
op.drop_column('receipts', 'file_hash')
|
||||
if 'processing_error' in columns:
|
||||
op.drop_column('receipts', 'processing_error')
|
||||
if 'processing_status' in columns:
|
||||
op.drop_column('receipts', 'processing_status')
|
||||
if 'batch_id' in columns:
|
||||
op.drop_column('receipts', 'batch_id')
|
||||
@@ -13,6 +13,7 @@ def create_data_entry_router() -> APIRouter:
|
||||
- /nomenclature - Nomenclature syncing from Oracle
|
||||
- /settings - User settings (OCR preferences)
|
||||
- /metrics - OCR analytics and metrics
|
||||
- /bulk - Bulk upload for batch processing
|
||||
|
||||
Returns:
|
||||
APIRouter: Configured router for data entry module
|
||||
@@ -24,6 +25,7 @@ def create_data_entry_router() -> APIRouter:
|
||||
from .ocr import router as ocr_router
|
||||
from .nomenclature import router as nomenclature_router
|
||||
from .ocr_settings import router as ocr_settings_router
|
||||
from .bulk import router as bulk_router
|
||||
|
||||
# Include all sub-routers (no prefix - already prefixed in main.py with /api/data-entry)
|
||||
router.include_router(receipts_router, prefix="/receipts", tags=["data-entry-receipts"])
|
||||
@@ -31,5 +33,7 @@ def create_data_entry_router() -> APIRouter:
|
||||
router.include_router(nomenclature_router, prefix="/nomenclature", tags=["data-entry-nomenclature"])
|
||||
# OCR settings and metrics (endpoints at /settings/* and /metrics/*)
|
||||
router.include_router(ocr_settings_router, tags=["data-entry-settings"])
|
||||
# Bulk upload for batch processing
|
||||
router.include_router(bulk_router, prefix="/bulk", tags=["data-entry-bulk"])
|
||||
|
||||
return router
|
||||
|
||||
997
backend/modules/data_entry/routers/bulk.py
Normal file
997
backend/modules/data_entry/routers/bulk.py
Normal file
@@ -0,0 +1,997 @@
|
||||
"""
|
||||
Bulk upload API endpoints for batch receipt processing.
|
||||
|
||||
Endpoints:
|
||||
- POST /upload - Submit multiple files for OCR processing in a single batch
|
||||
- GET /batches/{batch_id}/status - Get batch status with optional long-polling
|
||||
|
||||
Validation:
|
||||
- Max 100 files per batch
|
||||
- Max 10MB per file
|
||||
- Allowed types: PDF, PNG, JPG
|
||||
|
||||
Duplicate Detection (US-007):
|
||||
- SHA-256 hash calculated for each file
|
||||
- Duplicate files (same hash + company_id) are rejected with 409 Conflict info
|
||||
- Duplicates reported in error list, non-duplicates processed normally
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, Header
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.modules.data_entry.db.database import get_session
|
||||
from backend.modules.data_entry.db.models import BatchUpload, BatchJob, BatchStatus, Receipt, ReceiptAttachment
|
||||
from backend.modules.data_entry.schemas.bulk import (
|
||||
BulkUploadResponse,
|
||||
BulkUploadResponseWithDuplicates,
|
||||
BatchStatusResponse,
|
||||
BatchJobInfo,
|
||||
DuplicateFileInfo,
|
||||
RetryResponse,
|
||||
BatchRetryResponse,
|
||||
CancelJobResponse,
|
||||
CancelBatchResponse
|
||||
)
|
||||
from backend.modules.data_entry.services.ocr.job_queue import job_queue, OCRJobStatus
|
||||
from backend.config import settings
|
||||
|
||||
# Auth integration
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from shared.auth.models import CurrentUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Helper for selected company from header ============
|
||||
|
||||
async def get_selected_company(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
x_selected_company: Annotated[Optional[str], Header()] = None
|
||||
) -> int:
|
||||
"""
|
||||
Get selected company from X-Selected-Company header.
|
||||
|
||||
Validates that the user has access to the specified company.
|
||||
Falls back to user's first company if no header is provided.
|
||||
"""
|
||||
if x_selected_company:
|
||||
try:
|
||||
company_id = int(x_selected_company)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid company ID format: {x_selected_company}"
|
||||
)
|
||||
|
||||
if str(company_id) in current_user.companies:
|
||||
return company_id
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Nu aveți acces la firma {company_id}"
|
||||
)
|
||||
|
||||
# No header - use first company from user's list
|
||||
if current_user.companies:
|
||||
try:
|
||||
return int(current_user.companies[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nu aveți nicio firmă asignată"
|
||||
)
|
||||
|
||||
# Validation constants
|
||||
MAX_FILES_PER_BATCH = 100
|
||||
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10MB
|
||||
ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "application/pdf"}
|
||||
|
||||
|
||||
def compute_file_hash(content: bytes) -> str:
|
||||
"""
|
||||
Compute SHA-256 hash of file content.
|
||||
|
||||
Used for duplicate detection - same file content = same hash.
|
||||
|
||||
Args:
|
||||
content: Raw file bytes
|
||||
|
||||
Returns:
|
||||
Hexadecimal string of SHA-256 hash (64 characters)
|
||||
"""
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
|
||||
async def check_duplicate_hashes(
|
||||
session: AsyncSession,
|
||||
file_hashes: List[str],
|
||||
company_id: int
|
||||
) -> dict[str, int]:
|
||||
"""
|
||||
Check which file hashes already exist in the database for this company.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
file_hashes: List of SHA-256 hashes to check
|
||||
company_id: Company ID to scope the duplicate check
|
||||
|
||||
Returns:
|
||||
Dict mapping hash -> existing receipt_id for duplicates found
|
||||
"""
|
||||
if not file_hashes:
|
||||
return {}
|
||||
|
||||
# Query for existing receipts with these hashes for this company
|
||||
result = await session.execute(
|
||||
select(Receipt.file_hash, Receipt.id).where(
|
||||
and_(
|
||||
Receipt.file_hash.in_(file_hashes),
|
||||
Receipt.company_id == company_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Build hash -> receipt_id mapping
|
||||
# Note: result.all() is synchronous in SQLAlchemy async, returns list of tuples
|
||||
duplicates = {}
|
||||
rows = result.all()
|
||||
for row in rows:
|
||||
duplicates[row[0]] = row[1]
|
||||
|
||||
return duplicates
|
||||
|
||||
|
||||
@router.post("/upload", response_model=Union[BulkUploadResponse, BulkUploadResponseWithDuplicates])
|
||||
async def bulk_upload(
|
||||
files: List[UploadFile] = File(..., description="Multiple files to upload"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
selected_company: int = Depends(get_selected_company)
|
||||
):
|
||||
"""
|
||||
Upload multiple files for batch OCR processing.
|
||||
|
||||
Creates a batch record and queues all files as OCR jobs.
|
||||
Invalid files cause entire batch rejection (validation errors).
|
||||
Duplicate files are reported separately and skipped - non-duplicates are processed.
|
||||
|
||||
Duplicate Detection (US-007):
|
||||
- SHA-256 hash calculated for each file before processing
|
||||
- Files with existing hash for same company are rejected with 409 info
|
||||
- Response includes duplicate details with existing_receipt_id
|
||||
|
||||
Args:
|
||||
files: List of image/PDF files (max 100 files, max 10MB each)
|
||||
|
||||
Returns:
|
||||
BulkUploadResponse with batch_id and list of job_ids
|
||||
BulkUploadResponseWithDuplicates if some files were duplicates
|
||||
|
||||
Raises:
|
||||
400: If validation fails (too many files, file too large, invalid type)
|
||||
409: If ALL files are duplicates
|
||||
500: If job creation fails
|
||||
"""
|
||||
# Validate file count
|
||||
if len(files) == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No files provided"
|
||||
)
|
||||
|
||||
if len(files) > MAX_FILES_PER_BATCH:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Too many files. Maximum {MAX_FILES_PER_BATCH} files per batch."
|
||||
)
|
||||
|
||||
# Pre-validate all files before creating any jobs (atomic check)
|
||||
invalid_files = []
|
||||
file_contents = []
|
||||
|
||||
for file in files:
|
||||
# Check MIME type
|
||||
if file.content_type not in ALLOWED_MIME_TYPES:
|
||||
invalid_files.append(f"{file.filename}: Invalid type ({file.content_type})")
|
||||
continue
|
||||
|
||||
# Read content and check size
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE_BYTES:
|
||||
invalid_files.append(f"{file.filename}: File too large ({len(content) // (1024*1024)}MB > 10MB)")
|
||||
continue
|
||||
|
||||
# Compute SHA-256 hash for duplicate detection (US-007)
|
||||
file_hash = compute_file_hash(content)
|
||||
|
||||
# Store for later processing
|
||||
file_contents.append({
|
||||
"filename": file.filename,
|
||||
"content": content,
|
||||
"mime_type": file.content_type,
|
||||
"file_hash": file_hash
|
||||
})
|
||||
|
||||
# If any files are invalid, reject the entire batch
|
||||
if invalid_files:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"message": f"Validation failed for {len(invalid_files)} file(s)",
|
||||
"invalid_files": invalid_files
|
||||
}
|
||||
)
|
||||
|
||||
# Check for duplicates BEFORE creating batch (US-007)
|
||||
all_hashes = [f["file_hash"] for f in file_contents]
|
||||
existing_duplicates = await check_duplicate_hashes(session, all_hashes, selected_company)
|
||||
|
||||
# Separate duplicate files from processable files
|
||||
duplicate_files: List[DuplicateFileInfo] = []
|
||||
processable_files = []
|
||||
|
||||
for file_data in file_contents:
|
||||
if file_data["file_hash"] in existing_duplicates:
|
||||
existing_receipt_id = existing_duplicates[file_data["file_hash"]]
|
||||
duplicate_files.append(DuplicateFileInfo(
|
||||
filename=file_data["filename"],
|
||||
error="duplicate",
|
||||
existing_receipt_id=existing_receipt_id,
|
||||
message=f"Fișier duplicat - există deja ca bon #{existing_receipt_id}"
|
||||
))
|
||||
logger.info(
|
||||
f"[BulkUpload] Duplicate detected: {file_data['filename']} "
|
||||
f"(hash={file_data['file_hash'][:16]}...) matches receipt #{existing_receipt_id}"
|
||||
)
|
||||
else:
|
||||
processable_files.append(file_data)
|
||||
|
||||
# If ALL files are duplicates, return 409 Conflict
|
||||
if len(duplicate_files) == len(file_contents):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "all_duplicates",
|
||||
"message": f"Toate cele {len(duplicate_files)} fișiere sunt duplicate",
|
||||
"duplicates": [d.model_dump() for d in duplicate_files]
|
||||
}
|
||||
)
|
||||
|
||||
# If no processable files remain after filtering (shouldn't happen but be safe)
|
||||
if not processable_files:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "no_files_to_process",
|
||||
"message": "Nu există fișiere de procesat",
|
||||
"duplicates": [d.model_dump() for d in duplicate_files]
|
||||
}
|
||||
)
|
||||
|
||||
# Create batch record with company_id for auto-save
|
||||
batch = BatchUpload(
|
||||
user_id=current_user.username,
|
||||
company_id=selected_company,
|
||||
status=BatchStatus.PENDING,
|
||||
total_files=len(processable_files) # Only count processable files
|
||||
)
|
||||
session.add(batch)
|
||||
await session.flush() # Get batch.id before creating jobs
|
||||
|
||||
# Create OCR jobs for processable files only
|
||||
job_ids = []
|
||||
batch_jobs = []
|
||||
|
||||
try:
|
||||
for file_data in processable_files:
|
||||
# Create OCR job using existing job_queue
|
||||
# Pass batch_id and file_hash for tracking
|
||||
job = await job_queue.create_job(
|
||||
file_bytes=file_data["content"],
|
||||
mime_type=file_data["mime_type"],
|
||||
engine="doctr_plus", # Default engine for bulk
|
||||
username=current_user.username,
|
||||
original_filename=file_data["filename"],
|
||||
batch_id=batch.id, # Link job to batch for auto-save integration
|
||||
file_hash=file_data["file_hash"] # Pass hash for storage in receipt
|
||||
)
|
||||
|
||||
job_ids.append(job.id)
|
||||
|
||||
# Create batch_job link
|
||||
batch_job = BatchJob(
|
||||
batch_id=batch.id,
|
||||
job_id=job.id,
|
||||
filename=file_data["filename"]
|
||||
)
|
||||
batch_jobs.append(batch_job)
|
||||
|
||||
# Add all batch_job records
|
||||
for bj in batch_jobs:
|
||||
session.add(bj)
|
||||
|
||||
# Commit everything atomically
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[BulkUpload] Created batch {batch.id} with {len(job_ids)} jobs "
|
||||
f"for user {current_user.username}"
|
||||
f"{f', {len(duplicate_files)} duplicates skipped' if duplicate_files else ''}"
|
||||
)
|
||||
|
||||
# Return response with duplicate info if any duplicates were found
|
||||
if duplicate_files:
|
||||
return BulkUploadResponseWithDuplicates(
|
||||
batch_id=batch.id,
|
||||
job_ids=job_ids,
|
||||
total_files=len(file_contents),
|
||||
processed_files=len(job_ids),
|
||||
duplicate_files=len(duplicate_files),
|
||||
duplicates=duplicate_files,
|
||||
message=f"{len(job_ids)} fișier(e) în procesare, {len(duplicate_files)} duplicate ignorate"
|
||||
)
|
||||
|
||||
return BulkUploadResponse(
|
||||
batch_id=batch.id,
|
||||
job_ids=job_ids,
|
||||
total_files=len(job_ids),
|
||||
message=f"{len(job_ids)} files queued for processing"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Rollback on any error
|
||||
await session.rollback()
|
||||
logger.error(f"[BulkUpload] Failed to create batch: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to create batch: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# Long-polling constants
|
||||
MAX_WAIT_SECONDS = 30
|
||||
POLL_INTERVAL_SECONDS = 0.5
|
||||
|
||||
|
||||
async def _get_batch_status_snapshot(
|
||||
batch_id: int,
|
||||
session: AsyncSession
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Get current batch status snapshot.
|
||||
|
||||
Returns dict with status counts and jobs list, or None if batch not found.
|
||||
"""
|
||||
# Get batch record
|
||||
batch_result = await session.execute(
|
||||
select(BatchUpload).where(BatchUpload.id == batch_id)
|
||||
)
|
||||
batch = batch_result.scalar_one_or_none()
|
||||
|
||||
if not batch:
|
||||
return None
|
||||
|
||||
# Get all batch_jobs for this batch
|
||||
batch_jobs_result = await session.execute(
|
||||
select(BatchJob).where(BatchJob.batch_id == batch_id)
|
||||
)
|
||||
batch_jobs = batch_jobs_result.scalars().all()
|
||||
|
||||
if not batch_jobs:
|
||||
return {
|
||||
"batch": batch,
|
||||
"pending_count": 0,
|
||||
"processing_count": 0,
|
||||
"completed_count": 0,
|
||||
"failed_count": 0,
|
||||
"jobs": [],
|
||||
"total_amount": None
|
||||
}
|
||||
|
||||
# Get job statuses and error_messages from OCR job queue (SQLite)
|
||||
job_statuses = {}
|
||||
job_errors = {}
|
||||
for bj in batch_jobs:
|
||||
job = await job_queue.get_job(bj.job_id)
|
||||
if job:
|
||||
job_statuses[bj.job_id] = job.status.value
|
||||
job_errors[bj.job_id] = job.error_message
|
||||
else:
|
||||
# Job not found in queue - treat as failed
|
||||
job_statuses[bj.job_id] = "failed"
|
||||
job_errors[bj.job_id] = "Job not found in queue"
|
||||
|
||||
# Count by status
|
||||
pending_count = sum(1 for s in job_statuses.values() if s == "pending")
|
||||
processing_count = sum(1 for s in job_statuses.values() if s == "processing")
|
||||
completed_count = sum(1 for s in job_statuses.values() if s == "completed")
|
||||
failed_count = sum(1 for s in job_statuses.values() if s == "failed")
|
||||
|
||||
# Build jobs list with status info
|
||||
jobs_info = []
|
||||
for bj in batch_jobs:
|
||||
jobs_info.append({
|
||||
"job_id": bj.job_id,
|
||||
"filename": bj.filename,
|
||||
"status": job_statuses.get(bj.job_id, "failed"),
|
||||
"receipt_id": bj.receipt_id,
|
||||
"error_message": job_errors.get(bj.job_id)
|
||||
})
|
||||
|
||||
# Calculate total_amount from completed receipts
|
||||
total_amount = None
|
||||
receipt_ids = [bj.receipt_id for bj in batch_jobs if bj.receipt_id is not None]
|
||||
if receipt_ids:
|
||||
amount_result = await session.execute(
|
||||
select(func.sum(Receipt.amount)).where(Receipt.id.in_(receipt_ids))
|
||||
)
|
||||
total_sum = amount_result.scalar()
|
||||
if total_sum is not None:
|
||||
total_amount = float(total_sum)
|
||||
|
||||
return {
|
||||
"batch": batch,
|
||||
"pending_count": pending_count,
|
||||
"processing_count": processing_count,
|
||||
"completed_count": completed_count,
|
||||
"failed_count": failed_count,
|
||||
"jobs": jobs_info,
|
||||
"total_amount": total_amount
|
||||
}
|
||||
|
||||
|
||||
def _compute_batch_overall_status(pending: int, processing: int, completed: int, failed: int, total: int) -> str:
|
||||
"""Compute overall batch status from job counts."""
|
||||
if pending + processing == 0:
|
||||
# All jobs finished
|
||||
if failed == total:
|
||||
return BatchStatus.FAILED.value
|
||||
return BatchStatus.COMPLETED.value
|
||||
elif processing > 0 or completed > 0 or failed > 0:
|
||||
return BatchStatus.PROCESSING.value
|
||||
else:
|
||||
return BatchStatus.PENDING.value
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/status", response_model=BatchStatusResponse)
|
||||
async def get_batch_status(
|
||||
batch_id: int,
|
||||
wait: Optional[int] = Query(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=MAX_WAIT_SECONDS,
|
||||
description="Long-polling wait time in seconds (max 30)"
|
||||
),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get batch processing status with optional long-polling.
|
||||
|
||||
Returns aggregated status counts and individual job statuses.
|
||||
When `wait` parameter is provided, the endpoint will poll until:
|
||||
- Status changes from initial snapshot
|
||||
- All jobs complete (pending + processing = 0)
|
||||
- Timeout is reached
|
||||
|
||||
Args:
|
||||
batch_id: Batch ID to query
|
||||
wait: Optional wait time in seconds for long-polling (0-30)
|
||||
|
||||
Returns:
|
||||
BatchStatusResponse with status counts and job details
|
||||
|
||||
Raises:
|
||||
404: If batch not found
|
||||
"""
|
||||
# Get initial snapshot
|
||||
snapshot = await _get_batch_status_snapshot(batch_id, session)
|
||||
|
||||
if snapshot is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Batch {batch_id} not found"
|
||||
)
|
||||
|
||||
# If long-polling requested and jobs still in progress
|
||||
if wait and wait > 0:
|
||||
initial_pending = snapshot["pending_count"]
|
||||
initial_processing = snapshot["processing_count"]
|
||||
initial_completed = snapshot["completed_count"]
|
||||
initial_failed = snapshot["failed_count"]
|
||||
|
||||
# Only wait if there are still jobs in progress
|
||||
if initial_pending + initial_processing > 0:
|
||||
elapsed = 0.0
|
||||
while elapsed < wait:
|
||||
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
||||
elapsed += POLL_INTERVAL_SECONDS
|
||||
|
||||
# Refresh snapshot
|
||||
snapshot = await _get_batch_status_snapshot(batch_id, session)
|
||||
if snapshot is None:
|
||||
# Batch deleted during polling (edge case)
|
||||
raise HTTPException(status_code=404, detail=f"Batch {batch_id} not found")
|
||||
|
||||
# Check if status changed
|
||||
current_pending = snapshot["pending_count"]
|
||||
current_processing = snapshot["processing_count"]
|
||||
current_completed = snapshot["completed_count"]
|
||||
current_failed = snapshot["failed_count"]
|
||||
|
||||
if (current_pending != initial_pending or
|
||||
current_processing != initial_processing or
|
||||
current_completed != initial_completed or
|
||||
current_failed != initial_failed):
|
||||
# Status changed, return immediately
|
||||
break
|
||||
|
||||
# Check if all jobs finished
|
||||
if current_pending + current_processing == 0:
|
||||
break
|
||||
|
||||
# Build response
|
||||
batch = snapshot["batch"]
|
||||
total_files = batch.total_files
|
||||
|
||||
overall_status = _compute_batch_overall_status(
|
||||
snapshot["pending_count"],
|
||||
snapshot["processing_count"],
|
||||
snapshot["completed_count"],
|
||||
snapshot["failed_count"],
|
||||
total_files
|
||||
)
|
||||
|
||||
jobs = [
|
||||
BatchJobInfo(
|
||||
job_id=j["job_id"],
|
||||
filename=j["filename"],
|
||||
status=j["status"],
|
||||
receipt_id=j["receipt_id"],
|
||||
error_message=j.get("error_message")
|
||||
)
|
||||
for j in snapshot["jobs"]
|
||||
]
|
||||
|
||||
return BatchStatusResponse(
|
||||
batch_id=batch.id,
|
||||
status=overall_status,
|
||||
total_files=total_files,
|
||||
pending_count=snapshot["pending_count"],
|
||||
processing_count=snapshot["processing_count"],
|
||||
completed_count=snapshot["completed_count"],
|
||||
failed_count=snapshot["failed_count"],
|
||||
jobs=jobs,
|
||||
total_amount=snapshot["total_amount"],
|
||||
created_at=batch.created_at
|
||||
)
|
||||
|
||||
|
||||
# ============ Retry Endpoints (US-006) ============
|
||||
|
||||
|
||||
async def _retry_single_receipt(
|
||||
session: AsyncSession,
|
||||
receipt: Receipt,
|
||||
username: str
|
||||
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Retry processing for a single receipt.
|
||||
|
||||
Finds the original file from attachments, resets processing status,
|
||||
and creates a new OCR job.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
receipt: Receipt to retry
|
||||
username: Username for the new OCR job
|
||||
|
||||
Returns:
|
||||
Tuple of (success, job_id, error_message)
|
||||
"""
|
||||
# Get the first attachment to find the source file
|
||||
attachments_result = await session.execute(
|
||||
select(ReceiptAttachment)
|
||||
.where(ReceiptAttachment.receipt_id == receipt.id)
|
||||
.limit(1)
|
||||
)
|
||||
attachment = attachments_result.scalar_one_or_none()
|
||||
|
||||
if not attachment:
|
||||
return False, None, "Bonul nu are fișier atașat"
|
||||
|
||||
# Construct full path to attachment file
|
||||
file_path = settings.data_entry_upload_path_resolved / attachment.file_path
|
||||
|
||||
if not file_path.exists():
|
||||
return False, None, "Fișierul original nu mai este disponibil"
|
||||
|
||||
# Read file content
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"[Retry] Failed to read file {file_path}: {e}")
|
||||
return False, None, f"Eroare la citirea fișierului: {str(e)}"
|
||||
|
||||
# Create new OCR job
|
||||
try:
|
||||
job = await job_queue.create_job(
|
||||
file_bytes=file_bytes,
|
||||
mime_type=attachment.mime_type,
|
||||
engine="doctr_plus",
|
||||
username=username,
|
||||
original_filename=attachment.filename,
|
||||
batch_id=None, # No batch for retry - direct processing
|
||||
file_hash=receipt.file_hash
|
||||
)
|
||||
|
||||
# Reset receipt processing status
|
||||
receipt.processing_status = "pending"
|
||||
receipt.processing_error = None
|
||||
receipt.processing_started_at = datetime.utcnow()
|
||||
receipt.processing_completed_at = None
|
||||
|
||||
await session.flush()
|
||||
|
||||
logger.info(f"[Retry] Receipt {receipt.id} requeued as job {job.id}")
|
||||
return True, job.id, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Retry] Failed to create job for receipt {receipt.id}: {e}")
|
||||
return False, None, f"Eroare la crearea job-ului OCR: {str(e)}"
|
||||
|
||||
|
||||
@router.post("/retry/{receipt_id}", response_model=RetryResponse)
|
||||
async def retry_receipt(
|
||||
receipt_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
selected_company: int = Depends(get_selected_company)
|
||||
):
|
||||
"""
|
||||
Retry OCR processing for a single failed receipt.
|
||||
|
||||
Resets the receipt's processing_status to 'pending' and creates
|
||||
a new OCR job using the original attachment file.
|
||||
|
||||
Args:
|
||||
receipt_id: ID of the receipt to retry
|
||||
|
||||
Returns:
|
||||
RetryResponse with success status and new job ID
|
||||
|
||||
Raises:
|
||||
404: If receipt not found
|
||||
400: If receipt is not in 'failed' status
|
||||
400: If original file is not available
|
||||
"""
|
||||
# Get the receipt
|
||||
result = await session.execute(
|
||||
select(Receipt).where(
|
||||
and_(
|
||||
Receipt.id == receipt_id,
|
||||
Receipt.company_id == selected_company
|
||||
)
|
||||
)
|
||||
)
|
||||
receipt = result.scalar_one_or_none()
|
||||
|
||||
if not receipt:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Bonul #{receipt_id} nu a fost găsit"
|
||||
)
|
||||
|
||||
# Verify receipt is in failed status
|
||||
if receipt.processing_status != "failed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bonul nu este în stare de eroare (status actual: {receipt.processing_status})"
|
||||
)
|
||||
|
||||
# Attempt retry
|
||||
success, job_id, error = await _retry_single_receipt(
|
||||
session, receipt, current_user.username
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=error or "Eroare necunoscută la reîncărcare"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return RetryResponse(
|
||||
success=True,
|
||||
receipt_id=receipt_id,
|
||||
job_id=job_id,
|
||||
message="Bon reîncarcat în procesare"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/retry-batch/{batch_id}", response_model=BatchRetryResponse)
|
||||
async def retry_batch_failed(
|
||||
batch_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
selected_company: int = Depends(get_selected_company)
|
||||
):
|
||||
"""
|
||||
Retry all failed receipts in a batch.
|
||||
|
||||
Finds all receipts with batch_id matching and processing_status='failed',
|
||||
then attempts to retry each one.
|
||||
|
||||
Args:
|
||||
batch_id: Batch ID (UUID string from receipt.batch_id)
|
||||
|
||||
Returns:
|
||||
BatchRetryResponse with counts of successful and failed retries
|
||||
|
||||
Raises:
|
||||
404: If no failed receipts found for batch
|
||||
"""
|
||||
# Find all failed receipts in this batch
|
||||
result = await session.execute(
|
||||
select(Receipt).where(
|
||||
and_(
|
||||
Receipt.batch_id == batch_id,
|
||||
Receipt.company_id == selected_company,
|
||||
Receipt.processing_status == "failed"
|
||||
)
|
||||
)
|
||||
)
|
||||
failed_receipts = result.scalars().all()
|
||||
|
||||
if not failed_receipts:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Nu există bonuri cu erori în batch-ul {batch_id}"
|
||||
)
|
||||
|
||||
# Retry each receipt
|
||||
retried_count = 0
|
||||
failed_count = 0
|
||||
errors = []
|
||||
|
||||
for receipt in failed_receipts:
|
||||
success, job_id, error = await _retry_single_receipt(
|
||||
session, receipt, current_user.username
|
||||
)
|
||||
|
||||
if success:
|
||||
retried_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
errors.append(f"Bon #{receipt.id}: {error}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
return BatchRetryResponse(
|
||||
success=retried_count > 0,
|
||||
batch_id=batch_id,
|
||||
retried_count=retried_count,
|
||||
failed_count=failed_count,
|
||||
errors=errors,
|
||||
message=f"{retried_count} bonuri reîncarcate în procesare"
|
||||
+ (f", {failed_count} erori" if failed_count > 0 else "")
|
||||
)
|
||||
|
||||
|
||||
# ============ Cancel Endpoints (US-014) ============
|
||||
|
||||
|
||||
@router.post("/cancel/{job_id}", response_model=CancelJobResponse)
|
||||
async def cancel_job(
|
||||
job_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Cancel a single OCR processing job.
|
||||
|
||||
Only jobs with status 'pending' or 'processing' can be cancelled.
|
||||
Jobs with status 'completed' or 'failed' cannot be cancelled.
|
||||
|
||||
Important: If a receipt has already been created from this job,
|
||||
it will NOT be deleted - receipts are preserved for audit purposes.
|
||||
|
||||
Args:
|
||||
job_id: The UUID of the OCR job to cancel
|
||||
|
||||
Returns:
|
||||
CancelJobResponse with cancellation details
|
||||
|
||||
Raises:
|
||||
404: If job not found in batch_jobs table
|
||||
400: If job has already completed or failed
|
||||
"""
|
||||
# Find the job in batch_jobs table
|
||||
batch_job_result = await session.execute(
|
||||
select(BatchJob).where(BatchJob.job_id == job_id)
|
||||
)
|
||||
batch_job = batch_job_result.scalar_one_or_none()
|
||||
|
||||
if not batch_job:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Job {job_id} nu a fost găsit"
|
||||
)
|
||||
|
||||
# Get the OCR job from job_queue to check current status
|
||||
ocr_job = await job_queue.get_job(job_id)
|
||||
|
||||
if not ocr_job:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Job {job_id} nu există în coada de procesare"
|
||||
)
|
||||
|
||||
# Check if job can be cancelled
|
||||
current_status = ocr_job.status.value
|
||||
|
||||
if current_status == OCRJobStatus.completed.value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Job-ul a fost deja procesat cu succes. Nu poate fi anulat."
|
||||
)
|
||||
|
||||
if current_status == OCRJobStatus.failed.value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Job-ul a eșuat deja. Folosiți opțiunea de reîncercare în loc de anulare."
|
||||
)
|
||||
|
||||
if current_status == OCRJobStatus.cancelled.value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Job-ul a fost deja anulat."
|
||||
)
|
||||
|
||||
# Update job status to cancelled in job_queue (SQLite)
|
||||
cancelled_at = datetime.utcnow()
|
||||
success = await job_queue.update_status(
|
||||
job_id=job_id,
|
||||
status=OCRJobStatus.cancelled,
|
||||
error="Cancelled by user"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Eroare la anularea job-ului"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[CancelJob] Job {job_id} cancelled by {current_user.username} "
|
||||
f"(previous status: {current_status})"
|
||||
)
|
||||
|
||||
return CancelJobResponse(
|
||||
success=True,
|
||||
job_id=job_id,
|
||||
cancelled_at=cancelled_at,
|
||||
message=f"Job anulat cu succes"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cancel-batch/{batch_id}", response_model=CancelBatchResponse)
|
||||
async def cancel_batch(
|
||||
batch_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Cancel all pending/processing jobs in a batch.
|
||||
|
||||
Finds all jobs with status 'pending' or 'processing' in the specified batch
|
||||
and marks them as 'cancelled'. Jobs with status 'completed' or 'failed'
|
||||
are not affected.
|
||||
|
||||
Important: Receipts that have already been created from completed jobs
|
||||
will NOT be deleted - they are preserved for audit purposes.
|
||||
|
||||
Args:
|
||||
batch_id: The batch ID to cancel
|
||||
|
||||
Returns:
|
||||
CancelBatchResponse with counts of cancelled and skipped jobs
|
||||
|
||||
Raises:
|
||||
404: If batch not found or no jobs exist for batch
|
||||
"""
|
||||
# Verify batch exists
|
||||
batch_result = await session.execute(
|
||||
select(BatchUpload).where(BatchUpload.id == batch_id)
|
||||
)
|
||||
batch = batch_result.scalar_one_or_none()
|
||||
|
||||
if not batch:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Batch {batch_id} nu a fost găsit"
|
||||
)
|
||||
|
||||
# Get all batch_jobs for this batch
|
||||
batch_jobs_result = await session.execute(
|
||||
select(BatchJob).where(BatchJob.batch_id == batch_id)
|
||||
)
|
||||
batch_jobs = batch_jobs_result.scalars().all()
|
||||
|
||||
if not batch_jobs:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Nu există job-uri în batch-ul {batch_id}"
|
||||
)
|
||||
|
||||
# Process each job - cancel pending/processing, skip completed/failed
|
||||
cancelled_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for batch_job in batch_jobs:
|
||||
# Get current job status from OCR job queue
|
||||
ocr_job = await job_queue.get_job(batch_job.job_id)
|
||||
|
||||
if not ocr_job:
|
||||
# Job not found in queue - treat as skipped
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
current_status = ocr_job.status.value
|
||||
|
||||
# Only cancel pending or processing jobs
|
||||
if current_status in (OCRJobStatus.pending.value, OCRJobStatus.processing.value):
|
||||
success = await job_queue.update_status(
|
||||
job_id=batch_job.job_id,
|
||||
status=OCRJobStatus.cancelled,
|
||||
error="Cancelled by user (batch cancel)"
|
||||
)
|
||||
|
||||
if success:
|
||||
cancelled_count += 1
|
||||
logger.debug(f"[CancelBatch] Cancelled job {batch_job.job_id}")
|
||||
else:
|
||||
# Failed to cancel - count as skipped
|
||||
skipped_count += 1
|
||||
logger.warning(
|
||||
f"[CancelBatch] Failed to cancel job {batch_job.job_id}"
|
||||
)
|
||||
else:
|
||||
# Job is completed, failed, or already cancelled - skip it
|
||||
skipped_count += 1
|
||||
|
||||
logger.info(
|
||||
f"[CancelBatch] Batch {batch_id} cancelled by {current_user.username}: "
|
||||
f"{cancelled_count} cancelled, {skipped_count} skipped"
|
||||
)
|
||||
|
||||
# Build message
|
||||
if cancelled_count == 0:
|
||||
message = f"Nu există job-uri de anulat în batch-ul {batch_id}"
|
||||
elif skipped_count == 0:
|
||||
message = f"{cancelled_count} job-uri anulate"
|
||||
else:
|
||||
message = f"{cancelled_count} job-uri anulate, {skipped_count} ignorate (deja procesate)"
|
||||
|
||||
return CancelBatchResponse(
|
||||
success=cancelled_count > 0,
|
||||
batch_id=batch_id,
|
||||
cancelled_count=cancelled_count,
|
||||
skipped_count=skipped_count,
|
||||
message=message
|
||||
)
|
||||
@@ -4,7 +4,7 @@ from typing import List, Optional, Annotated
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Header, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.modules.data_entry.db.database import get_session
|
||||
@@ -19,6 +19,7 @@ from backend.modules.data_entry.schemas.receipt import (
|
||||
ReceiptResponse,
|
||||
ReceiptListResponse,
|
||||
ReceiptFilter,
|
||||
ProcessingStats,
|
||||
AttachmentResponse,
|
||||
AccountingEntryResponse,
|
||||
WorkflowAction,
|
||||
@@ -28,8 +29,12 @@ from backend.modules.data_entry.schemas.receipt import (
|
||||
AccountOption,
|
||||
CashRegisterOption,
|
||||
ExpenseTypeOption,
|
||||
BulkDeleteRequest,
|
||||
BulkDeleteResponse,
|
||||
BulkDeleteFailure,
|
||||
)
|
||||
from backend.modules.data_entry.db.models.receipt import ReceiptStatus, ReceiptDirection
|
||||
from backend.modules.data_entry.services import sse_service
|
||||
|
||||
# Auth integration
|
||||
from shared.auth.dependencies import get_current_user
|
||||
@@ -105,6 +110,51 @@ def get_current_user_company(current_user: CurrentUser) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
# ============ SSE Endpoint for Real-time Status Updates ============
|
||||
|
||||
@router.get("/sse/status")
|
||||
async def sse_status_stream(
|
||||
batch_id: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Optional batch_id to filter events for a specific batch"
|
||||
),
|
||||
):
|
||||
"""
|
||||
Server-Sent Events endpoint for real-time receipt status updates.
|
||||
|
||||
This endpoint provides a persistent connection that streams status change
|
||||
events as they occur. Clients receive updates for CRUD operations on receipts
|
||||
without needing to poll.
|
||||
|
||||
Query Parameters:
|
||||
batch_id: Optional filter to only receive events for a specific batch upload.
|
||||
|
||||
Event Format:
|
||||
data: {"receipt_id": 123, "status": "DRAFT", "processing_status": "completed", ...}
|
||||
|
||||
Headers:
|
||||
- Content-Type: text/event-stream
|
||||
- Cache-Control: no-cache
|
||||
- Connection: keep-alive
|
||||
|
||||
Reconnection:
|
||||
The retry: 3000 header hints clients to reconnect after 3 seconds if disconnected.
|
||||
|
||||
Example:
|
||||
curl -N http://localhost:8000/api/data-entry/receipts/sse/status
|
||||
curl -N http://localhost:8000/api/data-entry/receipts/sse/status?batch_id=abc-123
|
||||
"""
|
||||
return StreamingResponse(
|
||||
sse_service.subscribe(batch_id=batch_id),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # Disable nginx buffering
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============ Receipt CRUD Endpoints ============
|
||||
|
||||
@router.post("/", response_model=ReceiptResponse)
|
||||
@@ -128,12 +178,20 @@ async def list_receipts(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
# Bulk upload filters (US-012)
|
||||
processing_status: Optional[str] = Query(default=None, description="Filter by processing status: pending, processing, completed, failed"),
|
||||
batch_id: Optional[str] = Query(default=None, description="Filter by batch_id UUID"),
|
||||
sort_by: Optional[str] = Query(default=None, description="Sort field: processing_started_at, processing_started_at_asc"),
|
||||
# Pagination
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
selected_company: SelectedCompany = None,
|
||||
):
|
||||
"""Get paginated list of receipts with filters."""
|
||||
"""Get paginated list of receipts with filters.
|
||||
|
||||
US-012: Extended with batch_id, processing_status filters and processing_stats.
|
||||
"""
|
||||
# Disable browser caching to always get fresh data
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
@@ -148,6 +206,9 @@ async def list_receipts(
|
||||
date_from=date_type.fromisoformat(date_from) if date_from else None,
|
||||
date_to=date_type.fromisoformat(date_to) if date_to else None,
|
||||
search=search,
|
||||
processing_status=processing_status,
|
||||
batch_id=batch_id,
|
||||
sort_by=sort_by,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
@@ -231,6 +292,68 @@ async def update_receipt(
|
||||
return ReceiptResponse.model_validate(receipt)
|
||||
|
||||
|
||||
@router.delete("/bulk", response_model=BulkDeleteResponse)
|
||||
async def bulk_delete_receipts(
|
||||
data: BulkDeleteRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Bulk delete receipts (US-024).
|
||||
|
||||
Deletes multiple receipts in a single request with partial success support.
|
||||
|
||||
Validation rules:
|
||||
- Each receipt must be in DRAFT status
|
||||
- Each receipt must be created by the current user
|
||||
- Receipts with processing_status 'pending' or 'processing' cannot be deleted
|
||||
|
||||
Returns:
|
||||
BulkDeleteResponse with deleted IDs and failed items with error messages
|
||||
"""
|
||||
deleted: List[int] = []
|
||||
failed: List[BulkDeleteFailure] = []
|
||||
|
||||
for receipt_id in data.ids:
|
||||
# Get receipt with relationships for deletion
|
||||
receipt = await ReceiptCRUD.get_by_id(session, receipt_id, include_relations=True)
|
||||
|
||||
if not receipt:
|
||||
failed.append(BulkDeleteFailure(id=receipt_id, error="Bonul nu a fost găsit"))
|
||||
continue
|
||||
|
||||
# Check if receipt is being processed (bulk upload in progress)
|
||||
if receipt.processing_status in ["pending", "processing"]:
|
||||
failed.append(BulkDeleteFailure(
|
||||
id=receipt_id,
|
||||
error="Bonul este în curs de procesare și nu poate fi șters"
|
||||
))
|
||||
continue
|
||||
|
||||
# Check status - only DRAFT can be deleted
|
||||
if receipt.status != ReceiptStatus.DRAFT:
|
||||
failed.append(BulkDeleteFailure(
|
||||
id=receipt_id,
|
||||
error=f"Doar bonurile în status DRAFT pot fi șterse (status curent: {receipt.status.value})"
|
||||
))
|
||||
continue
|
||||
|
||||
# Check ownership
|
||||
if receipt.created_by != current_user.username:
|
||||
failed.append(BulkDeleteFailure(
|
||||
id=receipt_id,
|
||||
error="Doar creatorul bonului poate să-l șteargă"
|
||||
))
|
||||
continue
|
||||
|
||||
# All validations passed - delete the receipt
|
||||
# Note: Cascade delete handles attachments and accounting entries
|
||||
await ReceiptCRUD.delete(session, receipt)
|
||||
deleted.append(receipt_id)
|
||||
|
||||
return BulkDeleteResponse(deleted=deleted, failed=failed)
|
||||
|
||||
|
||||
@router.delete("/{receipt_id}")
|
||||
async def delete_receipt(
|
||||
receipt_id: int,
|
||||
@@ -261,6 +384,15 @@ async def submit_receipt(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
@@ -279,6 +411,15 @@ async def approve_receipt(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
@@ -298,6 +439,15 @@ async def reject_receipt(
|
||||
session, receipt_id, current_user.username, data.reason
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
@@ -316,6 +466,15 @@ async def resubmit_receipt(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
@@ -334,6 +493,15 @@ async def unapprove_receipt(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
|
||||
@@ -12,6 +12,12 @@ from .receipt import (
|
||||
WorkflowAction,
|
||||
RejectRequest,
|
||||
)
|
||||
from .bulk import (
|
||||
BulkUploadResponse,
|
||||
BatchJobInfo,
|
||||
BatchStatusResponse,
|
||||
BulkUploadError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ReceiptCreate",
|
||||
@@ -25,4 +31,9 @@ __all__ = [
|
||||
"AccountingEntryResponse",
|
||||
"WorkflowAction",
|
||||
"RejectRequest",
|
||||
# Bulk upload schemas
|
||||
"BulkUploadResponse",
|
||||
"BatchJobInfo",
|
||||
"BatchStatusResponse",
|
||||
"BulkUploadError",
|
||||
]
|
||||
|
||||
212
backend/modules/data_entry/schemas/bulk.py
Normal file
212
backend/modules/data_entry/schemas/bulk.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Pydantic schemas for bulk upload endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BulkUploadResponse(BaseModel):
|
||||
"""Response schema for bulk upload endpoint."""
|
||||
|
||||
batch_id: int = Field(..., description="Unique batch identifier for tracking")
|
||||
job_ids: List[str] = Field(..., description="List of OCR job UUIDs created")
|
||||
total_files: int = Field(..., description="Number of files in the batch")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"batch_id": 1,
|
||||
"job_ids": [
|
||||
"550e8400-e29b-41d4-a716-446655440001",
|
||||
"550e8400-e29b-41d4-a716-446655440002",
|
||||
],
|
||||
"total_files": 2,
|
||||
"message": "2 files queued for processing"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BatchJobInfo(BaseModel):
|
||||
"""Information about a single job in a batch."""
|
||||
|
||||
job_id: str = Field(..., description="OCR job UUID")
|
||||
filename: str = Field(..., description="Original filename")
|
||||
status: str = Field(..., description="Job status: pending, processing, completed, failed")
|
||||
receipt_id: Optional[int] = Field(None, description="Created receipt ID (if completed)")
|
||||
error_message: Optional[str] = Field(None, description="Error message (if failed)")
|
||||
|
||||
|
||||
class BatchStatusResponse(BaseModel):
|
||||
"""Response schema for batch status endpoint."""
|
||||
|
||||
batch_id: int = Field(..., description="Batch identifier")
|
||||
status: str = Field(..., description="Overall batch status")
|
||||
total_files: int = Field(..., description="Total number of files in batch")
|
||||
pending_count: int = Field(..., description="Number of pending jobs")
|
||||
processing_count: int = Field(..., description="Number of processing jobs")
|
||||
completed_count: int = Field(..., description="Number of completed jobs")
|
||||
failed_count: int = Field(..., description="Number of failed jobs")
|
||||
jobs: List[BatchJobInfo] = Field(..., description="List of jobs with their status")
|
||||
total_amount: Optional[float] = Field(None, description="Sum of all receipt amounts")
|
||||
created_at: datetime = Field(..., description="Batch creation timestamp")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"batch_id": 1,
|
||||
"status": "processing",
|
||||
"total_files": 5,
|
||||
"pending_count": 2,
|
||||
"processing_count": 1,
|
||||
"completed_count": 2,
|
||||
"failed_count": 0,
|
||||
"jobs": [
|
||||
{"job_id": "abc-123", "filename": "bon1.pdf", "status": "completed", "receipt_id": 15},
|
||||
{"job_id": "def-456", "filename": "bon2.jpg", "status": "processing", "receipt_id": None},
|
||||
],
|
||||
"total_amount": 150.50,
|
||||
"created_at": "2025-01-09T10:30:00"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DuplicateFileInfo(BaseModel):
|
||||
"""Information about a duplicate file detected during upload."""
|
||||
|
||||
filename: str = Field(..., description="Name of the duplicate file")
|
||||
error: str = Field(default="duplicate", description="Error type (always 'duplicate')")
|
||||
existing_receipt_id: int = Field(..., description="ID of the existing receipt with same file hash")
|
||||
message: str = Field(..., description="Human-readable error message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"filename": "bon_lidl.pdf",
|
||||
"error": "duplicate",
|
||||
"existing_receipt_id": 123,
|
||||
"message": "Fișier duplicat - există deja ca bon #123"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BulkUploadResponseWithDuplicates(BaseModel):
|
||||
"""Response schema for bulk upload with partial success (some duplicates)."""
|
||||
|
||||
batch_id: Optional[int] = Field(None, description="Batch ID (None if all files were duplicates)")
|
||||
job_ids: List[str] = Field(default_factory=list, description="List of OCR job UUIDs created")
|
||||
total_files: int = Field(..., description="Total number of files submitted")
|
||||
processed_files: int = Field(..., description="Number of files successfully queued")
|
||||
duplicate_files: int = Field(..., description="Number of duplicate files rejected")
|
||||
duplicates: List[DuplicateFileInfo] = Field(default_factory=list, description="List of duplicate file details")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"batch_id": 1,
|
||||
"job_ids": ["550e8400-e29b-41d4-a716-446655440001"],
|
||||
"total_files": 3,
|
||||
"processed_files": 1,
|
||||
"duplicate_files": 2,
|
||||
"duplicates": [
|
||||
{
|
||||
"filename": "bon_lidl.pdf",
|
||||
"error": "duplicate",
|
||||
"existing_receipt_id": 123,
|
||||
"message": "Fișier duplicat - există deja ca bon #123"
|
||||
}
|
||||
],
|
||||
"message": "1 fișier în procesare, 2 duplicate ignorate"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BulkUploadError(BaseModel):
|
||||
"""Error response for bulk upload validation failures."""
|
||||
|
||||
detail: str = Field(..., description="Error message")
|
||||
invalid_files: Optional[List[str]] = Field(None, description="List of invalid filenames")
|
||||
|
||||
|
||||
class RetryResponse(BaseModel):
|
||||
"""Response schema for retry endpoints."""
|
||||
|
||||
success: bool = Field(..., description="Whether the retry was successful")
|
||||
receipt_id: int = Field(..., description="Receipt ID that was retried")
|
||||
job_id: Optional[str] = Field(None, description="New OCR job ID created")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"receipt_id": 123,
|
||||
"job_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"message": "Bon reîncarcat în procesare"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BatchRetryResponse(BaseModel):
|
||||
"""Response schema for batch retry endpoint."""
|
||||
|
||||
success: bool = Field(..., description="Whether any retries were successful")
|
||||
batch_id: str = Field(..., description="Batch ID that was retried")
|
||||
retried_count: int = Field(..., description="Number of receipts successfully retried")
|
||||
failed_count: int = Field(..., description="Number of receipts that couldn't be retried")
|
||||
errors: List[str] = Field(default_factory=list, description="List of error messages")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"batch_id": "abc-123",
|
||||
"retried_count": 3,
|
||||
"failed_count": 0,
|
||||
"errors": [],
|
||||
"message": "3 bonuri reîncarcate în procesare"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CancelJobResponse(BaseModel):
|
||||
"""Response schema for cancel job endpoint."""
|
||||
|
||||
success: bool = Field(..., description="Whether the cancellation was successful")
|
||||
job_id: str = Field(..., description="Job ID that was cancelled")
|
||||
cancelled_at: datetime = Field(..., description="Timestamp when the job was cancelled")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"job_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"cancelled_at": "2025-01-11T15:30:00",
|
||||
"message": "Job anulat cu succes"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CancelBatchResponse(BaseModel):
|
||||
"""Response schema for cancel batch endpoint."""
|
||||
|
||||
success: bool = Field(..., description="Whether any jobs were cancelled")
|
||||
batch_id: int = Field(..., description="Batch ID that was cancelled")
|
||||
cancelled_count: int = Field(..., description="Number of jobs successfully cancelled")
|
||||
skipped_count: int = Field(..., description="Number of jobs skipped (completed/failed)")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"batch_id": 1,
|
||||
"cancelled_count": 3,
|
||||
"skipped_count": 2,
|
||||
"message": "3 job-uri anulate, 2 ignorate (deja procesate)"
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ from decimal import Decimal
|
||||
from typing import Optional, List, Any, Union
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
||||
|
||||
from backend.modules.data_entry.db.models.receipt import ReceiptType, ReceiptDirection, ReceiptStatus
|
||||
from backend.modules.data_entry.db.models.receipt import ReceiptType, ReceiptDirection, ReceiptStatus, ProcessingStatus
|
||||
from backend.modules.data_entry.db.models.accounting_entry import EntryType
|
||||
|
||||
|
||||
@@ -161,6 +161,14 @@ class ReceiptResponse(ReceiptBase):
|
||||
oracle_act_id: Optional[int] = None
|
||||
oracle_error: Optional[str] = None
|
||||
|
||||
# Bulk upload batch tracking (US-012)
|
||||
batch_id: Optional[str] = None
|
||||
processing_status: Optional[str] = None
|
||||
processing_error: Optional[str] = None
|
||||
file_hash: Optional[str] = None
|
||||
processing_started_at: Optional[datetime] = None
|
||||
processing_completed_at: Optional[datetime] = None
|
||||
|
||||
# Relationships (optional, loaded when needed)
|
||||
attachments: List[AttachmentResponse] = []
|
||||
entries: List[AccountingEntryResponse] = []
|
||||
@@ -196,6 +204,14 @@ class ReceiptResponse(ReceiptBase):
|
||||
return None
|
||||
|
||||
|
||||
class ProcessingStats(BaseModel):
|
||||
"""Statistics for bulk upload processing status (US-012)."""
|
||||
pending_count: int = 0
|
||||
processing_count: int = 0
|
||||
completed_count: int = 0
|
||||
failed_count: int = 0
|
||||
|
||||
|
||||
class ReceiptListResponse(BaseModel):
|
||||
"""Schema for paginated receipt list response."""
|
||||
items: List[ReceiptResponse]
|
||||
@@ -203,6 +219,8 @@ class ReceiptListResponse(BaseModel):
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
# Processing stats for bulk upload filtering (US-012)
|
||||
processing_stats: Optional[ProcessingStats] = None
|
||||
|
||||
|
||||
class ReceiptFilter(BaseModel):
|
||||
@@ -214,6 +232,11 @@ class ReceiptFilter(BaseModel):
|
||||
date_from: Optional[date] = None
|
||||
date_to: Optional[date] = None
|
||||
search: Optional[str] = None # Search in description, partner_name
|
||||
# Bulk upload filters (US-012)
|
||||
processing_status: Optional[str] = None # ProcessingStatus enum value
|
||||
batch_id: Optional[str] = None # Filter by batch_id
|
||||
sort_by: Optional[str] = None # Sort field (e.g., "processing_started_at")
|
||||
# Pagination
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
@@ -267,3 +290,22 @@ class ExpenseTypeOption(BaseModel):
|
||||
account_code: str
|
||||
has_vat: bool
|
||||
vat_percent: Decimal = Decimal("19")
|
||||
|
||||
|
||||
# ============ Bulk Delete Schemas (US-024) ============
|
||||
|
||||
class BulkDeleteRequest(BaseModel):
|
||||
"""Request schema for bulk delete endpoint."""
|
||||
ids: List[int] = Field(..., min_length=1, description="List of receipt IDs to delete")
|
||||
|
||||
|
||||
class BulkDeleteFailure(BaseModel):
|
||||
"""Schema for a single failed deletion."""
|
||||
id: int
|
||||
error: str
|
||||
|
||||
|
||||
class BulkDeleteResponse(BaseModel):
|
||||
"""Response schema for bulk delete with partial success support."""
|
||||
deleted: List[int] = Field(default_factory=list, description="IDs of successfully deleted receipts")
|
||||
failed: List[BulkDeleteFailure] = Field(default_factory=list, description="IDs that failed with error messages")
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
from .receipt_service import ReceiptService
|
||||
from .nomenclature_service import NomenclatureService
|
||||
from .expense_types import EXPENSE_TYPES, ExpenseType
|
||||
from .receipt_auto_create import ReceiptAutoCreateService, ReceiptCreateResult
|
||||
from . import sse_service
|
||||
|
||||
__all__ = [
|
||||
"ReceiptService",
|
||||
"NomenclatureService",
|
||||
"EXPENSE_TYPES",
|
||||
"ExpenseType",
|
||||
"ReceiptAutoCreateService",
|
||||
"ReceiptCreateResult",
|
||||
"sse_service",
|
||||
]
|
||||
|
||||
215
backend/modules/data_entry/services/cleanup_service.py
Normal file
215
backend/modules/data_entry/services/cleanup_service.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Cleanup service for auto-deleting expired failed receipts.
|
||||
|
||||
US-008: Backend - Auto-Cleanup Erori După 7 Zile
|
||||
- Finds receipts with processing_status='failed' and processing_completed_at < now() - 7 days
|
||||
- Deletes the receipts and their attached files from storage
|
||||
- Runs at startup and then daily as a background task
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.modules.data_entry.db.models.receipt import Receipt, ReceiptAttachment
|
||||
from backend.modules.data_entry.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cleanup configuration
|
||||
CLEANUP_RETENTION_DAYS = 7
|
||||
CLEANUP_INTERVAL_HOURS = 24
|
||||
|
||||
# In-memory storage for last cleanup stats (optional - for login notification)
|
||||
_last_cleanup_stats: dict = {
|
||||
"count": 0,
|
||||
"timestamp": None
|
||||
}
|
||||
|
||||
|
||||
def get_last_cleanup_stats() -> dict:
|
||||
"""Get stats from the last cleanup run for notification purposes."""
|
||||
return _last_cleanup_stats.copy()
|
||||
|
||||
|
||||
async def cleanup_expired_failed_receipts(session: AsyncSession) -> int:
|
||||
"""
|
||||
Find and delete receipts with processing_status='failed' older than 7 days.
|
||||
|
||||
This function:
|
||||
1. Queries for failed receipts where processing_completed_at < now() - 7 days
|
||||
2. Deletes attachment files from disk
|
||||
3. Deletes the receipt records (cascade deletes attachment records)
|
||||
|
||||
Args:
|
||||
session: AsyncSession for database operations
|
||||
|
||||
Returns:
|
||||
Number of receipts deleted
|
||||
"""
|
||||
global _last_cleanup_stats
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=CLEANUP_RETENTION_DAYS)
|
||||
|
||||
# Find expired failed receipts with their attachments
|
||||
query = select(Receipt).options(
|
||||
selectinload(Receipt.attachments)
|
||||
).where(
|
||||
and_(
|
||||
Receipt.processing_status == "failed",
|
||||
Receipt.processing_completed_at.isnot(None),
|
||||
Receipt.processing_completed_at < cutoff_date
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
expired_receipts = result.scalars().all()
|
||||
|
||||
if not expired_receipts:
|
||||
logger.debug("[Cleanup] No expired failed receipts found")
|
||||
return 0
|
||||
|
||||
deleted_count = 0
|
||||
deleted_files = 0
|
||||
|
||||
upload_base_path = settings.upload_path_resolved
|
||||
|
||||
for receipt in expired_receipts:
|
||||
try:
|
||||
# Delete attachment files from disk
|
||||
for attachment in receipt.attachments:
|
||||
file_path = upload_base_path / attachment.file_path
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
deleted_files += 1
|
||||
logger.debug(f"[Cleanup] Deleted file: {file_path}")
|
||||
except OSError as e:
|
||||
logger.warning(f"[Cleanup] Failed to delete file {file_path}: {e}")
|
||||
|
||||
# Also try to clean up empty parent directories
|
||||
parent_dir = file_path.parent
|
||||
if parent_dir.exists() and parent_dir != upload_base_path:
|
||||
try:
|
||||
# Only remove if directory is empty
|
||||
if not any(parent_dir.iterdir()):
|
||||
parent_dir.rmdir()
|
||||
logger.debug(f"[Cleanup] Removed empty directory: {parent_dir}")
|
||||
except OSError:
|
||||
pass # Directory not empty or permission issue, skip
|
||||
|
||||
# Delete receipt (cascade deletes attachment records in DB)
|
||||
await session.delete(receipt)
|
||||
deleted_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cleanup] Error deleting receipt {receipt.id}: {e}")
|
||||
continue
|
||||
|
||||
# Commit all deletions
|
||||
if deleted_count > 0:
|
||||
await session.commit()
|
||||
|
||||
# Update stats for notification
|
||||
_last_cleanup_stats = {
|
||||
"count": deleted_count,
|
||||
"files_deleted": deleted_files,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
logger.info(f"[Cleanup] Cleaned up {deleted_count} expired failed receipts ({deleted_files} files)")
|
||||
|
||||
return deleted_count
|
||||
|
||||
|
||||
async def run_cleanup_task(get_session_func) -> None:
|
||||
"""
|
||||
Background task that runs cleanup at startup and then every 24 hours.
|
||||
|
||||
Args:
|
||||
get_session_func: Async generator function that yields database sessions
|
||||
"""
|
||||
logger.info("[Cleanup] Starting cleanup background task")
|
||||
|
||||
# Run immediately at startup
|
||||
try:
|
||||
async for session in get_session_func():
|
||||
count = await cleanup_expired_failed_receipts(session)
|
||||
if count > 0:
|
||||
logger.info(f"[Cleanup] Initial cleanup: {count} receipts removed")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[Cleanup] Initial cleanup failed: {e}")
|
||||
|
||||
# Then run every 24 hours
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(CLEANUP_INTERVAL_HOURS * 3600)
|
||||
|
||||
async for session in get_session_func():
|
||||
count = await cleanup_expired_failed_receipts(session)
|
||||
if count > 0:
|
||||
logger.info(f"[Cleanup] Daily cleanup: {count} receipts removed")
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[Cleanup] Cleanup task cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Cleanup] Daily cleanup failed: {e}")
|
||||
# Continue running even if one cleanup fails
|
||||
|
||||
|
||||
# Global reference to cleanup task for graceful shutdown
|
||||
_cleanup_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
async def start_cleanup_task(get_session_func) -> bool:
|
||||
"""
|
||||
Start the cleanup background task.
|
||||
|
||||
Args:
|
||||
get_session_func: Async generator function that yields database sessions
|
||||
|
||||
Returns:
|
||||
True if task started successfully, False otherwise
|
||||
"""
|
||||
global _cleanup_task
|
||||
|
||||
if _cleanup_task is not None and not _cleanup_task.done():
|
||||
logger.warning("[Cleanup] Cleanup task already running")
|
||||
return False
|
||||
|
||||
try:
|
||||
_cleanup_task = asyncio.create_task(run_cleanup_task(get_session_func))
|
||||
logger.info("[Cleanup] ✅ Cleanup background task started")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[Cleanup] Failed to start cleanup task: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def stop_cleanup_task() -> None:
|
||||
"""Stop the cleanup background task gracefully."""
|
||||
global _cleanup_task
|
||||
|
||||
if _cleanup_task is not None and not _cleanup_task.done():
|
||||
_cleanup_task.cancel()
|
||||
try:
|
||||
await _cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("[Cleanup] Cleanup task stopped")
|
||||
|
||||
_cleanup_task = None
|
||||
|
||||
|
||||
def is_cleanup_task_running() -> bool:
|
||||
"""Check if the cleanup task is currently running."""
|
||||
return _cleanup_task is not None and not _cleanup_task.done()
|
||||
@@ -23,7 +23,9 @@ Schema:
|
||||
ocr_time_ms INTEGER, -- Actual OCR engine processing time
|
||||
created_by TEXT, -- Username
|
||||
original_filename TEXT,
|
||||
expires_at TIMESTAMP
|
||||
expires_at TIMESTAMP,
|
||||
batch_id INTEGER, -- Foreign key to batch_uploads (for bulk processing)
|
||||
file_hash TEXT -- SHA-256 hash for duplicate detection (US-007)
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -66,6 +68,7 @@ class OCRJobStatus(str, Enum):
|
||||
processing = "processing"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -86,6 +89,8 @@ class OCRJob:
|
||||
created_by: Optional[str] = None
|
||||
original_filename: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
batch_id: Optional[int] = None # Links to batch_uploads table for bulk processing
|
||||
file_hash: Optional[str] = None # SHA-256 hash for duplicate detection (US-007)
|
||||
|
||||
@property
|
||||
def queue_wait_ms(self) -> Optional[int]:
|
||||
@@ -163,7 +168,8 @@ class OCRJobQueue:
|
||||
ocr_time_ms INTEGER,
|
||||
created_by TEXT,
|
||||
original_filename TEXT,
|
||||
expires_at TIMESTAMP
|
||||
expires_at TIMESTAMP,
|
||||
batch_id INTEGER
|
||||
)
|
||||
''')
|
||||
|
||||
@@ -174,6 +180,20 @@ class OCRJobQueue:
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
|
||||
# Migration: add batch_id column if it doesn't exist
|
||||
try:
|
||||
await db.execute('ALTER TABLE ocr_jobs ADD COLUMN batch_id INTEGER')
|
||||
logger.info("[OCRJobQueue] Added batch_id column to existing table")
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
|
||||
# Migration: add file_hash column if it doesn't exist (US-007)
|
||||
try:
|
||||
await db.execute('ALTER TABLE ocr_jobs ADD COLUMN file_hash TEXT')
|
||||
logger.info("[OCRJobQueue] Added file_hash column to existing table")
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
|
||||
# Index for efficient queue queries
|
||||
await db.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_ocr_jobs_status
|
||||
@@ -197,7 +217,9 @@ class OCRJobQueue:
|
||||
mime_type: str,
|
||||
engine: str = "doctr_plus",
|
||||
username: Optional[str] = None,
|
||||
original_filename: Optional[str] = None
|
||||
original_filename: Optional[str] = None,
|
||||
batch_id: Optional[int] = None,
|
||||
file_hash: Optional[str] = None
|
||||
) -> OCRJob:
|
||||
"""
|
||||
Create a new OCR job.
|
||||
@@ -210,6 +232,8 @@ class OCRJobQueue:
|
||||
engine: OCR engine ('tesseract', 'doctr', 'doctr_plus', 'paddleocr')
|
||||
username: Username of requester
|
||||
original_filename: Original filename from upload
|
||||
batch_id: Optional batch ID for bulk upload processing
|
||||
file_hash: Optional SHA-256 hash for duplicate detection (US-007)
|
||||
|
||||
Returns:
|
||||
Created OCRJob instance
|
||||
@@ -241,15 +265,15 @@ class OCRJobQueue:
|
||||
await db.execute('''
|
||||
INSERT INTO ocr_jobs (
|
||||
id, status, file_path, mime_type, engine,
|
||||
created_at, created_by, original_filename, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
created_at, created_by, original_filename, expires_at, batch_id, file_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
job_id, OCRJobStatus.pending.value, str(file_path), mime_type, engine,
|
||||
now.isoformat(), username, original_filename, expires_at.isoformat()
|
||||
now.isoformat(), username, original_filename, expires_at.isoformat(), batch_id, file_hash
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"[OCRJobQueue] Created job {job_id}: engine={engine}, file={file_path.name}")
|
||||
logger.info(f"[OCRJobQueue] Created job {job_id}: engine={engine}, file={file_path.name}, batch_id={batch_id}")
|
||||
|
||||
return OCRJob(
|
||||
id=job_id,
|
||||
@@ -260,7 +284,9 @@ class OCRJobQueue:
|
||||
created_at=now,
|
||||
created_by=username,
|
||||
original_filename=original_filename,
|
||||
expires_at=expires_at
|
||||
expires_at=expires_at,
|
||||
batch_id=batch_id,
|
||||
file_hash=file_hash
|
||||
)
|
||||
|
||||
async def get_job(self, job_id: str) -> Optional[OCRJob]:
|
||||
@@ -601,6 +627,8 @@ class OCRJobQueue:
|
||||
created_by=row['created_by'],
|
||||
original_filename=row['original_filename'],
|
||||
expires_at=parse_datetime(row['expires_at']),
|
||||
batch_id=row['batch_id'] if 'batch_id' in row.keys() else None,
|
||||
file_hash=row['file_hash'] if 'file_hash' in row.keys() else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from typing import Optional, Set
|
||||
|
||||
from .job_queue import job_queue, OCRJobStatus, OCRJob
|
||||
from .ocr_worker_pool import ocr_worker_pool
|
||||
from backend.modules.data_entry.schemas.ocr import ExtractionData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -223,6 +224,21 @@ async def _process_job(job: OCRJob) -> None:
|
||||
validation_errors_count=len(extraction.get('validation_errors', [])),
|
||||
)
|
||||
|
||||
# Auto-save receipt for batch jobs
|
||||
if job.batch_id:
|
||||
auto_save_result = await _auto_save_batch_receipt(
|
||||
job=job,
|
||||
extraction=extraction,
|
||||
file_path=str(file_path)
|
||||
)
|
||||
if not auto_save_result:
|
||||
# Auto-save failed - mark job as failed
|
||||
# Note: job_queue status already updated to 'completed' above
|
||||
# We need to update it back to failed with the auto-save error
|
||||
logger.warning(
|
||||
f"[JobWorker] Job {job.id} OCR succeeded but auto-save failed"
|
||||
)
|
||||
|
||||
else:
|
||||
# Job failed
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
@@ -543,3 +559,107 @@ def _count_extracted_fields(extraction: dict) -> int:
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Auto-Save Batch Receipt Helper
|
||||
# ============================================================================
|
||||
|
||||
async def _auto_save_batch_receipt(
|
||||
job: OCRJob,
|
||||
extraction: dict,
|
||||
file_path: str
|
||||
) -> bool:
|
||||
"""
|
||||
Automatically create a receipt from OCR result for batch jobs.
|
||||
|
||||
Called when a batch job completes successfully. Creates the receipt,
|
||||
attachment, and accounting entries using ReceiptAutoCreateService.
|
||||
|
||||
Args:
|
||||
job: Completed OCRJob with batch_id set
|
||||
extraction: OCR extraction result dict
|
||||
file_path: Path to the original uploaded file
|
||||
|
||||
Returns:
|
||||
True if receipt created successfully, False otherwise
|
||||
"""
|
||||
if not job.batch_id:
|
||||
return True # Not a batch job, nothing to do
|
||||
|
||||
logger.info(f"[JobWorker] Auto-saving receipt for batch job {job.id} (batch_id={job.batch_id})")
|
||||
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from backend.modules.data_entry.db.database import get_db_session
|
||||
from backend.modules.data_entry.db.models import BatchUpload
|
||||
from backend.modules.data_entry.services.receipt_auto_create import ReceiptAutoCreateService
|
||||
from sqlalchemy import select
|
||||
|
||||
# Convert extraction dict to ExtractionData schema
|
||||
ocr_result = ExtractionData(**extraction)
|
||||
|
||||
async with await get_db_session() as session:
|
||||
# Get batch info to retrieve company_id and user_id
|
||||
batch_result = await session.execute(
|
||||
select(BatchUpload).where(BatchUpload.id == job.batch_id)
|
||||
)
|
||||
batch = batch_result.scalar_one_or_none()
|
||||
|
||||
if not batch:
|
||||
error_msg = f"Batch {job.batch_id} not found"
|
||||
logger.error(f"[JobWorker] Auto-save failed for job {job.id}: {error_msg}")
|
||||
await job_queue.update_status(
|
||||
job_id=job.id,
|
||||
status=OCRJobStatus.failed,
|
||||
error=f"Auto-save error: {error_msg}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Call ReceiptAutoCreateService
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=session,
|
||||
job_id=job.id,
|
||||
ocr_result=ocr_result,
|
||||
username=job.created_by or batch.user_id,
|
||||
batch_id=job.batch_id,
|
||||
company_id=batch.company_id,
|
||||
file_path=file_path,
|
||||
original_filename=job.original_filename,
|
||||
file_hash=job.file_hash # Pass file_hash for duplicate detection (US-007)
|
||||
)
|
||||
|
||||
if result.success:
|
||||
logger.info(
|
||||
f"[JobWorker] Auto-save successful for job {job.id}: "
|
||||
f"receipt_id={result.receipt_id}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
error_msg = result.error_message or "Unknown error"
|
||||
logger.warning(
|
||||
f"[JobWorker] Auto-save validation failed for job {job.id}: {error_msg}"
|
||||
)
|
||||
# Update job status to failed with the auto-save error
|
||||
await job_queue.update_status(
|
||||
job_id=job.id,
|
||||
status=OCRJobStatus.failed,
|
||||
error=f"Auto-save error: {error_msg}"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"[JobWorker] Auto-save exception for job {job.id}: {error_msg}")
|
||||
|
||||
# Update job status to failed
|
||||
try:
|
||||
await job_queue.update_status(
|
||||
job_id=job.id,
|
||||
status=OCRJobStatus.failed,
|
||||
error=f"Auto-save error: {error_msg}"
|
||||
)
|
||||
except Exception as update_err:
|
||||
logger.error(f"[JobWorker] Failed to update job status after auto-save error: {update_err}")
|
||||
|
||||
return False
|
||||
|
||||
385
backend/modules/data_entry/services/receipt_auto_create.py
Normal file
385
backend/modules/data_entry/services/receipt_auto_create.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Auto-create Receipt from OCR results for bulk upload flow.
|
||||
|
||||
This service handles automatic creation of Receipt records from OCR extraction
|
||||
results, enabling end-to-end processing without manual UI intervention.
|
||||
|
||||
The service:
|
||||
1. Maps OCR ExtractionData fields to Receipt fields
|
||||
2. Creates attachment from the original uploaded file
|
||||
3. Generates accounting entries
|
||||
4. Links the receipt back to the batch job for tracking
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.modules.data_entry.db.models.receipt import (
|
||||
Receipt,
|
||||
ReceiptAttachment,
|
||||
ReceiptStatus,
|
||||
ReceiptType,
|
||||
ReceiptDirection,
|
||||
)
|
||||
from backend.modules.data_entry.db.models.batch import BatchJob
|
||||
from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD
|
||||
from backend.modules.data_entry.db.crud.accounting_entry import AccountingEntryCRUD
|
||||
from backend.modules.data_entry.schemas.receipt import ReceiptCreate, TvaEntrySchema, PaymentMethodSchema
|
||||
from backend.modules.data_entry.schemas.ocr import ExtractionData
|
||||
from backend.modules.data_entry.services.receipt_service import ReceiptService
|
||||
from backend.modules.data_entry.services import sse_service
|
||||
from backend.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReceiptCreateResult:
|
||||
"""Result of auto-create operation."""
|
||||
success: bool
|
||||
receipt_id: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class ReceiptAutoCreateService:
|
||||
"""
|
||||
Service for automatically creating receipts from OCR results.
|
||||
|
||||
Used by the bulk upload flow to create receipts without user intervention.
|
||||
Created receipts are in DRAFT status and require review before approval.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _validate_ocr_result(ocr_result: ExtractionData) -> tuple[bool, str]:
|
||||
"""
|
||||
Perform minimal validation on OCR result.
|
||||
|
||||
Validates:
|
||||
- amount > 0 (required for receipt)
|
||||
- date is valid and not in future
|
||||
|
||||
Args:
|
||||
ocr_result: Extracted data from OCR
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# Validate amount exists and is positive
|
||||
if ocr_result.amount is None:
|
||||
return False, "Amount not extracted from receipt"
|
||||
|
||||
if ocr_result.amount <= 0:
|
||||
return False, f"Invalid amount: {ocr_result.amount} (must be > 0)"
|
||||
|
||||
# Validate date exists and is not in the future
|
||||
if ocr_result.receipt_date is None:
|
||||
return False, "Receipt date not extracted"
|
||||
|
||||
today = date.today()
|
||||
if ocr_result.receipt_date > today:
|
||||
return False, f"Receipt date {ocr_result.receipt_date} is in the future"
|
||||
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _map_ocr_to_receipt(
|
||||
ocr_result: ExtractionData,
|
||||
company_id: int,
|
||||
) -> ReceiptCreate:
|
||||
"""
|
||||
Map OCR ExtractionData fields to ReceiptCreate schema.
|
||||
|
||||
Args:
|
||||
ocr_result: Extracted data from OCR
|
||||
company_id: Company ID for the receipt
|
||||
|
||||
Returns:
|
||||
ReceiptCreate schema ready for database insertion
|
||||
"""
|
||||
# Map receipt type
|
||||
receipt_type = ReceiptType.BON_FISCAL
|
||||
if ocr_result.receipt_type == "chitanta":
|
||||
receipt_type = ReceiptType.CHITANTA
|
||||
|
||||
# Map TVA breakdown from OCR TvaEntry to schema TvaEntrySchema
|
||||
tva_breakdown: Optional[List[TvaEntrySchema]] = None
|
||||
if ocr_result.tva_entries:
|
||||
tva_breakdown = [
|
||||
TvaEntrySchema(
|
||||
code=entry.code,
|
||||
percent=entry.percent,
|
||||
amount=entry.amount
|
||||
)
|
||||
for entry in ocr_result.tva_entries
|
||||
]
|
||||
|
||||
# Map payment methods
|
||||
payment_methods: Optional[List[PaymentMethodSchema]] = None
|
||||
if ocr_result.payment_methods:
|
||||
payment_methods = [
|
||||
PaymentMethodSchema(
|
||||
method=pm.method,
|
||||
amount=pm.amount
|
||||
)
|
||||
for pm in ocr_result.payment_methods
|
||||
]
|
||||
|
||||
# Create receipt data
|
||||
return ReceiptCreate(
|
||||
receipt_type=receipt_type,
|
||||
direction=ReceiptDirection.CHELTUIALA, # Default to expense
|
||||
receipt_number=ocr_result.receipt_number,
|
||||
receipt_series=ocr_result.receipt_series,
|
||||
receipt_date=ocr_result.receipt_date,
|
||||
amount=ocr_result.amount,
|
||||
description=ocr_result.description,
|
||||
tva_breakdown=tva_breakdown,
|
||||
tva_total=ocr_result.tva_total,
|
||||
items_count=ocr_result.items_count,
|
||||
vendor_address=ocr_result.address,
|
||||
company_id=company_id,
|
||||
partner_name=ocr_result.partner_name,
|
||||
cui=ocr_result.cui,
|
||||
ocr_raw_text=ocr_result.raw_text[:5000] if ocr_result.raw_text else None, # Limit size
|
||||
payment_methods=payment_methods,
|
||||
payment_mode=ocr_result.suggested_payment_mode,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _create_attachment_from_file(
|
||||
session: AsyncSession,
|
||||
receipt_id: int,
|
||||
source_file_path: str,
|
||||
original_filename: Optional[str] = None,
|
||||
) -> Optional[ReceiptAttachment]:
|
||||
"""
|
||||
Create attachment by copying file from OCR job location.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
receipt_id: Receipt ID to attach to
|
||||
source_file_path: Path to the original file from OCR job
|
||||
original_filename: Original filename from upload (optional)
|
||||
|
||||
Returns:
|
||||
Created ReceiptAttachment or None if failed
|
||||
"""
|
||||
source_path = Path(source_file_path)
|
||||
|
||||
if not source_path.exists():
|
||||
logger.warning(f"[ReceiptAutoCreate] Source file not found: {source_path}")
|
||||
return None
|
||||
|
||||
# Generate stored filename
|
||||
ext = source_path.suffix.lower()
|
||||
stored_filename = f"{uuid.uuid4()}{ext}"
|
||||
|
||||
# Determine relative path (organized by year/month)
|
||||
now = datetime.utcnow()
|
||||
relative_path = Path(str(now.year)) / f"{now.month:02d}"
|
||||
|
||||
# Full destination path
|
||||
dest_dir = settings.data_entry_upload_path_resolved / relative_path
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_path = dest_dir / stored_filename
|
||||
|
||||
# Copy file to attachments directory
|
||||
try:
|
||||
shutil.copy2(source_path, dest_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[ReceiptAutoCreate] Failed to copy file: {e}")
|
||||
return None
|
||||
|
||||
# Get file size
|
||||
file_size = dest_path.stat().st_size
|
||||
|
||||
# Determine MIME type
|
||||
mime_map = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.pdf': 'application/pdf',
|
||||
}
|
||||
mime_type = mime_map.get(ext, 'application/octet-stream')
|
||||
|
||||
# Use original filename if provided, otherwise use source filename
|
||||
display_filename = original_filename or source_path.name
|
||||
|
||||
# Create attachment record
|
||||
attachment = ReceiptAttachment(
|
||||
receipt_id=receipt_id,
|
||||
filename=display_filename,
|
||||
stored_filename=stored_filename,
|
||||
file_path=str(relative_path / stored_filename),
|
||||
file_size=file_size,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
|
||||
session.add(attachment)
|
||||
await session.flush()
|
||||
|
||||
return attachment
|
||||
|
||||
@staticmethod
|
||||
async def _update_batch_job_receipt_id(
|
||||
session: AsyncSession,
|
||||
job_id: str,
|
||||
receipt_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Update batch_jobs table with the created receipt_id.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
job_id: OCR job UUID
|
||||
receipt_id: Created receipt ID
|
||||
"""
|
||||
await session.execute(
|
||||
update(BatchJob)
|
||||
.where(BatchJob.job_id == job_id)
|
||||
.values(receipt_id=receipt_id)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def create_from_ocr_result(
|
||||
session: AsyncSession,
|
||||
job_id: str,
|
||||
ocr_result: ExtractionData,
|
||||
username: str,
|
||||
batch_id: int,
|
||||
company_id: int,
|
||||
file_path: Optional[str] = None,
|
||||
original_filename: Optional[str] = None,
|
||||
file_hash: Optional[str] = None,
|
||||
) -> ReceiptCreateResult:
|
||||
"""
|
||||
Create a receipt from OCR extraction result.
|
||||
|
||||
This method:
|
||||
1. Validates the OCR result (amount > 0, date valid)
|
||||
2. Maps OCR fields to Receipt fields
|
||||
3. Creates the Receipt in DRAFT status
|
||||
4. Creates attachment from original file
|
||||
5. Generates accounting entries
|
||||
6. Updates batch_jobs with receipt_id
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
job_id: OCR job UUID for tracking
|
||||
ocr_result: Extracted data from OCR processing
|
||||
username: User who initiated the upload
|
||||
batch_id: Batch ID for grouping
|
||||
company_id: Company ID for the receipt
|
||||
file_path: Path to the original uploaded file
|
||||
original_filename: Original filename from upload
|
||||
file_hash: SHA-256 hash of the file for duplicate detection (US-007)
|
||||
|
||||
Returns:
|
||||
ReceiptCreateResult with success status and receipt_id or error
|
||||
"""
|
||||
try:
|
||||
# Step 1: Validate OCR result
|
||||
is_valid, error_msg = ReceiptAutoCreateService._validate_ocr_result(ocr_result)
|
||||
if not is_valid:
|
||||
logger.warning(f"[ReceiptAutoCreate] Validation failed for job {job_id}: {error_msg}")
|
||||
return ReceiptCreateResult(
|
||||
success=False,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
# Step 2: Map OCR to Receipt schema
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=ocr_result,
|
||||
company_id=company_id,
|
||||
)
|
||||
|
||||
# Step 3: Create receipt in DRAFT status
|
||||
receipt = await ReceiptCRUD.create(session, receipt_data, created_by=username)
|
||||
|
||||
# Set batch tracking fields (US-007, US-011)
|
||||
receipt.batch_id = str(batch_id)
|
||||
receipt.file_hash = file_hash
|
||||
receipt.processing_status = "completed"
|
||||
session.add(receipt)
|
||||
await session.flush()
|
||||
|
||||
logger.info(
|
||||
f"[ReceiptAutoCreate] Created receipt {receipt.id} for job {job_id}: "
|
||||
f"amount={receipt.amount}, vendor={receipt.partner_name}, file_hash={file_hash[:16] if file_hash else None}..."
|
||||
)
|
||||
|
||||
# Step 4: Create attachment from original file (if path provided)
|
||||
if file_path:
|
||||
attachment = await ReceiptAutoCreateService._create_attachment_from_file(
|
||||
session=session,
|
||||
receipt_id=receipt.id,
|
||||
source_file_path=file_path,
|
||||
original_filename=original_filename,
|
||||
)
|
||||
if attachment:
|
||||
logger.info(f"[ReceiptAutoCreate] Created attachment for receipt {receipt.id}")
|
||||
else:
|
||||
logger.warning(f"[ReceiptAutoCreate] Failed to create attachment for receipt {receipt.id}")
|
||||
|
||||
# Step 5: Generate accounting entries
|
||||
# Note: For DRAFT status, entries are generated but not required for validation
|
||||
try:
|
||||
entries = ReceiptService.generate_accounting_entries(receipt)
|
||||
if entries:
|
||||
await AccountingEntryCRUD.create_bulk(
|
||||
session, receipt.id, entries, is_auto_generated=True
|
||||
)
|
||||
logger.info(
|
||||
f"[ReceiptAutoCreate] Generated {len(entries)} accounting entries "
|
||||
f"for receipt {receipt.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't fail the receipt creation if entry generation fails
|
||||
logger.warning(
|
||||
f"[ReceiptAutoCreate] Failed to generate entries for receipt {receipt.id}: {e}"
|
||||
)
|
||||
|
||||
# Step 6: Update batch_jobs with receipt_id
|
||||
await ReceiptAutoCreateService._update_batch_job_receipt_id(
|
||||
session=session,
|
||||
job_id=job_id,
|
||||
receipt_id=receipt.id,
|
||||
)
|
||||
|
||||
# Commit all changes
|
||||
await session.commit()
|
||||
|
||||
# Broadcast SSE event for real-time updates (US-030)
|
||||
try:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't fail the receipt creation if SSE broadcast fails
|
||||
logger.warning(f"[ReceiptAutoCreate] SSE broadcast failed for receipt {receipt.id}: {e}")
|
||||
|
||||
return ReceiptCreateResult(
|
||||
success=True,
|
||||
receipt_id=receipt.id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ReceiptAutoCreate] Failed to create receipt for job {job_id}: {e}")
|
||||
await session.rollback()
|
||||
return ReceiptCreateResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from backend.modules.data_entry.schemas.receipt import (
|
||||
ReceiptFilter,
|
||||
ReceiptResponse,
|
||||
ReceiptListResponse,
|
||||
ProcessingStats,
|
||||
AccountingEntryCreate,
|
||||
)
|
||||
from backend.modules.data_entry.services.expense_types import EXPENSE_TYPES, get_expense_type
|
||||
@@ -53,17 +54,26 @@ class ReceiptService:
|
||||
session: AsyncSession,
|
||||
filters: ReceiptFilter,
|
||||
) -> ReceiptListResponse:
|
||||
"""Get paginated list of receipts."""
|
||||
"""Get paginated list of receipts with processing_stats (US-012)."""
|
||||
receipts, total = await ReceiptCRUD.get_list(session, filters)
|
||||
|
||||
pages = (total + filters.page_size - 1) // filters.page_size if total > 0 else 1
|
||||
|
||||
# Get processing stats for bulk uploaded receipts (US-012)
|
||||
stats_dict = await ReceiptCRUD.get_processing_stats(
|
||||
session,
|
||||
company_id=filters.company_id,
|
||||
batch_id=filters.batch_id,
|
||||
)
|
||||
processing_stats = ProcessingStats(**stats_dict)
|
||||
|
||||
return ReceiptListResponse(
|
||||
items=[ReceiptResponse.model_validate(r) for r in receipts],
|
||||
total=total,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
pages=pages,
|
||||
processing_stats=processing_stats,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
197
backend/modules/data_entry/services/sse_service.py
Normal file
197
backend/modules/data_entry/services/sse_service.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Server-Sent Events (SSE) service for real-time status updates.
|
||||
|
||||
This module implements an event broadcaster pattern using asyncio.Queue per client.
|
||||
When receipt status changes occur (CRUD operations), events are pushed to all
|
||||
connected clients who are listening for that specific batch or all receipts.
|
||||
|
||||
Usage:
|
||||
# In router endpoint (SSE stream):
|
||||
async for event in sse_service.subscribe(batch_id=None):
|
||||
yield event
|
||||
|
||||
# When status changes (from CRUD operations):
|
||||
await sse_service.broadcast_status_change(receipt_id, status, processing_status, batch_id)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import AsyncGenerator, Optional
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusChangeEvent:
|
||||
"""Event data for receipt status changes."""
|
||||
receipt_id: int
|
||||
status: str
|
||||
processing_status: Optional[str] = None
|
||||
batch_id: Optional[str] = None
|
||||
timestamp: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.timestamp is None:
|
||||
self.timestamp = datetime.utcnow().isoformat()
|
||||
|
||||
def to_sse_data(self) -> str:
|
||||
"""Format as SSE data line."""
|
||||
data = asdict(self)
|
||||
return f"data: {json.dumps(data)}\n\n"
|
||||
|
||||
|
||||
class SSEEventBroadcaster:
|
||||
"""
|
||||
Manages SSE client connections and broadcasts events.
|
||||
|
||||
Each client gets its own asyncio.Queue. When an event occurs,
|
||||
it's pushed to all relevant queues based on batch_id filtering.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Dict of {client_id: (queue, batch_id_filter)}
|
||||
# batch_id_filter is None for clients that want all events
|
||||
self._clients: dict[str, tuple[asyncio.Queue, Optional[str]]] = {}
|
||||
self._client_counter = 0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _generate_client_id(self) -> str:
|
||||
"""Generate unique client ID."""
|
||||
async with self._lock:
|
||||
self._client_counter += 1
|
||||
return f"client_{self._client_counter}_{datetime.utcnow().timestamp()}"
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
batch_id: Optional[str] = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Subscribe to SSE events.
|
||||
|
||||
Args:
|
||||
batch_id: Optional filter - only receive events for this batch.
|
||||
If None, receives all events.
|
||||
|
||||
Yields:
|
||||
SSE-formatted event strings (ready to send to client).
|
||||
"""
|
||||
client_id = await self._generate_client_id()
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
# Register client
|
||||
async with self._lock:
|
||||
self._clients[client_id] = (queue, batch_id)
|
||||
|
||||
logger.info(
|
||||
f"SSE client {client_id} connected (batch_id filter: {batch_id}). "
|
||||
f"Total clients: {len(self._clients)}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Send initial retry hint for reconnection
|
||||
yield "retry: 3000\n\n"
|
||||
|
||||
# Keep connection alive and yield events
|
||||
while True:
|
||||
try:
|
||||
# Wait for events with timeout for keep-alive
|
||||
event = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
yield event
|
||||
except asyncio.TimeoutError:
|
||||
# Send keep-alive comment to prevent connection timeout
|
||||
yield ": keep-alive\n\n"
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"SSE client {client_id} subscription cancelled")
|
||||
raise
|
||||
finally:
|
||||
# Cleanup: remove client from registry
|
||||
async with self._lock:
|
||||
self._clients.pop(client_id, None)
|
||||
logger.info(
|
||||
f"SSE client {client_id} disconnected. "
|
||||
f"Remaining clients: {len(self._clients)}"
|
||||
)
|
||||
|
||||
async def broadcast_status_change(
|
||||
self,
|
||||
receipt_id: int,
|
||||
status: str,
|
||||
processing_status: Optional[str] = None,
|
||||
batch_id: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Broadcast a status change event to all relevant clients.
|
||||
|
||||
Args:
|
||||
receipt_id: The receipt ID that changed.
|
||||
status: New workflow status (DRAFT, PENDING_REVIEW, etc.).
|
||||
processing_status: New processing status (pending, processing, completed, failed).
|
||||
batch_id: The batch ID this receipt belongs to (for filtering).
|
||||
|
||||
Returns:
|
||||
Number of clients notified.
|
||||
"""
|
||||
event = StatusChangeEvent(
|
||||
receipt_id=receipt_id,
|
||||
status=status,
|
||||
processing_status=processing_status,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
sse_data = event.to_sse_data()
|
||||
|
||||
notified = 0
|
||||
async with self._lock:
|
||||
for client_id, (queue, client_batch_filter) in self._clients.items():
|
||||
# Send event if:
|
||||
# 1. Client has no filter (wants all events), OR
|
||||
# 2. Client's filter matches the event's batch_id
|
||||
if client_batch_filter is None or client_batch_filter == batch_id:
|
||||
try:
|
||||
queue.put_nowait(sse_data)
|
||||
notified += 1
|
||||
except asyncio.QueueFull:
|
||||
logger.warning(
|
||||
f"SSE queue full for client {client_id}, dropping event"
|
||||
)
|
||||
|
||||
if notified > 0:
|
||||
logger.debug(
|
||||
f"SSE broadcast: receipt_id={receipt_id}, status={status}, "
|
||||
f"processing_status={processing_status}, notified={notified} clients"
|
||||
)
|
||||
|
||||
return notified
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
"""Get current number of connected clients."""
|
||||
return len(self._clients)
|
||||
|
||||
|
||||
# Singleton instance for the application
|
||||
sse_broadcaster = SSEEventBroadcaster()
|
||||
|
||||
|
||||
# Convenience functions for external use
|
||||
async def subscribe(batch_id: Optional[str] = None) -> AsyncGenerator[str, None]:
|
||||
"""Subscribe to SSE status change events."""
|
||||
async for event in sse_broadcaster.subscribe(batch_id):
|
||||
yield event
|
||||
|
||||
|
||||
async def broadcast_status_change(
|
||||
receipt_id: int,
|
||||
status: str,
|
||||
processing_status: Optional[str] = None,
|
||||
batch_id: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Broadcast a status change event."""
|
||||
return await sse_broadcaster.broadcast_status_change(
|
||||
receipt_id=receipt_id,
|
||||
status=status,
|
||||
processing_status=processing_status,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
13
conftest.py
Normal file
13
conftest.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Root pytest configuration for ROA2WEB.
|
||||
|
||||
Ensures proper Python path setup for all test imports.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to Python path at import time
|
||||
project_root = Path(__file__).parent
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
324
e2e/bulk-upload.spec.js
Normal file
324
e2e/bulk-upload.spec.js
Normal file
@@ -0,0 +1,324 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* E2E Tests for Bulk Receipt Upload (US-005)
|
||||
*
|
||||
* Prerequisites:
|
||||
* 1. Start the test environment: ./start-test.sh
|
||||
* 2. Ensure backend is running on port 8000
|
||||
* 3. Ensure frontend is running on port 3000
|
||||
*
|
||||
* Run:
|
||||
* npm run test:e2e
|
||||
* npm run test:e2e:headed # With visible browser
|
||||
*
|
||||
* Test PDFs are located in: docs/data-entry/
|
||||
*/
|
||||
|
||||
// Test configuration
|
||||
const TEST_USER = {
|
||||
username: 'MARIUS M',
|
||||
password: '123',
|
||||
company: 'MARIUSM AUTO'
|
||||
};
|
||||
|
||||
// Sample test PDFs from docs/data-entry/
|
||||
const TEST_PDFS = [
|
||||
'benzina 13 iulie.pdf',
|
||||
'brick igiena 604.pdf',
|
||||
'electrobering igiena iulie 604.pdf'
|
||||
];
|
||||
|
||||
// Corrupted file for error handling test
|
||||
const CORRUPTED_FILE = 'test-corrupted.txt';
|
||||
|
||||
test.describe('Bulk Receipt Upload E2E Tests', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to login page
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for login page to load
|
||||
await page.waitForSelector('input[placeholder*="Utilizator"]', { timeout: 10000 });
|
||||
|
||||
// Fill login credentials
|
||||
await page.fill('input[placeholder*="Utilizator"]', TEST_USER.username);
|
||||
await page.fill('input[type="password"]', TEST_USER.password);
|
||||
|
||||
// Click login button
|
||||
await page.click('button:has-text("Autentificare")');
|
||||
|
||||
// Wait for redirect after login
|
||||
await page.waitForURL(/\/(reports|data-entry)/, { timeout: 15000 });
|
||||
|
||||
// Select company if modal appears
|
||||
const companyModal = page.locator('.company-select-modal');
|
||||
if (await companyModal.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await page.click(`text="${TEST_USER.company}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('should navigate to bulk upload page', async ({ page }) => {
|
||||
// Navigate to data entry module
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
|
||||
// Verify page title
|
||||
await expect(page.locator('h1')).toContainText('Upload Bonuri în Lot');
|
||||
|
||||
// Verify upload zone is visible
|
||||
await expect(page.locator('.bulk-upload-zone')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should upload 3 real PDF receipts and process them', async ({ page }) => {
|
||||
// Navigate to bulk upload page
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
// Get file paths
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.map(f => path.join(testFilesPath, f));
|
||||
|
||||
// Upload files via file input
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
// Verify files appear in the list
|
||||
await expect(page.locator('.bulk-file-list')).toBeVisible();
|
||||
await expect(page.locator('.file-item')).toHaveCount(3);
|
||||
|
||||
// Verify file names are shown
|
||||
for (const pdfName of TEST_PDFS) {
|
||||
await expect(page.locator(`.file-item:has-text("${pdfName}")`)).toBeVisible();
|
||||
}
|
||||
|
||||
// Verify total count message
|
||||
await expect(page.locator('text=/3 fișier/')).toBeVisible();
|
||||
|
||||
// Click process button
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Wait for upload to complete and processing to start
|
||||
await expect(page.locator('.bulk-progress-bar')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for all files to be processed (up to 3 minutes for OCR)
|
||||
await page.waitForFunction(() => {
|
||||
const progress = document.querySelector('.bulk-progress-bar');
|
||||
if (!progress) return false;
|
||||
const text = progress.textContent || '';
|
||||
// Check if all 3 are processed (3/3)
|
||||
return text.includes('3/3') || text.includes('100%');
|
||||
}, { timeout: 180000 });
|
||||
|
||||
// Verify summary modal appears
|
||||
await expect(page.locator('.bulk-summary-modal, .p-dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify success count
|
||||
await expect(page.locator('.success-count, .stat-success .stat-value')).toBeVisible();
|
||||
|
||||
// Get the number of successful receipts
|
||||
const successCount = await page.locator('.success-count, .stat-success .stat-value').textContent();
|
||||
console.log(`Successfully processed: ${successCount} receipts`);
|
||||
|
||||
// Verify total amount is displayed
|
||||
await expect(page.locator('.total-amount')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show receipts in list after processing', async ({ page }) => {
|
||||
// First upload and process files (abbreviated version)
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.slice(0, 1).map(f => path.join(testFilesPath, f));
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Wait for processing to complete
|
||||
await page.waitForFunction(() => {
|
||||
const modal = document.querySelector('.bulk-summary-modal, .p-dialog');
|
||||
return modal && window.getComputedStyle(modal).display !== 'none';
|
||||
}, { timeout: 120000 });
|
||||
|
||||
// Click "Vezi bonurile create" link
|
||||
await page.click('button:has-text("Vezi bonurile create")');
|
||||
|
||||
// Verify navigation to receipts list
|
||||
await expect(page).toHaveURL(/\/data-entry/);
|
||||
|
||||
// Verify receipts are listed
|
||||
await expect(page.locator('.receipts-table, .p-datatable')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should handle file removal before upload', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.map(f => path.join(testFilesPath, f));
|
||||
|
||||
// Upload files
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
// Verify 3 files are listed
|
||||
await expect(page.locator('.file-item')).toHaveCount(3);
|
||||
|
||||
// Click remove button on first file
|
||||
await page.locator('.file-item').first().locator('button.btn-remove, .pi-times').click();
|
||||
|
||||
// Verify only 2 files remain
|
||||
await expect(page.locator('.file-item')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('should clear all files with "Golește lista" button', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.map(f => path.join(testFilesPath, f));
|
||||
|
||||
// Upload files
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
// Verify files are listed
|
||||
await expect(page.locator('.file-item')).toHaveCount(3);
|
||||
|
||||
// Click clear button
|
||||
await page.click('button:has-text("Golește lista")');
|
||||
|
||||
// Verify list is empty
|
||||
await expect(page.locator('.file-item')).toHaveCount(0);
|
||||
await expect(page.locator('.bulk-file-list')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should show progress during processing', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.slice(0, 2).map(f => path.join(testFilesPath, f));
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Verify progress bar appears
|
||||
await expect(page.locator('.bulk-progress-bar')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify progress list shows items
|
||||
await expect(page.locator('.bulk-progress-list')).toBeVisible();
|
||||
|
||||
// Verify status badges are shown
|
||||
await expect(page.locator('.status-badge, .p-tag')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should disable upload zone during processing', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = [path.join(testFilesPath, TEST_PDFS[0])];
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Wait for processing to start
|
||||
await expect(page.locator('.bulk-progress-bar')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify upload zone is disabled
|
||||
const uploadZone = page.locator('.bulk-upload-zone');
|
||||
await expect(uploadZone).toHaveClass(/disabled/);
|
||||
|
||||
// Verify process button is disabled
|
||||
await expect(page.locator('button:has-text("Procesează")')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should allow loading new batch after completion', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = [path.join(testFilesPath, TEST_PDFS[0])];
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Wait for summary modal
|
||||
await page.waitForFunction(() => {
|
||||
const modal = document.querySelector('.bulk-summary-modal, .p-dialog');
|
||||
return modal && window.getComputedStyle(modal).display !== 'none';
|
||||
}, { timeout: 120000 });
|
||||
|
||||
// Click "Încarcă alt batch" button
|
||||
await page.click('button:has-text("Încarcă alt batch")');
|
||||
|
||||
// Verify modal closes and upload zone is enabled
|
||||
await expect(page.locator('.bulk-summary-modal, .p-dialog')).not.toBeVisible();
|
||||
await expect(page.locator('.bulk-upload-zone')).toBeVisible();
|
||||
await expect(page.locator('.bulk-upload-zone')).not.toHaveClass(/disabled/);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
test.describe('Bulk Upload Error Handling', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('input[placeholder*="Utilizator"]', { timeout: 10000 });
|
||||
await page.fill('input[placeholder*="Utilizator"]', TEST_USER.username);
|
||||
await page.fill('input[type="password"]', TEST_USER.password);
|
||||
await page.click('button:has-text("Autentificare")');
|
||||
await page.waitForURL(/\/(reports|data-entry)/, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test('should reject files with invalid MIME type', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
// Create a text file (invalid type)
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
|
||||
// Try to set a text file - should be rejected by client-side validation
|
||||
// Note: This tests the accept attribute on the file input
|
||||
const acceptAttr = await fileInput.getAttribute('accept');
|
||||
expect(acceptAttr).toContain('pdf');
|
||||
expect(acceptAttr).toContain('image');
|
||||
});
|
||||
|
||||
test('should show error message when upload fails', async ({ page }) => {
|
||||
// Navigate to bulk upload with backend not running on a specific endpoint
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = [path.join(testFilesPath, TEST_PDFS[0])];
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
// Intercept the upload request and make it fail
|
||||
await page.route('**/api/data-entry/bulk/upload', route => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ detail: 'Internal server error' })
|
||||
});
|
||||
});
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Verify error message appears
|
||||
await expect(page.locator('.error-message')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.error-message')).toContainText(/eroare|error/i);
|
||||
});
|
||||
|
||||
});
|
||||
39
playwright.config.js
Normal file
39
playwright.config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright Configuration for ROA2WEB E2E Tests
|
||||
*
|
||||
* Run with:
|
||||
* npm run test:e2e # Headless
|
||||
* npm run test:e2e:headed # With browser visible
|
||||
* npm run test:e2e:debug # Debug mode
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
// Run local server before tests
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
asyncio_mode = "auto"
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
"ignore::PendingDeprecationWarning"
|
||||
]
|
||||
|
||||
[project]
|
||||
name = "roa2web"
|
||||
version = "1.0.0"
|
||||
description = "ROA2WEB ERP Application"
|
||||
requires-python = ">=3.10"
|
||||
609
scripts/ralph/prd.json
Normal file
609
scripts/ralph/prd.json
Normal file
@@ -0,0 +1,609 @@
|
||||
{
|
||||
"projectName": "mobile-ux-improvements",
|
||||
"branchName": "ralph/bulk-receipt-upload",
|
||||
"description": "Corectarea comportamentului de refresh pentru bulk upload, implementarea selecției multiple pe mobil cu interfață Android nativă, afișarea numelui fișierului pentru toate bonurile, și rezolvarea bug-urilor de UX raportate.",
|
||||
"cssRules": {
|
||||
"documentation": [
|
||||
"docs/ONBOARDING_CSS.md",
|
||||
"docs/DESIGN_TOKENS.md",
|
||||
"docs/CSS_PATTERNS.md"
|
||||
],
|
||||
"goldenRules": [
|
||||
"Folosește DOAR design tokens - NICIODATĂ valori hardcodate",
|
||||
"Verifică CSS_PATTERNS.md înainte de a scrie CSS nou",
|
||||
"Testează în AMBELE teme (light + dark mode)",
|
||||
"NICIODATĂ :deep() în componente (PrimeVue → vendor/)",
|
||||
"NICIODATĂ duplicate CSS (write once, use everywhere)"
|
||||
],
|
||||
"mobileLayoutTokens": {
|
||||
"topBarHeight": "56px",
|
||||
"bottomNavHeight": "56px",
|
||||
"fabSize": "56px",
|
||||
"fabBottomOffset": "72px",
|
||||
"touchTargetMin": "48px"
|
||||
},
|
||||
"selectionModeColors": {
|
||||
"selected": {
|
||||
"background": "var(--blue-50)",
|
||||
"border": "var(--blue-500)"
|
||||
},
|
||||
"selectedDark": {
|
||||
"background": "var(--blue-900)",
|
||||
"border": "var(--blue-400)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"userStories": [
|
||||
{
|
||||
"id": "US-001",
|
||||
"title": "Backend - Stocare Batch și Processing Status",
|
||||
"description": "Ca developer, vreau să extind schema Receipt pentru a stoca informații de batch, pentru că am nevoie de persistență pentru tracking.",
|
||||
"priority": 1,
|
||||
"acceptanceCriteria": [
|
||||
"Câmpuri noi în tabelul receipts: batch_id, processing_status, processing_error, file_hash, processing_started_at, processing_completed_at",
|
||||
"Index pe batch_id, file_hash, processing_status",
|
||||
"Migration reversibilă cu Alembic"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-002",
|
||||
"title": "Backend - Endpoint List cu Batch Info",
|
||||
"description": "Ca developer, vreau să extind endpoint-ul GET /receipts pentru a include info de batch.",
|
||||
"priority": 2,
|
||||
"acceptanceCriteria": [
|
||||
"Response include câmpurile de batch și processing pentru fiecare receipt",
|
||||
"Filtrare pe processing_status și batch_id funcționează",
|
||||
"Response include processing_stats"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-003",
|
||||
"title": "Backend - Reject Automat pentru Duplicate (File Hash)",
|
||||
"description": "Ca sistem, vreau să detectez și să reject fișierele duplicate la upload.",
|
||||
"priority": 3,
|
||||
"acceptanceCriteria": [
|
||||
"SHA-256 hash pentru duplicate detection",
|
||||
"Response include existing_receipt_id pentru duplicates"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-004",
|
||||
"title": "Frontend - Drag Anywhere pentru Upload",
|
||||
"description": "Ca utilizator, vreau să pot trage fișiere oriunde pe pagina de bonuri.",
|
||||
"priority": 4,
|
||||
"acceptanceCriteria": [
|
||||
"DragDropOverlay.vue componentă",
|
||||
"Global listeners cleanup în onUnmounted"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-005",
|
||||
"title": "Frontend - Row Grouping per Batch în DataTable",
|
||||
"description": "Ca utilizator, vreau să văd bonurile din același batch grupate vizual.",
|
||||
"priority": 5,
|
||||
"acceptanceCriteria": [
|
||||
"BatchGroupHeader.vue componentă",
|
||||
"Grupuri sortate după processing_started_at descending"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-006",
|
||||
"title": "Frontend - Coloană Status Procesare în Tabel",
|
||||
"description": "Ca utilizator, vreau să văd statusul fiecărui bon din batch într-o coloană dedicată.",
|
||||
"priority": 6,
|
||||
"acceptanceCriteria": [
|
||||
"ProcessingStatusCell.vue componentă",
|
||||
"Status updates în real-time via polling"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-007",
|
||||
"title": "Frontend - Mesaj Eroare Vizibil în Listă",
|
||||
"description": "Ca utilizator, vreau să văd mesajul de eroare direct în listă.",
|
||||
"priority": 7,
|
||||
"acceptanceCriteria": [
|
||||
"Truncated error message cu tooltip pentru full text"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-008",
|
||||
"title": "Frontend - Quick Filter Chips pentru Statusuri Procesare",
|
||||
"description": "Ca utilizator, vreau filtre rapide pentru bonurile cu erori sau în procesare.",
|
||||
"priority": 8,
|
||||
"acceptanceCriteria": [
|
||||
"Chips 'În procesare (N)' și 'Cu erori (N)' în status bar"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-009",
|
||||
"title": "Frontend - Lock Row în Procesare (Read-Only)",
|
||||
"description": "Ca utilizator, vreau ca bonurile în procesare să fie read-only.",
|
||||
"priority": 9,
|
||||
"acceptanceCriteria": [
|
||||
"Butoane și checkbox disabled pentru pending/processing",
|
||||
"Tooltip pe butoane dezactivate"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-010",
|
||||
"title": "Frontend - Retry Individual și Retry All Failed",
|
||||
"description": "Ca utilizator, vreau să pot re-procesa bonurile cu erori.",
|
||||
"priority": 10,
|
||||
"acceptanceCriteria": [
|
||||
"Buton Reîncercă per rând failed",
|
||||
"Buton Reîncercă toate erorile în BatchGroupHeader"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-011",
|
||||
"title": "Frontend - Auto-Resume Polling la Refresh/Revenire",
|
||||
"description": "Ca utilizator, vreau ca procesarea să continue când revin.",
|
||||
"priority": 11,
|
||||
"acceptanceCriteria": [
|
||||
"localStorage pentru active batch IDs",
|
||||
"Auto-resume polling la onMounted"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-012",
|
||||
"title": "Backend - Auto-Cleanup Erori După 7 Zile",
|
||||
"description": "Ca sistem, vreau să șterg automat bonurile cu erori după 7 zile.",
|
||||
"priority": 12,
|
||||
"acceptanceCriteria": [
|
||||
"Background job pentru cleanup",
|
||||
"Șterge receipts și attachments"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-013",
|
||||
"title": "Cleanup - Eliminare Pagină Separată Bulk Upload",
|
||||
"description": "Ca developer, vreau să elimin pagina separată de bulk upload.",
|
||||
"priority": 13,
|
||||
"acceptanceCriteria": [
|
||||
"Route redirect pentru backwards compatibility",
|
||||
"Șterge componente nefolosite"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-014",
|
||||
"title": "Backend - Endpoint Cancel Job Individual",
|
||||
"description": "Ca sistem, vreau un endpoint API pentru anularea unui job specific de procesare.",
|
||||
"priority": 14,
|
||||
"acceptanceCriteria": [
|
||||
"POST /api/data-entry/bulk/cancel/{job_id} endpoint creat",
|
||||
"Job-uri cu status completed/failed returnează 400 Bad Request",
|
||||
"Job-uri pending/processing sunt marcate cancelled",
|
||||
"Response include: {success, job_id, cancelled_at, message}"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-015",
|
||||
"title": "Backend - Endpoint Cancel Batch Complet",
|
||||
"description": "Ca sistem, vreau un endpoint API pentru anularea tuturor job-urilor dintr-un batch.",
|
||||
"priority": 15,
|
||||
"acceptanceCriteria": [
|
||||
"POST /api/data-entry/bulk/cancel-batch/{batch_id} endpoint creat",
|
||||
"Response include: {success, batch_id, cancelled_count, skipped_count, message}"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-016",
|
||||
"title": "Frontend - Store Actions pentru Cancel",
|
||||
"description": "Ca dezvoltator, vreau acțiuni în batchProgressStore pentru cancel.",
|
||||
"priority": 16,
|
||||
"acceptanceCriteria": [
|
||||
"batchProgressStore.cancelJob(jobId) implementat",
|
||||
"batchProgressStore.cancelBatch(batchId) implementat"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-017",
|
||||
"title": "Frontend - Afișare Jobs Pending în Tabel",
|
||||
"description": "Ca utilizator, vreau să văd fișierele încărcate imediat în tabel.",
|
||||
"priority": 17,
|
||||
"acceptanceCriteria": [
|
||||
"După upload success, rândurile pentru jobs apar instant în tabel",
|
||||
"Tabelul poate randa atât Receipt-uri cât și BatchJob-uri"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-018",
|
||||
"title": "Frontend - Tranziție Job → Receipt când OCR Termină",
|
||||
"description": "Ca sistem, vreau ca rândul de job să se transforme în receipt când OCR termină.",
|
||||
"priority": 18,
|
||||
"acceptanceCriteria": [
|
||||
"Când polling detectează job completed cu receipt_id, rândul se actualizează",
|
||||
"Tranziția e smooth - rândul NU dispare și reapare"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-019",
|
||||
"title": "Frontend - Animație Status Change",
|
||||
"description": "Ca utilizator, vreau o indicație vizuală când un fișier își schimbă statusul.",
|
||||
"priority": 19,
|
||||
"acceptanceCriteria": [
|
||||
"Badge-ul de status se schimbă cu CSS transition opacity 300ms",
|
||||
"Highlight verde/roșu subtil pentru completed/failed"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-020",
|
||||
"title": "Frontend - Buton Cancel Individual pe Row",
|
||||
"description": "Ca utilizator, vreau un buton Cancel pe fiecare fișier pending/processing.",
|
||||
"priority": 20,
|
||||
"acceptanceCriteria": [
|
||||
"Fișierele pending/processing au icon Cancel (×)",
|
||||
"După cancel success, rândul dispare cu fade-out 300ms"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-021",
|
||||
"title": "Frontend - Buton Cancel All în BatchGroupHeader",
|
||||
"description": "Ca utilizator, vreau un buton pentru a anula toate fișierele dintr-un batch.",
|
||||
"priority": 21,
|
||||
"acceptanceCriteria": [
|
||||
"BatchGroupHeader are buton 'Anulează tot' pentru batch-uri cu pending/processing jobs",
|
||||
"Job-urile completed/failed rămân vizibile"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-022",
|
||||
"title": "Frontend - Checkbox Disabled pentru Jobs în Procesare",
|
||||
"description": "Ca utilizator, vreau ca checkbox-urile să fie disabled pentru fișiere în procesare.",
|
||||
"priority": 22,
|
||||
"acceptanceCriteria": [
|
||||
"Checkbox-ul este disabled pentru rânduri de tip job",
|
||||
"Select All NU include job-urile în procesare"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-023",
|
||||
"title": "Frontend - Restore Jobs la Refresh/Revenire",
|
||||
"description": "Ca utilizator, vreau să văd job-urile pending când revin pe pagină.",
|
||||
"priority": 23,
|
||||
"acceptanceCriteria": [
|
||||
"La onMounted, verifică localStorage pentru active batch IDs",
|
||||
"Job-urile pending/processing apar în tabel"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-024",
|
||||
"title": "Backend - Endpoint Bulk Delete",
|
||||
"description": "Ca frontend, vreau să pot trimite o listă de ID-uri pentru ștergere.",
|
||||
"priority": 24,
|
||||
"acceptanceCriteria": [
|
||||
"DELETE /api/data-entry/receipts/bulk acceptă body: { ids: [...] }",
|
||||
"Returnează partial success response"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-025",
|
||||
"title": "Frontend - Buton Șterge în Bulk Actions Bar",
|
||||
"description": "Ca utilizator, vreau să văd un buton 'Șterge' când am selecții.",
|
||||
"priority": 25,
|
||||
"acceptanceCriteria": [
|
||||
"Butonul 'Șterge' apare în bulk actions bar când selectedReceipts.length > 0",
|
||||
"Butonul are icon pi-trash și severity danger"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-026",
|
||||
"title": "Frontend - Dialog Confirmare Ștergere Bulk",
|
||||
"description": "Ca utilizator, vreau o confirmare înainte de ștergere.",
|
||||
"priority": 26,
|
||||
"acceptanceCriteria": [
|
||||
"La click pe 'Șterge', apare dialog cu mesaj confirmare",
|
||||
"Dialog-ul folosește PrimeVue ConfirmDialog"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-027",
|
||||
"title": "Frontend - Bulk Delete cu Partial Success Toast",
|
||||
"description": "Ca utilizator, vreau să văd rezultatul ștergerii.",
|
||||
"priority": 27,
|
||||
"acceptanceCriteria": [
|
||||
"Toast arată rezultatul: 'X bonuri șterse'",
|
||||
"Bonurile șterse dispar instant din listă",
|
||||
"Selecția se golește după ștergere"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-028",
|
||||
"title": "Frontend - Navigare la Pagina Anterioară când Lista Devine Goală",
|
||||
"description": "Ca utilizator, vreau să fiu redirecționat când șterg toate bonurile de pe pagină.",
|
||||
"priority": 28,
|
||||
"acceptanceCriteria": [
|
||||
"După bulk delete, dacă lista devine goală și currentPage > 1, navigare la pagina anterioară",
|
||||
"Dacă eram pe pagina 1, afișează empty state"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-029",
|
||||
"title": "Frontend - Metodă updateReceiptInPlace în Store",
|
||||
"description": "Ca frontend, vreau să actualizez un singur rând fără să re-renderez toată lista.",
|
||||
"priority": 29,
|
||||
"acceptanceCriteria": [
|
||||
"Metoda updateReceiptInPlace(receiptId, updates) în receiptsStore",
|
||||
"Object.assign pentru updates, nu înlocuire array"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-030",
|
||||
"title": "Backend - SSE Endpoint pentru Status Updates",
|
||||
"description": "Ca frontend, vreau să primesc notificări real-time despre schimbări de status.",
|
||||
"priority": 30,
|
||||
"acceptanceCriteria": [
|
||||
"GET /api/data-entry/receipts/sse/status returnează SSE stream",
|
||||
"Format eveniment: {receipt_id, status, processing_status}"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-031",
|
||||
"title": "Frontend - SSE Client Service",
|
||||
"description": "Ca frontend, vreau să mă conectez la SSE și să actualizez rândurile individual.",
|
||||
"priority": 31,
|
||||
"acceptanceCriteria": [
|
||||
"sseService.js cu connect(), disconnect(), onStatusChange()",
|
||||
"Folosește native EventSource API"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-032",
|
||||
"title": "Frontend - Înlocuire Polling cu SSE",
|
||||
"description": "Ca frontend, vreau să folosesc SSE în loc de polling.",
|
||||
"priority": 32,
|
||||
"acceptanceCriteria": [
|
||||
"SSE în loc de setInterval pentru auto-refresh",
|
||||
"La primire eveniment SSE, apelează updateReceiptInPlace()"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-033",
|
||||
"title": "Frontend - Graceful Degradation la SSE Failure",
|
||||
"description": "Ca utilizator, vreau ca aplicația să funcționeze și fără SSE.",
|
||||
"priority": 33,
|
||||
"acceptanceCriteria": [
|
||||
"Dacă SSE fail, activează fallback la polling clasic",
|
||||
"Retry SSE periodic (la 30s)"
|
||||
],
|
||||
"passes": true,
|
||||
"notes": "Completed previously"
|
||||
},
|
||||
{
|
||||
"id": "US-034",
|
||||
"title": "Fix - Refresh Individual vs Refresh Total",
|
||||
"description": "Ca utilizator, vreau ca bonurile să se actualizeze individual fără să se reîncarce toată lista, pentru că vreau să văd progresul în timp real fără să pierd poziția.",
|
||||
"priority": 34,
|
||||
"acceptanceCriteria": [
|
||||
"SSE handler NU apelează store.fetchReceipts() când un receipt nu este în pagina curentă",
|
||||
"Verifică dacă receipt-ul aparține unui batch activ și îl adaugă local dacă nu există",
|
||||
"Ordinea bonurilor din batch rămâne stabilă (nu se reordonează)",
|
||||
"npm run typecheck passes",
|
||||
"Verify in browser: uploadează 5 bonuri, nu se reîncarcă pagina între procesări"
|
||||
],
|
||||
"technicalNotes": "Modificare în handleSSEStatusChange din ReceiptsListView.vue:2397. Când receipt nu e găsit: verifică dacă batch_id e în batchProgressStore, apoi fetch individual receipt și inserează local în poziția corectă.",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 1"
|
||||
},
|
||||
{
|
||||
"id": "US-035",
|
||||
"title": "Fix - Bonuri cu Eroare Rămân în Listă",
|
||||
"description": "Ca utilizator, vreau ca bonurile cu eroare de procesare să rămână vizibile în listă, pentru că vreau să le pot edita manual sau să le șterg.",
|
||||
"priority": 35,
|
||||
"acceptanceCriteria": [
|
||||
"Bonurile cu processing_status='failed' NU sunt eliminate din view după refresh",
|
||||
"Eroarea de extragere dată/sumă afișează bonul cu status 'Eroare' și buton 'Editează'",
|
||||
"Link rapid 'Editează manual' duce la formularul de editare cu datele disponibile pre-populate",
|
||||
"Bonurile failed au highlight vizual (roșu subtil) pentru identificare ușoară",
|
||||
"npm run typecheck passes",
|
||||
"Verify in browser: bon cu eroare OCR rămâne în listă și poate fi editat"
|
||||
],
|
||||
"technicalNotes": "Bonurile cu eroare ar trebui să aibă status='draft' și processing_status='failed'. Nu se șterg la refresh, doar se actualizează in-place.",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 2"
|
||||
},
|
||||
{
|
||||
"id": "US-036",
|
||||
"title": "Afișare Nume Fișier pentru Toate Bonurile",
|
||||
"description": "Ca utilizator, vreau să văd numele fișierului original pentru bonurile procesate, pentru că vreau să identific care bon corespunde cărui fișier uploadat.",
|
||||
"priority": 36,
|
||||
"acceptanceCriteria": [
|
||||
"Coloana 'Fișier' afișează original_filename și pentru receipt-uri completate, nu doar pentru job-uri",
|
||||
"Pe mobil, numele fișierului apare sub partener (font mic, gri)",
|
||||
"Tooltip pe desktop arată numele complet dacă e trunchiat",
|
||||
"Dacă receipt nu are original_filename, afișează '-'",
|
||||
"npm run typecheck passes",
|
||||
"Verify in browser: bon procesat afișează numele fișierului original"
|
||||
],
|
||||
"technicalNotes": "Backend trebuie să populeze original_filename pe receipt din job-ul OCR. Verifică că auto-save service copiază filename din job la receipt.",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 3"
|
||||
},
|
||||
{
|
||||
"id": "US-037",
|
||||
"title": "Fix - Upload Nu Mai Face Refresh Automat",
|
||||
"description": "Ca utilizator, vreau ca selectarea fișierelor să nu reîncarce pagina, pentru că pierd fișierele selectate înainte să apăs 'Procesează'.",
|
||||
"priority": 37,
|
||||
"acceptanceCriteria": [
|
||||
"Selectarea fișierelor via input NU declanșează refresh/reload pagină",
|
||||
"Fișierele selectate rămân în listă până la submit explicit",
|
||||
"Comportament identic pe desktop și mobil (Chrome, Safari)",
|
||||
"Nu există race condition între clonarea fișierelor și resetarea input-ului",
|
||||
"npm run typecheck passes",
|
||||
"Verify on mobile: selectează 5 fișiere, toate rămân în listă"
|
||||
],
|
||||
"technicalNotes": "Bug probabil în onBulkFileInputChange sau în interacțiunea cu store. Verifică că nu există watchers care declanșează refresh la schimbări de state. Test specific pe Chrome Android - vezi gotcha din CLAUDE.md despre File API.",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 4"
|
||||
},
|
||||
{
|
||||
"id": "US-038",
|
||||
"title": "Mobile - Selecție Multiplă prin Long-Press",
|
||||
"description": "Ca utilizator mobil, vreau să selectez bonuri prin apăsare lungă, pentru că vreau să pot șterge sau acționa asupra mai multor bonuri simultan.",
|
||||
"priority": 38,
|
||||
"acceptanceCriteria": [
|
||||
"Long-press (500ms) pe un card activează modul de selecție",
|
||||
"Card-ul selectat primește checkmark și background diferit (var(--blue-50))",
|
||||
"După activare, tap simplu pe alte carduri le adaugă/elimină din selecție",
|
||||
"Tap în afara cardurilor dezactivează modul selecție",
|
||||
"Header contextual arată numărul de elemente selectate",
|
||||
"npm run typecheck passes",
|
||||
"Verify on mobile: long-press selectează, tap adaugă la selecție"
|
||||
],
|
||||
"technicalNotes": "Implementare cu setTimeout(500ms) pe touchstart, clear pe touchend/touchmove. CSS: .receipt-card.selected { background: var(--blue-50); border-color: var(--blue-500); }",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 5"
|
||||
},
|
||||
{
|
||||
"id": "US-039",
|
||||
"title": "Mobile - Select All și Buton Ștergere",
|
||||
"description": "Ca utilizator mobil, vreau să am butoane 'Selectează tot' și 'Șterge' în modul selecție, pentru că vreau să pot șterge rapid mai multe bonuri.",
|
||||
"priority": 39,
|
||||
"acceptanceCriteria": [
|
||||
"În modul selecție, apare top bar contextual cu: număr selectate, buton 'Selectează tot', buton X pentru ieșire",
|
||||
"Apare bottom bar cu buton 'Șterge' (roșu, icon coș)",
|
||||
"'Selectează tot' selectează toate bonurile din pagina curentă",
|
||||
"Buton 'Șterge' afișează confirmare înainte de ștergere",
|
||||
"După ștergere, modul selecție se dezactivează",
|
||||
"npm run typecheck passes",
|
||||
"Verify on mobile: select all + delete funcționează"
|
||||
],
|
||||
"technicalNotes": "Top bar: position: sticky; top: 0. Bottom bar: position: fixed; bottom: 0. Animație slide-in pentru bars.",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 6"
|
||||
},
|
||||
{
|
||||
"id": "US-040",
|
||||
"title": "Mobile - Layout Android Nativ pentru Lista Bonuri",
|
||||
"description": "Ca utilizator mobil, vreau o interfață similară cu aplicațiile Android native, pentru că vreau experiență familiară și intuitivă.",
|
||||
"priority": 40,
|
||||
"acceptanceCriteria": [
|
||||
"Top Bar fixă cu: hamburger/back (stânga), titlu centrat, search+filter+more (dreapta)",
|
||||
"Filter Chips sub top bar (orizontal scrollabil): Toate, Ciorne, În așteptare, Validate, Respinse",
|
||||
"Bottom Navigation fixă cu 4 tab-uri: Bonuri (activ), Upload, Rapoarte, Setări",
|
||||
"FAB (56x56px) în colț dreapta jos, 16px de la margine, 72px de la bottom",
|
||||
"FAB se ascunde când scroll în jos, apare când scroll în sus",
|
||||
"Lista ocupă spațiul dintre top bar și bottom nav",
|
||||
"npm run typecheck passes",
|
||||
"Verify on mobile: layout similar cu Gmail/WhatsApp"
|
||||
],
|
||||
"technicalNotes": "CSS: .mobile-top-bar { position: fixed; top: 0; height: 56px; z-index: 1000 }. .mobile-bottom-nav { position: fixed; bottom: 0; height: 56px }. .mobile-fab { position: fixed; bottom: 72px; right: 16px; width: 56px; border-radius: 16px }",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 7"
|
||||
},
|
||||
{
|
||||
"id": "US-041",
|
||||
"title": "Mobile - Layout Android pentru Editare/Creare Bon",
|
||||
"description": "Ca utilizator mobil, vreau interfață de editare bon similară cu compose email în Gmail, pentru că vreau butoane de acțiune accesibile și layout familiar.",
|
||||
"priority": 41,
|
||||
"acceptanceCriteria": [
|
||||
"Top Bar cu: X/back (stânga), titlu centrat, attach+save icons (dreapta)",
|
||||
"Content Area scrollabilă cu form fields 100% width",
|
||||
"Bottom Action Bar fixă cu: 'Salvează Ciornă' (secondary), 'Trimite pentru Validare' (primary)",
|
||||
"Keyboard-aware: bottom bar se mută deasupra tastaturii",
|
||||
"npm run typecheck passes",
|
||||
"Verify on mobile: layout similar cu Gmail compose"
|
||||
],
|
||||
"technicalNotes": "CSS: .mobile-receipt-form { padding-bottom: 80px }. .mobile-form-bottom-bar { position: fixed; bottom: 0; display: flex; gap: var(--space-sm) }. Butoane cu flex: 1 pentru width egal.",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 8"
|
||||
},
|
||||
{
|
||||
"id": "US-042",
|
||||
"title": "Mobile - Layout Android pentru Vizualizare Bon",
|
||||
"description": "Ca utilizator mobil, vreau interfață de vizualizare bon similară cu view email în Gmail, pentru că vreau acțiuni rapide și navigare ușoară.",
|
||||
"priority": 42,
|
||||
"acceptanceCriteria": [
|
||||
"Top Bar cu: back arrow (stânga), edit+delete+more icons (dreapta)",
|
||||
"Content Area cu detalii bon (read-only)",
|
||||
"Bottom Action Bar cu butoane contextuale: DRAFT→Editează/Trimite, PENDING→Validează/Respinge, APPROVED→Anulează",
|
||||
"npm run typecheck passes",
|
||||
"Verify on mobile: acțiuni accesibile din bottom bar"
|
||||
],
|
||||
"technicalNotes": "Refolosește stilurile din US-041. Butoanele din bottom bar se schimbă dinamic bazat pe receipt.status.",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 9"
|
||||
},
|
||||
{
|
||||
"id": "US-043",
|
||||
"title": "Păstrare Ordine Bonuri la Refresh",
|
||||
"description": "Ca utilizator, vreau ca bonurile să rămână în ordinea în care au fost uploadate, pentru că vreau să urmăresc progresul fiecărui bon uploadat.",
|
||||
"priority": 43,
|
||||
"acceptanceCriteria": [
|
||||
"Bonurile din același batch păstrează ordinea de upload (nu se reordonează)",
|
||||
"Un bon finalizat rămâne în aceeași poziție vizuală, nu sare la sfârșit",
|
||||
"SSE updates modifică status-ul in-place, fără a muta rândul",
|
||||
"Refresh manual poate reordona, dar SSE updates nu",
|
||||
"npm run typecheck passes",
|
||||
"Verify in browser: bon procesat nu își schimbă poziția în listă"
|
||||
],
|
||||
"technicalNotes": "Lista trebuie sortată by created_at DESC sau by batch order, nu by last_modified. updateReceiptInPlace din store NU trebuie să reordoneze array-ul.",
|
||||
"passes": true,
|
||||
"notes": "Completed in iteration 10"
|
||||
}
|
||||
]
|
||||
}
|
||||
516
scripts/ralph/progress.txt
Normal file
516
scripts/ralph/progress.txt
Normal file
@@ -0,0 +1,516 @@
|
||||
# Ralph Progress Log
|
||||
Started: Sat Jan 11 02:30:00 PM UTC 2026
|
||||
Project: bulk-upload-list-integration (continuation of bulk-receipt-upload)
|
||||
Branch: ralph/bulk-receipt-upload (existing)
|
||||
PRD: tasks/prd-bulk-upload-list-integration.md
|
||||
Note: Continuing on same branch - bulk upload base already implemented
|
||||
---
|
||||
|
||||
[2026-01-11 14:05:11] Starting Ralph for project: bulk-upload-list-integration
|
||||
[2026-01-11 14:05:11] Max iterations: 50
|
||||
[2026-01-11 14:05:11] === Iteration 1/50 ===
|
||||
[2026-01-11 14:05:11] Working on story: US-011
|
||||
[2026-01-11 14:05:11] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_1_US-011.log)
|
||||
[2026-01-11 15:02:40] SUCCESS: Story US-011 passed!
|
||||
[2026-01-11 15:02:40] Changes committed
|
||||
[2026-01-11 15:02:40] Progress: 1/13 stories completed
|
||||
[2026-01-11 15:02:42] === Iteration 2/50 ===
|
||||
[2026-01-11 15:02:42] Working on story: US-012
|
||||
[2026-01-11 15:02:42] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_2_US-012.log)
|
||||
[2026-01-11 15:08:26] SUCCESS: Story US-012 passed!
|
||||
[2026-01-11 15:08:26] Changes committed
|
||||
[2026-01-11 15:08:26] Progress: 2/13 stories completed
|
||||
[2026-01-11 15:08:28] === Iteration 3/50 ===
|
||||
[2026-01-11 15:08:28] Working on story: US-007
|
||||
[2026-01-11 15:08:28] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_3_US-007.log)
|
||||
[2026-01-11 15:16:09] SUCCESS: Story US-007 passed!
|
||||
[2026-01-11 15:16:09] Changes committed
|
||||
[2026-01-11 15:16:09] Progress: 3/13 stories completed
|
||||
[2026-01-11 15:16:11] === Iteration 4/50 ===
|
||||
[2026-01-11 15:16:11] Working on story: US-001
|
||||
[2026-01-11 15:16:11] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_4_US-001.log)
|
||||
[2026-01-11 15:20:19] SUCCESS: Story US-001 passed!
|
||||
[2026-01-11 15:20:19] Changes committed
|
||||
[2026-01-11 15:20:19] Progress: 4/13 stories completed
|
||||
[2026-01-11 15:20:21] === Iteration 5/50 ===
|
||||
[2026-01-11 15:20:21] Working on story: US-002
|
||||
[2026-01-11 15:20:21] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_5_US-002.log)
|
||||
[2026-01-11 15:25:52] Story US-002 not yet complete, continuing...
|
||||
[2026-01-11 15:25:52] Progress: 4/13 stories completed
|
||||
[2026-01-11 15:25:54] === Iteration 6/50 ===
|
||||
[2026-01-11 15:25:54] Working on story: US-002
|
||||
[2026-01-11 15:25:54] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_6_US-002.log)
|
||||
[2026-01-11 15:28:49] SUCCESS: Story US-002 passed!
|
||||
[2026-01-11 15:28:49] Changes committed
|
||||
[2026-01-11 15:28:49] Progress: 5/13 stories completed
|
||||
[2026-01-11 15:28:51] === Iteration 7/50 ===
|
||||
[2026-01-11 15:28:51] Working on story: US-003
|
||||
[2026-01-11 15:28:51] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_7_US-003.log)
|
||||
[2026-01-11 15:31:48] SUCCESS: Story US-003 passed!
|
||||
[2026-01-11 15:31:48] Changes committed
|
||||
[2026-01-11 15:31:48] Progress: 6/13 stories completed
|
||||
[2026-01-11 15:31:50] === Iteration 8/50 ===
|
||||
[2026-01-11 15:31:50] Working on story: US-004
|
||||
[2026-01-11 15:31:50] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_8_US-004.log)
|
||||
[2026-01-11 15:36:36] SUCCESS: Story US-004 passed!
|
||||
[2026-01-11 15:36:37] Changes committed
|
||||
[2026-01-11 15:36:37] Progress: 7/13 stories completed
|
||||
[2026-01-11 15:36:39] === Iteration 9/50 ===
|
||||
[2026-01-11 15:36:39] Working on story: US-005
|
||||
[2026-01-11 15:36:39] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_9_US-005.log)
|
||||
[2026-01-11 15:41:56] SUCCESS: Story US-005 passed!
|
||||
[2026-01-11 15:41:56] Changes committed
|
||||
[2026-01-11 15:41:56] Progress: 8/13 stories completed
|
||||
[2026-01-11 15:41:58] === Iteration 10/50 ===
|
||||
[2026-01-11 15:41:58] Working on story: US-010
|
||||
[2026-01-11 15:41:58] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_10_US-010.log)
|
||||
[2026-01-11 15:48:38] SUCCESS: Story US-010 passed!
|
||||
[2026-01-11 15:48:38] Changes committed
|
||||
[2026-01-11 15:48:38] Progress: 9/13 stories completed
|
||||
[2026-01-11 15:48:40] === Iteration 11/50 ===
|
||||
[2026-01-11 15:48:40] Working on story: US-006
|
||||
[2026-01-11 15:48:40] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_11_US-006.log)
|
||||
[2026-01-11 15:48:44] Story US-006 not yet complete, continuing...
|
||||
[2026-01-11 15:48:44] Progress: 9/13 stories completed
|
||||
[2026-01-11 15:48:46] === Iteration 12/50 ===
|
||||
[2026-01-11 15:48:46] Working on story: US-006
|
||||
[2026-01-11 15:48:46] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_12_US-006.log)
|
||||
[2026-01-11 15:57:30] SUCCESS: Story US-006 passed!
|
||||
[2026-01-11 15:57:30] Changes committed
|
||||
[2026-01-11 15:57:30] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:57:32] === Iteration 13/50 ===
|
||||
[2026-01-11 15:57:32] Working on story: US-009
|
||||
[2026-01-11 15:57:32] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_13_US-009.log)
|
||||
[2026-01-11 15:58:11] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:58:11] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:58:13] === Iteration 14/50 ===
|
||||
[2026-01-11 15:58:13] Working on story: US-009
|
||||
[2026-01-11 15:58:13] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_14_US-009.log)
|
||||
[2026-01-11 15:58:16] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:58:16] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:58:18] === Iteration 15/50 ===
|
||||
[2026-01-11 15:58:18] Working on story: US-009
|
||||
[2026-01-11 15:58:18] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_15_US-009.log)
|
||||
[2026-01-11 15:58:22] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:58:22] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:58:24] === Iteration 16/50 ===
|
||||
[2026-01-11 15:58:24] Working on story: US-009
|
||||
[2026-01-11 15:58:24] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_16_US-009.log)
|
||||
[2026-01-11 15:58:27] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:58:27] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:58:29] === Iteration 17/50 ===
|
||||
[2026-01-11 15:58:29] Working on story: US-009
|
||||
[2026-01-11 15:58:29] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_17_US-009.log)
|
||||
[2026-01-11 15:58:33] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:58:33] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:58:35] === Iteration 18/50 ===
|
||||
[2026-01-11 15:58:35] Working on story: US-009
|
||||
[2026-01-11 15:58:35] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_18_US-009.log)
|
||||
[2026-01-11 15:58:39] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:58:39] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:58:41] === Iteration 19/50 ===
|
||||
[2026-01-11 15:58:41] Working on story: US-009
|
||||
[2026-01-11 15:58:41] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_19_US-009.log)
|
||||
[2026-01-11 15:58:44] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:58:44] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:58:46] === Iteration 20/50 ===
|
||||
[2026-01-11 15:58:46] Working on story: US-009
|
||||
[2026-01-11 15:58:46] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_20_US-009.log)
|
||||
[2026-01-11 15:58:50] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:58:50] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:58:52] === Iteration 21/50 ===
|
||||
[2026-01-11 15:58:52] Working on story: US-009
|
||||
[2026-01-11 15:58:52] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_21_US-009.log)
|
||||
[2026-01-11 15:58:56] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:58:56] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:58:58] === Iteration 22/50 ===
|
||||
[2026-01-11 15:58:58] Working on story: US-009
|
||||
[2026-01-11 15:58:58] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_22_US-009.log)
|
||||
[2026-01-11 15:59:01] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:01] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:03] === Iteration 23/50 ===
|
||||
[2026-01-11 15:59:03] Working on story: US-009
|
||||
[2026-01-11 15:59:03] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_23_US-009.log)
|
||||
[2026-01-11 15:59:07] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:07] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:09] === Iteration 24/50 ===
|
||||
[2026-01-11 15:59:09] Working on story: US-009
|
||||
[2026-01-11 15:59:09] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_24_US-009.log)
|
||||
[2026-01-11 15:59:12] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:12] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:14] === Iteration 25/50 ===
|
||||
[2026-01-11 15:59:14] Working on story: US-009
|
||||
[2026-01-11 15:59:14] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_25_US-009.log)
|
||||
[2026-01-11 15:59:18] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:18] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:20] === Iteration 26/50 ===
|
||||
[2026-01-11 15:59:20] Working on story: US-009
|
||||
[2026-01-11 15:59:20] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_26_US-009.log)
|
||||
[2026-01-11 15:59:23] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:23] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:25] === Iteration 27/50 ===
|
||||
[2026-01-11 15:59:25] Working on story: US-009
|
||||
[2026-01-11 15:59:25] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_27_US-009.log)
|
||||
[2026-01-11 15:59:29] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:29] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:31] === Iteration 28/50 ===
|
||||
[2026-01-11 15:59:31] Working on story: US-009
|
||||
[2026-01-11 15:59:31] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_28_US-009.log)
|
||||
[2026-01-11 15:59:35] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:35] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:37] === Iteration 29/50 ===
|
||||
[2026-01-11 15:59:37] Working on story: US-009
|
||||
[2026-01-11 15:59:37] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_29_US-009.log)
|
||||
[2026-01-11 15:59:40] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:40] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:42] === Iteration 30/50 ===
|
||||
[2026-01-11 15:59:42] Working on story: US-009
|
||||
[2026-01-11 15:59:42] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_30_US-009.log)
|
||||
[2026-01-11 15:59:46] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:46] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:48] === Iteration 31/50 ===
|
||||
[2026-01-11 15:59:48] Working on story: US-009
|
||||
[2026-01-11 15:59:48] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_31_US-009.log)
|
||||
[2026-01-11 15:59:52] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:52] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:54] === Iteration 32/50 ===
|
||||
[2026-01-11 15:59:54] Working on story: US-009
|
||||
[2026-01-11 15:59:54] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_32_US-009.log)
|
||||
[2026-01-11 15:59:57] Story US-009 not yet complete, continuing...
|
||||
[2026-01-11 15:59:57] Progress: 10/13 stories completed
|
||||
[2026-01-11 15:59:59] === Iteration 33/50 ===
|
||||
[2026-01-11 15:59:59] Working on story: US-009
|
||||
[2026-01-11 15:59:59] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_33_US-009.log)
|
||||
[2026-01-11 16:04:24] SUCCESS: Story US-009 passed!
|
||||
[2026-01-11 16:04:24] Changes committed
|
||||
[2026-01-11 16:04:24] Progress: 11/13 stories completed
|
||||
[2026-01-11 16:04:26] === Iteration 34/50 ===
|
||||
[2026-01-11 16:04:26] Working on story: US-008
|
||||
[2026-01-11 16:04:26] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_34_US-008.log)
|
||||
[2026-01-11 16:08:14] SUCCESS: Story US-008 passed!
|
||||
[2026-01-11 16:08:14] Changes committed
|
||||
[2026-01-11 16:08:14] Progress: 12/13 stories completed
|
||||
[2026-01-11 16:08:16] === Iteration 35/50 ===
|
||||
[2026-01-11 16:08:16] Working on story: US-013
|
||||
[2026-01-11 16:08:16] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_35_US-013.log)
|
||||
[2026-01-11 16:12:10] Story US-013 not yet complete, continuing...
|
||||
[2026-01-11 16:12:10] Progress: 12/13 stories completed
|
||||
[2026-01-11 16:12:12] === Iteration 36/50 ===
|
||||
[2026-01-11 16:12:12] Working on story: US-013
|
||||
[2026-01-11 16:12:12] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_36_US-013.log)
|
||||
[2026-01-11 16:14:52] SUCCESS: Story US-013 passed!
|
||||
[2026-01-11 16:14:52] Changes committed
|
||||
[2026-01-11 16:14:52] Progress: 13/13 stories completed
|
||||
[2026-01-11 16:14:54] === Iteration 37/50 ===
|
||||
[2026-01-11 16:14:54] SUCCESS: All stories completed! 🎉
|
||||
[2026-01-11 16:14:54] === Ralph Session Complete ===
|
||||
[2026-01-11 16:14:54] Final progress: 13/13 stories completed
|
||||
[2026-01-11 16:14:54] Branch: ralph/bulk-receipt-upload
|
||||
[2026-01-11 16:14:54] Logs: /workspace/roa2web/scripts/ralph/logs
|
||||
|
||||
---
|
||||
## Phase 2: Real-Time Progress în Listă
|
||||
Started: 2026-01-11 19:40
|
||||
Project: bulk-upload-realtime-progress
|
||||
Branch: ralph/bulk-receipt-upload (CONTINUĂ - NU crea branch nou!)
|
||||
PRD: tasks/prd-bulk-upload-realtime-progress.md
|
||||
|
||||
Această fază extinde bulk upload-ul din Phase 1 (US-001→US-013 COMPLETE) cu:
|
||||
- Afișare fișiere imediat în tabel după upload
|
||||
- Progres real-time pe măsură ce sunt procesate
|
||||
- Cancel individual și batch
|
||||
- Animații pentru status changes
|
||||
|
||||
### Noile User Stories (US-014 → US-023):
|
||||
|
||||
| ID | Title | Priority | Status |
|
||||
|----|-------|----------|--------|
|
||||
| US-014 | Backend - Cancel Job Individual | 14 | pending |
|
||||
| US-015 | Backend - Cancel Batch Complet | 15 | pending |
|
||||
| US-016 | Frontend - Store Actions Cancel | 16 | pending |
|
||||
| US-017 | Frontend - Afișare Jobs în Tabel | 17 | pending |
|
||||
| US-018 | Frontend - Tranziție Job→Receipt | 18 | pending |
|
||||
| US-019 | Frontend - Animație Status Change | 19 | pending |
|
||||
| US-020 | Frontend - Cancel Individual Row | 20 | pending |
|
||||
| US-021 | Frontend - Cancel All în Header | 21 | pending |
|
||||
| US-022 | Frontend - Checkbox Disabled Jobs | 22 | pending |
|
||||
| US-023 | Frontend - Restore Jobs Refresh | 23 | pending |
|
||||
|
||||
---
|
||||
(Ralph va adăuga aici progresul per iterație)
|
||||
[2026-01-11 19:42:53] Starting Ralph for project: bulk-upload-realtime-progress
|
||||
[2026-01-11 19:42:53] Max iterations: 50
|
||||
[2026-01-11 19:42:53] === Iteration 1/50 ===
|
||||
[2026-01-11 19:42:53] Working on story: US-014
|
||||
[2026-01-11 19:42:53] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_1_US-014.log)
|
||||
[2026-01-11 19:47:06] SUCCESS: Story US-014 passed!
|
||||
[2026-01-11 19:47:07] Changes committed
|
||||
[2026-01-11 19:47:07] Progress: 14/23 stories completed
|
||||
[2026-01-11 19:47:09] === Iteration 2/50 ===
|
||||
[2026-01-11 19:47:09] Working on story: US-015
|
||||
[2026-01-11 19:47:09] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_2_US-015.log)
|
||||
[2026-01-11 19:53:13] Story US-015 not yet complete, continuing...
|
||||
[2026-01-11 19:53:13] Progress: 14/23 stories completed
|
||||
[2026-01-11 19:53:15] === Iteration 3/50 ===
|
||||
[2026-01-11 19:53:15] Working on story: US-015
|
||||
[2026-01-11 19:53:15] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_3_US-015.log)
|
||||
[2026-01-11 19:57:53] Starting Ralph for project: bulk-upload-realtime-progress
|
||||
[2026-01-11 19:57:53] Max iterations: 50
|
||||
[2026-01-11 19:57:53] === Iteration 1/50 ===
|
||||
[2026-01-11 19:57:53] Working on story: US-015
|
||||
[2026-01-11 19:57:53] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_1_US-015.log)
|
||||
[2026-01-11 20:00:23] SUCCESS: Story US-015 passed!
|
||||
[2026-01-11 20:00:23] Changes committed
|
||||
[2026-01-11 20:00:23] Progress: 15/23 stories completed
|
||||
[2026-01-11 20:00:25] === Iteration 2/50 ===
|
||||
[2026-01-11 20:00:25] Working on story: US-016
|
||||
[2026-01-11 20:00:25] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_2_US-016.log)
|
||||
[2026-01-11 20:06:04] SUCCESS: Story US-016 passed!
|
||||
[2026-01-11 20:06:04] Changes committed
|
||||
[2026-01-11 20:06:04] Progress: 16/23 stories completed
|
||||
[2026-01-11 20:06:06] === Iteration 3/50 ===
|
||||
[2026-01-11 20:06:06] Working on story: US-017
|
||||
[2026-01-11 20:06:06] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_3_US-017.log)
|
||||
[2026-01-11 20:12:04] Story US-017 not yet complete, continuing...
|
||||
[2026-01-11 20:12:04] Progress: 16/23 stories completed
|
||||
[2026-01-11 20:12:06] === Iteration 4/50 ===
|
||||
[2026-01-11 20:12:06] Working on story: US-017
|
||||
[2026-01-11 20:12:06] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_4_US-017.log)
|
||||
[2026-01-11 20:16:17] SUCCESS: Story US-017 passed!
|
||||
[2026-01-11 20:16:17] Changes committed
|
||||
[2026-01-11 20:16:17] Progress: 17/23 stories completed
|
||||
[2026-01-11 20:16:19] === Iteration 5/50 ===
|
||||
[2026-01-11 20:16:19] Working on story: US-018
|
||||
[2026-01-11 20:16:19] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_5_US-018.log)
|
||||
[2026-01-11 20:22:23] SUCCESS: Story US-018 passed!
|
||||
[2026-01-11 20:22:23] Changes committed
|
||||
[2026-01-11 20:22:23] Progress: 18/23 stories completed
|
||||
[2026-01-11 20:22:25] === Iteration 6/50 ===
|
||||
[2026-01-11 20:22:25] Working on story: US-019
|
||||
[2026-01-11 20:22:25] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_6_US-019.log)
|
||||
[2026-01-11 20:27:28] SUCCESS: Story US-019 passed!
|
||||
[2026-01-11 20:27:28] Changes committed
|
||||
[2026-01-11 20:27:28] Progress: 19/23 stories completed
|
||||
[2026-01-11 20:27:30] === Iteration 7/50 ===
|
||||
[2026-01-11 20:27:30] Working on story: US-020
|
||||
[2026-01-11 20:27:30] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_7_US-020.log)
|
||||
[2026-01-11 20:32:44] SUCCESS: Story US-020 passed!
|
||||
[2026-01-11 20:32:44] Changes committed
|
||||
[2026-01-11 20:32:44] Progress: 20/23 stories completed
|
||||
[2026-01-11 20:32:46] === Iteration 8/50 ===
|
||||
[2026-01-11 20:32:46] Working on story: US-021
|
||||
[2026-01-11 20:32:46] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_8_US-021.log)
|
||||
[2026-01-11 20:35:59] SUCCESS: Story US-021 passed!
|
||||
[2026-01-11 20:35:59] Changes committed
|
||||
[2026-01-11 20:35:59] Progress: 21/23 stories completed
|
||||
[2026-01-11 20:36:01] === Iteration 9/50 ===
|
||||
[2026-01-11 20:36:01] Working on story: US-022
|
||||
[2026-01-11 20:36:01] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_9_US-022.log)
|
||||
[2026-01-11 20:38:17] SUCCESS: Story US-022 passed!
|
||||
[2026-01-11 20:38:17] Changes committed
|
||||
[2026-01-11 20:38:17] Progress: 22/23 stories completed
|
||||
[2026-01-11 20:38:19] === Iteration 10/50 ===
|
||||
[2026-01-11 20:38:19] Working on story: US-023
|
||||
[2026-01-11 20:38:19] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_10_US-023.log)
|
||||
[2026-01-11 20:44:18] Story US-023 not yet complete, continuing...
|
||||
[2026-01-11 20:44:18] Progress: 22/23 stories completed
|
||||
[2026-01-11 20:44:20] === Iteration 11/50 ===
|
||||
[2026-01-11 20:44:20] Working on story: US-023
|
||||
[2026-01-11 20:44:20] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_11_US-023.log)
|
||||
[2026-01-11 20:46:15] SUCCESS: Story US-023 passed!
|
||||
[2026-01-11 20:46:15] Changes committed
|
||||
[2026-01-11 20:46:15] Progress: 23/23 stories completed
|
||||
[2026-01-11 20:46:17] === Iteration 12/50 ===
|
||||
[2026-01-11 20:46:17] SUCCESS: All stories completed! 🎉
|
||||
[2026-01-11 20:46:17] === Ralph Session Complete ===
|
||||
[2026-01-11 20:46:17] Final progress: 23/23 stories completed
|
||||
[2026-01-11 20:46:17] Branch: ralph/bulk-receipt-upload
|
||||
[2026-01-11 20:46:17] Logs: /workspace/roa2web/scripts/ralph/logs
|
||||
|
||||
---
|
||||
## Phase 3: Bulk Actions & SSE Real-time Updates
|
||||
Started: 2026-01-11 21:50
|
||||
Project: bulk-actions-sse-refresh
|
||||
Branch: ralph/bulk-receipt-upload (CONTINUĂ - NU crea branch nou!)
|
||||
PRD: tasks/prd-bulk-actions-sse-refresh.md
|
||||
|
||||
Această fază adaugă:
|
||||
- Bulk Delete pentru bonuri selectate
|
||||
- SSE (Server-Sent Events) pentru actualizări real-time
|
||||
- Eliminare polling în favoarea SSE
|
||||
- Graceful degradation cu fallback la polling
|
||||
|
||||
### Noile User Stories (US-024 → US-033):
|
||||
|
||||
| ID | Title | Priority | Status |
|
||||
|----|-------|----------|--------|
|
||||
| US-024 | Backend - Endpoint Bulk Delete | 24 | pending |
|
||||
| US-025 | Frontend - Buton Șterge Bulk Actions | 25 | pending |
|
||||
| US-026 | Frontend - Dialog Confirmare Ștergere | 26 | pending |
|
||||
| US-027 | Frontend - Bulk Delete Partial Success | 27 | pending |
|
||||
| US-028 | Frontend - Navigare Pagină Anterioară | 28 | pending |
|
||||
| US-029 | Frontend - updateReceiptInPlace Store | 29 | pending |
|
||||
| US-030 | Backend - SSE Endpoint Status Updates | 30 | pending |
|
||||
| US-031 | Frontend - SSE Client Service | 31 | pending |
|
||||
| US-032 | Frontend - Înlocuire Polling cu SSE | 32 | pending |
|
||||
| US-033 | Frontend - Graceful Degradation SSE | 33 | pending |
|
||||
|
||||
---
|
||||
(Ralph va adăuga aici progresul per iterație)
|
||||
[2026-01-11 22:12:45] Starting Ralph for project: bulk-actions-sse-refresh
|
||||
[2026-01-11 22:12:45] Max iterations: 50
|
||||
[2026-01-11 22:12:45] === Iteration 1/50 ===
|
||||
[2026-01-11 22:12:45] Working on story: US-024
|
||||
[2026-01-11 22:12:45] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_1_US-024.log)
|
||||
[2026-01-11 22:48:00] MANUAL: US-024 marked as passed (code complete, Claude timed out after 30+ minutes)
|
||||
[2026-01-11 22:48:04] Starting Ralph for project: bulk-actions-sse-refresh
|
||||
[2026-01-11 22:48:04] Max iterations: 50
|
||||
[2026-01-11 22:48:04] === Iteration 1/50 ===
|
||||
[2026-01-11 22:48:04] Working on story: US-025
|
||||
[2026-01-11 22:48:04] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_1_US-025.log)
|
||||
[2026-01-11 22:51:34] SUCCESS: Story US-025 passed!
|
||||
[2026-01-11 22:51:34] Changes committed
|
||||
[2026-01-11 22:51:34] Progress: 25/33 stories completed
|
||||
[2026-01-11 22:51:36] === Iteration 2/50 ===
|
||||
[2026-01-11 22:51:36] Working on story: US-026
|
||||
[2026-01-11 22:51:36] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_2_US-026.log)
|
||||
[2026-01-11 22:55:07] SUCCESS: Story US-026 passed!
|
||||
[2026-01-11 22:55:07] Changes committed
|
||||
[2026-01-11 22:55:07] Progress: 26/33 stories completed
|
||||
[2026-01-11 22:55:09] === Iteration 3/50 ===
|
||||
[2026-01-11 22:55:09] Working on story: US-027
|
||||
[2026-01-11 22:55:09] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_3_US-027.log)
|
||||
[2026-01-11 22:59:03] SUCCESS: Story US-027 passed!
|
||||
[2026-01-11 22:59:03] Changes committed
|
||||
[2026-01-11 22:59:03] Progress: 27/33 stories completed
|
||||
[2026-01-11 22:59:05] === Iteration 4/50 ===
|
||||
[2026-01-11 22:59:05] Working on story: US-028
|
||||
[2026-01-11 22:59:05] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_4_US-028.log)
|
||||
[2026-01-11 23:03:22] SUCCESS: Story US-028 passed!
|
||||
[2026-01-11 23:03:22] Changes committed
|
||||
[2026-01-11 23:03:22] Progress: 28/33 stories completed
|
||||
[2026-01-11 23:03:24] === Iteration 5/50 ===
|
||||
[2026-01-11 23:03:24] Working on story: US-029
|
||||
[2026-01-11 23:03:24] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_5_US-029.log)
|
||||
[2026-01-11 23:04:38] SUCCESS: Story US-029 passed!
|
||||
[2026-01-11 23:04:38] Changes committed
|
||||
[2026-01-11 23:04:38] Progress: 29/33 stories completed
|
||||
[2026-01-11 23:04:40] === Iteration 6/50 ===
|
||||
[2026-01-11 23:04:40] Working on story: US-030
|
||||
[2026-01-11 23:04:40] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_6_US-030.log)
|
||||
[2026-01-11 23:35:52] MANUAL: US-030 marked as passed (SSE endpoint complete, Claude timed out after 30+ minutes)
|
||||
[2026-01-11 23:35:57] Starting Ralph for project: bulk-actions-sse-refresh
|
||||
[2026-01-11 23:35:57] Max iterations: 50
|
||||
[2026-01-11 23:35:57] === Iteration 1/50 ===
|
||||
[2026-01-11 23:35:57] Working on story: US-031
|
||||
[2026-01-11 23:35:57] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_1_US-031.log)
|
||||
[2026-01-11 23:37:36] SUCCESS: Story US-031 passed!
|
||||
[2026-01-11 23:37:36] Changes committed
|
||||
[2026-01-11 23:37:36] Progress: 31/33 stories completed
|
||||
[2026-01-11 23:37:38] === Iteration 2/50 ===
|
||||
[2026-01-11 23:37:38] Working on story: US-032
|
||||
[2026-01-11 23:37:38] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_2_US-032.log)
|
||||
[2026-01-11 23:41:53] SUCCESS: Story US-032 passed!
|
||||
[2026-01-11 23:41:53] Changes committed
|
||||
[2026-01-11 23:41:53] Progress: 32/33 stories completed
|
||||
[2026-01-11 23:41:55] === Iteration 3/50 ===
|
||||
[2026-01-11 23:41:55] Working on story: US-033
|
||||
[2026-01-11 23:41:55] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_3_US-033.log)
|
||||
[2026-01-11 23:44:52] SUCCESS: Story US-033 passed!
|
||||
[2026-01-11 23:44:53] Changes committed
|
||||
[2026-01-11 23:44:53] Progress: 33/33 stories completed
|
||||
[2026-01-11 23:44:55] === Iteration 4/50 ===
|
||||
[2026-01-11 23:44:55] SUCCESS: All stories completed! 🎉
|
||||
[2026-01-11 23:44:55] === Ralph Session Complete ===
|
||||
[2026-01-11 23:44:55] Final progress: 33/33 stories completed
|
||||
[2026-01-11 23:44:55] Branch: ralph/bulk-receipt-upload
|
||||
[2026-01-11 23:44:55] Logs: /workspace/roa2web/scripts/ralph/logs
|
||||
|
||||
---
|
||||
## Phase 2: Mobile UX Improvements
|
||||
Started: 2026-01-12
|
||||
|
||||
### New User Stories Added:
|
||||
- US-034: Fix - Refresh Individual vs Refresh Total
|
||||
- US-035: Fix - Bonuri cu Eroare Rămân în Listă
|
||||
- US-036: Afișare Nume Fișier pentru Toate Bonurile
|
||||
- US-037: Fix - Upload Nu Mai Face Refresh Automat
|
||||
- US-038: Mobile - Selecție Multiplă prin Long-Press
|
||||
- US-039: Mobile - Select All și Buton Ștergere
|
||||
- US-040: Mobile - Layout Android Nativ pentru Lista Bonuri
|
||||
- US-041: Mobile - Layout Android pentru Editare/Creare Bon
|
||||
- US-042: Mobile - Layout Android pentru Vizualizare Bon
|
||||
- US-043: Păstrare Ordine Bonuri la Refresh
|
||||
|
||||
### Prioritization:
|
||||
Faza 1 (Bug Fixes): US-034, US-035, US-037, US-043
|
||||
Faza 2 (Mobile Enhancements): US-036, US-038, US-039
|
||||
Faza 3 (Mobile Native Layout): US-040, US-041, US-042
|
||||
[2026-01-12 07:34:34] Starting Ralph for project: mobile-ux-improvements
|
||||
[2026-01-12 07:34:34] Max iterations: 50
|
||||
[2026-01-12 07:34:34] === Iteration 1/50 ===
|
||||
[2026-01-12 07:34:34] Working on story: US-034
|
||||
[2026-01-12 07:34:34] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_1_US-034.log)
|
||||
[2026-01-12 07:38:22] SUCCESS: Story US-034 passed!
|
||||
[2026-01-12 07:38:22] Changes committed
|
||||
[2026-01-12 07:38:22] Progress: 34/43 stories completed
|
||||
[2026-01-12 07:38:24] === Iteration 2/50 ===
|
||||
[2026-01-12 07:38:24] Working on story: US-035
|
||||
[2026-01-12 07:38:24] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_2_US-035.log)
|
||||
[2026-01-12 07:41:39] SUCCESS: Story US-035 passed!
|
||||
[2026-01-12 07:41:39] Changes committed
|
||||
[2026-01-12 07:41:39] Progress: 35/43 stories completed
|
||||
[2026-01-12 07:41:41] === Iteration 3/50 ===
|
||||
[2026-01-12 07:41:41] Working on story: US-036
|
||||
[2026-01-12 07:41:41] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_3_US-036.log)
|
||||
[2026-01-12 07:46:55] SUCCESS: Story US-036 passed!
|
||||
[2026-01-12 07:46:56] Changes committed
|
||||
[2026-01-12 07:46:56] Progress: 36/43 stories completed
|
||||
[2026-01-12 07:46:58] === Iteration 4/50 ===
|
||||
[2026-01-12 07:46:58] Working on story: US-037
|
||||
[2026-01-12 07:46:58] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_4_US-037.log)
|
||||
[2026-01-12 07:51:38] SUCCESS: Story US-037 passed!
|
||||
[2026-01-12 07:51:38] Changes committed
|
||||
[2026-01-12 07:51:38] Progress: 37/43 stories completed
|
||||
[2026-01-12 07:51:40] === Iteration 5/50 ===
|
||||
[2026-01-12 07:51:40] Working on story: US-038
|
||||
[2026-01-12 07:51:40] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_5_US-038.log)
|
||||
[2026-01-12 07:56:08] SUCCESS: Story US-038 passed!
|
||||
[2026-01-12 07:56:08] Changes committed
|
||||
[2026-01-12 07:56:08] Progress: 38/43 stories completed
|
||||
[2026-01-12 07:56:10] === Iteration 6/50 ===
|
||||
[2026-01-12 07:56:10] Working on story: US-039
|
||||
[2026-01-12 07:56:10] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_6_US-039.log)
|
||||
[2026-01-12 08:00:37] SUCCESS: Story US-039 passed!
|
||||
[2026-01-12 08:00:37] Changes committed
|
||||
[2026-01-12 08:00:38] Progress: 39/43 stories completed
|
||||
[2026-01-12 08:00:40] === Iteration 7/50 ===
|
||||
[2026-01-12 08:00:40] Working on story: US-040
|
||||
[2026-01-12 08:00:40] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_7_US-040.log)
|
||||
[2026-01-12 08:06:20] SUCCESS: Story US-040 passed!
|
||||
[2026-01-12 08:06:20] Changes committed
|
||||
[2026-01-12 08:06:20] Progress: 40/43 stories completed
|
||||
[2026-01-12 08:06:22] === Iteration 8/50 ===
|
||||
[2026-01-12 08:06:22] Working on story: US-041
|
||||
[2026-01-12 08:06:22] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_8_US-041.log)
|
||||
[2026-01-12 08:11:12] SUCCESS: Story US-041 passed!
|
||||
[2026-01-12 08:11:12] Changes committed
|
||||
[2026-01-12 08:11:12] Progress: 41/43 stories completed
|
||||
[2026-01-12 08:11:14] === Iteration 9/50 ===
|
||||
[2026-01-12 08:11:14] Working on story: US-042
|
||||
[2026-01-12 08:11:14] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_9_US-042.log)
|
||||
[2026-01-12 08:14:53] SUCCESS: Story US-042 passed!
|
||||
[2026-01-12 08:14:53] Changes committed
|
||||
[2026-01-12 08:14:53] Progress: 42/43 stories completed
|
||||
[2026-01-12 08:14:55] === Iteration 10/50 ===
|
||||
[2026-01-12 08:14:55] Working on story: US-043
|
||||
[2026-01-12 08:14:55] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_10_US-043.log)
|
||||
[2026-01-12 08:20:33] SUCCESS: Story US-043 passed!
|
||||
[2026-01-12 08:20:33] Changes committed
|
||||
[2026-01-12 08:20:33] Progress: 43/43 stories completed
|
||||
[2026-01-12 08:20:35] === Iteration 11/50 ===
|
||||
[2026-01-12 08:20:35] SUCCESS: All stories completed! 🎉
|
||||
[2026-01-12 08:20:35] === Ralph Session Complete ===
|
||||
[2026-01-12 08:20:35] Final progress: 43/43 stories completed
|
||||
[2026-01-12 08:20:35] Branch: ralph/bulk-receipt-upload
|
||||
[2026-01-12 08:20:35] Logs: /workspace/roa2web/scripts/ralph/logs
|
||||
67
scripts/ralph/prompt.md
Normal file
67
scripts/ralph/prompt.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Ralph Iteration Prompt Template
|
||||
|
||||
Acest fișier conține template-ul de prompt folosit de Ralph pentru fiecare iterație.
|
||||
|
||||
## Context
|
||||
|
||||
Ralph citește `prd.json` și selectează următoarea user story neprocesată (by priority).
|
||||
Apoi lansează Claude cu un prompt care include:
|
||||
|
||||
1. Story ID și titlu
|
||||
2. Acceptance criteria complete
|
||||
3. Technical notes
|
||||
4. CSS rules din PRD
|
||||
|
||||
## Cum funcționează
|
||||
|
||||
### Signals de la Claude:
|
||||
|
||||
- **STORY_PASSED** - Toate criteriile sunt îndeplinite, Ralph marchează story ca passed și commit-uiește
|
||||
- **STORY_BLOCKED: <reason>** - Story-ul nu poate fi completat, Ralph oprește loop-ul
|
||||
|
||||
### Flow per iterație:
|
||||
|
||||
```
|
||||
1. Ralph citește prd.json
|
||||
2. Selectează story cu priority minim care are passes=false
|
||||
3. Generează prompt cu detaliile story-ului
|
||||
4. Rulează: claude -p "<prompt>"
|
||||
5. Analizează output:
|
||||
- STORY_PASSED → mark passed, git commit, next iteration
|
||||
- STORY_BLOCKED → log reason, stop loop
|
||||
- Altceva → continue iteration (Claude încă lucrează)
|
||||
6. Sleep 2s, repeat
|
||||
```
|
||||
|
||||
## CSS Rules Reminder
|
||||
|
||||
Fiecare prompt include CSS rules din PRD pentru că sunt critice:
|
||||
|
||||
```
|
||||
IMPORTANT CSS RULES:
|
||||
- NEVER use hardcoded values - always use design tokens
|
||||
- Check docs/DESIGN_TOKENS.md before writing CSS
|
||||
- Test in BOTH light and dark mode
|
||||
- NEVER use :deep() in components
|
||||
```
|
||||
|
||||
## Manual Override
|
||||
|
||||
Dacă Ralph se blochează, poți:
|
||||
|
||||
1. Edita `prd.json` manual pentru a marca stories ca passed
|
||||
2. Adăuga notes explicative
|
||||
3. Relansa Ralph cu `./ralph.sh`
|
||||
|
||||
## Monitorizare
|
||||
|
||||
```bash
|
||||
# Vezi progress
|
||||
cat scripts/ralph/prd.json | jq '.userStories[] | {id, title, passes}'
|
||||
|
||||
# Vezi log curent
|
||||
tail -f scripts/ralph/progress.txt
|
||||
|
||||
# Vezi toate logurile
|
||||
ls -la scripts/ralph/logs/
|
||||
```
|
||||
203
scripts/ralph/ralph.sh
Executable file
203
scripts/ralph/ralph.sh
Executable file
@@ -0,0 +1,203 @@
|
||||
#!/bin/bash
|
||||
# Ralph - Autonomous Loop for PRD Implementation
|
||||
# Usage: ./ralph.sh [max_iterations]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
PRD_FILE="$SCRIPT_DIR/prd.json"
|
||||
PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
|
||||
PROMPT_FILE="$SCRIPT_DIR/prompt.md"
|
||||
LOG_DIR="$SCRIPT_DIR/logs"
|
||||
|
||||
MAX_ITERATIONS=${1:-50}
|
||||
ITERATION=0
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() {
|
||||
echo -e "${BLUE}[Ralph]${NC} $1"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$PROGRESS_FILE"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[Ralph ERROR]${NC} $1"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$PROGRESS_FILE"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[Ralph]${NC} $1"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1" >> "$PROGRESS_FILE"
|
||||
}
|
||||
|
||||
# Check prerequisites
|
||||
if [ ! -f "$PRD_FILE" ]; then
|
||||
error "prd.json not found at $PRD_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v claude &> /dev/null; then
|
||||
error "claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
error "jq not found. Install with: apt install jq"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create log directory
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# Get project info
|
||||
PROJECT_NAME=$(jq -r '.projectName' "$PRD_FILE")
|
||||
BRANCH_NAME=$(jq -r '.branchName' "$PRD_FILE")
|
||||
|
||||
log "Starting Ralph for project: $PROJECT_NAME"
|
||||
log "Max iterations: $MAX_ITERATIONS"
|
||||
|
||||
# Create branch if not exists
|
||||
cd "$PROJECT_ROOT"
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
if [ "$CURRENT_BRANCH" != "$BRANCH_NAME" ]; then
|
||||
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
|
||||
log "Switching to existing branch: $BRANCH_NAME"
|
||||
git checkout "$BRANCH_NAME"
|
||||
else
|
||||
log "Creating new branch: $BRANCH_NAME"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Function to get next pending story
|
||||
get_next_story() {
|
||||
jq -r '.userStories | map(select(.passes == false)) | sort_by(.priority) | .[0] | .id // empty' "$PRD_FILE"
|
||||
}
|
||||
|
||||
# Function to get story details
|
||||
get_story_details() {
|
||||
local story_id=$1
|
||||
jq -r --arg id "$story_id" '.userStories[] | select(.id == $id)' "$PRD_FILE"
|
||||
}
|
||||
|
||||
# Function to mark story as passed
|
||||
mark_story_passed() {
|
||||
local story_id=$1
|
||||
local notes=$2
|
||||
local tmp_file=$(mktemp)
|
||||
jq --arg id "$story_id" --arg notes "$notes" \
|
||||
'(.userStories[] | select(.id == $id)) |= (.passes = true | .notes = $notes)' \
|
||||
"$PRD_FILE" > "$tmp_file" && mv "$tmp_file" "$PRD_FILE"
|
||||
}
|
||||
|
||||
# Function to count stories
|
||||
count_stories() {
|
||||
local total=$(jq '.userStories | length' "$PRD_FILE")
|
||||
local passed=$(jq '[.userStories[] | select(.passes == true)] | length' "$PRD_FILE")
|
||||
echo "$passed/$total"
|
||||
}
|
||||
|
||||
# Main loop
|
||||
while [ $ITERATION -lt $MAX_ITERATIONS ]; do
|
||||
ITERATION=$((ITERATION + 1))
|
||||
|
||||
log "=== Iteration $ITERATION/$MAX_ITERATIONS ==="
|
||||
|
||||
# Get next story
|
||||
NEXT_STORY=$(get_next_story)
|
||||
|
||||
if [ -z "$NEXT_STORY" ]; then
|
||||
success "All stories completed! 🎉"
|
||||
break
|
||||
fi
|
||||
|
||||
log "Working on story: $NEXT_STORY"
|
||||
|
||||
# Get story details for prompt
|
||||
STORY_JSON=$(get_story_details "$NEXT_STORY")
|
||||
STORY_TITLE=$(echo "$STORY_JSON" | jq -r '.title')
|
||||
|
||||
# Create iteration prompt
|
||||
ITERATION_PROMPT="You are implementing user story $NEXT_STORY: $STORY_TITLE
|
||||
|
||||
Read the full PRD at scripts/ralph/prd.json for context and CSS rules.
|
||||
|
||||
Story details:
|
||||
$STORY_JSON
|
||||
|
||||
IMPORTANT CSS RULES (from PRD):
|
||||
- NEVER use hardcoded values - always use design tokens
|
||||
- Check docs/DESIGN_TOKENS.md and docs/CSS_PATTERNS.md before writing CSS
|
||||
- Test in BOTH light and dark mode
|
||||
- NEVER use :deep() in components
|
||||
|
||||
Your task:
|
||||
1. Implement this story following all acceptance criteria
|
||||
2. Run tests/typecheck to verify
|
||||
3. If ALL criteria pass, respond with: STORY_PASSED
|
||||
4. If blocked or need clarification, respond with: STORY_BLOCKED: <reason>
|
||||
|
||||
Do NOT move to other stories. Focus only on $NEXT_STORY."
|
||||
|
||||
# Run Claude
|
||||
LOG_FILE="$LOG_DIR/iteration_${ITERATION}_${NEXT_STORY}.log"
|
||||
log "Running Claude... (log: $LOG_FILE)"
|
||||
|
||||
# Run claude with the prompt (--output-format json avoids streaming mode issues)
|
||||
CLAUDE_OUTPUT=$(cd "$PROJECT_ROOT" && claude -p "$ITERATION_PROMPT" --output-format json 2>&1 | tee "$LOG_FILE")
|
||||
|
||||
# Check result
|
||||
if echo "$CLAUDE_OUTPUT" | grep -q "STORY_PASSED"; then
|
||||
success "Story $NEXT_STORY passed!"
|
||||
mark_story_passed "$NEXT_STORY" "Completed in iteration $ITERATION"
|
||||
|
||||
# Commit changes
|
||||
cd "$PROJECT_ROOT"
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add -A
|
||||
git commit -m "feat($PROJECT_NAME): Complete $NEXT_STORY - $STORY_TITLE
|
||||
|
||||
Implemented by Ralph autonomous loop.
|
||||
Iteration: $ITERATION
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
log "Changes committed"
|
||||
fi
|
||||
|
||||
elif echo "$CLAUDE_OUTPUT" | grep -q "STORY_BLOCKED"; then
|
||||
BLOCK_REASON=$(echo "$CLAUDE_OUTPUT" | grep "STORY_BLOCKED" | sed 's/STORY_BLOCKED://')
|
||||
error "Story $NEXT_STORY blocked: $BLOCK_REASON"
|
||||
log "Stopping loop due to blocked story"
|
||||
break
|
||||
else
|
||||
log "Story $NEXT_STORY not yet complete, continuing..."
|
||||
fi
|
||||
|
||||
# Progress update
|
||||
PROGRESS=$(count_stories)
|
||||
log "Progress: $PROGRESS stories completed"
|
||||
|
||||
# Small delay between iterations
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Final summary
|
||||
echo ""
|
||||
log "=== Ralph Session Complete ==="
|
||||
PROGRESS=$(count_stories)
|
||||
log "Final progress: $PROGRESS stories completed"
|
||||
log "Branch: $BRANCH_NAME"
|
||||
log "Logs: $LOG_DIR"
|
||||
|
||||
# Show remaining stories
|
||||
REMAINING=$(jq -r '[.userStories[] | select(.passes == false)] | length' "$PRD_FILE")
|
||||
if [ "$REMAINING" -gt 0 ]; then
|
||||
echo -e "\n${YELLOW}Remaining stories:${NC}"
|
||||
jq -r '.userStories[] | select(.passes == false) | " - \(.id): \(.title)"' "$PRD_FILE"
|
||||
fi
|
||||
190
src/assets/css/vendor/primevue-overrides.css
vendored
190
src/assets/css/vendor/primevue-overrides.css
vendored
@@ -243,3 +243,193 @@
|
||||
background-color: var(--surface-hover, #e3f2fd) !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ===== US-010: Row Lock for Processing Receipts ===== */
|
||||
/**
|
||||
* Processing row styling - visual indicator that the row is read-only.
|
||||
* Uses border-left accent and reduced opacity per acceptance criteria.
|
||||
*/
|
||||
.p-datatable .p-datatable-tbody > tr.row-processing {
|
||||
opacity: 0.7;
|
||||
border-left: 3px solid var(--blue-500);
|
||||
background-color: var(--blue-50) !important;
|
||||
transition: opacity var(--transition-fast, 150ms ease),
|
||||
background-color var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
/* Dark mode support for processing rows */
|
||||
[data-theme="dark"] .p-datatable .p-datatable-tbody > tr.row-processing {
|
||||
background-color: color-mix(in srgb, var(--blue-500) 15%, var(--surface-card)) !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) .p-datatable .p-datatable-tbody > tr.row-processing {
|
||||
background-color: color-mix(in srgb, var(--blue-500) 15%, var(--surface-card)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure hover doesn't override processing row styling too aggressively */
|
||||
.p-datatable .p-datatable-tbody > tr.row-processing:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Disabled buttons in processing rows show not-allowed cursor */
|
||||
.p-datatable .p-datatable-tbody > tr.row-processing .p-button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============ US-018: Failed Job Row Styling ============ */
|
||||
|
||||
/* Failed job row - red styling similar to processing but with red accent */
|
||||
.p-datatable .p-datatable-tbody > tr.row-failed {
|
||||
opacity: 0.85;
|
||||
border-left: 3px solid var(--red-500);
|
||||
background-color: var(--red-50) !important;
|
||||
transition: opacity var(--transition-fast, 150ms ease),
|
||||
background-color var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-datatable .p-datatable-tbody > tr.row-failed {
|
||||
background-color: color-mix(in srgb, var(--red-500) 15%, var(--surface-card)) !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) .p-datatable .p-datatable-tbody > tr.row-failed {
|
||||
background-color: color-mix(in srgb, var(--red-500) 15%, var(--surface-card)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover effect for failed rows */
|
||||
.p-datatable .p-datatable-tbody > tr.row-failed:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============ US-019: Status Change Animations ============ */
|
||||
|
||||
/**
|
||||
* Keyframe animations for row highlight on status change.
|
||||
* - highlightGreen: Plays when a job completes successfully (2s duration)
|
||||
* - highlightRed: Plays when a job fails (2s duration)
|
||||
*
|
||||
* The animations are subtle - starting with a colored background that
|
||||
* fades to transparent. Uses design tokens for colors.
|
||||
*/
|
||||
|
||||
/* Success highlight animation - green fade */
|
||||
@keyframes highlightGreen {
|
||||
0% {
|
||||
background-color: var(--green-100);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error highlight animation - red fade */
|
||||
@keyframes highlightRed {
|
||||
0% {
|
||||
background-color: var(--red-100);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row highlight class for completed status.
|
||||
* Applied when a row transitions to 'completed' status.
|
||||
* The animation runs once and fills forwards to stay at the end state.
|
||||
*/
|
||||
.p-datatable .p-datatable-tbody > tr.row-highlight-completed {
|
||||
animation: highlightGreen 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row highlight class for failed status.
|
||||
* Applied when a row transitions to 'failed' status.
|
||||
*/
|
||||
.p-datatable .p-datatable-tbody > tr.row-highlight-failed {
|
||||
animation: highlightRed 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments for highlight animations */
|
||||
[data-theme="dark"] .p-datatable .p-datatable-tbody > tr.row-highlight-completed {
|
||||
animation-name: highlightGreenDark;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-datatable .p-datatable-tbody > tr.row-highlight-failed {
|
||||
animation-name: highlightRedDark;
|
||||
}
|
||||
|
||||
@keyframes highlightGreenDark {
|
||||
0% {
|
||||
background-color: color-mix(in srgb, var(--green-500) 25%, var(--surface-card));
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes highlightRedDark {
|
||||
0% {
|
||||
background-color: color-mix(in srgb, var(--red-500) 25%, var(--surface-card));
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* System preference dark mode animations */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) .p-datatable .p-datatable-tbody > tr.row-highlight-completed {
|
||||
animation-name: highlightGreenDark;
|
||||
}
|
||||
|
||||
:root:not([data-theme]) .p-datatable .p-datatable-tbody > tr.row-highlight-failed {
|
||||
animation-name: highlightRedDark;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* US-019: Accessibility - Disable animations for users who prefer reduced motion.
|
||||
* This respects the prefers-reduced-motion media query as per acceptance criteria.
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.p-datatable .p-datatable-tbody > tr.row-highlight-completed,
|
||||
.p-datatable .p-datatable-tbody > tr.row-highlight-failed {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ US-020: Cancel Job Row Animation ============ */
|
||||
|
||||
/**
|
||||
* Fade-out animation for job rows being cancelled.
|
||||
* Applied when user cancels a pending/processing file.
|
||||
* Animation runs for 300ms before row is removed from DOM.
|
||||
*/
|
||||
@keyframes rowFadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr.row-cancelling {
|
||||
animation: rowFadeOut 300ms ease-out forwards;
|
||||
pointer-events: none; /* Prevent interaction during animation */
|
||||
}
|
||||
|
||||
/**
|
||||
* US-020: Accessibility - simplified animation for reduced motion preference.
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes rowFadeOut {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const menuSections = [
|
||||
items: [
|
||||
{ to: '/data-entry', icon: 'pi pi-list', label: 'Lista Bonuri' },
|
||||
{ to: '/data-entry/create', icon: 'pi pi-plus', label: 'Bon Nou' }
|
||||
// US-013: Removed bulk-upload link - functionality integrated into Lista Bonuri via drag & drop
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
397
src/modules/data-entry/components/bulk/BatchGroupHeader.vue
Normal file
397
src/modules/data-entry/components/bulk/BatchGroupHeader.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<div
|
||||
class="batch-group-header"
|
||||
:class="{ 'is-expanded': isExpanded, 'has-active-processing': hasActiveProcessing }"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<div class="batch-header-content">
|
||||
<i
|
||||
class="pi chevron-icon"
|
||||
:class="isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'"
|
||||
></i>
|
||||
|
||||
<div class="batch-info">
|
||||
<span class="batch-label">
|
||||
<template v-if="batchId">
|
||||
Batch {{ shortBatchId }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Alte bonuri
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<span class="batch-separator">•</span>
|
||||
|
||||
<span class="batch-date" v-if="formattedDate">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
<span class="batch-date" v-else>
|
||||
Creat manual
|
||||
</span>
|
||||
|
||||
<span class="batch-separator">•</span>
|
||||
|
||||
<span class="batch-count">
|
||||
{{ itemsCount }} {{ itemsCount === 1 ? 'fișier' : 'fișiere' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Processing status indicator -->
|
||||
<div v-if="hasActiveProcessing" class="batch-status-indicator processing">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<span>{{ activeProcessingCount }} în procesare</span>
|
||||
<!-- US-021: Cancel All button - visible when there are pending/processing jobs -->
|
||||
<button
|
||||
v-if="batchId"
|
||||
class="cancel-all-btn"
|
||||
@click.stop="handleCancelAll"
|
||||
title="Anulează toate fișierele în așteptare"
|
||||
>
|
||||
<i class="pi pi-times"></i>
|
||||
<span class="cancel-btn-text">Anulează tot</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="failedCount > 0" class="batch-status-indicator failed">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
<span>{{ failedCount }} cu erori</span>
|
||||
<!-- US-006: Retry All Failed button -->
|
||||
<button
|
||||
v-if="batchId && !retrying"
|
||||
class="retry-all-btn"
|
||||
@click.stop="handleRetryAll"
|
||||
title="Reîncercă toate erorile"
|
||||
>
|
||||
<i class="pi pi-refresh"></i>
|
||||
<span class="retry-btn-text">Reîncercă</span>
|
||||
</button>
|
||||
<span v-if="retrying" class="retry-loading">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="allCompleted" class="batch-status-indicator completed">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<span>Procesat</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
batchId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
processingStartedAt: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// US-006: Whether retry is in progress
|
||||
retrying: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'retry-all', 'cancel-all'])
|
||||
|
||||
/**
|
||||
* Handle retry all button click.
|
||||
* Stops propagation to prevent toggle, emits retry-all event.
|
||||
*/
|
||||
const handleRetryAll = () => {
|
||||
if (props.batchId) {
|
||||
emit('retry-all', props.batchId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel all button click.
|
||||
* Stops propagation to prevent toggle, emits cancel-all event with batch info.
|
||||
*
|
||||
* @emits cancel-all - Emits batch ID and counts for confirmation dialog
|
||||
*/
|
||||
const handleCancelAll = () => {
|
||||
if (props.batchId) {
|
||||
emit('cancel-all', {
|
||||
batchId: props.batchId,
|
||||
pendingProcessingCount: pendingProcessingCount.value,
|
||||
completedCount: completedJobsCount.value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compute short batch ID (first 8 chars) - format: B-XXXXXXXX
|
||||
const shortBatchId = computed(() => {
|
||||
if (!props.batchId) return ''
|
||||
// If batch_id is UUID-like, take first 8 chars
|
||||
const id = props.batchId.replace(/-/g, '').substring(0, 8).toUpperCase()
|
||||
return `B-${id}`
|
||||
})
|
||||
|
||||
// Format date as "DD Mon YYYY"
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.processingStartedAt) return null
|
||||
|
||||
const date = new Date(props.processingStartedAt)
|
||||
const months = ['Ian', 'Feb', 'Mar', 'Apr', 'Mai', 'Iun', 'Iul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const month = months[date.getMonth()]
|
||||
const year = date.getFullYear()
|
||||
|
||||
return `${day} ${month} ${year}`
|
||||
})
|
||||
|
||||
// Count items
|
||||
const itemsCount = computed(() => props.items.length)
|
||||
|
||||
// Check for active processing (pending or processing status)
|
||||
const activeProcessingCount = computed(() => {
|
||||
return props.items.filter(
|
||||
item => item.processing_status === 'pending' || item.processing_status === 'processing'
|
||||
).length
|
||||
})
|
||||
|
||||
const hasActiveProcessing = computed(() => activeProcessingCount.value > 0)
|
||||
|
||||
/**
|
||||
* US-021: Count of pending/processing items that CAN be cancelled.
|
||||
* This is the same as activeProcessingCount but with a clearer name for cancel context.
|
||||
*/
|
||||
const pendingProcessingCount = computed(() => activeProcessingCount.value)
|
||||
|
||||
/**
|
||||
* US-021: Count of completed items that will REMAIN after cancel.
|
||||
* These are receipts already processed successfully.
|
||||
*/
|
||||
const completedJobsCount = computed(() => {
|
||||
return props.items.filter(item => item.processing_status === 'completed').length
|
||||
})
|
||||
|
||||
// Failed items count
|
||||
const failedCount = computed(() => {
|
||||
return props.items.filter(item => item.processing_status === 'failed').length
|
||||
})
|
||||
|
||||
// Check if all completed
|
||||
const allCompleted = computed(() => {
|
||||
if (!props.batchId) return false // Manual items don't show completed status
|
||||
return props.items.every(item => item.processing_status === 'completed')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.batch-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.batch-group-header:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.batch-group-header.is-expanded {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.batch-group-header.has-active-processing {
|
||||
border-left: 3px solid var(--blue-500);
|
||||
}
|
||||
|
||||
.batch-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
transition: transform var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.batch-label {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.batch-separator {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.batch-date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.batch-count {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.batch-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-status-indicator.processing {
|
||||
background: var(--blue-50);
|
||||
color: var(--blue-600);
|
||||
}
|
||||
|
||||
.batch-status-indicator.failed {
|
||||
background: var(--red-50);
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.batch-status-indicator.completed {
|
||||
background: var(--green-50);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
/* US-006: Retry All Failed button */
|
||||
.retry-all-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-left: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--red-300);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--red-700);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.retry-all-btn:hover {
|
||||
background: var(--red-100);
|
||||
border-color: var(--red-400);
|
||||
}
|
||||
|
||||
.retry-all-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.retry-loading {
|
||||
margin-left: var(--space-sm);
|
||||
}
|
||||
|
||||
/* US-021: Cancel All button */
|
||||
.cancel-all-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-left: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.cancel-all-btn:hover {
|
||||
background: var(--red-50);
|
||||
border-color: var(--red-300);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.cancel-all-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.batch-group-header {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.batch-info {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.batch-separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.batch-date,
|
||||
.batch-count {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.batch-status-indicator {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.batch-status-indicator span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.retry-all-btn .retry-btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.retry-all-btn {
|
||||
padding: var(--space-xs);
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
|
||||
/* US-021: Hide cancel button text on mobile, show only icon */
|
||||
.cancel-all-btn .cancel-btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cancel-all-btn {
|
||||
padding: var(--space-xs);
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
393
src/modules/data-entry/components/bulk/DragDropOverlay.vue
Normal file
393
src/modules/data-entry/components/bulk/DragDropOverlay.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="drag-drop-overlay"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="overlay-content">
|
||||
<i class="pi pi-cloud-upload overlay-icon"></i>
|
||||
<p class="overlay-text">
|
||||
Eliberează pentru a încărca {{ fileCount }} {{ fileCount === 1 ? 'fișier' : 'fișiere' }}
|
||||
</p>
|
||||
<p class="overlay-hint">PDF, PNG, JPG (max 10MB per fișier)</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import api from '@data-entry/services/api'
|
||||
|
||||
const emit = defineEmits(['upload-started', 'upload-complete', 'upload-error'])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// Drag state
|
||||
const dragCounter = ref(0)
|
||||
const fileCount = ref(0)
|
||||
|
||||
// Validation constants (matching backend)
|
||||
const MAX_FILE_SIZE_MB = 10
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
const ALLOWED_MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg']
|
||||
|
||||
// Computed visibility based on drag counter
|
||||
const isVisible = computed(() => dragCounter.value > 0)
|
||||
|
||||
/**
|
||||
* Check if a drag event contains files.
|
||||
* We check types array for 'Files' or 'application/x-moz-file' (Firefox).
|
||||
*/
|
||||
const hasFiles = (event) => {
|
||||
if (!event.dataTransfer) return false
|
||||
const types = event.dataTransfer.types
|
||||
return types && (types.includes('Files') || types.includes('application/x-moz-file'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragenter on window.
|
||||
* Only activate overlay if dragging files (not text selection, etc.)
|
||||
*/
|
||||
const onWindowDragEnter = (event) => {
|
||||
if (!hasFiles(event)) return
|
||||
|
||||
dragCounter.value++
|
||||
|
||||
// Try to get file count from items (not always available during drag)
|
||||
if (event.dataTransfer?.items) {
|
||||
fileCount.value = event.dataTransfer.items.length
|
||||
} else {
|
||||
fileCount.value = 0 // Will show "fișiere" (plural)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragover on window.
|
||||
* Prevent default to allow drop.
|
||||
*/
|
||||
const onWindowDragOver = (event) => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragleave on window.
|
||||
* Decrement counter; hide overlay when 0.
|
||||
*/
|
||||
const onWindowDragLeave = () => {
|
||||
dragCounter.value = Math.max(0, dragCounter.value - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragleave on overlay itself (user drags outside window).
|
||||
*/
|
||||
const onDragLeave = (event) => {
|
||||
// Only hide if leaving to outside the document
|
||||
if (!event.relatedTarget || event.relatedTarget.nodeName === 'HTML') {
|
||||
dragCounter.value = 0
|
||||
fileCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drop on overlay.
|
||||
*/
|
||||
const onDrop = async (event) => {
|
||||
// Reset drag state immediately
|
||||
dragCounter.value = 0
|
||||
fileCount.value = 0
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
await handleFiles(Array.from(files))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drop on window (catch drops that miss overlay).
|
||||
*/
|
||||
const onWindowDrop = async (event) => {
|
||||
// Only handle if overlay was visible
|
||||
if (dragCounter.value === 0) return
|
||||
|
||||
event.preventDefault()
|
||||
dragCounter.value = 0
|
||||
fileCount.value = 0
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
await handleFiles(Array.from(files))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone files to memory immediately to avoid SnapshotState invalidation on mobile.
|
||||
* On Chrome Android/iOS, accessing File properties after the event handler returns
|
||||
* can fail because the browser invalidates the File object reference.
|
||||
* See: https://issues.chromium.org/40703873
|
||||
*
|
||||
* CRITICAL: Clone FIRST, then access properties from the cloned file.
|
||||
*/
|
||||
const cloneFilesToMemory = async (files) => {
|
||||
const clonedFiles = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Read file content into memory IMMEDIATELY, before any property access
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const clonedFile = new File([arrayBuffer], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
})
|
||||
clonedFiles.push(clonedFile)
|
||||
} catch (e) {
|
||||
console.warn('[DragDropOverlay] File clone failed:', e)
|
||||
// On clone failure, try to use original (will fail on mobile but works on desktop)
|
||||
clonedFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return clonedFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and upload files via bulk upload API.
|
||||
*/
|
||||
const handleFiles = async (files) => {
|
||||
// CRITICAL FIX for US-037: Clone files to memory FIRST
|
||||
// This prevents page reload on mobile when accessing file properties
|
||||
const clonedFiles = await cloneFilesToMemory(files)
|
||||
|
||||
const validFiles = []
|
||||
const invalidFiles = []
|
||||
|
||||
// Validate each CLONED file (safe to access properties now)
|
||||
for (const file of clonedFiles) {
|
||||
// Check MIME type
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
invalidFiles.push({
|
||||
name: file.name,
|
||||
reason: `Tip invalid (${file.type || 'necunoscut'})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
invalidFiles.push({
|
||||
name: file.name,
|
||||
reason: `Prea mare (${formatFileSize(file.size)} > ${MAX_FILE_SIZE_MB}MB)`
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// File already cloned, just add to valid list
|
||||
validFiles.push(file)
|
||||
}
|
||||
|
||||
// Show warning toast for invalid files
|
||||
if (invalidFiles.length > 0) {
|
||||
const fileList = invalidFiles
|
||||
.slice(0, 3)
|
||||
.map(f => `• ${f.name}: ${f.reason}`)
|
||||
.join('\n')
|
||||
|
||||
const moreCount = invalidFiles.length - 3
|
||||
const detail = moreCount > 0
|
||||
? `${fileList}\n...și încă ${moreCount} fișiere`
|
||||
: fileList
|
||||
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: `${invalidFiles.length} fișier${invalidFiles.length > 1 ? 'e' : ''} ignorat${invalidFiles.length > 1 ? 'e' : ''}`,
|
||||
detail,
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
|
||||
// If no valid files, we're done
|
||||
if (validFiles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Upload via bulk API
|
||||
try {
|
||||
emit('upload-started', validFiles.length)
|
||||
|
||||
const formData = new FormData()
|
||||
validFiles.forEach(file => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
const response = await api.post('/bulk/upload', formData)
|
||||
|
||||
// Show success toast
|
||||
const data = response.data
|
||||
let message = `${data.processed_files || data.total_files} fișier${(data.processed_files || data.total_files) > 1 ? 'e' : ''} încărcat${(data.processed_files || data.total_files) > 1 ? 'e' : ''}`
|
||||
|
||||
// Add duplicate info if present
|
||||
if (data.duplicate_files && data.duplicate_files > 0) {
|
||||
message += `, ${data.duplicate_files} duplicate ignorate`
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Upload reușit',
|
||||
detail: message,
|
||||
life: 4000
|
||||
})
|
||||
|
||||
// US-017: Include filenames mapped to job_ids for instant table display
|
||||
// Create job info array by pairing job_ids with original filenames
|
||||
// Note: Backend returns job_ids in the same order as files were submitted
|
||||
const jobs = data.job_ids.map((jobId, index) => ({
|
||||
job_id: jobId,
|
||||
filename: validFiles[index]?.name || `File ${index + 1}`
|
||||
}))
|
||||
|
||||
emit('upload-complete', {
|
||||
batchId: data.batch_id != null ? String(data.batch_id) : null,
|
||||
jobIds: data.job_ids,
|
||||
jobs: jobs, // US-017: Add job info with filenames
|
||||
totalFiles: data.total_files,
|
||||
processedFiles: data.processed_files,
|
||||
duplicates: data.duplicates
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DragDropOverlay] Upload failed:', error)
|
||||
|
||||
let errorMessage = 'Eroare la încărcare'
|
||||
|
||||
if (error.response?.data?.detail) {
|
||||
const detail = error.response.data.detail
|
||||
if (typeof detail === 'string') {
|
||||
errorMessage = detail
|
||||
} else if (detail.message) {
|
||||
errorMessage = detail.message
|
||||
// Show duplicate info if all files were duplicates
|
||||
if (detail.duplicates && detail.duplicates.length > 0) {
|
||||
const dupList = detail.duplicates
|
||||
.slice(0, 3)
|
||||
.map(d => `• ${d.filename}: ${d.message}`)
|
||||
.join('\n')
|
||||
errorMessage += '\n' + dupList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Upload eșuat',
|
||||
detail: errorMessage,
|
||||
life: 6000
|
||||
})
|
||||
|
||||
emit('upload-error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display.
|
||||
*/
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
// Event listeners setup/cleanup
|
||||
onMounted(() => {
|
||||
window.addEventListener('dragenter', onWindowDragEnter)
|
||||
window.addEventListener('dragover', onWindowDragOver)
|
||||
window.addEventListener('dragleave', onWindowDragLeave)
|
||||
window.addEventListener('drop', onWindowDrop)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('dragenter', onWindowDragEnter)
|
||||
window.removeEventListener('dragover', onWindowDragOver)
|
||||
window.removeEventListener('dragleave', onWindowDragLeave)
|
||||
window.removeEventListener('drop', onWindowDrop)
|
||||
})
|
||||
|
||||
// Expose handleFiles so parent can call it programmatically (e.g., from a button click)
|
||||
defineExpose({
|
||||
handleFiles
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drag-drop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-xl);
|
||||
background: var(--surface-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 3px dashed var(--primary-500);
|
||||
box-shadow: var(--shadow-xl);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.overlay-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--primary-500);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-text {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overlay-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Fade transition */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
202
src/modules/data-entry/components/bulk/ProcessingStatusCell.vue
Normal file
202
src/modules/data-entry/components/bulk/ProcessingStatusCell.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="processing-status-cell">
|
||||
<!-- Manual receipts (no batch_id) show dash -->
|
||||
<template v-if="!batchId">
|
||||
<span class="processing-status-dash">-</span>
|
||||
</template>
|
||||
|
||||
<!-- Pending status -->
|
||||
<template v-else-if="status === 'pending'">
|
||||
<span class="processing-status processing-pending">
|
||||
În așteptare
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Processing status with spinner -->
|
||||
<template v-else-if="status === 'processing'">
|
||||
<span class="processing-status processing-active">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
Se procesează...
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Completed status -->
|
||||
<template v-else-if="status === 'completed'">
|
||||
<span class="processing-status processing-success">
|
||||
✓ Procesat
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Failed status with error message below -->
|
||||
<template v-else-if="status === 'failed'">
|
||||
<div class="processing-failed-container">
|
||||
<span
|
||||
class="processing-status processing-failed"
|
||||
v-tooltip.top="{ value: errorMessage, showDelay: 200 }"
|
||||
@click.stop="handleErrorClick"
|
||||
>
|
||||
✗ Eroare
|
||||
</span>
|
||||
<!-- Truncated error message visible in list -->
|
||||
<span
|
||||
v-if="processingError"
|
||||
class="processing-error-message"
|
||||
v-tooltip.bottom="{ value: processingError, showDelay: 200 }"
|
||||
>
|
||||
{{ truncatedError }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Unknown/null status for batch items -->
|
||||
<template v-else>
|
||||
<span class="processing-status-dash">-</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Processing status value from receipt
|
||||
* Values: 'pending' | 'processing' | 'completed' | 'failed' | null
|
||||
*/
|
||||
status: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
/**
|
||||
* Batch ID - null means manual receipt (shows dash)
|
||||
*/
|
||||
batchId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
/**
|
||||
* Error message to show in tooltip (for failed status)
|
||||
*/
|
||||
processingError: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['error-click'])
|
||||
|
||||
/**
|
||||
* Computed error message for tooltip
|
||||
* Falls back to generic message if no specific error provided
|
||||
*/
|
||||
const errorMessage = computed(() => {
|
||||
return props.processingError || 'Eroare la procesare'
|
||||
})
|
||||
|
||||
/**
|
||||
* Truncated error message for inline display
|
||||
* Shows first 50 characters with '...' if longer
|
||||
*/
|
||||
const truncatedError = computed(() => {
|
||||
if (!props.processingError) return ''
|
||||
const maxLength = 50
|
||||
if (props.processingError.length <= maxLength) {
|
||||
return props.processingError
|
||||
}
|
||||
return props.processingError.substring(0, maxLength) + '...'
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle click on error status
|
||||
* Emits event for parent to handle (e.g., show modal with full error)
|
||||
*/
|
||||
const handleErrorClick = () => {
|
||||
emit('error-click', props.processingError)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.processing-status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.processing-status-dash {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.processing-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
white-space: nowrap;
|
||||
/* US-019: Smooth transition for status changes */
|
||||
transition: opacity 300ms ease, background-color 300ms ease, color 300ms ease;
|
||||
}
|
||||
|
||||
/* US-019: Respect prefers-reduced-motion for accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.processing-status {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pending: gray/muted */
|
||||
.processing-pending {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Processing: blue with spinner */
|
||||
.processing-active {
|
||||
background: var(--blue-50);
|
||||
color: var(--blue-600);
|
||||
border: 1px solid var(--blue-500);
|
||||
}
|
||||
|
||||
.processing-active .pi-spinner {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Completed: green */
|
||||
.processing-success {
|
||||
background: var(--green-50);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
/* Failed: red, clickable */
|
||||
.processing-failed {
|
||||
background: var(--red-50);
|
||||
color: var(--red-600);
|
||||
border: 1px solid var(--red-500);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.processing-failed:hover {
|
||||
background: var(--red-100);
|
||||
}
|
||||
|
||||
/* Container for failed status + error message */
|
||||
.processing-failed-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Truncated error message displayed below status */
|
||||
.processing-error-message {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--red-600);
|
||||
max-width: 200px;
|
||||
line-height: var(--leading-tight);
|
||||
cursor: help;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
375
src/modules/data-entry/services/sseService.js
Normal file
375
src/modules/data-entry/services/sseService.js
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* SSE Client Service
|
||||
*
|
||||
* Service for connecting to Server-Sent Events endpoint and receiving
|
||||
* real-time receipt status updates. Uses native EventSource API with
|
||||
* automatic reconnection.
|
||||
*
|
||||
* US-031: Frontend - SSE Client Service
|
||||
* US-033: Graceful Degradation - Falls back to polling when SSE fails,
|
||||
* retries SSE periodically, and switches back when available.
|
||||
*
|
||||
* @example
|
||||
* import { sseService } from '@data-entry/services/sseService'
|
||||
*
|
||||
* // Subscribe to status changes
|
||||
* sseService.onStatusChange((data) => {
|
||||
* console.log('Status changed:', data.receipt_id, data.status)
|
||||
* })
|
||||
*
|
||||
* // Connect (optionally filter by batch)
|
||||
* sseService.connect()
|
||||
* sseService.connect('batch-uuid-here')
|
||||
*
|
||||
* // Disconnect when done
|
||||
* sseService.disconnect()
|
||||
*/
|
||||
|
||||
/** @type {EventSource|null} */
|
||||
let eventSource = null
|
||||
|
||||
/** @type {((data: StatusChangeData) => void)|null} */
|
||||
let statusChangeCallback = null
|
||||
|
||||
/** @type {boolean} */
|
||||
let isConnected = false
|
||||
|
||||
// US-033: Graceful Degradation State
|
||||
/** @type {boolean} - True when SSE is active and working */
|
||||
let isSSEActive = false
|
||||
|
||||
/** @type {boolean} - True when polling fallback is active */
|
||||
let isPollingActive = false
|
||||
|
||||
/** @type {number|null} - Polling interval ID */
|
||||
let pollingIntervalId = null
|
||||
|
||||
/** @type {number|null} - SSE retry timeout ID */
|
||||
let sseRetryTimeoutId = null
|
||||
|
||||
/** @type {(() => Promise<void>)|null} - Polling callback for fetching data */
|
||||
let pollingCallback = null
|
||||
|
||||
/** @type {string|null} - Current batch ID for reconnection */
|
||||
let currentBatchId = null
|
||||
|
||||
/** @type {number} - Number of consecutive SSE failures */
|
||||
let consecutiveFailures = 0
|
||||
|
||||
/** @type {number} - Max failures before giving up SSE retries */
|
||||
const MAX_CONSECUTIVE_FAILURES = 5
|
||||
|
||||
// US-033: Timing constants
|
||||
const POLLING_INTERVAL_MS = 5000 // 5 seconds between polls
|
||||
const SSE_RETRY_INTERVAL_MS = 30000 // 30 seconds between SSE retry attempts
|
||||
|
||||
/**
|
||||
* @typedef {Object} StatusChangeData
|
||||
* @property {number} receipt_id - Receipt ID that changed
|
||||
* @property {string} status - Workflow status (DRAFT, PENDING_REVIEW, etc.)
|
||||
* @property {string|null} processing_status - Processing status (pending, processing, completed, failed)
|
||||
* @property {string|null} batch_id - Batch ID this receipt belongs to
|
||||
* @property {string} timestamp - ISO timestamp of the event
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build SSE endpoint URL with optional batch_id filter.
|
||||
*
|
||||
* @param {string|null} batchId - Optional batch ID to filter events
|
||||
* @returns {string} Full SSE endpoint URL
|
||||
*/
|
||||
function buildUrl(batchId) {
|
||||
const baseUrl = import.meta.env.BASE_URL + 'api/data-entry/receipts/sse/status'
|
||||
if (batchId) {
|
||||
return `${baseUrl}?batch_id=${encodeURIComponent(batchId)}`
|
||||
}
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to SSE endpoint for real-time status updates.
|
||||
*
|
||||
* If already connected, disconnects first before reconnecting.
|
||||
* EventSource handles automatic reconnection on network errors
|
||||
* (default retry is 3 seconds, as set by backend).
|
||||
*
|
||||
* US-033: Enhanced with graceful degradation - falls back to polling on failure.
|
||||
*
|
||||
* @param {string|null} [batchId=null] - Optional batch ID to filter events for
|
||||
*/
|
||||
function connect(batchId = null) {
|
||||
// Disconnect existing connection if any
|
||||
if (eventSource) {
|
||||
closeEventSource()
|
||||
}
|
||||
|
||||
// US-033: Store batch ID for reconnection attempts
|
||||
currentBatchId = batchId
|
||||
|
||||
const url = buildUrl(batchId)
|
||||
console.log(`[SSE] Connecting to ${url}`)
|
||||
|
||||
try {
|
||||
eventSource = new EventSource(url)
|
||||
isConnected = true
|
||||
|
||||
// Handle incoming messages
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
/** @type {StatusChangeData} */
|
||||
const data = JSON.parse(event.data)
|
||||
console.log('[SSE] Received status change:', data)
|
||||
|
||||
if (statusChangeCallback) {
|
||||
statusChangeCallback(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SSE] Failed to parse event data:', err, event.data)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle connection open
|
||||
eventSource.onopen = () => {
|
||||
console.log('[SSE] Connection opened')
|
||||
isConnected = true
|
||||
isSSEActive = true
|
||||
|
||||
// US-033: SSE is working, reset failure count
|
||||
consecutiveFailures = 0
|
||||
|
||||
// US-033: Stop polling if it was running - SSE is now active
|
||||
if (isPollingActive) {
|
||||
console.log('[SSE] SSE connection restored, stopping polling fallback')
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
// US-033: Clear any pending retry timeout
|
||||
if (sseRetryTimeoutId) {
|
||||
clearTimeout(sseRetryTimeoutId)
|
||||
sseRetryTimeoutId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
eventSource.onerror = (error) => {
|
||||
// US-033: Check if connection is permanently closed
|
||||
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||
console.log('[SSE] Connection closed')
|
||||
isConnected = false
|
||||
isSSEActive = false
|
||||
|
||||
// US-033: Increment failure count and activate fallback
|
||||
consecutiveFailures++
|
||||
console.log('SSE connection failed, falling back to polling')
|
||||
activateFallbackToPolling()
|
||||
} else if (eventSource?.readyState === EventSource.CONNECTING) {
|
||||
// EventSource is auto-reconnecting, this is normal behavior
|
||||
console.log('[SSE] Connection lost, reconnecting...')
|
||||
} else {
|
||||
console.error('[SSE] Connection error:', error)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// US-033: Handle any errors during EventSource creation
|
||||
console.error('[SSE] Failed to create EventSource:', err)
|
||||
console.log('SSE connection failed, falling back to polling')
|
||||
isConnected = false
|
||||
isSSEActive = false
|
||||
consecutiveFailures++
|
||||
activateFallbackToPolling()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the EventSource without full cleanup.
|
||||
* Used internally when reconnecting or switching states.
|
||||
*/
|
||||
function closeEventSource() {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
isConnected = false
|
||||
isSSEActive = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Activate fallback to polling when SSE fails.
|
||||
* Also schedules periodic SSE retry attempts.
|
||||
*/
|
||||
function activateFallbackToPolling() {
|
||||
// Start polling if we have a callback and aren't already polling
|
||||
if (pollingCallback && !isPollingActive) {
|
||||
startPolling()
|
||||
}
|
||||
|
||||
// US-033: Schedule SSE retry if we haven't exceeded max failures
|
||||
if (consecutiveFailures < MAX_CONSECUTIVE_FAILURES) {
|
||||
scheduleSSERetry()
|
||||
} else {
|
||||
console.log(`[SSE] Max consecutive failures (${MAX_CONSECUTIVE_FAILURES}) reached, stopping SSE retries`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Start polling as fallback mechanism.
|
||||
* Calls the polling callback at regular intervals.
|
||||
*/
|
||||
function startPolling() {
|
||||
if (isPollingActive || !pollingCallback) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[SSE] Starting polling fallback (interval: ${POLLING_INTERVAL_MS}ms)`)
|
||||
isPollingActive = true
|
||||
|
||||
// Execute immediately, then at intervals
|
||||
pollingCallback()
|
||||
|
||||
pollingIntervalId = setInterval(() => {
|
||||
if (pollingCallback) {
|
||||
pollingCallback()
|
||||
}
|
||||
}, POLLING_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Stop the polling fallback.
|
||||
*/
|
||||
function stopPolling() {
|
||||
if (pollingIntervalId) {
|
||||
clearInterval(pollingIntervalId)
|
||||
pollingIntervalId = null
|
||||
}
|
||||
isPollingActive = false
|
||||
console.log('[SSE] Polling fallback stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Schedule a retry attempt to reconnect to SSE.
|
||||
* If SSE connects successfully, polling will be stopped automatically.
|
||||
*/
|
||||
function scheduleSSERetry() {
|
||||
// Don't schedule if already scheduled
|
||||
if (sseRetryTimeoutId) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[SSE] Scheduling SSE retry in ${SSE_RETRY_INTERVAL_MS / 1000}s`)
|
||||
|
||||
sseRetryTimeoutId = setTimeout(() => {
|
||||
sseRetryTimeoutId = null
|
||||
|
||||
// Only retry if we're still in fallback mode (polling active)
|
||||
if (isPollingActive && !isSSEActive) {
|
||||
console.log('[SSE] Retrying SSE connection...')
|
||||
connect(currentBatchId)
|
||||
}
|
||||
}, SSE_RETRY_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from SSE endpoint.
|
||||
*
|
||||
* Closes the EventSource connection and cleans up all resources
|
||||
* including polling fallback and retry timers.
|
||||
*
|
||||
* US-033: Enhanced to clean up all graceful degradation state.
|
||||
*
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
function disconnect() {
|
||||
console.log('[SSE] Disconnecting and cleaning up')
|
||||
|
||||
// Close EventSource
|
||||
closeEventSource()
|
||||
|
||||
// US-033: Stop polling if active
|
||||
stopPolling()
|
||||
|
||||
// US-033: Clear retry timeout
|
||||
if (sseRetryTimeoutId) {
|
||||
clearTimeout(sseRetryTimeoutId)
|
||||
sseRetryTimeoutId = null
|
||||
}
|
||||
|
||||
// US-033: Reset state
|
||||
currentBatchId = null
|
||||
consecutiveFailures = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Set the polling callback for fallback mechanism.
|
||||
*
|
||||
* This callback will be called at regular intervals when SSE fails
|
||||
* and polling fallback is activated. Should fetch/refresh data.
|
||||
*
|
||||
* @param {(() => Promise<void>)|null} callback - Async function to call for polling
|
||||
*/
|
||||
function setPollingCallback(callback) {
|
||||
pollingCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for status change events.
|
||||
*
|
||||
* Only one callback can be registered at a time.
|
||||
* Call with null to unregister.
|
||||
*
|
||||
* @param {((data: StatusChangeData) => void)|null} callback - Callback function or null
|
||||
*/
|
||||
function onStatusChange(callback) {
|
||||
statusChangeCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently connected to SSE endpoint.
|
||||
*
|
||||
* @returns {boolean} True if connected
|
||||
*/
|
||||
function getIsConnected() {
|
||||
return isConnected && eventSource !== null && eventSource.readyState !== EventSource.CLOSED
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Check if SSE is actively working (not in fallback mode).
|
||||
*
|
||||
* @returns {boolean} True if SSE is active and receiving events
|
||||
*/
|
||||
function getIsSSEActive() {
|
||||
return isSSEActive
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Check if polling fallback is currently active.
|
||||
*
|
||||
* @returns {boolean} True if polling fallback is running
|
||||
*/
|
||||
function getIsPollingActive() {
|
||||
return isPollingActive
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE Service singleton
|
||||
*
|
||||
* US-033: Enhanced with graceful degradation capabilities.
|
||||
*/
|
||||
export const sseService = {
|
||||
connect,
|
||||
disconnect,
|
||||
onStatusChange,
|
||||
setPollingCallback,
|
||||
get isConnected() {
|
||||
return getIsConnected()
|
||||
},
|
||||
// US-033: Expose state for debugging/monitoring
|
||||
get isSSEActive() {
|
||||
return getIsSSEActive()
|
||||
},
|
||||
get isPollingActive() {
|
||||
return getIsPollingActive()
|
||||
},
|
||||
}
|
||||
|
||||
// Also export individual functions for flexibility
|
||||
export { connect, disconnect, onStatusChange, setPollingCallback }
|
||||
593
src/modules/data-entry/stores/batchProgressStore.js
Normal file
593
src/modules/data-entry/stores/batchProgressStore.js
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Batch Progress Store
|
||||
*
|
||||
* Pinia store for tracking bulk upload batch progress via long-polling.
|
||||
* Uses AbortController for clean cancellation on component unmount.
|
||||
*
|
||||
* US-009: Added localStorage persistence for active batch IDs to support
|
||||
* auto-resume polling after page refresh or navigation away.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '@data-entry/services/api'
|
||||
|
||||
/**
|
||||
* @typedef {Object} JobStatus
|
||||
* @property {string} job_id - OCR job UUID
|
||||
* @property {string} filename - Original filename
|
||||
* @property {string} status - Job status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
* @property {number|null} receipt_id - Created receipt ID (if completed)
|
||||
* @property {string|null} error_message - Error message (if failed)
|
||||
*/
|
||||
|
||||
// US-009: localStorage key for persisting active batch IDs
|
||||
const ACTIVE_BATCHES_STORAGE_KEY = 'roa2web_active_batch_ids'
|
||||
|
||||
/**
|
||||
* Get active batch IDs from localStorage
|
||||
* @returns {string[]} Array of batch ID strings
|
||||
*/
|
||||
function getStoredActiveBatches() {
|
||||
try {
|
||||
const stored = localStorage.getItem(ACTIVE_BATCHES_STORAGE_KEY)
|
||||
if (!stored) return []
|
||||
const parsed = JSON.parse(stored)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch (e) {
|
||||
console.warn('[BatchProgress] Failed to read localStorage:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save active batch IDs to localStorage
|
||||
* @param {string[]} batchIds - Array of batch ID strings
|
||||
*/
|
||||
function saveActiveBatches(batchIds) {
|
||||
try {
|
||||
if (batchIds.length === 0) {
|
||||
localStorage.removeItem(ACTIVE_BATCHES_STORAGE_KEY)
|
||||
} else {
|
||||
localStorage.setItem(ACTIVE_BATCHES_STORAGE_KEY, JSON.stringify(batchIds))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[BatchProgress] Failed to save to localStorage:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a batch ID to localStorage
|
||||
* @param {string} batchId - Batch ID to add
|
||||
*/
|
||||
function addActiveBatch(batchId) {
|
||||
const batches = getStoredActiveBatches()
|
||||
if (!batches.includes(batchId)) {
|
||||
batches.push(batchId)
|
||||
saveActiveBatches(batches)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a batch ID from localStorage
|
||||
* @param {string} batchId - Batch ID to remove
|
||||
*/
|
||||
function removeActiveBatch(batchId) {
|
||||
const batches = getStoredActiveBatches()
|
||||
const filtered = batches.filter(id => id !== batchId)
|
||||
saveActiveBatches(filtered)
|
||||
}
|
||||
|
||||
export const useBatchProgressStore = defineStore('batchProgress', () => {
|
||||
// ============ State ============
|
||||
|
||||
/** @type {import('vue').Ref<number|null>} Current batch ID being tracked */
|
||||
const batchId = ref(null)
|
||||
|
||||
/** @type {import('vue').Ref<Map<string, JobStatus>>} Map of job_id -> JobStatus */
|
||||
const jobs = ref(new Map())
|
||||
|
||||
/** @type {import('vue').Ref<boolean>} Whether polling is active */
|
||||
const isPolling = ref(false)
|
||||
|
||||
/** @type {import('vue').Ref<string|null>} Last error message */
|
||||
const error = ref(null)
|
||||
|
||||
/** @type {AbortController|null} Controller for canceling ongoing requests */
|
||||
let abortController = null
|
||||
|
||||
/**
|
||||
* US-018: Callback function for when jobs complete or fail.
|
||||
* Set this to receive notifications when job status transitions happen.
|
||||
* @type {((completedReceiptIds: number[], failedJobIds: string[]) => void)|null}
|
||||
*/
|
||||
let onJobsTransitionCallback = null
|
||||
|
||||
// ============ Computed ============
|
||||
|
||||
/** Number of jobs in 'pending' status */
|
||||
const pendingCount = computed(() => {
|
||||
let count = 0
|
||||
for (const job of jobs.value.values()) {
|
||||
if (job.status === 'pending') count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Number of jobs in 'processing' status */
|
||||
const processingCount = computed(() => {
|
||||
let count = 0
|
||||
for (const job of jobs.value.values()) {
|
||||
if (job.status === 'processing') count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Number of jobs in 'completed' status */
|
||||
const completedCount = computed(() => {
|
||||
let count = 0
|
||||
for (const job of jobs.value.values()) {
|
||||
if (job.status === 'completed') count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Number of jobs in 'failed' status */
|
||||
const failedCount = computed(() => {
|
||||
let count = 0
|
||||
for (const job of jobs.value.values()) {
|
||||
if (job.status === 'failed') count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Total number of jobs */
|
||||
const totalCount = computed(() => jobs.value.size)
|
||||
|
||||
/** Progress percentage (0-100) */
|
||||
const progress = computed(() => {
|
||||
if (totalCount.value === 0) return 0
|
||||
const finished = completedCount.value + failedCount.value
|
||||
return Math.round((finished / totalCount.value) * 100)
|
||||
})
|
||||
|
||||
/** Whether all jobs are finished (completed or failed) */
|
||||
const isComplete = computed(() => {
|
||||
if (totalCount.value === 0) return false
|
||||
return pendingCount.value === 0 && processingCount.value === 0
|
||||
})
|
||||
|
||||
/** Total amount from all completed receipts (from backend response) */
|
||||
const totalAmount = ref(0)
|
||||
|
||||
/** Jobs as array for iteration */
|
||||
const jobsArray = computed(() => Array.from(jobs.value.values()))
|
||||
|
||||
// ============ Actions ============
|
||||
|
||||
/**
|
||||
* Start long-polling for batch status updates.
|
||||
* Uses 30-second wait parameter for efficient long-polling.
|
||||
*
|
||||
* @param {string} id - Batch ID to track
|
||||
* @param {Array<{job_id: string, filename: string}>} [initialJobs] - Optional initial jobs to populate immediately
|
||||
*/
|
||||
async function startPolling(id, initialJobs = null) {
|
||||
// Stop any existing polling
|
||||
stopPolling()
|
||||
|
||||
// Reset state
|
||||
batchId.value = id
|
||||
jobs.value = new Map()
|
||||
error.value = null
|
||||
isPolling.value = true
|
||||
totalAmount.value = 0
|
||||
|
||||
// US-017: Pre-populate jobs immediately if initial jobs provided
|
||||
// This ensures jobs appear instantly in the table before polling fetches real status
|
||||
if (initialJobs && Array.isArray(initialJobs)) {
|
||||
for (const job of initialJobs) {
|
||||
jobs.value.set(job.job_id, {
|
||||
job_id: job.job_id,
|
||||
filename: job.filename,
|
||||
status: 'pending', // Initial status is always pending
|
||||
receipt_id: null,
|
||||
error_message: null,
|
||||
confidence: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// US-009: Persist batch ID to localStorage for resume after refresh
|
||||
addActiveBatch(id)
|
||||
|
||||
// Create new abort controller for this polling session
|
||||
abortController = new AbortController()
|
||||
|
||||
// Start polling loop
|
||||
await pollLoop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling and cancel any pending requests.
|
||||
*/
|
||||
function stopPolling() {
|
||||
isPolling.value = false
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal polling loop - fetches status until complete or stopped.
|
||||
*/
|
||||
async function pollLoop() {
|
||||
while (isPolling.value && batchId.value !== null) {
|
||||
try {
|
||||
// Fetch batch status with 30-second wait (long-polling)
|
||||
const response = await api.get(`/bulk/batches/${batchId.value}/status`, {
|
||||
params: { wait: 30 },
|
||||
signal: abortController?.signal,
|
||||
// Extend timeout for long-polling
|
||||
timeout: 35000
|
||||
})
|
||||
|
||||
const data = response.data
|
||||
|
||||
// US-018: Track status transitions for callback notification
|
||||
const previousJobs = jobs.value
|
||||
const newlyCompletedReceiptIds = []
|
||||
const newlyFailedJobIds = []
|
||||
|
||||
// Update jobs map
|
||||
const newJobs = new Map()
|
||||
for (const job of data.jobs) {
|
||||
const previousJob = previousJobs.get(job.job_id)
|
||||
const previousStatus = previousJob?.status
|
||||
|
||||
// Check for transitions to completed/failed
|
||||
if (previousStatus && previousStatus !== job.status) {
|
||||
if (job.status === 'completed' && job.receipt_id) {
|
||||
newlyCompletedReceiptIds.push(job.receipt_id)
|
||||
console.log(`[BatchProgress] Job ${job.job_id} completed -> receipt ${job.receipt_id}`)
|
||||
} else if (job.status === 'failed') {
|
||||
newlyFailedJobIds.push(job.job_id)
|
||||
console.log(`[BatchProgress] Job ${job.job_id} failed: ${job.error_message}`)
|
||||
}
|
||||
}
|
||||
|
||||
newJobs.set(job.job_id, {
|
||||
job_id: job.job_id,
|
||||
filename: job.filename,
|
||||
status: job.status,
|
||||
receipt_id: job.receipt_id || null,
|
||||
error_message: job.error_message || null,
|
||||
confidence: job.confidence || null
|
||||
})
|
||||
}
|
||||
jobs.value = newJobs
|
||||
|
||||
// US-018: Notify listener of status transitions
|
||||
if (onJobsTransitionCallback && (newlyCompletedReceiptIds.length > 0 || newlyFailedJobIds.length > 0)) {
|
||||
onJobsTransitionCallback(newlyCompletedReceiptIds, newlyFailedJobIds)
|
||||
}
|
||||
|
||||
// Update total amount from backend
|
||||
totalAmount.value = data.total_amount || 0
|
||||
|
||||
// Clear any previous error
|
||||
error.value = null
|
||||
|
||||
// Check if all jobs are finished
|
||||
const finished = data.completed_count + data.failed_count
|
||||
if (finished >= data.total_files) {
|
||||
console.log('[BatchProgress] All jobs finished, stopping polling')
|
||||
isPolling.value = false
|
||||
|
||||
// US-009: Remove completed batch from localStorage
|
||||
if (batchId.value) {
|
||||
removeActiveBatch(batchId.value)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// Ignore abort errors (expected when stopPolling is called)
|
||||
if (err.name === 'AbortError' || err.code === 'ERR_CANCELED') {
|
||||
console.log('[BatchProgress] Polling aborted')
|
||||
break
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
console.error('[BatchProgress] Polling error:', err)
|
||||
error.value = err.response?.data?.detail || err.message || 'Failed to fetch batch status'
|
||||
|
||||
// Wait before retrying on error
|
||||
await sleep(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset store to initial state.
|
||||
*/
|
||||
function reset() {
|
||||
stopPolling()
|
||||
batchId.value = null
|
||||
jobs.value = new Map()
|
||||
error.value = null
|
||||
totalAmount.value = 0
|
||||
}
|
||||
|
||||
// ============ US-009: Auto-Resume Functions ============
|
||||
|
||||
/**
|
||||
* Get list of active batch IDs stored in localStorage.
|
||||
* Used by views to check if there are batches to resume after refresh.
|
||||
*
|
||||
* @returns {string[]} Array of stored batch IDs
|
||||
*/
|
||||
function getStoredBatchIds() {
|
||||
return getStoredActiveBatches()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a specific batch ID from localStorage.
|
||||
* Called when a batch is determined to be complete (all items processed).
|
||||
*
|
||||
* @param {string} batchIdToRemove - Batch ID to remove from storage
|
||||
*/
|
||||
function clearStoredBatch(batchIdToRemove) {
|
||||
removeActiveBatch(batchIdToRemove)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored batch IDs from localStorage.
|
||||
* Used during cleanup or when all batches are confirmed complete.
|
||||
*/
|
||||
function clearAllStoredBatches() {
|
||||
saveActiveBatches([])
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Sleep for given milliseconds.
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// ============ US-023: Restore Jobs from Batch ============
|
||||
|
||||
/**
|
||||
* Restore jobs from a stored batch by fetching current status from API.
|
||||
* Only pending/processing jobs are added to the store (completed/failed are already receipts).
|
||||
*
|
||||
* US-023: Called on page refresh/return to restore visibility of active jobs.
|
||||
*
|
||||
* @param {string} storedBatchId - The batch ID to restore from
|
||||
* @returns {Promise<{hasActiveJobs: boolean, jobCount: number}>} Result of restoration
|
||||
*/
|
||||
async function restoreJobsFromBatch(storedBatchId) {
|
||||
try {
|
||||
console.log(`[BatchProgress] Restoring jobs from batch ${storedBatchId}`)
|
||||
|
||||
// Fetch current batch status from API (no wait, just get current state)
|
||||
const response = await api.get(`/bulk/batches/${storedBatchId}/status`, {
|
||||
params: { wait: 0 },
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const data = response.data
|
||||
|
||||
if (!data.jobs || data.jobs.length === 0) {
|
||||
console.log(`[BatchProgress] Batch ${storedBatchId} has no jobs, removing from storage`)
|
||||
removeActiveBatch(storedBatchId)
|
||||
return { hasActiveJobs: false, jobCount: 0 }
|
||||
}
|
||||
|
||||
// Count and filter active jobs (pending/processing only)
|
||||
const activeJobs = data.jobs.filter(
|
||||
job => job.status === 'pending' || job.status === 'processing'
|
||||
)
|
||||
|
||||
if (activeJobs.length === 0) {
|
||||
// All jobs are completed or failed - no need to restore to UI
|
||||
console.log(`[BatchProgress] Batch ${storedBatchId} has no active jobs (all completed/failed), removing from storage`)
|
||||
removeActiveBatch(storedBatchId)
|
||||
return { hasActiveJobs: false, jobCount: 0 }
|
||||
}
|
||||
|
||||
// Set batch ID and add active jobs to store
|
||||
batchId.value = storedBatchId
|
||||
|
||||
// Add jobs to the Map (merge with existing if any)
|
||||
for (const job of activeJobs) {
|
||||
jobs.value.set(job.job_id, {
|
||||
job_id: job.job_id,
|
||||
filename: job.filename,
|
||||
status: job.status,
|
||||
receipt_id: job.receipt_id || null,
|
||||
error_message: job.error_message || null,
|
||||
confidence: job.confidence || null
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[BatchProgress] Restored ${activeJobs.length} active jobs from batch ${storedBatchId}`)
|
||||
|
||||
// Start polling for updates
|
||||
if (!isPolling.value) {
|
||||
isPolling.value = true
|
||||
abortController = new AbortController()
|
||||
// Start polling loop in background
|
||||
pollLoop()
|
||||
}
|
||||
|
||||
return { hasActiveJobs: true, jobCount: activeJobs.length }
|
||||
} catch (err) {
|
||||
console.error(`[BatchProgress] Error restoring batch ${storedBatchId}:`, err)
|
||||
|
||||
// If batch not found (404), remove it from storage
|
||||
if (err.response?.status === 404) {
|
||||
console.log(`[BatchProgress] Batch ${storedBatchId} not found, removing from storage`)
|
||||
removeActiveBatch(storedBatchId)
|
||||
}
|
||||
|
||||
return { hasActiveJobs: false, jobCount: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ============ US-016: Cancel Actions ============
|
||||
|
||||
/**
|
||||
* Cancel a specific job by job ID.
|
||||
* After successful cancellation, the job is removed from the jobs Map.
|
||||
*
|
||||
* @param {string} jobId - The job ID to cancel
|
||||
* @returns {Promise<{success: boolean, message: string}>} Result of the operation
|
||||
*/
|
||||
async function cancelJob(jobId) {
|
||||
try {
|
||||
const response = await api.post(`/bulk/cancel/${jobId}`)
|
||||
const data = response.data
|
||||
|
||||
if (data.success) {
|
||||
// Remove the cancelled job from the Map
|
||||
jobs.value.delete(jobId)
|
||||
|
||||
console.log(`[BatchProgress] Job ${jobId} cancelled successfully`)
|
||||
return {
|
||||
success: true,
|
||||
message: data.message || 'Job anulat cu succes'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: data.message || 'Eroare la anularea job-ului'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BatchProgress] Error cancelling job:', err)
|
||||
|
||||
// Extract error message from response or use generic message
|
||||
const errorMessage = err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
'Eroare la anularea job-ului'
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending/processing jobs in a batch.
|
||||
* After successful cancellation, cancelled jobs are removed from the jobs Map.
|
||||
* Completed/failed jobs remain in the Map for visibility.
|
||||
*
|
||||
* @param {number|string} batchIdToCancel - The batch ID to cancel
|
||||
* @returns {Promise<{success: boolean, message: string, cancelledCount: number, skippedCount: number}>}
|
||||
*/
|
||||
async function cancelBatch(batchIdToCancel) {
|
||||
try {
|
||||
const response = await api.post(`/bulk/cancel-batch/${batchIdToCancel}`)
|
||||
const data = response.data
|
||||
|
||||
if (data.success) {
|
||||
// Remove all cancelled jobs from the Map
|
||||
// Jobs with 'pending' or 'processing' status were cancelled
|
||||
const jobsToRemove = []
|
||||
for (const [jobId, job] of jobs.value.entries()) {
|
||||
if (job.status === 'pending' || job.status === 'processing') {
|
||||
jobsToRemove.push(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the jobs
|
||||
for (const jobId of jobsToRemove) {
|
||||
jobs.value.delete(jobId)
|
||||
}
|
||||
|
||||
console.log(`[BatchProgress] Batch ${batchIdToCancel} cancelled: ${data.cancelled_count} cancelled, ${data.skipped_count} skipped`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: data.message || `${data.cancelled_count} job-uri anulate`,
|
||||
cancelledCount: data.cancelled_count,
|
||||
skippedCount: data.skipped_count
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: data.message || 'Eroare la anularea batch-ului',
|
||||
cancelledCount: 0,
|
||||
skippedCount: 0
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BatchProgress] Error cancelling batch:', err)
|
||||
|
||||
// Extract error message from response or use generic message
|
||||
const errorMessage = err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
'Eroare la anularea batch-ului'
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
cancelledCount: 0,
|
||||
skippedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Return Public API ============
|
||||
|
||||
return {
|
||||
// State
|
||||
batchId,
|
||||
jobs,
|
||||
isPolling,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
pendingCount,
|
||||
processingCount,
|
||||
completedCount,
|
||||
failedCount,
|
||||
totalCount,
|
||||
progress,
|
||||
isComplete,
|
||||
totalAmount,
|
||||
jobsArray,
|
||||
|
||||
// Actions
|
||||
startPolling,
|
||||
stopPolling,
|
||||
reset,
|
||||
|
||||
// US-009: Auto-Resume Functions
|
||||
getStoredBatchIds,
|
||||
clearStoredBatch,
|
||||
clearAllStoredBatches,
|
||||
|
||||
// US-023: Restore Jobs from Batch
|
||||
restoreJobsFromBatch,
|
||||
|
||||
// US-016: Cancel Actions
|
||||
cancelJob,
|
||||
cancelBatch,
|
||||
|
||||
// US-018: Transition Callback
|
||||
setOnJobsTransitionCallback: (callback) => {
|
||||
onJobsTransitionCallback = callback
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -15,6 +15,13 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
currentReceipt: null,
|
||||
pendingReceipts: [],
|
||||
stats: null,
|
||||
// Processing stats for bulk upload filtering (US-005)
|
||||
processingStats: {
|
||||
pending_count: 0,
|
||||
processing_count: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
pagination: {
|
||||
@@ -29,6 +36,7 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
direction: null,
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
processingStatus: null, // US-005: Filter by processing_status
|
||||
},
|
||||
// Nomenclatures
|
||||
partners: [],
|
||||
@@ -70,11 +78,25 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
if (this.filters.dateTo) {
|
||||
params.date_to = this.filters.dateTo
|
||||
}
|
||||
// US-005: Add processing_status filter
|
||||
// Map frontend filter values to backend values
|
||||
if (this.filters.processingStatus) {
|
||||
if (this.filters.processingStatus === 'in_processing') {
|
||||
// "În procesare" = pending + processing
|
||||
params.processing_status = 'pending,processing'
|
||||
} else {
|
||||
params.processing_status = this.filters.processingStatus
|
||||
}
|
||||
}
|
||||
|
||||
const response = await api.get('/', { params })
|
||||
this.receipts = response.data.items
|
||||
this.pagination.total = response.data.total
|
||||
this.pagination.pages = response.data.pages
|
||||
// US-005: Capture processing_stats from API response
|
||||
if (response.data.processing_stats) {
|
||||
this.processingStats = response.data.processing_stats
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || 'Failed to fetch receipts'
|
||||
throw error
|
||||
@@ -436,6 +458,123 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Retry Actions (US-006) ============
|
||||
|
||||
/**
|
||||
* Retry processing for a single failed receipt.
|
||||
* Calls POST /bulk/retry/{receipt_id} to requeue the OCR job.
|
||||
*
|
||||
* @param {number} receiptId - Receipt ID to retry
|
||||
* @returns {Promise<{success: boolean, job_id: string, message: string}>}
|
||||
*/
|
||||
async retryReceipt(receiptId) {
|
||||
try {
|
||||
// Use apiClient directly - bulk endpoints are at /api/data-entry/bulk, not /api/data-entry/receipts
|
||||
const response = await apiClient.post(`/bulk/retry/${receiptId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.detail || 'Eroare la reîncărcare'
|
||||
throw new Error(detail)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retry all failed receipts in a batch.
|
||||
* Calls POST /bulk/retry-batch/{batch_id} to requeue all failed OCR jobs.
|
||||
*
|
||||
* @param {string} batchId - Batch ID (UUID string)
|
||||
* @returns {Promise<{success: boolean, retried_count: number, failed_count: number, message: string}>}
|
||||
*/
|
||||
async retryBatchFailed(batchId) {
|
||||
try {
|
||||
// Use apiClient directly - bulk endpoints are at /api/data-entry/bulk, not /api/data-entry/receipts
|
||||
const response = await apiClient.post(`/bulk/retry-batch/${batchId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.detail || 'Eroare la reîncărcarea batch-ului'
|
||||
throw new Error(detail)
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Bulk Delete (US-027) ============
|
||||
|
||||
/**
|
||||
* Bulk delete receipts by IDs.
|
||||
* Calls DELETE /receipts/bulk and returns partial success response.
|
||||
*
|
||||
* @param {number[]} ids - Array of receipt IDs to delete
|
||||
* @returns {Promise<{deleted: number[], failed: Array<{id: number, error: string}>}>}
|
||||
*/
|
||||
async bulkDeleteReceipts(ids) {
|
||||
try {
|
||||
const response = await api.delete('/bulk', { data: { ids } })
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.detail || 'Eroare la ștergerea bonurilor'
|
||||
throw new Error(detail)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove receipts from local array by IDs (US-027).
|
||||
* Updates array in place without re-fetching from server.
|
||||
*
|
||||
* @param {number[]} ids - Array of receipt IDs to remove
|
||||
*/
|
||||
removeReceiptsLocally(ids) {
|
||||
const idsSet = new Set(ids)
|
||||
this.receipts = this.receipts.filter(r => !idsSet.has(r.id))
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a single receipt in place without replacing the array (US-029).
|
||||
* Uses Object.assign() to preserve Vue reactivity and only re-render
|
||||
* the affected row, not the entire list.
|
||||
*
|
||||
* @param {number} receiptId - Receipt ID to update
|
||||
* @param {Object} updates - Object with properties to update
|
||||
* @returns {boolean} true if receipt was found and updated, false otherwise
|
||||
*/
|
||||
updateReceiptInPlace(receiptId, updates) {
|
||||
const idx = this.receipts.findIndex(r => r.id === receiptId)
|
||||
if (idx !== -1) {
|
||||
Object.assign(this.receipts[idx], updates)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* Insert a receipt in place at the correct position (US-034, US-043).
|
||||
*
|
||||
* US-043: Always prepend to maintain upload order. When jobs complete,
|
||||
* they transition from jobItems (prepended in unifiedItems) to receiptsList.
|
||||
* Prepending to receiptsList ensures the receipt appears where the job was -
|
||||
* right after the remaining pending jobs.
|
||||
*
|
||||
* This maintains visual position stability:
|
||||
* - Jobs are shown first (from batchProgressStore.jobs)
|
||||
* - As jobs complete, their receipts prepend to this.receipts
|
||||
* - Combined: [remaining_jobs] + [newly_completed_receipts] + [older_receipts]
|
||||
*
|
||||
* @param {Object} receipt - The full receipt object to insert
|
||||
* @returns {boolean} true if receipt was inserted
|
||||
*/
|
||||
insertReceiptInPlace(receipt) {
|
||||
// Check if receipt already exists
|
||||
const existingIdx = this.receipts.findIndex(r => r.id === receipt.id)
|
||||
if (existingIdx !== -1) {
|
||||
// Already exists, update it instead (US-029: in-place update, no reordering)
|
||||
Object.assign(this.receipts[existingIdx], receipt)
|
||||
return true
|
||||
}
|
||||
|
||||
// US-043: Always prepend new receipts to maintain upload order.
|
||||
// This preserves the position of completed jobs (which are prepended in unifiedItems).
|
||||
this.receipts.unshift(receipt)
|
||||
return true
|
||||
},
|
||||
|
||||
// ============ Filters & Pagination ============
|
||||
|
||||
setFilters(filters) {
|
||||
@@ -450,6 +589,7 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
direction: null,
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
processingStatus: null, // US-005
|
||||
}
|
||||
this.pagination.page = 1
|
||||
},
|
||||
|
||||
@@ -1,7 +1,80 @@
|
||||
<template>
|
||||
<div class="receipt-unified-view">
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<div class="receipt-unified-view" :class="{ 'mobile-compose-layout': isMobile }">
|
||||
<!-- US-041/US-042: Mobile Top Bar - Gmail Style -->
|
||||
<header v-if="isMobile" class="mobile-compose-top-bar">
|
||||
<div class="top-bar-left">
|
||||
<Button
|
||||
:icon="isViewMode ? 'pi pi-arrow-left' : 'pi pi-times'"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="goBack"
|
||||
aria-label="Înapoi"
|
||||
/>
|
||||
<span class="top-bar-title">{{ mobileTitle }}</span>
|
||||
</div>
|
||||
<div class="top-bar-right">
|
||||
<!-- Create/Edit mode icons -->
|
||||
<Button
|
||||
v-if="!isViewMode"
|
||||
icon="pi pi-paperclip"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="triggerFileAttach"
|
||||
aria-label="Atașează fișier"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isViewMode"
|
||||
icon="pi pi-save"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
:loading="saving"
|
||||
@click="saveReceipt"
|
||||
aria-label="Salvează"
|
||||
/>
|
||||
<!-- US-042: View mode icons - edit, delete, more menu -->
|
||||
<Button
|
||||
v-if="isViewMode && (receipt?.status === 'draft' || receipt?.status === 'rejected')"
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="goToEdit"
|
||||
aria-label="Editează"
|
||||
/>
|
||||
<Button
|
||||
v-if="isViewMode && canDelete"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn top-bar-btn-danger"
|
||||
@click="confirmDelete"
|
||||
aria-label="Șterge"
|
||||
/>
|
||||
<Button
|
||||
v-if="isViewMode"
|
||||
icon="pi pi-ellipsis-v"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="toggleMoreMenu"
|
||||
aria-label="Mai multe opțiuni"
|
||||
aria-haspopup="true"
|
||||
aria-controls="more_menu"
|
||||
/>
|
||||
<Menu
|
||||
ref="moreMenuRef"
|
||||
id="more_menu"
|
||||
:model="moreMenuItems"
|
||||
:popup="true"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Desktop Header -->
|
||||
<div v-if="!isMobile" class="view-header">
|
||||
<div class="header-left">
|
||||
<Button
|
||||
icon="pi pi-arrow-left"
|
||||
@@ -183,19 +256,103 @@
|
||||
<Button label="Creaza" icon="pi pi-plus" @click="createLocalSupplier" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- US-041: Mobile Bottom Action Bar - Gmail Compose Style -->
|
||||
<footer v-if="isMobile && !isViewMode" class="mobile-form-bottom-bar" :class="{ 'keyboard-visible': keyboardVisible }">
|
||||
<Button
|
||||
label="Salvează Ciornă"
|
||||
icon="pi pi-save"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="bottom-bar-btn"
|
||||
:loading="saving"
|
||||
@click="saveReceipt"
|
||||
/>
|
||||
<Button
|
||||
label="Trimite pentru Validare"
|
||||
icon="pi pi-send"
|
||||
severity="success"
|
||||
class="bottom-bar-btn"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
@click="submitForReviewMobile"
|
||||
/>
|
||||
</footer>
|
||||
|
||||
<!-- US-042: Mobile View Mode Bottom Bar - Contextual buttons by status -->
|
||||
<footer v-if="isMobile && isViewMode && receipt" class="mobile-form-bottom-bar">
|
||||
<!-- DRAFT status: Editează / Trimite -->
|
||||
<Button
|
||||
v-if="receipt.status === 'draft'"
|
||||
label="Editează"
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="bottom-bar-btn"
|
||||
@click="goToEdit"
|
||||
/>
|
||||
<Button
|
||||
v-if="receipt.status === 'draft'"
|
||||
label="Trimite"
|
||||
icon="pi pi-send"
|
||||
severity="success"
|
||||
class="bottom-bar-btn"
|
||||
:loading="submitting"
|
||||
@click="submitReceipt"
|
||||
/>
|
||||
<!-- PENDING status: Validează / Respinge -->
|
||||
<Button
|
||||
v-if="receipt.status === 'pending_review'"
|
||||
label="Validează"
|
||||
icon="pi pi-check"
|
||||
severity="success"
|
||||
class="bottom-bar-btn"
|
||||
:loading="approving"
|
||||
@click="approveReceipt"
|
||||
/>
|
||||
<Button
|
||||
v-if="receipt.status === 'pending_review'"
|
||||
label="Respinge"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
class="bottom-bar-btn"
|
||||
@click="openRejectDialog"
|
||||
/>
|
||||
<!-- APPROVED status: Anulează -->
|
||||
<Button
|
||||
v-if="receipt.status === 'approved'"
|
||||
label="Anulează Validarea"
|
||||
icon="pi pi-replay"
|
||||
severity="warning"
|
||||
class="bottom-bar-btn"
|
||||
:loading="cancelling"
|
||||
@click="confirmCancelApproval"
|
||||
/>
|
||||
<!-- REJECTED status: Editează -->
|
||||
<Button
|
||||
v-if="receipt.status === 'rejected'"
|
||||
label="Editează"
|
||||
icon="pi pi-pencil"
|
||||
severity="primary"
|
||||
class="bottom-bar-btn"
|
||||
@click="goToEdit"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import Message from 'primevue/message'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { useReceiptsStore } from '@data-entry/stores/receiptsStore'
|
||||
import { useCompanyStore } from '@data-entry/stores/sharedStores'
|
||||
@@ -212,9 +369,33 @@ import {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const store = useReceiptsStore()
|
||||
const companyStore = useCompanyStore()
|
||||
|
||||
// US-041: Mobile detection and keyboard awareness
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const keyboardVisible = ref(false)
|
||||
const initialViewportHeight = ref(window.innerHeight)
|
||||
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
|
||||
// US-041: Detect virtual keyboard on mobile
|
||||
// When keyboard opens, viewport height decreases significantly
|
||||
if (isMobile.value) {
|
||||
const heightDiff = initialViewportHeight.value - window.visualViewport?.height || window.innerHeight
|
||||
keyboardVisible.value = heightDiff > 150 // Keyboard typically takes 150px+ of space
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisualViewportResize = () => {
|
||||
if (!isMobile.value) return
|
||||
const currentHeight = window.visualViewport?.height || window.innerHeight
|
||||
const heightDiff = initialViewportHeight.value - currentHeight
|
||||
keyboardVisible.value = heightDiff > 150
|
||||
}
|
||||
|
||||
// Mode detection
|
||||
const isViewMode = computed(() => !!route.params.id && !route.path.endsWith('/edit'))
|
||||
const isEditMode = computed(() => !!route.params.id && route.path.endsWith('/edit'))
|
||||
@@ -231,6 +412,62 @@ const modeIcon = computed(() => {
|
||||
return 'pi pi-plus-circle'
|
||||
})
|
||||
|
||||
// US-041: Mobile title - shorter for top bar
|
||||
const mobileTitle = computed(() => {
|
||||
if (isViewMode.value) return `Bon #${receipt.value?.id || ''}`
|
||||
if (isEditMode.value) return 'Editare Bon'
|
||||
return 'Bon Nou'
|
||||
})
|
||||
|
||||
// US-041: Can submit validation for mobile
|
||||
const canSubmit = computed(() => {
|
||||
return form.value.amount && form.value.amount > 0 && form.value.receipt_date
|
||||
})
|
||||
|
||||
// US-042: Can delete - allow delete for draft/rejected, not for approved/pending
|
||||
const canDelete = computed(() => {
|
||||
if (!receipt.value) return false
|
||||
return ['draft', 'rejected'].includes(receipt.value.status)
|
||||
})
|
||||
|
||||
// US-042: More menu items - contextual based on status
|
||||
const moreMenuItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
if (!receipt.value) return items
|
||||
|
||||
// Always available: view attachments
|
||||
if (existingAttachments.value.length > 0) {
|
||||
items.push({
|
||||
label: 'Vizualizează Atașamente',
|
||||
icon: 'pi pi-images',
|
||||
command: () => scrollToAttachments()
|
||||
})
|
||||
}
|
||||
|
||||
// Share option
|
||||
items.push({
|
||||
label: 'Partajează',
|
||||
icon: 'pi pi-share-alt',
|
||||
command: () => shareReceipt()
|
||||
})
|
||||
|
||||
// Separator before dangerous actions
|
||||
items.push({ separator: true })
|
||||
|
||||
// Delete option (if allowed)
|
||||
if (canDelete.value) {
|
||||
items.push({
|
||||
label: 'Șterge Bonul',
|
||||
icon: 'pi pi-trash',
|
||||
class: 'p-menuitem-danger',
|
||||
command: () => confirmDelete()
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Form state
|
||||
const form = ref(getDefaultUnifiedFormState())
|
||||
const receipt = ref(null)
|
||||
@@ -248,8 +485,12 @@ const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const approving = ref(false)
|
||||
const rejecting = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const syncingSuppliers = ref(false)
|
||||
|
||||
// US-042: More menu ref
|
||||
const moreMenuRef = ref(null)
|
||||
|
||||
// Supplier state
|
||||
const supplierSource = ref(null)
|
||||
const supplierWarning = ref({ show: false, cui: '', name: '' })
|
||||
@@ -301,6 +542,13 @@ const getStatusSeverity = (status) => {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// US-041: Mobile event listeners
|
||||
window.addEventListener('resize', handleResize)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', handleVisualViewportResize)
|
||||
}
|
||||
initialViewportHeight.value = window.innerHeight
|
||||
|
||||
// Load nomenclatures
|
||||
await store.fetchAllNomenclatures()
|
||||
|
||||
@@ -318,6 +566,14 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// US-041: Cleanup event listeners
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', handleVisualViewportResize)
|
||||
}
|
||||
})
|
||||
|
||||
// Load existing receipt
|
||||
const loadReceipt = async () => {
|
||||
try {
|
||||
@@ -768,6 +1024,219 @@ const formatDateTime = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString('ro-RO')
|
||||
}
|
||||
|
||||
// US-041: Mobile-specific functions
|
||||
const triggerFileAttach = () => {
|
||||
// Trigger the file input in the UnifiedReceiptForm component
|
||||
unifiedFormRef.value?.resetUpload?.()
|
||||
// Open file dialog via hidden input
|
||||
const fileInput = document.querySelector('.unified-receipt-form .hidden-input')
|
||||
if (fileInput) {
|
||||
fileInput.click()
|
||||
}
|
||||
}
|
||||
|
||||
const submitForReviewMobile = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
// For create mode, save first then submit
|
||||
if (isCreateMode.value) {
|
||||
saving.value = true
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
// Auto-create supplier if warning shown
|
||||
if (supplierWarning.value.show && form.value.cui) {
|
||||
try {
|
||||
await store.createLocalSupplier({
|
||||
name: form.value.partner_name || `Furnizor ${form.value.cui}`,
|
||||
fiscal_code: form.value.cui,
|
||||
address: form.value.vendor_address || null,
|
||||
})
|
||||
supplierWarning.value = { show: false, cui: '', name: '' }
|
||||
supplierSource.value = 'local'
|
||||
} catch (e) {
|
||||
console.warn('Auto-create supplier failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = unifiedFormToApiPayload(form.value)
|
||||
const savedReceipt = await store.createReceipt(payload)
|
||||
|
||||
// Upload files
|
||||
for (const file of selectedFiles.value) {
|
||||
try {
|
||||
await store.uploadAttachment(savedReceipt.id, file)
|
||||
} catch (e) {
|
||||
console.warn(`Upload failed: ${file.name}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Now submit for review
|
||||
const result = await store.submitReceipt(savedReceipt.id)
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost trimis spre aprobare',
|
||||
life: 3000,
|
||||
})
|
||||
router.push('/data-entry')
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: result.message,
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut trimite bonul',
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
submitting.value = false
|
||||
}
|
||||
} else {
|
||||
// Edit mode - use existing submitForReview
|
||||
await submitForReview()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
confirm.require({
|
||||
message: 'Sigur doriți să ștergeți acest bon?',
|
||||
header: 'Confirmare Ștergere',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await store.deleteReceipt(route.params.id)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost șters',
|
||||
life: 2000,
|
||||
})
|
||||
router.push('/data-entry')
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut șterge bonul',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// US-042: More menu toggle
|
||||
const toggleMoreMenu = (event) => {
|
||||
moreMenuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
// US-042: Helper functions for more menu
|
||||
const scrollToAttachments = () => {
|
||||
const attachmentsSection = document.querySelector('.attachments-section')
|
||||
if (attachmentsSection) {
|
||||
attachmentsSection.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const shareReceipt = async () => {
|
||||
if (navigator.share && receipt.value) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: `Bon #${receipt.value.id}`,
|
||||
text: `Bon fiscal - ${receipt.value.partner_name || 'Furnizor'} - ${receipt.value.amount} RON`,
|
||||
url: window.location.href
|
||||
})
|
||||
} catch (error) {
|
||||
// User cancelled sharing or share failed
|
||||
if (error.name !== 'AbortError') {
|
||||
// Fallback: copy URL to clipboard
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Link Copiat',
|
||||
detail: 'Link-ul a fost copiat în clipboard',
|
||||
life: 2000,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for browsers without Web Share API
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Link Copiat',
|
||||
detail: 'Link-ul a fost copiat în clipboard',
|
||||
life: 2000,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: 'Nu s-a putut copia link-ul',
|
||||
life: 3000,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// US-042: Cancel approval functionality
|
||||
const confirmCancelApproval = () => {
|
||||
confirm.require({
|
||||
message: 'Sigur doriți să anulați validarea acestui bon? Bonul va reveni la status "Ciornă".',
|
||||
header: 'Confirmare Anulare Validare',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-warning',
|
||||
acceptLabel: 'Da, Anulează',
|
||||
rejectLabel: 'Nu',
|
||||
accept: async () => {
|
||||
await cancelApproval()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const cancelApproval = async () => {
|
||||
cancelling.value = true
|
||||
try {
|
||||
// Use existing unapproveReceipt from store
|
||||
const result = await store.unapproveReceipt(route.params.id)
|
||||
if (result.success !== false) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Validarea a fost anulată. Bonul a revenit la status Ciornă.',
|
||||
life: 3000,
|
||||
})
|
||||
await loadReceipt()
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: result.message || 'Nu s-a putut anula validarea',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut anula validarea',
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -868,4 +1337,205 @@ const formatDateTime = (dateStr) => {
|
||||
[data-theme="dark"] .view-header {
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* US-041: Mobile Gmail Compose Layout
|
||||
* Similar to Gmail's email compose interface
|
||||
* PRD Mobile Layout Tokens:
|
||||
* - topBarHeight: 56px
|
||||
* - bottomNavHeight: 56px
|
||||
* - touchTargetMin: 48px
|
||||
* ======================================== */
|
||||
|
||||
/* Mobile compose layout container */
|
||||
.receipt-unified-view.mobile-compose-layout {
|
||||
padding: 0;
|
||||
padding-top: 56px; /* Space for fixed top bar */
|
||||
padding-bottom: 80px; /* Space for fixed bottom action bar */
|
||||
max-width: 100%;
|
||||
min-height: 100vh;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
/* Hide desktop header on mobile */
|
||||
.receipt-unified-view.mobile-compose-layout .view-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile Top Bar - Gmail Compose Style */
|
||||
.mobile-compose-top-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: var(--surface-card);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-xs);
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-left,
|
||||
.mobile-compose-top-bar .top-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px; /* Touch target minimum */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-btn:active {
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-btn-danger {
|
||||
color: var(--red-500);
|
||||
}
|
||||
|
||||
/* Mobile Bottom Action Bar - Gmail Compose Style */
|
||||
.mobile-form-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: auto;
|
||||
min-height: 56px;
|
||||
background: var(--surface-card);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
/* Safe area for iOS notch/home indicator */
|
||||
padding-bottom: max(var(--space-sm), env(safe-area-inset-bottom));
|
||||
transition: transform var(--transition-normal), opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Keyboard-aware: move bar above keyboard */
|
||||
.mobile-form-bottom-bar.keyboard-visible {
|
||||
position: absolute;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
/* Buttons in bottom bar - equal width */
|
||||
.mobile-form-bottom-bar .bottom-bar-btn {
|
||||
flex: 1;
|
||||
min-height: 48px; /* Touch target minimum */
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Primary button emphasis */
|
||||
.mobile-form-bottom-bar .bottom-bar-btn.p-button-success {
|
||||
flex: 1.2; /* Slightly wider for primary action */
|
||||
}
|
||||
|
||||
/* Mobile content area adjustments */
|
||||
.receipt-unified-view.mobile-compose-layout .rejection-message {
|
||||
margin: var(--space-sm);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.receipt-unified-view.mobile-compose-layout .validation-banners {
|
||||
margin: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Form inside mobile layout - 100% width */
|
||||
.receipt-unified-view.mobile-compose-layout :deep(.unified-receipt-form) {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.receipt-unified-view.mobile-compose-layout :deep(.form-body) {
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Dark Mode Support for Mobile Layout
|
||||
* ======================================== */
|
||||
[data-theme="dark"] .mobile-compose-top-bar {
|
||||
background: var(--surface-card);
|
||||
border-bottom-color: var(--surface-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-compose-top-bar .top-bar-btn {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-compose-top-bar .top-bar-btn:active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-compose-top-bar .top-bar-btn-danger {
|
||||
color: var(--red-400);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-form-bottom-bar {
|
||||
background: var(--surface-card);
|
||||
border-top-color: var(--surface-border);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* System Preference Dark Mode Support
|
||||
* ======================================== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar {
|
||||
background: var(--surface-card);
|
||||
border-bottom-color: var(--surface-border);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar .top-bar-btn {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar .top-bar-btn:active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar .top-bar-btn-danger {
|
||||
color: var(--red-400);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-form-bottom-bar {
|
||||
background: var(--surface-card);
|
||||
border-top-color: var(--surface-border);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,11 @@ const routes = [
|
||||
name: 'OCRMetrics',
|
||||
component: () => import('@data-entry/views/OCRMetricsView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Metrici OCR - ROA2WEB' }
|
||||
},
|
||||
{
|
||||
// US-013: Redirect old bulk-upload route to main list (backwards compatibility)
|
||||
path: 'bulk-upload',
|
||||
redirect: '/data-entry'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
300
tasks/prd-bulk-actions-sse-refresh.md
Normal file
300
tasks/prd-bulk-actions-sse-refresh.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# PRD: Bulk Actions & SSE Real-time Status Updates
|
||||
|
||||
## Branch de lucru
|
||||
|
||||
> **IMPORTANT**: Toate implementările din acest PRD trebuie făcute în branch-ul existent:
|
||||
> `ralph/bulk-receipt-upload`
|
||||
|
||||
---
|
||||
|
||||
## 1. Introducere
|
||||
|
||||
Pagina de listă bonuri fiscale (`ReceiptsListView.vue`) are deja funcționalitate de selecție multiplă (select all și individual), dar lipsesc acțiunile bulk (în special ștergerea). De asemenea, actualizarea statusului bonurilor la bulk import se face prin polling periodic care reîncarcă toată lista, ceea ce creează o experiență vizuală neplăcută (flicker, pierdere scroll, etc.).
|
||||
|
||||
Acest PRD adresează:
|
||||
1. **Bulk Delete** - posibilitatea de a șterge mai multe bonuri odată
|
||||
2. **SSE Real-time Updates** - actualizări incrementale ale statusului în loc de refresh total
|
||||
|
||||
---
|
||||
|
||||
## 2. Obiective
|
||||
|
||||
### Obiectiv Principal
|
||||
- Permiterea ștergerii în masă a bonurilor selectate cu UX fluid
|
||||
|
||||
### Obiective Secundare
|
||||
- Eliminarea refresh-ului total al paginii la actualizarea statusului bonurilor
|
||||
- Actualizări real-time via SSE pentru starea bonurilor în procesare
|
||||
- Experiență vizuală smooth fără flicker
|
||||
|
||||
### Metrici de Succes
|
||||
- Ștergerea a 10+ bonuri să dureze <2s
|
||||
- Nicio reîncărcare completă a listei la update status
|
||||
- SSE să reducă request-urile de polling cu 80%+
|
||||
|
||||
---
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
### US-001: Buton Șterge în Bulk Actions Bar
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd un buton "Șterge" când am selecții
|
||||
**Pentru că** vreau să pot șterge mai multe bonuri odată
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Butonul "Șterge" apare în bulk actions bar când `selectedReceipts.length > 0`
|
||||
- [ ] Butonul e vizibil lângă "Validează selectate" și "Deselectează" în header inline
|
||||
- [ ] Butonul are icon `pi-trash` și severity `danger`
|
||||
- [ ] Bonurile în procesare (pending/processing) NU sunt selectabile (deja implementat - verifică că funcționează)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: butonul apare doar când există selecții
|
||||
|
||||
---
|
||||
|
||||
### US-002: Dialog Confirmare Ștergere Bulk
|
||||
**Ca** utilizator
|
||||
**Vreau** o confirmare simplă înainte de ștergere
|
||||
**Pentru că** vreau să evit ștergeri accidentale
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] La click pe "Șterge", apare dialog cu mesaj: "Ești sigur că vrei să ștergi {N} bonuri?"
|
||||
- [ ] Dialog-ul are 2 butoane: "Anulează" (secondary) și "Șterge" (danger)
|
||||
- [ ] Dialog-ul folosește componenta PrimeVue `ConfirmDialog` sau `Dialog`
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: dialogul apare cu numărul corect de bonuri
|
||||
|
||||
---
|
||||
|
||||
### US-003: Backend Endpoint Bulk Delete
|
||||
**Ca** frontend
|
||||
**Vreau** să pot trimite o listă de ID-uri pentru ștergere
|
||||
**Pentru că** e mai eficient decât request-uri individuale
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Endpoint `DELETE /api/data-entry/receipts/bulk` acceptă body: `{ "ids": [1, 2, 3] }`
|
||||
- [ ] Validează că fiecare bon e în status DRAFT și creat de user-ul curent
|
||||
- [ ] Returnează partial success: `{ "deleted": [1, 2], "failed": [{"id": 3, "error": "..."}] }`
|
||||
- [ ] Șterge atașamentele și înregistrările contabile asociate
|
||||
- [ ] Bonurile în procesare nu pot fi șterse - returnează eroare specifică
|
||||
- [ ] npm run typecheck passes (pentru Python: ruff check)
|
||||
|
||||
---
|
||||
|
||||
### US-004: Frontend Bulk Delete cu Partial Success
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd rezultatul ștergerii
|
||||
**Pentru că** vreau să știu dacă toate bonurile au fost șterse
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] După ștergere, toast arată rezultatul: "X bonuri șterse" sau "X din Y șterse, Z au eșuat"
|
||||
- [ ] Bonurile șterse dispar instant din listă (fără animație per specificații)
|
||||
- [ ] Lista se actualizează local (nu re-fetch complet dacă nu e necesar)
|
||||
- [ ] Stats se actualizează după ștergere
|
||||
- [ ] Selecția se golește după ștergere
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: bonurile dispar instant, toast corect
|
||||
|
||||
---
|
||||
|
||||
### US-005: Navigare la Pagina Anterioară când Lista Devine Goală
|
||||
**Ca** utilizator
|
||||
**Vreau** să fiu redirecționat automat la pagina anterioară
|
||||
**Pentru că** nu vreau să văd o pagină goală
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] După bulk delete, dacă pagina curentă devine goală și există pagini anterioare, navighează la pagina anterioară
|
||||
- [ ] Dacă eram pe pagina 1 și devine goală, afișează empty state
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: navigare automată funcționează
|
||||
|
||||
---
|
||||
|
||||
### US-006: Backend SSE Endpoint pentru Status Updates
|
||||
**Ca** frontend
|
||||
**Vreau** să primesc notificări real-time despre schimbări de status
|
||||
**Pentru că** vreau să actualizez UI-ul fără polling
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Endpoint `GET /api/data-entry/receipts/sse/status` returnează Server-Sent Events
|
||||
- [ ] Conexiunea e persistent și trimite evenimente la schimbări de status
|
||||
- [ ] Format eveniment: `data: {"receipt_id": 123, "status": "completed", "processing_status": "completed"}`
|
||||
- [ ] Suport pentru filtrare pe batch_id (query param)
|
||||
- [ ] Include timeout handling și reconnect hints
|
||||
- [ ] ruff check passes
|
||||
|
||||
---
|
||||
|
||||
### US-007: Frontend SSE Client pentru Status Updates
|
||||
**Ca** frontend
|
||||
**Vreau** să mă conectez la SSE și să actualizez rândurile individual
|
||||
**Pentru că** vreau actualizări smooth fără reload
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Creează serviciu SSE în `src/modules/data-entry/services/sseService.js`
|
||||
- [ ] Conectare automată când există bonuri în procesare în listă
|
||||
- [ ] La primire eveniment, actualizează doar rândul afectat (nu toată lista)
|
||||
- [ ] Deconectare automată când nu mai sunt bonuri în procesare
|
||||
- [ ] Retry logic cu exponential backoff la disconnect
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: statusul se actualizează în timp real fără flicker
|
||||
|
||||
---
|
||||
|
||||
### US-008: Înlocuire Polling cu SSE
|
||||
**Ca** frontend
|
||||
**Vreau** să folosesc SSE în loc de polling interval
|
||||
**Pentru că** e mai eficient și mai smooth
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Elimină `setInterval` pentru auto-refresh când SSE e activ
|
||||
- [ ] Fallback la polling dacă SSE nu e disponibil (WebSocket blocked, etc.)
|
||||
- [ ] Când toate bonurile din batch sunt procesate, închide conexiunea SSE
|
||||
- [ ] Logging pentru debug (console.log la conectare/deconectare/evenimente)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: consola arată evenimente SSE, nu polling requests
|
||||
|
||||
---
|
||||
|
||||
### US-009: Update Row Individual fără Re-render Lista
|
||||
**Ca** frontend
|
||||
**Vreau** să actualizez un singur rând fără să re-renderez toată lista
|
||||
**Pentru că** vreau performanță și stabilitate vizuală
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Creează metodă `updateReceiptInPlace(receiptId, updates)` în store
|
||||
- [ ] Metoda modifică doar obiectul specific din array, nu înlocuiește array-ul
|
||||
- [ ] Vue reactivity detectează schimbarea și actualizează doar rândul afectat
|
||||
- [ ] Stats se actualizează separat (poate necesita re-fetch stats)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: DevTools Performance - doar un rând se re-renderează
|
||||
|
||||
---
|
||||
|
||||
### US-010: Graceful Degradation la SSE Failure
|
||||
**Ca** utilizator
|
||||
**Vreau** ca aplicația să funcționeze și fără SSE
|
||||
**Pentru că** pot avea probleme de rețea sau proxy
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Dacă SSE fail la connect, fallback la polling clasic (interval 5s)
|
||||
- [ ] Mesaj discret în consolă (nu toast pentru user)
|
||||
- [ ] Retry SSE periodic (la 30s) pentru a vedea dacă funcționează din nou
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: deconectează SSE, verifică că polling-ul preia
|
||||
|
||||
---
|
||||
|
||||
## 4. Cerințe Funcționale
|
||||
|
||||
1. [REQ-001] Butonul "Șterge" trebuie să fie roșu (severity danger) cu icon trash
|
||||
2. [REQ-002] Bonurile cu `processing_status` în `pending` sau `processing` nu pot fi șterse
|
||||
3. [REQ-003] Ștergerea bulk trebuie să șteargă și atașamentele și înregistrările contabile asociate
|
||||
4. [REQ-004] SSE trebuie să trimită evenimente pentru: status change, processing_status change
|
||||
5. [REQ-005] Frontend trebuie să mențină conexiunea SSE doar când există bonuri în procesare
|
||||
6. [REQ-006] La ștergerea ultimului bon de pe pagină, navighează automat la pagina anterioară
|
||||
|
||||
---
|
||||
|
||||
## 5. Non-Goals (Ce NU facem)
|
||||
|
||||
- ❌ Bulk edit (modificare date pe mai multe bonuri) - poate fi un alt PRD
|
||||
- ❌ Animații fade/slide la ștergere - utilizatorul a cerut instant remove
|
||||
- ❌ Undo pentru ștergere - nu e în scope
|
||||
- ❌ WebSocket în loc de SSE - SSE e mai simplu și suficient
|
||||
- ❌ Notificări push/toast pentru statusuri - doar actualizare în tabel
|
||||
|
||||
---
|
||||
|
||||
## 6. Considerații Tehnice
|
||||
|
||||
### Stack/Tehnologii
|
||||
- **Backend**: FastAPI cu SSE support (StreamingResponse)
|
||||
- **Frontend**: Vue 3 Composition API, Pinia store
|
||||
- **SSE**: Native EventSource API (bun suport browser)
|
||||
|
||||
### Patterns de Urmat
|
||||
- Service pattern în backend (nu logică în router)
|
||||
- Store actions în frontend pentru mutații
|
||||
- `@cached` decorator DOAR pentru reads, nu pentru deletes
|
||||
|
||||
### Dependențe
|
||||
- PrimeVue ConfirmDialog sau Dialog pentru confirmare
|
||||
- FastAPI `StreamingResponse` pentru SSE
|
||||
- Deja există: bulk actions bar, selection logic, checkbox disable pentru processing
|
||||
|
||||
### Riscuri Tehnice
|
||||
- SSE poate fi blocat de proxy/firewall - mitigat cu fallback la polling
|
||||
- Concurrency la delete bulk - poate conflicta cu procesare - mitigat cu check status
|
||||
|
||||
### Structură Fișiere Noi
|
||||
```
|
||||
backend/modules/data_entry/
|
||||
├── routers/
|
||||
│ └── receipts.py # Adaugă DELETE /bulk și GET /sse/status
|
||||
├── services/
|
||||
│ └── sse_service.py # NOU: SSE broadcaster
|
||||
└── schemas/
|
||||
└── receipt.py # Adaugă BulkDeleteRequest, BulkDeleteResponse
|
||||
|
||||
src/modules/data-entry/
|
||||
├── services/
|
||||
│ └── sseService.js # NOU: SSE client
|
||||
├── stores/
|
||||
│ └── receiptsStore.js # Adaugă updateReceiptInPlace
|
||||
└── views/receipts/
|
||||
└── ReceiptsListView.vue # Adaugă buton Șterge, integrare SSE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Considerații UI/UX
|
||||
|
||||
### Layout
|
||||
- Butonul Șterge: în bulk actions bar, header inline, lângă celelalte butoane
|
||||
- Dialog confirmare: centered modal, compact
|
||||
|
||||
### Stări
|
||||
- **Loading delete**: butonul Șterge arată spinner
|
||||
- **Success**: toast verde "X bonuri șterse"
|
||||
- **Partial fail**: toast warning "X din Y șterse, Z au eșuat"
|
||||
- **SSE connected**: indicator vizual opțional (poate în devtools)
|
||||
|
||||
### Accesibilitate
|
||||
- Dialog accesibil (focus trap, escape to close)
|
||||
- Buton cu label clear
|
||||
|
||||
---
|
||||
|
||||
## 8. Success Metrics
|
||||
|
||||
- Bulk delete 10 bonuri: < 2 secunde end-to-end
|
||||
- Zero full page refreshes la status updates
|
||||
- SSE latency: < 500ms de la schimbare status la UI update
|
||||
- Fallback polling activat în < 5s dacă SSE eșuează
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
- [x] Confirmare: simplă cu count ✓
|
||||
- [x] Bonuri în procesare: exclude automat ✓
|
||||
- [x] Polling: SSE ✓
|
||||
- [x] Error handling: partial success ✓
|
||||
- [x] Bara acțiuni: header inline ✓
|
||||
- [x] Animații: instant remove ✓
|
||||
- [x] Pagină goală: du la pagina anterioară ✓
|
||||
|
||||
---
|
||||
|
||||
## 10. Ordine Implementare Recomandată
|
||||
|
||||
1. **US-003** - Backend bulk delete (fundație)
|
||||
2. **US-001** - Buton Șterge în UI
|
||||
3. **US-002** - Dialog confirmare
|
||||
4. **US-004** - Frontend bulk delete cu toast
|
||||
5. **US-005** - Navigare pagină anterioară
|
||||
6. **US-009** - Update row in place (pregătire pentru SSE)
|
||||
7. **US-006** - Backend SSE endpoint
|
||||
8. **US-007** - Frontend SSE client
|
||||
9. **US-008** - Înlocuire polling cu SSE
|
||||
10. **US-010** - Graceful degradation
|
||||
406
tasks/prd-bulk-receipt-upload.md
Normal file
406
tasks/prd-bulk-receipt-upload.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# PRD: Bulk Receipt Upload & Auto-Processing
|
||||
|
||||
## 1. Introducere
|
||||
|
||||
Sistemul actual permite upload-ul și procesarea unui singur bon la un moment dat, cu intervenție manuală la editare/salvare. Această funcționalitate adaugă o pagină separată pentru upload multiple bonuri (PDF/PNG/JPG) care sunt procesate automat prin OCR și salvate direct în baza de date, fără intervenție manuală.
|
||||
|
||||
**Context tehnic:** Există deja infrastructura de job queue (SQLite) și worker pool pentru procesare paralelă OCR. Această funcționalitate va extinde sistemul existent.
|
||||
|
||||
## 2. Obiective
|
||||
|
||||
### Obiectiv Principal
|
||||
- Permiterea upload-ului bulk de bonuri (10-50 fișiere) cu procesare automată end-to-end
|
||||
|
||||
### Obiective Secundare
|
||||
- Reducerea timpului de introducere date cu 90%+ pentru batch-uri mari
|
||||
- Vizibilitate în timp real asupra progresului procesării
|
||||
- Separare clară între flow-ul manual (editare) și automat (bulk)
|
||||
|
||||
### Metrici de Succes
|
||||
- Timp mediu per bon < 10 secunde (vs. 2-3 minute manual)
|
||||
- Rata de succes OCR > 80% (bonuri procesate fără erori)
|
||||
- Upload batch 50 bonuri în < 10 minute
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
### US-001: Upload Multiple Fișiere
|
||||
**Ca** utilizator data-entry
|
||||
**Vreau** să selectez/trag multiple fișiere (PDF/PNG/JPG) într-o zonă de upload
|
||||
**Pentru că** vreau să procesez un lot întreg de bonuri dintr-o dată
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Drag & drop zone acceptă multiple fișiere simultan
|
||||
- [ ] Click pe zonă deschide file picker cu multi-select activat
|
||||
- [ ] Fișierele acceptate: PDF, PNG, JPG (max 10MB/fișier)
|
||||
- [ ] Fișierele invalide sunt ignorate cu mesaj de avertizare
|
||||
- [ ] Lista fișierelor selectate apare sub zona de upload
|
||||
- [ ] Buton "Șterge" per fișier pentru eliminare din batch
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that files appear in list after selection
|
||||
- [ ] **CSS:** Drop zone folosește `var(--surface-card)` background, `var(--surface-border)` border
|
||||
- [ ] **CSS:** Drop zone hover/active folosește `var(--blue-50)` background
|
||||
- [ ] **CSS:** Spacing între elemente folosește tokens (`--space-md`, `--space-lg`)
|
||||
- [ ] **CSS:** Testează în dark mode - drop zone vizibilă și contrastantă
|
||||
|
||||
### US-002: Vizualizare Batch Înainte de Submit
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd lista fișierelor selectate cu preview
|
||||
**Pentru că** vreau să verific că am selectat fișierele corecte înainte de procesare
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Lista arată: thumbnail (pentru imagini), nume fișier, mărime
|
||||
- [ ] Pentru PDF-uri se arată icon generic PDF
|
||||
- [ ] Counter total: "X fișiere selectate (Y MB)"
|
||||
- [ ] Buton "Golește lista" pentru resetare completă
|
||||
- [ ] Buton "Adaugă fișiere" pentru a adăuga la selecție existentă
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that thumbnails render correctly
|
||||
- [ ] **CSS:** Lista folosește pattern `.card` din `cards.css`
|
||||
- [ ] **CSS:** Thumbnail cu `border-radius: var(--radius-md)`
|
||||
- [ ] **CSS:** File size text cu `color: var(--color-text-secondary)`, `font-size: var(--text-sm)`
|
||||
- [ ] **CSS:** Butoane folosesc clasele `.btn .btn-secondary` și `.btn .btn-primary`
|
||||
- [ ] **CSS:** Testează în dark mode - thumbnails și text lizibile
|
||||
|
||||
### US-003: Submit Batch pentru Procesare
|
||||
**Ca** utilizator
|
||||
**Vreau** să trimit toate fișierele pentru procesare cu un singur click
|
||||
**Pentru că** vreau să declanșez procesarea automată a întregului lot
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Buton "Procesează X bonuri" submit-ează batch-ul
|
||||
- [ ] La submit, toate fișierele se uploadează și se creează câte un OCR job per fișier
|
||||
- [ ] După submit, UI-ul trece în modul "progres" (nu mai permite adăugare fișiere)
|
||||
- [ ] Dacă un upload individual eșuează (network error), se reîncearcă automat (max 3 retry)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that submit disables file addition
|
||||
|
||||
### US-004: Progres Real-Time per Fișier
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd progresul fiecărui fișier în timp real
|
||||
**Pentru că** vreau să știu câte bonuri s-au procesat și câte mai sunt în așteptare
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Fiecare fișier din listă arată status: "În așteptare" / "Se procesează..." / "Completat" / "Eroare"
|
||||
- [ ] Status vizual diferențiat: badge/icon color-coded (gri/albastru/verde/roșu)
|
||||
- [ ] La completare se arată confidence score overall (ex: "87% confidence")
|
||||
- [ ] Progress bar global: "15/50 procesate"
|
||||
- [ ] Timpul estimat rămas bazat pe average processing time
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that status updates in real-time without page refresh
|
||||
- [ ] **CSS:** Status badges folosesc culorile din tabel (vezi secțiunea 6):
|
||||
- Pending: `background: var(--surface-hover)`, `color: var(--color-text-secondary)`
|
||||
- Processing: `background: var(--blue-50)`, `color: var(--blue-600)` + spinner
|
||||
- Success: `background: var(--green-50)`, `color: var(--green-600)`
|
||||
- Error: `background: var(--red-50)`, `color: var(--red-600)`
|
||||
- [ ] **CSS:** Progress bar folosește PrimeVue ProgressBar (stilizat global)
|
||||
- [ ] **CSS:** Confidence score cu `font-family: var(--font-mono)` pentru numere
|
||||
- [ ] **CSS:** Testează în dark mode - badges vizibile și contrastate
|
||||
|
||||
### US-005: Salvare Automată a Bonurilor Procesate
|
||||
**Ca** sistem
|
||||
**Vreau** să salvez automat bonurile procesate cu succes în baza de date
|
||||
**Pentru că** utilizatorul dorește procesare 100% automată fără intervenție
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] După OCR completat cu succes, se creează automat un receipt în DB
|
||||
- [ ] Receipt-ul primește status "DRAFT" (poate fi editat ulterior dacă e nevoie)
|
||||
- [ ] Se atașează automat fișierul original la receipt
|
||||
- [ ] Se salvează toate câmpurile extrase: vendor, CUI, dată, sumă, TVA
|
||||
- [ ] Se generează automat accounting entries (ca la flow-ul manual)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify that receipts appear in receipt list after bulk processing
|
||||
|
||||
### US-006: Gestionare Erori OCR
|
||||
**Ca** utilizator
|
||||
**Vreau** ca bonurile cu erori OCR să fie marcate pentru review manual
|
||||
**Pentru că** vreau să procesez restul batch-ului fără blocaj, dar să nu pierd bonurile problematice
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] La eroare OCR, fișierul primește status "Eroare" cu mesaj explicativ
|
||||
- [ ] Bonurile cu erori rămân în lista vizibilă, nu sunt șterse
|
||||
- [ ] Buton "Deschide în editor" pentru fiecare bon cu eroare (redirect la pagina de editare manuală)
|
||||
- [ ] Procesarea celorlalte bonuri continuă independent
|
||||
- [ ] La final se arată sumar: "45 procesate cu succes, 5 cu erori"
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that error files show retry/edit options
|
||||
- [ ] **CSS:** Error row cu `background: var(--red-50)`, `border-left: 3px solid var(--red-500)`
|
||||
- [ ] **CSS:** Error message cu `color: var(--red-600)`, `font-size: var(--text-sm)`
|
||||
- [ ] **CSS:** Action buttons în error row folosesc `.btn .btn-sm` pattern
|
||||
- [ ] **CSS:** Testează în dark mode - erori vizibile dar nu agresive
|
||||
|
||||
### US-007: Rezumat Final Batch
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd un rezumat la finalul procesării batch-ului
|
||||
**Pentru că** vreau să știu câte bonuri s-au salvat și ce acțiuni mai am de făcut
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Modal/panel de rezumat cu statistici: procesate OK, erori, total sumă
|
||||
- [ ] Link direct la lista de receipts filtrat pe batch-ul curent (by date/user)
|
||||
- [ ] Opțiune "Încarcă alt batch" pentru a începe de la zero
|
||||
- [ ] Opțiune "Vezi bonurile cu erori" pentru review rapid
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that summary modal shows correct counts
|
||||
- [ ] **CSS:** Modal folosește PrimeVue Dialog (stilizat global) sau pattern `.card`
|
||||
- [ ] **CSS:** Statistici success cu `color: var(--green-600)`, errors cu `color: var(--red-600)`
|
||||
- [ ] **CSS:** Total sumă cu `font-size: var(--text-2xl)`, `font-weight: var(--font-bold)`, `font-family: var(--font-mono)`
|
||||
- [ ] **CSS:** Spacing consistent: `padding: var(--space-lg)`, `gap: var(--space-md)`
|
||||
- [ ] **CSS:** Testează în dark mode - modal și conținut lizibile
|
||||
|
||||
### US-008: Backend - Endpoint Batch Upload
|
||||
**Ca** developer
|
||||
**Vreau** un endpoint optimizat pentru upload multiple fișiere
|
||||
**Pentru că** upload-ul secvențial ar fi prea lent pentru 50+ fișiere
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] POST `/api/data-entry/bulk/upload` acceptă multipart cu multiple fișiere
|
||||
- [ ] Returnează lista de job_id-uri pentru tracking
|
||||
- [ ] Validare: max 100 fișiere per batch, max 10MB per fișier
|
||||
- [ ] Jobs se creează atomic (toate sau niciunul)
|
||||
- [ ] Returnează și un batch_id pentru grouping
|
||||
- [ ] pytest tests pass
|
||||
- [ ] API returns correct response schema
|
||||
|
||||
### US-009: Backend - Auto-Save Receipt din OCR Result
|
||||
**Ca** developer
|
||||
**Vreau** un service care creează automat receipt-uri din rezultatele OCR
|
||||
**Pentru că** flow-ul bulk trebuie să fie end-to-end fără intervenție UI
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `ReceiptAutoCreateService.create_from_ocr_result(job_id, ocr_result, user)`
|
||||
- [ ] Mapare completă OCR fields → Receipt fields
|
||||
- [ ] Creare attachment cu fișierul original
|
||||
- [ ] Generare accounting entries via existing logic
|
||||
- [ ] Validare minimă: suma > 0, dată validă
|
||||
- [ ] Return receipt_id sau error message
|
||||
- [ ] pytest tests pass
|
||||
|
||||
### US-010: Backend - Batch Status Endpoint
|
||||
**Ca** developer
|
||||
**Vreau** un endpoint pentru status-ul întregului batch
|
||||
**Pentru că** frontend-ul trebuie să poll-eze eficient pentru toate fișierele
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] GET `/api/data-entry/bulk/batches/{batch_id}/status`
|
||||
- [ ] Returnează status agregat: pending_count, processing_count, completed_count, failed_count
|
||||
- [ ] Include lista de job_id + status pentru fiecare fișier
|
||||
- [ ] Include receipt_id pentru jobs completate cu succes
|
||||
- [ ] Suportă long-polling (wait param) pentru eficiență
|
||||
- [ ] pytest tests pass
|
||||
|
||||
## 4. Cerințe Funcționale
|
||||
|
||||
1. [REQ-001] Sistemul trebuie să accepte upload simultan de până la 100 fișiere
|
||||
2. [REQ-002] Fiecare fișier trebuie să fie max 10MB
|
||||
3. [REQ-003] Formatele acceptate: PDF, PNG, JPG, JPEG
|
||||
4. [REQ-004] Procesarea trebuie să fie paralelă (max N workers din config)
|
||||
5. [REQ-005] Bonurile procesate cu succes se salvează automat cu status DRAFT
|
||||
6. [REQ-006] Bonurile cu erori rămân disponibile pentru retry/editare manuală
|
||||
7. [REQ-007] Fișierele originale se atașează automat la receipt-uri
|
||||
8. [REQ-008] Se generează automat accounting entries pentru fiecare receipt
|
||||
9. [REQ-009] Batch-urile trebuie să fie tracked per user (nu se văd batch-urile altora)
|
||||
10. [REQ-010] Job files se curăță automat după 24h (cleanup existing)
|
||||
|
||||
## 5. Non-Goals (Ce NU facem)
|
||||
|
||||
- **NU** facem aprobare automată (bonurile rămân DRAFT, nu APPROVED)
|
||||
- **NU** facem machine learning pentru îmbunătățirea OCR-ului
|
||||
- **NU** facem procesare pe server extern (totul rămâne local)
|
||||
- **NU** facem notificări (email/push) la finalizare batch
|
||||
- **NU** facem preview/editare în bulk page - pentru asta există pagina individuală
|
||||
- **NU** facem undo/rollback batch (bonurile create pot fi șterse individual)
|
||||
- **NU** facem scheduling (procesare imediată, nu amânată)
|
||||
- **NU** facem duplicate detection (poate fi adăugat ulterior)
|
||||
|
||||
## 6. Considerații Tehnice
|
||||
|
||||
### Stack/Tehnologii
|
||||
- **Frontend:** Vue 3 Composition API, PrimeVue (FileUpload, ProgressBar, DataTable)
|
||||
- **Backend:** FastAPI, SQLite (job queue existent), SQLModel (receipts)
|
||||
- **State:** Pinia store pentru batch progress tracking
|
||||
|
||||
### Patterns de Urmat
|
||||
- Folosește `OCRJobQueue` existent pentru job management
|
||||
- Extinde `job_worker.py` pentru auto-save la completare
|
||||
- Folosește pattern-ul de polling din `OCRUploadZone.vue` existent
|
||||
|
||||
### ⚠️ REGULI CSS OBLIGATORII
|
||||
|
||||
**CITEȘTE ÎNTÂI:** `docs/ONBOARDING_CSS.md` și `docs/DESIGN_TOKENS.md`
|
||||
|
||||
#### Golden Rules
|
||||
```
|
||||
✅ Folosește DOAR design tokens - NICIODATĂ valori hardcodate
|
||||
✅ Verifică CSS_PATTERNS.md înainte de a scrie CSS nou
|
||||
✅ Testează în AMBELE teme (light + dark mode)
|
||||
❌ NICIODATĂ :deep() în componente (PrimeVue → vendor/)
|
||||
❌ NICIODATĂ duplicate CSS (write once, use everywhere)
|
||||
```
|
||||
|
||||
#### Design Tokens Obligatorii
|
||||
|
||||
| Categorie | ❌ GREȘIT | ✅ CORECT |
|
||||
|-----------|-----------|-----------|
|
||||
| Spacing | `padding: 8px` | `padding: var(--space-sm)` |
|
||||
| Spacing | `margin: 16px` | `margin: var(--space-md)` |
|
||||
| Spacing | `gap: 24px` | `gap: var(--space-lg)` |
|
||||
| Font size | `font-size: 14px` | `font-size: var(--text-sm)` |
|
||||
| Font weight | `font-weight: 500` | `font-weight: var(--font-medium)` |
|
||||
| Font weight | `font-weight: 600` | `font-weight: var(--font-semibold)` |
|
||||
| Colors | `color: #111827` | `color: var(--color-text)` |
|
||||
| Colors | `color: #6b7280` | `color: var(--color-text-secondary)` |
|
||||
| Colors | `background: #ffffff` | `background: var(--surface-card)` |
|
||||
| Colors | `background: #f0fdf4` | `background: var(--green-50)` |
|
||||
| Colors | `border: #e5e7eb` | `border-color: var(--surface-border)` |
|
||||
| Radius | `border-radius: 8px` | `border-radius: var(--radius-md)` |
|
||||
| Shadow | `box-shadow: 0 4px 6px...` | `box-shadow: var(--shadow-md)` |
|
||||
| Transition | `transition: 0.2s` | `transition: var(--transition-fast)` |
|
||||
|
||||
#### Spacing Scale Reference
|
||||
| Token | Value | Use Case |
|
||||
|-------|-------|----------|
|
||||
| `--space-xs` | 4px | Icon gaps, badges |
|
||||
| `--space-sm` | 8px | Between related items |
|
||||
| `--space-md` | 16px | Component padding |
|
||||
| `--space-lg` | 24px | Section padding, cards |
|
||||
| `--space-xl` | 32px | Page margins |
|
||||
|
||||
#### Status Colors (pentru progres/erori)
|
||||
| Status | Background | Text/Icon |
|
||||
|--------|------------|-----------|
|
||||
| Pending | `var(--surface-hover)` | `var(--color-text-secondary)` |
|
||||
| Processing | `var(--blue-50)` | `var(--blue-600)` |
|
||||
| Success | `var(--green-50)` | `var(--green-600)` |
|
||||
| Error | `var(--red-50)` | `var(--red-600)` |
|
||||
| Warning | `var(--yellow-50)` | `var(--yellow-600)` |
|
||||
|
||||
#### Dark Mode - OBLIGATORIU
|
||||
- Folosește `--surface-*` tokens pentru backgrounds (auto-switch în dark mode)
|
||||
- Testează cu theme toggle din header (auto → light → dark)
|
||||
- NU folosi culori hardcodate care nu se schimbă în dark mode
|
||||
|
||||
#### Patterns Existente de Folosit
|
||||
| Pattern | File | Use Case |
|
||||
|---------|------|----------|
|
||||
| `.card` | `cards.css` | Container principal |
|
||||
| `.btn`, `.btn-primary` | `buttons.css` | Butoane |
|
||||
| `.form-group`, `.form-label` | `forms.css` | Formulare |
|
||||
| `.spinner` | `spinners.css` | Loading states |
|
||||
| `.trend`, `.trend-up` | `trends.css` | Indicators |
|
||||
| Utility classes | `utilities/` | `gap-md`, `text-center`, etc. |
|
||||
|
||||
#### PrimeVue Components
|
||||
- FileUpload, ProgressBar, DataTable, Tag, Badge - toate sunt stilizate global
|
||||
- NU adăuga `:deep()` în componente
|
||||
- Modificări PrimeVue → `src/assets/css/vendor/primevue-overrides.css`
|
||||
|
||||
### Dependențe
|
||||
- Job Queue existent: `backend/modules/data_entry/services/ocr/job_queue.py`
|
||||
- Worker Pool existent: `backend/modules/data_entry/services/ocr/ocr_worker_pool.py`
|
||||
- Receipt CRUD: `backend/modules/data_entry/db/crud/receipt.py`
|
||||
- Attachment CRUD: `backend/modules/data_entry/db/crud/attachment.py`
|
||||
|
||||
### Riscuri Tehnice
|
||||
- **Memory pressure:** Upload simultan de 100 fișiere × 10MB = 1GB potențial
|
||||
- Mitigare: Upload secvențial intern, buffer 5 fișiere max în memorie
|
||||
- **Queue overflow:** 100 jobs noi pot încetini procesarea existentă
|
||||
- Mitigare: Worker pool deja limitează concurența
|
||||
- **Browser crash:** Tab închis pierde tracking progress
|
||||
- Mitigare: Jobs persistă în DB, refresh poate recupera status
|
||||
|
||||
## 7. Considerații UI/UX
|
||||
|
||||
### Layout
|
||||
1. **Header:** Titlu "Upload Bulk Bonuri" + link înapoi la lista principală
|
||||
2. **Drop Zone:** Mare, centrată, cu icon și text instructiv
|
||||
3. **File List:** Tabel/listă sub drop zone cu progres per fișier
|
||||
4. **Actions Bar:** Butoane "Procesează", "Golește lista" - sticky la bottom
|
||||
|
||||
### Layout CSS Structure
|
||||
```
|
||||
.bulk-upload-page
|
||||
├── .page-header (pattern existent)
|
||||
│ └── h1 + breadcrumb
|
||||
├── .card (drop zone container)
|
||||
│ └── .upload-zone (dashed border, centered)
|
||||
├── .card (file list container)
|
||||
│ └── DataTable sau custom list
|
||||
└── .form-actions (sticky footer cu butoane)
|
||||
```
|
||||
|
||||
### Stări UI cu CSS
|
||||
|
||||
| Stare | Background | Border | Elements |
|
||||
|-------|------------|--------|----------|
|
||||
| **Empty** | `var(--surface-card)` | `2px dashed var(--surface-border)` | Icon mare + text instructiv |
|
||||
| **Drag Over** | `var(--blue-50)` | `2px dashed var(--blue-500)` | Border evidențiat |
|
||||
| **Files Selected** | `var(--surface-card)` | `1px solid var(--surface-border)` | Lista + action buttons |
|
||||
| **Processing** | `var(--surface-card)` | - | Spinner global + per-file status |
|
||||
| **Complete** | `var(--green-50)` subtle | - | Summary card |
|
||||
| **Has Errors** | - | - | Error items highlighted |
|
||||
|
||||
### Status Badge Styles
|
||||
```css
|
||||
/* Folosește PrimeVue Tag sau custom badges */
|
||||
.status-pending { background: var(--surface-hover); color: var(--color-text-secondary); }
|
||||
.status-processing { background: var(--blue-50); color: var(--blue-600); }
|
||||
.status-success { background: var(--green-50); color: var(--green-600); }
|
||||
.status-error { background: var(--red-50); color: var(--red-600); }
|
||||
```
|
||||
|
||||
### Accesibilitate
|
||||
- Keyboard navigation pentru file list
|
||||
- Screen reader announcements la status changes
|
||||
- Focus management la modal rezumat
|
||||
- WCAG contrast ratios respectate (toate token-urile sunt compliant)
|
||||
|
||||
## 8. Success Metrics
|
||||
|
||||
- **Upload Success Rate:** > 99% (fișierele ajung în queue)
|
||||
- **OCR Success Rate:** > 80% (bonuri procesate fără erori)
|
||||
- **Average Processing Time:** < 8 secunde/bon
|
||||
- **User Satisfaction:** Reducere timp introducere date cu 90%
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
- [ ] Limita de 100 fișiere este suficientă sau trebuie mărită?
|
||||
- [ ] Dorim să afișăm preview al datelor extrase înainte de save (ar contrazice "100% automat")?
|
||||
- [ ] Ce facem cu bonurile duplicate detectate ulterior (același număr bon + dată)?
|
||||
- [ ] Trebuie un "dry run" mode care procesează dar nu salvează?
|
||||
|
||||
---
|
||||
|
||||
## 10. Dependențe între User Stories
|
||||
|
||||
```
|
||||
US-001 (Upload Files)
|
||||
↓
|
||||
US-002 (Preview List) ← independent
|
||||
↓
|
||||
US-003 (Submit Batch) → US-008 (Backend Upload Endpoint)
|
||||
↓
|
||||
US-004 (Progress) ← US-010 (Backend Status Endpoint)
|
||||
↓
|
||||
US-005 (Auto-Save) ← US-009 (Backend Auto-Create Service)
|
||||
↓
|
||||
US-006 (Error Handling)
|
||||
↓
|
||||
US-007 (Summary)
|
||||
```
|
||||
|
||||
**Ordine recomandată de implementare:**
|
||||
1. US-008: Backend - Batch Upload Endpoint
|
||||
2. US-010: Backend - Batch Status Endpoint
|
||||
3. US-009: Backend - Auto-Save Service
|
||||
4. US-001: Frontend - Upload Zone
|
||||
5. US-002: Frontend - File Preview
|
||||
6. US-003: Frontend - Submit Batch
|
||||
7. US-004: Frontend - Progress Tracking
|
||||
8. US-005: Integration - Auto-Save Flow
|
||||
9. US-006: Frontend - Error Handling
|
||||
10. US-007: Frontend - Summary Modal
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-01-09
|
||||
**Author:** Claude Code
|
||||
**Status:** Draft - Pending Review
|
||||
499
tasks/prd-bulk-upload-list-integration.md
Normal file
499
tasks/prd-bulk-upload-list-integration.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# PRD: Bulk Upload Integration in Receipts List
|
||||
|
||||
## 1. Introducere
|
||||
|
||||
Funcționalitatea actuală de bulk upload există într-o pagină separată (`BulkUploadView.vue`). Când un fișier dă eroare la procesare, utilizatorul nu îl mai regăsește - acesta "dispare" din UI. Această funcționalitate integrează bulk upload direct în lista principală de bonuri, permițând vizibilitatea completă a tuturor bonurilor: în curs de upload, în procesare, procesate cu succes și cu erori.
|
||||
|
||||
**Problema de rezolvat:** Bonurile cu erori din bulk upload se pierd și nu mai pot fi regăsite. Utilizatorul nu are vizibilitate asupra batch-ului după ce părăsește pagina de bulk upload.
|
||||
|
||||
**Soluția:** Integrare completă în lista de bonuri cu:
|
||||
- Row grouping vizual per batch
|
||||
- Coloană pentru batch ID și status procesare
|
||||
- Mesaje de eroare vizibile în listă
|
||||
- Drag & drop overlay pe toată pagina
|
||||
- Quick filter chips pentru statusuri de procesare
|
||||
|
||||
## 2. Obiective
|
||||
|
||||
### Obiectiv Principal
|
||||
- Vizibilitate completă a tuturor bonurilor din bulk upload direct în lista principală
|
||||
|
||||
### Obiective Secundare
|
||||
- Eliminarea paginii separate de bulk upload (consolidare UX)
|
||||
- Recuperarea bonurilor cu erori fără a le pierde
|
||||
- Persistență: bonurile rămân vizibile chiar și după refresh/revenire
|
||||
|
||||
### Metrici de Succes
|
||||
- 0 bonuri "pierdute" după erori OCR
|
||||
- 100% tracking vizibil pentru toate batch-urile
|
||||
- Timp de onboarding pentru bulk upload < 30 secunde
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
### US-001: Drag Anywhere pentru Upload
|
||||
**Ca** utilizator data-entry
|
||||
**Vreau** să pot trage fișiere oriunde pe pagina de bonuri
|
||||
**Pentru că** vreau un flow natural fără să caut zona specifică de upload
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Tragerea unui fișier oriunde pe pagina de bonuri activează un overlay fullscreen
|
||||
- [ ] Overlay-ul arată: icon upload + text "Eliberează pentru a încărca X fișiere"
|
||||
- [ ] La eliberare, fișierele se validează și se uploadează
|
||||
- [ ] Fișierele invalide se afișează în toast cu motivul
|
||||
- [ ] Overlay dispare dacă utilizatorul trage fișierele în afara paginii
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that overlay appears on drag
|
||||
|
||||
**Detalii tehnice:**
|
||||
- Global event listeners pe `window`: `dragenter`, `dragover`, `dragleave`, `drop`
|
||||
- Cleanup listeners în `onUnmounted` pentru a evita memory leaks
|
||||
- `e.preventDefault()` pe toate evenimentele pentru a preveni deschiderea fișierelor în browser
|
||||
|
||||
### US-002: Row Grouping per Batch în DataTable
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd bonurile din același batch grupate vizual
|
||||
**Pentru că** vreau să identific rapid care bonuri aparțin aceluiași upload
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Bonurile cu același `batch_id` apar grupate în tabel
|
||||
- [ ] Header de grup expandabil: "Batch B-001 • 12 Jan 2026 • 15 fișiere"
|
||||
- [ ] Click pe header expandează/colapsează grupul
|
||||
- [ ] Bonurile fără batch (create manual) apar în grupul "Alte bonuri"
|
||||
- [ ] Grupurile sunt sortate după timestamp (cel mai recent sus)
|
||||
- [ ] Grupul în procesare este automat expandat
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that groups expand/collapse correctly
|
||||
|
||||
**Detalii tehnice:**
|
||||
- PrimeVue DataTable cu `rowGroupMode="subheader"` și `groupRowsBy="batch_id"`
|
||||
- Custom header slot pentru styling
|
||||
- State local pentru expanded groups
|
||||
|
||||
### US-003: Coloană Status Batch în Tabel
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd statusul fiecărui bon din batch într-o coloană dedicată
|
||||
**Pentru că** vreau să știu rapid care bonuri au reușit și care au erori
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Coloană nouă "Procesare" după coloana "Status" existentă
|
||||
- [ ] Valori posibile:
|
||||
- `pending` - "În așteptare" (gri)
|
||||
- `processing` - "Se procesează..." + spinner (albastru)
|
||||
- `completed` - "✓ Procesat" (verde)
|
||||
- `failed` - "✗ Eroare" (roșu) cu expand pentru mesaj
|
||||
- [ ] Bonurile manuale (fără batch) arată "-" în această coloană
|
||||
- [ ] Click pe "✗ Eroare" deschide tooltip/popover cu mesajul complet
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that status updates in real-time
|
||||
|
||||
**CSS Design Tokens:**
|
||||
```css
|
||||
.processing-pending { background: var(--surface-hover); color: var(--text-color-secondary); }
|
||||
.processing-active { background: var(--blue-50); color: var(--blue-600); }
|
||||
.processing-success { background: var(--green-50); color: var(--green-600); }
|
||||
.processing-failed { background: var(--red-50); color: var(--red-600); }
|
||||
```
|
||||
|
||||
### US-004: Mesaj Eroare Vizibil în Listă
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd mesajul de eroare direct în listă (prescurtat)
|
||||
**Pentru că** vreau să înțeleg problema fără să deschid detalii
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Pentru bonuri cu `status=failed`, se afișează primele 50 caractere din mesaj
|
||||
- [ ] Mesajul e trunchiat cu "..." dacă depășește 50 caractere
|
||||
- [ ] Hover/click arată mesajul complet într-un tooltip
|
||||
- [ ] Mesajul e afișat sub rândul principal (inline expand) sau în popover
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that full error message is accessible
|
||||
|
||||
**Exemple de mesaje:**
|
||||
- "OCR failed: format nerecunoscut pentru bon"
|
||||
- "Duplicate: bon similar existent (ID: 123)"
|
||||
- "Validare: suma totală nu poate fi 0"
|
||||
|
||||
### US-005: Quick Filter Chips pentru Statusuri Procesare
|
||||
**Ca** utilizator
|
||||
**Vreau** filtre rapide pentru a vedea doar bonurile cu erori sau în procesare
|
||||
**Pentru că** vreau să mă concentrez pe ce necesită atenție
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Chips noi în rândul de statusuri existente:
|
||||
- "În procesare (3)" - bonuri cu processing_status='processing' sau 'pending'
|
||||
- "Cu erori (2)" - bonuri cu processing_status='failed'
|
||||
- [ ] Chips apar doar când există batch-uri active (count > 0)
|
||||
- [ ] Click pe chip filtrează lista
|
||||
- [ ] Chips sunt colorat conform statusului (albastru/roșu)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that filtering works correctly
|
||||
|
||||
### US-006: Retry Individual și Retry All Failed
|
||||
**Ca** utilizator
|
||||
**Vreau** să pot re-procesa bonurile cu erori
|
||||
**Pentru că** unele erori pot fi temporare sau vreau să încerc din nou
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Buton "Reîncercă" pe fiecare rând cu eroare
|
||||
- [ ] Buton "Reîncercă toate erorile" în header-ul grupului de batch
|
||||
- [ ] La retry, statusul revine la "pending" și se re-uploadează fișierul
|
||||
- [ ] Retry păstrează batch_id original
|
||||
- [ ] Dacă fișierul original nu mai există, se afișează eroare
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that retry updates status correctly
|
||||
|
||||
**Detalii tehnice:**
|
||||
- Fișierele originale trebuie păstrate în storage până la cleanup (7 zile)
|
||||
- Retry apelează același endpoint de upload cu job_id existent
|
||||
|
||||
### US-007: Reject Automat pentru Duplicate (File Hash)
|
||||
**Ca** sistem
|
||||
**Vreau** să detectez și să reject fișierele duplicate la upload
|
||||
**Pentru că** utilizatorul nu trebuie să proceseze același bon de două ori
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] La upload, se calculează SHA-256 hash al fișierului
|
||||
- [ ] Dacă hash-ul există deja în DB, upload-ul e respins
|
||||
- [ ] Mesaj: "Fișier duplicat - există deja ca bon #123"
|
||||
- [ ] Link direct către bonul existent
|
||||
- [ ] Verificarea se face înainte de a crea job-ul OCR
|
||||
- [ ] pytest tests pass
|
||||
- [ ] API returns correct error schema for duplicates
|
||||
|
||||
### US-008: Auto-Cleanup Erori După 7 Zile
|
||||
**Ca** sistem
|
||||
**Vreau** să șterg automat bonurile cu erori după 7 zile
|
||||
**Pentru că** vreau să păstrez lista curată fără intervenție manuală
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Background job zilnic verifică bonurile cu `processing_status='failed'`
|
||||
- [ ] Bonurile mai vechi de 7 zile se șterg automat
|
||||
- [ ] Fișierele atașate se șterg și ele
|
||||
- [ ] Notificare toast la login: "3 bonuri cu erori au fost șterse (expirate)"
|
||||
- [ ] Utilizatorul poate vedea/extinde TTL în setări (opțional, nice-to-have)
|
||||
- [ ] pytest tests pass
|
||||
|
||||
### US-009: Auto-Resume Polling la Refresh/Revenire
|
||||
**Ca** utilizator
|
||||
**Vreau** ca procesarea să continue și să văd statusul actualizat automat când revin
|
||||
**Pentru că** nu vreau să pierd progresul sau să fac acțiuni manuale
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Procesarea OCR pe backend CONTINUĂ indiferent de starea browser-ului
|
||||
- [ ] La refresh/revenire, frontend detectează batch-uri incomplete și reia polling automat
|
||||
- [ ] Starea se stochează în localStorage: `active_batch_ids`
|
||||
- [ ] La completare, se curăță din localStorage
|
||||
- [ ] Dacă utilizatorul revine după ce procesarea s-a terminat, vede statusul final corect
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that progress resumes after refresh
|
||||
|
||||
**Detalii tehnice:**
|
||||
- Backend worker pool procesează independent de frontend
|
||||
- La `onMounted`, verifică localStorage pentru batch-uri active
|
||||
- Query backend pentru status curent al fiecărui batch
|
||||
- Reia polling doar pentru batch-uri care încă nu sunt complete
|
||||
- Afișează toast: "Procesare în curs detectată, se actualizează..."
|
||||
|
||||
### US-010: Lock Row în Procesare (Read-Only)
|
||||
**Ca** utilizator
|
||||
**Vreau** ca bonurile în procesare să fie read-only
|
||||
**Pentru că** nu are sens să editez un bon care încă nu e complet
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Bonuri cu `processing_status='pending'` sau `'processing'` au butoanele dezactivate
|
||||
- [ ] Visual: row are opacity 0.7 sau border-left albastru
|
||||
- [ ] Tooltip pe butoane: "Bonul se procesează, vă rugăm așteptați"
|
||||
- [ ] După completare, row-ul devine interactiv normal
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that buttons are disabled during processing
|
||||
|
||||
### US-011: Backend - Stocare Batch și Processing Status
|
||||
**Ca** developer
|
||||
**Vreau** să extind schema Receipt pentru a stoca informații de batch
|
||||
**Pentru că** am nevoie de persistență pentru tracking
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Câmpuri noi în tabelul `receipts`:
|
||||
- `batch_id` (string, nullable) - UUID batch
|
||||
- `processing_status` (enum: pending/processing/completed/failed, nullable)
|
||||
- `processing_error` (text, nullable) - mesaj eroare complet
|
||||
- `file_hash` (string, nullable) - SHA-256 pentru duplicate detection
|
||||
- `processing_started_at` (datetime, nullable)
|
||||
- `processing_completed_at` (datetime, nullable)
|
||||
- [ ] Index pe `batch_id` pentru query-uri eficiente
|
||||
- [ ] Index pe `file_hash` pentru duplicate detection
|
||||
- [ ] Migration reversibilă
|
||||
- [ ] pytest tests pass
|
||||
- [ ] Alembic migration works
|
||||
|
||||
### US-012: Backend - Endpoint List cu Batch Info
|
||||
**Ca** developer
|
||||
**Vreau** să extind endpoint-ul GET /receipts pentru a include info de batch
|
||||
**Pentru că** frontend-ul are nevoie de toate datele într-un singur request
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Response include câmpurile noi pentru fiecare receipt
|
||||
- [ ] Filtrare pe `processing_status` funcționează
|
||||
- [ ] Filtrare pe `batch_id` funcționează
|
||||
- [ ] Sorting pe `processing_started_at` funcționează
|
||||
- [ ] Include count-uri pentru fiecare processing_status în response (pentru chips)
|
||||
- [ ] pytest tests pass
|
||||
|
||||
### US-013: Eliminare Pagină Separată Bulk Upload
|
||||
**Ca** developer
|
||||
**Vreau** să elimin pagina separată de bulk upload
|
||||
**Pentru că** funcționalitatea e acum integrată în lista principală
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Șterge `/data-entry/bulk-upload` route
|
||||
- [ ] Șterge `BulkUploadView.vue`
|
||||
- [ ] Redirect de la vechea rută la `/data-entry` (pentru bookmarks)
|
||||
- [ ] Actualizare meniu/navigație să nu mai arate link-ul separat
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser that old route redirects correctly
|
||||
|
||||
## 4. Cerințe Funcționale
|
||||
|
||||
1. [REQ-001] Drag & drop pe toată pagina de bonuri activează upload overlay
|
||||
2. [REQ-002] Bonurile din batch-uri sunt grupate vizual în tabel
|
||||
3. [REQ-003] Coloană dedicată pentru status procesare (pending/processing/success/failed)
|
||||
4. [REQ-004] Mesajele de eroare sunt vizibile direct în listă
|
||||
5. [REQ-005] Fișierele duplicate (același hash) sunt respinse automat
|
||||
6. [REQ-006] Retry disponibil per bon și per batch pentru erori
|
||||
7. [REQ-007] Bonurile cu erori se șterg automat după 7 zile
|
||||
8. [REQ-008] Starea procesării persistă la refresh
|
||||
9. [REQ-009] Bonurile în procesare sunt read-only (locked)
|
||||
10. [REQ-010] Procesarea folosește configurația existentă din .env pentru workers paraleli
|
||||
11. [REQ-011] Procesarea backend continuă independent de browser; polling se reia automat la revenire
|
||||
|
||||
## 5. Non-Goals (Ce NU facem)
|
||||
|
||||
- **NU** implementăm WebSocket pentru status updates (rămânem pe long-polling existent)
|
||||
- **NU** adăugăm suport pentru editare inline în listă
|
||||
- **NU** implementăm preview imagine în row (poate fi adăugat ulterior)
|
||||
- **NU** facem grouping recursiv (batch în batch)
|
||||
- **NU** implementăm undo pentru retry
|
||||
- **NU** adăugăm notificări push/email la completare batch
|
||||
- **NU** facem drag & drop pentru reordonare (doar upload)
|
||||
|
||||
## 6. Considerații Tehnice
|
||||
|
||||
### Schema DB Extinsă
|
||||
|
||||
```sql
|
||||
ALTER TABLE receipts ADD COLUMN batch_id VARCHAR(36);
|
||||
ALTER TABLE receipts ADD COLUMN processing_status VARCHAR(20)
|
||||
CHECK (processing_status IN ('pending', 'processing', 'completed', 'failed'));
|
||||
ALTER TABLE receipts ADD COLUMN processing_error TEXT;
|
||||
ALTER TABLE receipts ADD COLUMN file_hash VARCHAR(64);
|
||||
ALTER TABLE receipts ADD COLUMN processing_started_at TIMESTAMP;
|
||||
ALTER TABLE receipts ADD COLUMN processing_completed_at TIMESTAMP;
|
||||
|
||||
CREATE INDEX idx_receipts_batch_id ON receipts(batch_id);
|
||||
CREATE INDEX idx_receipts_file_hash ON receipts(file_hash);
|
||||
CREATE INDEX idx_receipts_processing_status ON receipts(processing_status);
|
||||
```
|
||||
|
||||
### Component Architecture
|
||||
|
||||
```
|
||||
ReceiptsListView.vue (enhanced)
|
||||
├── DragDropOverlay.vue (new - fullscreen overlay)
|
||||
├── BatchGroupHeader.vue (new - expandable group header)
|
||||
├── ProcessingStatusCell.vue (new - status + error display)
|
||||
├── QuickFilterChips.vue (enhanced - add processing filters)
|
||||
└── DataTable (PrimeVue with row grouping)
|
||||
```
|
||||
|
||||
### Store Changes
|
||||
|
||||
```javascript
|
||||
// receiptsStore.js - extended
|
||||
state: {
|
||||
// ... existing
|
||||
processingStats: {
|
||||
pending_count: 0,
|
||||
processing_count: 0,
|
||||
failed_count: 0
|
||||
},
|
||||
activeBatchIds: [] // for localStorage persistence
|
||||
}
|
||||
|
||||
// batchProgressStore.js - reuse existing
|
||||
// Just connect to receiptsStore for updates
|
||||
```
|
||||
|
||||
### ⚠️ REGULI CSS OBLIGATORII
|
||||
|
||||
**CITEȘTE ÎNTÂI:** `docs/ONBOARDING_CSS.md` și `docs/DESIGN_TOKENS.md`
|
||||
|
||||
#### Processing Status Colors
|
||||
| Status | Background | Text/Icon | Border |
|
||||
|--------|------------|-----------|--------|
|
||||
| Pending | `var(--surface-hover)` | `var(--text-color-secondary)` | - |
|
||||
| Processing | `var(--blue-50)` | `var(--blue-600)` | `var(--blue-500)` |
|
||||
| Success | `var(--green-50)` | `var(--green-600)` | - |
|
||||
| Failed | `var(--red-50)` | `var(--red-600)` | `var(--red-500)` |
|
||||
|
||||
#### Drag Overlay
|
||||
```css
|
||||
.drag-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.drag-overlay-content {
|
||||
background: var(--surface-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
text-align: center;
|
||||
border: 3px dashed var(--primary-500);
|
||||
}
|
||||
```
|
||||
|
||||
#### Row Grouping Header
|
||||
```css
|
||||
.batch-group-header {
|
||||
background: var(--surface-ground);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-weight: var(--font-semibold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.batch-group-header:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
```
|
||||
|
||||
### Dependențe
|
||||
|
||||
- Refolosește `batchProgressStore.js` existent
|
||||
- Refolosește componente din `components/bulk/` unde posibil
|
||||
- Extinde `receiptsStore.js` cu câmpuri noi
|
||||
|
||||
### Riscuri Tehnice
|
||||
|
||||
- **Row grouping performance:** Pentru liste mari (>500 bonuri), grouping poate fi lent
|
||||
- Mitigare: Virtualizare sau paginare strictă
|
||||
- **LocalStorage limits:** Dacă sunt multe batch-uri active
|
||||
- Mitigare: Stochează doar ID-uri, nu date complete
|
||||
- **Race condition la retry:** Dacă utilizatorul face retry în timp ce procesarea originală se termină
|
||||
- Mitigare: Check status înainte de retry, abort dacă deja completat
|
||||
|
||||
## 7. Considerații UI/UX
|
||||
|
||||
### Layout Update
|
||||
|
||||
```
|
||||
ReceiptsListView.vue
|
||||
├── PageHeader
|
||||
├── QuickFilterChips (enhanced: + Processing | Erori)
|
||||
├── FiltersRow (existing)
|
||||
├── DragDropOverlay (fullscreen, hidden until drag)
|
||||
└── DataTable
|
||||
├── BatchGroupHeader (expandable)
|
||||
│ └── Receipt rows (with new Processing column)
|
||||
├── BatchGroupHeader
|
||||
│ └── Receipt rows
|
||||
└── "Alte bonuri" group (receipts fără batch)
|
||||
```
|
||||
|
||||
### Stări UI
|
||||
|
||||
| Stare | Visual |
|
||||
|-------|--------|
|
||||
| **Idle** | Pagină normală, fără overlay |
|
||||
| **Dragging** | Overlay fullscreen cu drop zone |
|
||||
| **Uploading** | Toast progress + batch nou apare în listă |
|
||||
| **Processing** | Rows cu spinner, read-only |
|
||||
| **Complete** | Rows normale, toast success |
|
||||
| **Has Errors** | Rows roșii cu mesaj, buton retry |
|
||||
|
||||
### Mobile Considerations
|
||||
|
||||
- Drag & drop nu funcționează pe mobile - oferă buton "Upload" explicit
|
||||
- Row grouping se transformă în cards grupate
|
||||
- Error messages în accordion expandabil
|
||||
|
||||
## 8. Success Metrics
|
||||
|
||||
- **Zero bonuri pierdute:** 100% vizibilitate pentru toate fișierele uploadate
|
||||
- **Retry success rate:** > 50% dintre erorile retry-uite să reușească
|
||||
- **Auto-cleanup:** < 100 bonuri cu erori la orice moment (TTL 7 zile)
|
||||
- **User satisfaction:** Reducere support tickets pentru "nu găsesc bonul"
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
- [x] Comportament la refresh → **Backend continuă procesarea, frontend reia polling automat la revenire**
|
||||
- [x] Auto-cleanup după 7 zile → **Confirmat**
|
||||
- [x] Retry individual + batch → **Confirmat ambele**
|
||||
- [x] Row complet blocat în procesare → **Confirmat**
|
||||
- [x] Grupare vizuală per batch → **Confirmat, row grouping**
|
||||
- [x] Mesaj eroare prescurtat → **Confirmat, 50 caractere**
|
||||
- [x] Drag anywhere → **Confirmat, overlay fullscreen**
|
||||
- [x] Quick filter chips → **Confirmat**
|
||||
- [x] Reject duplicate (hash) → **Confirmat**
|
||||
- [x] Batch info permanent → **Confirmat, rămâne în DB**
|
||||
- [x] Workers paraleli → **Configurabil din .env, existent**
|
||||
|
||||
---
|
||||
|
||||
## 10. Dependențe între User Stories
|
||||
|
||||
```
|
||||
US-011 (DB Schema) ─────────────────────────────────────────┐
|
||||
↓ │
|
||||
US-012 (API List + Batch Info) ─────────────────────────────┤
|
||||
↓ │
|
||||
US-007 (Duplicate Detection) ←───────────────────────────────┤
|
||||
↓ │
|
||||
US-001 (Drag Anywhere) ─────────────────────────────────────┤
|
||||
↓ │
|
||||
US-002 (Row Grouping) ←──────────────────────────────────────┤
|
||||
↓ │
|
||||
US-003 (Processing Status Column) ←──────────────────────────┤
|
||||
↓ │
|
||||
US-004 (Error Message Display) ←─────────────────────────────┤
|
||||
↓ │
|
||||
US-005 (Quick Filter Chips) │
|
||||
↓ │
|
||||
US-010 (Lock Row Processing) ←───────────────────────────────┤
|
||||
↓ │
|
||||
US-006 (Retry Individual + All) │
|
||||
↓ │
|
||||
US-009 (Persistence at Refresh) │
|
||||
↓ │
|
||||
US-008 (Auto-Cleanup 7 Days) │
|
||||
↓ │
|
||||
US-013 (Remove Old Bulk Page) │
|
||||
```
|
||||
|
||||
**Ordine recomandată de implementare:**
|
||||
|
||||
1. **US-011:** Backend - Extindere schema DB
|
||||
2. **US-012:** Backend - Endpoint list cu batch info
|
||||
3. **US-007:** Backend - Duplicate detection (file hash)
|
||||
4. **US-001:** Frontend - Drag Anywhere Overlay
|
||||
5. **US-002:** Frontend - Row Grouping per Batch
|
||||
6. **US-003:** Frontend - Processing Status Column
|
||||
7. **US-004:** Frontend - Error Message Display
|
||||
8. **US-005:** Frontend - Quick Filter Chips
|
||||
9. **US-010:** Frontend - Lock Row în Procesare
|
||||
10. **US-006:** Frontend - Retry Individual + All
|
||||
11. **US-009:** Frontend - Persistence at Refresh
|
||||
12. **US-008:** Backend - Auto-Cleanup Job
|
||||
13. **US-013:** Cleanup - Remove Old Bulk Page
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-01-11
|
||||
**Author:** Claude Code
|
||||
**Status:** Draft - Pending Review
|
||||
**Predecessor:** `prd-bulk-receipt-upload.md` (implementat)
|
||||
279
tasks/prd-bulk-upload-realtime-progress.md
Normal file
279
tasks/prd-bulk-upload-realtime-progress.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# PRD: Bulk Upload - Progres Real-Time în Listă
|
||||
|
||||
## 1. Introducere
|
||||
|
||||
Sistemul actual de bulk upload afișează fișierele în lista de bonuri **doar după** ce procesarea OCR se finalizează. Utilizatorul nu poate vedea ce fișiere a încărcat și în ce stadiu se află fiecare. Această funcționalitate va afișa toate fișierele imediat după upload, direct în tabelul de bonuri, cu actualizare în timp real a statusului pe măsură ce sunt procesate.
|
||||
|
||||
### Context Dezvoltare
|
||||
|
||||
| Aspect | Valoare |
|
||||
|--------|---------|
|
||||
| **Branch** | `ralph/bulk-receipt-upload` (continuare branch existent) |
|
||||
| **Bază** | Sistemul bulk upload implementat anterior (US-001 → US-013) |
|
||||
| **NU crea branch nou** | Toate modificările se fac în branch-ul existent |
|
||||
|
||||
## 2. Obiective
|
||||
|
||||
### Obiectiv Principal
|
||||
- Vizibilitate completă a tuturor fișierelor încărcate, de la momentul upload-ului până la finalizare
|
||||
|
||||
### Obiective Secundare
|
||||
- Feedback vizual clar pentru progresul procesării
|
||||
- Posibilitatea de a anula fișiere individuale sau întreg batch-ul
|
||||
- Identificarea rapidă a fișierelor cu erori
|
||||
|
||||
### Metrici de Succes
|
||||
- Utilizatorul vede fișierele în tabel în <2 secunde de la upload
|
||||
- Status-ul se actualizează în timp real fără refresh manual
|
||||
- Rata de abandon a bulk upload scade (utilizatorii înțeleg ce se întâmplă)
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
### US-001: Afișare Fișiere Imediat După Upload
|
||||
**Ca** utilizator care încarcă bonuri în bulk
|
||||
**Vreau** să văd toate fișierele selectate în tabel imediat după upload
|
||||
**Pentru că** vreau să știu că fișierele mele au fost primite și sunt în procesare
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] După drag-drop sau selectare fișiere, rândurile apar în tabel în <2 secunde
|
||||
- [ ] Fiecare fișier are un rând propriu în tabelul de bonuri
|
||||
- [ ] Rândurile sunt grupate în batch-ul corespunzător (BatchGroupHeader)
|
||||
- [ ] Coloanele de date (Data, Sumă, Furnizor, Tip) afișează '-' pentru fișiere neprocesate
|
||||
- [ ] Coloana "Fișier" afișează numele fișierului original
|
||||
- [ ] Coloana "Status Procesare" afișează "În așteptare" (pending)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: upload 3 fișiere și confirmă că apar instant în tabel
|
||||
|
||||
### US-002: Actualizare Status în Timp Real
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd statusul fiecărui fișier actualizându-se automat
|
||||
**Pentru că** vreau să urmăresc progresul fără să dau refresh
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Statusul se schimbă de la "În așteptare" → "Se procesează..." → "Procesat" / "Eroare"
|
||||
- [ ] Tranziția status include animație fade subtilă (CSS transition 300ms)
|
||||
- [ ] Badge-ul "Se procesează..." include spinner animat
|
||||
- [ ] Când procesarea se termină cu succes, coloanele se populează cu date extrase (Data, Sumă, etc.)
|
||||
- [ ] Când procesarea eșuează, coloana Status afișează "Eroare" cu tooltip pentru mesajul de eroare
|
||||
- [ ] Rândul rămâne în aceeași poziție în grup (nu se mută)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: urmărește un fișier din pending → processing → completed
|
||||
|
||||
### US-003: Animație la Schimbarea Statusului
|
||||
**Ca** utilizator
|
||||
**Vreau** o indicație vizuală când un fișier își schimbă statusul
|
||||
**Pentru că** vreau să observ ușor progresul când am multe fișiere
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Badge-ul de status se schimbă cu CSS transition (opacity fade)
|
||||
- [ ] Când trece la "Procesat", rândul primește un highlight verde subtil pentru 2s
|
||||
- [ ] Când trece la "Eroare", rândul primește un highlight roșu subtil pentru 2s
|
||||
- [ ] Animațiile folosesc design tokens (`--green-50`, `--red-50`)
|
||||
- [ ] Animațiile nu sunt distragătoare (subtile, nu flashy)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: testează tranziția vizuală pentru success și error
|
||||
|
||||
### US-004: Cancel Fișier Individual
|
||||
**Ca** utilizator
|
||||
**Vreau** să pot anula procesarea unui singur fișier
|
||||
**Pentru că** poate am încărcat din greșeală un fișier
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Fișierele cu status "În așteptare" sau "Se procesează" au buton/icon Cancel (×)
|
||||
- [ ] Click pe Cancel afișează confirmare: "Anulezi procesarea pentru {filename}?"
|
||||
- [ ] După confirmare, fișierul este eliminat din coadă și dispare din tabel
|
||||
- [ ] Fișierele deja procesate NU au buton Cancel (sunt bonuri valide)
|
||||
- [ ] Fișierele cu eroare au buton Retry, nu Cancel
|
||||
- [ ] Cancel individual nu afectează alte fișiere din batch
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: anulează un fișier pending și confirmă că dispare
|
||||
|
||||
### US-005: Cancel Tot Batch-ul
|
||||
**Ca** utilizator
|
||||
**Vreau** să pot anula toate fișierele dintr-un batch
|
||||
**Pentru că** poate am încărcat batch-ul greșit
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] BatchGroupHeader pentru batch-uri în procesare are buton "Anulează tot"
|
||||
- [ ] Click pe "Anulează tot" afișează confirmare cu numărul de fișiere afectate
|
||||
- [ ] Confirmarea menționează că fișierele deja procesate rămân ca bonuri
|
||||
- [ ] După confirmare, toate fișierele pending/processing sunt eliminate
|
||||
- [ ] Fișierele deja procesate (completed) rămân în sistem ca bonuri valide
|
||||
- [ ] Dacă toate fișierele sunt completed/failed, butonul nu apare
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: anulează un batch și confirmă că doar pending/processing dispar
|
||||
|
||||
### US-006: Checkbox Disabled pentru Fișiere în Procesare
|
||||
**Ca** utilizator
|
||||
**Vreau** să nu pot selecta fișierele în procesare pentru acțiuni bulk
|
||||
**Pentru că** nu are sens să validez/șterg un fișier care nu e încă procesat
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Checkbox-ul este disabled pentru rânduri cu status pending/processing
|
||||
- [ ] Hover pe checkbox disabled afișează tooltip: "Fișierul se procesează"
|
||||
- [ ] "Select All" nu include fișierele în procesare
|
||||
- [ ] Acțiunile bulk (Validează, Șterge) funcționează doar pe fișiere completed/failed
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: încearcă să selectezi un fișier pending - checkbox disabled
|
||||
|
||||
### US-007: Backend - Endpoint pentru Cancel Job
|
||||
**Ca** sistem
|
||||
**Vreau** un endpoint API pentru anularea job-urilor de procesare
|
||||
**Pentru că** frontend-ul trebuie să poată cancela fișiere
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] POST /api/data-entry/bulk/cancel/{job_id} - anulează un job specific
|
||||
- [ ] POST /api/data-entry/bulk/cancel-batch/{batch_id} - anulează tot batch-ul (pending/processing)
|
||||
- [ ] Endpoint-urile returnează lista de job-uri anulate
|
||||
- [ ] Job-urile cu status completed/failed nu pot fi anulate (return 400)
|
||||
- [ ] Dacă receipt-ul a fost deja creat, nu se șterge (rămâne în sistem)
|
||||
- [ ] Endpoint-urile actualizează status în job_queue la 'cancelled'
|
||||
- [ ] npm run typecheck passes (Python)
|
||||
- [ ] Testează cu curl: cancel job pending returnează 200
|
||||
|
||||
### US-008: Frontend - Integrare Store pentru Cancel
|
||||
**Ca** dezvoltator
|
||||
**Vreau** acțiuni în store pentru cancel
|
||||
**Pentru că** componentele trebuie să poată apela cancel
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] batchProgressStore.cancelJob(jobId) - apelează endpoint cancel individual
|
||||
- [ ] batchProgressStore.cancelBatch(batchId) - apelează endpoint cancel batch
|
||||
- [ ] După cancel, job-ul dispare din Map-ul de jobs
|
||||
- [ ] După cancel batch, toate job-urile pending/processing dispar
|
||||
- [ ] Polling-ul continuă pentru job-urile rămase (completed)
|
||||
- [ ] Erori la cancel sunt afișate utilizatorului (toast)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: cancel + verifică că store se actualizează
|
||||
|
||||
### US-009: Afișare Fișiere în Tabel - Componenta Row
|
||||
**Ca** dezvoltator
|
||||
**Vreau** să pot randa un rând pentru un job neprocesar încă
|
||||
**Pentru că** trebuie să afișez fișierele înainte de a deveni Receipt-uri
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Tabelul poate randa atât Receipt-uri cât și BatchJob-uri (pending files)
|
||||
- [ ] Pentru BatchJob: afișează filename în coloana Fișier
|
||||
- [ ] Pentru BatchJob: coloanele Data, Sumă, Furnizor, Tip afișează '-'
|
||||
- [ ] Pentru BatchJob: ProcessingStatusCell afișează status corect
|
||||
- [ ] Pentru BatchJob: acțiunile din meniu sunt disabled (doar Cancel disponibil)
|
||||
- [ ] Stilul rândului indică vizual că e în procesare (ușor muted/opacity)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: rândul pentru job pending arată corect
|
||||
|
||||
### US-010: Sincronizare Job Status cu Receipt
|
||||
**Ca** sistem
|
||||
**Vreau** să asociez job-urile cu receipt-urile create
|
||||
**Pentru că** când OCR termină, rândul trebuie să se transforme în receipt
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Când OCR termină cu succes, job-ul primește receipt_id
|
||||
- [ ] Frontend-ul detectează receipt_id și înlocuiește rândul de job cu receipt-ul real
|
||||
- [ ] Coloanele se populează cu datele din receipt (Data, Sumă, Furnizor)
|
||||
- [ ] Tranziția e smooth - nu dispare/reapare rândul
|
||||
- [ ] Dacă OCR eșuează, job-ul rămâne cu status 'failed' și error_message
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: urmărește un fișier cum devine receipt cu date populate
|
||||
|
||||
## 4. Cerințe Funcționale
|
||||
|
||||
1. **[REQ-001]** Sistemul trebuie să creeze rânduri vizuale în tabel imediat după upload, înainte de procesare OCR
|
||||
2. **[REQ-002]** Fiecare fișier încărcat trebuie să aibă vizibil: nume fișier, status procesare, acțiune cancel
|
||||
3. **[REQ-003]** Status-ul trebuie să se actualizeze în timp real via long-polling existent
|
||||
4. **[REQ-004]** Utilizatorul poate anula fișiere individuale sau tot batch-ul
|
||||
5. **[REQ-005]** Anularea afectează doar fișierele pending/processing, nu cele deja procesate
|
||||
6. **[REQ-006]** Rândurile pentru fișiere neprocesate au coloane de date goale ('-')
|
||||
7. **[REQ-007]** Checkbox-urile sunt disabled pentru fișiere în procesare
|
||||
8. **[REQ-008]** Animațiile de tranziție folosesc design tokens CSS
|
||||
|
||||
## 5. Non-Goals (Ce NU facem)
|
||||
|
||||
- **NU** implementăm preview/thumbnail pentru fișiere - doar nume + status
|
||||
- **NU** implementăm progress bar procentual per fișier - doar status discret
|
||||
- **NU** implementăm reordonare/prioritizare a cozii de procesare
|
||||
- **NU** implementăm pause/resume pentru procesare
|
||||
- **NU** ștergem receipt-urile deja create când se anulează un batch
|
||||
- **NU** permitem editarea fișierelor în timpul procesării
|
||||
|
||||
## 6. Considerații Tehnice
|
||||
|
||||
### Git Branch
|
||||
- **Branch:** `ralph/bulk-receipt-upload` - **CONTINUĂ în acest branch**
|
||||
- **NU crea branch nou** - această funcționalitate extinde bulk upload-ul existent
|
||||
- Commit messages: `feat(bulk-upload-realtime): US-XXX - Descriere`
|
||||
|
||||
### Stack/Tehnologii
|
||||
- **Frontend:** Vue 3, Pinia stores, PrimeVue components
|
||||
- **Backend:** FastAPI, SQLite (data-entry module), job_queue table
|
||||
- **Polling:** Long-polling existent în batchProgressStore
|
||||
|
||||
### Patterns de Urmat
|
||||
- ProcessingStatusCell pentru afișare status (extinde pentru cancel)
|
||||
- BatchGroupHeader pentru grupare (adaugă buton cancel all)
|
||||
- batchProgressStore pentru state management (adaugă cancel actions)
|
||||
- Design tokens din `docs/DESIGN_TOKENS.md` pentru culori/spacing
|
||||
|
||||
### Dependențe
|
||||
- Sistemul de bulk upload existent (US-001 până la US-013 anterioare)
|
||||
- Long-polling pentru status updates
|
||||
- BatchJob model din backend
|
||||
|
||||
### Riscuri Tehnice
|
||||
- **Race condition:** Job poate termina între request cancel și procesare - handle gracefully
|
||||
- **Performance:** Multe rânduri în tabel cu polling - optimizează re-renders cu Vue keys
|
||||
- **UX:** Rânduri care "dispar" pot fi confuze - animație clear pentru cancel
|
||||
|
||||
## 7. Considerații UI/UX
|
||||
|
||||
### Layout și Flow
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ BatchGroupHeader: "Batch B-abc123" - 5 fișiere [Anulează tot] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ □ │ bon1.pdf │ - │ - │ - │ În așteptare │ [×] │
|
||||
│ □ │ bon2.pdf │ - │ - │ - │ Se procesează...│ [×] │
|
||||
│ ☑ │ bon3.pdf │ 15 Ian │ 125.50 │ LIDL │ ✓ Procesat │ │
|
||||
│ □ │ bon4.pdf │ - │ - │ - │ ✗ Eroare │ [↻] │
|
||||
│ ☑ │ bon5.pdf │ 15 Ian │ 89.00 │ Mega │ ✓ Procesat │ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Stări (loading, error, empty, success)
|
||||
- **Pending:** Badge gri "În așteptare", checkbox disabled, buton Cancel
|
||||
- **Processing:** Badge albastru cu spinner "Se procesează...", checkbox disabled, buton Cancel
|
||||
- **Completed:** Badge verde "✓ Procesat", checkbox enabled, fără Cancel
|
||||
- **Failed:** Badge roșu "✗ Eroare" cu tooltip, checkbox disabled, buton Retry
|
||||
- **Cancelled:** Rândul dispare cu animație fade-out
|
||||
|
||||
### Accesibilitate
|
||||
- Buton Cancel are aria-label descriptiv
|
||||
- Tooltip-urile de eroare sunt accesibile cu keyboard
|
||||
- Animațiile respectă `prefers-reduced-motion`
|
||||
|
||||
## 8. Success Metrics
|
||||
|
||||
- **Time to visibility:** Fișierele apar în tabel în <2s de la upload
|
||||
- **Real-time accuracy:** Status-ul reflectă starea reală în <1s de la schimbare
|
||||
- **Cancel success rate:** 100% din cancel-uri pentru pending jobs funcționează
|
||||
- **User satisfaction:** Utilizatorii înțeleg ce se întâmplă cu fișierele lor
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
- [ ] Dacă utilizatorul navighează away și revine, cum se restaurează rândurile pentru jobs pending? (Probabil din localStorage + API call)
|
||||
- [ ] Limită maximă de fișiere afișate simultan în tabel? (Performance consideration)
|
||||
- [ ] Notificare sonoră/vizuală când tot batch-ul e procesat?
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Ordine Implementare Recomandată
|
||||
|
||||
1. **US-007** - Backend endpoints pentru cancel (independent)
|
||||
2. **US-008** - Store actions pentru cancel (depinde de US-007)
|
||||
3. **US-009** - Componenta row pentru jobs (independent)
|
||||
4. **US-001** - Afișare fișiere în tabel (depinde de US-009)
|
||||
5. **US-002** - Actualizare status real-time (depinde de US-001)
|
||||
6. **US-010** - Sincronizare job→receipt (depinde de US-002)
|
||||
7. **US-003** - Animații tranziție (depinde de US-002)
|
||||
8. **US-004** - Cancel individual (depinde de US-008)
|
||||
9. **US-005** - Cancel batch (depinde de US-008)
|
||||
10. **US-006** - Checkbox disabled (depinde de US-009)
|
||||
424
tasks/prd-mobile-ux-improvements.md
Normal file
424
tasks/prd-mobile-ux-improvements.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# PRD: Mobile UX Improvements & Bulk Upload Bug Fixes
|
||||
|
||||
## 1. Introducere
|
||||
|
||||
Acest PRD adresează multiple probleme identificate în modulul de bulk upload bonuri și îmbunătățiri UX pentru interfața mobilă. Problemele includ refresh incorect al listei, pierderea bonurilor cu eroare, lipsa selecției multiple pe mobil, și nevoia unei interfețe mai native pentru Android.
|
||||
|
||||
**Branch de lucru:** `ralph/bulk-receipt-upload` (continuăm în același branch)
|
||||
|
||||
**Context:** PRD-ul anterior (prd-bulk-receipt-upload.md) a implementat funcționalitatea de bază, dar testarea în producție a evidențiat probleme de UX și bugs ce necesită rezolvare.
|
||||
|
||||
## 2. Obiective
|
||||
|
||||
### Obiectiv Principal
|
||||
- Corectarea comportamentului de refresh pentru a păstra bonurile cu eroare în listă și a menține ordinea
|
||||
|
||||
### Obiective Secundare
|
||||
- Implementarea selecției multiple pe mobil cu interfață în stil Android nativ
|
||||
- Afișarea numelui fișierului pentru toate bonurile (nu doar cele în procesare)
|
||||
- Rezolvarea bug-ului de refresh automat la selectarea fișierelor
|
||||
|
||||
### Metrici de Succes
|
||||
- Zero bonuri "pierdute" după procesare bulk
|
||||
- Timp de selecție multiple pe mobil < 3 secunde pentru 10 bonuri
|
||||
- User satisfaction score > 4/5 pentru interfața mobilă
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
### US-034: Fix - Refresh Individual vs Refresh Total
|
||||
**Ca** utilizator
|
||||
**Vreau** ca bonurile să se actualizeze individual fără să se reîncarce toată lista
|
||||
**Pentru că** vreau să văd progresul în timp real fără să pierd poziția în listă
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] SSE handler NU apelează `store.fetchReceipts()` când un receipt nu este în pagina curentă
|
||||
- [ ] În schimb, verifică dacă receipt-ul aparține unui batch activ și îl adaugă local
|
||||
- [ ] Ordinea bonurilor din batch rămâne stabilă (nu se reordonează)
|
||||
- [ ] Bonurile noi din procesare se inserează în poziția corectă
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: uploadează 5 bonuri, nu se reîncarcă pagina între procesări
|
||||
|
||||
**Technical Notes:**
|
||||
- Modificare în `handleSSEStatusChange` din `ReceiptsListView.vue:2397`
|
||||
- Când receipt nu e găsit: verifică dacă batch_id e în batchProgressStore, apoi fetch individual receipt și inserează local
|
||||
|
||||
### US-035: Fix - Bonuri cu Eroare Rămân în Listă
|
||||
**Ca** utilizator
|
||||
**Vreau** ca bonurile cu eroare de procesare să rămână vizibile în listă
|
||||
**Pentru că** vreau să le pot edita manual sau să le șterg
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Bonurile cu `processing_status='failed'` NU sunt eliminate din view după refresh
|
||||
- [ ] Eroarea de extragere dată/sumă afișează bonul cu status "Eroare" și buton "Editează"
|
||||
- [ ] Link rapid "Editează manual" duce la formularul de editare cu datele disponibile pre-populate
|
||||
- [ ] Bonurile failed au highlight vizual (roșu subtil) pentru identificare ușoară
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: bon cu eroare OCR rămâne în listă și poate fi editat
|
||||
|
||||
**Technical Notes:**
|
||||
- Bonurile cu eroare ar trebui să aibă `status='draft'` și `processing_status='failed'`
|
||||
- Nu se șterg la refresh, doar se actualizează in-place
|
||||
|
||||
### US-036: Afișare Nume Fișier pentru Toate Bonurile
|
||||
**Ca** utilizator
|
||||
**Vreau** să văd numele fișierului original pentru bonurile procesate
|
||||
**Pentru că** vreau să identific care bon corespunde cărui fișier uploadat
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Coloana "Fișier" afișează `original_filename` și pentru receipt-uri completate, nu doar pentru job-uri
|
||||
- [ ] Pe mobil, numele fișierului apare sub partenăr (font mic, gri)
|
||||
- [ ] Tooltip pe desktop arată numele complet dacă e trunchiat
|
||||
- [ ] Dacă receipt nu are `original_filename`, afișează "-"
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: bon procesat afișează numele fișierului original
|
||||
|
||||
**Technical Notes:**
|
||||
- Backend trebuie să populeze `original_filename` pe receipt din job-ul OCR
|
||||
- Verifică că auto-save service copiază filename din job la receipt
|
||||
|
||||
### US-037: Fix - Upload Nu Mai Face Refresh Automat
|
||||
**Ca** utilizator
|
||||
**Vreau** ca selectarea fișierelor să nu reîncarce pagina
|
||||
**Pentru că** pierd fișierele selectate înainte să apăs "Procesează"
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Selectarea fișierelor via input NU declanșează refresh/reload pagină
|
||||
- [ ] Fișierele selectate rămân în listă până la submit explicit
|
||||
- [ ] Comportament identic pe desktop și mobil (Chrome, Safari)
|
||||
- [ ] Nu există race condition între clonarea fișierelor și resetarea input-ului
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify on mobile: selectează 5 fișiere, toate rămân în listă
|
||||
|
||||
**Technical Notes:**
|
||||
- Bug probabil în `onBulkFileInputChange` sau în interacțiunea cu store
|
||||
- Verifică că nu există watchers care declanșează refresh la schimbări de state
|
||||
- Test specific pe Chrome Android - vezi gotcha din CLAUDE.md despre File API
|
||||
|
||||
### US-038: Mobile - Selecție Multiplă prin Long-Press
|
||||
**Ca** utilizator mobil
|
||||
**Vreau** să selectez bonuri prin apăsare lungă
|
||||
**Pentru că** vreau să pot șterge sau acționa asupra mai multor bonuri simultan
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Long-press (500ms) pe un card activează modul de selecție
|
||||
- [ ] Card-ul selectat primește checkmark și background diferit
|
||||
- [ ] După activare, tap simplu pe alte carduri le adaugă/elimină din selecție
|
||||
- [ ] Tap în afara cardurilor (pe background) dezactivează modul selecție
|
||||
- [ ] Header contextual arată numărul de elemente selectate
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify on mobile: long-press selectează, tap adaugă la selecție
|
||||
|
||||
**CSS Requirements:**
|
||||
- `.receipt-card.selected { background: var(--blue-50); border-color: var(--blue-500); }`
|
||||
- `.receipt-card .selection-check { position: absolute; top: 8px; right: 8px; }`
|
||||
- Testează dark mode: background selectat vizibil
|
||||
|
||||
### US-039: Mobile - Select All și Buton Ștergere
|
||||
**Ca** utilizator mobil
|
||||
**Vreau** să am butoane "Selectează tot" și "Șterge" în modul selecție
|
||||
**Pentru că** vreau să pot șterge rapid mai multe bonuri
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] În modul selecție, apare top bar contextual cu:
|
||||
- Numărul de selectate: "X selectate"
|
||||
- Buton "Selectează tot"
|
||||
- Buton X pentru a ieși din modul selecție
|
||||
- [ ] Apare bottom bar cu buton "Șterge" (roșu, icon coș)
|
||||
- [ ] "Selectează tot" selectează toate bonurile din pagina curentă
|
||||
- [ ] Buton "Șterge" afișează confirmare înainte de ștergere
|
||||
- [ ] După ștergere, modul selecție se dezactivează
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify on mobile: select all + delete funcționează
|
||||
|
||||
**CSS Requirements:**
|
||||
- Top bar: `position: sticky; top: 0; z-index: 100;`
|
||||
- Bottom bar: `position: fixed; bottom: 0; left: 0; right: 0;`
|
||||
- Animație slide-in pentru bars
|
||||
|
||||
### US-040: Mobile - Layout Android Nativ pentru Lista Bonuri
|
||||
**Ca** utilizator mobil
|
||||
**Vreau** o interfață similară cu aplicațiile Android native
|
||||
**Pentru că** vreau experiență familiară și intuitivă
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] **Top Bar** fixă cu:
|
||||
- Stânga: Hamburger menu / Back arrow
|
||||
- Centru: Titlu "Bonuri Fiscale"
|
||||
- Dreapta: Search icon, Filter icon, More options (3 dots)
|
||||
- [ ] **Filter Chips** sub top bar (orizontal scrollabil):
|
||||
- Toate, Ciorne, În așteptare, Validate, Respinse
|
||||
- Chip activ are background primary color
|
||||
- [ ] **Bottom Navigation** fixă cu 4 tab-uri:
|
||||
- Bonuri (icon receipt, activ)
|
||||
- Upload (icon cloud-upload)
|
||||
- Rapoarte (icon chart)
|
||||
- Setări (icon cog)
|
||||
- [ ] **FAB** (Floating Action Button) în colț dreapta jos:
|
||||
- Icon "+" pentru adăugare bon nou
|
||||
- Se ascunde când se face scroll în jos, apare când scroll în sus
|
||||
- [ ] Lista ocupă spațiul dintre top bar și bottom nav
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify on mobile: layout similar cu Gmail/WhatsApp
|
||||
|
||||
**CSS Requirements:**
|
||||
```css
|
||||
.mobile-top-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: var(--surface-card);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: var(--surface-card);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mobile-fab {
|
||||
position: fixed;
|
||||
bottom: 72px; /* above bottom nav */
|
||||
right: 16px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: var(--primary-500);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
```
|
||||
|
||||
### US-041: Mobile - Layout Android pentru Editare/Creare Bon
|
||||
**Ca** utilizator mobil
|
||||
**Vreau** interfață de editare bon similară cu compose email în Gmail
|
||||
**Pentru că** vreau butoane de acțiune accesibile și layout familiar
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] **Top Bar** cu:
|
||||
- Stânga: X (close) sau Back arrow
|
||||
- Centru: "Bon Nou" / "Editare Bon #123"
|
||||
- Dreapta: Attach icon, Save/Submit icon
|
||||
- [ ] **Content Area** scrollabilă cu form fields
|
||||
- [ ] **Bottom Action Bar** fixă cu butoane:
|
||||
- "Salvează Ciornă" (secondary)
|
||||
- "Trimite pentru Validare" (primary)
|
||||
- [ ] Form fields ocupă 100% width pe mobil
|
||||
- [ ] Keyboard-aware: bottom bar se mută deasupra tastaturii
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify on mobile: layout similar cu Gmail compose
|
||||
|
||||
**CSS Requirements:**
|
||||
```css
|
||||
.mobile-receipt-form {
|
||||
padding-bottom: 80px; /* space for bottom bar */
|
||||
}
|
||||
|
||||
.mobile-form-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface-card);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mobile-form-bottom-bar .p-button {
|
||||
flex: 1;
|
||||
}
|
||||
```
|
||||
|
||||
### US-042: Mobile - Layout Android pentru Vizualizare Bon
|
||||
**Ca** utilizator mobil
|
||||
**Vreau** interfață de vizualizare bon similară cu view email în Gmail
|
||||
**Pentru că** vreau acțiuni rapide și navigare ușoară
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] **Top Bar** cu:
|
||||
- Stânga: Back arrow
|
||||
- Dreapta: Edit icon, Delete icon, More options
|
||||
- [ ] **Content Area** cu detalii bon (read-only)
|
||||
- [ ] **Bottom Action Bar** cu butoane contextuale:
|
||||
- Pentru DRAFT: "Editează", "Trimite"
|
||||
- Pentru PENDING: "Validează", "Respinge" (dacă are permisiuni)
|
||||
- Pentru APPROVED: "Anulează validare" (dacă are permisiuni)
|
||||
- [ ] Swipe left/right pentru navigare între bonuri (optional, nice-to-have)
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify on mobile: acțiuni accesibile din bottom bar
|
||||
|
||||
### US-043: Păstrare Ordine Bonuri la Refresh
|
||||
**Ca** utilizator
|
||||
**Vreau** ca bonurile să rămână în ordinea în care au fost uploadate
|
||||
**Pentru că** vreau să urmăresc progresul fiecărui bon uploadat
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Bonurile din același batch păstrează ordinea de upload (nu se reordonează alfabetic sau by date)
|
||||
- [ ] Un bon finalizat rămâne în aceeași poziție vizuală, nu sare la sfârșit
|
||||
- [ ] SSE updates modifică status-ul in-place, fără a muta rândul
|
||||
- [ ] Refresh manual (pull-to-refresh) poate reordona, dar SSE updates nu
|
||||
- [ ] npm run typecheck passes
|
||||
- [ ] Verify in browser: bon procesat nu își schimbă poziția în listă
|
||||
|
||||
**Technical Notes:**
|
||||
- Lista trebuie sortată by `created_at DESC` sau by batch order, nu by last_modified
|
||||
- `updateReceiptInPlace` din store NU trebuie să reordoneze array-ul
|
||||
|
||||
## 4. Cerințe Funcționale
|
||||
|
||||
1. [REQ-011] Bonurile cu eroare OCR rămân vizibile în listă cu status "Eroare"
|
||||
2. [REQ-012] Refresh via SSE NU reîncarcă toată lista, doar actualizează individual
|
||||
3. [REQ-013] Numele fișierului original e afișat pentru toate bonurile
|
||||
4. [REQ-014] Selecția multiplă pe mobil funcționează via long-press
|
||||
5. [REQ-015] Interfața mobilă urmează design patterns Android Material
|
||||
6. [REQ-016] Bottom navigation are 4 tab-uri: Bonuri, Upload, Rapoarte, Setări
|
||||
7. [REQ-017] FAB pentru "Bon Nou" în colț dreapta jos pe mobil
|
||||
8. [REQ-018] Ordinea bonurilor rămâne stabilă în cadrul unui batch
|
||||
|
||||
## 5. Non-Goals (Ce NU facem)
|
||||
|
||||
- **NU** facem swipe gestures pentru acțiuni (delete/archive) - prea complex
|
||||
- **NU** facem animații complexe de tranziție între pagini
|
||||
- **NU** schimbăm layout-ul desktop - doar mobil
|
||||
- **NU** facem tab-uri funcționale pentru Rapoarte/Setări (placeholder pentru moment)
|
||||
- **NU** facem pull-to-refresh - refresh via SSE e suficient
|
||||
- **NU** facem offline mode sau caching local
|
||||
|
||||
## 6. Considerații Tehnice
|
||||
|
||||
### Stack/Tehnologii
|
||||
- **Frontend:** Vue 3 Composition API, PrimeVue
|
||||
- **Mobile Detection:** `window.innerWidth < 768` sau `navigator.userAgent`
|
||||
- **Touch Events:** Native `touchstart`/`touchend` pentru long-press
|
||||
|
||||
### Patterns de Urmat
|
||||
- Design tokens din `docs/DESIGN_TOKENS.md`
|
||||
- CSS patterns din `docs/CSS_PATTERNS.md`
|
||||
- Mobile-first responsive approach
|
||||
|
||||
### ⚠️ Mobile Long-Press Implementation
|
||||
```javascript
|
||||
// Long-press detection (500ms)
|
||||
let pressTimer = null
|
||||
|
||||
const onTouchStart = (item) => {
|
||||
pressTimer = setTimeout(() => {
|
||||
enterSelectionMode(item)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const onTouchEnd = () => {
|
||||
clearTimeout(pressTimer)
|
||||
}
|
||||
|
||||
const onTouchMove = () => {
|
||||
clearTimeout(pressTimer) // Cancel if user drags
|
||||
}
|
||||
```
|
||||
|
||||
### Dependențe
|
||||
- Store existent: `receiptsStore.js`
|
||||
- SSE Service: `sseService.js`
|
||||
- Batch Progress Store: `batchProgressStore.js`
|
||||
|
||||
### Riscuri Tehnice
|
||||
- **Touch event conflicts:** Long-press poate conflicta cu scroll pe mobile
|
||||
- Mitigare: Setează threshold mic pentru detectare scroll vs. hold
|
||||
- **Performance:** Lista mare poate încetini selecția
|
||||
- Mitigare: Virtual scrolling dacă > 100 items
|
||||
- **Cross-browser:** Safari iOS poate avea comportament diferit
|
||||
- Mitigare: Test explicit pe Safari iOS
|
||||
|
||||
## 7. Considerații UI/UX
|
||||
|
||||
### Mobile Layout Reference (din imagini)
|
||||
|
||||
**img1.jpg & img2.jpg (WhatsApp style):**
|
||||
- Top bar contextual cu count și acțiuni
|
||||
- Filter chips orizontal scrollabile
|
||||
- Bottom navigation cu 4 tabs
|
||||
- FAB în colț dreapta jos
|
||||
|
||||
**img3.jpg & img4.jpg (Gmail style):**
|
||||
- Top bar minimalist cu back + acțiuni
|
||||
- Bottom action bar cu butoane contextuale
|
||||
- Full-width content area
|
||||
|
||||
### Stări Mobile
|
||||
|
||||
| Stare | Top Bar | Content | Bottom |
|
||||
|-------|---------|---------|--------|
|
||||
| **Normal** | Title + Search + Filter | Lista carduri | Bottom Nav + FAB |
|
||||
| **Selection Mode** | "X selectate" + Select All + Close | Carduri cu checkmarks | Delete button |
|
||||
| **View Receipt** | Back + Edit + Delete | Detalii bon | Acțiuni contextuale |
|
||||
| **Edit Receipt** | Close + Attach + Save | Form fields | Save Draft + Submit |
|
||||
|
||||
### Accesibilitate
|
||||
- Touch targets minim 48x48px
|
||||
- Contrast suficient pentru badges și text secundar
|
||||
- Screen reader support pentru selection mode
|
||||
|
||||
## 8. Success Metrics
|
||||
|
||||
- **Bug Fixes:** 0 bonuri pierdute la procesare bulk
|
||||
- **Upload Success:** 0 refresh-uri neintenționate la selectare fișiere
|
||||
- **Selection Time:** < 2 secunde pentru selectare 5 bonuri
|
||||
- **User Feedback:** Score > 4/5 pentru "Interfața mobilă e intuitivă"
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
- [ ] FAB-ul trebuie să aibă sub-menu (speed dial) pentru "Bon Nou" + "Upload Bulk"?
|
||||
- [ ] Bottom navigation tab "Upload" deschide file picker direct sau navighează la pagină separată?
|
||||
- [ ] Swipe gestures pentru delete/archive - implementăm în versiunea inițială sau amânăm?
|
||||
- [ ] Animație de highlight când un bon termină procesarea - fade green sau pulse?
|
||||
|
||||
---
|
||||
|
||||
## 10. Dependențe între User Stories
|
||||
|
||||
```
|
||||
US-034 (Fix Refresh) ─┬─→ US-035 (Erori rămân) → US-036 (Filename)
|
||||
│
|
||||
US-037 (Fix Upload) ──┘
|
||||
|
||||
US-038 (Long-press) → US-039 (Select All + Delete)
|
||||
↓
|
||||
US-040 (Mobile List Layout) ←─┘
|
||||
↓
|
||||
US-041 (Mobile Edit Layout) → US-042 (Mobile View Layout)
|
||||
|
||||
US-043 (Ordine) - independent, poate fi făcut oricând
|
||||
```
|
||||
|
||||
**Ordine recomandată de implementare:**
|
||||
|
||||
### Faza 1: Bug Fixes (Critice)
|
||||
1. **US-037**: Fix Upload Nu Mai Face Refresh Automat
|
||||
2. **US-034**: Fix Refresh Individual vs Total
|
||||
3. **US-035**: Bonuri cu Eroare Rămân în Listă
|
||||
4. **US-043**: Păstrare Ordine Bonuri
|
||||
|
||||
### Faza 2: Mobile Enhancements
|
||||
5. **US-036**: Afișare Nume Fișier pentru Toate Bonurile
|
||||
6. **US-038**: Mobile - Selecție Multiplă prin Long-Press
|
||||
7. **US-039**: Mobile - Select All și Buton Ștergere
|
||||
|
||||
### Faza 3: Mobile Native Layout
|
||||
8. **US-040**: Mobile - Layout Android pentru Lista Bonuri
|
||||
9. **US-041**: Mobile - Layout Android pentru Editare/Creare
|
||||
10. **US-042**: Mobile - Layout Android pentru Vizualizare
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-01-12
|
||||
**Author:** Claude Code
|
||||
**Status:** Draft - Pending Review
|
||||
0
test_cancel_job.py
Normal file
0
test_cancel_job.py
Normal file
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test package
|
||||
1
tests/backend/__init__.py
Normal file
1
tests/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Backend tests
|
||||
1205
tests/backend/test_bulk_upload.py
Normal file
1205
tests/backend/test_bulk_upload.py
Normal file
File diff suppressed because it is too large
Load Diff
388
tests/backend/test_cleanup_service.py
Normal file
388
tests/backend/test_cleanup_service.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Tests for cleanup service (US-008: Auto-Cleanup Erori După 7 Zile).
|
||||
|
||||
US-008 Acceptance Criteria:
|
||||
- Funcție cleanup_expired_failed_receipts() în services/
|
||||
- Background job/task rulează la startup și apoi zilnic
|
||||
- Găsește receipts cu processing_status='failed' și processing_completed_at < now() - 7 days
|
||||
- Șterge receipts-urile găsite și fișierele atașate din storage
|
||||
- Loghează numărul de receipts șterse
|
||||
- Salvează count în cache/memory pentru notificare la login
|
||||
- pytest tests pass pentru cleanup logic
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from backend.modules.data_entry.services.cleanup_service import (
|
||||
cleanup_expired_failed_receipts,
|
||||
get_last_cleanup_stats,
|
||||
start_cleanup_task,
|
||||
stop_cleanup_task,
|
||||
is_cleanup_task_running,
|
||||
CLEANUP_RETENTION_DAYS,
|
||||
)
|
||||
from backend.modules.data_entry.db.models.receipt import (
|
||||
Receipt, ReceiptAttachment, ReceiptStatus, ReceiptDirection, ReceiptType
|
||||
)
|
||||
|
||||
|
||||
class MockAttachment:
|
||||
"""Mock ReceiptAttachment for testing."""
|
||||
def __init__(self, file_path: str):
|
||||
self.id = 1
|
||||
self.receipt_id = 1
|
||||
self.filename = "test.jpg"
|
||||
self.stored_filename = "uuid-test.jpg"
|
||||
self.file_path = file_path
|
||||
self.file_size = 1024
|
||||
self.mime_type = "image/jpeg"
|
||||
self.uploaded_at = datetime.utcnow()
|
||||
|
||||
|
||||
class MockReceipt:
|
||||
"""Mock Receipt for testing."""
|
||||
def __init__(
|
||||
self,
|
||||
receipt_id: int,
|
||||
processing_status: str = "failed",
|
||||
processing_completed_at: datetime = None,
|
||||
attachments: list = None
|
||||
):
|
||||
self.id = receipt_id
|
||||
self.receipt_type = ReceiptType.BON_FISCAL
|
||||
self.direction = ReceiptDirection.CHELTUIALA
|
||||
self.receipt_number = f"NR-{receipt_id}"
|
||||
self.receipt_series = "ABC"
|
||||
self.receipt_date = datetime(2026, 1, 10).date()
|
||||
self.amount = Decimal("100.50")
|
||||
self.description = f"Test receipt {receipt_id}"
|
||||
self.expense_type_code = "CONSUMABILE"
|
||||
self.company_id = 1
|
||||
self.partner_name = "Test Vendor"
|
||||
self.cui = "12345678"
|
||||
self.payment_mode = "casa"
|
||||
self.status = ReceiptStatus.DRAFT
|
||||
self.created_by = "test_user"
|
||||
self.created_at = datetime.utcnow()
|
||||
self.updated_at = datetime.utcnow()
|
||||
self.batch_id = "test-batch-123"
|
||||
self.processing_status = processing_status
|
||||
self.processing_error = "OCR failed" if processing_status == "failed" else None
|
||||
self.file_hash = "abc123hash"
|
||||
self.processing_started_at = datetime.utcnow() - timedelta(days=10)
|
||||
self.processing_completed_at = processing_completed_at
|
||||
self.attachments = attachments or []
|
||||
self.entries = []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests for cleanup_expired_failed_receipts
|
||||
# ============================================================================
|
||||
|
||||
class TestCleanupExpiredFailedReceipts:
|
||||
"""Test cleanup_expired_failed_receipts function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_expired_receipts(self):
|
||||
"""Test cleanup returns 0 when no expired receipts exist."""
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
count = await cleanup_expired_failed_receipts(mock_session)
|
||||
|
||||
assert count == 0
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finds_expired_failed_receipts(self):
|
||||
"""Test cleanup finds receipts older than 7 days with failed status."""
|
||||
# Create receipt with processing_completed_at 10 days ago
|
||||
old_date = datetime.utcnow() - timedelta(days=10)
|
||||
mock_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=old_date)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_receipt]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
count = await cleanup_expired_failed_receipts(mock_session)
|
||||
|
||||
assert count == 1
|
||||
mock_session.delete.assert_called_once_with(mock_receipt)
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_delete_recent_failed_receipts(self):
|
||||
"""Test cleanup does NOT delete receipts completed less than 7 days ago."""
|
||||
# Create receipt with processing_completed_at 3 days ago (should NOT be deleted)
|
||||
recent_date = datetime.utcnow() - timedelta(days=3)
|
||||
mock_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=recent_date)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
# Simulate that the query returns empty (no expired receipts found)
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
count = await cleanup_expired_failed_receipts(mock_session)
|
||||
|
||||
assert count == 0
|
||||
mock_session.delete.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deletes_attachment_files_from_disk(self):
|
||||
"""Test cleanup deletes attachment files from storage."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create test file
|
||||
test_subdir = Path(temp_dir) / "receipts" / "1"
|
||||
test_subdir.mkdir(parents=True)
|
||||
test_file = test_subdir / "test.jpg"
|
||||
test_file.write_bytes(b"fake image content")
|
||||
|
||||
# Create mock receipt with attachment pointing to the test file
|
||||
old_date = datetime.utcnow() - timedelta(days=10)
|
||||
attachment = MockAttachment(str(Path("receipts/1/test.jpg")))
|
||||
mock_receipt = MockReceipt(
|
||||
1,
|
||||
processing_status="failed",
|
||||
processing_completed_at=old_date,
|
||||
attachments=[attachment]
|
||||
)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_receipt]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path(temp_dir)
|
||||
|
||||
count = await cleanup_expired_failed_receipts(mock_session)
|
||||
|
||||
assert count == 1
|
||||
# Verify file was deleted
|
||||
assert not test_file.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_missing_attachment_files_gracefully(self):
|
||||
"""Test cleanup handles missing files without crashing."""
|
||||
old_date = datetime.utcnow() - timedelta(days=10)
|
||||
attachment = MockAttachment("nonexistent/path/file.jpg")
|
||||
mock_receipt = MockReceipt(
|
||||
1,
|
||||
processing_status="failed",
|
||||
processing_completed_at=old_date,
|
||||
attachments=[attachment]
|
||||
)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_receipt]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
# Should not raise exception
|
||||
count = await cleanup_expired_failed_receipts(mock_session)
|
||||
|
||||
assert count == 1
|
||||
mock_session.delete.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_cleanup_stats(self):
|
||||
"""Test cleanup updates stats for notification."""
|
||||
old_date = datetime.utcnow() - timedelta(days=10)
|
||||
mock_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=old_date)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_receipt]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
await cleanup_expired_failed_receipts(mock_session)
|
||||
|
||||
stats = get_last_cleanup_stats()
|
||||
assert stats["count"] == 1
|
||||
assert stats["timestamp"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deletes_multiple_receipts(self):
|
||||
"""Test cleanup deletes multiple expired receipts."""
|
||||
old_date = datetime.utcnow() - timedelta(days=10)
|
||||
mock_receipts = [
|
||||
MockReceipt(1, processing_status="failed", processing_completed_at=old_date),
|
||||
MockReceipt(2, processing_status="failed", processing_completed_at=old_date),
|
||||
MockReceipt(3, processing_status="failed", processing_completed_at=old_date),
|
||||
]
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = mock_receipts
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
count = await cleanup_expired_failed_receipts(mock_session)
|
||||
|
||||
assert count == 3
|
||||
assert mock_session.delete.call_count == 3
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for cleanup stats
|
||||
# ============================================================================
|
||||
|
||||
class TestCleanupStats:
|
||||
"""Test cleanup stats functionality."""
|
||||
|
||||
def test_get_last_cleanup_stats_returns_copy(self):
|
||||
"""Test that get_last_cleanup_stats returns a copy, not the original dict."""
|
||||
stats1 = get_last_cleanup_stats()
|
||||
stats2 = get_last_cleanup_stats()
|
||||
stats1["modified"] = True
|
||||
assert "modified" not in stats2
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for background task management
|
||||
# ============================================================================
|
||||
|
||||
class TestCleanupTaskManagement:
|
||||
"""Test background task start/stop functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_cleanup_task_returns_true(self):
|
||||
"""Test start_cleanup_task returns True on success."""
|
||||
async def mock_get_session():
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
mock_session.execute.return_value = mock_result
|
||||
yield mock_session
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
success = await start_cleanup_task(mock_get_session)
|
||||
|
||||
assert success is True
|
||||
assert is_cleanup_task_running() is True
|
||||
|
||||
# Cleanup
|
||||
await stop_cleanup_task()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_cleanup_task(self):
|
||||
"""Test stop_cleanup_task stops the running task."""
|
||||
async def mock_get_session():
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
mock_session.execute.return_value = mock_result
|
||||
yield mock_session
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
await start_cleanup_task(mock_get_session)
|
||||
assert is_cleanup_task_running() is True
|
||||
|
||||
await stop_cleanup_task()
|
||||
# Give time for task to be cancelled
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert is_cleanup_task_running() is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for retention days configuration
|
||||
# ============================================================================
|
||||
|
||||
class TestCleanupConfiguration:
|
||||
"""Test cleanup configuration constants."""
|
||||
|
||||
def test_retention_days_is_7(self):
|
||||
"""Verify retention period is 7 days as per US-008 requirements."""
|
||||
assert CLEANUP_RETENTION_DAYS == 7
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration-style tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCleanupQueryLogic:
|
||||
"""Test the query logic for finding expired receipts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_failed_status_is_deleted(self):
|
||||
"""Test that only receipts with processing_status='failed' are deleted."""
|
||||
# The query should filter by processing_status='failed'
|
||||
# We verify this by checking that receipts with other statuses are not returned
|
||||
|
||||
old_date = datetime.utcnow() - timedelta(days=10)
|
||||
|
||||
# Create receipts with different statuses
|
||||
failed_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=old_date)
|
||||
completed_receipt = MockReceipt(2, processing_status="completed", processing_completed_at=old_date)
|
||||
pending_receipt = MockReceipt(3, processing_status="pending", processing_completed_at=old_date)
|
||||
|
||||
# Only the failed receipt should be in the query results
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [failed_receipt] # Only failed
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
count = await cleanup_expired_failed_receipts(mock_session)
|
||||
|
||||
assert count == 1
|
||||
mock_session.delete.assert_called_once_with(failed_receipt)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receipts_without_processing_completed_at_are_not_deleted(self):
|
||||
"""Test that receipts with NULL processing_completed_at are not deleted."""
|
||||
# Receipts that never finished processing should not be deleted
|
||||
mock_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=None)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
# Query should return empty because processing_completed_at is NULL
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
|
||||
mock_settings.upload_path_resolved = Path("/tmp/uploads")
|
||||
|
||||
count = await cleanup_expired_failed_receipts(mock_session)
|
||||
|
||||
assert count == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
642
tests/backend/test_receipt_auto_create.py
Normal file
642
tests/backend/test_receipt_auto_create.py
Normal file
@@ -0,0 +1,642 @@
|
||||
"""
|
||||
Tests for ReceiptAutoCreateService (US-009).
|
||||
|
||||
US-009 Acceptance Criteria:
|
||||
- Clasa ReceiptAutoCreateService cu metoda create_from_ocr_result(job_id, ocr_result, user, batch_id)
|
||||
- Mapare completă OCR fields → Receipt fields (vezi ExtractionData schema)
|
||||
- Creare attachment cu fișierul original din job.file_path
|
||||
- Generare accounting entries via AccountingService.generate_entries() existent
|
||||
- Validare minimă: suma > 0, dată validă (nu în viitor)
|
||||
- Return ReceiptCreateResult cu receipt_id sau error_message
|
||||
- Actualizează batch_jobs cu receipt_id după creare
|
||||
- pytest tests pass
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from backend.modules.data_entry.services.receipt_auto_create import (
|
||||
ReceiptAutoCreateService,
|
||||
ReceiptCreateResult,
|
||||
)
|
||||
from backend.modules.data_entry.schemas.ocr import ExtractionData, TvaEntry, PaymentMethod
|
||||
from backend.modules.data_entry.db.models.receipt import ReceiptType, ReceiptDirection
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def valid_ocr_result():
|
||||
"""Create a valid OCR extraction result."""
|
||||
return ExtractionData(
|
||||
receipt_type="bon_fiscal",
|
||||
receipt_number="123456",
|
||||
receipt_series="ABC",
|
||||
receipt_date=date.today() - timedelta(days=1), # Yesterday
|
||||
amount=Decimal("125.50"),
|
||||
partner_name="MEGA IMAGE SRL",
|
||||
cui="12345678",
|
||||
description="Produse alimentare",
|
||||
tva_entries=[
|
||||
TvaEntry(code="A", percent=19, amount=Decimal("20.00")),
|
||||
TvaEntry(code="B", percent=9, amount=Decimal("5.50")),
|
||||
],
|
||||
tva_total=Decimal("25.50"),
|
||||
address="Str. Test Nr. 1, Bucuresti",
|
||||
items_count=5,
|
||||
payment_methods=[
|
||||
PaymentMethod(method="CARD", amount=Decimal("125.50")),
|
||||
],
|
||||
suggested_payment_mode="banca",
|
||||
confidence_amount=0.95,
|
||||
confidence_date=0.90,
|
||||
confidence_vendor=0.85,
|
||||
overall_confidence=0.90,
|
||||
raw_text="BON FISCAL\nMEGA IMAGE SRL\nTotal: 125.50 LEI",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_ocr_result():
|
||||
"""Create minimal valid OCR result with only required fields."""
|
||||
return ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("50.00"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""Create a mock async session."""
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_file():
|
||||
"""Create a temporary file for attachment testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
f.write(b"fake pdf content for testing")
|
||||
temp_path = f.name
|
||||
yield temp_path
|
||||
# Cleanup
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests - Validation
|
||||
# ============================================================================
|
||||
|
||||
class TestValidation:
|
||||
"""Test OCR result validation."""
|
||||
|
||||
def test_validate_valid_result(self, valid_ocr_result):
|
||||
"""Test validation passes for valid OCR result."""
|
||||
is_valid, error = ReceiptAutoCreateService._validate_ocr_result(valid_ocr_result)
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_validate_missing_amount(self):
|
||||
"""Test validation fails when amount is missing."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=None,
|
||||
)
|
||||
is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result)
|
||||
assert is_valid is False
|
||||
assert "amount" in error.lower()
|
||||
|
||||
def test_validate_zero_amount(self):
|
||||
"""Test validation fails when amount is zero."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("0"),
|
||||
)
|
||||
is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result)
|
||||
assert is_valid is False
|
||||
assert "amount" in error.lower()
|
||||
|
||||
def test_validate_negative_amount(self):
|
||||
"""Test validation fails when amount is negative."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("-10.00"),
|
||||
)
|
||||
is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result)
|
||||
assert is_valid is False
|
||||
assert "amount" in error.lower()
|
||||
|
||||
def test_validate_missing_date(self):
|
||||
"""Test validation fails when date is missing."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=None,
|
||||
amount=Decimal("100.00"),
|
||||
)
|
||||
is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result)
|
||||
assert is_valid is False
|
||||
assert "date" in error.lower()
|
||||
|
||||
def test_validate_future_date(self):
|
||||
"""Test validation fails when date is in the future."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today() + timedelta(days=1), # Tomorrow
|
||||
amount=Decimal("100.00"),
|
||||
)
|
||||
is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result)
|
||||
assert is_valid is False
|
||||
assert "future" in error.lower()
|
||||
|
||||
def test_validate_today_date_passes(self):
|
||||
"""Test validation passes when date is today."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("100.00"),
|
||||
)
|
||||
is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result)
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests - Field Mapping
|
||||
# ============================================================================
|
||||
|
||||
class TestFieldMapping:
|
||||
"""Test OCR to Receipt field mapping."""
|
||||
|
||||
def test_map_basic_fields(self, valid_ocr_result):
|
||||
"""Test basic field mapping from OCR to Receipt."""
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=valid_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert receipt_data.receipt_number == "123456"
|
||||
assert receipt_data.receipt_series == "ABC"
|
||||
assert receipt_data.receipt_date == valid_ocr_result.receipt_date
|
||||
assert receipt_data.amount == Decimal("125.50")
|
||||
assert receipt_data.partner_name == "MEGA IMAGE SRL"
|
||||
assert receipt_data.cui == "12345678"
|
||||
assert receipt_data.description == "Produse alimentare"
|
||||
assert receipt_data.company_id == 1
|
||||
|
||||
def test_map_receipt_type_bon_fiscal(self, valid_ocr_result):
|
||||
"""Test mapping bon_fiscal type correctly."""
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=valid_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
assert receipt_data.receipt_type == ReceiptType.BON_FISCAL
|
||||
|
||||
def test_map_receipt_type_chitanta(self, valid_ocr_result):
|
||||
"""Test mapping chitanta type correctly."""
|
||||
valid_ocr_result.receipt_type = "chitanta"
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=valid_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
assert receipt_data.receipt_type == ReceiptType.CHITANTA
|
||||
|
||||
def test_map_default_direction_is_expense(self, valid_ocr_result):
|
||||
"""Test default direction is expense (cheltuiala)."""
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=valid_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
assert receipt_data.direction == ReceiptDirection.CHELTUIALA
|
||||
|
||||
def test_map_tva_breakdown(self, valid_ocr_result):
|
||||
"""Test TVA breakdown mapping."""
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=valid_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert receipt_data.tva_breakdown is not None
|
||||
assert len(receipt_data.tva_breakdown) == 2
|
||||
assert receipt_data.tva_breakdown[0].code == "A"
|
||||
assert receipt_data.tva_breakdown[0].percent == 19
|
||||
assert receipt_data.tva_breakdown[0].amount == Decimal("20.00")
|
||||
assert receipt_data.tva_total == Decimal("25.50")
|
||||
|
||||
def test_map_payment_methods(self, valid_ocr_result):
|
||||
"""Test payment methods mapping."""
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=valid_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert receipt_data.payment_methods is not None
|
||||
assert len(receipt_data.payment_methods) == 1
|
||||
assert receipt_data.payment_methods[0].method == "CARD"
|
||||
assert receipt_data.payment_methods[0].amount == Decimal("125.50")
|
||||
assert receipt_data.payment_mode == "banca"
|
||||
|
||||
def test_map_vendor_address(self, valid_ocr_result):
|
||||
"""Test vendor address mapping."""
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=valid_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
assert receipt_data.vendor_address == "Str. Test Nr. 1, Bucuresti"
|
||||
|
||||
def test_map_items_count(self, valid_ocr_result):
|
||||
"""Test items count mapping."""
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=valid_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
assert receipt_data.items_count == 5
|
||||
|
||||
def test_map_raw_text_truncated(self, valid_ocr_result):
|
||||
"""Test raw text is truncated to 5000 chars."""
|
||||
valid_ocr_result.raw_text = "X" * 10000
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=valid_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
assert len(receipt_data.ocr_raw_text) == 5000
|
||||
|
||||
def test_map_minimal_result(self, minimal_ocr_result):
|
||||
"""Test mapping works with minimal required fields."""
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=minimal_ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert receipt_data.amount == Decimal("50.00")
|
||||
assert receipt_data.receipt_date == date.today()
|
||||
assert receipt_data.company_id == 1
|
||||
assert receipt_data.receipt_number is None
|
||||
assert receipt_data.partner_name is None
|
||||
assert receipt_data.tva_breakdown is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests - ReceiptCreateResult
|
||||
# ============================================================================
|
||||
|
||||
class TestReceiptCreateResult:
|
||||
"""Test ReceiptCreateResult dataclass."""
|
||||
|
||||
def test_success_result(self):
|
||||
"""Test successful result has receipt_id."""
|
||||
result = ReceiptCreateResult(success=True, receipt_id=42)
|
||||
assert result.success is True
|
||||
assert result.receipt_id == 42
|
||||
assert result.error_message is None
|
||||
|
||||
def test_failure_result(self):
|
||||
"""Test failure result has error_message."""
|
||||
result = ReceiptCreateResult(success=False, error_message="Validation failed")
|
||||
assert result.success is False
|
||||
assert result.receipt_id is None
|
||||
assert result.error_message == "Validation failed"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests with Mocked Dependencies
|
||||
# ============================================================================
|
||||
|
||||
class TestCreateFromOcrResult:
|
||||
"""Test create_from_ocr_result method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_creation(self, mock_session, valid_ocr_result, temp_file):
|
||||
"""Test successful receipt creation from OCR result."""
|
||||
# Mock ReceiptCRUD.create to return a mock receipt
|
||||
mock_receipt = MagicMock()
|
||||
mock_receipt.id = 1
|
||||
mock_receipt.amount = valid_ocr_result.amount
|
||||
mock_receipt.partner_name = valid_ocr_result.partner_name
|
||||
|
||||
with patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD"
|
||||
) as mock_crud, patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD"
|
||||
) as mock_entry_crud, patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.settings"
|
||||
) as mock_settings:
|
||||
|
||||
mock_crud.create = AsyncMock(return_value=mock_receipt)
|
||||
mock_entry_crud.create_bulk = AsyncMock()
|
||||
|
||||
# Mock settings for attachment path
|
||||
mock_settings.data_entry_upload_path_resolved = Path(tempfile.gettempdir())
|
||||
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=mock_session,
|
||||
job_id="test-job-123",
|
||||
ocr_result=valid_ocr_result,
|
||||
username="test_user",
|
||||
batch_id=1,
|
||||
company_id=1,
|
||||
file_path=temp_file,
|
||||
original_filename="bon.pdf",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.receipt_id == 1
|
||||
assert result.error_message is None
|
||||
mock_crud.create.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_failure_returns_error(self, mock_session):
|
||||
"""Test that validation failure returns error result."""
|
||||
invalid_ocr = ExtractionData(
|
||||
receipt_date=date.today() + timedelta(days=5), # Future date
|
||||
amount=Decimal("100.00"),
|
||||
)
|
||||
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=mock_session,
|
||||
job_id="test-job-123",
|
||||
ocr_result=invalid_ocr,
|
||||
username="test_user",
|
||||
batch_id=1,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_message is not None
|
||||
assert "future" in result.error_message.lower()
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_amount_returns_error(self, mock_session):
|
||||
"""Test that zero amount returns error result."""
|
||||
zero_amount_ocr = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("0"),
|
||||
)
|
||||
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=mock_session,
|
||||
job_id="test-job-123",
|
||||
ocr_result=zero_amount_ocr,
|
||||
username="test_user",
|
||||
batch_id=1,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "amount" in result.error_message.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_job_updated_with_receipt_id(self, mock_session, valid_ocr_result):
|
||||
"""Test that batch_jobs table is updated with receipt_id."""
|
||||
mock_receipt = MagicMock()
|
||||
mock_receipt.id = 42
|
||||
|
||||
with patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD"
|
||||
) as mock_crud, patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD"
|
||||
) as mock_entry_crud:
|
||||
|
||||
mock_crud.create = AsyncMock(return_value=mock_receipt)
|
||||
mock_entry_crud.create_bulk = AsyncMock()
|
||||
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=mock_session,
|
||||
job_id="test-job-456",
|
||||
ocr_result=valid_ocr_result,
|
||||
username="test_user",
|
||||
batch_id=1,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
# Verify execute was called (for update batch_jobs)
|
||||
mock_session.execute.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accounting_entries_generated(self, mock_session, valid_ocr_result):
|
||||
"""Test that accounting entries are generated for the receipt."""
|
||||
mock_receipt = MagicMock()
|
||||
mock_receipt.id = 1
|
||||
mock_receipt.amount = valid_ocr_result.amount
|
||||
mock_receipt.direction = ReceiptDirection.CHELTUIALA
|
||||
mock_receipt.expense_type_code = None
|
||||
mock_receipt.payment_mode = "banca"
|
||||
mock_receipt.cash_register_account = None
|
||||
|
||||
with patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD"
|
||||
) as mock_crud, patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD"
|
||||
) as mock_entry_crud, patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.ReceiptService"
|
||||
) as mock_service:
|
||||
|
||||
mock_crud.create = AsyncMock(return_value=mock_receipt)
|
||||
mock_entry_crud.create_bulk = AsyncMock()
|
||||
mock_service.generate_accounting_entries = MagicMock(return_value=[
|
||||
MagicMock(), # Mock entries
|
||||
])
|
||||
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=mock_session,
|
||||
job_id="test-job-789",
|
||||
ocr_result=valid_ocr_result,
|
||||
username="test_user",
|
||||
batch_id=1,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
mock_service.generate_accounting_entries.assert_called_once_with(mock_receipt)
|
||||
mock_entry_crud.create_bulk.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_triggers_rollback(self, mock_session, valid_ocr_result):
|
||||
"""Test that exception during creation triggers rollback."""
|
||||
with patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD"
|
||||
) as mock_crud:
|
||||
|
||||
mock_crud.create = AsyncMock(side_effect=Exception("Database error"))
|
||||
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=mock_session,
|
||||
job_id="test-job-err",
|
||||
ocr_result=valid_ocr_result,
|
||||
username="test_user",
|
||||
batch_id=1,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "Database error" in result.error_message
|
||||
mock_session.rollback.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_creation_failure_doesnt_fail_receipt(
|
||||
self, mock_session, valid_ocr_result
|
||||
):
|
||||
"""Test that attachment creation failure doesn't fail receipt creation."""
|
||||
mock_receipt = MagicMock()
|
||||
mock_receipt.id = 1
|
||||
|
||||
with patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD"
|
||||
) as mock_crud, patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD"
|
||||
) as mock_entry_crud:
|
||||
|
||||
mock_crud.create = AsyncMock(return_value=mock_receipt)
|
||||
mock_entry_crud.create_bulk = AsyncMock()
|
||||
|
||||
# Pass a non-existent file path
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=mock_session,
|
||||
job_id="test-job-attach",
|
||||
ocr_result=valid_ocr_result,
|
||||
username="test_user",
|
||||
batch_id=1,
|
||||
company_id=1,
|
||||
file_path="/nonexistent/path/file.pdf",
|
||||
)
|
||||
|
||||
# Receipt should still be created successfully
|
||||
assert result.success is True
|
||||
assert result.receipt_id == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_generation_failure_doesnt_fail_receipt(
|
||||
self, mock_session, valid_ocr_result
|
||||
):
|
||||
"""Test that entry generation failure doesn't fail receipt creation."""
|
||||
mock_receipt = MagicMock()
|
||||
mock_receipt.id = 1
|
||||
|
||||
with patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD"
|
||||
) as mock_crud, patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.ReceiptService"
|
||||
) as mock_service:
|
||||
|
||||
mock_crud.create = AsyncMock(return_value=mock_receipt)
|
||||
mock_service.generate_accounting_entries = MagicMock(
|
||||
side_effect=Exception("Entry generation failed")
|
||||
)
|
||||
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=mock_session,
|
||||
job_id="test-job-entry-err",
|
||||
ocr_result=valid_ocr_result,
|
||||
username="test_user",
|
||||
batch_id=1,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
# Receipt should still be created successfully
|
||||
assert result.success is True
|
||||
assert result.receipt_id == 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Edge Cases
|
||||
# ============================================================================
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and boundary conditions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimal_valid_ocr_creates_receipt(self, mock_session, minimal_ocr_result):
|
||||
"""Test that minimal valid OCR result creates receipt."""
|
||||
mock_receipt = MagicMock()
|
||||
mock_receipt.id = 1
|
||||
|
||||
with patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD"
|
||||
) as mock_crud, patch(
|
||||
"backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD"
|
||||
):
|
||||
mock_crud.create = AsyncMock(return_value=mock_receipt)
|
||||
|
||||
result = await ReceiptAutoCreateService.create_from_ocr_result(
|
||||
session=mock_session,
|
||||
job_id="minimal-job",
|
||||
ocr_result=minimal_ocr_result,
|
||||
username="test_user",
|
||||
batch_id=1,
|
||||
company_id=1,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
def test_empty_tva_entries_maps_to_none(self):
|
||||
"""Test that empty TVA entries list maps to None."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("100.00"),
|
||||
tva_entries=[],
|
||||
)
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
assert receipt_data.tva_breakdown is None
|
||||
|
||||
def test_empty_payment_methods_maps_to_none(self):
|
||||
"""Test that empty payment methods list maps to None."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("100.00"),
|
||||
payment_methods=[],
|
||||
)
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
assert receipt_data.payment_methods is None
|
||||
|
||||
def test_none_raw_text_handled(self):
|
||||
"""Test that None raw text is handled correctly."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("100.00"),
|
||||
raw_text="",
|
||||
)
|
||||
receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt(
|
||||
ocr_result=ocr_result,
|
||||
company_id=1,
|
||||
)
|
||||
assert receipt_data.ocr_raw_text is None
|
||||
|
||||
def test_very_small_amount_valid(self):
|
||||
"""Test that very small positive amount is valid."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("0.01"),
|
||||
)
|
||||
is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result)
|
||||
assert is_valid is True
|
||||
|
||||
def test_very_large_amount_valid(self):
|
||||
"""Test that very large amount is valid."""
|
||||
ocr_result = ExtractionData(
|
||||
receipt_date=date.today(),
|
||||
amount=Decimal("999999999.99"),
|
||||
)
|
||||
is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result)
|
||||
assert is_valid is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
545
tests/backend/test_receipt_list_batch_info.py
Normal file
545
tests/backend/test_receipt_list_batch_info.py
Normal file
@@ -0,0 +1,545 @@
|
||||
"""
|
||||
Tests for receipt list endpoint with batch info (US-012).
|
||||
|
||||
US-012 Acceptance Criteria:
|
||||
- Response includes new fields for each receipt: batch_id, processing_status,
|
||||
processing_error, file_hash, processing_started_at, processing_completed_at
|
||||
- Filtering on processing_status works (?processing_status=failed)
|
||||
- Filtering on batch_id works (?batch_id=uuid)
|
||||
- Sorting on processing_started_at works
|
||||
- Response includes processing_stats with counts: {pending_count, processing_count,
|
||||
completed_count, failed_count}
|
||||
- pytest tests pass for all new filters
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from backend.modules.data_entry.routers.receipts import router
|
||||
from backend.modules.data_entry.db.models.receipt import (
|
||||
Receipt, ReceiptStatus, ReceiptDirection, ReceiptType
|
||||
)
|
||||
from backend.modules.data_entry.schemas.receipt import (
|
||||
ReceiptResponse, ReceiptListResponse, ProcessingStats, ReceiptFilter
|
||||
)
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from backend.modules.data_entry.db.database import get_session
|
||||
|
||||
|
||||
# Mock user for authentication
|
||||
class MockCurrentUser:
|
||||
username = "test_user"
|
||||
user_id = 1
|
||||
companies = ["1"]
|
||||
permissions = ["data_entry"]
|
||||
|
||||
|
||||
def create_mock_receipt(
|
||||
receipt_id: int,
|
||||
batch_id: str = None,
|
||||
processing_status: str = None,
|
||||
processing_error: str = None,
|
||||
file_hash: str = None,
|
||||
processing_started_at: datetime = None,
|
||||
processing_completed_at: datetime = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock Receipt object with batch fields."""
|
||||
receipt = MagicMock(spec=Receipt)
|
||||
receipt.id = receipt_id
|
||||
receipt.receipt_type = ReceiptType.BON_FISCAL
|
||||
receipt.direction = ReceiptDirection.CHELTUIALA
|
||||
receipt.receipt_number = f"NR-{receipt_id}"
|
||||
receipt.receipt_series = "ABC"
|
||||
receipt.receipt_date = datetime(2026, 1, 10).date()
|
||||
receipt.amount = Decimal("100.50")
|
||||
receipt.description = f"Test receipt {receipt_id}"
|
||||
receipt.tva_breakdown = None
|
||||
receipt.tva_total = None
|
||||
receipt.items_count = None
|
||||
receipt.vendor_address = None
|
||||
receipt.expense_type_code = "CONSUMABILE"
|
||||
receipt.company_id = 1
|
||||
receipt.partner_name = "Test Vendor"
|
||||
receipt.cui = "12345678"
|
||||
receipt.ocr_raw_text = None
|
||||
receipt.payment_methods = None
|
||||
receipt.cash_register_id = None
|
||||
receipt.cash_register_name = None
|
||||
receipt.cash_register_account = None
|
||||
receipt.payment_mode = "casa"
|
||||
receipt.status = ReceiptStatus.DRAFT
|
||||
receipt.created_by = "test_user"
|
||||
receipt.created_at = datetime.utcnow()
|
||||
receipt.updated_at = datetime.utcnow()
|
||||
receipt.submitted_at = None
|
||||
receipt.reviewed_by = None
|
||||
receipt.reviewed_at = None
|
||||
receipt.rejection_reason = None
|
||||
receipt.oracle_synced_at = None
|
||||
receipt.oracle_act_id = None
|
||||
receipt.oracle_error = None
|
||||
# Batch fields (US-012)
|
||||
receipt.batch_id = batch_id
|
||||
receipt.processing_status = processing_status
|
||||
receipt.processing_error = processing_error
|
||||
receipt.file_hash = file_hash
|
||||
receipt.processing_started_at = processing_started_at
|
||||
receipt.processing_completed_at = processing_completed_at
|
||||
# Relationships
|
||||
receipt.attachments = []
|
||||
receipt.entries = []
|
||||
return receipt
|
||||
|
||||
|
||||
def create_test_app(mock_session=None, mock_user=None):
|
||||
"""Create a test FastAPI app with the receipts router and dependency overrides."""
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/data-entry/receipts")
|
||||
|
||||
if mock_user is not None:
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
|
||||
if mock_session is not None:
|
||||
async def override_session():
|
||||
yield mock_session
|
||||
app.dependency_overrides[get_session] = override_session
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_current_user():
|
||||
return MockCurrentUser()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""Create a mock async session."""
|
||||
session = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for Response Schema with Batch Fields
|
||||
# ============================================================================
|
||||
|
||||
class TestReceiptResponseBatchFields:
|
||||
"""Test that ReceiptResponse includes batch fields."""
|
||||
|
||||
def test_response_includes_batch_id(self):
|
||||
"""Verify ReceiptResponse includes batch_id field."""
|
||||
receipt = create_mock_receipt(1, batch_id="test-batch-uuid")
|
||||
response = ReceiptResponse.model_validate(receipt)
|
||||
assert response.batch_id == "test-batch-uuid"
|
||||
|
||||
def test_response_includes_processing_status(self):
|
||||
"""Verify ReceiptResponse includes processing_status field."""
|
||||
receipt = create_mock_receipt(1, processing_status="completed")
|
||||
response = ReceiptResponse.model_validate(receipt)
|
||||
assert response.processing_status == "completed"
|
||||
|
||||
def test_response_includes_processing_error(self):
|
||||
"""Verify ReceiptResponse includes processing_error field."""
|
||||
receipt = create_mock_receipt(1, processing_error="OCR failed: invalid image")
|
||||
response = ReceiptResponse.model_validate(receipt)
|
||||
assert response.processing_error == "OCR failed: invalid image"
|
||||
|
||||
def test_response_includes_file_hash(self):
|
||||
"""Verify ReceiptResponse includes file_hash field."""
|
||||
receipt = create_mock_receipt(1, file_hash="abc123hash")
|
||||
response = ReceiptResponse.model_validate(receipt)
|
||||
assert response.file_hash == "abc123hash"
|
||||
|
||||
def test_response_includes_processing_started_at(self):
|
||||
"""Verify ReceiptResponse includes processing_started_at field."""
|
||||
started_at = datetime(2026, 1, 10, 10, 30, 0)
|
||||
receipt = create_mock_receipt(1, processing_started_at=started_at)
|
||||
response = ReceiptResponse.model_validate(receipt)
|
||||
assert response.processing_started_at == started_at
|
||||
|
||||
def test_response_includes_processing_completed_at(self):
|
||||
"""Verify ReceiptResponse includes processing_completed_at field."""
|
||||
completed_at = datetime(2026, 1, 10, 10, 35, 0)
|
||||
receipt = create_mock_receipt(1, processing_completed_at=completed_at)
|
||||
response = ReceiptResponse.model_validate(receipt)
|
||||
assert response.processing_completed_at == completed_at
|
||||
|
||||
def test_response_batch_fields_are_optional(self):
|
||||
"""Verify batch fields can be None."""
|
||||
receipt = create_mock_receipt(1)
|
||||
response = ReceiptResponse.model_validate(receipt)
|
||||
assert response.batch_id is None
|
||||
assert response.processing_status is None
|
||||
assert response.processing_error is None
|
||||
assert response.file_hash is None
|
||||
assert response.processing_started_at is None
|
||||
assert response.processing_completed_at is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for ProcessingStats Schema
|
||||
# ============================================================================
|
||||
|
||||
class TestProcessingStatsSchema:
|
||||
"""Test ProcessingStats schema."""
|
||||
|
||||
def test_processing_stats_structure(self):
|
||||
"""Verify ProcessingStats has correct structure."""
|
||||
stats = ProcessingStats(
|
||||
pending_count=5,
|
||||
processing_count=2,
|
||||
completed_count=10,
|
||||
failed_count=1
|
||||
)
|
||||
assert stats.pending_count == 5
|
||||
assert stats.processing_count == 2
|
||||
assert stats.completed_count == 10
|
||||
assert stats.failed_count == 1
|
||||
|
||||
def test_processing_stats_defaults_to_zero(self):
|
||||
"""Verify ProcessingStats defaults all counts to 0."""
|
||||
stats = ProcessingStats()
|
||||
assert stats.pending_count == 0
|
||||
assert stats.processing_count == 0
|
||||
assert stats.completed_count == 0
|
||||
assert stats.failed_count == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for ReceiptListResponse with processing_stats
|
||||
# ============================================================================
|
||||
|
||||
class TestReceiptListResponseWithStats:
|
||||
"""Test ReceiptListResponse includes processing_stats."""
|
||||
|
||||
def test_list_response_includes_processing_stats(self):
|
||||
"""Verify ReceiptListResponse includes processing_stats field."""
|
||||
receipt = create_mock_receipt(1)
|
||||
stats = ProcessingStats(pending_count=1, processing_count=0, completed_count=5, failed_count=2)
|
||||
response = ReceiptListResponse(
|
||||
items=[ReceiptResponse.model_validate(receipt)],
|
||||
total=1,
|
||||
page=1,
|
||||
page_size=20,
|
||||
pages=1,
|
||||
processing_stats=stats
|
||||
)
|
||||
assert response.processing_stats is not None
|
||||
assert response.processing_stats.pending_count == 1
|
||||
assert response.processing_stats.completed_count == 5
|
||||
assert response.processing_stats.failed_count == 2
|
||||
|
||||
def test_list_response_processing_stats_is_optional(self):
|
||||
"""Verify processing_stats can be None."""
|
||||
receipt = create_mock_receipt(1)
|
||||
response = ReceiptListResponse(
|
||||
items=[ReceiptResponse.model_validate(receipt)],
|
||||
total=1,
|
||||
page=1,
|
||||
page_size=20,
|
||||
pages=1,
|
||||
)
|
||||
assert response.processing_stats is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for ReceiptFilter with new fields
|
||||
# ============================================================================
|
||||
|
||||
class TestReceiptFilterWithBatchFields:
|
||||
"""Test ReceiptFilter includes batch filtering fields."""
|
||||
|
||||
def test_filter_includes_processing_status(self):
|
||||
"""Verify ReceiptFilter includes processing_status field."""
|
||||
filter_obj = ReceiptFilter(processing_status="failed")
|
||||
assert filter_obj.processing_status == "failed"
|
||||
|
||||
def test_filter_includes_batch_id(self):
|
||||
"""Verify ReceiptFilter includes batch_id field."""
|
||||
filter_obj = ReceiptFilter(batch_id="test-batch-uuid")
|
||||
assert filter_obj.batch_id == "test-batch-uuid"
|
||||
|
||||
def test_filter_includes_sort_by(self):
|
||||
"""Verify ReceiptFilter includes sort_by field."""
|
||||
filter_obj = ReceiptFilter(sort_by="processing_started_at")
|
||||
assert filter_obj.sort_by == "processing_started_at"
|
||||
|
||||
def test_filter_new_fields_are_optional(self):
|
||||
"""Verify new filter fields default to None."""
|
||||
filter_obj = ReceiptFilter()
|
||||
assert filter_obj.processing_status is None
|
||||
assert filter_obj.batch_id is None
|
||||
assert filter_obj.sort_by is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests for List Endpoint with Filters
|
||||
# ============================================================================
|
||||
|
||||
class TestListReceiptsEndpointWithFilters:
|
||||
"""Test GET /api/data-entry/receipts endpoint with new filters."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_receipts_returns_batch_fields(self, mock_session, mock_current_user):
|
||||
"""Test that list endpoint returns batch fields in response."""
|
||||
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
|
||||
|
||||
receipt = create_mock_receipt(
|
||||
1,
|
||||
batch_id="batch-123",
|
||||
processing_status="completed",
|
||||
file_hash="hash123",
|
||||
processing_started_at=datetime(2026, 1, 10, 10, 0, 0),
|
||||
processing_completed_at=datetime(2026, 1, 10, 10, 5, 0)
|
||||
)
|
||||
|
||||
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
|
||||
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
|
||||
mock_crud.get_processing_stats = AsyncMock(return_value={
|
||||
"pending_count": 0,
|
||||
"processing_count": 0,
|
||||
"completed_count": 1,
|
||||
"failed_count": 0
|
||||
})
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/data-entry/receipts/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
item = data["items"][0]
|
||||
assert item["batch_id"] == "batch-123"
|
||||
assert item["processing_status"] == "completed"
|
||||
assert item["file_hash"] == "hash123"
|
||||
assert "processing_started_at" in item
|
||||
assert "processing_completed_at" in item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_receipts_includes_processing_stats(self, mock_session, mock_current_user):
|
||||
"""Test that list endpoint returns processing_stats in response."""
|
||||
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
|
||||
|
||||
receipt = create_mock_receipt(1)
|
||||
|
||||
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
|
||||
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
|
||||
mock_crud.get_processing_stats = AsyncMock(return_value={
|
||||
"pending_count": 2,
|
||||
"processing_count": 1,
|
||||
"completed_count": 5,
|
||||
"failed_count": 1
|
||||
})
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/data-entry/receipts/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "processing_stats" in data
|
||||
stats = data["processing_stats"]
|
||||
assert stats["pending_count"] == 2
|
||||
assert stats["processing_count"] == 1
|
||||
assert stats["completed_count"] == 5
|
||||
assert stats["failed_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_processing_status(self, mock_session, mock_current_user):
|
||||
"""Test filtering by processing_status query param."""
|
||||
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
|
||||
|
||||
receipt = create_mock_receipt(1, processing_status="failed")
|
||||
|
||||
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
|
||||
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
|
||||
mock_crud.get_processing_stats = AsyncMock(return_value={
|
||||
"pending_count": 0,
|
||||
"processing_count": 0,
|
||||
"completed_count": 0,
|
||||
"failed_count": 1
|
||||
})
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/data-entry/receipts/?processing_status=failed")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify filter was passed to CRUD
|
||||
call_args = mock_crud.get_list.call_args
|
||||
filters = call_args[0][1] # Second positional argument is filters
|
||||
assert filters.processing_status == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_batch_id(self, mock_session, mock_current_user):
|
||||
"""Test filtering by batch_id query param."""
|
||||
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
|
||||
|
||||
batch_uuid = "test-batch-uuid-123"
|
||||
receipt = create_mock_receipt(1, batch_id=batch_uuid)
|
||||
|
||||
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
|
||||
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
|
||||
mock_crud.get_processing_stats = AsyncMock(return_value={
|
||||
"pending_count": 0,
|
||||
"processing_count": 0,
|
||||
"completed_count": 1,
|
||||
"failed_count": 0
|
||||
})
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(f"/api/data-entry/receipts/?batch_id={batch_uuid}")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify filter was passed to CRUD
|
||||
call_args = mock_crud.get_list.call_args
|
||||
filters = call_args[0][1]
|
||||
assert filters.batch_id == batch_uuid
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sort_by_processing_started_at(self, mock_session, mock_current_user):
|
||||
"""Test sorting by processing_started_at query param."""
|
||||
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
|
||||
|
||||
receipt = create_mock_receipt(1)
|
||||
|
||||
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
|
||||
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
|
||||
mock_crud.get_processing_stats = AsyncMock(return_value={
|
||||
"pending_count": 0,
|
||||
"processing_count": 0,
|
||||
"completed_count": 0,
|
||||
"failed_count": 0
|
||||
})
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/data-entry/receipts/?sort_by=processing_started_at")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify sort_by was passed to CRUD
|
||||
call_args = mock_crud.get_list.call_args
|
||||
filters = call_args[0][1]
|
||||
assert filters.sort_by == "processing_started_at"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_combined_filters(self, mock_session, mock_current_user):
|
||||
"""Test combining batch_id, processing_status, and sort_by filters."""
|
||||
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
|
||||
|
||||
batch_uuid = "batch-abc-123"
|
||||
receipt = create_mock_receipt(1, batch_id=batch_uuid, processing_status="completed")
|
||||
|
||||
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
|
||||
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
|
||||
mock_crud.get_processing_stats = AsyncMock(return_value={
|
||||
"pending_count": 0,
|
||||
"processing_count": 0,
|
||||
"completed_count": 1,
|
||||
"failed_count": 0
|
||||
})
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/api/data-entry/receipts/"
|
||||
f"?batch_id={batch_uuid}"
|
||||
f"&processing_status=completed"
|
||||
f"&sort_by=processing_started_at"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify all filters were passed to CRUD
|
||||
call_args = mock_crud.get_list.call_args
|
||||
filters = call_args[0][1]
|
||||
assert filters.batch_id == batch_uuid
|
||||
assert filters.processing_status == "completed"
|
||||
assert filters.sort_by == "processing_started_at"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for CRUD get_processing_stats
|
||||
# ============================================================================
|
||||
|
||||
class TestCRUDGetProcessingStats:
|
||||
"""Test ReceiptCRUD.get_processing_stats method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_processing_stats_returns_counts(self):
|
||||
"""Test that get_processing_stats returns proper count structure."""
|
||||
from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD
|
||||
|
||||
mock_session = AsyncMock()
|
||||
|
||||
# Mock query result
|
||||
mock_result = MagicMock()
|
||||
mock_result.all = MagicMock(return_value=[
|
||||
MagicMock(processing_status="pending", count=2),
|
||||
MagicMock(processing_status="processing", count=1),
|
||||
MagicMock(processing_status="completed", count=5),
|
||||
MagicMock(processing_status="failed", count=1),
|
||||
])
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
stats = await ReceiptCRUD.get_processing_stats(mock_session)
|
||||
|
||||
assert stats["pending_count"] == 2
|
||||
assert stats["processing_count"] == 1
|
||||
assert stats["completed_count"] == 5
|
||||
assert stats["failed_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_processing_stats_with_company_filter(self):
|
||||
"""Test that get_processing_stats respects company_id filter."""
|
||||
from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD
|
||||
|
||||
mock_session = AsyncMock()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.all = MagicMock(return_value=[
|
||||
MagicMock(processing_status="completed", count=3),
|
||||
])
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
stats = await ReceiptCRUD.get_processing_stats(mock_session, company_id=1)
|
||||
|
||||
assert stats["completed_count"] == 3
|
||||
assert stats["pending_count"] == 0
|
||||
assert stats["processing_count"] == 0
|
||||
assert stats["failed_count"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_processing_stats_with_batch_filter(self):
|
||||
"""Test that get_processing_stats respects batch_id filter."""
|
||||
from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD
|
||||
|
||||
mock_session = AsyncMock()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.all = MagicMock(return_value=[
|
||||
MagicMock(processing_status="pending", count=2),
|
||||
MagicMock(processing_status="failed", count=1),
|
||||
])
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
stats = await ReceiptCRUD.get_processing_stats(mock_session, batch_id="batch-123")
|
||||
|
||||
assert stats["pending_count"] == 2
|
||||
assert stats["failed_count"] == 1
|
||||
assert stats["completed_count"] == 0
|
||||
assert stats["processing_count"] == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
12
tests/conftest.py
Normal file
12
tests/conftest.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Pytest configuration for ROA2WEB tests.
|
||||
|
||||
This file ensures proper Python path setup for imports.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to Python path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
Reference in New Issue
Block a user