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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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:
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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/` |
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
17
data-entry-app/frontend/src/stores/accountingPeriod.js
Normal file
17
data-entry-app/frontend/src/stores/accountingPeriod.js
Normal 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
|
||||
);
|
||||
12
data-entry-app/frontend/src/stores/companies.js
Normal file
12
data-entry-app/frontend/src/stores/companies.js
Normal 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);
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user