From 21c12ddb0fd2bc27f8761d8a6273b9a31970aa90 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Thu, 11 Dec 2025 17:30:51 +0200 Subject: [PATCH] feat: Add data-entry-app for fiscal receipts with approval workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PLAN_DATA_ENTRY_RECEIPTS.md | 667 ++++++++++++++++++ data-entry-app/CLAUDE.md | 141 ++++ data-entry-app/README.md | 177 +++++ data-entry-app/backend/.env.example | 26 + data-entry-app/backend/alembic.ini | 106 +++ data-entry-app/backend/app/__init__.py | 1 + data-entry-app/backend/app/config.py | 96 +++ data-entry-app/backend/app/db/__init__.py | 4 + .../backend/app/db/crud/__init__.py | 10 + .../backend/app/db/crud/accounting_entry.py | 197 ++++++ .../backend/app/db/crud/attachment.py | 140 ++++ data-entry-app/backend/app/db/crud/receipt.py | 253 +++++++ data-entry-app/backend/app/db/database.py | 49 ++ .../backend/app/db/models/__init__.py | 13 + .../backend/app/db/models/accounting_entry.py | 49 ++ .../backend/app/db/models/receipt.py | 110 +++ data-entry-app/backend/app/main.py | 88 +++ .../backend/app/routers/__init__.py | 4 + .../backend/app/routers/receipts.py | 450 ++++++++++++ .../backend/app/schemas/__init__.py | 28 + data-entry-app/backend/app/schemas/receipt.py | 199 ++++++ .../backend/app/services/__init__.py | 11 + .../backend/app/services/expense_types.py | 101 +++ .../app/services/nomenclature_service.py | 164 +++++ .../backend/app/services/receipt_service.py | 389 ++++++++++ data-entry-app/backend/migrations/env.py | 79 +++ .../backend/migrations/script.py.mako | 27 + .../versions/001_initial_receipts.py | 112 +++ data-entry-app/backend/requirements.txt | 32 + data-entry-app/frontend/index.html | 13 + data-entry-app/frontend/package.json | 27 + data-entry-app/frontend/src/App.vue | 129 ++++ .../frontend/src/assets/css/main.css | 275 ++++++++ data-entry-app/frontend/src/main.js | 81 +++ data-entry-app/frontend/src/router/index.js | 49 ++ .../frontend/src/stores/receiptsStore.js | 365 ++++++++++ .../frontend/src/utils/constants.js | 47 ++ .../views/receipts/ReceiptApprovalView.vue | 488 +++++++++++++ .../src/views/receipts/ReceiptCreateView.vue | 535 ++++++++++++++ .../src/views/receipts/ReceiptDetailView.vue | 524 ++++++++++++++ .../src/views/receipts/ReceiptsListView.vue | 339 +++++++++ data-entry-app/frontend/vite.config.js | 29 + docs/data-entry/ARCHITECTURE.md | 275 ++++++++ docs/data-entry/REQUIREMENTS.md | 155 ++++ start-data-entry.sh | 470 ++++++++++++ 45 files changed, 7524 insertions(+) create mode 100644 PLAN_DATA_ENTRY_RECEIPTS.md create mode 100644 data-entry-app/CLAUDE.md create mode 100644 data-entry-app/README.md create mode 100644 data-entry-app/backend/.env.example create mode 100644 data-entry-app/backend/alembic.ini create mode 100644 data-entry-app/backend/app/__init__.py create mode 100644 data-entry-app/backend/app/config.py create mode 100644 data-entry-app/backend/app/db/__init__.py create mode 100644 data-entry-app/backend/app/db/crud/__init__.py create mode 100644 data-entry-app/backend/app/db/crud/accounting_entry.py create mode 100644 data-entry-app/backend/app/db/crud/attachment.py create mode 100644 data-entry-app/backend/app/db/crud/receipt.py create mode 100644 data-entry-app/backend/app/db/database.py create mode 100644 data-entry-app/backend/app/db/models/__init__.py create mode 100644 data-entry-app/backend/app/db/models/accounting_entry.py create mode 100644 data-entry-app/backend/app/db/models/receipt.py create mode 100644 data-entry-app/backend/app/main.py create mode 100644 data-entry-app/backend/app/routers/__init__.py create mode 100644 data-entry-app/backend/app/routers/receipts.py create mode 100644 data-entry-app/backend/app/schemas/__init__.py create mode 100644 data-entry-app/backend/app/schemas/receipt.py create mode 100644 data-entry-app/backend/app/services/__init__.py create mode 100644 data-entry-app/backend/app/services/expense_types.py create mode 100644 data-entry-app/backend/app/services/nomenclature_service.py create mode 100644 data-entry-app/backend/app/services/receipt_service.py create mode 100644 data-entry-app/backend/migrations/env.py create mode 100644 data-entry-app/backend/migrations/script.py.mako create mode 100644 data-entry-app/backend/migrations/versions/001_initial_receipts.py create mode 100644 data-entry-app/backend/requirements.txt create mode 100644 data-entry-app/frontend/index.html create mode 100644 data-entry-app/frontend/package.json create mode 100644 data-entry-app/frontend/src/App.vue create mode 100644 data-entry-app/frontend/src/assets/css/main.css create mode 100644 data-entry-app/frontend/src/main.js create mode 100644 data-entry-app/frontend/src/router/index.js create mode 100644 data-entry-app/frontend/src/stores/receiptsStore.js create mode 100644 data-entry-app/frontend/src/utils/constants.js create mode 100644 data-entry-app/frontend/src/views/receipts/ReceiptApprovalView.vue create mode 100644 data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue create mode 100644 data-entry-app/frontend/src/views/receipts/ReceiptDetailView.vue create mode 100644 data-entry-app/frontend/src/views/receipts/ReceiptsListView.vue create mode 100644 data-entry-app/frontend/vite.config.js create mode 100644 docs/data-entry/ARCHITECTURE.md create mode 100644 docs/data-entry/REQUIREMENTS.md create mode 100644 start-data-entry.sh diff --git a/PLAN_DATA_ENTRY_RECEIPTS.md b/PLAN_DATA_ENTRY_RECEIPTS.md new file mode 100644 index 0000000..c57b85e --- /dev/null +++ b/PLAN_DATA_ENTRY_RECEIPTS.md @@ -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/` diff --git a/data-entry-app/CLAUDE.md b/data-entry-app/CLAUDE.md new file mode 100644 index 0000000..b272dbc --- /dev/null +++ b/data-entry-app/CLAUDE.md @@ -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 diff --git a/data-entry-app/README.md b/data-entry-app/README.md new file mode 100644 index 0000000..100adf9 --- /dev/null +++ b/data-entry-app/README.md @@ -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 diff --git a/data-entry-app/backend/.env.example b/data-entry-app/backend/.env.example new file mode 100644 index 0000000..c2f2bfb --- /dev/null +++ b/data-entry-app/backend/.env.example @@ -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 diff --git a/data-entry-app/backend/alembic.ini b/data-entry-app/backend/alembic.ini new file mode 100644 index 0000000..4af7b4b --- /dev/null +++ b/data-entry-app/backend/alembic.ini @@ -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 diff --git a/data-entry-app/backend/app/__init__.py b/data-entry-app/backend/app/__init__.py new file mode 100644 index 0000000..2b808a5 --- /dev/null +++ b/data-entry-app/backend/app/__init__.py @@ -0,0 +1 @@ +# Data Entry App - Backend diff --git a/data-entry-app/backend/app/config.py b/data-entry-app/backend/app/config.py new file mode 100644 index 0000000..b6c2a18 --- /dev/null +++ b/data-entry-app/backend/app/config.py @@ -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() diff --git a/data-entry-app/backend/app/db/__init__.py b/data-entry-app/backend/app/db/__init__.py new file mode 100644 index 0000000..d31153e --- /dev/null +++ b/data-entry-app/backend/app/db/__init__.py @@ -0,0 +1,4 @@ +# Database module +from .database import get_session, init_db, engine + +__all__ = ["get_session", "init_db", "engine"] diff --git a/data-entry-app/backend/app/db/crud/__init__.py b/data-entry-app/backend/app/db/crud/__init__.py new file mode 100644 index 0000000..a1b6ba5 --- /dev/null +++ b/data-entry-app/backend/app/db/crud/__init__.py @@ -0,0 +1,10 @@ +# CRUD operations +from .receipt import ReceiptCRUD +from .attachment import AttachmentCRUD +from .accounting_entry import AccountingEntryCRUD + +__all__ = [ + "ReceiptCRUD", + "AttachmentCRUD", + "AccountingEntryCRUD", +] diff --git a/data-entry-app/backend/app/db/crud/accounting_entry.py b/data-entry-app/backend/app/db/crud/accounting_entry.py new file mode 100644 index 0000000..8051f1e --- /dev/null +++ b/data-entry-app/backend/app/db/crud/accounting_entry.py @@ -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, "" diff --git a/data-entry-app/backend/app/db/crud/attachment.py b/data-entry-app/backend/app/db/crud/attachment.py new file mode 100644 index 0000000..f746550 --- /dev/null +++ b/data-entry-app/backend/app/db/crud/attachment.py @@ -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 diff --git a/data-entry-app/backend/app/db/crud/receipt.py b/data-entry-app/backend/app/db/crud/receipt.py new file mode 100644 index 0000000..5231448 --- /dev/null +++ b/data-entry-app/backend/app/db/crud/receipt.py @@ -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 diff --git a/data-entry-app/backend/app/db/database.py b/data-entry-app/backend/app/db/database.py new file mode 100644 index 0000000..d502019 --- /dev/null +++ b/data-entry-app/backend/app/db/database.py @@ -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() diff --git a/data-entry-app/backend/app/db/models/__init__.py b/data-entry-app/backend/app/db/models/__init__.py new file mode 100644 index 0000000..a872220 --- /dev/null +++ b/data-entry-app/backend/app/db/models/__init__.py @@ -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", +] diff --git a/data-entry-app/backend/app/db/models/accounting_entry.py b/data-entry-app/backend/app/db/models/accounting_entry.py new file mode 100644 index 0000000..6398adc --- /dev/null +++ b/data-entry-app/backend/app/db/models/accounting_entry.py @@ -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") diff --git a/data-entry-app/backend/app/db/models/receipt.py b/data-entry-app/backend/app/db/models/receipt.py new file mode 100644 index 0000000..bc10e54 --- /dev/null +++ b/data-entry-app/backend/app/db/models/receipt.py @@ -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") diff --git a/data-entry-app/backend/app/main.py b/data-entry-app/backend/app/main.py new file mode 100644 index 0000000..82eca1b --- /dev/null +++ b/data-entry-app/backend/app/main.py @@ -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", + } diff --git a/data-entry-app/backend/app/routers/__init__.py b/data-entry-app/backend/app/routers/__init__.py new file mode 100644 index 0000000..665b26d --- /dev/null +++ b/data-entry-app/backend/app/routers/__init__.py @@ -0,0 +1,4 @@ +# API routers +from . import receipts + +__all__ = ["receipts"] diff --git a/data-entry-app/backend/app/routers/receipts.py b/data-entry-app/backend/app/routers/receipts.py new file mode 100644 index 0000000..c31dd46 --- /dev/null +++ b/data-entry-app/backend/app/routers/receipts.py @@ -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() diff --git a/data-entry-app/backend/app/schemas/__init__.py b/data-entry-app/backend/app/schemas/__init__.py new file mode 100644 index 0000000..f7a5ee7 --- /dev/null +++ b/data-entry-app/backend/app/schemas/__init__.py @@ -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", +] diff --git a/data-entry-app/backend/app/schemas/receipt.py b/data-entry-app/backend/app/schemas/receipt.py new file mode 100644 index 0000000..b9326e5 --- /dev/null +++ b/data-entry-app/backend/app/schemas/receipt.py @@ -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") diff --git a/data-entry-app/backend/app/services/__init__.py b/data-entry-app/backend/app/services/__init__.py new file mode 100644 index 0000000..7d3dd42 --- /dev/null +++ b/data-entry-app/backend/app/services/__init__.py @@ -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", +] diff --git a/data-entry-app/backend/app/services/expense_types.py b/data-entry-app/backend/app/services/expense_types.py new file mode 100644 index 0000000..fcb238e --- /dev/null +++ b/data-entry-app/backend/app/services/expense_types.py @@ -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", + }, +} diff --git a/data-entry-app/backend/app/services/nomenclature_service.py b/data-entry-app/backend/app/services/nomenclature_service.py new file mode 100644 index 0000000..ebdf1b4 --- /dev/null +++ b/data-entry-app/backend/app/services/nomenclature_service.py @@ -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") diff --git a/data-entry-app/backend/app/services/receipt_service.py b/data-entry-app/backend/app/services/receipt_service.py new file mode 100644 index 0000000..b6219e6 --- /dev/null +++ b/data-entry-app/backend/app/services/receipt_service.py @@ -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) diff --git a/data-entry-app/backend/migrations/env.py b/data-entry-app/backend/migrations/env.py new file mode 100644 index 0000000..35781d3 --- /dev/null +++ b/data-entry-app/backend/migrations/env.py @@ -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() diff --git a/data-entry-app/backend/migrations/script.py.mako b/data-entry-app/backend/migrations/script.py.mako new file mode 100644 index 0000000..6ce3351 --- /dev/null +++ b/data-entry-app/backend/migrations/script.py.mako @@ -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"} diff --git a/data-entry-app/backend/migrations/versions/001_initial_receipts.py b/data-entry-app/backend/migrations/versions/001_initial_receipts.py new file mode 100644 index 0000000..fcabcde --- /dev/null +++ b/data-entry-app/backend/migrations/versions/001_initial_receipts.py @@ -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") diff --git a/data-entry-app/backend/requirements.txt b/data-entry-app/backend/requirements.txt new file mode 100644 index 0000000..3164347 --- /dev/null +++ b/data-entry-app/backend/requirements.txt @@ -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 diff --git a/data-entry-app/frontend/index.html b/data-entry-app/frontend/index.html new file mode 100644 index 0000000..d474926 --- /dev/null +++ b/data-entry-app/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Data Entry - Bonuri Fiscale + + +
+ + + diff --git a/data-entry-app/frontend/package.json b/data-entry-app/frontend/package.json new file mode 100644 index 0000000..cdc69f4 --- /dev/null +++ b/data-entry-app/frontend/package.json @@ -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" + } +} diff --git a/data-entry-app/frontend/src/App.vue b/data-entry-app/frontend/src/App.vue new file mode 100644 index 0000000..5aa2ed5 --- /dev/null +++ b/data-entry-app/frontend/src/App.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/data-entry-app/frontend/src/assets/css/main.css b/data-entry-app/frontend/src/assets/css/main.css new file mode 100644 index 0000000..0873d2f --- /dev/null +++ b/data-entry-app/frontend/src/assets/css/main.css @@ -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%; + } +} diff --git a/data-entry-app/frontend/src/main.js b/data-entry-app/frontend/src/main.js new file mode 100644 index 0000000..e4b82fe --- /dev/null +++ b/data-entry-app/frontend/src/main.js @@ -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') diff --git a/data-entry-app/frontend/src/router/index.js b/data-entry-app/frontend/src/router/index.js new file mode 100644 index 0000000..7e0630a --- /dev/null +++ b/data-entry-app/frontend/src/router/index.js @@ -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 diff --git a/data-entry-app/frontend/src/stores/receiptsStore.js b/data-entry-app/frontend/src/stores/receiptsStore.js new file mode 100644 index 0000000..8baaabd --- /dev/null +++ b/data-entry-app/frontend/src/stores/receiptsStore.js @@ -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 + }, + }, +}) diff --git a/data-entry-app/frontend/src/utils/constants.js b/data-entry-app/frontend/src/utils/constants.js new file mode 100644 index 0000000..72404e6 --- /dev/null +++ b/data-entry-app/frontend/src/utils/constants.js @@ -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) +} diff --git a/data-entry-app/frontend/src/views/receipts/ReceiptApprovalView.vue b/data-entry-app/frontend/src/views/receipts/ReceiptApprovalView.vue new file mode 100644 index 0000000..e8254b9 --- /dev/null +++ b/data-entry-app/frontend/src/views/receipts/ReceiptApprovalView.vue @@ -0,0 +1,488 @@ +