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:
667
PLAN_DATA_ENTRY_RECEIPTS.md
Normal file
667
PLAN_DATA_ENTRY_RECEIPTS.md
Normal 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
141
data-entry-app/CLAUDE.md
Normal 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
177
data-entry-app/README.md
Normal 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
|
||||||
26
data-entry-app/backend/.env.example
Normal file
26
data-entry-app/backend/.env.example
Normal 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
|
||||||
106
data-entry-app/backend/alembic.ini
Normal file
106
data-entry-app/backend/alembic.ini
Normal 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
|
||||||
1
data-entry-app/backend/app/__init__.py
Normal file
1
data-entry-app/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Data Entry App - Backend
|
||||||
96
data-entry-app/backend/app/config.py
Normal file
96
data-entry-app/backend/app/config.py
Normal 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()
|
||||||
4
data-entry-app/backend/app/db/__init__.py
Normal file
4
data-entry-app/backend/app/db/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Database module
|
||||||
|
from .database import get_session, init_db, engine
|
||||||
|
|
||||||
|
__all__ = ["get_session", "init_db", "engine"]
|
||||||
10
data-entry-app/backend/app/db/crud/__init__.py
Normal file
10
data-entry-app/backend/app/db/crud/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# CRUD operations
|
||||||
|
from .receipt import ReceiptCRUD
|
||||||
|
from .attachment import AttachmentCRUD
|
||||||
|
from .accounting_entry import AccountingEntryCRUD
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ReceiptCRUD",
|
||||||
|
"AttachmentCRUD",
|
||||||
|
"AccountingEntryCRUD",
|
||||||
|
]
|
||||||
197
data-entry-app/backend/app/db/crud/accounting_entry.py
Normal file
197
data-entry-app/backend/app/db/crud/accounting_entry.py
Normal 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, ""
|
||||||
140
data-entry-app/backend/app/db/crud/attachment.py
Normal file
140
data-entry-app/backend/app/db/crud/attachment.py
Normal 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
|
||||||
253
data-entry-app/backend/app/db/crud/receipt.py
Normal file
253
data-entry-app/backend/app/db/crud/receipt.py
Normal 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
|
||||||
49
data-entry-app/backend/app/db/database.py
Normal file
49
data-entry-app/backend/app/db/database.py
Normal 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()
|
||||||
13
data-entry-app/backend/app/db/models/__init__.py
Normal file
13
data-entry-app/backend/app/db/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
49
data-entry-app/backend/app/db/models/accounting_entry.py
Normal file
49
data-entry-app/backend/app/db/models/accounting_entry.py
Normal 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")
|
||||||
110
data-entry-app/backend/app/db/models/receipt.py
Normal file
110
data-entry-app/backend/app/db/models/receipt.py
Normal 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")
|
||||||
88
data-entry-app/backend/app/main.py
Normal file
88
data-entry-app/backend/app/main.py
Normal 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",
|
||||||
|
}
|
||||||
4
data-entry-app/backend/app/routers/__init__.py
Normal file
4
data-entry-app/backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# API routers
|
||||||
|
from . import receipts
|
||||||
|
|
||||||
|
__all__ = ["receipts"]
|
||||||
450
data-entry-app/backend/app/routers/receipts.py
Normal file
450
data-entry-app/backend/app/routers/receipts.py
Normal 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()
|
||||||
28
data-entry-app/backend/app/schemas/__init__.py
Normal file
28
data-entry-app/backend/app/schemas/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
199
data-entry-app/backend/app/schemas/receipt.py
Normal file
199
data-entry-app/backend/app/schemas/receipt.py
Normal 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")
|
||||||
11
data-entry-app/backend/app/services/__init__.py
Normal file
11
data-entry-app/backend/app/services/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
101
data-entry-app/backend/app/services/expense_types.py
Normal file
101
data-entry-app/backend/app/services/expense_types.py
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
164
data-entry-app/backend/app/services/nomenclature_service.py
Normal file
164
data-entry-app/backend/app/services/nomenclature_service.py
Normal 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")
|
||||||
389
data-entry-app/backend/app/services/receipt_service.py
Normal file
389
data-entry-app/backend/app/services/receipt_service.py
Normal 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)
|
||||||
79
data-entry-app/backend/migrations/env.py
Normal file
79
data-entry-app/backend/migrations/env.py
Normal 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()
|
||||||
27
data-entry-app/backend/migrations/script.py.mako
Normal file
27
data-entry-app/backend/migrations/script.py.mako
Normal 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"}
|
||||||
@@ -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")
|
||||||
32
data-entry-app/backend/requirements.txt
Normal file
32
data-entry-app/backend/requirements.txt
Normal 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
|
||||||
13
data-entry-app/frontend/index.html
Normal file
13
data-entry-app/frontend/index.html
Normal 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>
|
||||||
27
data-entry-app/frontend/package.json
Normal file
27
data-entry-app/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
129
data-entry-app/frontend/src/App.vue
Normal file
129
data-entry-app/frontend/src/App.vue
Normal 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>
|
||||||
275
data-entry-app/frontend/src/assets/css/main.css
Normal file
275
data-entry-app/frontend/src/assets/css/main.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
data-entry-app/frontend/src/main.js
Normal file
81
data-entry-app/frontend/src/main.js
Normal 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')
|
||||||
49
data-entry-app/frontend/src/router/index.js
Normal file
49
data-entry-app/frontend/src/router/index.js
Normal 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
|
||||||
365
data-entry-app/frontend/src/stores/receiptsStore.js
Normal file
365
data-entry-app/frontend/src/stores/receiptsStore.js
Normal 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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
47
data-entry-app/frontend/src/utils/constants.js
Normal file
47
data-entry-app/frontend/src/utils/constants.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
535
data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue
Normal file
535
data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue
Normal 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>
|
||||||
524
data-entry-app/frontend/src/views/receipts/ReceiptDetailView.vue
Normal file
524
data-entry-app/frontend/src/views/receipts/ReceiptDetailView.vue
Normal 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>
|
||||||
339
data-entry-app/frontend/src/views/receipts/ReceiptsListView.vue
Normal file
339
data-entry-app/frontend/src/views/receipts/ReceiptsListView.vue
Normal 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>
|
||||||
29
data-entry-app/frontend/vite.config.js
Normal file
29
data-entry-app/frontend/vite.config.js
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
275
docs/data-entry/ARCHITECTURE.md
Normal file
275
docs/data-entry/ARCHITECTURE.md
Normal 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
|
||||||
155
docs/data-entry/REQUIREMENTS.md
Normal file
155
docs/data-entry/REQUIREMENTS.md
Normal 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
470
start-data-entry.sh
Normal 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
|
||||||
Reference in New Issue
Block a user