feat: Add shared components, refactor stores, improve data-entry workflow

Shared Components:
- Add CompanySelector.vue and PeriodSelector.vue components
- Add AppHeader.vue and SlideMenu.vue layout components
- Add shared stores factories (companies.js, accountingPeriod.js)
- Add shared routes factories (companies.py, calendar.py)
- Add shared models (company.py, calendar.py)
- Add shared layout styles (header.css, navigation.css)

Data Entry App:
- Update CLAUDE.md with prod/test server documentation
- Improve nomenclature sync service with better error handling
- Update receipts router and CRUD operations
- Add company/period stores using shared factories
- Update App.vue layout with shared components
- Fix OCRUploadZone file handling

Reports App:
- Refactor stores to use shared factories
- Update App.vue to use shared layout components

Infrastructure:
- Replace start-data-entry.sh with separate dev/test scripts
- Add .claude/rules for authentication, backend patterns, etc.
- Add implementation plan for OCR receipt improvements
- Clean up old documentation files

🤖 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-15 15:00:45 +02:00
parent c5fde510a8
commit 1a6e9b17d2
47 changed files with 4079 additions and 2595 deletions

View File

@@ -57,6 +57,89 @@ data-entry-app/
- `shared/frontend/stores/auth.js` - Pinia auth store factory
- `shared/frontend/styles/login.css` - Stiluri login
## Servere Oracle (Producție vs Test)
**IMPORTANT**: Există două servere Oracle separate. Verifică întotdeauna la care ești conectat!
| Server | IP Oracle | Tunel SSH | Schema Verificare | Company ID |
|--------|-----------|-----------|-------------------|------------|
| **PRODUCȚIE** | `10.0.20.36` | `./ssh_tunnel.sh` | `ROMFAST` | 114 |
| **TEST** | `10.0.20.121` | `./ssh-tunnel-test.sh` | `MARIUSM_AUTO` | 110 |
### Scripturi de Pornire
**IMPORTANT**: Folosește scriptul corespunzător mediului dorit!
```bash
# Pentru PRODUCȚIE (10.0.20.36)
./start-data-entry-dev.sh # Pornește tot (tunel + backend + frontend)
./start-data-entry-dev.sh stop # Oprește tot
./start-data-entry-dev.sh status # Verifică status
# Pentru TEST (10.0.20.121)
./start-data-entry-test.sh # Pornește tot (tunel + backend + frontend)
./start-data-entry-test.sh stop # Oprește tot
./start-data-entry-test.sh status # Verifică status
```
Scripturile fac automat:
1. Opresc tunelul celuilalt mediu (dacă rulează)
2. Pornesc tunelul corect
3. Copiază `.env.prod` sau `.env.test` în `.env`
4. Rulează migrările pe baza de date corectă
5. Pornesc frontend și backend
### Fișiere .env
| Fișier | Server | Database | Schema Test |
|--------|--------|----------|-------------|
| `.env.prod` | PRODUCȚIE (10.0.20.36) | `receipts_prod.db` | ROMFAST (id=114) |
| `.env.test` | TEST (10.0.20.121) | `receipts_test.db` | MARIUSM_AUTO (id=110) |
### Verificare conexiune
```bash
# Verifică la ce server ești conectat
./ssh_tunnel.sh status # Dacă rulează = PRODUCȚIE
./ssh-tunnel-test.sh status # Dacă rulează = TEST
# Verifică schema disponibilă (din backend/)
python3 -c "
import asyncio
from dotenv import load_dotenv
load_dotenv()
import sys; sys.path.insert(0, '../../shared')
from database.oracle_pool import oracle_pool
async def check():
await oracle_pool.initialize()
async with oracle_pool.get_connection() as conn:
with conn.cursor() as cur:
cur.execute('SELECT SCHEMA, NUME FROM CONTAFIN_ORACLE.V_NOM_FIRME ORDER BY NUME')
for row in cur.fetchall()[:10]:
print(f'{row[0]}: {row[1]}')
asyncio.run(check())
"
```
### Sincronizare Nomenclatoare
Query-ul pentru furnizori folosește `CORESP_TIP_PART`:
- `id_tip_part = 17` → Furnizori
- `id_tip_part = 22` → Casa LEI
- `id_tip_part = 23` → Casa Valută
- `id_tip_part = 24` → Bancă LEI
- `id_tip_part = 25` → Bancă Valută
```sql
-- Exemplu query furnizori pentru schema MARIUSM_AUTO (TEST)
SELECT B.ID_PART, B.DENUMIRE, B.COD_FISCAL, B.ADRESA
FROM MARIUSM_AUTO.CORESP_TIP_PART A
INNER JOIN MARIUSM_AUTO.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART
WHERE A.ID_TIP_PART = 17 AND (B.INACTIV = 0 OR B.INACTIV IS NULL)
ORDER BY B.DENUMIRE;
```
## Comenzi Dezvoltare
```bash
@@ -135,6 +218,15 @@ cd frontend && npm run test
## Common Issues
### Nomenclatoare goale / furnizori lipsă
- Verifică la ce server Oracle ești conectat: `./ssh_tunnel.sh status`
- Verifică dacă schema firmei selectate există pe acel server
- Sincronizează manual: `POST /api/nomenclature/sync/all`
### Conectat la serverul greșit
- PRODUCȚIE are schema `ROMFAST`, TEST are schema `MARIUSM_AUTO`
- Oprește tunelul curent și pornește cel corect (vezi secțiunea "Servere Oracle")
### SQLite locked
- Asigura-te ca nu ai multiple procese care acceseaza DB-ul

View File

@@ -1,455 +0,0 @@
# 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

@@ -1,273 +0,0 @@
# 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

@@ -59,7 +59,9 @@ class ReceiptCRUD:
session.add(receipt)
await session.commit()
await session.refresh(receipt)
return receipt
# Reload with relationships to avoid lazy loading issues with async
return await ReceiptCRUD.get_by_id(session, receipt.id, include_relations=True)
@staticmethod
async def get_by_id(
@@ -175,7 +177,9 @@ class ReceiptCRUD:
session.add(receipt)
await session.commit()
await session.refresh(receipt)
return receipt
# Reload with relationships to avoid lazy loading issues with async
return await ReceiptCRUD.get_by_id(session, receipt.id, include_relations=True)
@staticmethod
async def update_status(
@@ -206,7 +210,9 @@ class ReceiptCRUD:
session.add(receipt)
await session.commit()
await session.refresh(receipt)
return receipt
# Reload with relationships to avoid lazy loading issues with async
return await ReceiptCRUD.get_by_id(session, receipt.id, include_relations=True)
@staticmethod
async def delete(session: AsyncSession, receipt: Receipt) -> bool:

View File

@@ -132,6 +132,16 @@ 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"])
# Shared routes (companies, calendar)
from routes.companies import create_companies_router
from routes.calendar import create_calendar_router
companies_router = create_companies_router(oracle_pool) # No cache for data-entry
calendar_router = create_calendar_router(oracle_pool)
app.include_router(companies_router, prefix="/api/companies", tags=["companies"])
app.include_router(calendar_router, prefix="/api/calendar", tags=["calendar"])
# Root endpoint
@app.get("/")

View File

@@ -1,7 +1,7 @@
"""Nomenclature API endpoints."""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from typing import Optional, List, Annotated
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
@@ -20,6 +20,38 @@ from auth.models import CurrentUser
router = APIRouter()
# ============ Selected Company Dependency ============
async def get_selected_company(
current_user: CurrentUser = Depends(get_current_user),
x_selected_company: Annotated[Optional[str], Header()] = None
) -> int:
"""
Get selected company from X-Selected-Company header.
Validates user access. Falls back to first company if no header.
"""
if x_selected_company:
try:
company_id = int(x_selected_company)
except ValueError:
raise HTTPException(400, f"Invalid company ID: {x_selected_company}")
if str(company_id) in current_user.companies:
return company_id
raise HTTPException(403, f"Nu aveți acces la firma {company_id}")
if current_user.companies:
try:
return int(current_user.companies[0])
except (ValueError, IndexError):
pass
raise HTTPException(400, "Nu aveți nicio firmă asignată")
SelectedCompany = Annotated[int, Depends(get_selected_company)]
# Request/Response Models
class SupplierSearchResult(BaseModel):
found: bool
@@ -70,14 +102,13 @@ async def search_supplier(
name: Optional[str] = None,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""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)
cid = company_id or selected_company
found, supplier, source = await SyncService.search_supplier(
session, cid, fiscal_code, name
@@ -91,10 +122,10 @@ 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),
selected_company: SelectedCompany = None,
):
"""Get all suppliers (synced + local) for dropdown/autocomplete."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
suppliers = await SyncService.get_all_suppliers(session, cid, search)
@@ -115,10 +146,11 @@ async def create_local_supplier(
data: LocalSupplierCreate,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
selected_company: SelectedCompany = None,
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)
cid = company_id or selected_company
supplier = await SyncService.create_local_supplier(
session, cid, data.name, data.fiscal_code, data.address, current_user.username
@@ -136,10 +168,10 @@ async def create_local_supplier(
async def get_cash_registers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Get all cash registers for a company."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
registers = await SyncService.get_all_cash_registers(session, cid)
@@ -159,10 +191,10 @@ async def get_cash_registers(
async def sync_suppliers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Manually trigger supplier sync from Oracle."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
synced, errors = await SyncService.sync_suppliers(session, cid)
@@ -177,10 +209,10 @@ async def sync_suppliers(
async def sync_cash_registers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Manually trigger cash register sync from Oracle."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
synced, errors = await SyncService.sync_cash_registers(session, cid)
@@ -195,10 +227,10 @@ async def sync_cash_registers(
async def sync_all_nomenclatures(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Sync all nomenclatures (suppliers + cash registers) from Oracle."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
# Sync suppliers
suppliers_synced, suppliers_errors = await SyncService.sync_suppliers(session, cid)

View File

@@ -1,9 +1,9 @@
"""API endpoints for receipts."""
from typing import List, Optional
from typing import List, Optional, Annotated
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Header
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,20 +39,69 @@ from auth.models import CurrentUser
router = APIRouter()
# ============ Helper for current user's company ============
# ============ Helper for selected company from header ============
async def get_selected_company(
current_user: CurrentUser = Depends(get_current_user),
x_selected_company: Annotated[Optional[str], Header()] = None
) -> int:
"""
Get selected company from X-Selected-Company header.
Validates that the user has access to the specified company.
Falls back to user's first company if no header is provided.
Raises:
HTTPException 403: If user doesn't have access to specified company
HTTPException 400: If user has no companies assigned
"""
if x_selected_company:
try:
company_id = int(x_selected_company)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid company ID format: {x_selected_company}"
)
# Validate user has access to this company
# Auth stores companies as strings
if str(company_id) in current_user.companies:
return company_id
raise HTTPException(
status_code=403,
detail=f"Nu aveți acces la firma {company_id}"
)
# No header - use first company from user's list
if current_user.companies:
try:
return int(current_user.companies[0])
except (ValueError, IndexError):
pass
raise HTTPException(
status_code=400,
detail="Nu aveți nicio firmă asignată"
)
# Dependency for injection
SelectedCompany = Annotated[int, Depends(get_selected_company)]
# Legacy function for backwards compatibility (deprecated)
def get_current_user_company(current_user: CurrentUser) -> int:
"""
Get current user's active company.
Returns the first company from the user's companies list.
In future, this can be enhanced to use a session-based active company.
DEPRECATED: Use get_selected_company() dependency instead.
This function returns the first company, ignoring X-Selected-Company header.
"""
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
try:
return int(current_user.companies[0])
except (ValueError, IndexError):
return 1
return 1
@@ -80,16 +129,14 @@ 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: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""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,
company_id=company_id or selected_company,
created_by=created_by,
date_from=date_type.fromisoformat(date_from) if date_from else None,
date_to=date_type.fromisoformat(date_to) if date_to else None,
@@ -105,12 +152,11 @@ async def list_receipts(
async def list_pending_receipts(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""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
session, company_id or selected_company
)
return [ReceiptResponse.model_validate(r) for r in receipts]
@@ -120,13 +166,13 @@ async def get_receipt_stats(
company_id: Optional[int] = None,
my_receipts: bool = False,
session: AsyncSession = Depends(get_session),
selected_company: SelectedCompany = None,
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,
company_id or selected_company,
created_by=current_user.username if my_receipts else None,
)
@@ -415,12 +461,11 @@ async def get_partners(
search: Optional[str] = None,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""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, session
company_id or selected_company, search, session
)
@@ -428,12 +473,11 @@ async def get_partners(
async def get_accounts(
prefix: Optional[str] = None,
company_id: Optional[int] = None,
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""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
company_id or selected_company, prefix
)
@@ -441,11 +485,10 @@ async def get_accounts(
async def get_cash_registers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Get cash registers and bank accounts for dropdown."""
current_company = get_current_user_company(current_user)
return await NomenclatureService.get_cash_registers(company_id or current_company, session)
return await NomenclatureService.get_cash_registers(company_id or selected_company, session)
@router.get("/nomenclature/expense-types", response_model=List[ExpenseTypeOption])

View File

@@ -18,29 +18,54 @@ from app.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCash
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",
}
# Cache for schema lookups (populated dynamically from Oracle)
_schema_cache: dict[int, str] = {}
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)
async def get_schema_for_company(company_id: int) -> Optional[str]:
"""
Get Oracle schema for company ID from V_NOM_FIRME view.
Results are cached in memory for performance.
"""
# Check cache first
if company_id in _schema_cache:
return _schema_cache[company_id]
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT SCHEMA
FROM CONTAFIN_ORACLE.V_NOM_FIRME
WHERE ID_FIRMA = :company_id
""", {'company_id': company_id})
result = cursor.fetchone()
if result:
schema = result[0]
_schema_cache[company_id] = schema
logger.info(f"Resolved schema for company {company_id}: {schema}")
return schema
else:
logger.warning(f"No schema found for company {company_id}")
return None
except Exception as e:
logger.error(f"Error fetching schema for company {company_id}: {e}")
return None
@staticmethod
async def sync_suppliers(session: AsyncSession, company_id: int) -> Tuple[int, int]:
"""
Sync suppliers from Oracle NOM_PARTENERI to SQLite.
Sync suppliers (furnizori, id_tip_part=17) from Oracle to SQLite.
Uses CORESP_TIP_PART joined with VNOM_PARTENERI view.
Returns (synced_count, error_count).
"""
schema = SyncService.get_schema_for_company(company_id)
schema = await SyncService.get_schema_for_company(company_id)
if not schema:
logger.warning(f"No schema mapping for company {company_id}")
return 0, 0
@@ -51,11 +76,17 @@ class SyncService:
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Fetch active partners from Oracle
# Fetch active suppliers from Oracle
# id_tip_part = 17 means "furnizori" (suppliers)
# Using CORESP_TIP_PART to filter by partner type
cursor.execute(f"""
SELECT ID_PART, DEN_PART, COD_FISCAL, ADRESA
FROM {schema}.NOM_PARTENERI
WHERE ACTIV = 1
SELECT B.ID_PART, B.DENUMIRE, B.COD_FISCAL, B.ADRESA
FROM {schema}.CORESP_TIP_PART A
INNER JOIN {schema}.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART
WHERE A.ID_TIP_PART = 17
AND (B.INACTIV = 0 OR B.INACTIV IS NULL)
AND B.ID_PART IS NOT NULL
ORDER BY B.DENUMIRE
""")
rows = cursor.fetchall()
@@ -110,10 +141,16 @@ class SyncService:
@staticmethod
async def sync_cash_registers(session: AsyncSession, company_id: int) -> Tuple[int, int]:
"""
Sync cash registers from Oracle to SQLite.
Sync cash registers and bank accounts from Oracle to SQLite.
Returns (synced_count, error_count).
Uses CORESP_TIP_PART with:
- id_tip_part = 22: CASA LEI
- id_tip_part = 23: CASA VALUTA
- id_tip_part = 24: BANCA LEI
- id_tip_part = 25: BANCA VALUTA
"""
schema = SyncService.get_schema_for_company(company_id)
schema = await SyncService.get_schema_for_company(company_id)
if not schema:
logger.warning(f"No schema mapping for company {company_id}")
return 0, 0
@@ -121,25 +158,40 @@ class SyncService:
synced = 0
errors = 0
# Partner types mapping
# 22=CASA LEI, 23=CASA VALUTA -> cash
# 24=BANCA LEI, 25=BANCA VALUTA -> bank
partner_types = [22, 23, 24, 25]
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
# Fetch cash/bank partners from CORESP_TIP_PART
cursor.execute(f"""
SELECT ID_CASA, DEN_CASA, CONT
FROM {schema}.NOM_CASE
WHERE ACTIV = 1
SELECT B.ID_PART, B.DENUMIRE, A.ID_TIP_PART
FROM {schema}.CORESP_TIP_PART A
INNER JOIN {schema}.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART
WHERE A.ID_TIP_PART IN (22, 23, 24, 25)
AND (B.INACTIV = 0 OR B.INACTIV IS NULL)
AND B.ID_PART IS NOT NULL
ORDER BY A.ID_TIP_PART, B.DENUMIRE
""")
rows = cursor.fetchall()
# Type mapping: 22=CASA LEI, 23=CASA VALUTA -> cash; 24=BANCA LEI, 25=BANCA VALUTA -> bank
type_mapping = {
22: ("cash", "CASA_LEI"),
23: ("cash", "CASA_VALUTA"),
24: ("bank", "BANCA_LEI"),
25: ("bank", "BANCA_VALUTA"),
}
for row in rows:
try:
oracle_id, name, account_code = row
oracle_id, name, tip_part_id = row
# Determine type based on account code
register_type = "cash" if account_code.startswith("531") else "bank"
# Determine type based on partner type
register_type, account_code = type_mapping.get(tip_part_id, ("cash", "UNKNOWN"))
# Check if already exists
stmt = select(SyncedCashRegister).where(
@@ -152,7 +204,7 @@ class SyncService:
if existing:
# Update existing record
existing.name = name or ""
existing.account_code = account_code or ""
existing.account_code = account_code
existing.register_type = register_type
existing.synced_at = datetime.utcnow()
logger.debug(f"Updated cash register {oracle_id}: {name}")
@@ -162,7 +214,7 @@ class SyncService:
oracle_id=oracle_id,
company_id=company_id,
name=name or "",
account_code=account_code or "",
account_code=account_code,
register_type=register_type,
)
session.add(cash_register)

View File

@@ -1,6 +1,8 @@
"""Alembic environment configuration."""
import os
from logging.config import fileConfig
from dotenv import load_dotenv
from sqlalchemy import engine_from_config
from sqlalchemy import pool
@@ -8,14 +10,22 @@ from sqlalchemy import pool
from alembic import context
from sqlmodel import SQLModel
# Load environment variables from .env file
load_dotenv()
# Import all models to ensure they're registered with SQLModel
from app.db.models.receipt import Receipt, ReceiptAttachment
from app.db.models.accounting_entry import AccountingEntry
from app.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Override sqlalchemy.url from environment variable if set
db_path = os.getenv("SQLITE_DATABASE_PATH", "data/receipts.db")
config.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:

View File

@@ -1,346 +0,0 @@
# 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,484 +0,0 @@
# Plan: Sincronizare Nomenclatoare Oracle + Auth SSO + OCR Furnizori
## Obiective
1. **Sincronizare nomenclatoare din Oracle în SQLite** (furnizori, casa/banca)
2. **Auth pentru data-entry-app** cu SSO (frontend-uri separate pe path)
3. **OCR: căutare furnizor după CUI** + creare locală dacă nu există
4. **Deploy Windows IIS** cu path routing
---
## Arhitectura Aleasă
```
roa2web.romfast.ro (IIS + ARR)
├── /reports/ → reports-app/frontend/
├── /data/ → data-entry-app/frontend/
├── /api/reports/* → reports-backend:8001
├── /api/data/* → data-entry-backend:8003
└── /api/auth/* → reports-backend (auth provider)
```
**URL-uri compacte:**
- `roa2web.romfast.ro/reports/` - Rapoarte
- `roa2web.romfast.ro/data/` - Introducere date (bonuri fiscale)
- `roa2web.romfast.ro/api/reports/` - API rapoarte
- `roa2web.romfast.ro/api/data/` - API introducere date
**SSO**: Același domeniu = localStorage partajat = token JWT valid pentru ambele
---
## Faza 1: Auth pentru Data-Entry-App
### 1.1 Backend - Integrare shared/auth/
**Fișiere de modificat:**
- `data-entry-app/backend/app/main.py`
- `data-entry-app/backend/app/routers/receipts.py`
- `data-entry-app/backend/app/core/config.py`
**Acțiuni:**
```python
# main.py - Adăugare middleware
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent / "shared"))
from auth.middleware import AuthenticationMiddleware
from auth.dependencies import get_current_user
app.add_middleware(
AuthenticationMiddleware,
excluded_paths=["/docs", "/redoc", "/openapi.json", "/health", "/"]
)
```
```python
# receipts.py - Înlocuire placeholder
from auth.dependencies import get_current_user
from auth.models import CurrentUser
@router.get("/")
async def list_receipts(
current_user: CurrentUser = Depends(get_current_user)
):
# folosește current_user.username
```
### 1.2 Frontend - Auth Store + Login Page
**Fișiere de creat/copiat din reports-app:**
- `data-entry-app/frontend/src/stores/auth.js` (copiat)
- `data-entry-app/frontend/src/views/LoginView.vue` (copiat)
- `data-entry-app/frontend/src/router/index.js` (adăugat guard)
- `data-entry-app/frontend/src/services/api.js` (axios interceptor)
**Decizie SSO:**
- Frontend data-entry folosește `/api/auth/login` de pe reports-backend
- Sau: redirect la `/login` (reports-app) care setează token în localStorage
- Token valid pentru ambele (același JWT_SECRET_KEY)
---
## Faza 2: Sincronizare Nomenclatoare Oracle → SQLite
### 2.1 Noi Modele SQLModel
**Fișier:** `data-entry-app/backend/app/db/models/nomenclature.py`
```python
class SyncedSupplier(SQLModel, table=True):
"""Furnizori sincronizați din Oracle"""
__tablename__ = "synced_suppliers"
id: int = Field(primary_key=True) # ID din Oracle (ID_PART)
company_id: int = Field(index=True)
name: str = Field(max_length=200) # DEN_PART
fiscal_code: Optional[str] = Field(max_length=20, index=True) # COD_FISCAL
address: Optional[str] = Field(max_length=500)
synced_at: datetime = Field(default_factory=datetime.utcnow)
class LocalSupplier(SQLModel, table=True):
"""Furnizori creați local din OCR (neexistenți în 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: str = Field(max_length=20, unique=True, index=True)
address: Optional[str] = Field(max_length=500)
created_by: str = Field(max_length=100)
created_at: datetime = Field(default_factory=datetime.utcnow)
oracle_synced: bool = Field(default=False) # True când e creat în Oracle
class SyncedCashRegister(SQLModel, table=True):
"""Case/Bănci sincronizate din Oracle"""
__tablename__ = "synced_cash_registers"
id: int = Field(primary_key=True) # ID din Oracle
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=20) # CASA sau BANCA
synced_at: datetime = Field(default_factory=datetime.utcnow)
```
### 2.2 Alembic Migration
**Fișier:** `data-entry-app/backend/migrations/versions/xxx_add_nomenclature_tables.py`
### 2.3 Sync Service
**Fișier:** `data-entry-app/backend/app/services/sync_service.py`
```python
class NomenclatureSyncService:
"""Sincronizare nomenclatoare din Oracle în SQLite"""
@staticmethod
async def sync_suppliers(company_id: int, schema: str) -> int:
"""Sincronizează furnizori pentru o companie"""
async with oracle_pool.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
SELECT ID_PART, DEN_PART, COD_FISCAL, ADRESA
FROM {schema}.NOM_PARTENERI
WHERE TIP_PART IN ('F', 'A') -- Furnizori sau Ambele
""")
# Upsert în SQLite
@staticmethod
async def sync_cash_registers(company_id: int, schema: str) -> int:
"""Sincronizează case și bănci"""
# Similar pentru NOM_CASE și NOM_BANCI
@staticmethod
async def get_schema_for_company(company_id: int) -> str:
"""Obține schema Oracle pentru o companie"""
# Folosește cache din shared sau query V_NOM_FIRME
```
### 2.4 Strategia de Sync Hibrid
1. **La startup app**: Sync automat (background task)
2. **Periodic**: Task programat la 4h
3. **On-demand**: Căutare live în Oracle când CUI nu există local
**Fișier:** `data-entry-app/backend/app/main.py`
```python
@app.on_event("startup")
async def startup_sync():
# Background sync pentru company-urile active
asyncio.create_task(sync_nomenclatures_background())
```
---
## Faza 3: OCR + Căutare Furnizor după CUI
### 3.1 Flow Căutare Furnizor
```
OCR extrage CUI
Căutare în SyncedSupplier (SQLite)
↓ (nu găsit)
Căutare în LocalSupplier (SQLite)
↓ (nu găsit)
Căutare LIVE în Oracle (NOM_PARTENERI)
↓ (nu găsit)
Creare LocalSupplier cu date OCR
Utilizator poate edita înainte de submit
```
### 3.2 Endpoint Căutare Furnizor
**Fișier:** `data-entry-app/backend/app/routers/nomenclature.py`
```python
@router.get("/suppliers/search")
async def search_supplier(
company_id: int,
fiscal_code: Optional[str] = None,
name: Optional[str] = None,
current_user: CurrentUser = Depends(get_current_user)
) -> SupplierSearchResult:
"""
Caută furnizor:
1. În SQLite (synced + local)
2. Live în Oracle dacă nu găsit
3. Returnează sugestie creare dacă nu există
"""
@router.post("/suppliers/local")
async def create_local_supplier(
supplier: LocalSupplierCreate,
current_user: CurrentUser = Depends(get_current_user)
) -> LocalSupplier:
"""Crează furnizor local din date OCR"""
```
### 3.3 Modificare OCR Flow în Frontend
**Fișier:** `data-entry-app/frontend/src/views/ReceiptCreateView.vue`
```javascript
// După OCR, caută automat furnizor
async function handleOCRResult(ocrData) {
if (ocrData.cui) {
const result = await api.get('/api/data-entry/suppliers/search', {
params: { company_id: selectedCompany.id, fiscal_code: ocrData.cui }
});
if (result.found) {
form.partner_id = result.supplier.id;
form.partner_name = result.supplier.name;
} else {
// Afișează opțiune creare locală
showCreateSupplierDialog(ocrData);
}
}
}
```
---
## Faza 4: Deploy Windows IIS
### 4.1 Serviciu Windows pentru data-entry-backend
**Fișier:** `deployment/windows/scripts/Install-DataEntry.ps1`
Similar cu Install-ROA2WEB.ps1 dar:
- ServiceName: `ROA2WEB-DataEntry`
- Port: 8003
- BackendPath: `C:\inetpub\wwwroot\roa2web\data-entry-app\backend`
- FrontendPath: `C:\inetpub\wwwroot\roa2web\data-entry-app\frontend`
**Actualizare Install-ROA2WEB.ps1** pentru structura unitară:
- BackendPath: `C:\inetpub\wwwroot\roa2web\reports-app\backend`
- FrontendPath: `C:\inetpub\wwwroot\roa2web\reports-app\frontend`
### 4.2 Actualizare web.config
**Fișier:** `deployment/windows/config/web.config`
Reguli URL compacte (`/reports/`, `/data/`, `/api/reports/`, `/api/data/`):
```xml
<!-- API Auth (comun) -->
<rule name="Auth API" stopProcessing="true">
<match url="^api/auth/(.*)" />
<action type="Rewrite" url="http://localhost:8001/api/auth/{R:1}" />
</rule>
<!-- API Data Entry -->
<rule name="Data Entry API" stopProcessing="true">
<match url="^api/data/(.*)" />
<action type="Rewrite" url="http://localhost:8003/api/{R:1}" />
</rule>
<!-- API Reports -->
<rule name="Reports API" stopProcessing="true">
<match url="^api/reports/(.*)" />
<action type="Rewrite" url="http://localhost:8001/api/{R:1}" />
</rule>
<!-- Frontend Data Entry SPA (/data/) -->
<rule name="Data Entry SPA" stopProcessing="true">
<match url="^data($|/.*)" />
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<action type="Rewrite" url="/data/index.html" />
</rule>
<!-- Frontend Reports SPA (/reports/) -->
<rule name="Reports SPA" stopProcessing="true">
<match url="^reports($|/.*)" />
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<action type="Rewrite" url="/reports/index.html" />
</rule>
<!-- Root redirect la /reports/ -->
<rule name="Root Redirect" stopProcessing="true">
<match url="^$" />
<action type="Redirect" url="/reports/" redirectType="Found" />
</rule>
```
**IIS Virtual Directories (pentru URL-uri compacte):**
```powershell
# /reports/ → reports-app/frontend/
New-WebVirtualDirectory -Site "Default Web Site" -Name "reports" `
-PhysicalPath "C:\inetpub\wwwroot\roa2web\reports-app\frontend"
# /data/ → data-entry-app/frontend/
New-WebVirtualDirectory -Site "Default Web Site" -Name "data" `
-PhysicalPath "C:\inetpub\wwwroot\roa2web\data-entry-app\frontend"
```
### 4.3 Structura Foldere (UNITARĂ - identică dev/prod)
**În development (git repo):**
```
roa2web/
├── reports-app/
│ ├── backend/ # FastAPI port 8001
│ ├── frontend/ # Vue.js port 3000
│ └── telegram-bot/ # Bot Telegram
├── data-entry-app/
│ ├── backend/ # FastAPI port 8003
│ └── frontend/ # Vue.js port 3010
└── shared/ # Cod partajat (auth, database)
```
**În producție (Windows IIS) - IDENTIC:**
```
C:\inetpub\wwwroot\roa2web\
├── reports-app/
│ ├── backend/ # Serviciu Windows port 8001
│ └── frontend/ # Servit de IIS pe /
├── data-entry-app/
│ ├── backend/ # Serviciu Windows port 8003
│ └── frontend/ # Servit de IIS pe /data-entry/
├── telegram-bot/ # Serviciu Windows port 8002
└── shared/ # Cod partajat
```
**Avantaje structură unitară:**
- Deploy simplu: `xcopy /E /Y source\reports-app dest\reports-app`
- Path-uri identice în cod (no surprises)
- Un singur script de deploy pentru ambele medii
---
## Faza 5: Configurare Dev (identic cu prod)
### 5.1 Vite Config pentru URL-uri Compacte
**Fișier:** `data-entry-app/frontend/vite.config.js`
```javascript
export default defineConfig({
base: '/data/', // URL compact în producție
server: {
proxy: {
'/api/auth': 'http://localhost:8001',
'/api/data': 'http://localhost:8003'
}
}
})
```
**Fișier:** `reports-app/frontend/vite.config.js` (ACTUALIZAT)
```javascript
export default defineConfig({
base: '/reports/', // URL compact în producție (era '/')
server: {
proxy: {
'/api/auth': 'http://localhost:8001',
'/api/reports': 'http://localhost:8001'
}
}
})
```
**IMPORTANT:** Actualizare API calls în frontend:
- Reports: `/api/reports/companies`, `/api/reports/invoices`, etc.
- Data Entry: `/api/data/receipts`, `/api/data/suppliers`, etc.
- Auth (comun): `/api/auth/login`, `/api/auth/refresh`
### 5.2 Script Start Unificat
**Fișier:** `start-all.sh` (nou)
```bash
#!/bin/bash
# Pornește toate serviciile pentru dev
# SSH tunnel
./ssh_tunnel.sh start
# Reports backend
cd reports-app/backend && uvicorn app.main:app --port 8001 &
# Data entry backend
cd data-entry-app/backend && uvicorn app.main:app --port 8003 &
# Reports frontend
cd reports-app/frontend && npm run dev -- --port 3000 &
# Data entry frontend
cd data-entry-app/frontend && npm run dev -- --port 3010 &
wait
```
---
## Ordine Implementare
| # | Task | Efort | Dependențe |
|---|------|-------|------------|
| 1 | Modele SQLModel nomenclatoare | 30 min | - |
| 2 | Alembic migration | 15 min | #1 |
| 3 | Sync service (Oracle → SQLite) | 2h | #2 |
| 4 | Auth middleware în data-entry-backend | 1h | - |
| 5 | Auth store + login în data-entry-frontend | 1h | #4 |
| 6 | Endpoint căutare furnizor | 1h | #3 |
| 7 | Frontend OCR + furnizor flow | 1.5h | #6 |
| 8 | web.config IIS actualizat | 30 min | - |
| 9 | Script deploy data-entry Windows | 1h | #8 |
| 10 | Testare end-to-end | 1h | all |
**Total estimat: ~10h**
---
## Fișiere Critice de Modificat/Creat
### Backend data-entry-app:
- `app/main.py` - middleware auth + startup sync
- `app/db/models/nomenclature.py` - noi modele (CREARE)
- `app/services/sync_service.py` - sync Oracle (CREARE)
- `app/services/nomenclature_service.py` - refactorizare
- `app/routers/nomenclature.py` - endpoint-uri noi (CREARE)
- `app/routers/receipts.py` - auth dependencies
- `migrations/versions/xxx_nomenclature.py` - migrare (CREARE)
### Frontend data-entry-app:
- `src/stores/auth.js` - copiat din reports-app
- `src/views/LoginView.vue` - copiat + adaptat
- `src/router/index.js` - auth guard
- `src/services/api.js` - axios config
- `src/views/ReceiptCreateView.vue` - OCR + supplier flow
### Deploy (structură unitară):
- `deployment/windows/config/web.config` - reguli noi + actualizate
- `deployment/windows/scripts/Install-ROA2WEB.ps1` - ACTUALIZAT pentru structura unitară
- `deployment/windows/scripts/Install-DataEntry.ps1` - NOU
- `deployment/windows/scripts/Build-ROA2WEB.ps1` - ACTUALIZAT pentru ambele apps
- `deployment/windows/docs/WINDOWS_DEPLOYMENT.md` - ACTUALIZAT cu noua structură
### Shared:
- Nu necesită modificări (refolosim exact ce există)
---
## Întrebări Rezolvate
| Întrebare | Răspuns |
|-----------|---------|
| Furnizor nou din OCR? | Creare automată în SQLite (LocalSupplier) |
| Sync strategy? | Hibrid: startup + periodic 4h + on-demand |
| Auth sharing? | Frontend-uri separate pe path, același token JWT (SSO via localStorage) |
| Deployment? | IIS path routing, servicii Windows separate |
| Structura directoare? | **UNITARĂ** - grupat pe app (`{app}/backend`, `{app}/frontend`) identic dev/prod |
| SSO cum funcționează? | Același domeniu IIS → localStorage partajat → token valid pentru ambele API-uri |
| URL-uri? | **COMPACTE**: `/reports/`, `/data/`, `/api/reports/`, `/api/data/` |
| Root (/)? | Redirect automat la `/reports/` |

View File

@@ -1,38 +1,33 @@
<template>
<div class="app-container">
<header v-if="authStore.isAuthenticated" class="app-header">
<div class="header-content">
<h1 class="app-title">
<i class="pi pi-receipt"></i>
Data Entry - Bonuri Fiscale
</h1>
<nav class="app-nav">
<router-link to="/" class="nav-link">
<i class="pi pi-list"></i> Lista Bonuri
</router-link>
<router-link to="/create" class="nav-link">
<i class="pi pi-plus"></i> Bon Nou
</router-link>
<router-link to="/approval" class="nav-link">
<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>
<AppHeader
v-if="authStore.isAuthenticated"
title="Data Entry"
brand-link="/"
header-class="header-container--gradient"
:menu-open="menuOpen"
:companies-store="companyStore"
:period-store="periodStore"
:current-user="authStore.currentUser"
:show-user="false"
@menu-toggle="menuOpen = !menuOpen"
@company-changed="onCompanyChanged"
@period-changed="onPeriodChanged"
>
<template #brand>
<i class="pi pi-receipt"></i>
<span>Data Entry</span>
</template>
</AppHeader>
<SlideMenu
v-if="authStore.isAuthenticated"
:is-open="menuOpen"
:menu-items="dataEntryMenuItems"
:current-user="authStore.currentUser"
@close="menuOpen = false"
@logout="handleLogout"
/>
<main class="app-main">
<router-view />
@@ -44,23 +39,74 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useCompanyStore } from './stores/companies'
import { useAccountingPeriodStore } from './stores/accountingPeriod'
import { useReceiptsStore } from './stores/receiptsStore'
import apiService from './services/api'
import AppHeader from '../../../shared/frontend/components/layout/AppHeader.vue'
import SlideMenu from '../../../shared/frontend/components/layout/SlideMenu.vue'
const router = useRouter()
const authStore = useAuthStore()
const companyStore = useCompanyStore()
const periodStore = useAccountingPeriodStore()
const receiptsStore = useReceiptsStore()
const menuOpen = ref(false)
const pendingCount = ref(0)
// Menu items configuration
const dataEntryMenuItems = computed(() => [
{
title: 'Navigare',
items: [
{ to: '/', icon: 'pi pi-list', label: 'Lista Bonuri' },
{ to: '/create', icon: 'pi pi-plus', label: 'Bon Nou' },
{
to: '/approval',
icon: 'pi pi-check-circle',
label: 'Aprobare',
badge: pendingCount.value > 0 ? pendingCount.value : null
},
]
}
])
const handleLogout = () => {
authStore.logout()
companyStore.reset()
periodStore.reset()
router.push('/login')
}
onMounted(async () => {
if (authStore.isAuthenticated) {
const onCompanyChanged = async (company) => {
console.log('[App] Company changed:', company?.name)
// Trigger nomenclature sync for the selected company (non-blocking)
if (company?.id_firma) {
apiService.post('/nomenclature/sync/all', null, {
headers: { 'X-Selected-Company': company.id_firma }
}).then(() => {
console.log('[App] Nomenclature sync completed for company:', company.name)
}).catch(e => {
console.warn('[App] Nomenclature sync failed:', e.message || e)
})
}
// Refresh stats when company changes
await refreshStats()
}
const onPeriodChanged = (period) => {
console.log('[App] Period changed:', period?.display_name)
// Refresh data when period changes
refreshStats()
}
const refreshStats = async () => {
if (authStore.isAuthenticated && companyStore.selectedCompany) {
try {
const stats = await receiptsStore.fetchStats()
pendingCount.value = stats?.pending_review?.count || 0
@@ -68,7 +114,38 @@ onMounted(async () => {
console.error('Failed to fetch stats:', error)
}
}
}
onMounted(async () => {
if (authStore.isAuthenticated) {
// Load companies first
await companyStore.loadCompanies()
// If company is selected, trigger initial sync and load stats
if (companyStore.selectedCompany) {
// Sync nomenclatures for current company (background, non-blocking)
apiService.post('/nomenclature/sync/all', null, {
headers: { 'X-Selected-Company': companyStore.selectedCompany.id_firma }
}).then(() => {
console.log('[App] Initial nomenclature sync completed')
}).catch(e => {
console.warn('[App] Initial nomenclature sync skipped:', e.message || e)
})
await refreshStats()
}
}
})
// Watch for company selection to refresh stats
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await refreshStats()
}
}
)
</script>
<style scoped>
@@ -78,85 +155,6 @@ onMounted(async () => {
flex-direction: column;
}
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.app-title {
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.app-nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background-color 0.2s;
font-weight: 500;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.nav-link.router-link-active {
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;
@@ -164,16 +162,6 @@ onMounted(async () => {
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
text-align: center;
}
.app-nav {
flex-wrap: wrap;
justify-content: center;
}
.app-main {
padding: 1rem;
}

View File

@@ -1,6 +1,42 @@
/* Global styles for Data Entry App */
/* Import shared layout styles */
@import '../../../../../shared/frontend/styles/layout/header.css';
@import '../../../../../shared/frontend/styles/layout/navigation.css';
:root {
/* Layout variables */
--header-height: 60px;
--sidebar-width: 280px;
--z-header: 100;
--z-modal: 1000;
--z-modal-backdrop: 999;
/* Shadows */
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
/* Typography */
--font-semibold: 600;
--font-medium: 500;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
/* Radius */
--radius-md: 6px;
--radius-full: 9999px;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 24px;
/* Colors - Primary palette (matching reports-app) */
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;

View File

@@ -71,7 +71,7 @@
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import api from '@/services/api'
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
@@ -137,7 +137,7 @@ const processOCR = async () => {
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await axios.post('/api/ocr/extract', formData, {
const response = await api.post('/ocr/extract', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // 60 second timeout for OCR
})

View File

@@ -33,6 +33,7 @@ import Badge from 'primevue/badge'
import Toolbar from 'primevue/toolbar'
import Divider from 'primevue/divider'
import Tooltip from 'primevue/tooltip'
import Message from 'primevue/message'
// PrimeVue styles
import 'primevue/resources/themes/lara-light-blue/theme.css'
@@ -80,6 +81,7 @@ app.component('ProgressSpinner', ProgressSpinner)
app.component('Badge', Badge)
app.component('Toolbar', Toolbar)
app.component('Divider', Divider)
app.component('Message', Message)
// Register PrimeVue directives
app.directive('tooltip', Tooltip)

View File

@@ -9,13 +9,31 @@ const apiService = axios.create({
},
});
// Request interceptor to add auth token
// Request interceptor to add auth token and selected company
apiService.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add X-Selected-Company header from localStorage
// The company store saves the selected company per user
const user = JSON.parse(localStorage.getItem("user") || "null");
if (user?.username) {
const savedCompany = localStorage.getItem(`selected_company_${user.username}`);
if (savedCompany) {
try {
const company = JSON.parse(savedCompany);
if (company?.id_firma) {
config.headers["X-Selected-Company"] = company.id_firma;
}
} catch (e) {
// Invalid JSON, ignore
}
}
}
return config;
},
(error) => {

View File

@@ -0,0 +1,17 @@
/**
* Accounting Period Store for Data Entry App
*
* Uses the shared accounting period store factory from shared/frontend/stores/accountingPeriod.js
* Configured with the data-entry API service (port 8003)
*/
import { createAccountingPeriodStore } from "../../../../shared/frontend/stores/accountingPeriod";
import { apiService } from "../services/api";
import { useAuthStore } from "./auth";
import { useCompanyStore } from "./companies";
export const useAccountingPeriodStore = createAccountingPeriodStore(
apiService,
useAuthStore,
useCompanyStore
);

View File

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

View File

@@ -372,6 +372,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
import { useCompanyStore } from '../../stores/companies'
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
import OCRPreview from '../../components/ocr/OCRPreview.vue'
import Dialog from 'primevue/dialog'
@@ -380,11 +381,17 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useReceiptsStore()
const companyStore = useCompanyStore()
const isEditMode = computed(() => !!route.params.id)
const receiptId = computed(() => route.params.id)
const receipt = ref(null)
// Get selected company ID from store
const getSelectedCompanyId = () => {
return companyStore.selectedCompanyId || 1
}
const form = ref({
receipt_type: 'bon_fiscal',
direction: 'cheltuiala',
@@ -398,7 +405,7 @@ const form = ref({
cash_register_account: null,
receipt_number: '',
description: '',
company_id: 1, // Default company for Phase 1
company_id: getSelectedCompanyId(),
// TVA info (multiple entries support)
tva_breakdown: [], // Array of {code, percent, amount}
tva_total: null,
@@ -429,6 +436,9 @@ onMounted(async () => {
if (isEditMode.value) {
await loadReceipt()
} else {
// For new receipts, ensure company_id is set from the current selected company
form.value.company_id = companyStore.selectedCompanyId || 1
}
})

View File

@@ -13,10 +13,6 @@ export default defineConfig({
server: {
port: 3010,
proxy: {
'/api/auth': {
target: 'http://localhost:8001',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8003',
changeOrigin: true,