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

667
PLAN_DATA_ENTRY_RECEIPTS.md Normal file
View File

@@ -0,0 +1,667 @@
# Plan: Introducere Bonuri Fiscale - Faza 1 (MVP SQLite)
> **Plan Handover** - Acest document conține planul complet pentru implementare.
> Creat: 2025-12-11 | Status: Ready for implementation
## Obiectiv
Sistem de introducere bonuri fiscale cu:
- **Upload poze** bonuri de la utilizatori
- **Generare automată** note contabile (staging area)
- **Aprobare de contabil** înainte de finalizare
- SQLite + ORM (SQLModel) + Migrări (Alembic)
- Pregătit pentru integrare Oracle în Faza 2
---
## Setup Proiect
### Branch de dezvoltare
```bash
git checkout -b feature/data-entry-receipts
```
### Structură Directoare (SEPARAT de reports-app)
```
.
├── reports-app/ # EXISTENT - Raportări (read-only din Oracle)
│ ├── backend/
│ ├── frontend/
│ └── telegram-bot/
├── data-entry-app/ # NOU - Introduceri date (write în SQLite → Oracle)
│ ├── backend/ # FastAPI pentru introduceri
│ ├── frontend/ # Vue.js pentru introduceri
│ └── docs/ # Documentație și cerințe
├── shared/ # EXISTENT - Componente partajate
│ ├── database/
│ └── auth/
└── docs/ # Documentație generală proiect
└── data-entry/ # Documentație specifică data-entry
├── REQUIREMENTS.md # Cerințe inițiale (acest plan)
└── ARCHITECTURE.md # Decizii arhitecturale
```
### Documentație Salvată
La finalizarea planului, se vor crea:
1. `docs/data-entry/REQUIREMENTS.md` - Cerințe funcționale și tehnice
2. `docs/data-entry/ARCHITECTURE.md` - Decizii arhitecturale (ORM, workflow)
3. `data-entry-app/README.md` - Quick start pentru dezvoltare
---
## Workflow Principal
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. UTILIZATOR uploadează bon │
│ ├─ Poză bon fiscal / chitanță │
│ ├─ Date de bază: sumă, dată, furnizor │
│ └─ Status: DRAFT │
└──────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. SISTEM generează propunere note contabile │
│ ├─ Debit: Cont cheltuială (6022, 6024, etc.) │
│ ├─ Credit: Casă (5311) sau Bancă (5121) │
│ └─ Status: PENDING_REVIEW │
└──────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. CONTABIL revizuiește │
│ ├─ Verifică poza + datele │
│ ├─ Ajustează conturi dacă e nevoie │
│ ├─ APROBĂ → Status: APPROVED │
│ └─ RESPINGE → Status: REJECTED (cu motiv) │
└──────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. FAZA 2: Sync în Oracle │
│ ├─ INSERT ACT_TEMP │
│ ├─ pack_contafin.finalizeaza_scriere_act_rul() │
│ └─ Status: SYNCED │
└─────────────────────────────────────────────────────────────────┘
```
---
## Decizie Tehnică: ORM + Migrări
### Recomandare: **SQLModel + Alembic**
**Motivație:**
1. **Creat de autorul FastAPI** (Sebastian Ramirez) - integrare perfectă
2. **Un model = Pydantic + SQLAlchemy** - nu duplici definiții
3. **Async support** nativ
4. **Alembic** - standard industrial pentru migrări
5. **Validare automată** - Pydantic validează input, SQLAlchemy gestionează DB
---
## Arhitectură Propusă
### Backend Structure (`data-entry-app/backend/`)
```
data-entry-app/backend/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app entry point
│ ├── config.py # Settings & env vars
│ │
│ ├── db/ # Database layer (SQLModel)
│ │ ├── __init__.py
│ │ ├── database.py # Engine, SessionLocal, init
│ │ ├── models/
│ │ │ ├── __init__.py
│ │ │ ├── receipt.py # Receipt, ReceiptAttachment
│ │ │ └── accounting_entry.py # AccountingEntry
│ │ └── crud/
│ │ ├── __init__.py
│ │ ├── receipt.py
│ │ ├── attachment.py
│ │ └── accounting_entry.py
│ │
│ ├── schemas/
│ │ └── receipt.py # Request/Response Pydantic schemas
│ │
│ ├── services/
│ │ ├── receipt_service.py # Business logic + workflow
│ │ └── nomenclature_service.py # Nomenclatoare din Oracle
│ │
│ └── routers/
│ └── receipts.py # API endpoints
├── migrations/ # Alembic migrations
│ ├── env.py
│ ├── alembic.ini
│ └── versions/
│ └── 001_initial_receipts.py
├── data/
│ ├── receipts.db # SQLite database
│ └── uploads/ # Poze bonuri
├── requirements.txt
└── README.md
```
### Frontend Structure (`data-entry-app/frontend/`)
```
data-entry-app/frontend/
├── src/
│ ├── views/receipts/
│ │ ├── ReceiptsListView.vue
│ │ ├── ReceiptCreateView.vue
│ │ ├── ReceiptDetailView.vue
│ │ └── ReceiptApprovalView.vue
│ │
│ ├── components/receipts/
│ │ ├── ReceiptForm.vue
│ │ ├── ReceiptImageUpload.vue
│ │ └── AccountingEntriesTable.vue
│ │
│ ├── stores/
│ │ └── receiptsStore.js
│ │
│ └── router/
│ └── index.js
├── package.json
└── vite.config.js
```
---
## Modele de Date
### 1. Receipt (Bon Fiscal / Chitanță)
```python
# app/db/models/receipt.py
from sqlmodel import SQLModel, Field, Relationship
from datetime import datetime, date
from decimal import Decimal
from enum import Enum
from typing import Optional, List
class ReceiptType(str, Enum):
BON_FISCAL = "bon_fiscal"
CHITANTA = "chitanta"
class ReceiptDirection(str, Enum):
CHELTUIALA = "cheltuiala" # Plată (bon primit de la furnizor)
INCASARE = "incasare" # Încasare (bon emis către client)
class ReceiptStatus(str, Enum):
DRAFT = "draft" # Utilizator completează
PENDING_REVIEW = "pending_review" # Așteaptă aprobare contabil
APPROVED = "approved" # Aprobat de contabil
REJECTED = "rejected" # Respins de contabil
SYNCED = "synced" # Sincronizat în Oracle (Faza 2)
class Receipt(SQLModel, table=True):
"""Bon fiscal sau chitanță cu workflow aprobare"""
__tablename__ = "receipts"
id: Optional[int] = Field(default=None, primary_key=True)
# Identificare document
receipt_type: ReceiptType
direction: ReceiptDirection
receipt_number: Optional[str] = None
receipt_series: Optional[str] = None
# Date principale
receipt_date: date
amount: Decimal
description: Optional[str] = None
# Referințe Oracle (nomenclatoare)
company_id: int
partner_id: Optional[int] = None
partner_name: Optional[str] = None # Cache pentru display
cash_register_id: Optional[int] = None # ID casă/bancă Oracle
cash_register_name: Optional[str] = None # Cache pentru display
# Workflow
status: ReceiptStatus = Field(default=ReceiptStatus.DRAFT)
created_by: str # Username creator
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
submitted_at: Optional[datetime] = None # Când a fost trimis spre aprobare
# Aprobare
reviewed_by: Optional[str] = None # Username contabil
reviewed_at: Optional[datetime] = None
rejection_reason: Optional[str] = None # Motiv respingere
# Faza 2 - Oracle sync
oracle_synced_at: Optional[datetime] = None
oracle_act_id: Optional[int] = None
oracle_error: Optional[str] = None
# Relații
attachments: List["ReceiptAttachment"] = Relationship(back_populates="receipt")
entries: List["AccountingEntry"] = Relationship(back_populates="receipt")
```
### 2. ReceiptAttachment (Poze bonuri - OBLIGATORIU)
```python
class ReceiptAttachment(SQLModel, table=True):
"""Poză sau PDF bon fiscal"""
__tablename__ = "receipt_attachments"
id: Optional[int] = Field(default=None, primary_key=True)
receipt_id: int = Field(foreign_key="receipts.id")
filename: str # Nume original fișier
stored_filename: str # Nume pe disk (UUID)
file_path: str # Cale relativă
file_size: int # Bytes
mime_type: str # image/jpeg, application/pdf
uploaded_at: datetime = Field(default_factory=datetime.utcnow)
receipt: Optional["Receipt"] = Relationship(back_populates="attachments")
```
### 3. AccountingEntry (Note Contabile - Staging)
```python
class EntryType(str, Enum):
DEBIT = "debit"
CREDIT = "credit"
class AccountingEntry(SQLModel, table=True):
"""Notă contabilă propusă pentru bon"""
__tablename__ = "accounting_entries"
id: Optional[int] = Field(default=None, primary_key=True)
receipt_id: int = Field(foreign_key="receipts.id")
# Cont
entry_type: EntryType # Debit sau Credit
account_code: str # Ex: 6022, 5311, 4426
account_name: Optional[str] # Cache: "Cheltuieli combustibil"
# Valori
amount: Decimal
# Analitice (opțional)
partner_id: Optional[int] = None
cost_center_id: Optional[int] = None
# Auto-generat sau modificat manual
is_auto_generated: bool = True
modified_by: Optional[str] = None
modified_at: Optional[datetime] = None
receipt: Optional["Receipt"] = Relationship(back_populates="entries")
```
### Exemplu Note Contabile Generate
```
BON FISCAL BENZINĂ - 200 RON:
┌──────────┬────────┬──────────────────────────┬─────────┐
│ Tip │ Cont │ Denumire │ Sumă │
├──────────┼────────┼──────────────────────────┼─────────┤
│ DEBIT │ 6022 │ Cheltuieli combustibil │ 168.07 │
│ DEBIT │ 4426 │ TVA deductibilă │ 31.93 │
│ CREDIT │ 5311 │ Casă în lei │ 200.00 │
└──────────┴────────┴──────────────────────────┴─────────┘
```
---
## API Endpoints
### Bonuri (CRUD + Workflow)
```
POST /api/receipts/ # Creare bon nou (cu upload poză)
GET /api/receipts/ # Listă bonuri (filtrare, paginare)
GET /api/receipts/{id} # Detalii bon + note contabile
PUT /api/receipts/{id} # Modificare bon (doar DRAFT)
DELETE /api/receipts/{id} # Ștergere bon (doar DRAFT)
# Workflow
POST /api/receipts/{id}/submit # Trimite spre aprobare (DRAFT → PENDING)
POST /api/receipts/{id}/approve # Aprobă (PENDING → APPROVED) [Contabil]
POST /api/receipts/{id}/reject # Respinge (PENDING → REJECTED) [Contabil]
POST /api/receipts/{id}/resubmit # Re-trimite după corecții (REJECTED → PENDING)
```
### Note Contabile
```
GET /api/receipts/{id}/entries # Liste note contabile propuse
PUT /api/receipts/{id}/entries # Modificare note (contabil ajustează conturi)
POST /api/receipts/{id}/entries/regenerate # Re-generare automată
```
### Atașamente
```
POST /api/receipts/{id}/attachments # Upload poză/PDF
GET /api/receipts/{id}/attachments # Listă atașamente
GET /api/attachments/{id}/download # Download fișier
DELETE /api/attachments/{id} # Ștergere atașament
```
### Nomenclatoare (din Oracle - read only)
```
GET /api/receipts/partners # Furnizori/Clienți pentru dropdown
GET /api/receipts/accounts # Conturi sintetice (6xxx, 7xxx, etc.)
GET /api/receipts/cash-registers # Case și bănci
GET /api/receipts/expense-types # Tipuri cheltuieli predefinite (cu cont asociat)
```
---
## Frontend Views
```
frontend/src/views/receipts/
├── ReceiptsListView.vue # Listă bonuri cu filtrare pe status
├── ReceiptCreateView.vue # Form creare + upload poză
├── ReceiptDetailView.vue # Vizualizare + editare note contabile
└── ReceiptApprovalView.vue # View pentru contabil (aprobare în masă)
```
### ReceiptCreateView - Form utilizator
```
┌──────────────────────────────────────────────────────┐
│ UPLOAD POZĂ BON [Drag & Drop sau Click] │
│ ┌────────────────────────────────────────────┐ │
│ │ │ │
│ │ [Previzualizare imagine] │ │
│ │ │ │
│ └────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────┤
│ Tip document: ○ Bon fiscal ○ Chitanță │
│ Direcție: ○ Cheltuială ○ Încasare │
├──────────────────────────────────────────────────────┤
│ Data: [DatePicker] │
│ Suma: [InputNumber] RON │
│ Furnizor: [Dropdown - din Oracle] │
│ Tip cheltuială:[Dropdown - Benzină, Materiale...] │
│ Casă/Bancă: [Dropdown - din Oracle] │
│ Descriere: [Textarea] │
├──────────────────────────────────────────────────────┤
│ [Salvează Draft] [Trimite spre aprobare]│
└──────────────────────────────────────────────────────┘
```
### ReceiptApprovalView - View Contabil
```
┌────────────────────────────────────────────────────────────────┐
│ BONURI DE APROBAT (3) [Aprobă selectate] │
├────────────────────────────────────────────────────────────────┤
│ ☑ │ Data │ Furnizor │ Sumă │ Status │ Acțiuni │
├───┼──────────┼───────────────┼─────────┼─────────┼───────────┤
│ ☑ │ 10.12.24 │ OMV Petrom │ 200 RON │ PENDING │ [👁️][✓][✗]│
│ ☑ │ 09.12.24 │ Dedeman │ 450 RON │ PENDING │ [👁️][✓][✗]│
│ ☐ │ 08.12.24 │ Kaufland │ 85 RON │ PENDING │ [👁️][✓][✗]│
└────────────────────────────────────────────────────────────────┘
[👁️] = Deschide detalii + poză + note contabile editabile
[✓] = Aprobă
[✗] = Respinge (cu motiv)
```
---
## Pași Implementare
### Etapa 0: Setup Proiect și Documentație
1. [ ] Creare branch: `git checkout -b feature/data-entry-receipts`
2. [ ] Creare structură directoare: `data-entry-app/{backend,frontend,docs}`
3. [ ] Creare documentație:
- `docs/data-entry/REQUIREMENTS.md` - Cerințe funcționale
- `docs/data-entry/ARCHITECTURE.md` - Decizii tehnice (ORM, workflow)
4. [ ] Creare `data-entry-app/README.md` - Quick start
5. [ ] Creare `data-entry-app/CLAUDE.md` - Instrucțiuni pentru Claude Code
### Etapa 1: Setup Backend (SQLModel + Alembic)
6. [ ] Creare `data-entry-app/backend/requirements.txt`:
- `fastapi`, `uvicorn`, `sqlmodel`, `alembic`, `python-multipart`
- `aiosqlite`, `pydantic`, `python-dotenv`
7. [ ] Creare `app/main.py` - FastAPI app cu CORS, lifespan
8. [ ] Creare `app/config.py` - Settings (DB path, upload path)
9. [ ] Creare `app/db/database.py` - engine async, session factory
10. [ ] Setup Alembic: `alembic init migrations`
### Etapa 2: Modele și Migrări
11. [ ] Creare `app/db/models/receipt.py` - Receipt, ReceiptAttachment
12. [ ] Creare `app/db/models/accounting_entry.py` - AccountingEntry
13. [ ] Prima migrare: `001_initial_receipts.py`
14. [ ] Creare folder `data/uploads/` pentru fișiere
### Etapa 3: Backend CRUD + Upload
15. [ ] Creare `app/db/crud/receipt.py` - operații CRUD
16. [ ] Creare `app/db/crud/attachment.py` - upload/download fișiere
17. [ ] Creare `app/db/crud/accounting_entry.py` - note contabile
18. [ ] Creare `app/schemas/receipt.py` - request/response Pydantic
### Etapa 4: Business Logic + Workflow
19. [ ] Creare `app/services/receipt_service.py`:
- `create_receipt()` - creare + upload poză
- `generate_accounting_entries()` - generare automată note
- `submit_for_review()` - DRAFT → PENDING
- `approve_receipt()` - PENDING → APPROVED
- `reject_receipt()` - PENDING → REJECTED
### Etapa 5: API Endpoints
20. [ ] Creare `app/routers/receipts.py` - toate endpoint-urile
21. [ ] Register router în `main.py`
22. [ ] Middleware pentru upload fișiere
### Etapa 6: Nomenclatoare Oracle
23. [ ] Creare `app/services/nomenclature_service.py`:
- `get_partners()` - furnizori/clienți din Oracle
- `get_expense_accounts()` - conturi 6xxx
- `get_cash_registers()` - case/bănci
- `get_expense_types()` - tipuri cheltuieli predefinite
### Etapa 7: Frontend Setup
24. [ ] `npm create vite@latest frontend -- --template vue`
25. [ ] Instalare dependențe: `pinia`, `vue-router`, `primevue`, `axios`
26. [ ] Copy configurație PrimeVue din reports-app
27. [ ] Copy CSS shared din reports-app (design tokens, patterns)
### Etapa 8: Frontend Views
28. [ ] Creare `views/receipts/ReceiptsListView.vue` - listă cu filtre
29. [ ] Creare `views/receipts/ReceiptCreateView.vue` - form + upload
30. [ ] Creare `views/receipts/ReceiptDetailView.vue` - detalii + note
31. [ ] Creare `views/receipts/ReceiptApprovalView.vue` - view contabil
32. [ ] Creare `stores/receiptsStore.js` - Pinia store
33. [ ] Configurare router și layout
### Etapa 9: Testing & Finalizare
34. [ ] Unit tests pentru CRUD
35. [ ] Integration tests pentru API
36. [ ] Manual testing checklist
37. [ ] Actualizare documentație
38. [ ] Commit și push pe branch
39. [ ] Creare PR către main
---
## Fișiere de Creat
### Documentație (Etapa 0):
```
docs/data-entry/
├── REQUIREMENTS.md # Cerințe funcționale (din acest plan)
└── ARCHITECTURE.md # Decizii tehnice (ORM, workflow)
data-entry-app/
├── CLAUDE.md # Instrucțiuni pentru Claude Code
└── README.md # Quick start pentru dezvoltare
```
### Conținut `data-entry-app/CLAUDE.md`:
```markdown
# CLAUDE.md - Data Entry App
## Scop
Aplicație pentru introducere date în ERP (bonuri fiscale, chitanțe) cu workflow de aprobare.
## Documentație de Referință
- **Cerințe**: `docs/data-entry/REQUIREMENTS.md`
- **Arhitectură**: `docs/data-entry/ARCHITECTURE.md`
- **Quick Start**: `README.md`
## Decizii Tehnice
- **ORM**: SQLModel (Pydantic + SQLAlchemy)
- **Migrări**: Alembic
- **Database**: SQLite (Faza 1) → Oracle (Faza 2)
- **Frontend**: Vue.js 3 + PrimeVue (consistent cu reports-app)
## Workflow Bonuri
1. DRAFT → utilizator completează + upload poză
2. PENDING_REVIEW → sistem generează note contabile
3. APPROVED/REJECTED → contabil aprobă sau respinge
4. SYNCED → (Faza 2) date în Oracle
## Structură Directoare
- `backend/` - FastAPI API (port 8003)
- `frontend/` - Vue.js UI (port 3010)
- `docs/` - Documentație specifică
## Componente Partajate
- `shared/database/oracle_pool.py` - Conexiune Oracle
- `shared/auth/` - JWT authentication
## Comenzi Dezvoltare
```bash
# Backend
cd backend && pip install -r requirements.txt
uvicorn app.main:app --reload --port 8003
# Frontend
cd frontend && npm install && npm run dev
# Migrări
cd backend && alembic upgrade head
```
## 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()`
```
---
## Tipuri Cheltuieli Predefinite
Pentru dropdown "Tip cheltuială" - mapare automată la conturi:
| Tip Cheltuială | Cont Debit | TVA | Descriere |
|----------------|------------|-----|-----------|
| Combustibil | 6022 | 4426 (19%) | Benzină, motorină |
| Materiale consumabile | 6028 | 4426 (19%) | Diverse materiale |
| Rechizite birou | 6024 | 4426 (19%) | Papetărie, toner |
| Telefonie | 626 | 4426 (19%) | Telefon, internet |
| Parcare | 6022 | 4426 (19%) | Taxe parcare |
| Alimentație | 6028 | - | Fără TVA deductibilă |
| Transport | 624 | 4426 (19%) | Taxi, transport |
| Altele | 628 | 4426 (19%) | Alte cheltuieli |
**Logica generare note:**
```python
def generate_entries(receipt):
expense_type = EXPENSE_TYPES[receipt.expense_type_code]
entries = []
if expense_type.has_vat:
net_amount = receipt.amount / 1.19
vat_amount = receipt.amount - net_amount
entries.append(AccountingEntry(
entry_type=EntryType.DEBIT,
account_code=expense_type.account_code, # ex: 6022
amount=net_amount
))
entries.append(AccountingEntry(
entry_type=EntryType.DEBIT,
account_code="4426", # TVA deductibilă
amount=vat_amount
))
else:
entries.append(AccountingEntry(
entry_type=EntryType.DEBIT,
account_code=expense_type.account_code,
amount=receipt.amount
))
# Credit - casă sau bancă
entries.append(AccountingEntry(
entry_type=EntryType.CREDIT,
account_code=receipt.cash_register_account, # 5311 sau 5121
amount=receipt.amount
))
return entries
```
---
## Faza 2 Preview (Oracle Integration)
După ce Faza 1 funcționează, Faza 2 va adăuga:
```python
# receipt_service.py - metodă nouă
async def sync_to_oracle(receipt_id: int):
"""
Sincronizează bon APPROVED în Oracle:
1. pack_contafin.init_scriere_act_rul_local()
2. Pentru fiecare AccountingEntry:
INSERT INTO ACT_TEMP (
ID_ACT, DATAIREG, DATAACT, SCD, ASCD, SCC, ASCC,
SUMA, ID_CTR, ID_PARTD, EXPLICATIA, ...
)
3. pack_contafin.finalizeaza_scriere_act_rul()
→ SCRIE_IN_ACT()
→ SCRIE_IN_RUL()
→ Actualizare situații (BV, BP, TVA, etc.)
4. Update receipt.status = SYNCED, oracle_act_id = ...
"""
pass
```
---
## Riscuri și Mitigări
| Risc | Impact | Mitigare |
|------|--------|----------|
| SQLModel e relativ nou | Mediu | Fallback la SQLAlchemy pur dacă e nevoie |
| Upload fișiere mari | Mic | Limit 10MB, compresie imagini |
| Workflow complex | Mediu | Începem cu workflow simplu, adăugăm features gradual |
| Generare note greșite | Mare | Contabilul poate edita înainte de aprobare |
---
## Success Criteria (Faza 1)
- [ ] Utilizator poate uploada poză bon + date de bază
- [ ] Sistem generează automat note contabile
- [ ] Contabil poate vedea, edita și aproba note
- [ ] Bonurile aprobate sunt vizibile în listă
- [ ] Migrările Alembic funcționează corect
- [ ] Poze bonuri se salvează și se afișează corect
---
## Context Handover
**Pentru sesiunea următoare:**
1. Citește acest fișier `PLAN_DATA_ENTRY_RECEIPTS.md`
2. Începe cu Etapa 0 - creare branch și structură directoare
3. Referință pentru proceduri Oracle: `docs/PACK_CONTAFIN.pck`, `docs/PACK_FACTURARE.pck`
4. Pattern-uri existente pentru SQLite: `reports-app/telegram-bot/app/db/`

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,
}
})

View File

@@ -0,0 +1,275 @@
# Architecture: Data Entry App
## Overview
Aplicatie separata pentru introducere date in ERP, cu workflow de aprobare si staging area inainte de sincronizare in Oracle.
## Decizii Arhitecturale
### 1. SQLModel + Alembic
**Alegere**: SQLModel (Pydantic + SQLAlchemy) cu Alembic pentru migrari
**Motivatie**:
- Creat de autorul FastAPI - integrare perfecta
- Un model = Pydantic + SQLAlchemy - nu duplici definitii
- Async support nativ
- Alembic - standard industrial pentru migrari
- Validare automata - Pydantic valideaza input, SQLAlchemy gestioneaza DB
**Alternative considerate**:
- SQLAlchemy pur: Mai verbose, necesita scheme Pydantic separate
- Tortoise ORM: Async nativ dar comunitate mai mica
- Peewee: Simplu dar fara async
### 2. Separare de Reports-App
**Alegere**: Aplicatie separata in `data-entry-app/`
**Motivatie**:
- Responsabilitati diferite: reports = read-only, data-entry = write
- Lifecycle diferit: data-entry poate avea releases mai frecvente
- Risc izolat: bug in data-entry nu afecteaza raportarile
- Scalare independenta
**Shared Components**:
- `shared/database/oracle_pool.py` - conexiune Oracle pentru nomenclatoare
- `shared/auth/` - autentificare JWT comuna
### 3. Workflow cu Staging Area
**Alegere**: SQLite local ca staging, apoi sync in Oracle
**Motivatie**:
- Permite lucru offline (utilizator poate completa bonuri)
- Review de contabil inainte de date in Oracle
- Rollback simplu (stergem din SQLite)
- Audit trail complet
**Flow**:
```
User Input → SQLite (staging) → Contabil Review → Oracle (final)
```
### 4. Storage Fisiere
**Alegere**: Filesystem local cu referinte in DB
**Motivatie**:
- Simplu de implementat si backup
- Performanta buna pentru imagini
- Poate migra la S3/Azure Blob daca e nevoie
**Structura**:
```
data/uploads/
{year}/
{month}/
{uuid}.{ext}
```
## Diagrama Componente
```
┌─────────────────────────────────────────────────────────────────┐
│ data-entry-app │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Frontend │────▶│ Backend │────▶│ SQLite │ │
│ │ Vue.js │ │ FastAPI │ │ (staging) │ │
│ │ :3010 │ │ :8003 │ │ │ │
│ └──────────────┘ └──────┬───────┘ └──────────────┘ │
│ │ │
│ │ Nomenclatoare │
│ ▼ │
│ ┌──────────────┐ │
│ │ Oracle │ │
│ │ (read-only) │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Model de Date
### Receipt (Bon Fiscal)
```
receipts
├── id (PK)
├── receipt_type: enum(bon_fiscal, chitanta)
├── direction: enum(cheltuiala, incasare)
├── receipt_number, receipt_series
├── receipt_date, amount, description
├── company_id, partner_id, partner_name
├── cash_register_id, cash_register_name
├── expense_type_code
├── status: enum(draft, pending_review, approved, rejected, synced)
├── created_by, created_at, updated_at
├── submitted_at, reviewed_by, reviewed_at
├── rejection_reason
└── oracle_synced_at, oracle_act_id, oracle_error
```
### ReceiptAttachment (Atasamente)
```
receipt_attachments
├── id (PK)
├── receipt_id (FK)
├── filename, stored_filename
├── file_path, file_size, mime_type
└── uploaded_at
```
### AccountingEntry (Note Contabile)
```
accounting_entries
├── id (PK)
├── receipt_id (FK)
├── entry_type: enum(debit, credit)
├── account_code, account_name
├── amount
├── partner_id, cost_center_id
├── is_auto_generated
└── modified_by, modified_at
```
## Workflow States
```
┌─────────┐
│ DRAFT │◀────────────────────┐
└────┬────┘ │
│ submit() │ (edit after reject)
▼ │
┌──────────────┐ │
│PENDING_REVIEW│──────────────────┤
└──────┬───────┘ │
│ │
┌─────┴─────┐ │
▼ ▼ │
┌────────┐ ┌────────┐ │
│APPROVED│ │REJECTED│──────────────┘
└────┬───┘ └────────┘ resubmit()
│ (Faza 2)
┌──────┐
│SYNCED│
└──────┘
```
## Generare Note Contabile
### Algoritm
```python
def generate_entries(receipt):
expense_type = EXPENSE_TYPES[receipt.expense_type_code]
entries = []
if expense_type.has_vat:
net_amount = receipt.amount / Decimal('1.19')
vat_amount = receipt.amount - net_amount
# Cheltuiala (debit)
entries.append(Entry(DEBIT, expense_type.account, net_amount))
# TVA (debit)
entries.append(Entry(DEBIT, "4426", vat_amount))
else:
entries.append(Entry(DEBIT, expense_type.account, receipt.amount))
# Credit casa/banca
entries.append(Entry(CREDIT, receipt.cash_register_account, receipt.amount))
return entries
```
### Exemplu: Bon Benzina 200 RON
```
Debit 6022 Cheltuieli combustibil 168.07
Debit 4426 TVA deductibila 31.93
Credit 5311 Casa in lei 200.00
```
## Integrare Oracle (Faza 2)
### Proceduri Stocate
```sql
-- 1. Initializare
pack_contafin.init_scriere_act_rul_local()
-- 2. Insert linii
INSERT INTO ACT_TEMP (
ID_ACT, DATAIREG, DATAACT, SCD, ASCD, SCC, ASCC,
SUMA, ID_CTR, ID_PARTD, EXPLICATIA, ...
)
-- 3. Finalizare
pack_contafin.finalizeaza_scriere_act_rul()
SCRIE_IN_ACT()
SCRIE_IN_RUL()
Actualizare situatii (BV, BP, TVA)
```
## Securitate
### Autentificare
- JWT tokens din shared auth
- Middleware valideaza token si injecteaza user
### Autorizare
- Permisiuni verificate in services
- Utilizator poate edita doar bonurile proprii in DRAFT
- Doar contabil poate aproba/respinge
### Upload Fisiere
- Validare MIME type (whitelist)
- Sanitizare nume fisier
- Limita dimensiune (10MB)
- Stocare cu UUID (previne path traversal)
## Configuratie
### Environment Variables
```bash
# SQLite Database
SQLITE_DATABASE_PATH=data/receipts.db
# File Storage
UPLOAD_PATH=data/uploads
MAX_UPLOAD_SIZE=10485760 # 10MB
# Oracle (pentru nomenclatoare)
ORACLE_USER=CONTAFIN_ORACLE
ORACLE_PASSWORD=***
ORACLE_HOST=localhost
ORACLE_PORT=1526
ORACLE_SID=ROA
# JWT (shared)
JWT_SECRET_KEY=***
JWT_ALGORITHM=HS256
```
## Testing Strategy
### Unit Tests
- CRUD operations
- Workflow transitions
- Entry generation logic
### Integration Tests
- API endpoints
- File upload/download
- Oracle nomenclature fetch
### E2E Tests
- Complete workflow: create → submit → approve
- File upload cu preview

View File

@@ -0,0 +1,155 @@
# Requirements: Data Entry - Bonuri Fiscale (Faza 1)
## Obiectiv
Sistem de introducere bonuri fiscale cu:
- **Upload poze** bonuri de la utilizatori
- **Generare automata** note contabile (staging area)
- **Aprobare de contabil** inainte de finalizare
- SQLite + ORM (SQLModel) + Migrari (Alembic)
- Pregatit pentru integrare Oracle in Faza 2
## Cerinte Functionale
### 1. Gestiune Bonuri Fiscale
#### 1.1 Creare Bon
- Utilizatorul poate uploada o poza a bonului fiscal
- Campuri obligatorii: tip document, directie, data, suma, furnizor, casa/banca
- Campuri optionale: numar bon, serie, descriere
- Tipuri document: Bon Fiscal, Chitanta
- Directii: Cheltuiala (plata), Incasare
#### 1.2 Upload Atasamente
- Suport pentru imagini (JPEG, PNG) si PDF
- Limita dimensiune: 10MB per fisier
- Multiple atasamente per bon
- Previzualizare imagine in UI
#### 1.3 Workflow Aprobare
```
DRAFT → PENDING_REVIEW → APPROVED/REJECTED → (Faza 2: SYNCED)
```
- **DRAFT**: Utilizator completeaza datele
- **PENDING_REVIEW**: Trimis spre aprobare, sistem genereaza note contabile
- **APPROVED**: Contabil a aprobat
- **REJECTED**: Contabil a respins (cu motiv)
- **SYNCED**: (Faza 2) Sincronizat in Oracle
### 2. Note Contabile
#### 2.1 Generare Automata
- La trimiterea spre aprobare, sistemul genereaza propuneri note contabile
- Logica bazata pe tip cheltuiala selectat
- Include calcul TVA automat (19%)
#### 2.2 Editare de Contabil
- Contabilul poate modifica conturile propuse
- Poate adauga/sterge linii
- Modificarile sunt logate
#### 2.3 Tipuri Cheltuieli Predefinite
| Tip Cheltuiala | Cont Debit | TVA | Descriere |
|----------------|------------|-----|-----------|
| Combustibil | 6022 | 4426 (19%) | Benzina, motorina |
| Materiale consumabile | 6028 | 4426 (19%) | Diverse materiale |
| Rechizite birou | 6024 | 4426 (19%) | Papetarie, toner |
| Telefonie | 626 | 4426 (19%) | Telefon, internet |
| Parcare | 6022 | 4426 (19%) | Taxe parcare |
| Alimentatie | 6028 | - | Fara TVA deductibila |
| Transport | 624 | 4426 (19%) | Taxi, transport |
| Altele | 628 | 4426 (19%) | Alte cheltuieli |
### 3. Roluri si Permisiuni
#### 3.1 Utilizator Standard
- Creare bonuri noi (DRAFT)
- Editare bonuri proprii in DRAFT
- Trimitere spre aprobare
- Vizualizare status bonuri proprii
#### 3.2 Contabil (Rol Aprobare)
- Toate permisiunile utilizator
- Vizualizare toate bonurile in asteptare
- Aprobare/Respingere bonuri
- Editare note contabile propuse
- Aprobare in masa
### 4. Nomenclatoare
Preluate din Oracle (read-only):
- **Parteneri**: Furnizori si clienti
- **Conturi**: Plan conturi sintetice
- **Case/Banci**: Registre casa si conturi bancare
- **Firme**: Lista firme disponibile
## Cerinte Non-Functionale
### Performance
- Timp raspuns API < 200ms (fara upload)
- Upload fisiere < 5s pentru 10MB
- Paginare pentru liste > 50 elemente
### Securitate
- Autentificare JWT (shared cu reports-app)
- Validare MIME type pentru fisiere
- Sanitizare nume fisiere
- Acces bazat pe rol
### Scalabilitate
- Arhitectura pregatita pentru Oracle (Faza 2)
- Separare clara intre storage local si DB
- API design RESTful consistent
## API Endpoints
### Bonuri
```
POST /api/receipts/ # Creare bon
GET /api/receipts/ # Lista bonuri
GET /api/receipts/{id} # Detalii bon
PUT /api/receipts/{id} # Modificare bon (DRAFT)
DELETE /api/receipts/{id} # Stergere bon (DRAFT)
```
### Workflow
```
POST /api/receipts/{id}/submit # DRAFT → PENDING
POST /api/receipts/{id}/approve # PENDING → APPROVED
POST /api/receipts/{id}/reject # PENDING → REJECTED
POST /api/receipts/{id}/resubmit # REJECTED → PENDING
```
### Note Contabile
```
GET /api/receipts/{id}/entries # Lista note
PUT /api/receipts/{id}/entries # Modificare note
POST /api/receipts/{id}/entries/regenerate # Re-generare
```
### Atasamente
```
POST /api/receipts/{id}/attachments # Upload
GET /api/receipts/{id}/attachments # Lista
GET /api/attachments/{id}/download # Download
DELETE /api/attachments/{id} # Stergere
```
### Nomenclatoare
```
GET /api/receipts/partners # Furnizori/Clienti
GET /api/receipts/accounts # Conturi
GET /api/receipts/cash-registers # Case/Banci
GET /api/receipts/expense-types # Tipuri cheltuieli
```
## Criterii de Succes (Faza 1)
- [ ] Utilizator poate uploada poza bon + date de baza
- [ ] Sistem genereaza automat note contabile
- [ ] Contabil poate vedea, edita si aproba note
- [ ] Bonurile aprobate sunt vizibile in lista
- [ ] Migrarile Alembic functioneaza corect
- [ ] Poze bonuri se salveaza si se afiseaza corect

470
start-data-entry.sh Normal file
View File

@@ -0,0 +1,470 @@
#!/bin/bash
# Data Entry App Starter Script
# Starts backend and frontend services for the data entry application
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored messages
print_message() {
echo -e "${BLUE}[DATA-ENTRY]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to check if port is in use
check_port() {
local port=$1
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then
return 0
else
return 1
fi
}
# Function to cleanup processes on exit
cleanup() {
print_message "Stopping services..."
# Kill background processes
if [[ -n $BACKEND_PID ]]; then
kill $BACKEND_PID 2>/dev/null || true
fi
if [[ -n $FRONTEND_PID ]]; then
kill $FRONTEND_PID 2>/dev/null || true
fi
print_success "All services stopped."
exit 0
}
# Function to stop all services
stop_services() {
print_message "Stopping all Data Entry services..."
# Stop backend on port 8003
print_message "Checking for backend processes on port 8003..."
if check_port 8003; then
BACKEND_PIDS=$(lsof -ti:8003)
if [[ -n $BACKEND_PIDS ]]; then
echo $BACKEND_PIDS | xargs kill -TERM 2>/dev/null || true
sleep 2
echo $BACKEND_PIDS | xargs kill -KILL 2>/dev/null || true
print_success "Backend processes stopped"
fi
else
print_message "No backend processes found on port 8003"
fi
# Stop frontend on port 3010
print_message "Checking for frontend processes on port 3010..."
if check_port 3010; then
FRONTEND_PIDS=$(lsof -ti:3010)
if [[ -n $FRONTEND_PIDS ]]; then
echo $FRONTEND_PIDS | xargs kill -TERM 2>/dev/null || true
sleep 2
echo $FRONTEND_PIDS | xargs kill -KILL 2>/dev/null || true
print_success "Frontend processes stopped"
fi
else
print_message "No frontend processes found on port 3010"
fi
# Kill any remaining processes related to data-entry
pkill -f "uvicorn.*data-entry" 2>/dev/null || true
pkill -f "node.*data-entry" 2>/dev/null || true
pkill -f "vite.*3010" 2>/dev/null || true
print_success "✅ All Data Entry services have been stopped!"
exit 0
}
# Function to stop individual service
stop_service() {
local service=$1
case $service in
backend)
print_message "Stopping backend..."
if check_port 8003; then
BACKEND_PIDS=$(lsof -ti:8003)
if [[ -n $BACKEND_PIDS ]]; then
echo $BACKEND_PIDS | xargs kill -TERM 2>/dev/null || true
sleep 2
echo $BACKEND_PIDS | xargs kill -KILL 2>/dev/null || true
print_success "Backend stopped"
else
print_message "Backend not running"
fi
else
print_message "Backend not running on port 8003"
fi
;;
frontend)
print_message "Stopping frontend..."
if check_port 3010; then
FRONTEND_PIDS=$(lsof -ti:3010)
if [[ -n $FRONTEND_PIDS ]]; then
echo $FRONTEND_PIDS | xargs kill -TERM 2>/dev/null || true
sleep 2
echo $FRONTEND_PIDS | xargs kill -KILL 2>/dev/null || true
print_success "Frontend stopped"
fi
else
print_message "Frontend not running on port 3010"
fi
;;
*)
print_error "Unknown service: $service"
print_message "Valid services: backend, frontend"
exit 1
;;
esac
}
# Function to start individual service
start_service() {
local service=$1
case $service in
backend)
print_message "Starting backend..."
if check_port 8003; then
print_warning "Port 8003 already in use. Backend might be running."
return 1
fi
cd data-entry-app/backend/
# Check if virtual environment exists
if [ ! -d "venv" ]; then
print_message "Creating Python virtual environment..."
python3 -m venv venv
fi
# Activate virtual environment
source venv/bin/activate
# Check if dependencies are installed
if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then
print_message "Installing backend dependencies..."
pip install --upgrade pip > /dev/null 2>&1
pip install -r requirements.txt
fi
# Run Alembic migrations
print_message "Running database migrations..."
alembic upgrade head 2>/dev/null || print_warning "Migration may have already been applied"
print_message "Starting uvicorn server..."
nohup uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 &
sleep 2
for i in {1..10}; do
if check_port 8003; then
print_success "Backend started on http://localhost:8003"
cd - > /dev/null
return 0
fi
sleep 1
done
print_error "Backend failed to start"
cat /tmp/data_entry_backend.log
cd - > /dev/null
exit 1
;;
frontend)
print_message "Starting frontend..."
if check_port 3010; then
print_warning "Port 3010 already in use. Frontend might be running."
return 1
fi
cd data-entry-app/frontend/
# Check node_modules
if [ ! -d "node_modules" ] || [ ! -f "node_modules/.bin/vite" ]; then
print_message "Installing frontend dependencies..."
npm install
fi
print_message "Starting Vite development server..."
nohup npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 &
sleep 3
for i in {1..10}; do
if check_port 3010; then
print_success "Frontend started on http://localhost:3010"
cd - > /dev/null
return 0
fi
sleep 1
done
print_error "Frontend failed to start"
cat /tmp/data_entry_frontend.log
cd - > /dev/null
exit 1
;;
*)
print_error "Unknown service: $service"
print_message "Valid services: backend, frontend"
exit 1
;;
esac
}
# Function to restart individual service
restart_service() {
local service=$1
print_message "Restarting $service..."
stop_service $service
sleep 2
start_service $service
print_success "$service restarted successfully"
}
# Function to show service status
show_status() {
echo -e "${BLUE}Data Entry Services Status${NC}"
echo
# Check backend
if check_port 8003; then
echo -e " Backend: ${GREEN}✓ Running${NC} (http://localhost:8003)"
else
echo -e " Backend: ${RED}✗ Stopped${NC}"
fi
# Check frontend
if check_port 3010; then
echo -e " Frontend: ${GREEN}✓ Running${NC} (http://localhost:3010)"
else
echo -e " Frontend: ${RED}✗ Stopped${NC}"
fi
echo
}
# Function to show usage
show_usage() {
echo -e "${BLUE}Data Entry App Starter Script${NC}"
echo
echo "Usage:"
echo " ./start-data-entry.sh Start all services"
echo " ./start-data-entry.sh start Start all services"
echo " ./start-data-entry.sh stop Stop all services"
echo " ./start-data-entry.sh status Show services status"
echo
echo " ./start-data-entry.sh restart <service> Restart specific service"
echo " ./start-data-entry.sh start <service> Start specific service"
echo " ./start-data-entry.sh stop <service> Stop specific service"
echo
echo "Services:"
echo " backend - FastAPI (port 8003)"
echo " frontend - Vue.js/Vite (port 3010)"
echo
echo "Examples:"
echo " ./start-data-entry.sh restart backend Restart only backend"
echo " ./start-data-entry.sh stop frontend Stop only frontend"
echo
}
# Check command line arguments
case $1 in
stop)
if [[ -n $2 ]]; then
stop_service $2
exit 0
else
stop_services
fi
;;
start)
if [[ -n $2 ]]; then
start_service $2
exit 0
else
true # Continue with normal start process
fi
;;
restart)
if [[ -z $2 ]]; then
print_error "Please specify which service to restart"
echo
show_usage
exit 1
fi
restart_service $2
exit 0
;;
status)
show_status
exit 0
;;
help|--help|-h)
show_usage
exit 0
;;
"")
# No parameter - start all services
true
;;
*)
print_error "Unknown parameter: $1"
echo
show_usage
exit 1
;;
esac
# Set up signal handlers
trap cleanup SIGINT SIGTERM
print_message "Starting Data Entry Development Environment..."
echo
# Step 1: Start Backend
print_message "1. Starting Backend (FastAPI)..."
# Check if backend port is already in use
if check_port 8003; then
print_warning "Port 8003 is already in use. Backend might already be running."
read -p "Continue anyway? (y/n): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
cleanup
fi
fi
cd data-entry-app/backend/
# Check if .env file exists
if [ ! -f ".env" ]; then
if [ -f ".env.example" ]; then
print_message "Creating .env from .env.example..."
cp .env.example .env
else
print_error ".env file not found!"
exit 1
fi
fi
# Check if virtual environment exists
if [ ! -d "venv" ]; then
print_message "Creating Python virtual environment..."
python3 -m venv venv
fi
# Activate virtual environment
source venv/bin/activate
# Check if dependencies are installed
if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then
print_message "Installing backend dependencies..."
pip install --upgrade pip > /dev/null 2>&1
pip install -r requirements.txt
fi
# Ensure data directories exist
mkdir -p data/uploads
# Run Alembic migrations
print_message "Running database migrations..."
alembic upgrade head 2>/dev/null || print_warning "Migration may have already been applied or first run"
# Start backend in background
print_message "Starting uvicorn server..."
uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload &
BACKEND_PID=$!
# Wait for backend to start
sleep 2
for i in {1..10}; do
if check_port 8003; then
print_success "Backend started successfully on http://localhost:8003"
break
fi
if [ $i -eq 10 ]; then
print_error "Backend failed to start after 10 attempts"
cleanup
fi
sleep 1
done
# Step 2: Start Frontend
print_message "2. Starting Frontend (Vue.js)..."
cd ../frontend/
# Check if node_modules exists
if [ ! -d "node_modules" ]; then
print_message "Installing frontend dependencies..."
npm install
fi
# Check for WSL compatibility
if [ -f "node_modules/.bin/vite.cmd" ] && [ ! -f "node_modules/.bin/vite" ]; then
print_warning "Windows node_modules detected, reinstalling for WSL..."
rm -rf node_modules package-lock.json
npm install
fi
# Start frontend in background
print_message "Starting Vite development server..."
npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 &
FRONTEND_PID=$!
# Wait for frontend to start
sleep 3
for i in {1..10}; do
if check_port 3010; then
print_success "Frontend started successfully on http://localhost:3010"
break
fi
if [ $i -eq 10 ]; then
print_error "Frontend failed to start after 10 attempts"
print_message "Check log at /tmp/data_entry_frontend.log"
cat /tmp/data_entry_frontend.log
cleanup
fi
sleep 1
done
# Summary
echo
print_success "🚀 Data Entry Development Environment is now running!"
echo
echo -e "${BLUE}Services:${NC}"
echo " • Backend: http://localhost:8003"
echo " • Frontend: http://localhost:3010"
echo " • API Docs: http://localhost:8003/docs"
echo
echo -e "${BLUE}Quick Links:${NC}"
echo " • Lista Bonuri: http://localhost:3010/"
echo " • Bon Nou: http://localhost:3010/create"
echo " • Aprobare: http://localhost:3010/approval"
echo
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}"
echo
# Keep script running and wait for user interrupt
wait