Consolidate 3 separate applications (reports-app, data-entry-app, telegram-bot) into a unified
architecture with single backend and frontend:
Backend Changes:
- Unified FastAPI backend at backend/ with modular structure
- Modules: reports, data_entry, telegram in backend/modules/
- Centralized config.py and main.py with all routers registered
- Single worker mode (--workers 1) for Telegram bot compatibility
- Shared Oracle connection pool and JWT authentication
- Unified requirements.txt and environment configuration
Frontend Changes:
- Single Vue.js SPA with module-based routing
- Unified frontend at src/ with modules in src/modules/{reports,data-entry}/
- Shared components and stores in src/shared/
- Error boundaries for module isolation
- Dual API proxy in Vite for module communication
Infrastructure:
- New unified startup scripts: start-prod.sh, start-test.sh, start-backend.sh
- Environment templates: .env.dev.example, .env.test.example, .env.prod.example
- Updated deployment scripts for Windows IIS
- Simplified SSH tunnel management
Documentation:
- Comprehensive CLAUDE.md with architecture overview
- Module-specific docs in docs/{data-entry,telegram}/
- Architecture decision records in docs/ARCHITECTURE-DECISIONS.md
- Deployment guides consolidated in deployment/windows/docs/
This migration reduces complexity, improves maintainability, and enables easier
deployment while maintaining all existing functionality.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
448 lines
15 KiB
Python
448 lines
15 KiB
Python
"""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 backend.modules.data_entry.db.models.receipt import Receipt, ReceiptStatus, ReceiptDirection
|
|
from backend.modules.data_entry.db.models.accounting_entry import EntryType
|
|
from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD
|
|
from backend.modules.data_entry.db.crud.accounting_entry import AccountingEntryCRUD
|
|
from backend.modules.data_entry.schemas.receipt import (
|
|
ReceiptCreate,
|
|
ReceiptUpdate,
|
|
ReceiptFilter,
|
|
ReceiptResponse,
|
|
ReceiptListResponse,
|
|
AccountingEntryCreate,
|
|
)
|
|
from backend.modules.data_entry.services.expense_types import EXPENSE_TYPES, get_expense_type
|
|
|
|
|
|
# Payment mode to accounting account mapping
|
|
PAYMENT_MODE_ACCOUNTS = {
|
|
'casa': ('5311', 'Casa in lei'),
|
|
'banca': ('5121', 'Conturi la banci in lei'),
|
|
'avans_decontare': ('542', 'Avansuri de trezorerie'),
|
|
}
|
|
|
|
|
|
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,
|
|
))
|
|
|
|
# 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,
|
|
))
|
|
|
|
# Credit entry - based on payment_mode (new) or cash_register (legacy)
|
|
if receipt.payment_mode and receipt.payment_mode in PAYMENT_MODE_ACCOUNTS:
|
|
credit_account, credit_name = PAYMENT_MODE_ACCOUNTS[receipt.payment_mode]
|
|
elif receipt.cash_register_account:
|
|
# Backwards compatibility for existing receipts
|
|
credit_account = receipt.cash_register_account
|
|
credit_name = receipt.cash_register_name or "Casa/Banca"
|
|
else:
|
|
# Default fallback
|
|
credit_account = "5311"
|
|
credit_name = "Casa in lei"
|
|
|
|
entries.append(AccountingEntryCreate(
|
|
entry_type=EntryType.CREDIT,
|
|
account_code=credit_account,
|
|
account_name=credit_name,
|
|
amount=amount,
|
|
))
|
|
|
|
else:
|
|
# Income: Debit cash/bank, Credit income account
|
|
# Based on payment_mode (new) or cash_register (legacy)
|
|
if receipt.payment_mode and receipt.payment_mode in PAYMENT_MODE_ACCOUNTS:
|
|
cash_account, cash_name = PAYMENT_MODE_ACCOUNTS[receipt.payment_mode]
|
|
elif receipt.cash_register_account:
|
|
cash_account = receipt.cash_register_account
|
|
cash_name = receipt.cash_register_name or "Casa/Banca"
|
|
else:
|
|
cash_account = "5311"
|
|
cash_name = "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
|
|
|
|
# Validate payment_mode or cash_register (backwards compatibility)
|
|
if not receipt.payment_mode and not receipt.cash_register_account:
|
|
return False, "Modul de plata este obligatoriu", 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)
|
|
|
|
# Refresh receipt to clear stale relationship references after entry deletion
|
|
await session.refresh(receipt)
|
|
|
|
# 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).
|
|
Requires valid CUI (fiscal code) for approval.
|
|
"""
|
|
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 CUI is present (required for Oracle import)
|
|
if not receipt.cui:
|
|
return False, "Trebuie completat codul fiscal (CUI) pentru aprobare", 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 unapprove_receipt(
|
|
session: AsyncSession,
|
|
receipt_id: int,
|
|
username: str,
|
|
) -> Tuple[bool, str, Optional[Receipt]]:
|
|
"""
|
|
Unapprove receipt (APPROVED → PENDING_REVIEW).
|
|
Returns receipt to pending review for corrections.
|
|
"""
|
|
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
|
|
|
|
if not receipt:
|
|
return False, "Receipt not found", None
|
|
|
|
if receipt.status != ReceiptStatus.APPROVED:
|
|
return False, "Receipt is not approved", None
|
|
|
|
# Update status back to pending review
|
|
updated = await ReceiptCRUD.update_status(
|
|
session, receipt, ReceiptStatus.PENDING_REVIEW
|
|
)
|
|
|
|
return True, "Receipt returned to pending review", 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)
|
|
|
|
# Refresh receipt to clear stale relationship references after entry deletion
|
|
await session.refresh(receipt)
|
|
|
|
# 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)
|