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

@@ -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)