feat: Add data-entry-app for fiscal receipts with approval workflow

New application for entering fiscal receipts (bonuri fiscale) with:

Backend (FastAPI + SQLModel + Alembic):
- Receipt, ReceiptAttachment, AccountingEntry models
- CRUD operations with async SQLite database
- Workflow: DRAFT → PENDING_REVIEW → APPROVED/REJECTED
- Auto-generation of accounting entries with VAT calculation
- File upload support (images, PDFs)
- Predefined expense types (Fuel, Materials, Office, etc.)
- Nomenclature service for partners, accounts, cash registers

Frontend (Vue.js 3 + PrimeVue + Pinia):
- ReceiptsListView with filters and stats
- ReceiptCreateView with image upload
- ReceiptDetailView with accounting entries
- ReceiptApprovalView for accountant approval

Documentation:
- REQUIREMENTS.md with functional specifications
- ARCHITECTURE.md with technical decisions
- CLAUDE.md for AI assistant guidance

Phase 1 MVP uses SQLite, prepared for Oracle integration in Phase 2.

🤖 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-11 17:30:51 +02:00
parent 5823cedb94
commit 21c12ddb0f
45 changed files with 7524 additions and 0 deletions

141
data-entry-app/CLAUDE.md Normal file
View File

@@ -0,0 +1,141 @@
# CLAUDE.md - Data Entry App
## Scop
Aplicatie pentru introducere date in ERP (bonuri fiscale, chitante) cu workflow de aprobare.
## Documentatie de Referinta
- **Cerinte**: `docs/data-entry/REQUIREMENTS.md`
- **Arhitectura**: `docs/data-entry/ARCHITECTURE.md`
- **Quick Start**: `README.md`
## Decizii Tehnice
- **ORM**: SQLModel (Pydantic + SQLAlchemy)
- **Migrari**: Alembic
- **Database**: SQLite (Faza 1) → Oracle (Faza 2)
- **Frontend**: Vue.js 3 + PrimeVue (consistent cu reports-app)
## Workflow Bonuri
```
1. DRAFT → utilizator completeaza + upload poza
2. PENDING_REVIEW → sistem genereaza note contabile
3. APPROVED/REJECTED → contabil aproba sau respinge
4. SYNCED → (Faza 2) date in Oracle
```
## Structura Directoare
```
data-entry-app/
├── backend/ # FastAPI API (port 8003)
│ ├── app/
│ │ ├── db/ # SQLModel models + CRUD
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # Business logic
│ │ └── routers/ # API endpoints
│ ├── migrations/ # Alembic migrations
│ └── data/ # SQLite DB + uploads
├── frontend/ # Vue.js UI (port 3010)
│ └── src/
│ ├── views/ # Page components
│ ├── components/ # Reusable components
│ └── stores/ # Pinia stores
└── docs/ # Documentatie specifica
```
## Componente Partajate
- `shared/database/oracle_pool.py` - Conexiune Oracle pentru nomenclatoare
- `shared/auth/` - JWT authentication
## Comenzi Dezvoltare
```bash
# Backend
cd data-entry-app/backend
pip install -r requirements.txt
alembic upgrade head
uvicorn app.main:app --reload --port 8003
# Frontend
cd data-entry-app/frontend
npm install
npm run dev -- --port 3010
# Migrari
cd data-entry-app/backend
alembic revision --autogenerate -m "description"
alembic upgrade head
```
## Tipuri Cheltuieli (hardcoded in Faza 1)
| Cod | Tip | Cont | TVA |
|-----|-----|------|-----|
| FUEL | Combustibil | 6022 | 19% |
| MATERIALS | Materiale | 6028 | 19% |
| OFFICE | Rechizite | 6024 | 19% |
| PHONE | Telefonie | 626 | 19% |
| PARKING | Parcare | 6022 | 19% |
| FOOD | Alimentatie | 6028 | 0% |
| TRANSPORT | Transport | 624 | 19% |
| OTHER | Altele | 628 | 19% |
## Integrare Oracle (Faza 2)
Vezi `docs/PACK_CONTAFIN.pck` pentru procedurile stocate:
- `pack_contafin.init_scriere_act_rul_local()`
- `INSERT INTO ACT_TEMP (...)`
- `pack_contafin.finalizeaza_scriere_act_rul()`
## API Endpoints Summary
### Receipts CRUD
- `POST /api/receipts/` - Create
- `GET /api/receipts/` - List (filterable)
- `GET /api/receipts/{id}` - Detail
- `PUT /api/receipts/{id}` - Update (DRAFT only)
- `DELETE /api/receipts/{id}` - Delete (DRAFT only)
### Workflow
- `POST /api/receipts/{id}/submit` - Send for review
- `POST /api/receipts/{id}/approve` - Approve
- `POST /api/receipts/{id}/reject` - Reject
- `POST /api/receipts/{id}/resubmit` - Resubmit after rejection
### Attachments
- `POST /api/receipts/{id}/attachments` - Upload
- `GET /api/attachments/{id}/download` - Download
- `DELETE /api/attachments/{id}` - Delete
### Nomenclatures
- `GET /api/receipts/partners` - Partners from Oracle
- `GET /api/receipts/accounts` - Accounts from Oracle
- `GET /api/receipts/cash-registers` - Cash registers from Oracle
- `GET /api/receipts/expense-types` - Expense types (hardcoded)
## Testing
```bash
# Backend tests
cd backend && pytest
# Frontend tests
cd frontend && npm run test
```
## Common Issues
### SQLite locked
- Asigura-te ca nu ai multiple procese care acceseaza DB-ul
### Upload fails
- Verifica permisiuni pe `data/uploads/`
- Verifica MIME type (doar image/*, application/pdf)
### Migration errors
- `alembic downgrade -1` pentru rollback
- Sterge migration file si regenereaza

177
data-entry-app/README.md Normal file
View File

@@ -0,0 +1,177 @@
# Data Entry App - Bonuri Fiscale
Aplicatie pentru introducere bonuri fiscale cu workflow de aprobare.
## Quick Start
### Prerequisites
- Python 3.10+
- Node.js 18+
- (Optional) SSH tunnel pentru Oracle nomenclatoare
### Backend Setup
```bash
cd data-entry-app/backend
# Create virtual environment
python -m venv venv
source venv/bin/activate # Linux/Mac
# sau: venv\Scripts\activate # Windows
# Install dependencies
pip install -r requirements.txt
# Create .env file
cp .env.example .env
# Edit .env with your settings
# Run migrations
alembic upgrade head
# Start server
uvicorn app.main:app --reload --port 8003
```
### Frontend Setup
```bash
cd data-entry-app/frontend
# Install dependencies
npm install
# Start dev server
npm run dev -- --port 3010
```
### Access
- **Backend API**: http://localhost:8003
- **API Docs**: http://localhost:8003/docs
- **Frontend**: http://localhost:3010
## Features
### Pentru Utilizatori
- Upload poze bonuri fiscale
- Completare date bon (suma, data, furnizor)
- Selectie tip cheltuiala
- Trimitere spre aprobare
### Pentru Contabili
- Vizualizare bonuri in asteptare
- Editare note contabile propuse
- Aprobare/Respingere bonuri
- Aprobare in masa
## Workflow
```
DRAFT → PENDING_REVIEW → APPROVED/REJECTED → (SYNCED in Oracle)
```
1. **DRAFT**: Utilizator completeaza datele
2. **PENDING_REVIEW**: Sistemul genereaza note contabile automat
3. **APPROVED**: Contabil a aprobat bonul
4. **REJECTED**: Contabil a respins (utilizatorul poate corecta)
## Project Structure
```
data-entry-app/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry point
│ │ ├── config.py # Settings
│ │ ├── db/
│ │ │ ├── database.py # SQLite engine
│ │ │ ├── models/ # SQLModel models
│ │ │ └── crud/ # CRUD operations
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # Business logic
│ │ └── routers/ # API endpoints
│ ├── migrations/ # Alembic migrations
│ ├── data/
│ │ ├── receipts.db # SQLite database
│ │ └── uploads/ # Uploaded files
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── views/receipts/ # Page components
│ │ ├── components/receipts/ # Reusable components
│ │ ├── stores/ # Pinia stores
│ │ └── router/ # Vue Router
│ ├── package.json
│ └── vite.config.js
└── docs/ # Documentation
```
## Environment Variables
### Backend (.env)
```bash
# SQLite
SQLITE_DATABASE_PATH=data/receipts.db
# File uploads
UPLOAD_PATH=data/uploads
MAX_UPLOAD_SIZE_MB=10
# Oracle (for nomenclatures)
ORACLE_USER=CONTAFIN_ORACLE
ORACLE_PASSWORD=your_password
ORACLE_HOST=localhost
ORACLE_PORT=1526
ORACLE_SID=ROA
# JWT (shared with reports-app)
JWT_SECRET_KEY=your_secret_key
JWT_ALGORITHM=HS256
```
## Development
### Create new migration
```bash
cd backend
alembic revision --autogenerate -m "Add new field"
alembic upgrade head
```
### Run tests
```bash
# Backend
cd backend && pytest
# Frontend
cd frontend && npm run test
```
## API Documentation
Full API documentation available at http://localhost:8003/docs when backend is running.
### Key Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | /api/receipts/ | Create receipt |
| GET | /api/receipts/ | List receipts |
| GET | /api/receipts/{id} | Get receipt details |
| POST | /api/receipts/{id}/submit | Submit for review |
| POST | /api/receipts/{id}/approve | Approve receipt |
| POST | /api/receipts/{id}/reject | Reject receipt |
| POST | /api/receipts/{id}/attachments | Upload attachment |
## Phase 2 (Future)
- Oracle sync for approved receipts
- Integration with pack_contafin procedures
- Automatic posting to ACT/RUL tables

View File

@@ -0,0 +1,26 @@
# SQLite Database
SQLITE_DATABASE_PATH=data/receipts.db
# File uploads
UPLOAD_PATH=data/uploads
MAX_UPLOAD_SIZE_MB=10
# Oracle Database (for nomenclatures - through SSH tunnel)
ORACLE_USER=CONTAFIN_ORACLE
ORACLE_PASSWORD=your_password
ORACLE_HOST=localhost
ORACLE_PORT=1526
ORACLE_SID=ROA
# JWT Authentication (shared with reports-app)
JWT_SECRET_KEY=your_secret_key_here
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=480
# API Settings
API_HOST=0.0.0.0
API_PORT=8003
DEBUG=true
# CORS
CORS_ORIGINS=http://localhost:3010,http://localhost:3000

View File

@@ -0,0 +1,106 @@
# Alembic Configuration for Data Entry App
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
timezone = UTC
# max length of characters to apply to the "slug" field
truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
output_encoding = utf-8
sqlalchemy.url = sqlite:///data/receipts.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1 @@
# Data Entry App - Backend

View File

@@ -0,0 +1,96 @@
"""Application configuration using pydantic-settings."""
import os
from pathlib import Path
from typing import List
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# App info
app_name: str = "Data Entry API"
app_version: str = "1.0.0"
debug: bool = False
# API
api_host: str = "0.0.0.0"
api_port: int = 8003
# SQLite Database
sqlite_database_path: str = "data/receipts.db"
# File uploads
upload_path: str = "data/uploads"
max_upload_size_mb: int = 10
allowed_mime_types: List[str] = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
]
# Oracle Database (for nomenclatures)
oracle_user: str = ""
oracle_password: str = ""
oracle_host: str = "localhost"
oracle_port: int = 1526
oracle_sid: str = "ROA"
# JWT Authentication
jwt_secret_key: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 480
# CORS
cors_origins: str = "http://localhost:3010,http://localhost:3000"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore"
@property
def database_url(self) -> str:
"""Get SQLite database URL for async."""
return f"sqlite+aiosqlite:///{self.sqlite_database_path}"
@property
def sync_database_url(self) -> str:
"""Get SQLite database URL for sync operations (Alembic)."""
return f"sqlite:///{self.sqlite_database_path}"
@property
def upload_path_resolved(self) -> Path:
"""Get resolved upload path."""
path = Path(self.upload_path)
path.mkdir(parents=True, exist_ok=True)
return path
@property
def max_upload_size_bytes(self) -> int:
"""Get max upload size in bytes."""
return self.max_upload_size_mb * 1024 * 1024
@property
def cors_origins_list(self) -> List[str]:
"""Get CORS origins as list."""
return [origin.strip() for origin in self.cors_origins.split(",")]
@property
def oracle_dsn(self) -> str:
"""Get Oracle DSN string."""
return f"{self.oracle_host}:{self.oracle_port}/{self.oracle_sid}"
@lru_cache()
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()
# Convenience instance
settings = get_settings()

View File

@@ -0,0 +1,4 @@
# Database module
from .database import get_session, init_db, engine
__all__ = ["get_session", "init_db", "engine"]

View File

@@ -0,0 +1,10 @@
# CRUD operations
from .receipt import ReceiptCRUD
from .attachment import AttachmentCRUD
from .accounting_entry import AccountingEntryCRUD
__all__ = [
"ReceiptCRUD",
"AttachmentCRUD",
"AccountingEntryCRUD",
]

View File

@@ -0,0 +1,197 @@
"""CRUD operations for accounting entries."""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.accounting_entry import AccountingEntry, EntryType
from app.schemas.receipt import AccountingEntryCreate, AccountingEntryUpdate
class AccountingEntryCRUD:
"""CRUD operations for AccountingEntry model."""
@staticmethod
async def create(
session: AsyncSession,
receipt_id: int,
data: AccountingEntryCreate,
sort_order: int = 0,
is_auto_generated: bool = True,
) -> AccountingEntry:
"""Create a new accounting entry."""
entry = AccountingEntry(
receipt_id=receipt_id,
entry_type=data.entry_type,
account_code=data.account_code,
account_name=data.account_name,
amount=data.amount,
partner_id=data.partner_id,
cost_center_id=data.cost_center_id,
is_auto_generated=is_auto_generated,
sort_order=sort_order,
)
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
@staticmethod
async def create_bulk(
session: AsyncSession,
receipt_id: int,
entries: List[AccountingEntryCreate],
is_auto_generated: bool = True,
) -> List[AccountingEntry]:
"""Create multiple accounting entries at once."""
created_entries = []
for idx, entry_data in enumerate(entries):
entry = AccountingEntry(
receipt_id=receipt_id,
entry_type=entry_data.entry_type,
account_code=entry_data.account_code,
account_name=entry_data.account_name,
amount=entry_data.amount,
partner_id=entry_data.partner_id,
cost_center_id=entry_data.cost_center_id,
is_auto_generated=is_auto_generated,
sort_order=idx,
)
session.add(entry)
created_entries.append(entry)
await session.commit()
for entry in created_entries:
await session.refresh(entry)
return created_entries
@staticmethod
async def get_by_id(
session: AsyncSession,
entry_id: int,
) -> Optional[AccountingEntry]:
"""Get accounting entry by ID."""
query = select(AccountingEntry).where(AccountingEntry.id == entry_id)
result = await session.execute(query)
return result.scalar_one_or_none()
@staticmethod
async def get_by_receipt_id(
session: AsyncSession,
receipt_id: int,
) -> List[AccountingEntry]:
"""Get all accounting entries for a receipt."""
query = select(AccountingEntry).where(
AccountingEntry.receipt_id == receipt_id
).order_by(AccountingEntry.sort_order.asc())
result = await session.execute(query)
return list(result.scalars().all())
@staticmethod
async def update(
session: AsyncSession,
entry: AccountingEntry,
data: AccountingEntryUpdate,
modified_by: str,
) -> AccountingEntry:
"""Update an accounting entry."""
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(entry, field, value)
entry.is_auto_generated = False
entry.modified_by = modified_by
entry.modified_at = datetime.utcnow()
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
@staticmethod
async def delete(session: AsyncSession, entry: AccountingEntry) -> bool:
"""Delete an accounting entry."""
await session.delete(entry)
await session.commit()
return True
@staticmethod
async def delete_all_for_receipt(session: AsyncSession, receipt_id: int) -> int:
"""Delete all accounting entries for a receipt."""
query = delete(AccountingEntry).where(AccountingEntry.receipt_id == receipt_id)
result = await session.execute(query)
await session.commit()
return result.rowcount
@staticmethod
async def replace_all_for_receipt(
session: AsyncSession,
receipt_id: int,
entries: List[AccountingEntryCreate],
modified_by: str,
) -> List[AccountingEntry]:
"""Replace all entries for a receipt with new ones."""
# Delete existing entries
await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id)
# Create new entries (marked as manually modified)
created_entries = []
for idx, entry_data in enumerate(entries):
entry = AccountingEntry(
receipt_id=receipt_id,
entry_type=entry_data.entry_type,
account_code=entry_data.account_code,
account_name=entry_data.account_name,
amount=entry_data.amount,
partner_id=entry_data.partner_id,
cost_center_id=entry_data.cost_center_id,
is_auto_generated=False,
modified_by=modified_by,
modified_at=datetime.utcnow(),
sort_order=idx,
)
session.add(entry)
created_entries.append(entry)
await session.commit()
for entry in created_entries:
await session.refresh(entry)
return created_entries
@staticmethod
async def validate_entries(entries: List[AccountingEntryCreate]) -> tuple[bool, str]:
"""
Validate accounting entries.
Returns (is_valid, error_message).
"""
if not entries:
return False, "At least one entry is required"
total_debit = sum(
e.amount for e in entries if e.entry_type == EntryType.DEBIT
)
total_credit = sum(
e.amount for e in entries if e.entry_type == EntryType.CREDIT
)
# Check balance (debit should equal credit)
if abs(total_debit - total_credit) > 0.01:
return False, f"Entries not balanced: Debit={total_debit}, Credit={total_credit}"
# Check for valid account codes
for entry in entries:
if not entry.account_code or len(entry.account_code) < 3:
return False, f"Invalid account code: {entry.account_code}"
return True, ""

View File

@@ -0,0 +1,140 @@
"""CRUD operations for receipt attachments."""
import os
import uuid
import aiofiles
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import UploadFile
from app.db.models.receipt import ReceiptAttachment
from app.config import settings
class AttachmentCRUD:
"""CRUD operations for ReceiptAttachment model."""
@staticmethod
def _generate_stored_filename(original_filename: str) -> str:
"""Generate unique filename for storage."""
ext = Path(original_filename).suffix.lower()
return f"{uuid.uuid4()}{ext}"
@staticmethod
def _get_upload_path(stored_filename: str) -> Path:
"""Get full path for storing file, organized by year/month."""
now = datetime.utcnow()
relative_path = Path(str(now.year)) / f"{now.month:02d}"
full_path = settings.upload_path_resolved / relative_path
# Ensure directory exists
full_path.mkdir(parents=True, exist_ok=True)
return relative_path / stored_filename
@staticmethod
async def create(
session: AsyncSession,
receipt_id: int,
file: UploadFile,
) -> ReceiptAttachment:
"""Create attachment by saving file and creating DB record."""
# Generate stored filename
stored_filename = AttachmentCRUD._generate_stored_filename(file.filename or "upload")
# Get relative path
relative_path = AttachmentCRUD._get_upload_path(stored_filename)
# Full path for saving
full_path = settings.upload_path_resolved / relative_path
# Read file content
content = await file.read()
file_size = len(content)
# Validate file size
if file_size > settings.max_upload_size_bytes:
raise ValueError(f"File too large. Maximum size is {settings.max_upload_size_mb}MB")
# Validate MIME type
mime_type = file.content_type or "application/octet-stream"
if mime_type not in settings.allowed_mime_types:
raise ValueError(f"File type not allowed: {mime_type}")
# Save file
async with aiofiles.open(full_path, "wb") as f:
await f.write(content)
# Create DB record
attachment = ReceiptAttachment(
receipt_id=receipt_id,
filename=file.filename or "upload",
stored_filename=stored_filename,
file_path=str(relative_path),
file_size=file_size,
mime_type=mime_type,
)
session.add(attachment)
await session.commit()
await session.refresh(attachment)
return attachment
@staticmethod
async def get_by_id(
session: AsyncSession,
attachment_id: int,
) -> Optional[ReceiptAttachment]:
"""Get attachment by ID."""
query = select(ReceiptAttachment).where(ReceiptAttachment.id == attachment_id)
result = await session.execute(query)
return result.scalar_one_or_none()
@staticmethod
async def get_by_receipt_id(
session: AsyncSession,
receipt_id: int,
) -> List[ReceiptAttachment]:
"""Get all attachments for a receipt."""
query = select(ReceiptAttachment).where(
ReceiptAttachment.receipt_id == receipt_id
).order_by(ReceiptAttachment.uploaded_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
@staticmethod
def get_file_path(attachment: ReceiptAttachment) -> Path:
"""Get full file path for an attachment."""
return settings.upload_path_resolved / attachment.file_path
@staticmethod
async def delete(session: AsyncSession, attachment: ReceiptAttachment) -> bool:
"""Delete attachment (file and DB record)."""
# Delete file
file_path = AttachmentCRUD.get_file_path(attachment)
if file_path.exists():
os.remove(file_path)
# Delete DB record
await session.delete(attachment)
await session.commit()
return True
@staticmethod
async def delete_all_for_receipt(session: AsyncSession, receipt_id: int) -> int:
"""Delete all attachments for a receipt."""
attachments = await AttachmentCRUD.get_by_receipt_id(session, receipt_id)
count = 0
for attachment in attachments:
await AttachmentCRUD.delete(session, attachment)
count += 1
return count

View File

@@ -0,0 +1,253 @@
"""CRUD operations for receipts."""
from datetime import datetime, date
from typing import Optional, List, Tuple
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db.models.receipt import Receipt, ReceiptStatus
from app.schemas.receipt import ReceiptCreate, ReceiptUpdate, ReceiptFilter
class ReceiptCRUD:
"""CRUD operations for Receipt model."""
@staticmethod
async def create(
session: AsyncSession,
data: ReceiptCreate,
created_by: str,
) -> Receipt:
"""Create a new receipt."""
receipt = Receipt(
**data.model_dump(),
created_by=created_by,
status=ReceiptStatus.DRAFT,
)
session.add(receipt)
await session.commit()
await session.refresh(receipt)
return receipt
@staticmethod
async def get_by_id(
session: AsyncSession,
receipt_id: int,
include_relations: bool = True,
) -> Optional[Receipt]:
"""Get receipt by ID, optionally with relationships."""
query = select(Receipt).where(Receipt.id == receipt_id)
if include_relations:
query = query.options(
selectinload(Receipt.attachments),
selectinload(Receipt.entries),
)
result = await session.execute(query)
return result.scalar_one_or_none()
@staticmethod
async def get_list(
session: AsyncSession,
filters: ReceiptFilter,
) -> Tuple[List[Receipt], int]:
"""Get paginated list of receipts with filters."""
# Base query
query = select(Receipt).options(
selectinload(Receipt.attachments),
selectinload(Receipt.entries),
)
# Apply filters
if filters.status:
query = query.where(Receipt.status == filters.status)
if filters.company_id:
query = query.where(Receipt.company_id == filters.company_id)
if filters.created_by:
query = query.where(Receipt.created_by == filters.created_by)
if filters.date_from:
query = query.where(Receipt.receipt_date >= filters.date_from)
if filters.date_to:
query = query.where(Receipt.receipt_date <= filters.date_to)
if filters.search:
search_term = f"%{filters.search}%"
query = query.where(
or_(
Receipt.description.ilike(search_term),
Receipt.partner_name.ilike(search_term),
Receipt.receipt_number.ilike(search_term),
)
)
# Count total
count_query = select(func.count()).select_from(query.subquery())
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# Apply pagination and ordering
query = query.order_by(Receipt.created_at.desc())
offset = (filters.page - 1) * filters.page_size
query = query.offset(offset).limit(filters.page_size)
# Execute
result = await session.execute(query)
receipts = result.scalars().all()
return list(receipts), total
@staticmethod
async def get_pending_review(
session: AsyncSession,
company_id: Optional[int] = None,
) -> List[Receipt]:
"""Get all receipts pending review."""
query = select(Receipt).where(
Receipt.status == ReceiptStatus.PENDING_REVIEW
).options(
selectinload(Receipt.attachments),
selectinload(Receipt.entries),
)
if company_id:
query = query.where(Receipt.company_id == company_id)
query = query.order_by(Receipt.submitted_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
@staticmethod
async def update(
session: AsyncSession,
receipt: Receipt,
data: ReceiptUpdate,
) -> Receipt:
"""Update receipt fields."""
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(receipt, field, value)
receipt.updated_at = datetime.utcnow()
session.add(receipt)
await session.commit()
await session.refresh(receipt)
return receipt
@staticmethod
async def update_status(
session: AsyncSession,
receipt: Receipt,
new_status: ReceiptStatus,
reviewed_by: Optional[str] = None,
rejection_reason: Optional[str] = None,
) -> Receipt:
"""Update receipt workflow status."""
receipt.status = new_status
receipt.updated_at = datetime.utcnow()
if new_status == ReceiptStatus.PENDING_REVIEW:
receipt.submitted_at = datetime.utcnow()
if new_status in [ReceiptStatus.APPROVED, ReceiptStatus.REJECTED]:
receipt.reviewed_by = reviewed_by
receipt.reviewed_at = datetime.utcnow()
if new_status == ReceiptStatus.REJECTED:
receipt.rejection_reason = rejection_reason
if new_status == ReceiptStatus.DRAFT:
# Reset review fields when moving back to draft
receipt.rejection_reason = None
session.add(receipt)
await session.commit()
await session.refresh(receipt)
return receipt
@staticmethod
async def delete(session: AsyncSession, receipt: Receipt) -> bool:
"""Delete a receipt (cascade deletes attachments and entries)."""
await session.delete(receipt)
await session.commit()
return True
@staticmethod
async def can_edit(receipt: Receipt, username: str) -> bool:
"""Check if user can edit receipt."""
# Only DRAFT receipts can be edited
if receipt.status != ReceiptStatus.DRAFT:
return False
# Only creator can edit their own drafts
return receipt.created_by == username
@staticmethod
async def can_delete(receipt: Receipt, username: str) -> bool:
"""Check if user can delete receipt."""
# Only DRAFT receipts can be deleted
if receipt.status != ReceiptStatus.DRAFT:
return False
# Only creator can delete their own drafts
return receipt.created_by == username
@staticmethod
async def can_submit(receipt: Receipt, username: str) -> bool:
"""Check if user can submit receipt for review."""
# Only DRAFT or REJECTED receipts can be submitted
if receipt.status not in [ReceiptStatus.DRAFT, ReceiptStatus.REJECTED]:
return False
# Only creator can submit their own receipts
return receipt.created_by == username
@staticmethod
async def get_stats(
session: AsyncSession,
company_id: int,
created_by: Optional[str] = None,
) -> dict:
"""Get receipt statistics."""
base_query = select(
Receipt.status,
func.count(Receipt.id).label("count"),
func.sum(Receipt.amount).label("total_amount"),
).where(
Receipt.company_id == company_id
)
if created_by:
base_query = base_query.where(Receipt.created_by == created_by)
query = base_query.group_by(Receipt.status)
result = await session.execute(query)
rows = result.all()
stats = {
"draft": {"count": 0, "amount": 0},
"pending_review": {"count": 0, "amount": 0},
"approved": {"count": 0, "amount": 0},
"rejected": {"count": 0, "amount": 0},
"synced": {"count": 0, "amount": 0},
"total": {"count": 0, "amount": 0},
}
for row in rows:
status_key = row.status.value
stats[status_key] = {
"count": row.count,
"amount": float(row.total_amount or 0),
}
stats["total"]["count"] += row.count
stats["total"]["amount"] += float(row.total_amount or 0)
return stats

View File

@@ -0,0 +1,49 @@
"""Database configuration and session management using SQLModel."""
from pathlib import Path
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel
from app.config import settings
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
future=True,
)
# Create async session factory
async_session_maker = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def init_db() -> None:
"""Initialize database - create tables if they don't exist."""
# Ensure data directory exists
db_path = Path(settings.sqlite_database_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Get async database session for dependency injection."""
async with async_session_maker() as session:
try:
yield session
finally:
await session.close()
# Convenience function for manual session usage
async def get_db_session() -> AsyncSession:
"""Get a new database session (manual management)."""
return async_session_maker()

View File

@@ -0,0 +1,13 @@
# Database models
from .receipt import Receipt, ReceiptAttachment, ReceiptStatus, ReceiptType, ReceiptDirection
from .accounting_entry import AccountingEntry, EntryType
__all__ = [
"Receipt",
"ReceiptAttachment",
"ReceiptStatus",
"ReceiptType",
"ReceiptDirection",
"AccountingEntry",
"EntryType",
]

View File

@@ -0,0 +1,49 @@
"""AccountingEntry SQLModel model for proposed accounting entries."""
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
if TYPE_CHECKING:
from .receipt import Receipt
class EntryType(str, Enum):
"""Type of accounting entry."""
DEBIT = "debit"
CREDIT = "credit"
class AccountingEntry(SQLModel, table=True):
"""Proposed accounting entry for a receipt."""
__tablename__ = "accounting_entries"
id: Optional[int] = Field(default=None, primary_key=True)
receipt_id: int = Field(foreign_key="receipts.id", index=True)
# Account
entry_type: EntryType
account_code: str = Field(max_length=20) # e.g., 6022, 5311, 4426
account_name: Optional[str] = Field(default=None, max_length=200) # Cache: "Cheltuieli combustibil"
# Amount
amount: Decimal = Field(decimal_places=2, max_digits=15)
# Analytics (optional)
partner_id: Optional[int] = Field(default=None)
cost_center_id: Optional[int] = Field(default=None)
# Entry metadata
is_auto_generated: bool = Field(default=True) # True if system-generated
modified_by: Optional[str] = Field(default=None, max_length=100) # Username if modified
modified_at: Optional[datetime] = Field(default=None)
# Order for display
sort_order: int = Field(default=0)
# Relationship
receipt: Optional["Receipt"] = Relationship(back_populates="entries")

View File

@@ -0,0 +1,110 @@
"""Receipt and ReceiptAttachment SQLModel models."""
from datetime import datetime, date
from decimal import Decimal
from enum import Enum
from typing import Optional, List, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
class ReceiptType(str, Enum):
"""Type of receipt document."""
BON_FISCAL = "bon_fiscal"
CHITANTA = "chitanta"
class ReceiptDirection(str, Enum):
"""Direction of receipt - expense or income."""
CHELTUIALA = "cheltuiala" # Expense (receipt from supplier)
INCASARE = "incasare" # Income (receipt issued to client)
class ReceiptStatus(str, Enum):
"""Workflow status of receipt."""
DRAFT = "draft" # User is filling in data
PENDING_REVIEW = "pending_review" # Awaiting accountant approval
APPROVED = "approved" # Approved by accountant
REJECTED = "rejected" # Rejected by accountant
SYNCED = "synced" # Synced to Oracle (Phase 2)
if TYPE_CHECKING:
from .accounting_entry import AccountingEntry
class Receipt(SQLModel, table=True):
"""Receipt (Bon Fiscal / Chitanta) with approval workflow."""
__tablename__ = "receipts"
id: Optional[int] = Field(default=None, primary_key=True)
# Document identification
receipt_type: ReceiptType = Field(default=ReceiptType.BON_FISCAL)
direction: ReceiptDirection = Field(default=ReceiptDirection.CHELTUIALA)
receipt_number: Optional[str] = Field(default=None, max_length=50)
receipt_series: Optional[str] = Field(default=None, max_length=20)
# Main data
receipt_date: date
amount: Decimal = Field(decimal_places=2, max_digits=15)
description: Optional[str] = Field(default=None, max_length=500)
# Expense type (for auto-generating accounting entries)
expense_type_code: Optional[str] = Field(default=None, max_length=20)
# Oracle references (nomenclatures)
company_id: int
partner_id: Optional[int] = Field(default=None)
partner_name: Optional[str] = Field(default=None, max_length=200) # Cache for display
cash_register_id: Optional[int] = Field(default=None) # Cash/Bank ID from Oracle
cash_register_name: Optional[str] = Field(default=None, max_length=100) # Cache for display
cash_register_account: Optional[str] = Field(default=None, max_length=20) # Account code (5311, 5121)
# Workflow
status: ReceiptStatus = Field(default=ReceiptStatus.DRAFT)
created_by: str = Field(max_length=100) # Username of creator
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
submitted_at: Optional[datetime] = Field(default=None) # When submitted for approval
# Approval
reviewed_by: Optional[str] = Field(default=None, max_length=100) # Accountant username
reviewed_at: Optional[datetime] = Field(default=None)
rejection_reason: Optional[str] = Field(default=None, max_length=500) # Reason for rejection
# Phase 2 - Oracle sync
oracle_synced_at: Optional[datetime] = Field(default=None)
oracle_act_id: Optional[int] = Field(default=None)
oracle_error: Optional[str] = Field(default=None, max_length=500)
# Relationships
attachments: List["ReceiptAttachment"] = Relationship(
back_populates="receipt",
sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
entries: List["AccountingEntry"] = Relationship(
back_populates="receipt",
sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
class ReceiptAttachment(SQLModel, table=True):
"""Attachment (photo or PDF) for a receipt."""
__tablename__ = "receipt_attachments"
id: Optional[int] = Field(default=None, primary_key=True)
receipt_id: int = Field(foreign_key="receipts.id", index=True)
# File info
filename: str = Field(max_length=255) # Original filename
stored_filename: str = Field(max_length=255) # Filename on disk (UUID)
file_path: str = Field(max_length=500) # Relative path
file_size: int # Size in bytes
mime_type: str = Field(max_length=100) # MIME type (image/jpeg, application/pdf)
uploaded_at: datetime = Field(default_factory=datetime.utcnow)
# Relationship
receipt: Optional[Receipt] = Relationship(back_populates="attachments")

View File

@@ -0,0 +1,88 @@
"""FastAPI application entry point for Data Entry App."""
import sys
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
# Add shared modules to path
project_root = Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(project_root / "shared"))
from app.config import settings
from app.db.database import init_db
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan - startup and shutdown events."""
# Startup
print(f"Starting {settings.app_name} v{settings.app_version}")
# Initialize database
await init_db()
print("Database initialized")
# Ensure upload directory exists
settings.upload_path_resolved
print(f"Upload path: {settings.upload_path_resolved}")
yield
# Shutdown
print("Shutting down...")
# Create FastAPI app
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="API pentru introducere bonuri fiscale cu workflow de aprobare",
lifespan=lifespan,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files for uploads (optional - can serve through nginx in prod)
uploads_path = Path(settings.upload_path)
if uploads_path.exists():
app.mount("/uploads", StaticFiles(directory=str(uploads_path)), name="uploads")
# Health check endpoint
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {
"status": "healthy",
"app": settings.app_name,
"version": settings.app_version,
}
# Import and include routers
from app.routers import receipts
app.include_router(receipts.router, prefix="/api/receipts", tags=["receipts"])
# Root endpoint
@app.get("/")
async def root():
"""Root endpoint - API information."""
return {
"name": settings.app_name,
"version": settings.app_version,
"docs": "/docs",
"health": "/health",
}

View File

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

View File

@@ -0,0 +1,450 @@
"""API endpoints for receipts."""
from typing import List, Optional
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.database import get_session
from app.db.crud.receipt import ReceiptCRUD
from app.db.crud.attachment import AttachmentCRUD
from app.db.crud.accounting_entry import AccountingEntryCRUD
from app.services.receipt_service import ReceiptService
from app.services.nomenclature_service import NomenclatureService
from app.schemas.receipt import (
ReceiptCreate,
ReceiptUpdate,
ReceiptResponse,
ReceiptListResponse,
ReceiptFilter,
AttachmentResponse,
AccountingEntryResponse,
WorkflowAction,
RejectRequest,
EntriesUpdateRequest,
PartnerOption,
AccountOption,
CashRegisterOption,
ExpenseTypeOption,
)
from app.db.models.receipt import ReceiptStatus
router = APIRouter()
# ============ Helper for current user (simplified for Phase 1) ============
async def get_current_user() -> str:
"""
Get current authenticated user.
Phase 1: Returns hardcoded user for testing.
Phase 2: Will integrate with shared JWT auth.
"""
# TODO: Integrate with shared/auth middleware
return "test_user"
async def get_current_user_company() -> int:
"""
Get current user's active company.
Phase 1: Returns hardcoded company for testing.
Phase 2: Will get from JWT token or session.
"""
# TODO: Integrate with shared/auth
return 1
# ============ Receipt CRUD Endpoints ============
@router.post("/", response_model=ReceiptResponse)
async def create_receipt(
data: ReceiptCreate,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Create a new receipt in DRAFT status."""
receipt = await ReceiptService.create_receipt(session, data, current_user)
return ReceiptResponse.model_validate(receipt)
@router.get("/", response_model=ReceiptListResponse)
async def list_receipts(
status: Optional[ReceiptStatus] = None,
company_id: Optional[int] = None,
created_by: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
search: Optional[str] = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_company: int = Depends(get_current_user_company),
):
"""Get paginated list of receipts with filters."""
from datetime import date as date_type
filters = ReceiptFilter(
status=status,
company_id=company_id or current_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,
search=search,
page=page,
page_size=page_size,
)
return await ReceiptService.get_receipts(session, filters)
@router.get("/pending", response_model=List[ReceiptResponse])
async def list_pending_receipts(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_company: int = Depends(get_current_user_company),
):
"""Get all receipts pending review (for accountant view)."""
receipts = await ReceiptCRUD.get_pending_review(
session, company_id or current_company
)
return [ReceiptResponse.model_validate(r) for r in receipts]
@router.get("/stats")
async def get_receipt_stats(
company_id: Optional[int] = None,
my_receipts: bool = False,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
current_company: int = Depends(get_current_user_company),
):
"""Get receipt statistics."""
return await ReceiptCRUD.get_stats(
session,
company_id or current_company,
created_by=current_user if my_receipts else None,
)
@router.get("/{receipt_id}", response_model=ReceiptResponse)
async def get_receipt(
receipt_id: int,
session: AsyncSession = Depends(get_session),
):
"""Get receipt details with attachments and accounting entries."""
receipt = await ReceiptService.get_receipt(session, receipt_id)
if not receipt:
raise HTTPException(status_code=404, detail="Receipt not found")
return ReceiptResponse.model_validate(receipt)
@router.put("/{receipt_id}", response_model=ReceiptResponse)
async def update_receipt(
receipt_id: int,
data: ReceiptUpdate,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Update receipt (only DRAFT status, only by creator)."""
success, message, receipt = await ReceiptService.update_receipt(
session, receipt_id, data, current_user
)
if not success:
raise HTTPException(status_code=400, detail=message)
return ReceiptResponse.model_validate(receipt)
@router.delete("/{receipt_id}")
async def delete_receipt(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Delete receipt (only DRAFT status, only by creator)."""
success, message = await ReceiptService.delete_receipt(
session, receipt_id, current_user
)
if not success:
raise HTTPException(status_code=400, detail=message)
return {"success": True, "message": message}
# ============ Workflow Endpoints ============
@router.post("/{receipt_id}/submit", response_model=WorkflowAction)
async def submit_receipt(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Submit receipt for review (DRAFT → PENDING_REVIEW)."""
success, message, receipt = await ReceiptService.submit_for_review(
session, receipt_id, current_user
)
return WorkflowAction(
success=success,
message=message,
receipt=ReceiptResponse.model_validate(receipt) if receipt else None,
)
@router.post("/{receipt_id}/approve", response_model=WorkflowAction)
async def approve_receipt(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Approve receipt (PENDING_REVIEW → APPROVED). Accountant action."""
success, message, receipt = await ReceiptService.approve_receipt(
session, receipt_id, current_user
)
return WorkflowAction(
success=success,
message=message,
receipt=ReceiptResponse.model_validate(receipt) if receipt else None,
)
@router.post("/{receipt_id}/reject", response_model=WorkflowAction)
async def reject_receipt(
receipt_id: int,
data: RejectRequest,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Reject receipt (PENDING_REVIEW → REJECTED). Accountant action."""
success, message, receipt = await ReceiptService.reject_receipt(
session, receipt_id, current_user, data.reason
)
return WorkflowAction(
success=success,
message=message,
receipt=ReceiptResponse.model_validate(receipt) if receipt else None,
)
@router.post("/{receipt_id}/resubmit", response_model=WorkflowAction)
async def resubmit_receipt(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Resubmit rejected receipt after corrections (REJECTED → PENDING_REVIEW)."""
success, message, receipt = await ReceiptService.resubmit_receipt(
session, receipt_id, current_user
)
return WorkflowAction(
success=success,
message=message,
receipt=ReceiptResponse.model_validate(receipt) if receipt else None,
)
# ============ Accounting Entries Endpoints ============
@router.get("/{receipt_id}/entries", response_model=List[AccountingEntryResponse])
async def get_receipt_entries(
receipt_id: int,
session: AsyncSession = Depends(get_session),
):
"""Get accounting entries for a receipt."""
entries = await AccountingEntryCRUD.get_by_receipt_id(session, receipt_id)
return [AccountingEntryResponse.model_validate(e) for e in entries]
@router.put("/{receipt_id}/entries", response_model=List[AccountingEntryResponse])
async def update_receipt_entries(
receipt_id: int,
data: EntriesUpdateRequest,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Update accounting entries for a receipt (accountant action)."""
success, message, entries = await ReceiptService.update_entries(
session, receipt_id, data.entries, current_user
)
if not success:
raise HTTPException(status_code=400, detail=message)
return [AccountingEntryResponse.model_validate(e) for e in entries]
@router.post("/{receipt_id}/entries/regenerate", response_model=List[AccountingEntryResponse])
async def regenerate_entries(
receipt_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Regenerate accounting entries based on receipt data."""
success, message, _ = await ReceiptService.regenerate_entries(
session, receipt_id, current_user
)
if not success:
raise HTTPException(status_code=400, detail=message)
entries = await AccountingEntryCRUD.get_by_receipt_id(session, receipt_id)
return [AccountingEntryResponse.model_validate(e) for e in entries]
# ============ Attachment Endpoints ============
@router.post("/{receipt_id}/attachments", response_model=AttachmentResponse)
async def upload_attachment(
receipt_id: int,
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Upload attachment for a receipt."""
# Check receipt exists and user can modify it
receipt = await ReceiptCRUD.get_by_id(session, receipt_id, include_relations=False)
if not receipt:
raise HTTPException(status_code=404, detail="Receipt not found")
# Only allow uploads for DRAFT and REJECTED receipts
if receipt.status not in [ReceiptStatus.DRAFT, ReceiptStatus.REJECTED]:
raise HTTPException(
status_code=400,
detail="Cannot upload attachments for this receipt status"
)
# Only creator can upload
if receipt.created_by != current_user:
raise HTTPException(
status_code=403,
detail="Only the creator can upload attachments"
)
try:
attachment = await AttachmentCRUD.create(session, receipt_id, file)
return AttachmentResponse.model_validate(attachment)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{receipt_id}/attachments", response_model=List[AttachmentResponse])
async def list_attachments(
receipt_id: int,
session: AsyncSession = Depends(get_session),
):
"""Get all attachments for a receipt."""
attachments = await AttachmentCRUD.get_by_receipt_id(session, receipt_id)
return [AttachmentResponse.model_validate(a) for a in attachments]
@router.get("/attachments/{attachment_id}/download")
async def download_attachment(
attachment_id: int,
session: AsyncSession = Depends(get_session),
):
"""Download an attachment file."""
attachment = await AttachmentCRUD.get_by_id(session, attachment_id)
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
file_path = AttachmentCRUD.get_file_path(attachment)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
return FileResponse(
path=str(file_path),
filename=attachment.filename,
media_type=attachment.mime_type,
)
@router.delete("/attachments/{attachment_id}")
async def delete_attachment(
attachment_id: int,
session: AsyncSession = Depends(get_session),
current_user: str = Depends(get_current_user),
):
"""Delete an attachment."""
attachment = await AttachmentCRUD.get_by_id(session, attachment_id)
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# Get receipt to check permissions
receipt = await ReceiptCRUD.get_by_id(session, attachment.receipt_id, include_relations=False)
if not receipt:
raise HTTPException(status_code=404, detail="Receipt not found")
# Only allow deletion for DRAFT receipts by creator
if receipt.status != ReceiptStatus.DRAFT:
raise HTTPException(
status_code=400,
detail="Cannot delete attachments for this receipt status"
)
if receipt.created_by != current_user:
raise HTTPException(
status_code=403,
detail="Only the creator can delete attachments"
)
await AttachmentCRUD.delete(session, attachment)
return {"success": True, "message": "Attachment deleted"}
# ============ Nomenclature Endpoints ============
@router.get("/nomenclature/partners", response_model=List[PartnerOption])
async def get_partners(
search: Optional[str] = None,
company_id: Optional[int] = None,
current_company: int = Depends(get_current_user_company),
):
"""Get partners (suppliers/customers) for dropdown."""
return await NomenclatureService.get_partners(
company_id or current_company, search
)
@router.get("/nomenclature/accounts", response_model=List[AccountOption])
async def get_accounts(
prefix: Optional[str] = None,
company_id: Optional[int] = None,
current_company: int = Depends(get_current_user_company),
):
"""Get chart of accounts for dropdown."""
return await NomenclatureService.get_accounts(
company_id or current_company, prefix
)
@router.get("/nomenclature/cash-registers", response_model=List[CashRegisterOption])
async def get_cash_registers(
company_id: Optional[int] = None,
current_company: int = Depends(get_current_user_company),
):
"""Get cash registers and bank accounts for dropdown."""
return await NomenclatureService.get_cash_registers(company_id or current_company)
@router.get("/nomenclature/expense-types", response_model=List[ExpenseTypeOption])
async def get_expense_types():
"""Get predefined expense types for dropdown."""
return await NomenclatureService.get_expense_types()

View File

@@ -0,0 +1,28 @@
# Pydantic schemas
from .receipt import (
ReceiptCreate,
ReceiptUpdate,
ReceiptResponse,
ReceiptListResponse,
ReceiptFilter,
AttachmentResponse,
AccountingEntryCreate,
AccountingEntryUpdate,
AccountingEntryResponse,
WorkflowAction,
RejectRequest,
)
__all__ = [
"ReceiptCreate",
"ReceiptUpdate",
"ReceiptResponse",
"ReceiptListResponse",
"ReceiptFilter",
"AttachmentResponse",
"AccountingEntryCreate",
"AccountingEntryUpdate",
"AccountingEntryResponse",
"WorkflowAction",
"RejectRequest",
]

View File

@@ -0,0 +1,199 @@
"""Pydantic schemas for receipts API."""
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List
from pydantic import BaseModel, Field, ConfigDict
from app.db.models.receipt import ReceiptType, ReceiptDirection, ReceiptStatus
from app.db.models.accounting_entry import EntryType
# ============ Accounting Entry Schemas ============
class AccountingEntryBase(BaseModel):
"""Base schema for accounting entry."""
entry_type: EntryType
account_code: str = Field(max_length=20)
account_name: Optional[str] = Field(default=None, max_length=200)
amount: Decimal
partner_id: Optional[int] = None
cost_center_id: Optional[int] = None
class AccountingEntryCreate(AccountingEntryBase):
"""Schema for creating an accounting entry."""
pass
class AccountingEntryUpdate(BaseModel):
"""Schema for updating an accounting entry."""
entry_type: Optional[EntryType] = None
account_code: Optional[str] = Field(default=None, max_length=20)
account_name: Optional[str] = Field(default=None, max_length=200)
amount: Optional[Decimal] = None
partner_id: Optional[int] = None
cost_center_id: Optional[int] = None
class AccountingEntryResponse(AccountingEntryBase):
"""Schema for accounting entry response."""
model_config = ConfigDict(from_attributes=True)
id: int
receipt_id: int
is_auto_generated: bool
modified_by: Optional[str] = None
modified_at: Optional[datetime] = None
sort_order: int
# ============ Attachment Schemas ============
class AttachmentResponse(BaseModel):
"""Schema for attachment response."""
model_config = ConfigDict(from_attributes=True)
id: int
receipt_id: int
filename: str
stored_filename: str
file_path: str
file_size: int
mime_type: str
uploaded_at: datetime
# ============ Receipt Schemas ============
class ReceiptBase(BaseModel):
"""Base schema for receipt."""
receipt_type: ReceiptType = ReceiptType.BON_FISCAL
direction: ReceiptDirection = ReceiptDirection.CHELTUIALA
receipt_number: Optional[str] = Field(default=None, max_length=50)
receipt_series: Optional[str] = Field(default=None, max_length=20)
receipt_date: date
amount: Decimal = Field(gt=0)
description: Optional[str] = Field(default=None, max_length=500)
expense_type_code: Optional[str] = Field(default=None, max_length=20)
company_id: int
partner_id: Optional[int] = None
partner_name: Optional[str] = Field(default=None, max_length=200)
cash_register_id: Optional[int] = None
cash_register_name: Optional[str] = Field(default=None, max_length=100)
cash_register_account: Optional[str] = Field(default=None, max_length=20)
class ReceiptCreate(ReceiptBase):
"""Schema for creating a receipt."""
pass
class ReceiptUpdate(BaseModel):
"""Schema for updating a receipt (DRAFT only)."""
receipt_type: Optional[ReceiptType] = None
direction: Optional[ReceiptDirection] = None
receipt_number: Optional[str] = Field(default=None, max_length=50)
receipt_series: Optional[str] = Field(default=None, max_length=20)
receipt_date: Optional[date] = None
amount: Optional[Decimal] = Field(default=None, gt=0)
description: Optional[str] = Field(default=None, max_length=500)
expense_type_code: Optional[str] = Field(default=None, max_length=20)
partner_id: Optional[int] = None
partner_name: Optional[str] = Field(default=None, max_length=200)
cash_register_id: Optional[int] = None
cash_register_name: Optional[str] = Field(default=None, max_length=100)
cash_register_account: Optional[str] = Field(default=None, max_length=20)
class ReceiptResponse(ReceiptBase):
"""Schema for receipt response with all fields."""
model_config = ConfigDict(from_attributes=True)
id: int
status: ReceiptStatus
created_by: str
created_at: datetime
updated_at: datetime
submitted_at: Optional[datetime] = None
reviewed_by: Optional[str] = None
reviewed_at: Optional[datetime] = None
rejection_reason: Optional[str] = None
oracle_synced_at: Optional[datetime] = None
oracle_act_id: Optional[int] = None
oracle_error: Optional[str] = None
# Relationships (optional, loaded when needed)
attachments: List[AttachmentResponse] = []
entries: List[AccountingEntryResponse] = []
class ReceiptListResponse(BaseModel):
"""Schema for paginated receipt list response."""
items: List[ReceiptResponse]
total: int
page: int
page_size: int
pages: int
class ReceiptFilter(BaseModel):
"""Schema for filtering receipts."""
status: Optional[ReceiptStatus] = None
company_id: Optional[int] = None
created_by: Optional[str] = None
date_from: Optional[date] = None
date_to: Optional[date] = None
search: Optional[str] = None # Search in description, partner_name
page: int = Field(default=1, ge=1)
page_size: int = Field(default=20, ge=1, le=100)
# ============ Workflow Schemas ============
class WorkflowAction(BaseModel):
"""Schema for workflow action response."""
success: bool
message: str
receipt: Optional[ReceiptResponse] = None
class RejectRequest(BaseModel):
"""Schema for rejection request."""
reason: str = Field(min_length=5, max_length=500)
class EntriesUpdateRequest(BaseModel):
"""Schema for bulk updating accounting entries."""
entries: List[AccountingEntryCreate]
# ============ Nomenclature Schemas ============
class PartnerOption(BaseModel):
"""Schema for partner dropdown option."""
id: int
name: str
code: Optional[str] = None
class AccountOption(BaseModel):
"""Schema for account dropdown option."""
code: str
name: str
class CashRegisterOption(BaseModel):
"""Schema for cash register dropdown option."""
id: int
name: str
account_code: str # 5311, 5121, etc.
class ExpenseTypeOption(BaseModel):
"""Schema for expense type dropdown option."""
code: str
name: str
account_code: str
has_vat: bool
vat_percent: Decimal = Decimal("19")

View File

@@ -0,0 +1,11 @@
# Business logic services
from .receipt_service import ReceiptService
from .nomenclature_service import NomenclatureService
from .expense_types import EXPENSE_TYPES, ExpenseType
__all__ = [
"ReceiptService",
"NomenclatureService",
"EXPENSE_TYPES",
"ExpenseType",
]

View File

@@ -0,0 +1,101 @@
"""Predefined expense types for automatic accounting entry generation."""
from decimal import Decimal
from dataclasses import dataclass
from typing import Dict, Optional
@dataclass
class ExpenseType:
"""Expense type definition with accounting configuration."""
code: str
name: str
account_code: str
account_name: str
has_vat: bool
vat_percent: Decimal = Decimal("19")
vat_account: str = "4426"
# Predefined expense types
EXPENSE_TYPES: Dict[str, ExpenseType] = {
"FUEL": ExpenseType(
code="FUEL",
name="Combustibil",
account_code="6022",
account_name="Cheltuieli cu combustibilii",
has_vat=True,
),
"MATERIALS": ExpenseType(
code="MATERIALS",
name="Materiale consumabile",
account_code="6028",
account_name="Alte cheltuieli cu materiale consumabile",
has_vat=True,
),
"OFFICE": ExpenseType(
code="OFFICE",
name="Rechizite birou",
account_code="6024",
account_name="Cheltuieli privind materialele pentru ambalat",
has_vat=True,
),
"PHONE": ExpenseType(
code="PHONE",
name="Telefonie / Internet",
account_code="626",
account_name="Cheltuieli postale si taxe de telecomunicatii",
has_vat=True,
),
"PARKING": ExpenseType(
code="PARKING",
name="Parcare",
account_code="6022",
account_name="Cheltuieli cu combustibilii",
has_vat=True,
),
"FOOD": ExpenseType(
code="FOOD",
name="Alimentatie",
account_code="6028",
account_name="Alte cheltuieli cu materiale consumabile",
has_vat=False, # No deductible VAT for food
),
"TRANSPORT": ExpenseType(
code="TRANSPORT",
name="Transport",
account_code="624",
account_name="Cheltuieli cu transportul de bunuri si personal",
has_vat=True,
),
"OTHER": ExpenseType(
code="OTHER",
name="Altele",
account_code="628",
account_name="Alte cheltuieli cu serviciile executate de terti",
has_vat=True,
),
}
def get_expense_type(code: str) -> Optional[ExpenseType]:
"""Get expense type by code."""
return EXPENSE_TYPES.get(code)
def get_all_expense_types() -> Dict[str, ExpenseType]:
"""Get all expense types."""
return EXPENSE_TYPES.copy()
# Default cash register accounts
CASH_REGISTER_ACCOUNTS = {
"CASA": {
"code": "5311",
"name": "Casa in lei",
},
"BANCA": {
"code": "5121",
"name": "Conturi la banci in lei",
},
}

View File

@@ -0,0 +1,164 @@
"""Service for fetching nomenclatures from Oracle (read-only)."""
from typing import List, Optional
from decimal import Decimal
from app.schemas.receipt import (
PartnerOption,
AccountOption,
CashRegisterOption,
ExpenseTypeOption,
)
from app.services.expense_types import EXPENSE_TYPES
class NomenclatureService:
"""
Service for fetching nomenclatures.
In Phase 1 (MVP), some nomenclatures are hardcoded.
In Phase 2, these will be fetched from Oracle.
"""
@staticmethod
async def get_partners(company_id: int, search: Optional[str] = None) -> List[PartnerOption]:
"""
Get partners (suppliers/customers) for a company.
Phase 1: Returns empty list or mock data.
Phase 2: Will fetch from Oracle NOM_PARTENERI.
"""
# TODO: Implement Oracle fetch in Phase 2
# For now, return some mock data for testing
mock_partners = [
PartnerOption(id=1, name="OMV Petrom", code="RO123456"),
PartnerOption(id=2, name="Dedeman", code="RO789012"),
PartnerOption(id=3, name="Kaufland", code="RO345678"),
PartnerOption(id=4, name="Emag", code="RO901234"),
PartnerOption(id=5, name="Altex", code="RO567890"),
]
if search:
search_lower = search.lower()
mock_partners = [
p for p in mock_partners
if search_lower in p.name.lower() or (p.code and search_lower in p.code.lower())
]
return mock_partners
@staticmethod
async def get_accounts(company_id: int, prefix: Optional[str] = None) -> List[AccountOption]:
"""
Get chart of accounts for a company.
Phase 1: Returns common expense/income accounts.
Phase 2: Will fetch from Oracle PLAN_CONTURI.
"""
# Common accounts for expenses and receipts
accounts = [
# Expense accounts (Class 6)
AccountOption(code="6022", name="Cheltuieli cu combustibilii"),
AccountOption(code="6024", name="Cheltuieli materiale pentru ambalat"),
AccountOption(code="6028", name="Alte cheltuieli cu materiale consumabile"),
AccountOption(code="624", name="Cheltuieli cu transportul de bunuri si personal"),
AccountOption(code="626", name="Cheltuieli postale si taxe telecomunicatii"),
AccountOption(code="628", name="Alte cheltuieli cu serviciile executate de terti"),
# VAT
AccountOption(code="4426", name="TVA deductibila"),
AccountOption(code="4427", name="TVA colectata"),
# Cash and Bank (Class 5)
AccountOption(code="5311", name="Casa in lei"),
AccountOption(code="5121", name="Conturi la banci in lei"),
# Income accounts (Class 7)
AccountOption(code="7588", name="Alte venituri din exploatare"),
]
if prefix:
accounts = [a for a in accounts if a.code.startswith(prefix)]
return accounts
@staticmethod
async def get_cash_registers(company_id: int) -> List[CashRegisterOption]:
"""
Get cash registers and bank accounts for a company.
Phase 1: Returns default options.
Phase 2: Will fetch from Oracle NOM_CASE / NOM_BANCI.
"""
# Default cash registers
return [
CashRegisterOption(id=1, name="Casa principala", account_code="5311"),
CashRegisterOption(id=2, name="Cont BCR", account_code="5121"),
CashRegisterOption(id=3, name="Cont BRD", account_code="5121"),
]
@staticmethod
async def get_expense_types() -> List[ExpenseTypeOption]:
"""
Get predefined expense types with their accounting configuration.
"""
return [
ExpenseTypeOption(
code=et.code,
name=et.name,
account_code=et.account_code,
has_vat=et.has_vat,
vat_percent=et.vat_percent,
)
for et in EXPENSE_TYPES.values()
]
@staticmethod
async def get_companies(username: str) -> List[dict]:
"""
Get companies accessible by user.
Phase 1: Returns mock data.
Phase 2: Will fetch from shared auth based on user permissions.
"""
# TODO: Integrate with shared auth to get user's companies
return [
{"id": 1, "name": "SC Test SRL", "cui": "RO12345678"},
{"id": 2, "name": "SC Demo SA", "cui": "RO87654321"},
]
# ============ Phase 2 Oracle Integration Methods ============
@staticmethod
async def _fetch_partners_oracle(company_id: int, search: Optional[str] = None) -> List[PartnerOption]:
"""
Fetch partners from Oracle NOM_PARTENERI.
Will be implemented in Phase 2.
"""
# TODO: Implement using shared oracle_pool
# Example query:
# SELECT ID_PART, DEN_PART, COD_FISCAL
# FROM {schema}.NOM_PARTENERI
# WHERE DEN_PART LIKE :search
raise NotImplementedError("Oracle integration pending - Phase 2")
@staticmethod
async def _fetch_accounts_oracle(company_id: int, prefix: Optional[str] = None) -> List[AccountOption]:
"""
Fetch chart of accounts from Oracle PLAN_CONTURI.
Will be implemented in Phase 2.
"""
# TODO: Implement using shared oracle_pool
raise NotImplementedError("Oracle integration pending - Phase 2")
@staticmethod
async def _fetch_cash_registers_oracle(company_id: int) -> List[CashRegisterOption]:
"""
Fetch cash registers from Oracle NOM_CASE / NOM_BANCI.
Will be implemented in Phase 2.
"""
# TODO: Implement using shared oracle_pool
raise NotImplementedError("Oracle integration pending - Phase 2")

View File

@@ -0,0 +1,389 @@
"""Business logic service for receipts workflow."""
from decimal import Decimal, ROUND_HALF_UP
from typing import List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.receipt import Receipt, ReceiptStatus, ReceiptDirection
from app.db.models.accounting_entry import EntryType
from app.db.crud.receipt import ReceiptCRUD
from app.db.crud.accounting_entry import AccountingEntryCRUD
from app.schemas.receipt import (
ReceiptCreate,
ReceiptUpdate,
ReceiptFilter,
ReceiptResponse,
ReceiptListResponse,
AccountingEntryCreate,
)
from app.services.expense_types import EXPENSE_TYPES, get_expense_type
class ReceiptService:
"""Service for receipt business logic and workflow."""
@staticmethod
async def create_receipt(
session: AsyncSession,
data: ReceiptCreate,
created_by: str,
) -> Receipt:
"""Create a new receipt in DRAFT status."""
return await ReceiptCRUD.create(session, data, created_by)
@staticmethod
async def get_receipt(
session: AsyncSession,
receipt_id: int,
) -> Optional[Receipt]:
"""Get receipt by ID with all relationships."""
return await ReceiptCRUD.get_by_id(session, receipt_id, include_relations=True)
@staticmethod
async def get_receipts(
session: AsyncSession,
filters: ReceiptFilter,
) -> ReceiptListResponse:
"""Get paginated list of receipts."""
receipts, total = await ReceiptCRUD.get_list(session, filters)
pages = (total + filters.page_size - 1) // filters.page_size if total > 0 else 1
return ReceiptListResponse(
items=[ReceiptResponse.model_validate(r) for r in receipts],
total=total,
page=filters.page,
page_size=filters.page_size,
pages=pages,
)
@staticmethod
async def update_receipt(
session: AsyncSession,
receipt_id: int,
data: ReceiptUpdate,
username: str,
) -> Tuple[bool, str, Optional[Receipt]]:
"""
Update receipt (only DRAFT status).
Returns (success, message, receipt).
"""
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
if not receipt:
return False, "Receipt not found", None
if not await ReceiptCRUD.can_edit(receipt, username):
return False, "Cannot edit this receipt", None
updated = await ReceiptCRUD.update(session, receipt, data)
return True, "Receipt updated", updated
@staticmethod
async def delete_receipt(
session: AsyncSession,
receipt_id: int,
username: str,
) -> Tuple[bool, str]:
"""
Delete receipt (only DRAFT status).
Returns (success, message).
"""
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
if not receipt:
return False, "Receipt not found"
if not await ReceiptCRUD.can_delete(receipt, username):
return False, "Cannot delete this receipt"
await ReceiptCRUD.delete(session, receipt)
return True, "Receipt deleted"
@staticmethod
def generate_accounting_entries(receipt: Receipt) -> List[AccountingEntryCreate]:
"""
Generate accounting entries based on receipt data and expense type.
"""
entries: List[AccountingEntryCreate] = []
# Get expense type configuration
expense_type = get_expense_type(receipt.expense_type_code or "OTHER")
if not expense_type:
expense_type = EXPENSE_TYPES["OTHER"]
amount = Decimal(str(receipt.amount))
if receipt.direction == ReceiptDirection.CHELTUIALA:
# Expense: Debit expense account, Credit cash/bank
if expense_type.has_vat:
# Calculate net and VAT
vat_rate = expense_type.vat_percent / Decimal("100")
net_amount = (amount / (1 + vat_rate)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
vat_amount = amount - net_amount
# Debit: Expense account (net)
entries.append(AccountingEntryCreate(
entry_type=EntryType.DEBIT,
account_code=expense_type.account_code,
account_name=expense_type.account_name,
amount=net_amount,
partner_id=receipt.partner_id,
))
# Debit: VAT deductible
entries.append(AccountingEntryCreate(
entry_type=EntryType.DEBIT,
account_code=expense_type.vat_account,
account_name="TVA deductibila",
amount=vat_amount,
))
else:
# No VAT - full amount to expense
entries.append(AccountingEntryCreate(
entry_type=EntryType.DEBIT,
account_code=expense_type.account_code,
account_name=expense_type.account_name,
amount=amount,
partner_id=receipt.partner_id,
))
# Credit: Cash/Bank
cash_account = receipt.cash_register_account or "5311"
cash_name = receipt.cash_register_name or "Casa in lei"
entries.append(AccountingEntryCreate(
entry_type=EntryType.CREDIT,
account_code=cash_account,
account_name=cash_name,
amount=amount,
))
else:
# Income: Debit cash/bank, Credit income account
# For now, simple income posting
cash_account = receipt.cash_register_account or "5311"
cash_name = receipt.cash_register_name or "Casa in lei"
# Debit: Cash/Bank
entries.append(AccountingEntryCreate(
entry_type=EntryType.DEBIT,
account_code=cash_account,
account_name=cash_name,
amount=amount,
))
# Credit: Income account (7xx - to be configured)
entries.append(AccountingEntryCreate(
entry_type=EntryType.CREDIT,
account_code="7588",
account_name="Alte venituri din exploatare",
amount=amount,
))
return entries
@staticmethod
async def submit_for_review(
session: AsyncSession,
receipt_id: int,
username: str,
) -> Tuple[bool, str, Optional[Receipt]]:
"""
Submit receipt for review (DRAFT/REJECTED → PENDING_REVIEW).
Generates accounting entries automatically.
"""
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
if not receipt:
return False, "Receipt not found", None
if not await ReceiptCRUD.can_submit(receipt, username):
return False, "Cannot submit this receipt", None
# Check if receipt has at least one attachment
if not receipt.attachments:
return False, "Receipt must have at least one attachment", None
# Check required fields
if not receipt.expense_type_code:
return False, "Expense type is required", None
if not receipt.cash_register_account:
return False, "Cash register is required", None
# Generate accounting entries
entries = ReceiptService.generate_accounting_entries(receipt)
# Delete existing entries and create new ones
await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id)
await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True)
# Update status
updated = await ReceiptCRUD.update_status(
session, receipt, ReceiptStatus.PENDING_REVIEW
)
# Reload with entries
updated = await ReceiptCRUD.get_by_id(session, receipt_id)
return True, "Receipt submitted for review", updated
@staticmethod
async def approve_receipt(
session: AsyncSession,
receipt_id: int,
username: str,
) -> Tuple[bool, str, Optional[Receipt]]:
"""
Approve receipt (PENDING_REVIEW → APPROVED).
"""
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
if not receipt:
return False, "Receipt not found", None
if receipt.status != ReceiptStatus.PENDING_REVIEW:
return False, "Receipt is not pending review", None
# Validate accounting entries
if not receipt.entries:
return False, "Receipt has no accounting entries", None
# Update status
updated = await ReceiptCRUD.update_status(
session, receipt, ReceiptStatus.APPROVED, reviewed_by=username
)
return True, "Receipt approved", updated
@staticmethod
async def reject_receipt(
session: AsyncSession,
receipt_id: int,
username: str,
reason: str,
) -> Tuple[bool, str, Optional[Receipt]]:
"""
Reject receipt (PENDING_REVIEW → REJECTED).
"""
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
if not receipt:
return False, "Receipt not found", None
if receipt.status != ReceiptStatus.PENDING_REVIEW:
return False, "Receipt is not pending review", None
# Update status
updated = await ReceiptCRUD.update_status(
session,
receipt,
ReceiptStatus.REJECTED,
reviewed_by=username,
rejection_reason=reason,
)
return True, "Receipt rejected", updated
@staticmethod
async def resubmit_receipt(
session: AsyncSession,
receipt_id: int,
username: str,
) -> Tuple[bool, str, Optional[Receipt]]:
"""
Resubmit rejected receipt after corrections (REJECTED → PENDING_REVIEW).
"""
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
if not receipt:
return False, "Receipt not found", None
if receipt.status != ReceiptStatus.REJECTED:
return False, "Receipt is not rejected", None
if receipt.created_by != username:
return False, "Only the creator can resubmit", None
# Re-generate accounting entries
entries = ReceiptService.generate_accounting_entries(receipt)
await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id)
await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True)
# Update status
updated = await ReceiptCRUD.update_status(
session, receipt, ReceiptStatus.PENDING_REVIEW
)
# Reload with entries
updated = await ReceiptCRUD.get_by_id(session, receipt_id)
return True, "Receipt resubmitted for review", updated
@staticmethod
async def regenerate_entries(
session: AsyncSession,
receipt_id: int,
username: str,
) -> Tuple[bool, str, List[AccountingEntryCreate]]:
"""
Regenerate accounting entries for a receipt.
"""
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
if not receipt:
return False, "Receipt not found", []
if receipt.status not in [ReceiptStatus.DRAFT, ReceiptStatus.PENDING_REVIEW]:
return False, "Cannot regenerate entries for this receipt status", []
# Generate new entries
entries = ReceiptService.generate_accounting_entries(receipt)
# Replace existing entries
await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id)
await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True)
return True, "Entries regenerated", entries
@staticmethod
async def update_entries(
session: AsyncSession,
receipt_id: int,
entries: List[AccountingEntryCreate],
username: str,
) -> Tuple[bool, str, List]:
"""
Update accounting entries for a receipt (accountant action).
"""
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
if not receipt:
return False, "Receipt not found", []
if receipt.status != ReceiptStatus.PENDING_REVIEW:
return False, "Can only modify entries for receipts pending review", []
# Validate entries
is_valid, error = await AccountingEntryCRUD.validate_entries(entries)
if not is_valid:
return False, error, []
# Replace entries
updated_entries = await AccountingEntryCRUD.replace_all_for_receipt(
session, receipt_id, entries, username
)
return True, "Entries updated", updated_entries
@staticmethod
async def get_pending_count(
session: AsyncSession,
company_id: Optional[int] = None,
) -> int:
"""Get count of receipts pending review."""
receipts = await ReceiptCRUD.get_pending_review(session, company_id)
return len(receipts)

View File

@@ -0,0 +1,79 @@
"""Alembic environment configuration."""
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlmodel import SQLModel
# 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
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True, # Required for SQLite ALTER TABLE support
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True, # Required for SQLite ALTER TABLE support
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,112 @@
"""Initial receipts schema
Revision ID: 001_initial
Revises:
Create Date: 2024-12-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '001_initial'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create receipts table
op.create_table(
'receipts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('receipt_type', sa.Enum('BON_FISCAL', 'CHITANTA', name='receipttype'), nullable=False),
sa.Column('direction', sa.Enum('CHELTUIALA', 'INCASARE', name='receiptdirection'), nullable=False),
sa.Column('receipt_number', sa.String(length=50), nullable=True),
sa.Column('receipt_series', sa.String(length=20), nullable=True),
sa.Column('receipt_date', sa.Date(), nullable=False),
sa.Column('amount', sa.Numeric(precision=15, scale=2), nullable=False),
sa.Column('description', sa.String(length=500), nullable=True),
sa.Column('expense_type_code', sa.String(length=20), nullable=True),
sa.Column('company_id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=True),
sa.Column('partner_name', sa.String(length=200), nullable=True),
sa.Column('cash_register_id', sa.Integer(), nullable=True),
sa.Column('cash_register_name', sa.String(length=100), nullable=True),
sa.Column('cash_register_account', sa.String(length=20), nullable=True),
sa.Column('status', sa.Enum('DRAFT', 'PENDING_REVIEW', 'APPROVED', 'REJECTED', 'SYNCED', name='receiptstatus'), nullable=False),
sa.Column('created_by', sa.String(length=100), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('submitted_at', sa.DateTime(), nullable=True),
sa.Column('reviewed_by', sa.String(length=100), nullable=True),
sa.Column('reviewed_at', sa.DateTime(), nullable=True),
sa.Column('rejection_reason', sa.String(length=500), nullable=True),
sa.Column('oracle_synced_at', sa.DateTime(), nullable=True),
sa.Column('oracle_act_id', sa.Integer(), nullable=True),
sa.Column('oracle_error', sa.String(length=500), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_receipts_company_id'), 'receipts', ['company_id'], unique=False)
op.create_index(op.f('ix_receipts_status'), 'receipts', ['status'], unique=False)
op.create_index(op.f('ix_receipts_created_by'), 'receipts', ['created_by'], unique=False)
op.create_index(op.f('ix_receipts_receipt_date'), 'receipts', ['receipt_date'], unique=False)
# Create receipt_attachments table
op.create_table(
'receipt_attachments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('receipt_id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('stored_filename', sa.String(length=255), nullable=False),
sa.Column('file_path', sa.String(length=500), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=False),
sa.Column('uploaded_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['receipt_id'], ['receipts.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_receipt_attachments_receipt_id'), 'receipt_attachments', ['receipt_id'], unique=False)
# Create accounting_entries table
op.create_table(
'accounting_entries',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('receipt_id', sa.Integer(), nullable=False),
sa.Column('entry_type', sa.Enum('DEBIT', 'CREDIT', name='entrytype'), nullable=False),
sa.Column('account_code', sa.String(length=20), nullable=False),
sa.Column('account_name', sa.String(length=200), nullable=True),
sa.Column('amount', sa.Numeric(precision=15, scale=2), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=True),
sa.Column('cost_center_id', sa.Integer(), nullable=True),
sa.Column('is_auto_generated', sa.Boolean(), nullable=False),
sa.Column('modified_by', sa.String(length=100), nullable=True),
sa.Column('modified_at', sa.DateTime(), nullable=True),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['receipt_id'], ['receipts.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_accounting_entries_receipt_id'), 'accounting_entries', ['receipt_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_accounting_entries_receipt_id'), table_name='accounting_entries')
op.drop_table('accounting_entries')
op.drop_index(op.f('ix_receipt_attachments_receipt_id'), table_name='receipt_attachments')
op.drop_table('receipt_attachments')
op.drop_index(op.f('ix_receipts_receipt_date'), table_name='receipts')
op.drop_index(op.f('ix_receipts_created_by'), table_name='receipts')
op.drop_index(op.f('ix_receipts_status'), table_name='receipts')
op.drop_index(op.f('ix_receipts_company_id'), table_name='receipts')
op.drop_table('receipts')
# Drop enums (SQLite doesn't actually use these, but for consistency)
op.execute("DROP TYPE IF EXISTS receipttype")
op.execute("DROP TYPE IF EXISTS receiptdirection")
op.execute("DROP TYPE IF EXISTS receiptstatus")
op.execute("DROP TYPE IF EXISTS entrytype")

View File

@@ -0,0 +1,32 @@
# FastAPI
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
# Database - SQLModel + Alembic
sqlmodel>=0.0.14
sqlalchemy[asyncio]>=2.0.25
aiosqlite>=0.19.0
alembic>=1.13.1
# Pydantic
pydantic>=2.5.3
pydantic-settings>=2.1.0
# File handling
python-multipart>=0.0.6
aiofiles>=23.2.1
Pillow>=10.2.0
# Authentication (shared)
PyJWT>=2.8.0
# Oracle (for nomenclatures)
oracledb>=2.0.1
# Utils
python-dotenv>=1.0.0
httpx>=0.26.0
# Testing
pytest>=8.0.0
pytest-asyncio>=0.23.3

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Data Entry - Bonuri Fiscale</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,27 @@
{
"name": "data-entry-frontend",
"version": "1.0.0",
"description": "Data Entry App - Vue.js Frontend",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.5",
"primevue": "^3.48.0",
"primeicons": "^6.0.1",
"@primevue/themes": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.10",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.20.0"
}
}

View File

@@ -0,0 +1,129 @@
<template>
<div class="app-container">
<header 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>
</nav>
</div>
</header>
<main class="app-main">
<router-view />
</main>
<Toast position="top-right" />
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useReceiptsStore } from './stores/receiptsStore'
const receiptsStore = useReceiptsStore()
const pendingCount = ref(0)
onMounted(async () => {
try {
const stats = await receiptsStore.fetchStats()
pendingCount.value = stats?.pending_review?.count || 0
} catch (error) {
console.error('Failed to fetch stats:', error)
}
})
</script>
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
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;
}
.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);
}
.app-main {
flex: 1;
padding: 2rem;
background-color: #f5f7fa;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
text-align: center;
}
.app-nav {
flex-wrap: wrap;
justify-content: center;
}
.app-main {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,275 @@
/* Global styles for Data Entry App */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Card styles */
.roa-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.roa-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.roa-card-title {
font-size: 1.25rem;
font-weight: 600;
color: #333;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Form styles */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field label {
font-weight: 500;
color: #555;
font-size: 0.9rem;
}
.form-field .p-inputtext,
.form-field .p-dropdown,
.form-field .p-calendar,
.form-field .p-inputnumber {
width: 100%;
}
.form-field-full {
grid-column: 1 / -1;
}
/* Status badges */
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-draft {
background-color: #e3f2fd;
color: #1976d2;
}
.status-pending {
background-color: #fff3e0;
color: #f57c00;
}
.status-approved {
background-color: #e8f5e9;
color: #388e3c;
}
.status-rejected {
background-color: #ffebee;
color: #d32f2f;
}
.status-synced {
background-color: #e0f2f1;
color: #00796b;
}
/* Table styles */
.data-table-container {
overflow-x: auto;
}
.p-datatable .p-datatable-header {
background: transparent;
border: none;
padding: 0 0 1rem 0;
}
.p-datatable .p-datatable-thead > tr > th {
background: #f8f9fa;
color: #495057;
font-weight: 600;
}
/* Button groups */
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Upload area */
.upload-area {
border: 2px dashed #ddd;
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover {
border-color: #667eea;
background-color: #f8f9ff;
}
.upload-area.has-files {
border-style: solid;
border-color: #667eea;
}
/* Image preview */
.image-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.image-preview-item {
position: relative;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 1;
}
.image-preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-preview-item .remove-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
/* Accounting entries table */
.entries-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.entries-table th,
.entries-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #eee;
}
.entries-table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.entries-table .debit {
color: #d32f2f;
}
.entries-table .credit {
color: #388e3c;
}
/* Stats cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 1.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
text-align: center;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: 700;
color: #333;
}
.stat-card .stat-label {
color: #666;
font-size: 0.9rem;
margin-top: 0.25rem;
}
/* Loading state */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.empty-state i {
font-size: 4rem;
color: #ddd;
margin-bottom: 1rem;
}
.empty-state h3 {
margin-bottom: 0.5rem;
}
/* Responsive utilities */
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.button-group {
flex-direction: column;
}
.button-group .p-button {
width: 100%;
}
}

View File

@@ -0,0 +1,81 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
// PrimeVue components
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Dropdown from 'primevue/dropdown'
import Calendar from 'primevue/calendar'
import Textarea from 'primevue/textarea'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import FileUpload from 'primevue/fileupload'
import Image from 'primevue/image'
import Tag from 'primevue/tag'
import Card from 'primevue/card'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Checkbox from 'primevue/checkbox'
import RadioButton from 'primevue/radiobutton'
import ProgressSpinner from 'primevue/progressspinner'
import Badge from 'primevue/badge'
import Toolbar from 'primevue/toolbar'
import Divider from 'primevue/divider'
// PrimeVue styles
import 'primevue/resources/themes/lara-light-blue/theme.css'
import 'primevue/resources/primevue.min.css'
import 'primeicons/primeicons.css'
// Custom styles
import './assets/css/main.css'
const app = createApp(App)
// Pinia store
app.use(createPinia())
// Router
app.use(router)
// PrimeVue
app.use(PrimeVue, { ripple: true })
app.use(ToastService)
app.use(ConfirmationService)
// Register PrimeVue components globally
app.component('Button', Button)
app.component('InputText', InputText)
app.component('InputNumber', InputNumber)
app.component('Dropdown', Dropdown)
app.component('Calendar', Calendar)
app.component('Textarea', Textarea)
app.component('DataTable', DataTable)
app.component('Column', Column)
app.component('Dialog', Dialog)
app.component('Toast', Toast)
app.component('ConfirmDialog', ConfirmDialog)
app.component('FileUpload', FileUpload)
app.component('Image', Image)
app.component('Tag', Tag)
app.component('Card', Card)
app.component('TabView', TabView)
app.component('TabPanel', TabPanel)
app.component('Checkbox', Checkbox)
app.component('RadioButton', RadioButton)
app.component('ProgressSpinner', ProgressSpinner)
app.component('Badge', Badge)
app.component('Toolbar', Toolbar)
app.component('Divider', Divider)
app.mount('#app')

View File

@@ -0,0 +1,49 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'ReceiptsList',
component: () => import('../views/receipts/ReceiptsListView.vue'),
meta: { title: 'Lista Bonuri' }
},
{
path: '/create',
name: 'ReceiptCreate',
component: () => import('../views/receipts/ReceiptCreateView.vue'),
meta: { title: 'Bon Nou' }
},
{
path: '/receipt/:id',
name: 'ReceiptDetail',
component: () => import('../views/receipts/ReceiptDetailView.vue'),
meta: { title: 'Detalii Bon' }
},
{
path: '/receipt/:id/edit',
name: 'ReceiptEdit',
component: () => import('../views/receipts/ReceiptCreateView.vue'),
meta: { title: 'Editare Bon' }
},
{
path: '/approval',
name: 'ReceiptApproval',
component: () => import('../views/receipts/ReceiptApprovalView.vue'),
meta: { title: 'Aprobare Bonuri' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Update page title
router.beforeEach((to, from, next) => {
document.title = to.meta.title
? `${to.meta.title} | Data Entry`
: 'Data Entry - Bonuri Fiscale'
next()
})
export default router

View File

@@ -0,0 +1,365 @@
import { defineStore } from 'pinia'
import axios from 'axios'
const api = axios.create({
baseURL: '/api/receipts',
headers: {
'Content-Type': 'application/json',
},
})
export const useReceiptsStore = defineStore('receipts', {
state: () => ({
receipts: [],
currentReceipt: null,
pendingReceipts: [],
stats: null,
loading: false,
error: null,
pagination: {
page: 1,
pageSize: 20,
total: 0,
pages: 1,
},
filters: {
status: null,
search: '',
dateFrom: null,
dateTo: null,
},
// Nomenclatures
partners: [],
accounts: [],
cashRegisters: [],
expenseTypes: [],
}),
getters: {
hasReceipts: (state) => state.receipts.length > 0,
hasPendingReceipts: (state) => state.pendingReceipts.length > 0,
pendingCount: (state) => state.pendingReceipts.length,
},
actions: {
// ============ Receipts CRUD ============
async fetchReceipts() {
this.loading = true
this.error = null
try {
const params = {
page: this.pagination.page,
page_size: this.pagination.pageSize,
}
if (this.filters.status) {
params.status = this.filters.status
}
if (this.filters.search) {
params.search = this.filters.search
}
if (this.filters.dateFrom) {
params.date_from = this.filters.dateFrom
}
if (this.filters.dateTo) {
params.date_to = this.filters.dateTo
}
const response = await api.get('/', { params })
this.receipts = response.data.items
this.pagination.total = response.data.total
this.pagination.pages = response.data.pages
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch receipts'
throw error
} finally {
this.loading = false
}
},
async fetchReceiptById(id) {
this.loading = true
this.error = null
try {
const response = await api.get(`/${id}`)
this.currentReceipt = response.data
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch receipt'
throw error
} finally {
this.loading = false
}
},
async createReceipt(data) {
this.loading = true
this.error = null
try {
const response = await api.post('/', data)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to create receipt'
throw error
} finally {
this.loading = false
}
},
async updateReceipt(id, data) {
this.loading = true
this.error = null
try {
const response = await api.put(`/${id}`, data)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to update receipt'
throw error
} finally {
this.loading = false
}
},
async deleteReceipt(id) {
this.loading = true
this.error = null
try {
await api.delete(`/${id}`)
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to delete receipt'
throw error
} finally {
this.loading = false
}
},
// ============ Workflow Actions ============
async submitReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/submit`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to submit receipt'
throw error
} finally {
this.loading = false
}
},
async approveReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/approve`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to approve receipt'
throw error
} finally {
this.loading = false
}
},
async rejectReceipt(id, reason) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/reject`, { reason })
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to reject receipt'
throw error
} finally {
this.loading = false
}
},
async resubmitReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/resubmit`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to resubmit receipt'
throw error
} finally {
this.loading = false
}
},
// ============ Pending Receipts ============
async fetchPendingReceipts() {
this.loading = true
this.error = null
try {
const response = await api.get('/pending')
this.pendingReceipts = response.data
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch pending receipts'
throw error
} finally {
this.loading = false
}
},
// ============ Attachments ============
async uploadAttachment(receiptId, file) {
const formData = new FormData()
formData.append('file', file)
try {
const response = await api.post(`/${receiptId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to upload attachment')
}
},
async deleteAttachment(attachmentId) {
try {
await api.delete(`/attachments/${attachmentId}`)
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to delete attachment')
}
},
getAttachmentUrl(attachmentId) {
return `/api/receipts/attachments/${attachmentId}/download`
},
// ============ Accounting Entries ============
async fetchEntries(receiptId) {
try {
const response = await api.get(`/${receiptId}/entries`)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to fetch entries')
}
},
async updateEntries(receiptId, entries) {
try {
const response = await api.put(`/${receiptId}/entries`, { entries })
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to update entries')
}
},
async regenerateEntries(receiptId) {
try {
const response = await api.post(`/${receiptId}/entries/regenerate`)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to regenerate entries')
}
},
// ============ Nomenclatures ============
async fetchPartners(search = '') {
try {
const response = await api.get('/nomenclature/partners', {
params: { search },
})
this.partners = response.data
return response.data
} catch (error) {
console.error('Failed to fetch partners:', error)
return []
}
},
async fetchAccounts(prefix = '') {
try {
const response = await api.get('/nomenclature/accounts', {
params: { prefix },
})
this.accounts = response.data
return response.data
} catch (error) {
console.error('Failed to fetch accounts:', error)
return []
}
},
async fetchCashRegisters() {
try {
const response = await api.get('/nomenclature/cash-registers')
this.cashRegisters = response.data
return response.data
} catch (error) {
console.error('Failed to fetch cash registers:', error)
return []
}
},
async fetchExpenseTypes() {
try {
const response = await api.get('/nomenclature/expense-types')
this.expenseTypes = response.data
return response.data
} catch (error) {
console.error('Failed to fetch expense types:', error)
return []
}
},
async fetchAllNomenclatures() {
await Promise.all([
this.fetchPartners(),
this.fetchCashRegisters(),
this.fetchExpenseTypes(),
])
},
// ============ Stats ============
async fetchStats() {
try {
const response = await api.get('/stats')
this.stats = response.data
return response.data
} catch (error) {
console.error('Failed to fetch stats:', error)
return null
}
},
// ============ Filters & Pagination ============
setFilters(filters) {
this.filters = { ...this.filters, ...filters }
this.pagination.page = 1
},
clearFilters() {
this.filters = {
status: null,
search: '',
dateFrom: null,
dateTo: null,
}
this.pagination.page = 1
},
setPage(page) {
this.pagination.page = page
},
clearCurrentReceipt() {
this.currentReceipt = null
},
},
})

View File

@@ -0,0 +1,47 @@
// Constants for the application
export const EXPENSE_TYPES = {
FUEL: 'Combustibil',
MATERIALS: 'Materiale consumabile',
OFFICE: 'Rechizite birou',
PHONE: 'Telefonie / Internet',
PARKING: 'Parcare',
FOOD: 'Alimentatie',
TRANSPORT: 'Transport',
OTHER: 'Altele',
}
export const RECEIPT_TYPES = {
bon_fiscal: 'Bon Fiscal',
chitanta: 'Chitanta',
}
export const RECEIPT_DIRECTIONS = {
cheltuiala: 'Cheltuiala',
incasare: 'Incasare',
}
export const RECEIPT_STATUSES = {
draft: { label: 'Ciorna', class: 'status-draft', severity: 'info' },
pending_review: { label: 'In asteptare', class: 'status-pending', severity: 'warning' },
approved: { label: 'Aprobat', class: 'status-approved', severity: 'success' },
rejected: { label: 'Respins', class: 'status-rejected', severity: 'danger' },
synced: { label: 'Sincronizat', class: 'status-synced', severity: 'success' },
}
export const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('ro-RO')
}
export const formatDateTime = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ro-RO')
}
export const formatAmount = (amount, currency = 'RON') => {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency,
}).format(amount)
}

View File

@@ -0,0 +1,488 @@
<template>
<div class="receipt-approval-view">
<div class="roa-card">
<div class="roa-card-header">
<h2 class="roa-card-title">
<i class="pi pi-check-circle"></i>
Aprobare Bonuri
<Badge v-if="pendingReceipts.length" :value="pendingReceipts.length" severity="danger" />
</h2>
<Button
v-if="selectedReceipts.length > 0"
:label="`Aproba selectate (${selectedReceipts.length})`"
icon="pi pi-check"
severity="success"
@click="approveSelected"
:loading="approving"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-container">
<ProgressSpinner />
</div>
<!-- Empty State -->
<div v-else-if="!pendingReceipts.length" class="empty-state">
<i class="pi pi-check-circle"></i>
<h3>Niciun bon de aprobat</h3>
<p>Toate bonurile au fost procesate</p>
</div>
<!-- Pending Receipts List -->
<div v-else>
<DataTable
v-model:selection="selectedReceipts"
:value="pendingReceipts"
responsiveLayout="scroll"
stripedRows
>
<Column selectionMode="multiple" headerStyle="width: 3rem" />
<Column field="receipt_date" header="Data" style="width: 100px">
<template #body="{ data }">
{{ formatDate(data.receipt_date) }}
</template>
</Column>
<Column field="partner_name" header="Furnizor" style="min-width: 150px">
<template #body="{ data }">
{{ data.partner_name || '-' }}
</template>
</Column>
<Column field="amount" header="Suma" style="width: 120px">
<template #body="{ data }">
<strong>{{ formatAmount(data.amount) }}</strong>
</template>
</Column>
<Column field="created_by" header="Creat de" style="width: 120px" />
<Column field="attachments" header="Atasamente" style="width: 100px">
<template #body="{ data }">
<Badge :value="data.attachments?.length || 0" />
</template>
</Column>
<Column header="Actiuni" style="width: 200px">
<template #body="{ data }">
<div class="button-group">
<Button
icon="pi pi-eye"
severity="info"
text
rounded
@click="viewReceipt(data)"
v-tooltip="'Detalii'"
/>
<Button
icon="pi pi-check"
severity="success"
text
rounded
@click="approveReceipt(data)"
v-tooltip="'Aproba'"
/>
<Button
icon="pi pi-times"
severity="danger"
text
rounded
@click="openRejectDialog(data)"
v-tooltip="'Respinge'"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Receipt Detail Dialog -->
<Dialog
v-model:visible="detailDialog"
modal
:header="`Bon #${selectedReceiptDetail?.id}`"
:style="{ width: '90vw', maxWidth: '900px' }"
>
<template v-if="selectedReceiptDetail">
<TabView>
<TabPanel header="Detalii">
<div class="detail-grid-dialog">
<div class="detail-section">
<h4>Informatii Document</h4>
<div class="detail-list">
<div class="detail-item">
<span class="label">Tip:</span>
<span>{{ selectedReceiptDetail.receipt_type === 'bon_fiscal' ? 'Bon Fiscal' : 'Chitanta' }}</span>
</div>
<div class="detail-item">
<span class="label">Data:</span>
<span>{{ formatDate(selectedReceiptDetail.receipt_date) }}</span>
</div>
<div class="detail-item">
<span class="label">Suma:</span>
<strong>{{ formatAmount(selectedReceiptDetail.amount) }}</strong>
</div>
<div class="detail-item">
<span class="label">Furnizor:</span>
<span>{{ selectedReceiptDetail.partner_name || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">Descriere:</span>
<span>{{ selectedReceiptDetail.description || '-' }}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Atasamente</h4>
<div v-if="selectedReceiptDetail.attachments?.length" class="attachments-preview">
<div
v-for="att in selectedReceiptDetail.attachments"
:key="att.id"
class="attachment-preview-item"
>
<Image
v-if="att.mime_type?.startsWith('image/')"
:src="store.getAttachmentUrl(att.id)"
:alt="att.filename"
preview
width="150"
/>
<a v-else :href="store.getAttachmentUrl(att.id)" target="_blank">
<i class="pi pi-file-pdf"></i>
{{ att.filename }}
</a>
</div>
</div>
<p v-else class="no-data">Niciun atasament</p>
</div>
</div>
</TabPanel>
<TabPanel header="Note Contabile">
<div v-if="selectedReceiptDetail.entries?.length" class="entries-section">
<table class="entries-table">
<thead>
<tr>
<th>Tip</th>
<th>Cont</th>
<th>Denumire</th>
<th style="text-align: right;">Suma</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in selectedReceiptDetail.entries" :key="entry.id">
<td>
<Tag
:value="entry.entry_type === 'debit' ? 'D' : 'C'"
:severity="entry.entry_type === 'debit' ? 'danger' : 'success'"
/>
</td>
<td>{{ entry.account_code }}</td>
<td>{{ entry.account_name || '-' }}</td>
<td :class="entry.entry_type" style="text-align: right;">
{{ formatAmount(entry.amount) }}
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="no-data">Nu exista note contabile</p>
</TabPanel>
</TabView>
<div class="dialog-actions">
<Button
label="Aproba"
icon="pi pi-check"
severity="success"
@click="approveReceipt(selectedReceiptDetail)"
/>
<Button
label="Respinge"
icon="pi pi-times"
severity="danger"
@click="openRejectDialog(selectedReceiptDetail); detailDialog = false;"
/>
</div>
</template>
</Dialog>
<!-- Reject Dialog -->
<Dialog
v-model:visible="rejectDialog"
modal
header="Respingere Bon"
:style="{ width: '500px' }"
>
<div class="form-field">
<label>Motiv respingere *</label>
<Textarea
v-model="rejectReason"
rows="4"
placeholder="Introduceti motivul respingerii..."
/>
</div>
<template #footer>
<Button
label="Anuleaza"
icon="pi pi-times"
severity="secondary"
@click="rejectDialog = false"
/>
<Button
label="Respinge"
icon="pi pi-check"
severity="danger"
@click="confirmReject"
:disabled="!rejectReason || rejectReason.length < 5"
:loading="rejecting"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
const toast = useToast()
const store = useReceiptsStore()
const pendingReceipts = ref([])
const selectedReceipts = ref([])
const loading = ref(true)
const approving = ref(false)
const rejecting = ref(false)
const detailDialog = ref(false)
const selectedReceiptDetail = ref(null)
const rejectDialog = ref(false)
const receiptToReject = ref(null)
const rejectReason = ref('')
onMounted(async () => {
await loadPendingReceipts()
})
const loadPendingReceipts = async () => {
loading.value = true
try {
pendingReceipts.value = await store.fetchPendingReceipts()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-au putut incarca bonurile',
life: 5000,
})
} finally {
loading.value = false
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('ro-RO')
}
const formatAmount = (amount) => {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
}).format(amount)
}
const viewReceipt = (receipt) => {
selectedReceiptDetail.value = receipt
detailDialog.value = true
}
const approveReceipt = async (receipt) => {
approving.value = true
try {
const result = await store.approveReceipt(receipt.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost aprobat',
life: 3000,
})
detailDialog.value = false
await loadPendingReceipts()
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut aproba bonul',
life: 5000,
})
} finally {
approving.value = false
}
}
const approveSelected = async () => {
if (!selectedReceipts.value.length) return
approving.value = true
let successCount = 0
let errorCount = 0
for (const receipt of selectedReceipts.value) {
try {
const result = await store.approveReceipt(receipt.id)
if (result.success) {
successCount++
} else {
errorCount++
}
} catch (error) {
errorCount++
}
}
approving.value = false
selectedReceipts.value = []
if (successCount > 0) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: `${successCount} bonuri aprobate`,
life: 3000,
})
}
if (errorCount > 0) {
toast.add({
severity: 'warn',
summary: 'Atentie',
detail: `${errorCount} bonuri nu au putut fi aprobate`,
life: 5000,
})
}
await loadPendingReceipts()
}
const openRejectDialog = (receipt) => {
receiptToReject.value = receipt
rejectReason.value = ''
rejectDialog.value = true
}
const confirmReject = async () => {
if (!receiptToReject.value || !rejectReason.value) return
rejecting.value = true
try {
const result = await store.rejectReceipt(receiptToReject.value.id, rejectReason.value)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost respins',
life: 3000,
})
rejectDialog.value = false
await loadPendingReceipts()
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut respinge bonul',
life: 5000,
})
} finally {
rejecting.value = false
}
}
</script>
<style scoped>
.detail-grid-dialog {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
@media (max-width: 768px) {
.detail-grid-dialog {
grid-template-columns: 1fr;
}
}
.detail-section h4 {
margin-bottom: 1rem;
color: #333;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.detail-item {
display: flex;
gap: 0.5rem;
}
.detail-item .label {
color: #666;
min-width: 80px;
}
.attachments-preview {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.attachment-preview-item {
max-width: 150px;
}
.no-data {
color: #666;
font-style: italic;
}
.entries-section {
overflow-x: auto;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
</style>

View File

@@ -0,0 +1,535 @@
<template>
<div class="receipt-create-view">
<div class="roa-card">
<div class="roa-card-header">
<h2 class="roa-card-title">
<i class="pi pi-plus-circle"></i>
{{ isEditMode ? 'Editare Bon Fiscal' : 'Bon Fiscal Nou' }}
</h2>
<Button
label="Inapoi"
icon="pi pi-arrow-left"
severity="secondary"
@click="$router.push('/')"
/>
</div>
<form @submit.prevent="saveReceipt">
<!-- Upload Section -->
<div class="upload-section">
<h3>
<i class="pi pi-camera"></i>
Poza Bon (obligatoriu)
</h3>
<FileUpload
ref="fileUpload"
mode="advanced"
:multiple="true"
accept="image/*,application/pdf"
:maxFileSize="10000000"
@select="onFileSelect"
@remove="onFileRemove"
:auto="false"
:showUploadButton="false"
:showCancelButton="false"
>
<template #empty>
<div class="upload-area">
<i class="pi pi-cloud-upload" style="font-size: 3rem; color: #667eea;"></i>
<p>Trage fisierele aici sau click pentru a selecta</p>
<p style="font-size: 0.8rem; color: #888;">
Formate acceptate: JPG, PNG, PDF (max 10MB)
</p>
</div>
</template>
</FileUpload>
<!-- Existing attachments (edit mode) -->
<div v-if="existingAttachments.length" class="image-preview-grid">
<div
v-for="att in existingAttachments"
:key="att.id"
class="image-preview-item"
>
<img
v-if="att.mime_type?.startsWith('image/')"
:src="store.getAttachmentUrl(att.id)"
:alt="att.filename"
/>
<div v-else class="pdf-preview">
<i class="pi pi-file-pdf" style="font-size: 3rem;"></i>
<span>{{ att.filename }}</span>
</div>
<Button
icon="pi pi-times"
severity="danger"
rounded
class="remove-btn"
@click="removeExistingAttachment(att.id)"
/>
</div>
</div>
</div>
<Divider />
<!-- Receipt Details -->
<h3>
<i class="pi pi-info-circle"></i>
Detalii Bon
</h3>
<div class="form-grid">
<div class="form-field">
<label>Tip Document *</label>
<div class="radio-group">
<div class="radio-item">
<RadioButton
v-model="form.receipt_type"
value="bon_fiscal"
inputId="type_bon"
/>
<label for="type_bon">Bon Fiscal</label>
</div>
<div class="radio-item">
<RadioButton
v-model="form.receipt_type"
value="chitanta"
inputId="type_chitanta"
/>
<label for="type_chitanta">Chitanta</label>
</div>
</div>
</div>
<div class="form-field">
<label>Directie *</label>
<div class="radio-group">
<div class="radio-item">
<RadioButton
v-model="form.direction"
value="cheltuiala"
inputId="dir_cheltuiala"
/>
<label for="dir_cheltuiala">Cheltuiala</label>
</div>
<div class="radio-item">
<RadioButton
v-model="form.direction"
value="incasare"
inputId="dir_incasare"
/>
<label for="dir_incasare">Incasare</label>
</div>
</div>
</div>
<div class="form-field">
<label>Data Bon *</label>
<Calendar
v-model="form.receipt_date"
dateFormat="dd.mm.yy"
showIcon
required
/>
</div>
<div class="form-field">
<label>Suma (RON) *</label>
<InputNumber
v-model="form.amount"
mode="currency"
currency="RON"
locale="ro-RO"
:minFractionDigits="2"
:maxFractionDigits="2"
required
/>
</div>
<div class="form-field">
<label>Furnizor</label>
<Dropdown
v-model="form.partner_id"
:options="partners"
optionLabel="name"
optionValue="id"
placeholder="Selecteaza furnizor"
filter
showClear
@change="onPartnerChange"
/>
</div>
<div class="form-field">
<label>Tip Cheltuiala *</label>
<Dropdown
v-model="form.expense_type_code"
:options="expenseTypes"
optionLabel="name"
optionValue="code"
placeholder="Selecteaza tip"
required
/>
</div>
<div class="form-field">
<label>Casa / Banca *</label>
<Dropdown
v-model="form.cash_register_id"
:options="cashRegisters"
optionLabel="name"
optionValue="id"
placeholder="Selecteaza casa/banca"
@change="onCashRegisterChange"
required
/>
</div>
<div class="form-field">
<label>Numar Bon</label>
<InputText v-model="form.receipt_number" placeholder="Optional" />
</div>
<div class="form-field form-field-full">
<label>Descriere</label>
<Textarea
v-model="form.description"
rows="3"
placeholder="Detalii suplimentare..."
/>
</div>
</div>
<Divider />
<!-- Actions -->
<div class="button-group" style="justify-content: flex-end;">
<Button
type="button"
label="Anuleaza"
icon="pi pi-times"
severity="secondary"
@click="$router.push('/')"
/>
<Button
type="submit"
label="Salveaza Ciorna"
icon="pi pi-save"
:loading="saving"
/>
<Button
v-if="isEditMode && receipt?.status === 'draft'"
type="button"
label="Trimite spre aprobare"
icon="pi pi-send"
severity="success"
:loading="submitting"
@click="submitForReview"
/>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useReceiptsStore()
const isEditMode = computed(() => !!route.params.id)
const receiptId = computed(() => route.params.id)
const receipt = ref(null)
const form = ref({
receipt_type: 'bon_fiscal',
direction: 'cheltuiala',
receipt_date: new Date(),
amount: null,
partner_id: null,
partner_name: null,
expense_type_code: null,
cash_register_id: null,
cash_register_name: null,
cash_register_account: null,
receipt_number: '',
description: '',
company_id: 1, // Default company for Phase 1
})
const selectedFiles = ref([])
const existingAttachments = ref([])
const saving = ref(false)
const submitting = ref(false)
const partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes)
const cashRegisters = computed(() => store.cashRegisters)
onMounted(async () => {
await store.fetchAllNomenclatures()
if (isEditMode.value) {
await loadReceipt()
}
})
const loadReceipt = async () => {
try {
receipt.value = await store.fetchReceiptById(receiptId.value)
// Populate form
form.value = {
receipt_type: receipt.value.receipt_type,
direction: receipt.value.direction,
receipt_date: new Date(receipt.value.receipt_date),
amount: parseFloat(receipt.value.amount),
partner_id: receipt.value.partner_id,
partner_name: receipt.value.partner_name,
expense_type_code: receipt.value.expense_type_code,
cash_register_id: receipt.value.cash_register_id,
cash_register_name: receipt.value.cash_register_name,
cash_register_account: receipt.value.cash_register_account,
receipt_number: receipt.value.receipt_number || '',
description: receipt.value.description || '',
company_id: receipt.value.company_id,
}
existingAttachments.value = receipt.value.attachments || []
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut incarca bonul',
life: 5000,
})
router.push('/')
}
}
const onPartnerChange = (event) => {
const partner = partners.value.find(p => p.id === event.value)
form.value.partner_name = partner?.name || null
}
const onCashRegisterChange = (event) => {
const cr = cashRegisters.value.find(c => c.id === event.value)
form.value.cash_register_name = cr?.name || null
form.value.cash_register_account = cr?.account_code || null
}
const onFileSelect = (event) => {
selectedFiles.value = [...selectedFiles.value, ...event.files]
}
const onFileRemove = (event) => {
selectedFiles.value = selectedFiles.value.filter(f => f.name !== event.file.name)
}
const removeExistingAttachment = async (attachmentId) => {
try {
await store.deleteAttachment(attachmentId)
existingAttachments.value = existingAttachments.value.filter(a => a.id !== attachmentId)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Atasamentul a fost sters',
life: 3000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
}
}
const validateForm = () => {
if (!form.value.receipt_date) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Data bonului este obligatorie',
life: 3000,
})
return false
}
if (!form.value.amount || form.value.amount <= 0) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Suma trebuie sa fie mai mare decat 0',
life: 3000,
})
return false
}
if (!form.value.expense_type_code) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Tipul cheltuielii este obligatoriu',
life: 3000,
})
return false
}
if (!form.value.cash_register_id) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Casa/Banca este obligatorie',
life: 3000,
})
return false
}
return true
}
const saveReceipt = async () => {
if (!validateForm()) return
saving.value = true
try {
const data = {
...form.value,
receipt_date: form.value.receipt_date.toISOString().split('T')[0],
}
let savedReceipt
if (isEditMode.value) {
savedReceipt = await store.updateReceipt(receiptId.value, data)
} else {
savedReceipt = await store.createReceipt(data)
}
// Upload new files
for (const file of selectedFiles.value) {
try {
await store.uploadAttachment(savedReceipt.id, file)
} catch (error) {
toast.add({
severity: 'warn',
summary: 'Atentie',
detail: `Nu s-a putut incarca: ${file.name}`,
life: 5000,
})
}
}
toast.add({
severity: 'success',
summary: 'Succes',
detail: isEditMode.value ? 'Bonul a fost actualizat' : 'Bonul a fost creat',
life: 3000,
})
router.push(`/receipt/${savedReceipt.id}`)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut salva bonul',
life: 5000,
})
} finally {
saving.value = false
}
}
const submitForReview = async () => {
// First save any changes
if (!validateForm()) return
submitting.value = true
try {
// Save first
await saveReceipt()
// Then submit
const result = await store.submitReceipt(receiptId.value)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis spre aprobare',
life: 3000,
})
router.push('/')
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut trimite bonul',
life: 5000,
})
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.upload-section {
margin-bottom: 1.5rem;
}
.upload-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.radio-group {
display: flex;
gap: 1.5rem;
}
.radio-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pdf-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background: #f5f5f5;
padding: 1rem;
}
.pdf-preview span {
font-size: 0.75rem;
margin-top: 0.5rem;
text-align: center;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,524 @@
<template>
<div class="receipt-detail-view">
<!-- Loading -->
<div v-if="loading" class="loading-container">
<ProgressSpinner />
</div>
<template v-else-if="receipt">
<!-- Header Card -->
<div class="roa-card">
<div class="roa-card-header">
<div>
<h2 class="roa-card-title">
<i class="pi pi-receipt"></i>
Bon #{{ receipt.id }}
</h2>
<span :class="['status-badge', getStatusClass(receipt.status)]">
{{ getStatusLabel(receipt.status) }}
</span>
</div>
<div class="button-group">
<Button
label="Inapoi"
icon="pi pi-arrow-left"
severity="secondary"
@click="$router.push('/')"
/>
<Button
v-if="receipt.status === 'draft'"
label="Editeaza"
icon="pi pi-pencil"
@click="$router.push(`/receipt/${receipt.id}/edit`)"
/>
<Button
v-if="receipt.status === 'draft'"
label="Trimite spre aprobare"
icon="pi pi-send"
severity="success"
@click="submitReceipt"
:loading="submitting"
/>
<Button
v-if="receipt.status === 'rejected'"
label="Re-trimite"
icon="pi pi-refresh"
severity="warning"
@click="resubmitReceipt"
:loading="submitting"
/>
</div>
</div>
<!-- Rejection Reason -->
<div v-if="receipt.rejection_reason" class="rejection-alert">
<i class="pi pi-exclamation-triangle"></i>
<div>
<strong>Motiv respingere:</strong>
<p>{{ receipt.rejection_reason }}</p>
<small>Respins de {{ receipt.reviewed_by }} la {{ formatDateTime(receipt.reviewed_at) }}</small>
</div>
</div>
</div>
<div class="detail-grid">
<!-- Receipt Details -->
<div class="roa-card">
<h3>
<i class="pi pi-info-circle"></i>
Detalii Bon
</h3>
<div class="detail-list">
<div class="detail-item">
<span class="label">Tip Document</span>
<span class="value">
{{ receipt.receipt_type === 'bon_fiscal' ? 'Bon Fiscal' : 'Chitanta' }}
</span>
</div>
<div class="detail-item">
<span class="label">Directie</span>
<span class="value">
{{ receipt.direction === 'cheltuiala' ? 'Cheltuiala' : 'Incasare' }}
</span>
</div>
<div class="detail-item">
<span class="label">Data</span>
<span class="value">{{ formatDate(receipt.receipt_date) }}</span>
</div>
<div class="detail-item">
<span class="label">Suma</span>
<span class="value amount">{{ formatAmount(receipt.amount) }}</span>
</div>
<div class="detail-item">
<span class="label">Furnizor</span>
<span class="value">{{ receipt.partner_name || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">Tip Cheltuiala</span>
<span class="value">{{ getExpenseTypeName(receipt.expense_type_code) }}</span>
</div>
<div class="detail-item">
<span class="label">Casa/Banca</span>
<span class="value">{{ receipt.cash_register_name || '-' }}</span>
</div>
<div class="detail-item" v-if="receipt.receipt_number">
<span class="label">Numar Bon</span>
<span class="value">{{ receipt.receipt_number }}</span>
</div>
<div class="detail-item" v-if="receipt.description">
<span class="label">Descriere</span>
<span class="value">{{ receipt.description }}</span>
</div>
</div>
<Divider />
<div class="detail-list">
<div class="detail-item">
<span class="label">Creat de</span>
<span class="value">{{ receipt.created_by }}</span>
</div>
<div class="detail-item">
<span class="label">Creat la</span>
<span class="value">{{ formatDateTime(receipt.created_at) }}</span>
</div>
<div class="detail-item" v-if="receipt.submitted_at">
<span class="label">Trimis la</span>
<span class="value">{{ formatDateTime(receipt.submitted_at) }}</span>
</div>
<div class="detail-item" v-if="receipt.reviewed_by">
<span class="label">Revizuit de</span>
<span class="value">{{ receipt.reviewed_by }}</span>
</div>
<div class="detail-item" v-if="receipt.reviewed_at">
<span class="label">Revizuit la</span>
<span class="value">{{ formatDateTime(receipt.reviewed_at) }}</span>
</div>
</div>
</div>
<!-- Attachments -->
<div class="roa-card">
<h3>
<i class="pi pi-images"></i>
Atasamente ({{ receipt.attachments?.length || 0 }})
</h3>
<div v-if="receipt.attachments?.length" class="attachments-grid">
<div
v-for="att in receipt.attachments"
:key="att.id"
class="attachment-item"
>
<template v-if="att.mime_type?.startsWith('image/')">
<Image
:src="store.getAttachmentUrl(att.id)"
:alt="att.filename"
preview
class="attachment-image"
/>
</template>
<template v-else>
<a
:href="store.getAttachmentUrl(att.id)"
target="_blank"
class="pdf-link"
>
<i class="pi pi-file-pdf"></i>
{{ att.filename }}
</a>
</template>
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-image"></i>
<p>Niciun atasament</p>
</div>
</div>
</div>
<!-- Accounting Entries -->
<div class="roa-card">
<h3>
<i class="pi pi-book"></i>
Note Contabile
</h3>
<div v-if="receipt.entries?.length" class="entries-table-container">
<table class="entries-table">
<thead>
<tr>
<th>Tip</th>
<th>Cont</th>
<th>Denumire Cont</th>
<th style="text-align: right;">Suma</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in receipt.entries" :key="entry.id">
<td>
<Tag
:value="entry.entry_type === 'debit' ? 'D' : 'C'"
:severity="entry.entry_type === 'debit' ? 'danger' : 'success'"
/>
</td>
<td>{{ entry.account_code }}</td>
<td>{{ entry.account_name || '-' }}</td>
<td :class="entry.entry_type" style="text-align: right;">
{{ formatAmount(entry.amount) }}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="3" style="text-align: right;"><strong>Total Debit:</strong></td>
<td class="debit" style="text-align: right;">
<strong>{{ formatAmount(totalDebit) }}</strong>
</td>
</tr>
<tr>
<td colspan="3" style="text-align: right;"><strong>Total Credit:</strong></td>
<td class="credit" style="text-align: right;">
<strong>{{ formatAmount(totalCredit) }}</strong>
</td>
</tr>
</tfoot>
</table>
<div v-if="!isBalanced" class="balance-warning">
<i class="pi pi-exclamation-triangle"></i>
Atentie: Notele contabile nu sunt echilibrate!
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-book"></i>
<p>Notele contabile vor fi generate la trimiterea spre aprobare</p>
</div>
</div>
</template>
<!-- Not Found -->
<div v-else class="empty-state">
<i class="pi pi-exclamation-circle"></i>
<h3>Bonul nu a fost gasit</h3>
<Button label="Inapoi la lista" @click="$router.push('/')" />
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
import { EXPENSE_TYPES } from '../../utils/constants'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useReceiptsStore()
const receipt = ref(null)
const loading = ref(true)
const submitting = ref(false)
const totalDebit = computed(() => {
if (!receipt.value?.entries) return 0
return receipt.value.entries
.filter(e => e.entry_type === 'debit')
.reduce((sum, e) => sum + parseFloat(e.amount), 0)
})
const totalCredit = computed(() => {
if (!receipt.value?.entries) return 0
return receipt.value.entries
.filter(e => e.entry_type === 'credit')
.reduce((sum, e) => sum + parseFloat(e.amount), 0)
})
const isBalanced = computed(() => {
return Math.abs(totalDebit.value - totalCredit.value) < 0.01
})
onMounted(async () => {
await loadReceipt()
})
const loadReceipt = async () => {
loading.value = true
try {
receipt.value = await store.fetchReceiptById(route.params.id)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut incarca bonul',
life: 5000,
})
} finally {
loading.value = false
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('ro-RO')
}
const formatDateTime = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ro-RO')
}
const formatAmount = (amount) => {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
}).format(amount)
}
const getStatusClass = (status) => {
const classes = {
draft: 'status-draft',
pending_review: 'status-pending',
approved: 'status-approved',
rejected: 'status-rejected',
synced: 'status-synced',
}
return classes[status] || ''
}
const getStatusLabel = (status) => {
const labels = {
draft: 'Ciorna',
pending_review: 'In asteptare',
approved: 'Aprobat',
rejected: 'Respins',
synced: 'Sincronizat',
}
return labels[status] || status
}
const getExpenseTypeName = (code) => {
return EXPENSE_TYPES[code] || code || '-'
}
const submitReceipt = async () => {
submitting.value = true
try {
const result = await store.submitReceipt(receipt.value.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis spre aprobare',
life: 3000,
})
await loadReceipt()
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut trimite bonul',
life: 5000,
})
} finally {
submitting.value = false
}
}
const resubmitReceipt = async () => {
submitting.value = true
try {
const result = await store.resubmitReceipt(receipt.value.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost re-trimis spre aprobare',
life: 3000,
})
await loadReceipt()
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut re-trimite bonul',
life: 5000,
})
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
.detail-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
}
.detail-item .label {
color: #666;
font-size: 0.9rem;
}
.detail-item .value {
font-weight: 500;
}
.detail-item .value.amount {
font-size: 1.1rem;
color: #333;
}
.rejection-alert {
display: flex;
gap: 1rem;
padding: 1rem;
background: #fff3e0;
border-radius: 8px;
margin-top: 1rem;
}
.rejection-alert i {
font-size: 1.5rem;
color: #f57c00;
}
.rejection-alert p {
margin: 0.5rem 0;
}
.rejection-alert small {
color: #666;
}
.attachments-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.attachment-item {
border-radius: 8px;
overflow: hidden;
}
.attachment-image {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.pdf-link {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
text-decoration: none;
color: #333;
}
.pdf-link i {
font-size: 3rem;
color: #d32f2f;
margin-bottom: 0.5rem;
}
.entries-table-container {
overflow-x: auto;
}
.balance-warning {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.75rem;
background: #fff3e0;
border-radius: 8px;
color: #f57c00;
}
</style>

View File

@@ -0,0 +1,339 @@
<template>
<div class="receipts-list-view">
<!-- Stats Cards -->
<div class="stats-grid" v-if="stats">
<div class="stat-card">
<div class="stat-value">{{ stats.draft?.count || 0 }}</div>
<div class="stat-label">Ciorne</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.pending_review?.count || 0 }}</div>
<div class="stat-label">In asteptare</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.approved?.count || 0 }}</div>
<div class="stat-label">Aprobate</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.rejected?.count || 0 }}</div>
<div class="stat-label">Respinse</div>
</div>
</div>
<!-- Main Card -->
<div class="roa-card">
<div class="roa-card-header">
<h2 class="roa-card-title">
<i class="pi pi-list"></i>
Lista Bonuri Fiscale
</h2>
<Button
label="Bon Nou"
icon="pi pi-plus"
@click="$router.push('/create')"
/>
</div>
<!-- Filters -->
<div class="filters-section">
<div class="form-grid">
<div class="form-field">
<label>Status</label>
<Dropdown
v-model="filters.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="Toate"
showClear
@change="onFilterChange"
/>
</div>
<div class="form-field">
<label>Cautare</label>
<InputText
v-model="filters.search"
placeholder="Furnizor, descriere..."
@keyup.enter="onFilterChange"
/>
</div>
<div class="form-field">
<label>De la data</label>
<Calendar
v-model="filters.dateFrom"
dateFormat="dd.mm.yy"
showIcon
@date-select="onFilterChange"
/>
</div>
<div class="form-field">
<label>Pana la data</label>
<Calendar
v-model="filters.dateTo"
dateFormat="dd.mm.yy"
showIcon
@date-select="onFilterChange"
/>
</div>
</div>
<div class="button-group" style="margin-top: 1rem;">
<Button
label="Filtreaza"
icon="pi pi-search"
@click="onFilterChange"
/>
<Button
label="Reseteaza"
icon="pi pi-times"
severity="secondary"
@click="clearFilters"
/>
</div>
</div>
<Divider />
<!-- Loading -->
<div v-if="loading" class="loading-container">
<ProgressSpinner />
</div>
<!-- Empty State -->
<div v-else-if="!receipts.length" class="empty-state">
<i class="pi pi-inbox"></i>
<h3>Niciun bon gasit</h3>
<p>Creaza primul bon fiscal folosind butonul "Bon Nou"</p>
</div>
<!-- Data Table -->
<div v-else class="data-table-container">
<DataTable
:value="receipts"
:paginator="true"
:rows="pagination.pageSize"
:totalRecords="pagination.total"
:lazy="true"
@page="onPageChange"
responsiveLayout="scroll"
stripedRows
>
<Column field="receipt_date" header="Data" style="width: 100px">
<template #body="{ data }">
{{ formatDate(data.receipt_date) }}
</template>
</Column>
<Column field="receipt_type" header="Tip" style="width: 100px">
<template #body="{ data }">
<Tag :value="data.receipt_type === 'bon_fiscal' ? 'Bon' : 'Chitanta'" />
</template>
</Column>
<Column field="partner_name" header="Furnizor" style="min-width: 150px">
<template #body="{ data }">
{{ data.partner_name || '-' }}
</template>
</Column>
<Column field="amount" header="Suma" style="width: 120px">
<template #body="{ data }">
<strong>{{ formatAmount(data.amount) }}</strong>
</template>
</Column>
<Column field="status" header="Status" style="width: 130px">
<template #body="{ data }">
<span :class="['status-badge', getStatusClass(data.status)]">
{{ getStatusLabel(data.status) }}
</span>
</template>
</Column>
<Column field="attachments" header="Atasamente" style="width: 100px">
<template #body="{ data }">
<Badge :value="data.attachments?.length || 0" />
</template>
</Column>
<Column header="Actiuni" style="width: 150px">
<template #body="{ data }">
<div class="button-group">
<Button
icon="pi pi-eye"
severity="info"
text
rounded
@click="viewReceipt(data.id)"
v-tooltip="'Vizualizeaza'"
/>
<Button
v-if="data.status === 'draft'"
icon="pi pi-pencil"
severity="warning"
text
rounded
@click="editReceipt(data.id)"
v-tooltip="'Editeaza'"
/>
<Button
v-if="data.status === 'draft'"
icon="pi pi-trash"
severity="danger"
text
rounded
@click="confirmDelete(data)"
v-tooltip="'Sterge'"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { useReceiptsStore } from '../../stores/receiptsStore'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const store = useReceiptsStore()
const filters = ref({
status: null,
search: '',
dateFrom: null,
dateTo: null,
})
const statusOptions = [
{ label: 'Ciorna', value: 'draft' },
{ label: 'In asteptare', value: 'pending_review' },
{ label: 'Aprobat', value: 'approved' },
{ label: 'Respins', value: 'rejected' },
]
const receipts = computed(() => store.receipts)
const loading = computed(() => store.loading)
const pagination = computed(() => store.pagination)
const stats = computed(() => store.stats)
onMounted(async () => {
await store.fetchStats()
await store.fetchReceipts()
})
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('ro-RO')
}
const formatAmount = (amount) => {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
}).format(amount)
}
const getStatusClass = (status) => {
const classes = {
draft: 'status-draft',
pending_review: 'status-pending',
approved: 'status-approved',
rejected: 'status-rejected',
synced: 'status-synced',
}
return classes[status] || ''
}
const getStatusLabel = (status) => {
const labels = {
draft: 'Ciorna',
pending_review: 'In asteptare',
approved: 'Aprobat',
rejected: 'Respins',
synced: 'Sincronizat',
}
return labels[status] || status
}
const onFilterChange = async () => {
store.setFilters({
status: filters.value.status,
search: filters.value.search,
dateFrom: filters.value.dateFrom
? filters.value.dateFrom.toISOString().split('T')[0]
: null,
dateTo: filters.value.dateTo
? filters.value.dateTo.toISOString().split('T')[0]
: null,
})
await store.fetchReceipts()
}
const clearFilters = async () => {
filters.value = {
status: null,
search: '',
dateFrom: null,
dateTo: null,
}
store.clearFilters()
await store.fetchReceipts()
}
const onPageChange = async (event) => {
store.setPage(event.page + 1)
await store.fetchReceipts()
}
const viewReceipt = (id) => {
router.push(`/receipt/${id}`)
}
const editReceipt = (id) => {
router.push(`/receipt/${id}/edit`)
}
const confirmDelete = (receipt) => {
confirm.require({
message: `Sigur doriti sa stergeti acest bon?`,
header: 'Confirmare stergere',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await store.deleteReceipt(receipt.id)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost sters',
life: 3000,
})
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut sterge bonul',
life: 5000,
})
}
},
})
}
</script>
<style scoped>
.filters-section {
margin-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 3010,
proxy: {
'/api': {
target: 'http://localhost:8003',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:8003',
changeOrigin: true,
}
}
},
build: {
outDir: 'dist',
sourcemap: true,
}
})