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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 15:00:45 +02:00
parent c5fde510a8
commit 1a6e9b17d2
47 changed files with 4079 additions and 2595 deletions

View File

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

View File

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

View File

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

View File

@@ -132,6 +132,16 @@ from auth.routes import create_auth_router
auth_router = create_auth_router(prefix="") # No prefix - we set it in include_router
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
# Shared routes (companies, calendar)
from routes.companies import create_companies_router
from routes.calendar import create_calendar_router
companies_router = create_companies_router(oracle_pool) # No cache for data-entry
calendar_router = create_calendar_router(oracle_pool)
app.include_router(companies_router, prefix="/api/companies", tags=["companies"])
app.include_router(calendar_router, prefix="/api/calendar", tags=["calendar"])
# Root endpoint
@app.get("/")

View File

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

View File

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

View File

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

View File

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