feat: Add JWT auth and nomenclature sync to data-entry-app

Integrate shared JWT authentication into data-entry-app:
- Add Oracle pool initialization for auth service
- Add AuthenticationMiddleware to protect API routes
- Update all receipt endpoints to use CurrentUser from JWT
- Add shared auth router (/api/auth/login, /api/auth/refresh)

Add nomenclature synchronization feature:
- Create SQLite models for synced suppliers, local suppliers, and cash registers
- Add nomenclature router with sync triggers and CRUD endpoints
- Add sync service for Oracle → SQLite nomenclature data
- Update nomenclature_service to use synced SQLite data with fallbacks

Create shared frontend components:
- Add shared/frontend/ with LoginView.vue, auth store factory, login.css
- Integrate shared login and auth into data-entry-app frontend
- Add axios-based API service with token refresh interceptor

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-14 18:36:24 +02:00
parent 682a4b64b9
commit c5fde510a8
37 changed files with 28907 additions and 903 deletions

View File

@@ -38,8 +38,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Working on `reports-app/` or `shared/`**:
→ Use instructions from this file (below)
**Working on shared components** (`shared/auth/`, `shared/database/`):
**Working on shared components** (`shared/auth/`, `shared/database/`, `shared/frontend/`):
→ These are used by BOTH apps - be careful with changes!
`shared/frontend/` contains: LoginView.vue, auth store factory, login styles
---
@@ -48,9 +49,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
### Microservices Structure
```
.
├── shared/ # Shared components (DB pool, auth, utils)
│ ├── database/ # Oracle pool (used by reports-app)
── auth/ # JWT auth (used by both apps)
├── shared/ # Shared components (DB pool, auth, frontend)
│ ├── database/ # Oracle pool (used by both apps)
── auth/ # JWT auth (used by both apps)
│ └── frontend/ # Shared Vue components, stores, styles
│ ├── components/ # LoginView.vue
│ ├── stores/ # auth.js (Pinia store factory)
│ └── styles/ # login.css
├── reports-app/ # READ-ONLY reports from Oracle
│ ├── backend/ # FastAPI API (port 8001)

View File

@@ -48,8 +48,14 @@ data-entry-app/
## Componente Partajate
- `shared/database/oracle_pool.py` - Conexiune Oracle pentru nomenclatoare
- `shared/auth/` - JWT authentication
### Backend
- `shared/database/oracle_pool.py` - Conexiune Oracle pentru nomenclatoare si autentificare
- `shared/auth/` - JWT authentication (middleware, routes, service)
### Frontend
- `shared/frontend/components/LoginView.vue` - Componenta login partajata
- `shared/frontend/stores/auth.js` - Pinia auth store factory
- `shared/frontend/styles/login.css` - Stiluri login
## Comenzi Dezvoltare

View File

@@ -0,0 +1,455 @@
# Implementation Summary: Nomenclature Sync (FAZA 3)
**Date**: 2025-12-13
**Status**: COMPLETED
**Developer**: Claude Code
---
## Overview
Successfully implemented FAZA 3: Nomenclature Sync for the data-entry-app. This feature enables the application to maintain a local SQLite cache of nomenclatures (suppliers, cash registers) from Oracle, reducing latency and improving performance.
## Files Created
### 1. Models
**File**: `/app/db/models/nomenclature.py`
- `SyncedSupplier` - Suppliers synced from Oracle NOM_PARTENERI
- `LocalSupplier` - Suppliers created locally from OCR (not yet in Oracle)
- `SyncedCashRegister` - Cash registers and bank accounts synced from Oracle
### 2. Service Layer
**File**: `/app/services/sync_service.py`
- `SyncService.sync_suppliers()` - Sync suppliers from Oracle to SQLite
- `SyncService.sync_cash_registers()` - Sync cash registers from Oracle to SQLite
- `SyncService.search_supplier()` - Search in synced + local suppliers
- `SyncService.create_local_supplier()` - Create local supplier from OCR data
- `SyncService.get_all_suppliers()` - Get all suppliers for dropdown
- `SyncService.get_all_cash_registers()` - Get all cash registers for dropdown
- `SyncService.get_schema_for_company()` - Map company ID to Oracle schema
**Company-to-Schema Mapping**:
```python
COMPANY_SCHEMAS = {
1: "CONTAFIN",
2: "CONTAFIN2",
}
```
> **TODO**: Move to config table or environment variable
### 3. API Router
**File**: `/app/routers/nomenclature.py`
New endpoints:
- `GET /api/nomenclature/suppliers` - Get all suppliers (synced + local)
- `GET /api/nomenclature/suppliers/search` - Search supplier by fiscal code or name
- `POST /api/nomenclature/suppliers/local` - Create local supplier from OCR
- `GET /api/nomenclature/cash-registers` - Get all cash registers
- `POST /api/nomenclature/sync/suppliers` - Manual supplier sync
- `POST /api/nomenclature/sync/cash-registers` - Manual cash register sync
- `POST /api/nomenclature/sync/all` - Sync all nomenclatures
### 4. Database Migration
**File**: `/migrations/versions/20251213_002805_add_nomenclature_tables.py`
- Creates `synced_suppliers` table with indexes
- Creates `local_suppliers` table with indexes
- Creates `synced_cash_registers` table with indexes
**Applied**: Yes (migration revision: 3a653da79002)
### 5. Documentation
**File**: `NOMENCLATURE_SYNC.md`
- Complete implementation guide
- Architecture overview
- API reference
- Usage examples
- Troubleshooting guide
**File**: `IMPLEMENTATION_SUMMARY_NOMENCLATURE_SYNC.md` (this file)
- Implementation summary
- Files changed
- Testing checklist
## Files Modified
### 1. `/app/db/models/__init__.py`
**Change**: Added imports for nomenclature models
```python
from .nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister
```
### 2. `/app/services/nomenclature_service.py`
**Changes**:
- Updated `get_partners()` to accept optional `session` parameter
- Added SQLite fallback: returns synced/local suppliers if available
- Falls back to mock data if no synced data
- Updated `get_cash_registers()` to accept optional `session` parameter
- Added SQLite fallback for cash registers
### 3. `/app/routers/receipts.py`
**Changes**:
- Updated `get_partners()` endpoint to pass `session` to service
- Updated `get_cash_registers()` endpoint to pass `session` to service
### 4. `/app/routers/__init__.py`
**Change**: Added nomenclature router to exports
```python
from . import receipts, nomenclature
__all__ = ["receipts", "nomenclature"]
```
### 5. `/app/main.py`
**Change**: Registered nomenclature router
```python
from app.routers import receipts, ocr, nomenclature
app.include_router(nomenclature.router, prefix="/api/nomenclature", tags=["nomenclature"])
```
## Database Schema
### synced_suppliers
```sql
CREATE TABLE synced_suppliers (
id INTEGER PRIMARY KEY,
oracle_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
name VARCHAR(200) NOT NULL,
fiscal_code VARCHAR(50),
address VARCHAR(500),
synced_at DATETIME NOT NULL
);
CREATE INDEX ix_synced_suppliers_oracle_id ON synced_suppliers(oracle_id);
CREATE INDEX ix_synced_suppliers_company_id ON synced_suppliers(company_id);
CREATE INDEX ix_synced_suppliers_fiscal_code ON synced_suppliers(fiscal_code);
```
### local_suppliers
```sql
CREATE TABLE local_suppliers (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL,
name VARCHAR(200) NOT NULL,
fiscal_code VARCHAR(50),
address VARCHAR(500),
created_by VARCHAR(100) NOT NULL,
created_at DATETIME NOT NULL,
pending_oracle_sync BOOLEAN NOT NULL
);
CREATE INDEX ix_local_suppliers_company_id ON local_suppliers(company_id);
CREATE INDEX ix_local_suppliers_fiscal_code ON local_suppliers(fiscal_code);
```
### synced_cash_registers
```sql
CREATE TABLE synced_cash_registers (
id INTEGER PRIMARY KEY,
oracle_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
name VARCHAR(100) NOT NULL,
account_code VARCHAR(20) NOT NULL,
register_type VARCHAR(10) NOT NULL,
synced_at DATETIME NOT NULL
);
CREATE INDEX ix_synced_cash_registers_oracle_id ON synced_cash_registers(oracle_id);
CREATE INDEX ix_synced_cash_registers_company_id ON synced_cash_registers(company_id);
```
## API Endpoints Summary
### Nomenclature Endpoints
#### GET /api/nomenclature/suppliers
Get all suppliers (synced + local) for dropdown/autocomplete.
**Query Params**:
- `search` (optional) - Filter by name or fiscal code
- `company_id` (optional) - Company ID (defaults to user's first company)
**Response**:
```json
[
{
"id": 1,
"oracle_id": 123,
"name": "OMV Petrom",
"fiscal_code": "RO123456",
"source": "synced"
},
{
"id": 2,
"name": "Local Supplier SRL",
"fiscal_code": "RO789012",
"source": "local"
}
]
```
#### GET /api/nomenclature/suppliers/search
Search for supplier by fiscal code or name.
**Query Params**:
- `fiscal_code` (optional) - Fiscal code to search
- `name` (optional) - Name to search (partial match)
- `company_id` (optional) - Company ID
**Response**:
```json
{
"found": true,
"supplier": {
"id": 1,
"oracle_id": 123,
"name": "OMV Petrom",
"fiscal_code": "RO123456",
"address": "Str. Example 123"
},
"source": "synced"
}
```
#### POST /api/nomenclature/suppliers/local
Create a local supplier from OCR data.
**Body**:
```json
{
"name": "New Supplier SRL",
"fiscal_code": "RO12345678",
"address": "Str. Example 123"
}
```
**Response**:
```json
{
"id": 5,
"name": "New Supplier SRL",
"fiscal_code": "RO12345678",
"address": "Str. Example 123",
"is_local": true
}
```
#### GET /api/nomenclature/cash-registers
Get all cash registers for a company.
**Query Params**:
- `company_id` (optional) - Company ID
**Response**:
```json
[
{
"id": 1,
"oracle_id": 10,
"name": "Casa principala",
"account_code": "5311",
"register_type": "cash"
},
{
"id": 2,
"oracle_id": 20,
"name": "Cont BCR",
"account_code": "5121",
"register_type": "bank"
}
]
```
#### POST /api/nomenclature/sync/suppliers
Manually trigger supplier sync from Oracle.
**Response**:
```json
{
"synced": 150,
"errors": 0,
"message": "Synced 150 suppliers with 0 errors"
}
```
#### POST /api/nomenclature/sync/cash-registers
Manually trigger cash register sync from Oracle.
**Response**:
```json
{
"synced": 5,
"errors": 0,
"message": "Synced 5 cash registers with 0 errors"
}
```
#### POST /api/nomenclature/sync/all
Sync all nomenclatures (suppliers + cash registers).
**Response**:
```json
{
"suppliers": {
"synced": 150,
"errors": 0
},
"cash_registers": {
"synced": 5,
"errors": 0
},
"total_synced": 155,
"total_errors": 0,
"message": "Synced 150 suppliers and 5 cash registers"
}
```
## Testing Checklist
### Unit Tests
- [ ] Test `SyncService.sync_suppliers()` with mock Oracle data
- [ ] Test `SyncService.sync_cash_registers()` with mock Oracle data
- [ ] Test `SyncService.search_supplier()` for synced suppliers
- [ ] Test `SyncService.search_supplier()` for local suppliers
- [ ] Test `SyncService.create_local_supplier()`
- [ ] Test upsert logic (update existing vs insert new)
### Integration Tests
- [ ] Test nomenclature router endpoints with authentication
- [ ] Test `/api/nomenclature/suppliers` endpoint
- [ ] Test `/api/nomenclature/suppliers/search` endpoint
- [ ] Test `/api/nomenclature/suppliers/local` endpoint
- [ ] Test `/api/nomenclature/cash-registers` endpoint
- [ ] Test `/api/nomenclature/sync/suppliers` endpoint
- [ ] Test `/api/nomenclature/sync/all` endpoint
### Manual Testing
- [ ] Start backend: `uvicorn app.main:app --reload --port 8003`
- [ ] Verify `/docs` shows new nomenclature endpoints
- [ ] Test sync endpoint (requires Oracle connection)
- [ ] Test search endpoint with various queries
- [ ] Test create local supplier endpoint
- [ ] Verify existing `/api/receipts/nomenclature/partners` still works
- [ ] Verify existing `/api/receipts/nomenclature/cash-registers` still works
### Oracle Connection Testing
- [ ] Verify SSH tunnel is running (dev/Linux)
- [ ] Test Oracle connection via health endpoint
- [ ] Verify company schema mapping is correct
- [ ] Test sync with real Oracle data
- [ ] Verify table names match actual Oracle schema
### Error Handling Testing
- [ ] Test sync with invalid company ID
- [ ] Test sync with Oracle connection error
- [ ] Test search with no results
- [ ] Test create local supplier with duplicate fiscal code
- [ ] Test endpoints with missing authentication token
## Dependencies
All required dependencies are already in `requirements.txt`:
- `oracledb>=2.0.1` - Oracle database connection
- `sqlmodel>=0.0.14` - ORM for SQLite
- `alembic>=1.13.1` - Database migrations
## Deployment Notes
### Development
1. Ensure SSH tunnel is running: `./ssh_tunnel.sh start`
2. Apply migration: `alembic upgrade head`
3. Run initial sync: `POST /api/nomenclature/sync/all`
4. Start backend: `uvicorn app.main:app --reload --port 8003`
### Production
1. Update `COMPANY_SCHEMAS` in `sync_service.py` with production mappings
2. Apply migration: `alembic upgrade head`
3. Set up cron job for periodic sync (daily recommended)
4. Configure Oracle connection (no SSH tunnel needed on Windows prod)
### Migration Commands
```bash
# Check current version
alembic current
# Apply migration
alembic upgrade head
# Rollback migration
alembic downgrade -1
# View migration history
alembic history
```
## Known Issues / TODOs
1. **Company Schema Mapping**: Currently hardcoded in `sync_service.py`
- TODO: Move to config table or environment variable
2. **Oracle Table Names**: Assumes `NOM_PARTENERI` and `NOM_CASE` exist
- TODO: Verify actual table names in production Oracle schema
- TODO: Add error handling for missing tables
3. **Sync Scheduling**: No automatic periodic sync implemented
- TODO: Add background task or cron job for daily sync
4. **Conflict Resolution**: No logic to handle local supplier matching synced supplier
- TODO: Implement merge logic when OCR supplier matches Oracle supplier
5. **Bidirectional Sync**: Local suppliers not pushed to Oracle
- TODO: Implement sync from SQLite to Oracle for approved local suppliers
6. **Performance**: Sync loads all records at once
- TODO: Implement batch processing for large datasets
- TODO: Add incremental sync (requires Oracle last_modified timestamp)
7. **Validation**: No validation for duplicate fiscal codes
- TODO: Add uniqueness constraint and conflict resolution
8. **Testing**: No unit tests written yet
- TODO: Add comprehensive test suite
## Success Criteria
**Completed**:
- SQLite tables created for synced nomenclatures
- Sync service implemented with Oracle integration
- API endpoints for sync and query operations
- Updated existing nomenclature service to use synced data
- Database migration created and applied
- All files have correct Python syntax
- Documentation created
**Pending**:
- Unit tests
- Integration tests
- Manual testing with real Oracle data
- Production deployment
- Scheduled sync setup
## Next Steps
1. **Testing Phase**:
- Write unit tests for sync service
- Write integration tests for API endpoints
- Manual testing with real Oracle connection
- Performance testing with large datasets
2. **Production Readiness**:
- Update company schema mappings for production
- Verify Oracle table names
- Set up cron job for periodic sync
- Add monitoring and alerting
3. **Enhancements**:
- Implement scheduled background sync
- Add sync status dashboard in frontend
- Implement conflict resolution
- Add bidirectional sync (SQLite → Oracle)
## Related Documentation
- Complete Guide: `NOMENCLATURE_SYNC.md`
- Architecture: `docs/data-entry/ARCHITECTURE.md`
- API Docs: Available at `/docs` when app is running
---
**Implementation completed successfully!** All core features are in place and ready for testing.

View File

@@ -0,0 +1,273 @@
# Nomenclature Sync - Implementation Guide
## Overview
This document describes the implementation of FAZA 3: Nomenclature Sync for the Data Entry App.
The nomenclature sync system allows the data-entry-app to maintain a local SQLite cache of nomenclatures (suppliers, cash registers) from Oracle, reducing the need for live Oracle queries and improving performance.
## Architecture
### Database Tables
Three new SQLite tables were added:
1. **synced_suppliers** - Suppliers synced from Oracle NOM_PARTENERI
- `oracle_id` - Original Oracle ID
- `company_id` - Company this supplier belongs to
- `name` - Supplier name
- `fiscal_code` - CUI/CIF
- `address` - Supplier address
- `synced_at` - Last sync timestamp
2. **local_suppliers** - Suppliers created locally from OCR (not in Oracle)
- `company_id` - Company ID
- `name` - Supplier name
- `fiscal_code` - CUI/CIF
- `address` - Supplier address
- `created_by` - Username who created it
- `pending_oracle_sync` - Flag for future Oracle sync
3. **synced_cash_registers** - Cash registers and bank accounts from Oracle
- `oracle_id` - Original Oracle ID
- `company_id` - Company ID
- `name` - Register name
- `account_code` - Account code (5311, 5121, etc.)
- `register_type` - 'cash' or 'bank'
- `synced_at` - Last sync timestamp
### Components
#### 1. Models (`app/db/models/nomenclature.py`)
SQLModel models for the three tables above.
#### 2. Sync Service (`app/services/sync_service.py`)
Core business logic for syncing nomenclatures:
- `sync_suppliers()` - Sync suppliers from Oracle to SQLite
- `sync_cash_registers()` - Sync cash registers from Oracle to SQLite
- `search_supplier()` - Search in synced + local suppliers
- `create_local_supplier()` - Create local supplier from OCR data
- `get_all_suppliers()` - Get all suppliers for dropdown
- `get_all_cash_registers()` - Get all cash registers for dropdown
#### 3. API Router (`app/routers/nomenclature.py`)
New API endpoints:
**GET /api/nomenclature/suppliers**
- Get all suppliers (synced + local) for dropdown/autocomplete
- Query params: `search`, `company_id`
- Returns: List of SupplierOption
**GET /api/nomenclature/suppliers/search**
- Search for supplier by fiscal code or name
- Query params: `fiscal_code`, `name`, `company_id`
- Returns: SupplierSearchResult (found, supplier, source)
**POST /api/nomenclature/suppliers/local**
- Create a local supplier from OCR data
- Body: LocalSupplierCreate (name, fiscal_code, address)
- Returns: LocalSupplierResponse
**GET /api/nomenclature/cash-registers**
- Get all cash registers for a company
- Query params: `company_id`
- Returns: List of CashRegisterOption
**POST /api/nomenclature/sync/suppliers**
- Manually trigger supplier sync from Oracle
- Returns: SyncResult (synced count, errors)
**POST /api/nomenclature/sync/cash-registers**
- Manually trigger cash register sync from Oracle
- Returns: SyncResult (synced count, errors)
**POST /api/nomenclature/sync/all**
- Sync all nomenclatures (suppliers + cash registers)
- Returns: Combined sync results
#### 4. Updated Services
**nomenclature_service.py** was updated to use synced data:
- `get_partners()` - Now accepts optional `session` parameter, returns synced data if available, falls back to mock
- `get_cash_registers()` - Now accepts optional `session` parameter, returns synced data if available, falls back to mock
**receipts.py router** was updated to pass session to nomenclature service.
## Company Schema Mapping
The sync service needs to know which Oracle schema to query for each company. This is configured in `sync_service.py`:
```python
COMPANY_SCHEMAS = {
1: "CONTAFIN",
2: "CONTAFIN2",
}
```
**TODO**: Move this to a config table or environment variable for production.
## Oracle Integration
The sync service connects to Oracle using the shared `oracle_pool` from `/shared/database/oracle_pool.py`.
**Prerequisites**:
- SSH tunnel must be running (development/Linux)
- Oracle connection pool must be initialized
- Environment variables must be set (ORACLE_USER, ORACLE_PASSWORD, ORACLE_HOST, ORACLE_PORT, ORACLE_SID)
**Oracle Tables Used**:
- `{schema}.NOM_PARTENERI` - Suppliers (WHERE ACTIV = 1)
- `{schema}.NOM_CASE` - Cash registers (WHERE ACTIV = 1)
**Note**: Table and column names may need adjustment based on actual Oracle schema.
## Usage Flow
### Initial Setup (One-time)
1. Ensure Oracle connection is available:
```bash
# Start SSH tunnel (if on Linux/dev)
./ssh_tunnel.sh start
```
2. Run initial sync:
```bash
# Via API (authenticated request)
POST /api/nomenclature/sync/all
```
Or programmatically:
```python
from app.services.sync_service import SyncService
# Sync for company 1
synced, errors = await SyncService.sync_suppliers(session, company_id=1)
synced, errors = await SyncService.sync_cash_registers(session, company_id=1)
```
### Periodic Sync
Set up a cron job or scheduled task to sync nomenclatures periodically (e.g., daily):
```python
# Example: Add to app lifespan or background task
async def sync_all_companies():
"""Sync nomenclatures for all companies."""
async with get_db_session() as session:
for company_id in [1, 2]: # All company IDs
await SyncService.sync_suppliers(session, company_id)
await SyncService.sync_cash_registers(session, company_id)
```
### Using Synced Data
The existing endpoints (`/api/receipts/nomenclature/partners`, `/api/receipts/nomenclature/cash-registers`) now automatically use synced data when available.
**Frontend** - No changes needed! Existing code continues to work:
```javascript
// Get suppliers (now from synced data)
const response = await api.get('/api/receipts/nomenclature/partners?search=OMV');
```
### Creating Local Suppliers from OCR
When OCR extracts a supplier not in Oracle:
```javascript
// Create local supplier
const response = await api.post('/api/nomenclature/suppliers/local', {
name: "New Supplier SRL",
fiscal_code: "RO12345678",
address: "Str. Example 123"
});
```
The local supplier will be:
- Available immediately in dropdowns
- Flagged for future Oracle sync (`pending_oracle_sync = True`)
- Created by current user (`created_by = username`)
## Migration
Migration: `20251213_002805_add_nomenclature_tables.py`
Applied with:
```bash
alembic upgrade head
```
To rollback:
```bash
alembic downgrade -1
```
## Testing
### Manual Testing
1. Test sync endpoint:
```bash
curl -X POST http://localhost:8003/api/nomenclature/sync/suppliers \
-H "Authorization: Bearer YOUR_TOKEN"
```
2. Test search:
```bash
curl "http://localhost:8003/api/nomenclature/suppliers/search?name=OMV" \
-H "Authorization: Bearer YOUR_TOKEN"
```
3. Test get all suppliers:
```bash
curl "http://localhost:8003/api/nomenclature/suppliers" \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Unit Tests
TODO: Add unit tests in `tests/test_sync_service.py`:
- Test supplier sync
- Test cash register sync
- Test search functionality
- Test local supplier creation
## Troubleshooting
### Sync fails with "No schema mapping"
- Update `COMPANY_SCHEMAS` in `sync_service.py` with correct company-to-schema mappings
### Sync fails with Oracle connection error
- Verify SSH tunnel is running: `./ssh_tunnel.sh status`
- Check Oracle credentials in `.env`
- Test Oracle connection: `curl http://localhost:8003/health`
### Tables not found in Oracle
- Verify table names in Oracle (may differ from NOM_PARTENERI, NOM_CASE)
- Update SQL queries in `sync_service.py` to match actual schema
### Duplicate suppliers after sync
- The sync uses upsert logic (update if exists, insert if new)
- Check `oracle_id` + `company_id` uniqueness in synced_suppliers table
## Future Enhancements
1. **Scheduled Background Sync** - Add cron job or Celery task for automatic daily sync
2. **Sync Status Dashboard** - UI to show last sync time, sync statistics
3. **Conflict Resolution** - Handle cases where local supplier matches synced supplier
4. **Bidirectional Sync** - Push local suppliers to Oracle when approved
5. **Incremental Sync** - Only sync changed records (requires last_modified timestamp in Oracle)
6. **Multi-Company Support** - Auto-detect user's companies and sync all
7. **Sync Notifications** - Notify users when sync completes or fails
8. **Audit Log** - Track all sync operations for compliance
## Related Files
- Models: `/app/db/models/nomenclature.py`
- Service: `/app/services/sync_service.py`
- Router: `/app/routers/nomenclature.py`
- Migration: `/migrations/versions/20251213_002805_add_nomenclature_tables.py`
- Updated: `/app/services/nomenclature_service.py`
- Updated: `/app/routers/receipts.py`
- Updated: `/app/main.py`

View File

@@ -1,6 +1,7 @@
# Database models
from .receipt import Receipt, ReceiptAttachment, ReceiptStatus, ReceiptType, ReceiptDirection
from .accounting_entry import AccountingEntry, EntryType
from .nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister
__all__ = [
"Receipt",
@@ -10,4 +11,7 @@ __all__ = [
"ReceiptDirection",
"AccountingEntry",
"EntryType",
"SyncedSupplier",
"LocalSupplier",
"SyncedCashRegister",
]

View File

@@ -0,0 +1,46 @@
"""Nomenclature models for synced and local data."""
from typing import Optional
from datetime import datetime
from sqlmodel import SQLModel, Field
class SyncedSupplier(SQLModel, table=True):
"""Suppliers synced from Oracle NOM_PARTENERI."""
__tablename__ = "synced_suppliers"
id: Optional[int] = Field(default=None, primary_key=True)
oracle_id: int = Field(index=True) # Original Oracle ID
company_id: int = Field(index=True) # Company this supplier belongs to
name: str = Field(max_length=200)
fiscal_code: Optional[str] = Field(default=None, max_length=50, index=True) # CUI/CIF
address: Optional[str] = Field(default=None, max_length=500)
synced_at: datetime = Field(default_factory=datetime.utcnow)
class LocalSupplier(SQLModel, table=True):
"""Suppliers created locally from OCR (not in Oracle)."""
__tablename__ = "local_suppliers"
id: Optional[int] = Field(default=None, primary_key=True)
company_id: int = Field(index=True)
name: str = Field(max_length=200)
fiscal_code: Optional[str] = Field(default=None, max_length=50, index=True)
address: Optional[str] = Field(default=None, max_length=500)
created_by: str = Field(max_length=100) # Username who created it
created_at: datetime = Field(default_factory=datetime.utcnow)
# Flag to indicate if it should be synced to Oracle later
pending_oracle_sync: bool = Field(default=True)
class SyncedCashRegister(SQLModel, table=True):
"""Cash registers and bank accounts synced from Oracle."""
__tablename__ = "synced_cash_registers"
id: Optional[int] = Field(default=None, primary_key=True)
oracle_id: int = Field(index=True)
company_id: int = Field(index=True)
name: str = Field(max_length=100)
account_code: str = Field(max_length=20) # 5311, 5121, etc.
register_type: str = Field(max_length=10) # 'cash' or 'bank'
synced_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -6,6 +6,10 @@ import threading
from pathlib import Path
from contextlib import asynccontextmanager
# Load .env file BEFORE any imports that use os.getenv()
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
# Configure logging to show INFO level messages
@@ -24,6 +28,9 @@ sys.path.insert(0, str(project_root / "shared"))
from app.config import settings
from app.db.database import init_db
# Import Oracle pool for auth service
from database.oracle_pool import oracle_pool
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -31,7 +38,15 @@ async def lifespan(app: FastAPI):
# Startup
print(f"Starting {settings.app_name} v{settings.app_version}")
# Initialize database
# Initialize Oracle pool (required for authentication)
try:
await oracle_pool.initialize()
print("Oracle pool initialized")
except Exception as e:
print(f"Warning: Oracle pool initialization failed: {e}")
print("Authentication will not work without Oracle connection")
# Initialize SQLite database
await init_db()
print("Database initialized")
@@ -55,6 +70,11 @@ async def lifespan(app: FastAPI):
# Shutdown
print("Shutting down...")
try:
await oracle_pool.close()
print("Oracle pool closed")
except Exception as e:
print(f"Warning: Oracle pool close failed: {e}")
# Create FastAPI app
@@ -74,6 +94,14 @@ app.add_middleware(
allow_headers=["*"],
)
# Authentication middleware
from auth.middleware import AuthenticationMiddleware
app.add_middleware(
AuthenticationMiddleware,
excluded_paths=["/docs", "/redoc", "/openapi.json", "/health", "/", "/api/auth/login", "/api/auth/refresh"]
)
# Mount static files for uploads (optional - can serve through nginx in prod)
uploads_path = Path(settings.upload_path)
if uploads_path.exists():
@@ -92,10 +120,17 @@ async def health_check():
# Import and include routers
from app.routers import receipts, ocr
from app.routers import receipts, ocr, nomenclature
app.include_router(receipts.router, prefix="/api/receipts", tags=["receipts"])
app.include_router(ocr.router, prefix="/api/ocr", tags=["ocr"])
app.include_router(nomenclature.router, prefix="/api/nomenclature", tags=["nomenclature"])
# Auth router
from auth.routes import create_auth_router
auth_router = create_auth_router(prefix="") # No prefix - we set it in include_router
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
# Root endpoint

View File

@@ -1,4 +1,4 @@
# API routers
from . import receipts
from . import receipts, nomenclature
__all__ = ["receipts"]
__all__ = ["receipts", "nomenclature"]

View File

@@ -0,0 +1,221 @@
"""Nomenclature API endpoints."""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.db.database import get_session
from app.services.sync_service import SyncService
# Import auth dependencies
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent.parent.parent
sys.path.insert(0, str(project_root / "shared"))
from auth.dependencies import get_current_user
from auth.models import CurrentUser
router = APIRouter()
# Request/Response Models
class SupplierSearchResult(BaseModel):
found: bool
supplier: Optional[dict] = None
source: str # 'synced', 'local', 'not_found'
class LocalSupplierCreate(BaseModel):
name: str
fiscal_code: Optional[str] = None
address: Optional[str] = None
class LocalSupplierResponse(BaseModel):
id: int
name: str
fiscal_code: Optional[str]
address: Optional[str]
is_local: bool = True
class SyncResult(BaseModel):
synced: int
errors: int
message: str
class SupplierOption(BaseModel):
id: int
oracle_id: Optional[int] = None
name: str
fiscal_code: Optional[str]
source: str # 'synced' or 'local'
class CashRegisterOption(BaseModel):
id: int
oracle_id: int
name: str
account_code: str
register_type: str
# Endpoints
@router.get("/suppliers/search", response_model=SupplierSearchResult)
async def search_supplier(
fiscal_code: Optional[str] = None,
name: Optional[str] = None,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
"""Search for supplier by fiscal code or name."""
if not fiscal_code and not name:
raise HTTPException(status_code=400, detail="Provide fiscal_code or name")
# Use provided company_id or first from user
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
found, supplier, source = await SyncService.search_supplier(
session, cid, fiscal_code, name
)
return SupplierSearchResult(found=found, supplier=supplier, source=source)
@router.get("/suppliers", response_model=List[SupplierOption])
async def get_suppliers(
search: Optional[str] = None,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
"""Get all suppliers (synced + local) for dropdown/autocomplete."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
suppliers = await SyncService.get_all_suppliers(session, cid, search)
return [
SupplierOption(
id=s["id"],
oracle_id=s.get("oracle_id"),
name=s["name"],
fiscal_code=s.get("fiscal_code"),
source=s["source"]
)
for s in suppliers
]
@router.post("/suppliers/local", response_model=LocalSupplierResponse)
async def create_local_supplier(
data: LocalSupplierCreate,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
"""Create a local supplier from OCR data."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
supplier = await SyncService.create_local_supplier(
session, cid, data.name, data.fiscal_code, data.address, current_user.username
)
return LocalSupplierResponse(
id=supplier.id,
name=supplier.name,
fiscal_code=supplier.fiscal_code,
address=supplier.address,
)
@router.get("/cash-registers", response_model=List[CashRegisterOption])
async def get_cash_registers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
"""Get all cash registers for a company."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
registers = await SyncService.get_all_cash_registers(session, cid)
return [
CashRegisterOption(
id=r["id"],
oracle_id=r["oracle_id"],
name=r["name"],
account_code=r["account_code"],
register_type=r["register_type"]
)
for r in registers
]
@router.post("/sync/suppliers", response_model=SyncResult)
async def sync_suppliers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
"""Manually trigger supplier sync from Oracle."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
synced, errors = await SyncService.sync_suppliers(session, cid)
return SyncResult(
synced=synced,
errors=errors,
message=f"Synced {synced} suppliers with {errors} errors"
)
@router.post("/sync/cash-registers", response_model=SyncResult)
async def sync_cash_registers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
"""Manually trigger cash register sync from Oracle."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
synced, errors = await SyncService.sync_cash_registers(session, cid)
return SyncResult(
synced=synced,
errors=errors,
message=f"Synced {synced} cash registers with {errors} errors"
)
@router.post("/sync/all", response_model=dict)
async def sync_all_nomenclatures(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
"""Sync all nomenclatures (suppliers + cash registers) from Oracle."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
# Sync suppliers
suppliers_synced, suppliers_errors = await SyncService.sync_suppliers(session, cid)
# Sync cash registers
registers_synced, registers_errors = await SyncService.sync_cash_registers(session, cid)
return {
"suppliers": {
"synced": suppliers_synced,
"errors": suppliers_errors
},
"cash_registers": {
"synced": registers_synced,
"errors": registers_errors
},
"total_synced": suppliers_synced + registers_synced,
"total_errors": suppliers_errors + registers_errors,
"message": f"Synced {suppliers_synced} suppliers and {registers_synced} cash registers"
}

View File

@@ -13,6 +13,10 @@ from app.services.ocr_service import ocr_service
from app.services.ocr_engine import OCREngine
from app.schemas.ocr import OCRResponse, OCRStatusResponse, ExtractionData, TvaEntry
# Auth integration (will be protected by middleware)
from auth.dependencies import get_current_user
from auth.models import CurrentUser
router = APIRouter()

View File

@@ -31,31 +31,28 @@ from app.schemas.receipt import (
)
from app.db.models.receipt import ReceiptStatus
# Auth integration
from auth.dependencies import get_current_user
from auth.models import CurrentUser
router = APIRouter()
# ============ Helper for current user (simplified for Phase 1) ============
# ============ Helper for current user's company ============
async def get_current_user() -> str:
"""
Get current authenticated user.
Phase 1: Returns hardcoded user for testing.
Phase 2: Will integrate with shared JWT auth.
"""
# TODO: Integrate with shared/auth middleware
return "test_user"
async def get_current_user_company() -> int:
def get_current_user_company(current_user: CurrentUser) -> int:
"""
Get current user's active company.
Phase 1: Returns hardcoded company for testing.
Phase 2: Will get from JWT token or session.
Returns the first company from the user's companies list.
In future, this can be enhanced to use a session-based active company.
"""
# TODO: Integrate with shared/auth
if current_user.companies:
# For data-entry-app, we assume company ID is numeric
# If companies are stored as strings, convert to int
# For now, return 1 as default (Phase 1)
return 1
return 1
@@ -65,10 +62,10 @@ async def get_current_user_company() -> int:
async def create_receipt(
data: ReceiptCreate,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Create a new receipt in DRAFT status."""
receipt = await ReceiptService.create_receipt(session, data, current_user)
receipt = await ReceiptService.create_receipt(session, data, current_user.username)
return ReceiptResponse.model_validate(receipt)
@@ -83,12 +80,13 @@ async def list_receipts(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_company: int = Depends(get_current_user_company),
current_user: CurrentUser = Depends(get_current_user),
):
"""Get paginated list of receipts with filters."""
from datetime import date as date_type
current_company = get_current_user_company(current_user)
filters = ReceiptFilter(
status=status,
company_id=company_id or current_company,
@@ -107,9 +105,10 @@ async def list_receipts(
async def list_pending_receipts(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_company: int = Depends(get_current_user_company),
current_user: CurrentUser = Depends(get_current_user),
):
"""Get all receipts pending review (for accountant view)."""
current_company = get_current_user_company(current_user)
receipts = await ReceiptCRUD.get_pending_review(
session, company_id or current_company
)
@@ -121,14 +120,14 @@ async def get_receipt_stats(
company_id: Optional[int] = None,
my_receipts: bool = False,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_company: int = Depends(get_current_user_company),
current_user: CurrentUser = Depends(get_current_user),
):
"""Get receipt statistics."""
current_company = get_current_user_company(current_user)
return await ReceiptCRUD.get_stats(
session,
company_id or current_company,
created_by=current_user if my_receipts else None,
created_by=current_user.username if my_receipts else None,
)
@@ -151,11 +150,11 @@ async def update_receipt(
receipt_id: int,
data: ReceiptUpdate,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Update receipt (only DRAFT status, only by creator)."""
success, message, receipt = await ReceiptService.update_receipt(
session, receipt_id, data, current_user
session, receipt_id, data, current_user.username
)
if not success:
@@ -168,11 +167,11 @@ async def update_receipt(
async def delete_receipt(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Delete receipt (only DRAFT status, only by creator)."""
success, message = await ReceiptService.delete_receipt(
session, receipt_id, current_user
session, receipt_id, current_user.username
)
if not success:
@@ -187,11 +186,11 @@ async def delete_receipt(
async def submit_receipt(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Submit receipt for review (DRAFT → PENDING_REVIEW)."""
success, message, receipt = await ReceiptService.submit_for_review(
session, receipt_id, current_user
session, receipt_id, current_user.username
)
return WorkflowAction(
@@ -205,11 +204,11 @@ async def submit_receipt(
async def approve_receipt(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Approve receipt (PENDING_REVIEW → APPROVED). Accountant action."""
success, message, receipt = await ReceiptService.approve_receipt(
session, receipt_id, current_user
session, receipt_id, current_user.username
)
return WorkflowAction(
@@ -224,11 +223,11 @@ async def reject_receipt(
receipt_id: int,
data: RejectRequest,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Reject receipt (PENDING_REVIEW → REJECTED). Accountant action."""
success, message, receipt = await ReceiptService.reject_receipt(
session, receipt_id, current_user, data.reason
session, receipt_id, current_user.username, data.reason
)
return WorkflowAction(
@@ -242,11 +241,11 @@ async def reject_receipt(
async def resubmit_receipt(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Resubmit rejected receipt after corrections (REJECTED → PENDING_REVIEW)."""
success, message, receipt = await ReceiptService.resubmit_receipt(
session, receipt_id, current_user
session, receipt_id, current_user.username
)
return WorkflowAction(
@@ -273,11 +272,11 @@ async def update_receipt_entries(
receipt_id: int,
data: EntriesUpdateRequest,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Update accounting entries for a receipt (accountant action)."""
success, message, entries = await ReceiptService.update_entries(
session, receipt_id, data.entries, current_user
session, receipt_id, data.entries, current_user.username
)
if not success:
@@ -290,11 +289,11 @@ async def update_receipt_entries(
async def regenerate_entries(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Regenerate accounting entries based on receipt data."""
success, message, _ = await ReceiptService.regenerate_entries(
session, receipt_id, current_user
session, receipt_id, current_user.username
)
if not success:
@@ -311,7 +310,7 @@ async def upload_attachment(
receipt_id: int,
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Upload attachment for a receipt."""
# Check receipt exists and user can modify it
@@ -328,7 +327,7 @@ async def upload_attachment(
)
# Only creator can upload
if receipt.created_by != current_user:
if receipt.created_by != current_user.username:
raise HTTPException(
status_code=403,
detail="Only the creator can upload attachments"
@@ -378,7 +377,7 @@ async def download_attachment(
async def delete_attachment(
attachment_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_user: CurrentUser = Depends(get_current_user),
):
"""Delete an attachment."""
attachment = await AttachmentCRUD.get_by_id(session, attachment_id)
@@ -399,7 +398,7 @@ async def delete_attachment(
detail="Cannot delete attachments for this receipt status"
)
if receipt.created_by != current_user:
if receipt.created_by != current_user.username:
raise HTTPException(
status_code=403,
detail="Only the creator can delete attachments"
@@ -415,11 +414,13 @@ async def delete_attachment(
async def get_partners(
search: Optional[str] = None,
company_id: Optional[int] = None,
current_company: int = Depends(get_current_user_company),
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
"""Get partners (suppliers/customers) for dropdown."""
current_company = get_current_user_company(current_user)
return await NomenclatureService.get_partners(
company_id or current_company, search
company_id or current_company, search, session
)
@@ -427,9 +428,10 @@ async def get_partners(
async def get_accounts(
prefix: Optional[str] = None,
company_id: Optional[int] = None,
current_company: int = Depends(get_current_user_company),
current_user: CurrentUser = Depends(get_current_user),
):
"""Get chart of accounts for dropdown."""
current_company = get_current_user_company(current_user)
return await NomenclatureService.get_accounts(
company_id or current_company, prefix
)
@@ -438,10 +440,12 @@ async def get_accounts(
@router.get("/nomenclature/cash-registers", response_model=List[CashRegisterOption])
async def get_cash_registers(
company_id: Optional[int] = None,
current_company: int = Depends(get_current_user_company),
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
"""Get cash registers and bank accounts for dropdown."""
return await NomenclatureService.get_cash_registers(company_id or current_company)
current_company = get_current_user_company(current_user)
return await NomenclatureService.get_cash_registers(company_id or current_company, session)
@router.get("/nomenclature/expense-types", response_model=List[ExpenseTypeOption])

View File

@@ -3,6 +3,9 @@
from typing import List, Optional
from decimal import Decimal
from sqlmodel import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.receipt import (
PartnerOption,
AccountOption,
@@ -10,6 +13,7 @@ from app.schemas.receipt import (
ExpenseTypeOption,
)
from app.services.expense_types import EXPENSE_TYPES
from app.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister
class NomenclatureService:
@@ -21,15 +25,55 @@ class NomenclatureService:
"""
@staticmethod
async def get_partners(company_id: int, search: Optional[str] = None) -> List[PartnerOption]:
async def get_partners(
company_id: int,
search: Optional[str] = None,
session: Optional[AsyncSession] = None
) -> List[PartnerOption]:
"""
Get partners (suppliers/customers) for a company.
Phase 1: Returns empty list or mock data.
Phase 2: Will fetch from Oracle NOM_PARTENERI.
Phase 1: Returns mock data.
Phase 2: Returns synced data from SQLite (from Oracle sync).
Phase 3: Will fetch live from Oracle.
"""
# TODO: Implement Oracle fetch in Phase 2
# For now, return some mock data for testing
# If session is provided, try to get from synced SQLite data
if session:
# Try to get from SQLite synced data
stmt = select(SyncedSupplier).where(SyncedSupplier.company_id == company_id)
if search:
stmt = stmt.where(
(SyncedSupplier.name.ilike(f"%{search}%")) |
(SyncedSupplier.fiscal_code.ilike(f"%{search}%"))
)
stmt = stmt.limit(50) # Limit results
result = await session.execute(stmt)
suppliers = result.scalars().all()
if suppliers:
# Also get local suppliers
local_stmt = select(LocalSupplier).where(LocalSupplier.company_id == company_id)
if search:
local_stmt = local_stmt.where(
(LocalSupplier.name.ilike(f"%{search}%")) |
(LocalSupplier.fiscal_code.ilike(f"%{search}%"))
)
local_stmt = local_stmt.limit(50)
local_result = await session.execute(local_stmt)
local_suppliers = local_result.scalars().all()
# Combine both
partners = []
for s in suppliers:
partners.append(PartnerOption(id=s.id, name=s.name, code=s.fiscal_code))
for l in local_suppliers:
partners.append(PartnerOption(id=l.id, name=f"{l.name} (local)", code=l.fiscal_code))
return partners
# Fallback to mock data for Phase 1
mock_partners = [
PartnerOption(id=1, name="OMV Petrom", code="RO123456"),
PartnerOption(id=2, name="Dedeman", code="RO789012"),
@@ -83,14 +127,30 @@ class NomenclatureService:
return accounts
@staticmethod
async def get_cash_registers(company_id: int) -> List[CashRegisterOption]:
async def get_cash_registers(
company_id: int,
session: Optional[AsyncSession] = None
) -> List[CashRegisterOption]:
"""
Get cash registers and bank accounts for a company.
Phase 1: Returns default options.
Phase 2: Will fetch from Oracle NOM_CASE / NOM_BANCI.
Phase 2: Returns synced data from SQLite (from Oracle sync).
Phase 3: Will fetch live from Oracle NOM_CASE / NOM_BANCI.
"""
# Default cash registers
# If session is provided, try to get from synced SQLite data
if session:
stmt = select(SyncedCashRegister).where(SyncedCashRegister.company_id == company_id)
result = await session.execute(stmt)
registers = result.scalars().all()
if registers:
return [
CashRegisterOption(id=r.id, name=r.name, account_code=r.account_code)
for r in registers
]
# Fallback to default cash registers for Phase 1
return [
CashRegisterOption(id=1, name="Casa principala", account_code="5311"),
CashRegisterOption(id=2, name="Cont BCR", account_code="5121"),

View File

@@ -0,0 +1,356 @@
"""Service for syncing nomenclatures from Oracle to SQLite."""
import sys
from pathlib import Path
from typing import Optional, List, Tuple
from datetime import datetime
import logging
from sqlmodel import select
from sqlalchemy.ext.asyncio import AsyncSession
# Add shared modules path
project_root = Path(__file__).parent.parent.parent.parent.parent
sys.path.insert(0, str(project_root / "shared"))
from database.oracle_pool import oracle_pool
from app.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister
logger = logging.getLogger(__name__)
# Company ID to Oracle Schema mapping
# TODO: This should come from a config table or environment variable
COMPANY_SCHEMAS = {
1: "CONTAFIN", # Example mapping - update with real schema names
2: "CONTAFIN2",
}
class SyncService:
"""Service for syncing nomenclatures from Oracle."""
@staticmethod
def get_schema_for_company(company_id: int) -> Optional[str]:
"""Get Oracle schema for company ID."""
return COMPANY_SCHEMAS.get(company_id)
@staticmethod
async def sync_suppliers(session: AsyncSession, company_id: int) -> Tuple[int, int]:
"""
Sync suppliers from Oracle NOM_PARTENERI to SQLite.
Returns (synced_count, error_count).
"""
schema = SyncService.get_schema_for_company(company_id)
if not schema:
logger.warning(f"No schema mapping for company {company_id}")
return 0, 0
synced = 0
errors = 0
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Fetch active partners from Oracle
cursor.execute(f"""
SELECT ID_PART, DEN_PART, COD_FISCAL, ADRESA
FROM {schema}.NOM_PARTENERI
WHERE ACTIV = 1
""")
rows = cursor.fetchall()
for row in rows:
try:
oracle_id, name, fiscal_code, address = row
# Check if already exists
stmt = select(SyncedSupplier).where(
SyncedSupplier.oracle_id == oracle_id,
SyncedSupplier.company_id == company_id
)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
# Update existing record
existing.name = name or ""
existing.fiscal_code = fiscal_code
existing.address = address
existing.synced_at = datetime.utcnow()
logger.debug(f"Updated supplier {oracle_id}: {name}")
else:
# Create new record
supplier = SyncedSupplier(
oracle_id=oracle_id,
company_id=company_id,
name=name or "",
fiscal_code=fiscal_code,
address=address,
)
session.add(supplier)
logger.debug(f"Created supplier {oracle_id}: {name}")
synced += 1
except Exception as e:
logger.error(f"Error processing supplier row {row}: {e}")
errors += 1
# Commit all changes
await session.commit()
logger.info(f"Synced {synced} suppliers for company {company_id}, {errors} errors")
except Exception as e:
logger.error(f"Error syncing suppliers for company {company_id}: {e}")
errors += 1
await session.rollback()
return synced, errors
@staticmethod
async def sync_cash_registers(session: AsyncSession, company_id: int) -> Tuple[int, int]:
"""
Sync cash registers from Oracle to SQLite.
Returns (synced_count, error_count).
"""
schema = SyncService.get_schema_for_company(company_id)
if not schema:
logger.warning(f"No schema mapping for company {company_id}")
return 0, 0
synced = 0
errors = 0
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Fetch cash registers (both cash and bank)
# Assuming similar structure to NOM_PARTENERI
# TODO: Verify actual table name and structure in Oracle
cursor.execute(f"""
SELECT ID_CASA, DEN_CASA, CONT
FROM {schema}.NOM_CASE
WHERE ACTIV = 1
""")
rows = cursor.fetchall()
for row in rows:
try:
oracle_id, name, account_code = row
# Determine type based on account code
register_type = "cash" if account_code.startswith("531") else "bank"
# Check if already exists
stmt = select(SyncedCashRegister).where(
SyncedCashRegister.oracle_id == oracle_id,
SyncedCashRegister.company_id == company_id
)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
# Update existing record
existing.name = name or ""
existing.account_code = account_code or ""
existing.register_type = register_type
existing.synced_at = datetime.utcnow()
logger.debug(f"Updated cash register {oracle_id}: {name}")
else:
# Create new record
cash_register = SyncedCashRegister(
oracle_id=oracle_id,
company_id=company_id,
name=name or "",
account_code=account_code or "",
register_type=register_type,
)
session.add(cash_register)
logger.debug(f"Created cash register {oracle_id}: {name}")
synced += 1
except Exception as e:
logger.error(f"Error processing cash register row {row}: {e}")
errors += 1
# Commit all changes
await session.commit()
logger.info(f"Synced {synced} cash registers for company {company_id}, {errors} errors")
except Exception as e:
logger.error(f"Error syncing cash registers for company {company_id}: {e}")
errors += 1
await session.rollback()
return synced, errors
@staticmethod
async def search_supplier(
session: AsyncSession,
company_id: int,
fiscal_code: Optional[str] = None,
name: Optional[str] = None
) -> Tuple[bool, Optional[dict], str]:
"""
Search for supplier in SQLite first, then Oracle if not found.
Returns (found, supplier_data, source).
Source can be: 'synced', 'local', 'not_found'
"""
# 1. Search in synced suppliers
if fiscal_code:
stmt = select(SyncedSupplier).where(
SyncedSupplier.company_id == company_id,
SyncedSupplier.fiscal_code == fiscal_code
)
elif name:
stmt = select(SyncedSupplier).where(
SyncedSupplier.company_id == company_id,
SyncedSupplier.name.ilike(f"%{name}%")
)
else:
return False, None, "no_query"
result = await session.execute(stmt)
supplier = result.scalar_one_or_none()
if supplier:
return True, {
"id": supplier.id,
"oracle_id": supplier.oracle_id,
"name": supplier.name,
"fiscal_code": supplier.fiscal_code,
"address": supplier.address,
}, "synced"
# 2. Search in local suppliers
if fiscal_code:
stmt = select(LocalSupplier).where(
LocalSupplier.company_id == company_id,
LocalSupplier.fiscal_code == fiscal_code
)
elif name:
stmt = select(LocalSupplier).where(
LocalSupplier.company_id == company_id,
LocalSupplier.name.ilike(f"%{name}%")
)
result = await session.execute(stmt)
local = result.scalar_one_or_none()
if local:
return True, {
"id": local.id,
"name": local.name,
"fiscal_code": local.fiscal_code,
"address": local.address,
"is_local": True,
}, "local"
# 3. Try live Oracle search (optional fallback for unsynced data)
# This is a fallback - ideally sync should be up to date
# TODO: Implement live Oracle search if needed
return False, None, "not_found"
@staticmethod
async def create_local_supplier(
session: AsyncSession,
company_id: int,
name: str,
fiscal_code: Optional[str],
address: Optional[str],
created_by: str
) -> LocalSupplier:
"""Create a local supplier entry from OCR data."""
supplier = LocalSupplier(
company_id=company_id,
name=name,
fiscal_code=fiscal_code,
address=address,
created_by=created_by,
)
session.add(supplier)
await session.commit()
await session.refresh(supplier)
logger.info(f"Created local supplier: {name} (CUI: {fiscal_code})")
return supplier
@staticmethod
async def get_all_suppliers(
session: AsyncSession,
company_id: int,
search: Optional[str] = None
) -> List[dict]:
"""
Get all suppliers (synced + local) for a company.
Used for dropdown/autocomplete in UI.
"""
suppliers = []
# Get synced suppliers
stmt = select(SyncedSupplier).where(SyncedSupplier.company_id == company_id)
if search:
stmt = stmt.where(
(SyncedSupplier.name.ilike(f"%{search}%")) |
(SyncedSupplier.fiscal_code.ilike(f"%{search}%"))
)
stmt = stmt.limit(50) # Limit results for performance
result = await session.execute(stmt)
synced = result.scalars().all()
for s in synced:
suppliers.append({
"id": s.id,
"oracle_id": s.oracle_id,
"name": s.name,
"fiscal_code": s.fiscal_code,
"source": "synced"
})
# Get local suppliers
stmt = select(LocalSupplier).where(LocalSupplier.company_id == company_id)
if search:
stmt = stmt.where(
(LocalSupplier.name.ilike(f"%{search}%")) |
(LocalSupplier.fiscal_code.ilike(f"%{search}%"))
)
stmt = stmt.limit(50)
result = await session.execute(stmt)
local = result.scalars().all()
for l in local:
suppliers.append({
"id": l.id,
"name": l.name,
"fiscal_code": l.fiscal_code,
"source": "local"
})
return suppliers
@staticmethod
async def get_all_cash_registers(
session: AsyncSession,
company_id: int
) -> List[dict]:
"""
Get all cash registers for a company.
Used for dropdown in UI.
"""
stmt = select(SyncedCashRegister).where(SyncedCashRegister.company_id == company_id)
result = await session.execute(stmt)
registers = result.scalars().all()
return [
{
"id": r.id,
"oracle_id": r.oracle_id,
"name": r.name,
"account_code": r.account_code,
"register_type": r.register_type
}
for r in registers
]

View File

@@ -0,0 +1,89 @@
"""add nomenclature tables
Revision ID: 3a653da79002
Revises: 1cfb423c6953
Create Date: 2025-12-13 00:28:05.719430+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '3a653da79002'
down_revision: Union[str, None] = '1cfb423c6953'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('local_suppliers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('company_id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('fiscal_code', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True),
sa.Column('address', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True),
sa.Column('created_by', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('pending_oracle_sync', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('local_suppliers', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_local_suppliers_company_id'), ['company_id'], unique=False)
batch_op.create_index(batch_op.f('ix_local_suppliers_fiscal_code'), ['fiscal_code'], unique=False)
op.create_table('synced_cash_registers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('oracle_id', sa.Integer(), nullable=False),
sa.Column('company_id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column('account_code', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('register_type', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False),
sa.Column('synced_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('synced_cash_registers', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_synced_cash_registers_company_id'), ['company_id'], unique=False)
batch_op.create_index(batch_op.f('ix_synced_cash_registers_oracle_id'), ['oracle_id'], unique=False)
op.create_table('synced_suppliers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('oracle_id', sa.Integer(), nullable=False),
sa.Column('company_id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('fiscal_code', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True),
sa.Column('address', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('synced_suppliers', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_synced_suppliers_company_id'), ['company_id'], unique=False)
batch_op.create_index(batch_op.f('ix_synced_suppliers_fiscal_code'), ['fiscal_code'], unique=False)
batch_op.create_index(batch_op.f('ix_synced_suppliers_oracle_id'), ['oracle_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('synced_suppliers', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_synced_suppliers_oracle_id'))
batch_op.drop_index(batch_op.f('ix_synced_suppliers_fiscal_code'))
batch_op.drop_index(batch_op.f('ix_synced_suppliers_company_id'))
op.drop_table('synced_suppliers')
with op.batch_alter_table('synced_cash_registers', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_synced_cash_registers_oracle_id'))
batch_op.drop_index(batch_op.f('ix_synced_cash_registers_company_id'))
op.drop_table('synced_cash_registers')
with op.batch_alter_table('local_suppliers', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_local_suppliers_fiscal_code'))
batch_op.drop_index(batch_op.f('ix_local_suppliers_company_id'))
op.drop_table('local_suppliers')
# ### end Alembic commands ###

View File

@@ -0,0 +1,346 @@
# Plan: Implementare Auth SSO + Nomenclatoare Sync
> **Plan Handover Document** - Salvat pentru continuare în altă sesiune
> **Data**: 2025-12-13 | **Branch**: `feature/data-entry-receipts`
## Obiectiv
Integrare autentificare SSO și sincronizare nomenclatoare Oracle în data-entry-app conform `IMPLEMENTATION_PLAN_AUTH_UNITAR.md`.
---
## Instrucțiuni Implementare
### Metodologie
1. **Execută fazele în paralel** unde e posibil (Faza 1+2 pot rula simultan, Faza 3+4 pot rula simultan)
2. **Folosește agenți Task** pentru viteza - lansează agenți în paralel pentru task-uri independente
3. **Testează după fiecare fază** - nu trece la următoarea fără validare
4. **Urmărește progresul** în acest fișier - marchează task-urile completate cu ✅
### Comenzi de Start
```bash
# Asigură-te că SSH tunnel rulează (pentru Oracle)
./ssh_tunnel.sh start
# Backend reports (pentru auth API - port 8001)
cd reports-app/backend && uvicorn app.main:app --reload --port 8001
# Backend data-entry (port 8003)
cd data-entry-app/backend && uvicorn app.main:app --reload --port 8003
# Frontend data-entry (port 3010)
cd data-entry-app/frontend && npm run dev
```
### Progres Implementare
- [x] **FAZA 1**: Auth Backend - ✅ 6/6 task-uri COMPLETE
- [x] **FAZA 2**: Auth Frontend - ✅ 6/6 task-uri COMPLETE
- [x] **FAZA 3**: Nomenclatoare Sync - ✅ 6/6 task-uri COMPLETE
- [x] **FAZA 4**: OCR + Supplier Search - ✅ 2/2 task-uri COMPLETE
> **Status**: ✅ **IMPLEMENTARE COMPLETĂ** - 2025-12-13
---
## Stare Curentă (IMPLEMENTAT)
### Backend Data-Entry ✅
- ✅ Models: Receipt, ReceiptAttachment, AccountingEntry - complete
- ✅ CRUD operations - complete
- ✅ API Routers: receipts.py, ocr.py, **nomenclature.py**
- ✅ Services: receipt_service, ocr_service, **sync_service**
- ✅ Workflow: DRAFT → PENDING → APPROVED/REJECTED
-**Auth**: Integrare shared/auth (middleware + CurrentUser)
-**Nomenclatoare**: SQLite sync (SyncedSupplier, LocalSupplier, SyncedCashRegister)
-`sys.path.insert` pentru shared/ în main.py
### Frontend Data-Entry ✅
- ✅ Views: List, Create, Detail, Approval, **LoginView**
- ✅ Components: OCR components + **Create Supplier Dialog**
- ✅ Store: receiptsStore.js + **auth.js**
- ✅ Router: routes + **auth guards + /login**
-**Auth Store**: `src/stores/auth.js` - creat
-**Login View**: `src/views/LoginView.vue` - creat
-**Router Guards**: beforeEach cu requiresAuth
-**API Service**: `src/services/api.js` - creat cu interceptors
### Shared Auth (disponibil pentru integrare)
-`shared/auth/routes.py` - `create_auth_router()` (linia 39-430)
-`shared/auth/middleware.py` - `AuthenticationMiddleware`
-`shared/auth/dependencies.py` - `get_current_user`
-`shared/auth/models.py` - `CurrentUser`, `TokenResponse`
### Referință Reports-App (pentru copiere)
- `reports-app/frontend/src/stores/auth.js` - 119 linii
- `reports-app/frontend/src/services/api.js` - 141 linii
- `reports-app/frontend/src/views/LoginView.vue` - 367 linii
- `reports-app/frontend/src/router/index.js` - auth guard la liniile 96-114
---
## Faze Implementare
### FAZA 1: Auth Backend (6 task-uri)
#### Task 1.1: Adaugă AuthenticationMiddleware în main.py
**Fișier**: `data-entry-app/backend/app/main.py`
**Acțiune**: După CORS middleware (linia 75), adaugă:
```python
from auth.middleware import AuthenticationMiddleware
app.add_middleware(
AuthenticationMiddleware,
excluded_paths=["/docs", "/redoc", "/openapi.json", "/health", "/", "/api/auth/login", "/api/auth/refresh"]
)
```
#### Task 1.2: Adaugă Auth Router în main.py
**Fișier**: `data-entry-app/backend/app/main.py`
**Acțiune**: După include_router pentru ocr (linia 98), adaugă:
```python
from auth.routes import create_auth_router
auth_router = create_auth_router()
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
```
#### Task 1.3: Înlocuiește get_current_user în receipts.py
**Fișier**: `data-entry-app/backend/app/routers/receipts.py`
**Acțiune**: Șterge liniile 38-59 și înlocuiește cu:
```python
from auth.dependencies import get_current_user
from auth.models import CurrentUser
```
Apoi actualizează type hints: `current_user: str``current_user: CurrentUser`
Și accesează `current_user.username` în loc de `current_user`
#### Task 1.4: Înlocuiește get_current_user în ocr.py
**Fișier**: `data-entry-app/backend/app/routers/ocr.py`
**Acțiune**: Similar cu receipts.py, adaugă importurile auth și folosește `CurrentUser`
#### Task 1.5: Actualizează type hints în toate endpoint-urile
Actualizează toate funcțiile care folosesc `current_user: str` să folosească `current_user: CurrentUser`
#### Task 1.6: Testare backend auth
```bash
cd data-entry-app/backend
uvicorn app.main:app --reload --port 8003
# Test: curl http://localhost:8003/api/receipts/ → 401 Unauthorized
```
---
### FAZA 2: Auth Frontend (6 task-uri)
#### Task 2.1: Crează API service
**Fișier NOU**: `data-entry-app/frontend/src/services/api.js`
**Acțiune**: Copiază din `reports-app/frontend/src/services/api.js` cu modificări:
- Schimbă BASE_URL pentru a funcționa cu proxy-ul
- Modifică refresh token URL
#### Task 2.2: Crează Auth Store
**Fișier NOU**: `data-entry-app/frontend/src/stores/auth.js`
**Acțiune**: Copiază din `reports-app/frontend/src/stores/auth.js`
- Modifică import apiService din `../services/api`
#### Task 2.3: Crează LoginView
**Fișier NOU**: `data-entry-app/frontend/src/views/LoginView.vue`
**Acțiune**: Copiază din `reports-app/frontend/src/views/LoginView.vue`
- Schimbă titlul: "ROA Reports" → "Data Entry"
- Schimbă subtitle: "Rapoarte ERP" → "Introducere Bonuri Fiscale"
- Schimbă redirect după login: "/dashboard" → "/"
#### Task 2.4: Actualizează Router cu auth guards
**Fișier**: `data-entry-app/frontend/src/router/index.js`
**Acțiune**: Adaugă auth guard similar cu reports-app (liniile 96-114)
```javascript
import { useAuthStore } from '@/stores/auth'
// Adaugă rută login
// Adaugă meta: { requiresAuth: true } la rutele protejate
// Adaugă beforeEach guard
```
#### Task 2.5: Actualizează vite.config.js pentru auth proxy
**Fișier**: `data-entry-app/frontend/vite.config.js`
**Acțiune**: Adaugă proxy pentru auth:
```javascript
'/api/auth': {
target: 'http://localhost:8001',
changeOrigin: true,
}
```
#### Task 2.6: Testare frontend auth
```bash
cd data-entry-app/frontend
npm run dev
# Test: Accesează http://localhost:3010 → Redirect la /login
# Login cu credențiale Oracle → Redirect la /
```
---
### FAZA 3: Nomenclatoare Oracle→SQLite (6 task-uri)
#### Task 3.1: Crează modele SQLModel
**Fișier NOU**: `data-entry-app/backend/app/db/models/nomenclature.py`
- `SyncedSupplier` - furnizori sincronizați din Oracle
- `LocalSupplier` - furnizori creați local (din OCR)
- `SyncedCashRegister` - case/bănci sincronizate
#### Task 3.2: Crează Alembic migration
```bash
cd data-entry-app/backend
alembic revision --autogenerate -m "add nomenclature tables"
alembic upgrade head
```
#### Task 3.3: Crează Sync Service
**Fișier NOU**: `data-entry-app/backend/app/services/sync_service.py`
- `sync_suppliers(company_id, schema)` - sync furnizori Oracle→SQLite
- `sync_cash_registers(company_id, schema)` - sync case/bănci
- `get_schema_for_company(company_id)` - lookup schema
#### Task 3.4: Crează Nomenclature Router
**Fișier NOU**: `data-entry-app/backend/app/routers/nomenclature.py`
- `GET /suppliers/search` - căutare furnizor (SQLite + Oracle live)
- `POST /suppliers/local` - creare furnizor local
- `POST /sync/suppliers` - trigger manual sync
#### Task 3.5: Înregistrează router în main.py
```python
from app.routers import nomenclature
app.include_router(nomenclature.router, prefix="/api/nomenclature", tags=["nomenclature"])
```
#### Task 3.6: Actualizare nomenclature_service.py existent
Înlocuiește mock data cu query-uri din tabelele SQLite sincronizate
---
### FAZA 4: Integrare OCR + Supplier Search (2 task-uri)
#### Task 4.1: Actualizare ReceiptCreateView.vue
**Fișier**: `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue`
**Acțiune**: După OCR result, caută automat furnizor după CUI:
```javascript
async function handleOCRResult(ocrData) {
if (ocrData.cui) {
const result = await receiptsStore.searchSupplier(ocrData.cui);
if (result.found) {
form.partner_id = result.supplier.id;
form.partner_name = result.supplier.name;
} else {
showCreateSupplierDialog(ocrData);
}
}
}
```
#### Task 4.2: Adaugă supplier search în receiptsStore.js
**Fișier**: `data-entry-app/frontend/src/stores/receiptsStore.js`
**Acțiune**: Adaugă action `searchSupplier(fiscalCode)` și `createLocalSupplier(data)`
---
## Sumar Fișiere
### De Modificat
| Fișier | Faza |
|--------|------|
| `data-entry-app/backend/app/main.py` | 1, 3 |
| `data-entry-app/backend/app/routers/receipts.py` | 1 |
| `data-entry-app/backend/app/routers/ocr.py` | 1 |
| `data-entry-app/frontend/src/router/index.js` | 2 |
| `data-entry-app/frontend/vite.config.js` | 2 |
| `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue` | 4 |
| `data-entry-app/frontend/src/stores/receiptsStore.js` | 4 |
### De Creat (NOU)
| Fișier | Faza |
|--------|------|
| `data-entry-app/frontend/src/services/api.js` | 2 |
| `data-entry-app/frontend/src/stores/auth.js` | 2 |
| `data-entry-app/frontend/src/views/LoginView.vue` | 2 |
| `data-entry-app/backend/app/db/models/nomenclature.py` | 3 |
| `data-entry-app/backend/app/services/sync_service.py` | 3 |
| `data-entry-app/backend/app/routers/nomenclature.py` | 3 |
| `migrations/versions/xxx_nomenclature.py` | 3 |
---
## Ordine Execuție
**Faza 1 + 2 (Auth)****Faza 3 + 4 (Nomenclatoare)**
Fazele 1-2 sunt blocante pentru funcționalitatea completă, dar Faza 3-4 poate fi amânată dacă e nevoie (nomenclatoarele rămân mock data temporar).
---
## Strategie Execuție cu Agenți
### Agenți Paraleli Recomandați
**Round 1 - Auth (Backend + Frontend simultan):**
```
Agent A: Faza 1 - Task 1.1-1.5 (Backend auth)
Agent B: Faza 2 - Task 2.1-2.3 (Frontend auth files)
```
După Round 1, testare manuală auth flow.
**Round 2 - Finalizare Auth + Start Nomenclatoare:**
```
Agent A: Faza 2 - Task 2.4-2.5 (Router guards, vite config)
Agent B: Faza 3 - Task 3.1-3.2 (Modele SQLModel + migration)
```
**Round 3 - Nomenclatoare + Integration:**
```
Agent A: Faza 3 - Task 3.3-3.6 (Sync service + router)
Agent B: Faza 4 - Task 4.1-4.2 (Frontend OCR supplier)
```
### Validare După Fiecare Fază
**După Faza 1:**
```bash
curl http://localhost:8003/api/receipts/
# Expected: 401 Unauthorized
```
**După Faza 2:**
```bash
# Browser: http://localhost:3010
# Expected: Redirect to /login
# Login cu credențiale Oracle → Redirect la /
```
**După Faza 3:**
```bash
curl http://localhost:8003/api/nomenclature/suppliers/search?fiscal_code=RO12345678
# Expected: Search result sau sugestie creare local
```
**După Faza 4:**
```
# Browser: Crează bon nou → Upload poză → OCR
# Expected: Furnizor găsit automat sau dialog creare
```
---
## Context pentru Sesiune Următoare
### Fișiere Cheie de Citit
1. Acest plan: `/home/marius/.claude/plans/unified-orbiting-sonnet.md`
2. CLAUDE.md principal: `/mnt/e/proiecte/roa2web/CLAUDE.md`
3. CLAUDE.md data-entry: `/mnt/e/proiecte/roa2web/data-entry-app/CLAUDE.md`
### Comenzi Quick Start
```bash
cd /mnt/e/proiecte/roa2web
git status # Verifică branch feature/data-entry-receipts
./ssh_tunnel.sh start # SSH tunnel pentru Oracle
```
### Dependențe Servicii
- **reports-backend:8001** - NECESAR pentru auth API (login, refresh)
- **data-entry-backend:8003** - Backend principal
- **Oracle DB** - Via SSH tunnel, necesar pentru auth + nomenclatoare

View File

@@ -1,254 +0,0 @@
# Plan: OCR Inteligent cu Early Exit
> **Context Handover Document** - Plan de implementare pentru următoarea sesiune
## Obiectiv
Optimizare proces OCR - dacă PaddleOCR pe light preprocessing dă rezultate bune, să NU mai ruleze heavy preprocessing și Tesseract.
---
## Criterii Early Exit (TOATE trebuie îndeplinite)
**Continuă cu alte încercări DACĂ:**
- Confidență < **85%** SAU
- Lipsește ORICARE din câmpurile critice:
- Număr bon (`receipt_number`)
- Dată (`receipt_date`)
- Valoare totală (`amount`)
- Valoare TVA (`tva_total` sau `tva_entries`)
- Cod fiscal (`cui`)
**Early exit DOAR când:**
- Confidență >= 85% **ȘI**
- TOATE 5 câmpurile sunt extrase
---
## Flow Propus: Adaptive OCR Pipeline
```
1. PaddleOCR + Light Preprocessing (cel mai rapid, cel mai bun pentru PDF-uri clare)
Verifică: conf >= 85% AND toate 5 câmpurile extrase?
├─ DA → STOP, returnează rezultat
└─ NU → continuă la pasul 2
2. PaddleOCR + Heavy Preprocessing (pentru bonuri termice șterse)
Combină cu rezultatul anterior (merge)
Verifică: toate câmpurile extrase acum?
├─ DA → STOP
└─ NU → continuă la pasul 3
3. Tesseract + Light (fallback pentru cazuri dificile)
Combină toate rezultatele
Returnează cel mai bun rezultat combinat
```
---
## Beneficii Estimate
| Tip document | OCR calls | Timp estimat |
|--------------|-----------|--------------|
| PDF clar (Kineterra) | 1 | ~2-3 sec |
| PDF mediu | 2 | ~5 sec |
| Bon termic șters | 3 | ~8-10 sec |
**Comparație cu acum:** Totdeauna 4 calls → maxim 3, de obicei 1-2
---
## Fișier de Modificat
**`data-entry-app/backend/app/services/ocr_service.py`**
### Înlocuire completă `_process_sync()`:
```python
def _process_sync(
self,
image_path: Path,
mime_type: str
) -> Tuple[bool, str, Optional[ExtractionResult]]:
"""Synchronous processing with ADAPTIVE OCR pipeline."""
logger.info(f"[OCR Service] Starting processing: {image_path}, mime: {mime_type}")
# Load image
if mime_type == 'application/pdf':
try:
images = self.preprocessor.pdf_to_images(image_path)
if not images:
return False, "Failed to extract images from PDF", None
image = images[0]
except RuntimeError as e:
return False, str(e), None
else:
try:
image = self.preprocessor.load_image(image_path)
except ValueError as e:
return False, str(e), None
raw_texts = []
extraction = None
# ══════════════════════════════════════════════════════════════
# STEP 1: PaddleOCR + Light (fastest, best for clear PDFs)
# ══════════════════════════════════════════════════════════════
logger.info("[OCR] Step 1: PaddleOCR + Light preprocessing")
light_img = self.preprocessor.preprocess_light(image)
try:
paddle_light = self.ocr_engine._paddle_recognize(light_img)
if paddle_light and paddle_light.text:
extraction = self.extractor.extract(paddle_light.text)
extraction.ocr_engine = "paddle-light"
raw_texts.append(f"═══ PaddleOCR (light, conf: {paddle_light.confidence:.0%}) ═══\n{paddle_light.text}")
# Early exit if complete
if self._is_extraction_complete(extraction):
extraction.raw_text = "\n\n".join(raw_texts)
logger.info("[OCR] ✓ Early exit: complete extraction from paddle-light")
return True, "OCR complete (fast mode)", extraction
except Exception as e:
logger.warning(f"[OCR] PaddleOCR light failed: {e}")
extraction = ExtractionResult()
# ══════════════════════════════════════════════════════════════
# STEP 2: PaddleOCR + Heavy (for faded thermal receipts)
# ══════════════════════════════════════════════════════════════
logger.info("[OCR] Step 2: PaddleOCR + Heavy preprocessing")
heavy_img = self.preprocessor.preprocess_heavy(image)
try:
paddle_heavy = self.ocr_engine._paddle_recognize(heavy_img)
if paddle_heavy and paddle_heavy.text:
extraction_heavy = self.extractor.extract(paddle_heavy.text)
extraction_heavy.ocr_engine = "paddle-heavy"
raw_texts.append(f"═══ PaddleOCR (heavy, conf: {paddle_heavy.confidence:.0%}) ═══\n{paddle_heavy.text}")
# Merge with previous
extraction = self._merge_extractions(extraction, extraction_heavy)
if self._is_extraction_complete(extraction):
extraction.raw_text = "\n\n".join(raw_texts)
extraction.ocr_engine = "paddle-adaptive"
logger.info("[OCR] ✓ Early exit: complete extraction after paddle-heavy")
return True, "OCR complete (paddle dual)", extraction
except Exception as e:
logger.warning(f"[OCR] PaddleOCR heavy failed: {e}")
# ══════════════════════════════════════════════════════════════
# STEP 3: Tesseract fallback
# ══════════════════════════════════════════════════════════════
logger.info("[OCR] Step 3: Tesseract fallback")
try:
tesseract_result = self.ocr_engine._tesseract_recognize(light_img)
if tesseract_result and tesseract_result.text:
extraction_tess = self.extractor.extract(tesseract_result.text)
extraction_tess.ocr_engine = "tesseract"
raw_texts.append(f"═══ Tesseract (conf: {tesseract_result.confidence:.0%}) ═══\n{tesseract_result.text}")
extraction = self._merge_extractions(extraction, extraction_tess)
except Exception as e:
logger.warning(f"[OCR] Tesseract failed: {e}")
# Final result
if extraction is None:
return False, "No text detected", None
extraction.raw_text = "\n\n".join(raw_texts)
extraction.ocr_engine = "adaptive-full"
# Build result message
fields_found = []
if extraction.amount: fields_found.append("amount")
if extraction.receipt_date: fields_found.append("date")
if extraction.receipt_number: fields_found.append("number")
if extraction.cui: fields_found.append("CUI")
if extraction.tva_total or extraction.tva_entries: fields_found.append("TVA")
message = f"OCR complete (full pipeline). Found: {', '.join(fields_found) or 'no fields'}"
logger.info(f"[OCR] Final result: {message}")
return True, message, extraction
```
### Adăugare metodă `_is_extraction_complete()`:
```python
def _is_extraction_complete(self, ext: ExtractionResult, min_confidence: float = 0.85) -> bool:
"""
Check if extraction has ALL required fields to skip further processing.
Required for early exit (ALL must be true):
- Overall confidence >= 85%
- ALL 5 critical fields present: number, date, amount, TVA, CUI
"""
# Must have high confidence
if ext.overall_confidence < min_confidence:
logger.info(f"[OCR] Confidence {ext.overall_confidence:.0%} < {min_confidence:.0%} - continuing")
return False
# Check all required fields
has_number = bool(ext.receipt_number)
has_date = bool(ext.receipt_date)
has_amount = bool(ext.amount)
has_tva = bool(ext.tva_total) or bool(ext.tva_entries)
has_cui = bool(ext.cui)
missing = []
if not has_number: missing.append("number")
if not has_date: missing.append("date")
if not has_amount: missing.append("amount")
if not has_tva: missing.append("TVA")
if not has_cui: missing.append("CUI")
if missing:
logger.info(f"[OCR] Missing: {', '.join(missing)} - continuing")
return False
logger.info(f"[OCR] ✓ All 5 fields found with {ext.overall_confidence:.0%} confidence")
return True
```
---
## Cod de Șters
După implementare, poți șterge:
- `_merge_all_extractions()` - înlocuită de flow secvențial
- `_format_dual_raw_text()` - nefolosită
- Bucla `for i, processed in enumerate(variants):` - înlocuită complet
---
## Context: Rezultate OCR Kineterra
Din testele anterioare, **PaddleOCR + Light** a dat cele mai bune rezultate:
| Variantă | Conf | CUI | Adresa |
|----------|------|-----|--------|
| **PaddleOCR Light** | **83%** | **31180432** ✓ | MUN.CONSTANTA ✓ |
| PaddleOCR Heavy | 83% | 31189432 ✗ | CONSTANTN ✗ |
| Tesseract Light | 50% | 31100400 ✗ | corupt |
| Tesseract Heavy | 42% | - | corupt |
---
## Testare
După implementare, testează cu toate PDF-urile:
1. **`abonament kineterra.pdf`** - ar trebui să facă early exit la Step 1
2. **`benzina 27 octombrie.pdf`** - verifică extracție completă
3. **`igiena 11 octombrie.pdf`** - verifică extracție completă
4. **`benzina 14 august.pdf`** - verifică extracție completă
---
*Generat: 2024-12-12*
*Pentru continuare în următoarea sesiune Claude*

View File

@@ -1,6 +1,6 @@
<template>
<div class="app-container">
<header class="app-header">
<header v-if="authStore.isAuthenticated" class="app-header">
<div class="header-content">
<h1 class="app-title">
<i class="pi pi-receipt"></i>
@@ -17,6 +17,19 @@
<i class="pi pi-check-circle"></i> Aprobare
<Badge v-if="pendingCount > 0" :value="pendingCount" severity="danger" />
</router-link>
<div class="user-menu">
<span class="user-name">
<i class="pi pi-user"></i>
{{ authStore.currentUser?.username || 'User' }}
</span>
<Button
icon="pi pi-sign-out"
label="Ieșire"
class="logout-button"
@click="handleLogout"
text
/>
</div>
</nav>
</div>
</header>
@@ -32,18 +45,29 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useReceiptsStore } from './stores/receiptsStore'
const router = useRouter()
const authStore = useAuthStore()
const receiptsStore = useReceiptsStore()
const pendingCount = ref(0)
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
onMounted(async () => {
if (authStore.isAuthenticated) {
try {
const stats = await receiptsStore.fetchStats()
pendingCount.value = stats?.pending_review?.count || 0
} catch (error) {
console.error('Failed to fetch stats:', error)
}
}
})
</script>
@@ -83,6 +107,7 @@ onMounted(async () => {
.app-nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
.nav-link {
@@ -105,6 +130,33 @@ onMounted(async () => {
background-color: rgba(255, 255, 255, 0.3);
}
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
margin-left: 1rem;
padding-left: 1rem;
border-left: 1px solid rgba(255, 255, 255, 0.3);
}
.user-name {
display: flex;
align-items: center;
gap: 0.5rem;
color: white;
font-weight: 500;
font-size: 0.9rem;
}
.logout-button {
color: white !important;
padding: 0.5rem 1rem;
}
.logout-button:hover {
background-color: rgba(255, 255, 255, 0.2) !important;
}
.app-main {
flex: 1;
padding: 2rem;

View File

@@ -1,5 +1,28 @@
/* Global styles for Data Entry App */
:root {
/* Colors - Primary palette (matching reports-app) */
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-primary-light: #3b82f6;
/* Compatibility aliases */
--primary-color: var(--color-primary);
--text-color: #111827;
--text-color-secondary: #6b7280;
/* Surface colors for PrimeVue */
--surface-0: #ffffff;
--surface-50: #f8fafc;
--surface-100: #f1f5f9;
--surface-200: #e2e8f0;
/* Red color palette for errors */
--red-50: #fef2f2;
--red-200: #fecaca;
--red-800: #991b1b;
}
* {
box-sizing: border-box;
}

View File

@@ -11,6 +11,7 @@ import router from './router'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Password from 'primevue/password'
import Dropdown from 'primevue/dropdown'
import Calendar from 'primevue/calendar'
import Textarea from 'primevue/textarea'
@@ -31,6 +32,7 @@ import ProgressSpinner from 'primevue/progressspinner'
import Badge from 'primevue/badge'
import Toolbar from 'primevue/toolbar'
import Divider from 'primevue/divider'
import Tooltip from 'primevue/tooltip'
// PrimeVue styles
import 'primevue/resources/themes/lara-light-blue/theme.css'
@@ -57,6 +59,7 @@ app.use(ConfirmationService)
app.component('Button', Button)
app.component('InputText', InputText)
app.component('InputNumber', InputNumber)
app.component('Password', Password)
app.component('Dropdown', Dropdown)
app.component('Calendar', Calendar)
app.component('Textarea', Textarea)
@@ -78,4 +81,7 @@ app.component('Badge', Badge)
app.component('Toolbar', Toolbar)
app.component('Divider', Divider)
// Register PrimeVue directives
app.directive('tooltip', Tooltip)
app.mount('#app')

View File

@@ -1,35 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue'),
meta: { title: 'Conectare', requiresAuth: false }
},
{
path: '/',
name: 'ReceiptsList',
component: () => import('../views/receipts/ReceiptsListView.vue'),
meta: { title: 'Lista Bonuri' }
meta: { title: 'Lista Bonuri', requiresAuth: true }
},
{
path: '/create',
name: 'ReceiptCreate',
component: () => import('../views/receipts/ReceiptCreateView.vue'),
meta: { title: 'Bon Nou' }
meta: { title: 'Bon Nou', requiresAuth: true }
},
{
path: '/receipt/:id',
name: 'ReceiptDetail',
component: () => import('../views/receipts/ReceiptDetailView.vue'),
meta: { title: 'Detalii Bon' }
meta: { title: 'Detalii Bon', requiresAuth: true }
},
{
path: '/receipt/:id/edit',
name: 'ReceiptEdit',
component: () => import('../views/receipts/ReceiptCreateView.vue'),
meta: { title: 'Editare Bon' }
meta: { title: 'Editare Bon', requiresAuth: true }
},
{
path: '/approval',
name: 'ReceiptApproval',
component: () => import('../views/receipts/ReceiptApprovalView.vue'),
meta: { title: 'Aprobare Bonuri' }
meta: { title: 'Aprobare Bonuri', requiresAuth: true }
}
]
@@ -38,12 +45,26 @@ const router = createRouter({
routes
})
// Update page title
// Authentication guard and page title
router.beforeEach((to, from, next) => {
// Update page title
document.title = to.meta.title
? `${to.meta.title} | Data Entry`
: 'Data Entry - Bonuri Fiscale'
// Check authentication
const authStore = useAuthStore()
const requiresAuth = to.meta.requiresAuth !== false
if (requiresAuth && !authStore.isAuthenticated) {
// Redirect to login if not authenticated
next({ name: 'Login', query: { redirect: to.fullPath } })
} else if (to.name === 'Login' && authStore.isAuthenticated) {
// Redirect to home if already authenticated
next({ name: 'ReceiptsList' })
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,82 @@
import axios from "axios";
// Create axios instance with base configuration
const apiService = axios.create({
baseURL: import.meta.env.BASE_URL + "api",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor to add auth token
apiService.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// Response interceptor for handling errors and token refresh
apiService.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// Handle 401 Unauthorized errors
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
const response = await axios.post(
import.meta.env.BASE_URL + "api/auth/refresh",
{ refresh_token: refreshToken },
);
const { access_token } = response.data;
localStorage.setItem("access_token", access_token);
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
originalRequest.headers["Authorization"] = `Bearer ${access_token}`;
return apiService(originalRequest);
}
} catch (refreshError) {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
const loginPath = import.meta.env.BASE_URL + "login";
if (window.location.pathname !== loginPath) {
window.location.href = loginPath;
}
return Promise.reject(refreshError);
}
}
if (error.response) {
const message = error.response.data?.detail || error.response.data?.message || `Server error: ${error.response.status}`;
console.error("API Error:", { status: error.response.status, message, url: error.config.url });
} else if (error.request) {
console.error("Network Error:", error.message);
} else {
console.error("Request Error:", error.message);
}
return Promise.reject(error);
},
);
export { apiService };
export default apiService;

View File

@@ -0,0 +1,11 @@
/**
* Auth Store for Data Entry App
*
* Uses the shared auth store factory from shared/frontend/stores/auth.js
* Configured with the data-entry API service (port 8003)
*/
import { createAuthStore } from "../../../../shared/frontend/stores/auth";
import { apiService } from "../services/api";
export const useAuthStore = createAuthStore(apiService);

View File

@@ -1,12 +1,13 @@
import { defineStore } from 'pinia'
import axios from 'axios'
import { apiService } from '../services/api'
const api = axios.create({
baseURL: '/api/receipts',
headers: {
'Content-Type': 'application/json',
},
})
// Create receipts-specific API wrapper
const api = {
get: (url, config) => apiService.get(`/receipts${url}`, config),
post: (url, data, config) => apiService.post(`/receipts${url}`, data, config),
put: (url, data, config) => apiService.put(`/receipts${url}`, data, config),
delete: (url, config) => apiService.delete(`/receipts${url}`, config),
}
export const useReceiptsStore = defineStore('receipts', {
state: () => ({
@@ -324,6 +325,33 @@ export const useReceiptsStore = defineStore('receipts', {
])
},
async searchSupplier(fiscalCode) {
try {
const response = await apiService.get('/nomenclature/suppliers/search', {
params: { fiscal_code: fiscalCode },
})
return response.data
} catch (error) {
console.error('Supplier search failed:', error)
return { found: false, source: 'error' }
}
},
async createLocalSupplier(data) {
try {
const response = await apiService.post('/nomenclature/suppliers/local', data)
// Add to local partners list
this.partners.push({
id: response.data.id,
name: response.data.name,
code: response.data.fiscal_code,
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to create supplier')
}
},
// ============ Stats ============
async fetchStats() {

View File

@@ -0,0 +1,16 @@
<template>
<SharedLoginView
app-title="Data Entry"
app-subtitle="Introducere Bonuri Fiscale"
app-icon="pi-file-edit"
redirect-path="/"
:auth-store="authStore"
/>
</template>
<script setup>
import SharedLoginView from "@shared/frontend/components/LoginView.vue";
import { useAuthStore } from "../stores/auth";
const authStore = useAuthStore();
</script>

View File

@@ -328,6 +328,42 @@
</div>
</form>
</div>
<!-- Create Supplier Dialog -->
<Dialog
v-model:visible="showCreateSupplierDialog"
header="Furnizor Negasit"
:modal="true"
:style="{ width: '450px' }"
>
<div class="dialog-content">
<p>
<i class="pi pi-exclamation-triangle" style="color: var(--orange-500);"></i>
Furnizorul cu CUI <strong>{{ pendingSupplierData?.fiscal_code }}</strong> nu a fost gasit in baza de date.
</p>
<p>Doriti sa creati un furnizor local cu datele extrase din bon?</p>
<div class="form-field" style="margin-top: 1rem;">
<label>Nume Furnizor</label>
<InputText v-model="pendingSupplierData.name" class="w-full" />
</div>
<div class="form-field">
<label>CUI</label>
<InputText v-model="pendingSupplierData.fiscal_code" class="w-full" disabled />
</div>
<div class="form-field">
<label>Adresa</label>
<InputText v-model="pendingSupplierData.address" class="w-full" />
</div>
</div>
<template #footer>
<Button label="Anuleaza" severity="secondary" @click="cancelCreateSupplier" />
<Button label="Creaza Furnizor" icon="pi pi-plus" @click="createLocalSupplier" />
</template>
</Dialog>
</div>
</template>
@@ -338,6 +374,7 @@ import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
import OCRPreview from '../../components/ocr/OCRPreview.vue'
import Dialog from 'primevue/dialog'
const route = useRoute()
const router = useRouter()
@@ -379,6 +416,10 @@ const ocrUploadZone = ref(null)
const ocrData = ref(null)
const ocrFile = ref(null)
// Supplier dialog refs
const showCreateSupplierDialog = ref(false)
const pendingSupplierData = ref(null)
const partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes)
const cashRegisters = computed(() => store.cashRegisters)
@@ -452,42 +493,21 @@ const onOCRError = (message) => {
})
}
const applyOCRData = (data) => {
// Apply OCR data to form
const applyOCRData = async (data) => {
// Apply basic OCR data to form
if (data.receipt_type) {
form.value.receipt_type = data.receipt_type
}
if (data.receipt_date) {
form.value.receipt_date = new Date(data.receipt_date)
}
if (data.amount) {
form.value.amount = parseFloat(data.amount)
}
if (data.receipt_number) {
form.value.receipt_number = data.receipt_number
}
// Try to find matching partner by name or CUI
if (data.partner_name || data.cui) {
const matchingPartner = partners.value.find(p => {
const nameMatch = data.partner_name &&
p.name.toLowerCase().includes(data.partner_name.toLowerCase())
const cuiMatch = data.cui && p.cui === data.cui
return nameMatch || cuiMatch
})
if (matchingPartner) {
form.value.partner_id = matchingPartner.id
form.value.partner_name = matchingPartner.name
} else if (data.partner_name) {
// Store the extracted name even if no match
form.value.partner_name = data.partner_name
}
}
// Apply TVA entries
if (data.tva_entries?.length > 0) {
form.value.tva_breakdown = data.tva_entries.map(e => ({
@@ -496,14 +516,52 @@ const applyOCRData = (data) => {
amount: parseFloat(e.amount)
}))
}
if (data.tva_total) {
form.value.tva_total = parseFloat(data.tva_total)
if (data.tva_total) form.value.tva_total = parseFloat(data.tva_total)
if (data.items_count) form.value.items_count = data.items_count
if (data.address) form.value.vendor_address = data.address
// Auto-search supplier by CUI if available
if (data.cui) {
toast.add({
severity: 'info',
summary: 'Cautare furnizor',
detail: `Se cauta furnizor dupa CUI: ${data.cui}`,
life: 2000,
})
const result = await store.searchSupplier(data.cui)
if (result.found && result.supplier) {
// Found! Auto-select
form.value.partner_id = result.supplier.id
form.value.partner_name = result.supplier.name
toast.add({
severity: 'success',
summary: 'Furnizor gasit',
detail: `${result.supplier.name} (${result.source})`,
life: 3000,
})
} else {
// Not found - offer to create
pendingSupplierData.value = {
name: data.partner_name || '',
fiscal_code: data.cui,
address: data.address || '',
}
if (data.items_count) {
form.value.items_count = data.items_count
showCreateSupplierDialog.value = true
}
} else if (data.partner_name) {
// No CUI but have name - try name search
const matchingPartner = partners.value.find(p =>
p.name.toLowerCase().includes(data.partner_name.toLowerCase())
)
if (matchingPartner) {
form.value.partner_id = matchingPartner.id
form.value.partner_name = matchingPartner.name
} else {
form.value.partner_name = data.partner_name
}
if (data.address) {
form.value.vendor_address = data.address
}
// Clear OCR preview
@@ -521,6 +579,40 @@ const dismissOCRData = () => {
ocrData.value = null
}
const createLocalSupplier = async () => {
if (!pendingSupplierData.value) return
try {
const supplier = await store.createLocalSupplier(pendingSupplierData.value)
// Auto-select the new supplier
form.value.partner_id = supplier.id
form.value.partner_name = supplier.name
toast.add({
severity: 'success',
summary: 'Furnizor creat',
detail: `${supplier.name} a fost adaugat`,
life: 3000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
} finally {
showCreateSupplierDialog.value = false
pendingSupplierData.value = null
}
}
const cancelCreateSupplier = () => {
showCreateSupplierDialog.value = false
pendingSupplierData.value = null
}
const onPartnerChange = (event) => {
const partner = partners.value.find(p => p.id === event.value)
form.value.partner_name = partner?.name || null
@@ -853,4 +945,33 @@ const submitForReview = async () => {
font-weight: 600;
color: #0284c7;
}
/* Dialog content */
.dialog-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dialog-content p {
margin: 0;
line-height: 1.6;
}
.dialog-content p:first-child {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.dialog-content .form-field {
margin-bottom: 0.5rem;
}
.dialog-content .form-field label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: #334155;
}
</style>

View File

@@ -6,12 +6,17 @@ export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../../shared', import.meta.url))
}
},
server: {
port: 3010,
proxy: {
'/api/auth': {
target: 'http://localhost:8001',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8003',
changeOrigin: true,

8989
docs/PACK_CONTAFIN.pck Normal file

File diff suppressed because it is too large Load Diff

16928
docs/PACK_FACTURARE.pck Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -33,8 +33,11 @@ Aplicatie separata pentru introducere date in ERP, cu workflow de aprobare si st
- Scalare independenta
**Shared Components**:
- `shared/database/oracle_pool.py` - conexiune Oracle pentru nomenclatoare
- `shared/auth/` - autentificare JWT comuna
- `shared/database/oracle_pool.py` - conexiune Oracle pentru nomenclatoare si autentificare
- `shared/auth/` - autentificare JWT comuna (middleware, routes factory, auth service)
- `shared/frontend/components/LoginView.vue` - UI login partajat
- `shared/frontend/stores/auth.js` - Pinia auth store factory
- `shared/frontend/styles/login.css` - stiluri login
### 3. Workflow cu Staging Area

View File

@@ -1,119 +1,11 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
/**
* Auth Store for Reports App
*
* Uses the shared auth store factory from shared/frontend/stores/auth.js
* Configured with the reports API service (port 8001)
*/
import { createAuthStore } from "../../../../shared/frontend/stores/auth";
import { apiService } from "../services/api";
export const useAuthStore = defineStore("auth", () => {
// State
const accessToken = ref(localStorage.getItem("access_token"));
const refreshToken = ref(localStorage.getItem("refresh_token"));
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
const isLoading = ref(false);
const error = ref(null);
// Getters
const isAuthenticated = computed(() => !!accessToken.value);
const currentUser = computed(() => user.value);
// Actions
const login = async (credentials) => {
isLoading.value = true;
error.value = null;
try {
const loginData = {
username: credentials.username,
password: credentials.password,
};
const response = await apiService.post("/auth/login", loginData);
const { access_token, refresh_token, user: userData } = response.data;
accessToken.value = access_token;
refreshToken.value = refresh_token;
user.value = userData;
localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token);
localStorage.setItem("user", JSON.stringify(userData));
apiService.defaults.headers.common["Authorization"] =
`Bearer ${access_token}`;
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Login failed";
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const logout = () => {
accessToken.value = null;
refreshToken.value = null;
user.value = null;
error.value = null;
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
// Note: selected_company is now per-user and persists across logout/login
// It's stored as 'selected_company_${username}' in localStorage
delete apiService.defaults.headers.common["Authorization"];
};
const refreshAccessToken = async () => {
if (!refreshToken.value) {
logout();
return false;
}
try {
const response = await apiService.post("/auth/refresh", {
refresh_token: refreshToken.value,
});
const { access_token } = response.data;
accessToken.value = access_token;
localStorage.setItem("access_token", access_token);
apiService.defaults.headers.common["Authorization"] =
`Bearer ${access_token}`;
return true;
} catch (err) {
console.error("Token refresh failed:", err);
logout();
return false;
}
};
const initializeAuth = () => {
if (accessToken.value) {
apiService.defaults.headers.common["Authorization"] =
`Bearer ${accessToken.value}`;
}
};
const clearError = () => {
error.value = null;
};
initializeAuth();
return {
accessToken,
refreshToken,
user,
isLoading,
error,
isAuthenticated,
currentUser,
login,
logout,
refreshAccessToken,
initializeAuth,
clearError,
};
});
export const useAuthStore = createAuthStore(apiService);

View File

@@ -1,367 +1,16 @@
<template>
<div class="login-container">
<div class="login-wrapper">
<Card class="login-card">
<template #header>
<div class="login-header">
<i class="pi pi-chart-bar text-primary text-6xl"></i>
<h1 class="login-title">ROA Reports</h1>
<p class="login-subtitle">Rapoarte ERP - Facturi și Încasări</p>
</div>
</template>
<template #content>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username" class="form-label required"
>Utilizator</label
>
<InputText
id="username"
v-model="credentials.username"
placeholder="Introduceți numele de utilizator"
:class="{ invalid: formErrors.username }"
class="w-full"
autocomplete="username"
@blur="validateField('username')"
<SharedLoginView
app-title="ROA Reports"
app-subtitle="Rapoarte ERP - Facturi și Încasări"
app-icon="pi-chart-bar"
redirect-path="/dashboard"
:auth-store="authStore"
/>
<span v-if="formErrors.username" class="form-error">
{{ formErrors.username }}
</span>
</div>
<div class="form-group">
<label for="password" class="form-label required">Parolă</label>
<Password
id="password"
v-model="credentials.password"
placeholder="Introduceți parola"
:class="{ invalid: formErrors.password }"
class="w-full"
:feedback="false"
toggle-mask
autocomplete="current-password"
@blur="validateField('password')"
/>
<span v-if="formErrors.password" class="form-error">
{{ formErrors.password }}
</span>
</div>
<div v-if="authStore.error" class="error-message">
<i class="pi pi-exclamation-triangle"></i>
<span>{{ authStore.error }}</span>
</div>
<Button
type="submit"
label="Conectare"
class="w-full login-button"
:loading="authStore.isLoading"
:disabled="!isFormValid"
/>
</form>
</template>
<template #footer>
<div class="login-footer">
<small class="text-color-secondary">
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate
</small>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useToast } from "primevue/usetoast";
import SharedLoginView from "@shared/frontend/components/LoginView.vue";
import { useAuthStore } from "../stores/auth";
const router = useRouter();
const toast = useToast();
const authStore = useAuthStore();
// Form data
const credentials = ref({
username: "",
password: "",
});
const formErrors = ref({
username: "",
password: "",
});
// Computed properties
const currentYear = computed(() => new Date().getFullYear());
const isFormValid = computed(() => {
return (
credentials.value.username.trim() !== "" &&
credentials.value.password.trim() !== "" &&
!formErrors.value.username &&
!formErrors.value.password
);
});
// Methods
const validateField = (field) => {
switch (field) {
case "username":
formErrors.value.username =
credentials.value.username.trim() === ""
? "Numele de utilizator este obligatoriu"
: "";
break;
case "password":
formErrors.value.password =
credentials.value.password.trim() === ""
? "Parola este obligatorie"
: "";
break;
}
};
const validateForm = () => {
validateField("username");
validateField("password");
return isFormValid.value;
};
const handleLogin = async () => {
if (!validateForm()) {
return;
}
try {
const result = await authStore.login(credentials.value);
if (result.success) {
// Redirect to dashboard (removed welcome notification)
router.push("/dashboard");
} else {
toast.add({
severity: "error",
summary: "Eroare de conectare",
detail: result.error || "Date de conectare incorecte",
life: 5000,
});
}
} catch (error) {
console.error("Login error:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "A apărut o eroare neașteptată",
life: 5000,
});
}
};
// Clear errors when user starts typing
const clearErrors = () => {
authStore.clearError();
formErrors.value = {
username: "",
password: "",
};
};
// Lifecycle hooks
onMounted(() => {
// Clear any previous errors
clearErrors();
// Focus on username field
const usernameInput = document.getElementById("username");
if (usernameInput) {
usernameInput.focus();
}
});
onUnmounted(() => {
clearErrors();
});
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--color-primary-light) 0%,
var(--color-primary) 100%
);
padding: 1rem;
}
.login-wrapper {
width: 100%;
max-width: 400px;
}
.login-card {
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.login-header {
text-align: center;
padding: 2rem 2rem 1rem 2rem;
background: white;
}
.login-title {
margin: 1rem 0 0.5rem 0;
color: var(--primary-color);
font-size: 2rem;
font-weight: 700;
}
.login-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.95rem;
}
.login-form {
padding: 0 2rem 2rem 2rem;
}
.login-button {
margin-top: 1rem;
padding: 0.75rem;
font-size: 1.1rem;
font-weight: 600;
background: var(--color-primary-light) !important;
color: white !important;
border: none !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.login-button:hover {
background: var(--color-primary) !important;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.login-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background-color: var(--red-50);
color: var(--red-800);
border: 1px solid var(--red-200);
border-radius: 6px;
font-size: 0.9rem;
}
.login-footer {
text-align: center;
padding: 1rem 2rem;
background-color: var(--surface-50);
border-top: 1px solid var(--surface-200);
}
/* Responsive design */
@media (max-width: 768px) {
.login-container {
padding: 0.5rem;
}
.login-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.login-card {
border-radius: 8px;
}
.login-header {
padding: 1.5rem 1rem;
}
.login-title {
font-size: 1.5rem;
}
.login-form {
padding: 0 1rem 1.5rem 1rem;
}
/* Ensure inputs are touch-friendly */
.p-inputtext,
.p-password input {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
}
.login-footer {
padding: 1rem;
}
}
@media (max-width: 480px) {
.login-container {
padding: 0.25rem;
}
.login-card {
margin: 0;
}
.login-header {
padding: 1rem 0.5rem;
}
.login-title {
font-size: 1.25rem;
}
.login-subtitle {
font-size: 0.875rem;
}
.login-form {
padding: 0 0.5rem 1rem 0.5rem;
}
.login-footer {
padding: 0.75rem 0.5rem;
}
}
/* Animation for smooth transitions */
.login-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -18,7 +18,8 @@ export default defineConfig({
base: process.env.NODE_ENV === 'production' ? '/roa2web/' : '/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../../shared', import.meta.url))
}
},
server: {

View File

@@ -116,6 +116,9 @@ def create_auth_router(
logger.info(f"Successful login for user {login_data.username}")
return token_response
except HTTPException:
# Re-raise HTTP exceptions as-is (e.g., 401 for invalid credentials)
raise
except AuthenticationError as e:
logger.error(f"Authentication error for user {login_data.username}: {str(e)}")
raise HTTPException(

View File

@@ -0,0 +1,212 @@
<template>
<div class="login-container">
<div class="login-wrapper">
<Card class="login-card">
<template #header>
<div class="login-header">
<i :class="['pi', appIcon, 'text-primary', 'text-6xl']"></i>
<h1 class="login-title">{{ appTitle }}</h1>
<p class="login-subtitle">{{ appSubtitle }}</p>
</div>
</template>
<template #content>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username" class="form-label required">Utilizator</label>
<InputText
id="username"
v-model="credentials.username"
placeholder="Introduceți numele de utilizator"
:class="{ invalid: formErrors.username }"
class="w-full"
autocomplete="username"
@blur="validateField('username')"
/>
<span v-if="formErrors.username" class="form-error">
{{ formErrors.username }}
</span>
</div>
<div class="form-group">
<label for="password" class="form-label required">Parolă</label>
<Password
id="password"
v-model="credentials.password"
placeholder="Introduceți parola"
:class="{ invalid: formErrors.password }"
class="w-full"
:feedback="false"
toggle-mask
autocomplete="current-password"
@blur="validateField('password')"
/>
<span v-if="formErrors.password" class="form-error">
{{ formErrors.password }}
</span>
</div>
<div v-if="authStore.error" class="login-error-message">
<i class="pi pi-exclamation-triangle"></i>
<span>{{ authStore.error }}</span>
</div>
<Button
type="submit"
label="Conectare"
class="w-full login-button"
:loading="authStore.isLoading"
:disabled="!isFormValid"
/>
</form>
</template>
<template #footer>
<div class="login-footer">
<small class="text-color-secondary">
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate
</small>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useToast } from "primevue/usetoast";
// Props for app-specific customization
const props = defineProps({
appTitle: {
type: String,
required: true,
},
appSubtitle: {
type: String,
required: true,
},
appIcon: {
type: String,
required: true,
},
redirectPath: {
type: String,
default: "/",
},
authStore: {
type: Object,
required: true,
},
});
const router = useRouter();
const toast = useToast();
// Form data
const credentials = ref({
username: "",
password: "",
});
const formErrors = ref({
username: "",
password: "",
});
// Computed properties
const currentYear = computed(() => new Date().getFullYear());
const isFormValid = computed(() => {
return (
credentials.value.username.trim() !== "" &&
credentials.value.password.trim() !== "" &&
!formErrors.value.username &&
!formErrors.value.password
);
});
// Methods
const validateField = (field) => {
switch (field) {
case "username":
formErrors.value.username =
credentials.value.username.trim() === ""
? "Numele de utilizator este obligatoriu"
: "";
break;
case "password":
formErrors.value.password =
credentials.value.password.trim() === ""
? "Parola este obligatorie"
: "";
break;
}
};
const validateForm = () => {
validateField("username");
validateField("password");
return isFormValid.value;
};
const handleLogin = async () => {
if (!validateForm()) {
return;
}
try {
const result = await props.authStore.login(credentials.value);
if (result.success) {
router.push(props.redirectPath);
} else {
toast.add({
severity: "error",
summary: "Eroare de conectare",
detail: result.error || "Date de conectare incorecte",
life: 5000,
});
}
} catch (error) {
console.error("Login error:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "A apărut o eroare neașteptată",
life: 5000,
});
}
};
// Clear errors when user starts typing
const clearErrors = () => {
props.authStore.clearError();
formErrors.value = {
username: "",
password: "",
};
};
// Lifecycle hooks
onMounted(() => {
// Clear any previous errors
clearErrors();
// Focus on username field
const usernameInput = document.getElementById("username");
if (usernameInput) {
usernameInput.focus();
}
});
onUnmounted(() => {
clearErrors();
});
</script>
<style>
@import "../styles/login.css";
</style>

View File

@@ -0,0 +1,133 @@
/**
* Shared Auth Store Factory
*
* Creates a Pinia auth store that can be used by any ROA2WEB application.
* Each app passes its own apiService instance configured with the correct baseURL.
*
* Usage:
* import { createAuthStore } from '@shared/frontend/stores/auth';
* import { apiService } from '../services/api';
* export const useAuthStore = createAuthStore(apiService);
*/
import { defineStore } from "pinia";
import { ref, computed } from "vue";
/**
* Factory function to create an auth store with the provided API service
* @param {Object} apiService - Axios instance configured for the app's API
* @returns {Function} Pinia store definition
*/
export function createAuthStore(apiService) {
return defineStore("auth", () => {
// State
const accessToken = ref(localStorage.getItem("access_token"));
const refreshToken = ref(localStorage.getItem("refresh_token"));
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
const isLoading = ref(false);
const error = ref(null);
// Getters
const isAuthenticated = computed(() => !!accessToken.value);
const currentUser = computed(() => user.value);
// Actions
const login = async (credentials) => {
isLoading.value = true;
error.value = null;
try {
const response = await apiService.post("/auth/login", {
username: credentials.username,
password: credentials.password,
});
const { access_token, refresh_token, user: userData } = response.data;
accessToken.value = access_token;
refreshToken.value = refresh_token;
user.value = userData;
localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token);
localStorage.setItem("user", JSON.stringify(userData));
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Login failed";
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const logout = () => {
accessToken.value = null;
refreshToken.value = null;
user.value = null;
error.value = null;
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
delete apiService.defaults.headers.common["Authorization"];
};
const refreshAccessToken = async () => {
if (!refreshToken.value) {
logout();
return false;
}
try {
const response = await apiService.post("/auth/refresh", {
refresh_token: refreshToken.value,
});
const { access_token } = response.data;
accessToken.value = access_token;
localStorage.setItem("access_token", access_token);
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
return true;
} catch (err) {
console.error("Token refresh failed:", err);
logout();
return false;
}
};
const initializeAuth = () => {
if (accessToken.value) {
apiService.defaults.headers.common["Authorization"] = `Bearer ${accessToken.value}`;
}
};
const clearError = () => {
error.value = null;
};
// Initialize on store creation
initializeAuth();
return {
// State
accessToken,
refreshToken,
user,
isLoading,
error,
// Getters
isAuthenticated,
currentUser,
// Actions
login,
logout,
refreshAccessToken,
initializeAuth,
clearError,
};
});
}

View File

@@ -0,0 +1,177 @@
/* Shared Login Page Styles */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--color-primary-light) 0%,
var(--color-primary) 100%
);
padding: 1rem;
}
.login-wrapper {
width: 100%;
max-width: 400px;
}
.login-card {
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.login-header {
text-align: center;
padding: 2rem 2rem 1rem 2rem;
background: white;
}
.login-title {
margin: 1rem 0 0.5rem 0;
color: var(--primary-color);
font-size: 2rem;
font-weight: 700;
}
.login-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.95rem;
}
.login-form {
padding: 0 2rem 2rem 2rem;
}
.login-button {
margin-top: 1rem;
padding: 0.75rem;
font-size: 1.1rem;
font-weight: 600;
background: var(--color-primary-light) !important;
color: white !important;
border: none !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.login-button:hover {
background: var(--color-primary) !important;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.login-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.login-error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background-color: var(--red-50);
color: var(--red-800);
border: 1px solid var(--red-200);
border-radius: 6px;
font-size: 0.9rem;
}
.login-footer {
text-align: center;
padding: 1rem 2rem;
background-color: var(--surface-50);
border-top: 1px solid var(--surface-200);
}
/* Responsive design */
@media (max-width: 768px) {
.login-container {
padding: 0.5rem;
}
.login-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.login-card {
border-radius: 8px;
}
.login-header {
padding: 1.5rem 1rem;
}
.login-title {
font-size: 1.5rem;
}
.login-form {
padding: 0 1rem 1.5rem 1rem;
}
/* Ensure inputs are touch-friendly */
.login-container .p-inputtext,
.login-container .p-password input {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
}
.login-footer {
padding: 1rem;
}
}
@media (max-width: 480px) {
.login-container {
padding: 0.25rem;
}
.login-card {
margin: 0;
}
.login-header {
padding: 1rem 0.5rem;
}
.login-title {
font-size: 1.25rem;
}
.login-subtitle {
font-size: 0.875rem;
}
.login-form {
padding: 0 0.5rem 1rem 0.5rem;
}
.login-footer {
padding: 0.75rem 0.5rem;
}
}
/* Animation for smooth transitions */
.login-card {
animation: loginFadeInUp 0.6s ease-out;
}
@keyframes loginFadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -342,8 +342,47 @@ trap cleanup SIGINT SIGTERM
print_message "Starting Data Entry Development Environment..."
echo
# Step 1: Start Backend
print_message "1. Starting Backend (FastAPI)..."
# Step 1: Start Frontend FIRST (for fast UI availability)
print_message "1. Starting Frontend (Vue.js)..."
cd data-entry-app/frontend/
# Check if node_modules exists
if [ ! -d "node_modules" ]; then
print_message "Installing frontend dependencies..."
npm install
fi
# Check for WSL compatibility
if [ -f "node_modules/.bin/vite.cmd" ] && [ ! -f "node_modules/.bin/vite" ]; then
print_warning "Windows node_modules detected, reinstalling for WSL..."
rm -rf node_modules package-lock.json
npm install
fi
# Start frontend in background
print_message "Starting Vite development server..."
npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 &
FRONTEND_PID=$!
# Wait for frontend to start
sleep 3
for i in {1..10}; do
if check_port 3010; then
print_success "Frontend started on http://localhost:3010"
break
fi
if [ $i -eq 10 ]; then
print_error "Frontend failed to start after 10 attempts"
print_message "Check log at /tmp/data_entry_frontend.log"
cat /tmp/data_entry_frontend.log
cleanup
fi
sleep 1
done
# Step 2: Start Backend (with OCR loading in background)
print_message "2. Starting Backend (FastAPI + OCR)..."
# Check if backend port is already in use
if check_port 8003; then
@@ -355,7 +394,7 @@ if check_port 8003; then
fi
fi
cd data-entry-app/backend/
cd ../backend/
# Check if .env file exists
if [ ! -f ".env" ]; then
@@ -391,59 +430,22 @@ mkdir -p data/uploads
print_message "Running database migrations..."
alembic upgrade head 2>/dev/null || print_warning "Migration may have already been applied or first run"
# Start backend in background
print_message "Starting uvicorn server..."
uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload &
# Start backend in background (OCR loads asynchronously)
print_message "Starting uvicorn server (OCR loads in background)..."
uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 &
BACKEND_PID=$!
# Wait for backend to start
sleep 2
for i in {1..10}; do
if check_port 8003; then
print_success "Backend started successfully on http://localhost:8003"
break
fi
if [ $i -eq 10 ]; then
print_error "Backend failed to start after 10 attempts"
cleanup
fi
sleep 1
done
# Step 2: Start Frontend
print_message "2. Starting Frontend (Vue.js)..."
cd ../frontend/
# Check if node_modules exists
if [ ! -d "node_modules" ]; then
print_message "Installing frontend dependencies..."
npm install
fi
# Check for WSL compatibility
if [ -f "node_modules/.bin/vite.cmd" ] && [ ! -f "node_modules/.bin/vite" ]; then
print_warning "Windows node_modules detected, reinstalling for WSL..."
rm -rf node_modules package-lock.json
npm install
fi
# Start frontend in background
print_message "Starting Vite development server..."
npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 &
FRONTEND_PID=$!
# Wait for frontend to start
# Wait for backend to start (uvicorn --reload takes longer to bind)
sleep 3
for i in {1..10}; do
if check_port 3010; then
print_success "Frontend started successfully on http://localhost:3010"
for i in {1..20}; do
if check_port 8003; then
print_success "Backend started on http://localhost:8003"
print_message "Note: OCR engine loading in background (first OCR request may be slow)"
break
fi
if [ $i -eq 10 ]; then
print_error "Frontend failed to start after 10 attempts"
print_message "Check log at /tmp/data_entry_frontend.log"
cat /tmp/data_entry_frontend.log
if [ $i -eq 20 ]; then
print_error "Backend failed to start after 20 attempts"
cat /tmp/data_entry_backend.log
cleanup
fi
sleep 1