Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
800b0ba2c5
commit
12c8615615
15
.env.example
15
.env.example
@ -20,6 +20,13 @@ PLATFORM_SECONDARY_COLOR=#0f172a
|
|||||||
# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً <slug>.torbatyar.ir)
|
# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً <slug>.torbatyar.ir)
|
||||||
PLATFORM_BASE_DOMAIN=torbatyar.ir
|
PLATFORM_BASE_DOMAIN=torbatyar.ir
|
||||||
|
|
||||||
|
# ---- Auto SSL for tenant domains (Celery → SSH → nginx certbot) ----
|
||||||
|
SSL_PROVISION_ENABLED=false
|
||||||
|
SSL_PROVISION_HOST=192.168.10.156
|
||||||
|
SSL_PROVISION_USER=torbatyaruser
|
||||||
|
SSL_PROVISION_PASSWORD=
|
||||||
|
SSL_PROVISION_SCRIPT=/opt/torbatyar/bin/provision_ssl.py
|
||||||
|
|
||||||
# ---- Core Database (core_platform_db) ----
|
# ---- Core Database (core_platform_db) ----
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
@ -71,6 +78,12 @@ FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback
|
|||||||
CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir,http://localhost:3000
|
CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir,http://localhost:3000
|
||||||
CORE_SERVICE_URL=http://core-service:8000
|
CORE_SERVICE_URL=http://core-service:8000
|
||||||
|
|
||||||
|
# ---- Accounting Service ----
|
||||||
|
ACCOUNTING_SERVICE_URL=http://accounting-service:8002
|
||||||
|
ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db
|
||||||
|
ACCOUNTING_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/accounting_db
|
||||||
|
ACCOUNTING_SERVICE_NAME=accounting-service
|
||||||
|
|
||||||
# ---- OTP / Payamak SMS (Core Service) ----
|
# ---- OTP / Payamak SMS (Core Service) ----
|
||||||
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملیپیامک)
|
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملیپیامک)
|
||||||
PAYAMAK_USERNAME=9155105404
|
PAYAMAK_USERNAME=9155105404
|
||||||
@ -90,12 +103,14 @@ PLATFORM_ADMIN_MOBILES=09155105404
|
|||||||
|
|
||||||
# ---- Frontend (Next.js) ----
|
# ---- Frontend (Next.js) ----
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
|
NEXT_PUBLIC_PLATFORM_BASE_DOMAIN=torbatyar.ir
|
||||||
NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir
|
NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir
|
NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir
|
||||||
NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir
|
NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir
|
||||||
NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
|
NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
|
||||||
NEXT_PUBLIC_KEYCLOAK_REALM=superapp
|
NEXT_PUBLIC_KEYCLOAK_REALM=superapp
|
||||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
|
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
|
||||||
|
NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002
|
||||||
|
|
||||||
INTERNAL_TOKEN_SECRET=change-me-internal-secret
|
INTERNAL_TOKEN_SECRET=change-me-internal-secret
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -37,6 +37,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Deploy temp artifacts
|
# Deploy temp artifacts
|
||||||
scripts/_deploy_bundle.tar.gz
|
scripts/_deploy_bundle.tar.gz
|
||||||
|
scripts/_deploy_*.tar.gz
|
||||||
scripts/_tmp_*
|
scripts/_tmp_*
|
||||||
scripts/_check_*
|
scripts/_check_*
|
||||||
scripts/_fix_*
|
scripts/_fix_*
|
||||||
|
|||||||
109
README.md
109
README.md
@ -1,101 +1,72 @@
|
|||||||
# SuperApp SaaS Platform
|
# TorbatYar SuperApp SaaS Platform
|
||||||
|
|
||||||
پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
|
پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
|
||||||
این مخزن شامل **فاز ۱ (Core Platform)** و **فاز ۲ (Identity & Access + SSO)** است.
|
|
||||||
|
|
||||||
> برند، رنگها و تنظیمات هیچکدام در کد hardcode نشدهاند و همگی از `.env` / `config` / دیتابیس خوانده میشوند.
|
> برند، رنگها و تنظیمات هیچکدام در کد hardcode نشدهاند و همگی از `.env` / `config` / دیتابیس خوانده میشوند.
|
||||||
|
|
||||||
## معماری در یک نگاه
|
## Documentation (source of truth)
|
||||||
|
|
||||||
- **جداسازی اجباری Frontend/Backend:** دو اپلیکیشن کاملاً مستقل (`backend/` و `frontend/`).
|
شروع از [`docs/README.md`](./docs/README.md).
|
||||||
- **الگوی دیتابیس:** Database-per-service. هر سرویس فقط دیتابیس خودش را میشناسد.
|
|
||||||
- **ارتباط بین سرویسها:** فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
|
|
||||||
- **چندمستأجری:** همه جداول بیزینسی ستون `tenant_id` دارند.
|
|
||||||
|
|
||||||
جزئیات کامل در پوشه [`docs/`](./docs) موجود است:
|
| Document | Responsibility |
|
||||||
|
|
||||||
| فایل | توضیح |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [`docs/architecture.md`](./docs/architecture.md) | معماری کلان و جداسازی Frontend/Backend |
|
| [`docs/architecture/architecture.md`](./docs/architecture/architecture.md) | Architecture overview |
|
||||||
| [`docs/database_schema.md`](./docs/database_schema.md) | مدل دیتابیس Core |
|
| [`docs/reference/database-schema.md`](./docs/reference/database-schema.md) | Database schema reference |
|
||||||
| [`docs/services_contracts.md`](./docs/services_contracts.md) | قراردادهای ارتباطی سرویسها |
|
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
|
||||||
| [`docs/developer_guide.md`](./docs/developer_guide.md) | راهنمای توسعهدهنده |
|
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
|
||||||
| [`docs/progress.md`](./docs/progress.md) | چکلیست پیشرفت فازها |
|
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
|
||||||
| [`docs/last_step.md`](./docs/last_step.md) | آخرین وضعیت |
|
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
|
||||||
|
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
||||||
|
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
||||||
|
| [`docs/next-steps.md`](./docs/next-steps.md) | Immediate next milestone |
|
||||||
|
| [`docs/roadmap.md`](./docs/roadmap.md) | Future roadmap |
|
||||||
|
| [`docs/deployment/`](./docs/deployment/) | Deployment runbooks |
|
||||||
|
|
||||||
## ساختار پروژه
|
## Architecture at a glance
|
||||||
|
|
||||||
|
- **Mandatory Frontend/Backend separation** — `backend/` and `frontend/` are independent apps.
|
||||||
|
- **Database-per-service** — no cross-DB queries ([ADR-001](./docs/architecture/adr/ADR-001.md)).
|
||||||
|
- **Inter-service communication** — REST, Webhook, Async Event, Outbox/Inbox only.
|
||||||
|
- **Multi-tenancy** — business tables carry `tenant_id`.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
superapp-platform/
|
TorbatYar/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── core-service/ # FastAPI Core Platform
|
│ ├── core-service/ # FastAPI Core Platform
|
||||||
│ ├── shared-lib/ # کتابخانه مشترک backend
|
│ ├── shared-lib/ # Shared backend library
|
||||||
│ └── services/ # placeholder سرویسهای آینده
|
│ └── services/ # Identity (active) + future modules
|
||||||
├── frontend/ # Next.js (کاملاً جدا)
|
├── frontend/ # Next.js
|
||||||
│ ├── app/
|
├── docs/ # Documentation architecture (canonical)
|
||||||
│ ├── components/
|
├── infrastructure/ # Nginx, Keycloak, deploy env samples
|
||||||
│ ├── lib/
|
├── scripts/ # Ops / verification scripts
|
||||||
│ ├── hooks/
|
|
||||||
│ ├── styles/
|
|
||||||
│ └── public/
|
|
||||||
├── docs/
|
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .env.example
|
├── .env.example
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## راهاندازی سریع (Docker)
|
## Quick start (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
سایت: http://localhost:3000
|
- **Frontend:** http://localhost:3000
|
||||||
|
- **Core API:** http://localhost:8000/docs
|
||||||
|
- **Identity API:** http://localhost:8001/docs
|
||||||
|
- **Keycloak:** http://localhost:8080
|
||||||
|
|
||||||
سرویسها پس از بالا آمدن (یک دستور: `docker compose up -d --build`):
|
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
||||||
|
|
||||||
- **Frontend:** http://localhost:3000 (login: http://localhost:3000/login)
|
## Tests (Backend)
|
||||||
- Core API: http://localhost:8000 (مستندات: http://localhost:8000/docs)
|
|
||||||
- Identity API: http://localhost:8001 (SSO: http://localhost:8001/docs)
|
|
||||||
- Keycloak: http://localhost:8080 (admin/admin — کاربر نمونه: platform.admin/admin123)
|
|
||||||
- PostgreSQL: `localhost:5432`
|
|
||||||
- Redis: `localhost:6379`
|
|
||||||
|
|
||||||
> Frontend داخل Docker با hot-reload اجرا میشود؛ دیگر نیازی به `npm run dev` جداگانه نیست.
|
|
||||||
|
|
||||||
## راهاندازی محلی
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
```bash
|
|
||||||
cd backend/core-service
|
|
||||||
python -m venv .venv
|
|
||||||
# Windows: .venv\Scripts\Activate.ps1
|
|
||||||
pip install -r requirements.txt
|
|
||||||
alembic upgrade head
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
```bash
|
|
||||||
# ترجیحاً از Docker (همراه بقیه سرویسها):
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# یا اجرای مستقیم برای دیباگ UI:
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
Frontend: http://localhost:3000
|
|
||||||
|
|
||||||
## اجرای تستها (Backend)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend/core-service
|
cd backend/core-service
|
||||||
pytest -q
|
pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
## فازهای بعدی
|
## Current status
|
||||||
|
|
||||||
سرویسهای آینده در [`backend/services/`](./backend/services) بهصورت placeholder آمادهاند.
|
See [`docs/progress.md`](./docs/progress.md). Next milestone: [`docs/next-steps.md`](./docs/next-steps.md).
|
||||||
UI کامل در [`frontend/`](./frontend) توسعه مییابد.
|
|
||||||
|
|||||||
@ -2,20 +2,26 @@
|
|||||||
|
|
||||||
تمام کد منبع backend فقط در این پوشه قرار دارد.
|
تمام کد منبع backend فقط در این پوشه قرار دارد.
|
||||||
|
|
||||||
## ساختار
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── core-service/ # سرویس Core Platform (FastAPI)
|
├── core-service/ # Core Platform (FastAPI) — Active
|
||||||
├── shared-lib/ # کتابخانه مشترک بین سرویسهای backend
|
├── shared-lib/ # Shared backend library
|
||||||
└── services/ # placeholder سرویسهای آینده
|
└── services/ # identity-access (Active) + future module placeholders
|
||||||
```
|
```
|
||||||
|
|
||||||
## قوانین (اجباری)
|
## Rules (mandatory)
|
||||||
- **هیچ** کد React، Next.js، Tailwind، UI Component یا HTML در backend نیست.
|
|
||||||
- frontend در پوشه جداگانه `frontend/` قرار دارد.
|
- **No** React, Next.js, Tailwind, UI components, or HTML pages in backend.
|
||||||
- ارتباط با frontend فقط از طریق REST API.
|
- Frontend lives only in `frontend/`.
|
||||||
|
- Communicate with frontend only via versioned REST APIs.
|
||||||
|
- Database-per-service; no cross-DB access.
|
||||||
|
- Follow [`docs/development/project-principles.md`](../docs/development/project-principles.md)
|
||||||
|
and [`docs/architecture/module-boundaries.md`](../docs/architecture/module-boundaries.md).
|
||||||
|
|
||||||
|
## Run Core
|
||||||
|
|
||||||
## راهاندازی
|
|
||||||
```bash
|
```bash
|
||||||
cd backend/core-service
|
cd backend/core-service
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
@ -24,8 +30,16 @@ alembic upgrade head
|
|||||||
uvicorn app.main:app --reload
|
uvicorn app.main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
## تستها
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend/core-service
|
cd backend/core-service
|
||||||
pytest -q
|
pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [Developer Guide](../docs/development/developer-guide.md)
|
||||||
|
- [Module Registry](../docs/module-registry.md)
|
||||||
|
- [Services Contracts](../docs/reference/services-contracts.md)
|
||||||
|
- [Services README](services/README.md)
|
||||||
|
|||||||
@ -16,3 +16,6 @@ alembic downgrade -1
|
|||||||
```
|
```
|
||||||
|
|
||||||
> آدرس دیتابیس از `settings.database_url_sync` (متغیر محیطی `DATABASE_URL_SYNC`) خوانده میشود.
|
> آدرس دیتابیس از `settings.database_url_sync` (متغیر محیطی `DATABASE_URL_SYNC`) خوانده میشود.
|
||||||
|
|
||||||
|
Related: [Database Architecture](../../../docs/architecture/database-architecture.md), [Coding Standards — migrations](../../../docs/development/coding-standards.md).
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from app.api.v1 import (
|
|||||||
me,
|
me,
|
||||||
onboarding,
|
onboarding,
|
||||||
plans,
|
plans,
|
||||||
|
public_tenant,
|
||||||
service_registry,
|
service_registry,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tenant_context,
|
tenant_context,
|
||||||
@ -20,6 +21,7 @@ api_router = APIRouter()
|
|||||||
|
|
||||||
# health خارج از prefix نسخه هم در main اضافه میشود؛ اینجا برای کامل بودن.
|
# health خارج از prefix نسخه هم در main اضافه میشود؛ اینجا برای کامل بودن.
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(public_tenant.router)
|
||||||
api_router.include_router(admin_tenants.router)
|
api_router.include_router(admin_tenants.router)
|
||||||
api_router.include_router(tenants.router)
|
api_router.include_router(tenants.router)
|
||||||
api_router.include_router(domains.router)
|
api_router.include_router(domains.router)
|
||||||
|
|||||||
76
backend/core-service/app/api/v1/public_tenant.py
Normal file
76
backend/core-service/app/api/v1/public_tenant.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"""اسکیما و endpoint عمومی سایت tenant (بدون نیاز به لاگین)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db
|
||||||
|
from app.models.enums import TenantStatus
|
||||||
|
from app.repositories.tenant import DomainRepository, TenantRepository
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/public", tags=["public"])
|
||||||
|
|
||||||
|
|
||||||
|
class PublicTenantSiteRead(BaseModel):
|
||||||
|
"""اطلاعات عمومی قابل نمایش روی سابدامین tenant."""
|
||||||
|
|
||||||
|
tenant_id: UUID
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
status: TenantStatus
|
||||||
|
business_type: str | None = None
|
||||||
|
primary_color: str | None = None
|
||||||
|
secondary_color: str | None = None
|
||||||
|
logo_url: str | None = None
|
||||||
|
favicon_url: str | None = None
|
||||||
|
primary_domain: str | None = None
|
||||||
|
tagline: str = Field(default="روی پلتفرم تربتیار")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenant-site", response_model=PublicTenantSiteRead)
|
||||||
|
async def get_public_tenant_site(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> PublicTenantSiteRead:
|
||||||
|
"""Resolve سایت tenant از روی Host (middleware) یا ?slug=."""
|
||||||
|
tenant_id = getattr(request.state, "tenant_id", None)
|
||||||
|
tenant_slug = getattr(request.state, "tenant_slug", None)
|
||||||
|
query_slug = (request.query_params.get("slug") or "").strip().lower() or None
|
||||||
|
|
||||||
|
tenant_repo = TenantRepository(db)
|
||||||
|
domain_repo = DomainRepository(db)
|
||||||
|
tenant = None
|
||||||
|
if tenant_id is not None:
|
||||||
|
tenant = await tenant_repo.get(tenant_id)
|
||||||
|
elif tenant_slug:
|
||||||
|
tenant = await tenant_repo.get_by_slug(tenant_slug)
|
||||||
|
elif query_slug:
|
||||||
|
tenant = await tenant_repo.get_by_slug(query_slug)
|
||||||
|
|
||||||
|
if tenant is None:
|
||||||
|
raise NotFoundError("سایت این دامنه یافت نشد یا هنوز فعال نشده است")
|
||||||
|
|
||||||
|
if tenant.status in {TenantStatus.SUSPENDED, TenantStatus.ARCHIVED}:
|
||||||
|
raise NotFoundError("این workspace در حال حاضر در دسترس نیست")
|
||||||
|
|
||||||
|
domains = await domain_repo.list_by_tenant(tenant.id)
|
||||||
|
primary = next((d for d in domains if d.is_primary), None)
|
||||||
|
if primary is None and domains:
|
||||||
|
primary = domains[0]
|
||||||
|
|
||||||
|
return PublicTenantSiteRead(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
name=tenant.name,
|
||||||
|
slug=tenant.slug,
|
||||||
|
status=tenant.status,
|
||||||
|
business_type=tenant.business_type,
|
||||||
|
primary_color=tenant.primary_color,
|
||||||
|
secondary_color=tenant.secondary_color,
|
||||||
|
logo_url=tenant.logo_url,
|
||||||
|
favicon_url=tenant.favicon_url,
|
||||||
|
primary_domain=primary.domain if primary else None,
|
||||||
|
)
|
||||||
@ -35,6 +35,16 @@ class Settings(BaseSettings):
|
|||||||
# دامنه پایه برای تخصیص زیردامنه خودکار در onboarding (مثلاً tenant.slug + "." + این مقدار)
|
# دامنه پایه برای تخصیص زیردامنه خودکار در onboarding (مثلاً tenant.slug + "." + این مقدار)
|
||||||
platform_base_domain: str = Field(default="", validation_alias="PLATFORM_BASE_DOMAIN")
|
platform_base_domain: str = Field(default="", validation_alias="PLATFORM_BASE_DOMAIN")
|
||||||
|
|
||||||
|
# ---- Auto SSL (Let's Encrypt via nginx host) ----
|
||||||
|
ssl_provision_enabled: bool = Field(default=False, validation_alias="SSL_PROVISION_ENABLED")
|
||||||
|
ssl_provision_host: str = Field(default="", validation_alias="SSL_PROVISION_HOST")
|
||||||
|
ssl_provision_user: str = Field(default="", validation_alias="SSL_PROVISION_USER")
|
||||||
|
ssl_provision_password: str = Field(default="", validation_alias="SSL_PROVISION_PASSWORD")
|
||||||
|
ssl_provision_script: str = Field(
|
||||||
|
default="/opt/torbatyar/bin/provision_ssl.py",
|
||||||
|
validation_alias="SSL_PROVISION_SCRIPT",
|
||||||
|
)
|
||||||
|
|
||||||
# ---- Database ----
|
# ---- Database ----
|
||||||
# آدرس async برای برنامه و sync برای Alembic.
|
# آدرس async برای برنامه و sync برای Alembic.
|
||||||
database_url: str = "postgresql+asyncpg://superapp:superapp_password@localhost:5432/core_platform_db"
|
database_url: str = "postgresql+asyncpg://superapp:superapp_password@localhost:5432/core_platform_db"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from app.models.domain import Domain
|
|||||||
from app.repositories.tenant import DomainRepository, TenantRepository
|
from app.repositories.tenant import DomainRepository, TenantRepository
|
||||||
from app.schemas.domain import DomainCreate
|
from app.schemas.domain import DomainCreate
|
||||||
from app.services.event_service import EventService
|
from app.services.event_service import EventService
|
||||||
|
from app.services.ssl_provision import enqueue_domain_ssl
|
||||||
from shared.events import CoreEventType
|
from shared.events import CoreEventType
|
||||||
from shared.exceptions import ConflictError, NotFoundError
|
from shared.exceptions import ConflictError, NotFoundError
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ class DomainService:
|
|||||||
)
|
)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(domain)
|
await self.session.refresh(domain)
|
||||||
|
enqueue_domain_ssl(domain.domain)
|
||||||
return domain
|
return domain
|
||||||
|
|
||||||
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
|
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
|
||||||
|
|||||||
@ -28,6 +28,7 @@ from app.schemas.tenant import TenantCreate
|
|||||||
from app.services.event_service import EventService
|
from app.services.event_service import EventService
|
||||||
from app.services.membership_service import MembershipService
|
from app.services.membership_service import MembershipService
|
||||||
from app.services.plan_service import PlanService
|
from app.services.plan_service import PlanService
|
||||||
|
from app.services.ssl_provision import enqueue_domain_ssl
|
||||||
from app.services.subscription_service import SubscriptionService
|
from app.services.subscription_service import SubscriptionService
|
||||||
from app.services.tenant_service import TenantService
|
from app.services.tenant_service import TenantService
|
||||||
from shared.events import CoreEventType
|
from shared.events import CoreEventType
|
||||||
@ -102,6 +103,7 @@ class OnboardingService:
|
|||||||
payload={"domain": host, "type": "subdomain", "auto_assigned": True},
|
payload={"domain": host, "type": "subdomain", "auto_assigned": True},
|
||||||
)
|
)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
|
enqueue_domain_ssl(host)
|
||||||
|
|
||||||
async def update_branding(
|
async def update_branding(
|
||||||
self, tenant_id: UUID, data: OnboardingBrandingUpdate
|
self, tenant_id: UUID, data: OnboardingBrandingUpdate
|
||||||
@ -143,6 +145,8 @@ class OnboardingService:
|
|||||||
payload={"domain": data.custom_domain, "type": "custom"},
|
payload={"domain": data.custom_domain, "type": "custom"},
|
||||||
)
|
)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
|
# فقط اگر DNS از قبل به nginx اشاره کند certbot موفق میشود
|
||||||
|
enqueue_domain_ssl(data.custom_domain)
|
||||||
await self.session.refresh(tenant)
|
await self.session.refresh(tenant)
|
||||||
return tenant
|
return tenant
|
||||||
|
|
||||||
|
|||||||
30
backend/core-service/app/services/ssl_provision.py
Normal file
30
backend/core-service/app/services/ssl_provision.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""صفبندی صدور خودکار SSL برای دامنهٔ تازهساختهشده tenant."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_domain_ssl(domain: str | None) -> None:
|
||||||
|
"""پس از ساخت دامنه، صدور/گسترش گواهی را به Celery میسپارد.
|
||||||
|
|
||||||
|
اگر SSL_PROVISION_ENABLED=false باشد هیچ کاری نمیکند (مثلاً محیط local).
|
||||||
|
"""
|
||||||
|
host = (domain or "").strip().lower()
|
||||||
|
if not host:
|
||||||
|
return
|
||||||
|
if not settings.ssl_provision_enabled:
|
||||||
|
logger.info("ssl_provision_skipped_disabled", extra={"domain": host})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from app.workers.tasks import provision_domain_ssl
|
||||||
|
|
||||||
|
provision_domain_ssl.delay(host)
|
||||||
|
logger.info("ssl_provision_enqueued", extra={"domain": host})
|
||||||
|
except Exception as exc: # pragma: no cover - نباید onboarding را بشکند
|
||||||
|
logger.warning(
|
||||||
|
"ssl_provision_enqueue_failed",
|
||||||
|
extra={"domain": host, "error": str(exc)},
|
||||||
|
)
|
||||||
@ -3,6 +3,10 @@
|
|||||||
توکن ممکن است از دو منبع باشد:
|
توکن ممکن است از دو منبع باشد:
|
||||||
۱. JWT محلی OTP (HS256) — sub برابر با users.id است.
|
۱. JWT محلی OTP (HS256) — sub برابر با users.id است.
|
||||||
۲. JWT مرکزی Keycloak (RS256) — sub برابر با users.keycloak_sub است.
|
۲. JWT مرکزی Keycloak (RS256) — sub برابر با users.keycloak_sub است.
|
||||||
|
|
||||||
|
اگر کاربر SSO هنوز به رکورد Core لینک نشده باشد، با موبایل/نامکاربری
|
||||||
|
(JIT) لینک یا ساخته میشود — نه اینکه پیام «تأیید موبایل» بدهد وقتی موبایل
|
||||||
|
قبلاً تأیید شده است.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -10,8 +14,11 @@ from uuid import UUID
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.enums import UserRole, UserStatus
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.user import UserRepository
|
from app.repositories.user import UserRepository
|
||||||
|
from app.utils.phone import normalize_mobile
|
||||||
from shared.exceptions import ForbiddenError
|
from shared.exceptions import ForbiddenError
|
||||||
from shared.security import CurrentUser
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
@ -22,12 +29,7 @@ class UserService:
|
|||||||
self.repo = UserRepository(session)
|
self.repo = UserRepository(session)
|
||||||
|
|
||||||
async def resolve_current(self, current_user: CurrentUser) -> User:
|
async def resolve_current(self, current_user: CurrentUser) -> User:
|
||||||
"""کاربر Core متناظر با هویت جاری را پیدا میکند.
|
"""کاربر Core متناظر با هویت جاری را پیدا میکند (با JIT link در صورت نیاز)."""
|
||||||
|
|
||||||
اگر کاربر SSO هنوز به یک رکورد Core لینک نشده باشد (مثلاً موبایل
|
|
||||||
تأیید نشده)، خطای قابل مدیریت در frontend برمیگرداند تا کاربر به
|
|
||||||
جریان تکمیل موبایل هدایت شود.
|
|
||||||
"""
|
|
||||||
candidate_id: UUID | None = None
|
candidate_id: UUID | None = None
|
||||||
try:
|
try:
|
||||||
candidate_id = UUID(str(current_user.user_id))
|
candidate_id = UUID(str(current_user.user_id))
|
||||||
@ -39,11 +41,76 @@ class UserService:
|
|||||||
if user is not None:
|
if user is not None:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
user = await self.repo.get_by_keycloak_sub(str(current_user.user_id))
|
sub = str(current_user.user_id)
|
||||||
|
user = await self.repo.get_by_keycloak_sub(sub)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
# JIT: لینک/ساخت از روی موبایل یا username (معمولاً همان موبایل)
|
||||||
|
linked = await self._jit_link_or_create(current_user)
|
||||||
|
if linked is not None:
|
||||||
|
return linked
|
||||||
|
|
||||||
raise ForbiddenError(
|
raise ForbiddenError(
|
||||||
"پروفایل کاربری هسته یافت نشد. ابتدا شماره موبایل خود را تأیید کنید.",
|
"پروفایل کاربری هسته یافت نشد. ابتدا شماره موبایل خود را تأیید کنید.",
|
||||||
error_code="core_profile_not_linked",
|
error_code="core_profile_not_linked",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _jit_link_or_create(self, current_user: CurrentUser) -> User | None:
|
||||||
|
mobile = self._extract_mobile(current_user)
|
||||||
|
if not mobile:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sub = str(current_user.user_id)
|
||||||
|
existing = await self.repo.get_by_mobile(mobile)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.keycloak_sub and existing.keycloak_sub != sub:
|
||||||
|
raise ForbiddenError(
|
||||||
|
"این شماره موبایل به حساب دیگری متصل است.",
|
||||||
|
error_code="mobile_linked_elsewhere",
|
||||||
|
)
|
||||||
|
existing.keycloak_sub = sub
|
||||||
|
existing.mobile_verified = True
|
||||||
|
if current_user.email and not existing.email:
|
||||||
|
existing.email = current_user.email
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
role = (
|
||||||
|
UserRole.PLATFORM_ADMIN
|
||||||
|
if mobile in settings.platform_admin_mobile_set
|
||||||
|
else UserRole.USER
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
mobile=mobile,
|
||||||
|
role=role,
|
||||||
|
status=UserStatus.ACTIVE,
|
||||||
|
mobile_verified=True,
|
||||||
|
keycloak_sub=sub,
|
||||||
|
email=current_user.email,
|
||||||
|
)
|
||||||
|
await self.repo.add(user)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_mobile(current_user: CurrentUser) -> str | None:
|
||||||
|
candidates: list[str] = []
|
||||||
|
if current_user.username:
|
||||||
|
candidates.append(current_user.username)
|
||||||
|
if current_user.email and "@" in current_user.email:
|
||||||
|
local = current_user.email.split("@", 1)[0]
|
||||||
|
# ایمیل مصنوعی ثبتنام موبایل: 9155105404@mobile.torbatyar.local
|
||||||
|
if local.isdigit() and len(local) >= 10:
|
||||||
|
candidates.append("0" + local if not local.startswith("0") else local)
|
||||||
|
else:
|
||||||
|
candidates.append(local)
|
||||||
|
|
||||||
|
for raw in candidates:
|
||||||
|
try:
|
||||||
|
return normalize_mobile(raw)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|||||||
@ -70,3 +70,67 @@ def process_outbox_events() -> dict:
|
|||||||
result = asyncio.run(_run_process_outbox())
|
result = asyncio.run(_run_process_outbox())
|
||||||
logger.info("outbox_processed", extra=result)
|
logger.info("outbox_processed", extra=result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _run_remote_ssl_provision(domain: str) -> str:
|
||||||
|
"""اجرای اسکریپت provision_ssl روی سرور nginx از طریق SSH."""
|
||||||
|
import base64
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
host = settings.ssl_provision_host
|
||||||
|
user = settings.ssl_provision_user
|
||||||
|
password = settings.ssl_provision_password
|
||||||
|
script = settings.ssl_provision_script
|
||||||
|
if not host or not user or not password:
|
||||||
|
raise RuntimeError("SSL provision SSH credentials are not configured")
|
||||||
|
|
||||||
|
inner = f"python3 {shlex.quote(script)} {shlex.quote(domain)}"
|
||||||
|
b64 = base64.b64encode(inner.encode("utf-8")).decode("ascii")
|
||||||
|
remote = (
|
||||||
|
f"echo {shlex.quote(password)} | sudo -S -p '' "
|
||||||
|
f"bash -c {shlex.quote(f'echo {b64} | base64 -d | bash')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
host,
|
||||||
|
username=user,
|
||||||
|
password=password,
|
||||||
|
timeout=30,
|
||||||
|
allow_agent=False,
|
||||||
|
look_for_keys=False,
|
||||||
|
)
|
||||||
|
_, stdout, stderr = client.exec_command(remote, timeout=300, get_pty=True)
|
||||||
|
out = (stdout.read() + stderr.read()).decode("utf-8", "replace")
|
||||||
|
exit_status = stdout.channel.recv_exit_status()
|
||||||
|
if exit_status != 0:
|
||||||
|
raise RuntimeError(f"ssl provision failed ({exit_status}): {out[-2000:]}")
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
name="app.workers.tasks.provision_domain_ssl",
|
||||||
|
bind=True,
|
||||||
|
max_retries=5,
|
||||||
|
default_retry_delay=60,
|
||||||
|
)
|
||||||
|
def provision_domain_ssl(self, domain: str) -> dict:
|
||||||
|
"""صدور خودکار SSL برای دامنهٔ tenant تازهساختهشده."""
|
||||||
|
if not settings.ssl_provision_enabled:
|
||||||
|
return {"skipped": True, "domain": domain, "reason": "disabled"}
|
||||||
|
try:
|
||||||
|
output = _run_remote_ssl_provision(domain)
|
||||||
|
logger.info("ssl_provision_ok", extra={"domain": domain})
|
||||||
|
return {"ok": True, "domain": domain, "output": output[-1500:]}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"ssl_provision_retry",
|
||||||
|
extra={"domain": domain, "error": str(exc), "retries": self.request.retries},
|
||||||
|
)
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
|||||||
@ -20,6 +20,9 @@ celery==5.4.0
|
|||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
|
|
||||||
|
# ---- SSH برای صدور خودکار SSL روی nginx ----
|
||||||
|
paramiko==3.4.1
|
||||||
|
|
||||||
# ---- Auth / JWT ----
|
# ---- Auth / JWT ----
|
||||||
pyjwt[crypto]==2.8.0
|
pyjwt[crypto]==2.8.0
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
# services/ — سرویسهای آینده (Placeholder)
|
# services/ — Backend Modules
|
||||||
|
|
||||||
این پوشه شامل placeholder سرویسهای آینده پلتفرم است. در **فاز ۱** هیچکدام
|
This folder holds deployable backend services beyond Core.
|
||||||
پیادهسازی نشدهاند و فقط README مسئولیتها موجود است.
|
|
||||||
|
|
||||||
هر سرویس در فازهای بعدی طبق اصول معماری زیر ساخته میشود:
|
| Service | Status | Docs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `identity-access/` | **Active** | [README](identity-access/README.md), [module registry](../../docs/module-registry.md#identity-access) |
|
||||||
|
| `accounting/` … others | **Scaffolded** placeholders | Per-service README + [module registry](../../docs/module-registry.md) |
|
||||||
|
|
||||||
- **Database-per-service:** هر سرویس دیتابیس مستقل خود را دارد.
|
## Architecture rules
|
||||||
- همه جداول بیزینسی ستون `tenant_id` دارند.
|
|
||||||
- ارتباط بین سرویسها فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
|
|
||||||
- ارتباط مستقیم بین دیتابیس سرویسها **ممنوع** است.
|
|
||||||
|
|
||||||
جزئیات قراردادها در [`../../docs/services_contracts.md`](../../docs/services_contracts.md).
|
- **Database-per-service** with `tenant_id` on business tables.
|
||||||
|
- Inter-service communication only via REST, Webhook, Async Event, Outbox/Inbox.
|
||||||
|
- **No** direct cross-database queries.
|
||||||
|
- Register modules/providers in docs when implementation starts.
|
||||||
|
|
||||||
|
Contracts: [`docs/reference/services-contracts.md`](../../docs/reference/services-contracts.md)
|
||||||
|
Boundaries: [`docs/architecture/module-boundaries.md`](../../docs/architecture/module-boundaries.md)
|
||||||
|
|||||||
22
backend/services/accounting/Dockerfile.dev
Normal file
22
backend/services/accounting/Dockerfile.dev
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Dev image: dependencies only — code mounted with uvicorn --reload
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PYTHONPATH=/app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/shared-lib/ /shared-lib/
|
||||||
|
COPY backend/services/accounting/requirements.txt /app/requirements.txt
|
||||||
|
|
||||||
|
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
|
||||||
|
&& pip install --upgrade pip \
|
||||||
|
&& pip install -r requirements.txt
|
||||||
|
|
||||||
|
EXPOSE 8002
|
||||||
@ -1,19 +1,52 @@
|
|||||||
# Accounting Service (Placeholder)
|
# Accounting Service
|
||||||
|
|
||||||
> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
|
> Status: **Active** — Phases 5.1 through 5.11 implemented (v0.5.11.0).
|
||||||
|
|
||||||
## مسئولیتها
|
## Responsibilities
|
||||||
- حسابداری ۴ سطحی
|
|
||||||
- اسناد دوبل (Double-entry)
|
|
||||||
- مرکز هزینه
|
|
||||||
- پروژه
|
|
||||||
- فاکتور
|
|
||||||
- پرداخت
|
|
||||||
- مالیات
|
|
||||||
- سامانه مؤدیان
|
|
||||||
- گزارشهای مالی
|
|
||||||
|
|
||||||
## اصول
|
- حسابداری ۴ سطحی، موتور ثبت مرکزی (ADR-010)
|
||||||
- دیتابیس مستقل (`accounting_db`) با `tenant_id` در جداول بیزینسی.
|
- دفتر کل، خزانه، AR/AP، یکپارچهسازی فروش/خرید/موجودی
|
||||||
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
- دارایی ثابت، حقوق و دستمزد
|
||||||
- بررسی دسترسی قابلیتها از Core (`accounting.*`).
|
- گزارشگیری مالی، انطباق و حاکمیت
|
||||||
|
|
||||||
|
## API Groups
|
||||||
|
|
||||||
|
| Prefix | Phase |
|
||||||
|
| --- | --- |
|
||||||
|
| `/api/v1/accounts` | 5.1 |
|
||||||
|
| `/api/v1/fiscal` | 5.1, 5.3 |
|
||||||
|
| `/api/v1/posting` | 5.2 |
|
||||||
|
| `/api/v1/ledger` | 5.3 |
|
||||||
|
| `/api/v1/treasury` | 5.4 |
|
||||||
|
| `/api/v1/ar-ap` | 5.5 |
|
||||||
|
| `/api/v1/sales-accounting` | 5.6 |
|
||||||
|
| `/api/v1/purchase-inventory` | 5.7 |
|
||||||
|
| `/api/v1/assets` | 5.8 |
|
||||||
|
| `/api/v1/payroll` | 5.9 |
|
||||||
|
| `/api/v1/reporting` | 5.10 |
|
||||||
|
| `/api/v1/compliance` | 5.11 |
|
||||||
|
|
||||||
|
## Key Engines
|
||||||
|
|
||||||
|
| Engine | Phase | Rule |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PostingEngine | 5.2 | Sole creator of JournalEntry |
|
||||||
|
| BalanceEngine | 5.3 | Ledger balances |
|
||||||
|
| InventoryValuationEngine | 5.7 | Valuation separated from posting |
|
||||||
|
| DepreciationEngine | 5.8 | Asset depreciation |
|
||||||
|
| PayrollEngine | 5.9 | Salary calculation |
|
||||||
|
| FinancialReportEngine | 5.10 | Reports from posted data only |
|
||||||
|
| AuditFramework | 5.11 | Immutable audit trail |
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/services/accounting
|
||||||
|
pytest -q # 21 tests
|
||||||
|
uvicorn app.main:app --port 8002 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [Phases 5.1–5.11](../../../docs/phases/Accounting/README.md)
|
||||||
|
- [ADR-010](../../../docs/architecture/adr/ADR-010.md)
|
||||||
|
|||||||
40
backend/services/accounting/alembic.ini
Normal file
40
backend/services/accounting/alembic.ini
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[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
|
||||||
42
backend/services/accounting/alembic/env.py
Normal file
42
backend/services/accounting/alembic/env.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
|
||||||
|
if config.config_file_name:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
context.configure(
|
||||||
|
url=settings.database_url_sync,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
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)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
18
backend/services/accounting/alembic/versions/0001_initial.py
Normal file
18
backend/services/accounting/alembic/versions/0001_initial.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"""Initial accounting schema — Phases 5.1 through 5.6."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
revision = "0001_initial"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.drop_all(bind=bind)
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
"""Phases 5.7–5.11 schema additions."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: ensure all models loaded
|
||||||
|
|
||||||
|
revision = "0002_phases_57_511"
|
||||||
|
down_revision = "0001_initial"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
1
backend/services/accounting/app/__init__.py
Normal file
1
backend/services/accounting/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.5.11.0"
|
||||||
37
backend/services/accounting/app/api/deps.py
Normal file
37
backend/services/accounting/app/api/deps.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"""Common API dependencies."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends, Query, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from shared.exceptions import TenantNotResolvedError
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_db",
|
||||||
|
"get_pagination",
|
||||||
|
"require_tenant",
|
||||||
|
"get_current_user",
|
||||||
|
"AsyncSession",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_pagination(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
) -> PaginationParams:
|
||||||
|
return PaginationParams(page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
def require_tenant(request: Request) -> UUID:
|
||||||
|
tenant_id = getattr(request.state, "tenant_id", None)
|
||||||
|
if tenant_id is None:
|
||||||
|
raise TenantNotResolvedError(
|
||||||
|
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
|
||||||
|
)
|
||||||
|
return tenant_id
|
||||||
33
backend/services/accounting/app/api/v1/__init__.py
Normal file
33
backend/services/accounting/app/api/v1/__init__.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import (
|
||||||
|
accounts,
|
||||||
|
compliance,
|
||||||
|
fiscal,
|
||||||
|
fixed_assets,
|
||||||
|
health,
|
||||||
|
ledger,
|
||||||
|
payroll,
|
||||||
|
posting,
|
||||||
|
purchase_inventory,
|
||||||
|
receivable_payable,
|
||||||
|
reporting,
|
||||||
|
sales_accounting,
|
||||||
|
setup,
|
||||||
|
treasury,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(accounts.router, prefix="/accounts", tags=["accounts"])
|
||||||
|
api_router.include_router(fiscal.router, prefix="/fiscal", tags=["fiscal"])
|
||||||
|
api_router.include_router(posting.router, prefix="/posting", tags=["posting"])
|
||||||
|
api_router.include_router(ledger.router, prefix="/ledger", tags=["ledger"])
|
||||||
|
api_router.include_router(treasury.router, prefix="/treasury", tags=["treasury"])
|
||||||
|
api_router.include_router(receivable_payable.router, prefix="/ar-ap", tags=["ar-ap"])
|
||||||
|
api_router.include_router(sales_accounting.router, prefix="/sales-accounting", tags=["sales-accounting"])
|
||||||
|
api_router.include_router(purchase_inventory.router, prefix="/purchase-inventory", tags=["purchase-inventory"])
|
||||||
|
api_router.include_router(fixed_assets.router, prefix="/assets", tags=["assets"])
|
||||||
|
api_router.include_router(payroll.router, prefix="/payroll", tags=["payroll"])
|
||||||
|
api_router.include_router(reporting.router, prefix="/reporting", tags=["reporting"])
|
||||||
|
api_router.include_router(compliance.router, prefix="/compliance", tags=["compliance"])
|
||||||
|
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
|
||||||
482
backend/services/accounting/app/api/v1/accounts.py
Normal file
482
backend/services/accounting/app/api/v1/accounts.py
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
"""Foundation CRUD API — Phase 5.1 + PATCH/archive + COA templates."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.foundation import Account, ChartOfAccounts, CostCenter, Currency, Project
|
||||||
|
from app.models.types import AccountStatus
|
||||||
|
from app.repositories.foundation import (
|
||||||
|
AccountRepository,
|
||||||
|
ChartOfAccountsRepository,
|
||||||
|
CostCenterRepository,
|
||||||
|
CurrencyRepository,
|
||||||
|
ProjectRepository,
|
||||||
|
)
|
||||||
|
from app.schemas.foundation import (
|
||||||
|
AccountCreate,
|
||||||
|
AccountRead,
|
||||||
|
AccountUpdate,
|
||||||
|
ChartOfAccountsCreate,
|
||||||
|
ChartOfAccountsRead,
|
||||||
|
ChartOfAccountsUpdate,
|
||||||
|
CoaTemplateImportRequest,
|
||||||
|
CoaTemplateRead,
|
||||||
|
CostCenterCreate,
|
||||||
|
CostCenterRead,
|
||||||
|
CostCenterUpdate,
|
||||||
|
CurrencyCreate,
|
||||||
|
CurrencyRead,
|
||||||
|
CurrencyUpdate,
|
||||||
|
ProjectCreate,
|
||||||
|
ProjectRead,
|
||||||
|
ProjectUpdate,
|
||||||
|
)
|
||||||
|
from app.services.coa_templates import COA_TEMPLATES, import_coa_template
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_update(entity, data: dict) -> None:
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Charts ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/charts", response_model=ChartOfAccountsRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_chart(
|
||||||
|
body: ChartOfAccountsCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ChartOfAccountsRepository(db)
|
||||||
|
if body.is_default:
|
||||||
|
for existing in await repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
existing.is_default = False
|
||||||
|
entity = ChartOfAccounts(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/charts", response_model=list[ChartOfAccountsRead])
|
||||||
|
async def list_charts(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ChartOfAccountsRepository(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/charts/{chart_id}", response_model=ChartOfAccountsRead)
|
||||||
|
async def get_chart(
|
||||||
|
chart_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
entity = await ChartOfAccountsRepository(db).get(tenant_id, chart_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/charts/{chart_id}", response_model=ChartOfAccountsRead)
|
||||||
|
async def update_chart(
|
||||||
|
chart_id: UUID,
|
||||||
|
body: ChartOfAccountsUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ChartOfAccountsRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, chart_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
if data.get("is_default") is True:
|
||||||
|
for existing in await repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
if existing.id != chart_id:
|
||||||
|
existing.is_default = False
|
||||||
|
_apply_update(entity, data)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/charts/{chart_id}/archive", response_model=ChartOfAccountsRead)
|
||||||
|
async def archive_chart(
|
||||||
|
chart_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ChartOfAccountsRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, chart_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
|
||||||
|
entity.is_active = False
|
||||||
|
entity.is_default = False
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
# ── Templates (static paths before /{account_id}) ────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=list[CoaTemplateRead])
|
||||||
|
async def list_coa_templates(_user: CurrentUser = Depends(get_current_user)):
|
||||||
|
return list(COA_TEMPLATES.values())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates/{template_id}", response_model=CoaTemplateRead)
|
||||||
|
async def get_coa_template(template_id: str, _user: CurrentUser = Depends(get_current_user)):
|
||||||
|
tpl = COA_TEMPLATES.get(template_id)
|
||||||
|
if tpl is None:
|
||||||
|
raise NotFoundError("قالب یافت نشد", error_code="template_not_found")
|
||||||
|
return tpl
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/templates/{template_id}/import",
|
||||||
|
response_model=list[AccountRead],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def import_template(
|
||||||
|
template_id: str,
|
||||||
|
body: CoaTemplateImportRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
accounts = await import_coa_template(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
template_id=template_id,
|
||||||
|
chart_id=body.chart_id,
|
||||||
|
chart_name=body.chart_name,
|
||||||
|
chart_code=body.chart_code,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
# ── Currencies ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/currencies", response_model=CurrencyRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_currency(
|
||||||
|
body: CurrencyCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CurrencyRepository(db)
|
||||||
|
if body.is_base:
|
||||||
|
for existing in await repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
existing.is_base = False
|
||||||
|
entity = Currency(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/currencies", response_model=list[CurrencyRead])
|
||||||
|
async def list_currencies(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CurrencyRepository(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/currencies/{currency_id}", response_model=CurrencyRead)
|
||||||
|
async def update_currency(
|
||||||
|
currency_id: UUID,
|
||||||
|
body: CurrencyUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CurrencyRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, currency_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("ارز یافت نشد", error_code="currency_not_found")
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
if data.get("is_base") is True:
|
||||||
|
for existing in await repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
if existing.id != currency_id:
|
||||||
|
existing.is_base = False
|
||||||
|
_apply_update(entity, data)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/currencies/{currency_id}/set-base", response_model=CurrencyRead)
|
||||||
|
async def set_base_currency(
|
||||||
|
currency_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CurrencyRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, currency_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("ارز یافت نشد", error_code="currency_not_found")
|
||||||
|
if not entity.is_active:
|
||||||
|
raise AppError(
|
||||||
|
"ارز غیرفعال نمیتواند پایه باشد",
|
||||||
|
status_code=422,
|
||||||
|
error_code="currency_inactive",
|
||||||
|
)
|
||||||
|
for existing in await repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
existing.is_base = existing.id == currency_id
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/currencies/{currency_id}/archive", response_model=CurrencyRead)
|
||||||
|
async def archive_currency(
|
||||||
|
currency_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CurrencyRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, currency_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("ارز یافت نشد", error_code="currency_not_found")
|
||||||
|
if entity.is_base:
|
||||||
|
raise AppError(
|
||||||
|
"ارز پایه قابل آرشیو نیست",
|
||||||
|
status_code=422,
|
||||||
|
error_code="base_currency_archive",
|
||||||
|
)
|
||||||
|
entity.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cost centers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/cost-centers", response_model=CostCenterRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_cost_center(
|
||||||
|
body: CostCenterCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CostCenterRepository(db)
|
||||||
|
entity = CostCenter(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cost-centers", response_model=list[CostCenterRead])
|
||||||
|
async def list_cost_centers(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CostCenterRepository(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/cost-centers/{cost_center_id}", response_model=CostCenterRead)
|
||||||
|
async def update_cost_center(
|
||||||
|
cost_center_id: UUID,
|
||||||
|
body: CostCenterUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CostCenterRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, cost_center_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("مرکز هزینه یافت نشد", error_code="cost_center_not_found")
|
||||||
|
_apply_update(entity, body.model_dump(exclude_unset=True))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cost-centers/{cost_center_id}/archive", response_model=CostCenterRead)
|
||||||
|
async def archive_cost_center(
|
||||||
|
cost_center_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CostCenterRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, cost_center_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("مرکز هزینه یافت نشد", error_code="cost_center_not_found")
|
||||||
|
entity.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
# ── Projects ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_project(
|
||||||
|
body: ProjectCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ProjectRepository(db)
|
||||||
|
entity = Project(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects", response_model=list[ProjectRead])
|
||||||
|
async def list_projects(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ProjectRepository(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/projects/{project_id}", response_model=ProjectRead)
|
||||||
|
async def update_project(
|
||||||
|
project_id: UUID,
|
||||||
|
body: ProjectUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ProjectRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, project_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("پروژه یافت نشد", error_code="project_not_found")
|
||||||
|
_apply_update(entity, body.model_dump(exclude_unset=True))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/archive", response_model=ProjectRead)
|
||||||
|
async def archive_project(
|
||||||
|
project_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ProjectRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, project_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("پروژه یافت نشد", error_code="project_not_found")
|
||||||
|
entity.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
# ── Accounts (param routes last) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("", response_model=AccountRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_account(
|
||||||
|
body: AccountCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AccountRepository(db)
|
||||||
|
entity = Account(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AccountRead])
|
||||||
|
async def list_accounts(
|
||||||
|
chart_id: UUID | None = Query(default=None),
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AccountRepository(db)
|
||||||
|
if chart_id is not None:
|
||||||
|
return await repo.list_by_chart(
|
||||||
|
tenant_id, chart_id, offset=pagination.offset, limit=pagination.page_size
|
||||||
|
)
|
||||||
|
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{account_id}", response_model=AccountRead)
|
||||||
|
async def get_account(
|
||||||
|
account_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AccountRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, account_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{account_id}", response_model=AccountRead)
|
||||||
|
async def update_account(
|
||||||
|
account_id: UUID,
|
||||||
|
body: AccountUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AccountRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, account_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
|
||||||
|
_apply_update(entity, body.model_dump(exclude_unset=True))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{account_id}/archive", response_model=AccountRead)
|
||||||
|
async def archive_account(
|
||||||
|
account_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AccountRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, account_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
|
||||||
|
entity.status = AccountStatus.INACTIVE
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
178
backend/services/accounting/app/api/v1/compliance.py
Normal file
178
backend/services/accounting/app/api/v1/compliance.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
"""Phase 5.11 — Compliance, Audit & Governance API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.compliance import CompliancePolicy, GovernanceRule
|
||||||
|
from app.models.types import PolicyViolationSeverity, RiskLevel
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.services.compliance_service import AuditFramework, ComplianceEngine, GovernanceService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class AuditRecordCreate(BaseModel):
|
||||||
|
action: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
source_module: str | None = None
|
||||||
|
before_value: str | None = None
|
||||||
|
after_value: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CompliancePolicyCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
policy_type: str
|
||||||
|
rules_config: str
|
||||||
|
country_code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRequestCreate(BaseModel):
|
||||||
|
workflow_id: UUID
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class RejectRequest(BaseModel):
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class RiskCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
title: str
|
||||||
|
risk_category: str
|
||||||
|
risk_level: RiskLevel
|
||||||
|
risk_score: int = 0
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CompliancePolicyRepo(TenantBaseRepository[CompliancePolicy]):
|
||||||
|
model = CompliancePolicy
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-records")
|
||||||
|
async def list_audit_records(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination=Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from app.services.compliance_service import AuditRecordRepo
|
||||||
|
|
||||||
|
repo = AuditRecordRepo(db)
|
||||||
|
records = await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(r.id),
|
||||||
|
"action": r.action,
|
||||||
|
"resource_type": r.resource_type,
|
||||||
|
"actor_user_id": r.actor_user_id,
|
||||||
|
"created_at": r.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/audit-records", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_audit_record(
|
||||||
|
body: AuditRecordCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
audit = AuditFramework(db)
|
||||||
|
record = await audit.record(
|
||||||
|
tenant_id,
|
||||||
|
actor_user_id=user.user_id,
|
||||||
|
action=body.action,
|
||||||
|
resource_type=body.resource_type,
|
||||||
|
resource_id=body.resource_id,
|
||||||
|
source_module=body.source_module,
|
||||||
|
before_value=body.before_value,
|
||||||
|
after_value=body.after_value,
|
||||||
|
reason=body.reason,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(record.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/policies", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_compliance_policy(
|
||||||
|
body: CompliancePolicyCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CompliancePolicyRepo(db)
|
||||||
|
entity = CompliancePolicy(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/approvals")
|
||||||
|
async def request_approval(
|
||||||
|
body: ApprovalRequestCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
gov = GovernanceService(db)
|
||||||
|
request = await gov.request_approval(
|
||||||
|
tenant_id,
|
||||||
|
workflow_id=body.workflow_id,
|
||||||
|
resource_type=body.resource_type,
|
||||||
|
resource_id=body.resource_id,
|
||||||
|
requested_by=user.user_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(request.id), "status": request.status.value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/approvals/{request_id}/approve")
|
||||||
|
async def approve_request(
|
||||||
|
request_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
gov = GovernanceService(db)
|
||||||
|
request = await gov.approve(tenant_id, request_id, approver_user_id=user.user_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(request.id), "status": request.status.value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/approvals/{request_id}/reject")
|
||||||
|
async def reject_request(
|
||||||
|
request_id: UUID,
|
||||||
|
body: RejectRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
gov = GovernanceService(db)
|
||||||
|
request = await gov.reject(tenant_id, request_id, approver_user_id=user.user_id, reason=body.reason)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(request.id), "status": request.status.value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/risks", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_risk(
|
||||||
|
body: RiskCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
gov = GovernanceService(db)
|
||||||
|
risk = await gov.create_risk(tenant_id, **body.model_dump())
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(risk.id), "risk_level": risk.risk_level.value}
|
||||||
225
backend/services/accounting/app/api/v1/fiscal.py
Normal file
225
backend/services/accounting/app/api/v1/fiscal.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
"""Fiscal year/period API — Phase 5.1 + 5.3 + PATCH / set-current / generate."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.foundation import FiscalPeriod, FiscalYear
|
||||||
|
from app.repositories.foundation import FiscalPeriodRepository, FiscalYearRepository
|
||||||
|
from app.schemas.foundation import (
|
||||||
|
FiscalPeriodCreate,
|
||||||
|
FiscalPeriodRead,
|
||||||
|
FiscalPeriodUpdate,
|
||||||
|
FiscalYearCreate,
|
||||||
|
FiscalYearRead,
|
||||||
|
FiscalYearUpdate,
|
||||||
|
)
|
||||||
|
from app.services.fiscal_service import FiscalManagementService
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_update(entity, data: dict) -> None:
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/years", response_model=FiscalYearRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_fiscal_year(
|
||||||
|
body: FiscalYearCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = FiscalYearRepository(db)
|
||||||
|
if body.is_current:
|
||||||
|
for y in await repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
y.is_current = False
|
||||||
|
entity = FiscalYear(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/years", response_model=list[FiscalYearRead])
|
||||||
|
async def list_fiscal_years(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = FiscalYearRepository(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, limit=100)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/years/{year_id}", response_model=FiscalYearRead)
|
||||||
|
async def update_fiscal_year(
|
||||||
|
year_id: UUID,
|
||||||
|
body: FiscalYearUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = FiscalYearRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, year_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("سال مالی یافت نشد", error_code="year_not_found")
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
if data.get("is_current") is True:
|
||||||
|
for y in await repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
y.is_current = y.id == year_id
|
||||||
|
data.pop("is_current", None)
|
||||||
|
entity.is_current = True
|
||||||
|
_apply_update(entity, data)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/years/{year_id}/set-current", response_model=FiscalYearRead)
|
||||||
|
async def set_current_year(
|
||||||
|
year_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = FiscalManagementService(db)
|
||||||
|
year = await svc.set_current_year(tenant_id, year_id)
|
||||||
|
await db.commit()
|
||||||
|
return year
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/years/{year_id}/close", response_model=FiscalYearRead)
|
||||||
|
async def close_fiscal_year(
|
||||||
|
year_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = FiscalManagementService(db)
|
||||||
|
year = await svc.close_fiscal_year(tenant_id, year_id)
|
||||||
|
await db.commit()
|
||||||
|
return year
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/years/{year_id}/generate-periods", response_model=list[FiscalPeriodRead])
|
||||||
|
async def generate_periods(
|
||||||
|
year_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = FiscalManagementService(db)
|
||||||
|
periods = await svc.generate_monthly_periods(tenant_id, year_id)
|
||||||
|
await db.commit()
|
||||||
|
return periods
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/periods", response_model=FiscalPeriodRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_fiscal_period(
|
||||||
|
body: FiscalPeriodCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = FiscalPeriodRepository(db)
|
||||||
|
if body.is_current:
|
||||||
|
for p in await repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
p.is_current = False
|
||||||
|
entity = FiscalPeriod(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/periods", response_model=list[FiscalPeriodRead])
|
||||||
|
async def list_fiscal_periods(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = FiscalPeriodRepository(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, limit=100)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/periods/{period_id}", response_model=FiscalPeriodRead)
|
||||||
|
async def update_fiscal_period(
|
||||||
|
period_id: UUID,
|
||||||
|
body: FiscalPeriodUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = FiscalPeriodRepository(db)
|
||||||
|
entity = await repo.get(tenant_id, period_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
if data.get("is_current") is True:
|
||||||
|
for p in await repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
p.is_current = p.id == period_id
|
||||||
|
data.pop("is_current", None)
|
||||||
|
entity.is_current = True
|
||||||
|
_apply_update(entity, data)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/periods/{period_id}/set-current", response_model=FiscalPeriodRead)
|
||||||
|
async def set_current_period(
|
||||||
|
period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = FiscalManagementService(db)
|
||||||
|
period = await svc.set_current_period(tenant_id, period_id)
|
||||||
|
await db.commit()
|
||||||
|
return period
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/periods/{period_id}/lock", response_model=FiscalPeriodRead)
|
||||||
|
async def lock_period(
|
||||||
|
period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = FiscalManagementService(db)
|
||||||
|
period = await svc.lock_period(tenant_id, period_id)
|
||||||
|
await db.commit()
|
||||||
|
return period
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/periods/{period_id}/close", response_model=FiscalPeriodRead)
|
||||||
|
async def close_period(
|
||||||
|
period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = FiscalManagementService(db)
|
||||||
|
period = await svc.close_period(tenant_id, period_id)
|
||||||
|
await db.commit()
|
||||||
|
return period
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/periods/{period_id}/reopen", response_model=FiscalPeriodRead)
|
||||||
|
async def reopen_period(
|
||||||
|
period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = FiscalManagementService(db)
|
||||||
|
period = await svc.reopen_period(tenant_id, period_id)
|
||||||
|
await db.commit()
|
||||||
|
return period
|
||||||
154
backend/services/accounting/app/api/v1/fixed_assets.py
Normal file
154
backend/services/accounting/app/api/v1/fixed_assets.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"""Phase 5.8 — Fixed Assets API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.fixed_assets import Asset, AssetCategory
|
||||||
|
from app.models.types import AssetStatus, DepreciationMethod
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.services.asset_service import AssetAccountingService, DepreciationEngine
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class AssetCategoryCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
default_useful_life_months: int = 60
|
||||||
|
default_depreciation_method: DepreciationMethod = DepreciationMethod.STRAIGHT_LINE
|
||||||
|
|
||||||
|
|
||||||
|
class AssetCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
category_id: UUID | None = None
|
||||||
|
acquisition_cost: Decimal = Decimal("0")
|
||||||
|
residual_value: Decimal = Decimal("0")
|
||||||
|
useful_life_months: int = 60
|
||||||
|
depreciation_method: DepreciationMethod = DepreciationMethod.STRAIGHT_LINE
|
||||||
|
|
||||||
|
|
||||||
|
class DepreciateRequest(BaseModel):
|
||||||
|
expense_account_id: UUID
|
||||||
|
accumulated_account_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class AssetCategoryRepo(TenantBaseRepository[AssetCategory]):
|
||||||
|
model = AssetCategory
|
||||||
|
|
||||||
|
|
||||||
|
class AssetRepo(TenantBaseRepository[Asset]):
|
||||||
|
model = Asset
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_category(
|
||||||
|
body: AssetCategoryCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AssetCategoryRepo(db)
|
||||||
|
entity = AssetCategory(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def list_categories(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cats = await AssetCategoryRepo(db).list_by_tenant(tenant_id, limit=100)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(c.id),
|
||||||
|
"code": c.code,
|
||||||
|
"name": c.name,
|
||||||
|
"default_useful_life_months": c.default_useful_life_months,
|
||||||
|
}
|
||||||
|
for c in cats
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assets", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_asset(
|
||||||
|
body: AssetCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AssetRepo(db)
|
||||||
|
entity = Asset(tenant_id=tenant_id, status=AssetStatus.DRAFT, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id), "code": entity.code}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assets", response_model=list[dict])
|
||||||
|
async def list_assets(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AssetRepo(db)
|
||||||
|
assets = await repo.list_by_tenant(tenant_id, limit=100)
|
||||||
|
return [{"id": str(a.id), "code": a.code, "name": a.name, "status": a.status.value} for a in assets]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assets/{asset_id}/activate")
|
||||||
|
async def activate_asset(
|
||||||
|
asset_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = AssetAccountingService(db)
|
||||||
|
asset = await svc.activate_asset(tenant_id, asset_id, user.user_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(asset.id), "status": asset.status.value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assets/{asset_id}/depreciate")
|
||||||
|
async def depreciate_asset(
|
||||||
|
asset_id: UUID,
|
||||||
|
body: DepreciateRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = AssetAccountingService(db)
|
||||||
|
dep = await svc.depreciate_asset(
|
||||||
|
tenant_id, asset_id,
|
||||||
|
expense_account_id=body.expense_account_id,
|
||||||
|
accumulated_account_id=body.accumulated_account_id,
|
||||||
|
actor_user_id=user.user_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(dep.id), "amount": str(dep.amount), "voucher_id": str(dep.voucher_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assets/{asset_id}/depreciation-schedule")
|
||||||
|
async def get_depreciation_schedule(
|
||||||
|
asset_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AssetRepo(db)
|
||||||
|
asset = await repo.get(tenant_id, asset_id)
|
||||||
|
if asset is None:
|
||||||
|
return {"schedule": []}
|
||||||
|
engine = DepreciationEngine()
|
||||||
|
return {"schedule": engine.generate_schedule(asset)}
|
||||||
10
backend/services/accounting/app/api/v1/health.py
Normal file
10
backend/services/accounting/app/api/v1/health.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app import __version__
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "ok", "service": "accounting-service", "version": __version__}
|
||||||
93
backend/services/accounting/app/api/v1/ledger.py
Normal file
93
backend/services/accounting/app/api/v1/ledger.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""General Ledger & Trial Balance API — Phase 5.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.repositories.ledger import LedgerBalanceRepository
|
||||||
|
from app.services.balance_engine import BalanceEngine
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceRead(BaseModel):
|
||||||
|
account_id: UUID
|
||||||
|
fiscal_period_id: UUID
|
||||||
|
opening_balance: Decimal
|
||||||
|
debit_total: Decimal
|
||||||
|
credit_total: Decimal
|
||||||
|
closing_balance: Decimal
|
||||||
|
current_balance: Decimal
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TrialBalanceRead(BaseModel):
|
||||||
|
fiscal_period_id: UUID
|
||||||
|
total_debit: Decimal
|
||||||
|
total_credit: Decimal
|
||||||
|
difference: Decimal
|
||||||
|
is_balanced: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balances/{account_id}", response_model=BalanceRead | None)
|
||||||
|
async def get_account_balance(
|
||||||
|
account_id: UUID,
|
||||||
|
fiscal_period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = LedgerBalanceRepository(db)
|
||||||
|
balance = await repo.get_by_account_period(tenant_id, account_id, fiscal_period_id)
|
||||||
|
return balance
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balances", response_model=list[BalanceRead])
|
||||||
|
async def list_period_balances(
|
||||||
|
fiscal_period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = LedgerBalanceRepository(db)
|
||||||
|
return await repo.list_by_period(tenant_id, fiscal_period_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trial-balance", response_model=TrialBalanceRead)
|
||||||
|
async def generate_trial_balance(
|
||||||
|
fiscal_period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = BalanceEngine(db)
|
||||||
|
snapshot = await engine.generate_trial_balance(tenant_id, fiscal_period_id)
|
||||||
|
await db.commit()
|
||||||
|
return TrialBalanceRead(
|
||||||
|
fiscal_period_id=snapshot.fiscal_period_id,
|
||||||
|
total_debit=snapshot.total_debit,
|
||||||
|
total_credit=snapshot.total_credit,
|
||||||
|
difference=snapshot.difference,
|
||||||
|
is_balanced=snapshot.is_balanced,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recalculate")
|
||||||
|
async def recalculate_balances(
|
||||||
|
fiscal_period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = BalanceEngine(db)
|
||||||
|
count = await engine.recalculate_period(tenant_id, fiscal_period_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"recalculated_accounts": count}
|
||||||
185
backend/services/accounting/app/api/v1/payroll.py
Normal file
185
backend/services/accounting/app/api/v1/payroll.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"""Phase 5.9 — Payroll API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.payroll import Department, Employee, PayrollPeriod
|
||||||
|
from app.models.types import EmploymentStatus, PayrollPeriodStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.services.payroll_service import PayrollAccountingService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeCreate(BaseModel):
|
||||||
|
employee_code: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
department_id: UUID | None = None
|
||||||
|
base_salary: Decimal = Decimal("0")
|
||||||
|
hire_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollPeriodCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
|
||||||
|
|
||||||
|
class CalculatePayrollRequest(BaseModel):
|
||||||
|
payroll_period_id: UUID
|
||||||
|
employee_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class PostPayrollRequest(BaseModel):
|
||||||
|
profile_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentRepo(TenantBaseRepository[Department]):
|
||||||
|
model = Department
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeRepo(TenantBaseRepository[Employee]):
|
||||||
|
model = Employee
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollPeriodRepo(TenantBaseRepository[PayrollPeriod]):
|
||||||
|
model = PayrollPeriod
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/departments", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_department(
|
||||||
|
body: DepartmentCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = DepartmentRepo(db)
|
||||||
|
entity = Department(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/departments")
|
||||||
|
async def list_departments(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
deps = await DepartmentRepo(db).list_by_tenant(tenant_id, limit=100)
|
||||||
|
return [{"id": str(d.id), "code": d.code, "name": d.name} for d in deps]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/employees", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_employee(
|
||||||
|
body: EmployeeCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = EmployeeRepo(db)
|
||||||
|
entity = Employee(tenant_id=tenant_id, employment_status=EmploymentStatus.ACTIVE, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id), "employee_code": entity.employee_code}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees")
|
||||||
|
async def list_employees(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = EmployeeRepo(db)
|
||||||
|
employees = await repo.list_by_tenant(tenant_id, limit=100)
|
||||||
|
return [{"id": str(e.id), "code": e.employee_code, "name": f"{e.first_name} {e.last_name}"} for e in employees]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/periods", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_payroll_period(
|
||||||
|
body: PayrollPeriodCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = PayrollPeriodRepo(db)
|
||||||
|
entity = PayrollPeriod(tenant_id=tenant_id, status=PayrollPeriodStatus.OPEN, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/periods")
|
||||||
|
async def list_payroll_periods(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
periods = await PayrollPeriodRepo(db).list_by_tenant(tenant_id, limit=100)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"start_date": p.start_date.isoformat(),
|
||||||
|
"end_date": p.end_date.isoformat(),
|
||||||
|
"status": p.status.value,
|
||||||
|
}
|
||||||
|
for p in periods
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calculate")
|
||||||
|
async def calculate_payroll(
|
||||||
|
body: CalculatePayrollRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = PayrollAccountingService(db)
|
||||||
|
payroll = await svc.calculate_payroll(tenant_id, body.payroll_period_id, body.employee_id)
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"id": str(payroll.id),
|
||||||
|
"gross_salary": str(payroll.gross_salary),
|
||||||
|
"net_salary": str(payroll.net_salary),
|
||||||
|
"status": payroll.status.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/payrolls/{payroll_id}/post")
|
||||||
|
async def post_payroll(
|
||||||
|
payroll_id: UUID,
|
||||||
|
body: PostPayrollRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from app.models.types import PayrollStatus
|
||||||
|
from app.services.payroll_service import PayrollRepo
|
||||||
|
|
||||||
|
payroll_repo = PayrollRepo(db)
|
||||||
|
payroll = await payroll_repo.get(tenant_id, payroll_id)
|
||||||
|
if payroll is None:
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
raise NotFoundError("حقوق یافت نشد", error_code="payroll_not_found")
|
||||||
|
payroll.status = PayrollStatus.APPROVED
|
||||||
|
|
||||||
|
svc = PayrollAccountingService(db)
|
||||||
|
result = await svc.post_payroll(tenant_id, payroll_id, actor_user_id=user.user_id, profile_id=body.profile_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(result.id), "status": result.status.value, "voucher_id": str(result.voucher_id)}
|
||||||
202
backend/services/accounting/app/api/v1/posting.py
Normal file
202
backend/services/accounting/app/api/v1/posting.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
"""Posting Engine API — Phase 5.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.posting import Voucher, VoucherLine
|
||||||
|
from app.models.types import VoucherStatus
|
||||||
|
from app.repositories.posting import AuditLogRepository, VoucherRepository
|
||||||
|
from app.schemas.posting import (
|
||||||
|
AuditLogRead,
|
||||||
|
PostRequest,
|
||||||
|
ReverseRequest,
|
||||||
|
VoucherCreate,
|
||||||
|
VoucherRead,
|
||||||
|
VoucherUpdate,
|
||||||
|
)
|
||||||
|
from app.services.posting_engine import PostingEngine
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vouchers", response_model=list[VoucherRead])
|
||||||
|
async def list_vouchers(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Voucher)
|
||||||
|
.options(selectinload(Voucher.lines))
|
||||||
|
.where(Voucher.tenant_id == tenant_id)
|
||||||
|
.offset(pagination.offset)
|
||||||
|
.limit(pagination.page_size)
|
||||||
|
.order_by(Voucher.created_at.desc())
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vouchers", response_model=VoucherRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_voucher(
|
||||||
|
body: VoucherCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = VoucherRepository(db)
|
||||||
|
voucher = Voucher(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fiscal_period_id=body.fiscal_period_id,
|
||||||
|
voucher_number=body.voucher_number,
|
||||||
|
voucher_date=body.voucher_date,
|
||||||
|
status=VoucherStatus.DRAFT,
|
||||||
|
description=body.description,
|
||||||
|
reference_number=body.reference_number,
|
||||||
|
source_module=body.source_module,
|
||||||
|
created_by=user.user_id,
|
||||||
|
)
|
||||||
|
await repo.add(voucher)
|
||||||
|
for i, line in enumerate(body.lines, start=1):
|
||||||
|
db.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
voucher_id=voucher.id,
|
||||||
|
line_number=i,
|
||||||
|
**line.model_dump(),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
result = await repo.get_with_lines(tenant_id, voucher.id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vouchers/{voucher_id}", response_model=VoucherRead)
|
||||||
|
async def get_voucher(
|
||||||
|
voucher_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = VoucherRepository(db)
|
||||||
|
voucher = await repo.get_with_lines(tenant_id, voucher_id)
|
||||||
|
if voucher is None:
|
||||||
|
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
|
||||||
|
return voucher
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/vouchers/{voucher_id}", response_model=VoucherRead)
|
||||||
|
async def update_draft_voucher(
|
||||||
|
voucher_id: UUID,
|
||||||
|
body: VoucherUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = VoucherRepository(db)
|
||||||
|
voucher = await repo.get_with_lines(tenant_id, voucher_id)
|
||||||
|
if voucher is None:
|
||||||
|
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
|
||||||
|
if voucher.status != VoucherStatus.DRAFT:
|
||||||
|
raise AppError(
|
||||||
|
"فقط اسناد پیشنویس قابل ویرایش هستند",
|
||||||
|
status_code=422,
|
||||||
|
error_code="voucher_not_draft",
|
||||||
|
)
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
lines_data = data.pop("lines", None)
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(voucher, key, value)
|
||||||
|
if lines_data is not None:
|
||||||
|
for line in list(voucher.lines):
|
||||||
|
await db.delete(line)
|
||||||
|
await db.flush()
|
||||||
|
for i, line in enumerate(lines_data, start=1):
|
||||||
|
db.add(
|
||||||
|
VoucherLine(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
voucher_id=voucher.id,
|
||||||
|
line_number=i,
|
||||||
|
**line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await repo.get_with_lines(tenant_id, voucher_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vouchers/{voucher_id}/validate", response_model=VoucherRead)
|
||||||
|
async def validate_voucher(
|
||||||
|
voucher_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = PostingEngine(db)
|
||||||
|
voucher = await engine.validate_voucher(tenant_id, voucher_id)
|
||||||
|
await db.commit()
|
||||||
|
return await VoucherRepository(db).get_with_lines(tenant_id, voucher.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vouchers/{voucher_id}/post", response_model=VoucherRead)
|
||||||
|
async def post_voucher(
|
||||||
|
voucher_id: UUID,
|
||||||
|
body: PostRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = PostingEngine(db)
|
||||||
|
voucher = await engine.post_voucher(
|
||||||
|
tenant_id, voucher_id, actor_user_id=user.user_id, source_module=body.source_module
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await VoucherRepository(db).get_with_lines(tenant_id, voucher.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vouchers/{voucher_id}/reverse", response_model=VoucherRead)
|
||||||
|
async def reverse_voucher(
|
||||||
|
voucher_id: UUID,
|
||||||
|
body: ReverseRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = PostingEngine(db)
|
||||||
|
voucher = await engine.reverse_voucher(
|
||||||
|
tenant_id, voucher_id, actor_user_id=user.user_id, reversal_date=body.reversal_date
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await VoucherRepository(db).get_with_lines(tenant_id, voucher.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vouchers/{voucher_id}/cancel", response_model=VoucherRead)
|
||||||
|
async def cancel_voucher(
|
||||||
|
voucher_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = PostingEngine(db)
|
||||||
|
voucher = await engine.cancel_voucher(tenant_id, voucher_id, actor_user_id=user.user_id)
|
||||||
|
await db.commit()
|
||||||
|
return voucher
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-logs", response_model=list[AuditLogRead])
|
||||||
|
async def list_audit_logs(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = AuditLogRepository(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, limit=100)
|
||||||
106
backend/services/accounting/app/api/v1/purchase_inventory.py
Normal file
106
backend/services/accounting/app/api/v1/purchase_inventory.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""Phase 5.7 — Purchase & Inventory accounting API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.purchase_inventory import InventoryPostingProfile, PurchasePostingProfile
|
||||||
|
from app.models.types import InventoryValuationMethod
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.services.purchase_inventory_service import PurchaseInventoryAccountingService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseProfileCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
inventory_account_id: UUID | None = None
|
||||||
|
grni_account_id: UUID | None = None
|
||||||
|
liability_account_id: UUID | None = None
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class GoodsReceiptRequest(BaseModel):
|
||||||
|
source_document_id: str
|
||||||
|
amount: Decimal
|
||||||
|
profile_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ValuationRequest(BaseModel):
|
||||||
|
item_id: UUID
|
||||||
|
quantity: Decimal
|
||||||
|
unit_cost: Decimal
|
||||||
|
method: InventoryValuationMethod = InventoryValuationMethod.WEIGHTED_AVERAGE
|
||||||
|
warehouse_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseProfileRepo(TenantBaseRepository[PurchasePostingProfile]):
|
||||||
|
model = PurchasePostingProfile
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posting-profiles", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_purchase_profile(
|
||||||
|
body: PurchaseProfileCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = PurchaseProfileRepo(db)
|
||||||
|
entity = PurchasePostingProfile(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id), "name": entity.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview/goods-receipt")
|
||||||
|
async def preview_goods_receipt(
|
||||||
|
body: GoodsReceiptRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = PurchaseInventoryAccountingService(db)
|
||||||
|
preview = await svc.preview_goods_receipt(tenant_id, body.amount, body.profile_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"is_balanced": preview.is_balanced, "total_debit": str(preview.total_debit)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/post/goods-receipt")
|
||||||
|
async def post_goods_receipt(
|
||||||
|
body: GoodsReceiptRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = PurchaseInventoryAccountingService(db)
|
||||||
|
voucher = await svc.post_goods_receipt(
|
||||||
|
tenant_id,
|
||||||
|
source_document_id=body.source_document_id,
|
||||||
|
amount=body.amount,
|
||||||
|
actor_user_id=user.user_id,
|
||||||
|
profile_id=body.profile_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"voucher_id": str(voucher.id), "status": voucher.status.value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/valuation")
|
||||||
|
async def record_valuation(
|
||||||
|
body: ValuationRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = PurchaseInventoryAccountingService(db)
|
||||||
|
record = await svc.record_valuation(
|
||||||
|
tenant_id, body.item_id, body.quantity, body.unit_cost, body.method, body.warehouse_id
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(record.id), "total_value": str(record.total_value)}
|
||||||
267
backend/services/accounting/app/api/v1/receivable_payable.py
Normal file
267
backend/services/accounting/app/api/v1/receivable_payable.py
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
"""AR/AP API — Phase 5.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.receivable_payable import CustomerAccount, ReceivableInvoice, SupplierAccount
|
||||||
|
from app.models.types import InvoiceStatus, SettlementStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.services.aging_engine import AgingEngine
|
||||||
|
from app.services.settlement_engine import SettlementEngine, SettlementRepository
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
credit_limit: Decimal | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
outstanding_balance: Decimal
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ReceivableInvoiceCreate(BaseModel):
|
||||||
|
customer_id: UUID
|
||||||
|
invoice_number: str
|
||||||
|
invoice_date: date
|
||||||
|
due_date: date | None = None
|
||||||
|
total_amount: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementCreate(BaseModel):
|
||||||
|
settlement_number: str
|
||||||
|
settlement_date: date
|
||||||
|
settlement_type: str
|
||||||
|
party_type: str
|
||||||
|
party_id: UUID
|
||||||
|
total_amount: Decimal
|
||||||
|
allocations: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRepo(TenantBaseRepository[CustomerAccount]):
|
||||||
|
model = CustomerAccount
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierRepo(TenantBaseRepository[SupplierAccount]):
|
||||||
|
model = SupplierAccount
|
||||||
|
|
||||||
|
|
||||||
|
class ReceivableRepo(TenantBaseRepository[ReceivableInvoice]):
|
||||||
|
model = ReceivableInvoice
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/customers", response_model=CustomerRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_customer(
|
||||||
|
body: CustomerCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CustomerRepo(db)
|
||||||
|
entity = CustomerAccount(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers", response_model=list[CustomerRead])
|
||||||
|
async def list_customers(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CustomerRepo(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, limit=100)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/customers/{customer_id}", response_model=CustomerRead)
|
||||||
|
async def update_customer(
|
||||||
|
customer_id: UUID,
|
||||||
|
body: dict,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
repo = CustomerRepo(db)
|
||||||
|
entity = await repo.get(tenant_id, customer_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("مشتری یافت نشد", error_code="customer_not_found")
|
||||||
|
if "name" in body and body["name"] is not None:
|
||||||
|
entity.name = body["name"]
|
||||||
|
if "credit_limit" in body:
|
||||||
|
entity.credit_limit = body["credit_limit"]
|
||||||
|
if "is_active" in body and body["is_active"] is not None:
|
||||||
|
entity.is_active = bool(body["is_active"])
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{customer_id}", response_model=CustomerRead)
|
||||||
|
async def get_customer(
|
||||||
|
customer_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
entity = await CustomerRepo(db).get(tenant_id, customer_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("مشتری یافت نشد", error_code="customer_not_found")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/suppliers")
|
||||||
|
async def list_suppliers(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
suppliers = await SupplierRepo(db).list_by_tenant(tenant_id, limit=100)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(s.id),
|
||||||
|
"code": s.code,
|
||||||
|
"name": s.name,
|
||||||
|
"outstanding_balance": str(s.outstanding_balance),
|
||||||
|
"is_active": s.is_active,
|
||||||
|
}
|
||||||
|
for s in suppliers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/suppliers", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_supplier(
|
||||||
|
body: CustomerCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = SupplierRepo(db)
|
||||||
|
entity = SupplierAccount(tenant_id=tenant_id, code=body.code, name=body.name)
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id), "code": entity.code, "name": entity.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/receivable-invoices")
|
||||||
|
async def list_receivable_invoices(
|
||||||
|
customer_id: UUID | None = None,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
invoices = await ReceivableRepo(db).list_by_tenant(tenant_id, limit=200)
|
||||||
|
rows = invoices
|
||||||
|
if customer_id is not None:
|
||||||
|
rows = [i for i in invoices if i.customer_id == customer_id]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(i.id),
|
||||||
|
"customer_id": str(i.customer_id),
|
||||||
|
"invoice_number": i.invoice_number,
|
||||||
|
"invoice_date": i.invoice_date.isoformat(),
|
||||||
|
"due_date": i.due_date.isoformat() if i.due_date else None,
|
||||||
|
"total_amount": str(i.total_amount),
|
||||||
|
"remaining_amount": str(i.remaining_amount),
|
||||||
|
"status": i.status.value,
|
||||||
|
}
|
||||||
|
for i in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/receivable-invoices", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_receivable_invoice(
|
||||||
|
body: ReceivableInvoiceCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ReceivableRepo(db)
|
||||||
|
entity = ReceivableInvoice(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
customer_id=body.customer_id,
|
||||||
|
invoice_number=body.invoice_number,
|
||||||
|
invoice_date=body.invoice_date,
|
||||||
|
due_date=body.due_date,
|
||||||
|
total_amount=body.total_amount,
|
||||||
|
paid_amount=Decimal("0"),
|
||||||
|
remaining_amount=body.total_amount,
|
||||||
|
status=InvoiceStatus.ISSUED,
|
||||||
|
)
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settlements")
|
||||||
|
async def create_settlement(
|
||||||
|
body: SettlementCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from app.models.receivable_payable import Settlement
|
||||||
|
|
||||||
|
settlement = Settlement(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
settlement_number=body.settlement_number,
|
||||||
|
settlement_date=body.settlement_date,
|
||||||
|
settlement_type=body.settlement_type,
|
||||||
|
party_type=body.party_type,
|
||||||
|
party_id=body.party_id,
|
||||||
|
total_amount=body.total_amount,
|
||||||
|
status=SettlementStatus.DRAFT,
|
||||||
|
)
|
||||||
|
repo = SettlementRepository(db)
|
||||||
|
await repo.add(settlement)
|
||||||
|
|
||||||
|
engine = SettlementEngine(db)
|
||||||
|
if body.party_type == "customer":
|
||||||
|
settlement = await engine.allocate_receivable(tenant_id, settlement, body.allocations)
|
||||||
|
else:
|
||||||
|
settlement = await engine.allocate_payable(tenant_id, settlement, body.allocations)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(settlement.id), "status": settlement.status.value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{customer_id}/aging")
|
||||||
|
async def get_customer_aging(
|
||||||
|
customer_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = AgingEngine(db)
|
||||||
|
snapshot = await engine.calculate_customer_aging(tenant_id, customer_id)
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"current": str(snapshot.current_amount),
|
||||||
|
"days_1_30": str(snapshot.days_1_30),
|
||||||
|
"days_31_60": str(snapshot.days_31_60),
|
||||||
|
"days_61_90": str(snapshot.days_61_90),
|
||||||
|
"days_90_plus": str(snapshot.days_90_plus),
|
||||||
|
"total": str(snapshot.total_outstanding),
|
||||||
|
}
|
||||||
123
backend/services/accounting/app/api/v1/reporting.py
Normal file
123
backend/services/accounting/app/api/v1/reporting.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"""Phase 5.10 — Financial Reporting API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.reporting import Dashboard, ReportTemplate
|
||||||
|
from app.models.types import ReportExportFormat, ReportType
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.services.reporting_service import FinancialReportEngine
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ReportTemplateCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
report_type: ReportType
|
||||||
|
template_config: str
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
dashboard_type: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExportRequest(BaseModel):
|
||||||
|
export_format: ReportExportFormat = ReportExportFormat.JSON
|
||||||
|
|
||||||
|
|
||||||
|
class ReportTemplateRepo(TenantBaseRepository[ReportTemplate]):
|
||||||
|
model = ReportTemplate
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardRepo(TenantBaseRepository[Dashboard]):
|
||||||
|
model = Dashboard
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trial-balance")
|
||||||
|
async def generate_trial_balance(
|
||||||
|
fiscal_period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = FinancialReportEngine(db)
|
||||||
|
report = await engine.generate_trial_balance_report(tenant_id, fiscal_period_id, generated_by=user.user_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(report.id), "report_type": report.report_type.value, "data": report.report_data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/balance-sheet")
|
||||||
|
async def generate_balance_sheet(
|
||||||
|
fiscal_period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = FinancialReportEngine(db)
|
||||||
|
report = await engine.generate_balance_sheet(tenant_id, fiscal_period_id, generated_by=user.user_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(report.id), "data": report.report_data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/income-statement")
|
||||||
|
async def generate_income_statement(
|
||||||
|
fiscal_period_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = FinancialReportEngine(db)
|
||||||
|
report = await engine.generate_income_statement(tenant_id, fiscal_period_id, generated_by=user.user_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(report.id), "data": report.report_data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reports/{report_id}/export")
|
||||||
|
async def export_report(
|
||||||
|
report_id: UUID,
|
||||||
|
body: ExportRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engine = FinancialReportEngine(db)
|
||||||
|
export = await engine.export_report(tenant_id, report_id, body.export_format, exported_by=user.user_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(export.id), "format": export.export_format.value, "file_path": export.file_path}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboards")
|
||||||
|
async def create_dashboard(
|
||||||
|
body: DashboardCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = DashboardRepo(db)
|
||||||
|
entity = Dashboard(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates")
|
||||||
|
async def create_report_template(
|
||||||
|
body: ReportTemplateCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = ReportTemplateRepo(db)
|
||||||
|
entity = ReportTemplate(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id)}
|
||||||
134
backend/services/accounting/app/api/v1/sales_accounting.py
Normal file
134
backend/services/accounting/app/api/v1/sales_accounting.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""Sales Accounting Integration API — Phase 5.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.sales_accounting import SalesPostingProfile
|
||||||
|
from app.models.types import SalesDocumentType
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.services.sales_accounting_service import SalesAccountingService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SalesPostingProfileCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
document_type: SalesDocumentType
|
||||||
|
revenue_account_id: UUID | None = None
|
||||||
|
receivable_account_id: UUID | None = None
|
||||||
|
cash_account_id: UUID | None = None
|
||||||
|
discount_account_id: UUID | None = None
|
||||||
|
tax_account_id: UUID | None = None
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SalesPostingProfileRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
name: str
|
||||||
|
document_type: SalesDocumentType
|
||||||
|
is_default: bool
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SalesPreviewRequest(BaseModel):
|
||||||
|
document_type: SalesDocumentType = SalesDocumentType.INVOICE
|
||||||
|
total_amount: Decimal
|
||||||
|
discount_amount: Decimal = Decimal("0")
|
||||||
|
tax_amount: Decimal = Decimal("0")
|
||||||
|
profile_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SalesPostRequest(BaseModel):
|
||||||
|
document_type: SalesDocumentType = SalesDocumentType.INVOICE
|
||||||
|
source_document_id: str
|
||||||
|
total_amount: Decimal
|
||||||
|
discount_amount: Decimal = Decimal("0")
|
||||||
|
tax_amount: Decimal = Decimal("0")
|
||||||
|
profile_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SalesProfileRepo(TenantBaseRepository[SalesPostingProfile]):
|
||||||
|
model = SalesPostingProfile
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posting-profiles", response_model=SalesPostingProfileRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_posting_profile(
|
||||||
|
body: SalesPostingProfileCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = SalesProfileRepo(db)
|
||||||
|
entity = SalesPostingProfile(tenant_id=tenant_id, **body.model_dump())
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posting-profiles", response_model=list[SalesPostingProfileRead])
|
||||||
|
async def list_posting_profiles(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = SalesProfileRepo(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, limit=100)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
async def preview_accounting(
|
||||||
|
body: SalesPreviewRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = SalesAccountingService(db)
|
||||||
|
preview = await svc.preview_sales_invoice(
|
||||||
|
tenant_id,
|
||||||
|
document_type=body.document_type,
|
||||||
|
total_amount=body.total_amount,
|
||||||
|
discount_amount=body.discount_amount,
|
||||||
|
tax_amount=body.tax_amount,
|
||||||
|
profile_id=body.profile_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"is_balanced": preview.is_balanced,
|
||||||
|
"total_debit": str(preview.total_debit),
|
||||||
|
"total_credit": str(preview.total_credit),
|
||||||
|
"preview_data": preview.preview_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/post")
|
||||||
|
async def post_sales_accounting(
|
||||||
|
body: SalesPostRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = SalesAccountingService(db)
|
||||||
|
voucher = await svc.post_sales_invoice(
|
||||||
|
tenant_id,
|
||||||
|
document_type=body.document_type,
|
||||||
|
source_document_id=body.source_document_id,
|
||||||
|
total_amount=body.total_amount,
|
||||||
|
discount_amount=body.discount_amount,
|
||||||
|
tax_amount=body.tax_amount,
|
||||||
|
profile_id=body.profile_id,
|
||||||
|
actor_user_id=user.user_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"voucher_id": str(voucher.id), "voucher_number": voucher.voucher_number, "status": voucher.status.value}
|
||||||
68
backend/services/accounting/app/api/v1/setup.py
Normal file
68
backend/services/accounting/app/api/v1/setup.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"""Setup progress API — computed from real tenant data (no mocks)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.repositories.foundation import (
|
||||||
|
AccountRepository,
|
||||||
|
ChartOfAccountsRepository,
|
||||||
|
CurrencyRepository,
|
||||||
|
FiscalPeriodRepository,
|
||||||
|
FiscalYearRepository,
|
||||||
|
)
|
||||||
|
from app.repositories.posting import VoucherRepository
|
||||||
|
from app.schemas.foundation import SetupStatusRead
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=SetupStatusRead)
|
||||||
|
async def setup_status(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
charts = await ChartOfAccountsRepository(db).list_by_tenant(tenant_id, limit=1)
|
||||||
|
accounts = await AccountRepository(db).list_by_tenant(tenant_id, limit=1)
|
||||||
|
currencies = await CurrencyRepository(db).list_by_tenant(tenant_id, limit=100)
|
||||||
|
years = await FiscalYearRepository(db).list_by_tenant(tenant_id, limit=1)
|
||||||
|
periods = await FiscalPeriodRepository(db).list_by_tenant(tenant_id, limit=1)
|
||||||
|
|
||||||
|
has_cash = False
|
||||||
|
has_customer = False
|
||||||
|
try:
|
||||||
|
from app.api.v1.treasury import CashBoxRepo
|
||||||
|
from app.api.v1.receivable_payable import CustomerRepo
|
||||||
|
|
||||||
|
has_cash = bool(await CashBoxRepo(db).list_by_tenant(tenant_id, limit=1))
|
||||||
|
has_customer = bool(await CustomerRepo(db).list_by_tenant(tenant_id, limit=1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
has_voucher = bool(await VoucherRepository(db).list_by_tenant(tenant_id, limit=1))
|
||||||
|
|
||||||
|
flags = {
|
||||||
|
"has_chart": bool(charts),
|
||||||
|
"has_accounts": bool(accounts),
|
||||||
|
"has_currency": bool(currencies),
|
||||||
|
"has_base_currency": any(c.is_base for c in currencies),
|
||||||
|
"has_fiscal_year": bool(years),
|
||||||
|
"has_fiscal_period": bool(periods),
|
||||||
|
"has_cash_box": has_cash,
|
||||||
|
"has_customer": has_customer,
|
||||||
|
"has_voucher": has_voucher,
|
||||||
|
}
|
||||||
|
completed = sum(1 for v in flags.values() if v)
|
||||||
|
total = len(flags)
|
||||||
|
return SetupStatusRead(
|
||||||
|
**flags,
|
||||||
|
completed_steps=completed,
|
||||||
|
total_steps=total,
|
||||||
|
percent=round(100 * completed / total) if total else 0,
|
||||||
|
)
|
||||||
206
backend/services/accounting/app/api/v1/treasury.py
Normal file
206
backend/services/accounting/app/api/v1/treasury.py
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
"""Treasury API — Phase 5.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.treasury import Bank, BankAccount, CashBox
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.services.treasury_service import TreasuryService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class CashBoxCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
account_id: UUID | None = None
|
||||||
|
opening_balance: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
class CashBoxRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
current_balance: Decimal
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CashReceiptRequest(BaseModel):
|
||||||
|
cash_box_id: UUID
|
||||||
|
amount: Decimal
|
||||||
|
debit_account_id: UUID
|
||||||
|
credit_account_id: UUID
|
||||||
|
description: str | None = None
|
||||||
|
transaction_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BankCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class BankAccountCreate(BaseModel):
|
||||||
|
bank_id: UUID
|
||||||
|
account_number: str
|
||||||
|
iban: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CashBoxRepo(TenantBaseRepository[CashBox]):
|
||||||
|
model = CashBox
|
||||||
|
|
||||||
|
|
||||||
|
class BankRepo(TenantBaseRepository[Bank]):
|
||||||
|
model = Bank
|
||||||
|
|
||||||
|
|
||||||
|
class BankAccountRepo(TenantBaseRepository[BankAccount]):
|
||||||
|
model = BankAccount
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cash-boxes", response_model=CashBoxRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_cash_box(
|
||||||
|
body: CashBoxCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CashBoxRepo(db)
|
||||||
|
entity = CashBox(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
code=body.code,
|
||||||
|
name=body.name,
|
||||||
|
account_id=body.account_id,
|
||||||
|
opening_balance=body.opening_balance,
|
||||||
|
current_balance=body.opening_balance,
|
||||||
|
)
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cash-boxes", response_model=list[CashBoxRead])
|
||||||
|
async def list_cash_boxes(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = CashBoxRepo(db)
|
||||||
|
return await repo.list_by_tenant(tenant_id, limit=100)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cash-receipts")
|
||||||
|
async def record_cash_receipt(
|
||||||
|
body: CashReceiptRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
svc = TreasuryService(db)
|
||||||
|
tx = await svc.record_cash_receipt(
|
||||||
|
tenant_id, body.cash_box_id, body.amount,
|
||||||
|
debit_account_id=body.debit_account_id,
|
||||||
|
credit_account_id=body.credit_account_id,
|
||||||
|
actor_user_id=user.user_id,
|
||||||
|
description=body.description,
|
||||||
|
transaction_date=body.transaction_date,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(tx.id), "voucher_id": str(tx.voucher_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/banks", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_bank(
|
||||||
|
body: BankCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = BankRepo(db)
|
||||||
|
entity = Bank(tenant_id=tenant_id, code=body.code, name=body.name)
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bank-accounts", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_bank_account(
|
||||||
|
body: BankAccountCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = BankAccountRepo(db)
|
||||||
|
entity = BankAccount(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
bank_id=body.bank_id,
|
||||||
|
account_number=body.account_number,
|
||||||
|
iban=body.iban,
|
||||||
|
)
|
||||||
|
await repo.add(entity)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": str(entity.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/banks")
|
||||||
|
async def list_banks(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
banks = await BankRepo(db).list_by_tenant(tenant_id, limit=100)
|
||||||
|
return [{"id": str(b.id), "code": b.code, "name": b.name, "is_active": b.is_active} for b in banks]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bank-accounts")
|
||||||
|
async def list_bank_accounts(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
accounts = await BankAccountRepo(db).list_by_tenant(tenant_id, limit=100)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(a.id),
|
||||||
|
"bank_id": str(a.bank_id),
|
||||||
|
"account_number": a.account_number,
|
||||||
|
"iban": a.iban,
|
||||||
|
"is_active": a.is_active,
|
||||||
|
}
|
||||||
|
for a in accounts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/cash-boxes/{cash_box_id}")
|
||||||
|
async def update_cash_box(
|
||||||
|
cash_box_id: UUID,
|
||||||
|
body: dict,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
repo = CashBoxRepo(db)
|
||||||
|
entity = await repo.get(tenant_id, cash_box_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
|
||||||
|
if "name" in body and body["name"] is not None:
|
||||||
|
entity.name = body["name"]
|
||||||
|
if "is_active" in body and body["is_active"] is not None:
|
||||||
|
entity.is_active = bool(body["is_active"])
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return CashBoxRead.model_validate(entity)
|
||||||
75
backend/services/accounting/app/core/config.py
Normal file
75
backend/services/accounting/app/core/config.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""تنظیمات Accounting Service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
environment: Literal["development", "staging", "production", "test"] = "development"
|
||||||
|
debug: bool = True
|
||||||
|
log_level: str = "INFO"
|
||||||
|
service_name: str = Field(
|
||||||
|
default="accounting-service", validation_alias="ACCOUNTING_SERVICE_NAME"
|
||||||
|
)
|
||||||
|
api_v1_prefix: str = "/api/v1"
|
||||||
|
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/accounting_db",
|
||||||
|
validation_alias="ACCOUNTING_DATABASE_URL",
|
||||||
|
)
|
||||||
|
database_url_sync: str = Field(
|
||||||
|
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/accounting_db",
|
||||||
|
validation_alias="ACCOUNTING_DATABASE_URL_SYNC",
|
||||||
|
)
|
||||||
|
|
||||||
|
core_service_url: str = Field(
|
||||||
|
default="http://localhost:8000", validation_alias="CORE_SERVICE_URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
keycloak_enabled: bool = True
|
||||||
|
keycloak_server_url: str = Field(
|
||||||
|
default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL"
|
||||||
|
)
|
||||||
|
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
|
||||||
|
keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM")
|
||||||
|
|
||||||
|
jwt_algorithm: str = "RS256"
|
||||||
|
jwt_audience: str = "account"
|
||||||
|
jwt_verify_signature: bool = True
|
||||||
|
auth_required: bool = True
|
||||||
|
|
||||||
|
cors_origins: str = Field(
|
||||||
|
default="http://localhost:3000,http://127.0.0.1:3000",
|
||||||
|
validation_alias="CORS_ORIGINS",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def keycloak_public_base(self) -> str:
|
||||||
|
return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def keycloak_public_realm_url(self) -> str:
|
||||||
|
return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
21
backend/services/accounting/app/core/database.py
Normal file
21
backend/services/accounting/app/core/database.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, pool_pre_ping=True, future=True)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
14
backend/services/accounting/app/core/logging.py
Normal file
14
backend/services/accounting/app/core/logging.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "INFO") -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
return logging.getLogger(name)
|
||||||
49
backend/services/accounting/app/core/security.py
Normal file
49
backend/services/accounting/app/core/security.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"""JWT authentication dependencies for Accounting service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from shared.auth import JWTSettings, JWTValidator
|
||||||
|
from shared.exceptions import UnauthorizedError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_jwt_validator() -> JWTValidator:
|
||||||
|
return JWTValidator(
|
||||||
|
JWTSettings(
|
||||||
|
keycloak_enabled=settings.keycloak_enabled,
|
||||||
|
keycloak_server_url=settings.keycloak_server_url,
|
||||||
|
keycloak_realm=settings.keycloak_realm,
|
||||||
|
jwt_algorithm=settings.jwt_algorithm,
|
||||||
|
jwt_audience=settings.jwt_audience,
|
||||||
|
jwt_verify_signature=settings.jwt_verify_signature,
|
||||||
|
jwt_issuer=settings.keycloak_public_realm_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if not settings.auth_required:
|
||||||
|
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||||
|
if credentials is None or not credentials.credentials:
|
||||||
|
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
|
||||||
|
return await get_jwt_validator().validate(credentials.credentials)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_optional_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
|
) -> CurrentUser | None:
|
||||||
|
if not settings.auth_required:
|
||||||
|
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||||
|
if credentials is None or not credentials.credentials:
|
||||||
|
return None
|
||||||
|
return await get_jwt_validator().validate(credentials.credentials)
|
||||||
125
backend/services/accounting/app/events/types.py
Normal file
125
backend/services/accounting/app/events/types.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""Accounting event type definitions (contracts only — no consumers)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingEventType(str, enum.Enum):
|
||||||
|
# Foundation
|
||||||
|
ACCOUNT_CREATED = "account.created"
|
||||||
|
CHART_OF_ACCOUNTS_CREATED = "chart_of_accounts.created"
|
||||||
|
FISCAL_YEAR_CREATED = "fiscal_year.created"
|
||||||
|
FISCAL_PERIOD_OPENED = "fiscal_period.opened"
|
||||||
|
|
||||||
|
# Posting (Phase 5.2)
|
||||||
|
VOUCHER_CREATED = "voucher.created"
|
||||||
|
VOUCHER_VALIDATED = "voucher.validated"
|
||||||
|
VOUCHER_POSTED = "voucher.posted"
|
||||||
|
VOUCHER_CANCELLED = "voucher.cancelled"
|
||||||
|
VOUCHER_REVERSED = "voucher.reversed"
|
||||||
|
JOURNAL_ENTRY_CREATED = "journal_entry.created"
|
||||||
|
POSTING_COMPLETED = "posting.completed"
|
||||||
|
POSTING_FAILED = "posting.failed"
|
||||||
|
|
||||||
|
# Ledger (Phase 5.3)
|
||||||
|
FISCAL_YEAR_CLOSED = "fiscal_year.closed"
|
||||||
|
FISCAL_PERIOD_LOCKED = "fiscal_period.locked"
|
||||||
|
FISCAL_PERIOD_CLOSED = "fiscal_period.closed"
|
||||||
|
TRIAL_BALANCE_GENERATED = "trial_balance.generated"
|
||||||
|
LEDGER_UPDATED = "ledger.updated"
|
||||||
|
BALANCE_RECALCULATED = "balance.recalculated"
|
||||||
|
|
||||||
|
# Treasury (Phase 5.4)
|
||||||
|
CASH_RECEIVED = "cash.received"
|
||||||
|
CASH_PAID = "cash.paid"
|
||||||
|
BANK_DEPOSIT_CREATED = "bank_deposit.created"
|
||||||
|
BANK_WITHDRAWAL_CREATED = "bank_withdrawal.created"
|
||||||
|
CHEQUE_ISSUED = "cheque.issued"
|
||||||
|
CHEQUE_RECEIVED = "cheque.received"
|
||||||
|
CHEQUE_DEPOSITED = "cheque.deposited"
|
||||||
|
CHEQUE_CLEARED = "cheque.cleared"
|
||||||
|
CHEQUE_BOUNCED = "cheque.bounced"
|
||||||
|
TRANSFER_COMPLETED = "transfer.completed"
|
||||||
|
TREASURY_TRANSACTION_POSTED = "treasury_transaction.posted"
|
||||||
|
BANK_RECONCILIATION_COMPLETED = "bank_reconciliation.completed"
|
||||||
|
|
||||||
|
# AR/AP (Phase 5.5)
|
||||||
|
CUSTOMER_INVOICE_CREATED = "customer_invoice.created"
|
||||||
|
SUPPLIER_INVOICE_CREATED = "supplier_invoice.created"
|
||||||
|
CUSTOMER_PAYMENT_RECEIVED = "customer_payment.received"
|
||||||
|
SUPPLIER_PAYMENT_COMPLETED = "supplier_payment.completed"
|
||||||
|
SETTLEMENT_COMPLETED = "settlement.completed"
|
||||||
|
CREDIT_NOTE_ISSUED = "credit_note.issued"
|
||||||
|
DEBIT_NOTE_ISSUED = "debit_note.issued"
|
||||||
|
ADVANCE_PAYMENT_RECORDED = "advance_payment.recorded"
|
||||||
|
ADVANCE_RECEIPT_RECORDED = "advance_receipt.recorded"
|
||||||
|
CUSTOMER_AGING_CALCULATED = "customer_aging.calculated"
|
||||||
|
SUPPLIER_AGING_CALCULATED = "supplier_aging.calculated"
|
||||||
|
|
||||||
|
# Sales Accounting (Phase 5.6)
|
||||||
|
SALES_INVOICE_POSTED = "sales_invoice.posted"
|
||||||
|
SALES_INVOICE_CANCELLED = "sales_invoice.cancelled"
|
||||||
|
SALES_RETURN_POSTED = "sales_return.posted"
|
||||||
|
REVENUE_RECOGNIZED = "revenue.recognized"
|
||||||
|
DISCOUNT_APPLIED = "discount.applied"
|
||||||
|
SALES_ACCOUNTING_COMPLETED = "sales_accounting.completed"
|
||||||
|
POSTING_RULE_EXECUTED = "posting_rule.executed"
|
||||||
|
|
||||||
|
# Purchase/Inventory (Phase 5.7)
|
||||||
|
GOODS_RECEIVED = "goods.received"
|
||||||
|
GOODS_ISSUED = "goods.issued"
|
||||||
|
INVENTORY_ADJUSTED = "inventory.adjusted"
|
||||||
|
INVENTORY_TRANSFERRED = "inventory.transferred"
|
||||||
|
PURCHASE_COMPLETED = "purchase.completed"
|
||||||
|
PURCHASE_RETURNED = "purchase.returned"
|
||||||
|
INVENTORY_REVALUED = "inventory.revalued"
|
||||||
|
INVENTORY_COST_ADJUSTED = "inventory.cost_adjusted"
|
||||||
|
INVENTORY_ACCOUNTING_COMPLETED = "inventory_accounting.completed"
|
||||||
|
PURCHASE_ACCOUNTING_COMPLETED = "purchase_accounting.completed"
|
||||||
|
|
||||||
|
# Fixed Assets (Phase 5.8)
|
||||||
|
ASSET_CREATED = "asset.created"
|
||||||
|
ASSET_ACTIVATED = "asset.activated"
|
||||||
|
ASSET_DEPRECIATED = "asset.depreciated"
|
||||||
|
ASSET_TRANSFERRED = "asset.transferred"
|
||||||
|
ASSET_REVALUED = "asset.revalued"
|
||||||
|
ASSET_DISPOSED = "asset.disposed"
|
||||||
|
ASSET_SOLD = "asset.sold"
|
||||||
|
ASSET_RETIRED = "asset.retired"
|
||||||
|
ASSET_IMPAIRED = "asset.impaired"
|
||||||
|
DEPRECIATION_CALCULATED = "depreciation.calculated"
|
||||||
|
ASSET_ACCOUNTING_COMPLETED = "asset_accounting.completed"
|
||||||
|
|
||||||
|
# Payroll (Phase 5.9)
|
||||||
|
PAYROLL_CALCULATED = "payroll.calculated"
|
||||||
|
PAYROLL_APPROVED = "payroll.approved"
|
||||||
|
PAYROLL_POSTED = "payroll.posted"
|
||||||
|
PAYROLL_PAID = "payroll.paid"
|
||||||
|
PAYROLL_REVERSED = "payroll.reversed"
|
||||||
|
SALARY_ADJUSTED = "salary.adjusted"
|
||||||
|
BONUS_PAID = "bonus.paid"
|
||||||
|
OVERTIME_APPROVED = "overtime.approved"
|
||||||
|
LOAN_DEDUCTED = "loan.deducted"
|
||||||
|
ADVANCE_RECOVERED = "advance.recovered"
|
||||||
|
PAYROLL_ACCOUNTING_COMPLETED = "payroll_accounting.completed"
|
||||||
|
|
||||||
|
# Reporting (Phase 5.10)
|
||||||
|
FINANCIAL_REPORT_GENERATED = "financial_report.generated"
|
||||||
|
DASHBOARD_GENERATED = "dashboard.generated"
|
||||||
|
BALANCE_SHEET_GENERATED = "balance_sheet.generated"
|
||||||
|
INCOME_STATEMENT_GENERATED = "income_statement.generated"
|
||||||
|
CASH_FLOW_GENERATED = "cash_flow.generated"
|
||||||
|
REPORT_EXPORTED = "report.exported"
|
||||||
|
SCHEDULED_REPORT_GENERATED = "scheduled_report.generated"
|
||||||
|
FINANCIAL_SNAPSHOT_CREATED = "financial_snapshot.created"
|
||||||
|
|
||||||
|
# Compliance (Phase 5.11)
|
||||||
|
AUDIT_RECORD_CREATED = "audit_record.created"
|
||||||
|
COMPLIANCE_VALIDATED = "compliance.validated"
|
||||||
|
POLICY_VIOLATION_DETECTED = "policy_violation.detected"
|
||||||
|
APPROVAL_COMPLETED = "approval.completed"
|
||||||
|
APPROVAL_REJECTED = "approval.rejected"
|
||||||
|
GOVERNANCE_RULE_EXECUTED = "governance_rule.executed"
|
||||||
|
RISK_DETECTED = "risk.detected"
|
||||||
|
EVIDENCE_UPLOADED = "evidence.uploaded"
|
||||||
|
RETENTION_POLICY_APPLIED = "retention_policy.applied"
|
||||||
67
backend/services/accounting/app/main.py
Normal file
67
backend/services/accounting/app/main.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app import __version__
|
||||||
|
from app.api.v1 import api_router
|
||||||
|
from app.api.v1 import health
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging import configure_logging, get_logger
|
||||||
|
from app.middlewares.tenant import TenantHeaderMiddleware
|
||||||
|
from shared.exceptions import AppError
|
||||||
|
from shared.responses import ErrorDetail, ErrorResponse
|
||||||
|
|
||||||
|
configure_logging(settings.log_level)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("service_starting", extra={"service": settings.service_name})
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title="Accounting Service",
|
||||||
|
version=__version__,
|
||||||
|
description="سرویس حسابداری — فاز ۵.۱ تا ۵.۱۱",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.add_middleware(TenantHeaderMiddleware)
|
||||||
|
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=ErrorResponse(
|
||||||
|
error=ErrorDetail(code=exc.error_code, message=exc.message, details=exc.details)
|
||||||
|
).model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=ErrorResponse(
|
||||||
|
error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
|
||||||
|
).model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
24
backend/services/accounting/app/middlewares/tenant.py
Normal file
24
backend/services/accounting/app/middlewares/tenant.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Tenant header resolution middleware."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID
|
||||||
|
|
||||||
|
|
||||||
|
class TenantHeaderMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Resolve tenant from X-Tenant-ID header only (microservice pattern)."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
request.state.tenant_id = None
|
||||||
|
raw = request.headers.get(HEADER_TENANT_ID)
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
request.state.tenant_id = UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return await call_next(request)
|
||||||
142
backend/services/accounting/app/models/__init__.py
Normal file
142
backend/services/accounting/app/models/__init__.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""Import all models for Alembic metadata discovery."""
|
||||||
|
from app.models.compliance import ( # noqa: F401
|
||||||
|
ApprovalRequest,
|
||||||
|
ApprovalStep,
|
||||||
|
ApprovalWorkflow,
|
||||||
|
AuditHistory,
|
||||||
|
AuditRecord,
|
||||||
|
CompliancePolicy,
|
||||||
|
ControlDefinition,
|
||||||
|
Delegation,
|
||||||
|
Evidence,
|
||||||
|
GovernanceRule,
|
||||||
|
PolicyViolation,
|
||||||
|
RetentionPolicy,
|
||||||
|
RiskRecord,
|
||||||
|
)
|
||||||
|
from app.models.fixed_assets import ( # noqa: F401
|
||||||
|
Asset,
|
||||||
|
AssetAccountingProfile,
|
||||||
|
AssetCategory,
|
||||||
|
AssetDepreciation,
|
||||||
|
AssetDisposal,
|
||||||
|
AssetGroup,
|
||||||
|
AssetHistory,
|
||||||
|
AssetImpairment,
|
||||||
|
AssetLocation,
|
||||||
|
AssetRevaluation,
|
||||||
|
AssetTransfer,
|
||||||
|
DepreciationSchedule,
|
||||||
|
)
|
||||||
|
from app.models.foundation import ( # noqa: F401
|
||||||
|
Account,
|
||||||
|
AccountingSettings,
|
||||||
|
ChartOfAccounts,
|
||||||
|
CostCenter,
|
||||||
|
Currency,
|
||||||
|
Dimension,
|
||||||
|
DocumentNumberSequence,
|
||||||
|
ExchangeRate,
|
||||||
|
FiscalPeriod,
|
||||||
|
FiscalYear,
|
||||||
|
Project,
|
||||||
|
TenantAccountingConfiguration,
|
||||||
|
)
|
||||||
|
from app.models.ledger import ( # noqa: F401
|
||||||
|
AccountingCalendar,
|
||||||
|
BalanceSnapshot,
|
||||||
|
ClosingEntry,
|
||||||
|
GeneralLedger,
|
||||||
|
LedgerBalance,
|
||||||
|
OpeningEntry,
|
||||||
|
TrialBalanceSnapshot,
|
||||||
|
)
|
||||||
|
from app.models.payroll import ( # noqa: F401
|
||||||
|
Allowance,
|
||||||
|
Benefit,
|
||||||
|
Deduction,
|
||||||
|
Department,
|
||||||
|
Employee,
|
||||||
|
EmployeeAdvance,
|
||||||
|
EmployeeLoan,
|
||||||
|
EmploymentContract,
|
||||||
|
Payroll,
|
||||||
|
PayrollAccountingProfile,
|
||||||
|
PayrollHistory,
|
||||||
|
PayrollItem,
|
||||||
|
PayrollPeriod,
|
||||||
|
PayrollSettlement,
|
||||||
|
Position,
|
||||||
|
SalaryComponent,
|
||||||
|
)
|
||||||
|
from app.models.posting import ( # noqa: F401
|
||||||
|
AccountingAuditLog,
|
||||||
|
Journal,
|
||||||
|
JournalEntry,
|
||||||
|
PostingError,
|
||||||
|
PostingLog,
|
||||||
|
PostingReference,
|
||||||
|
Voucher,
|
||||||
|
VoucherLine,
|
||||||
|
)
|
||||||
|
from app.models.purchase_inventory import ( # noqa: F401
|
||||||
|
InventoryAccountingConfiguration,
|
||||||
|
InventoryCostAdjustment,
|
||||||
|
InventoryPostingProfile,
|
||||||
|
InventorySnapshot,
|
||||||
|
InventoryValuationHistory,
|
||||||
|
InventoryValuationMethodConfig,
|
||||||
|
PurchaseAccountingConfiguration,
|
||||||
|
PurchasePostingProfile,
|
||||||
|
)
|
||||||
|
from app.models.receivable_payable import ( # noqa: F401
|
||||||
|
AdvancePayment,
|
||||||
|
AdvanceReceipt,
|
||||||
|
AgingSnapshot,
|
||||||
|
CreditNote,
|
||||||
|
CustomerAccount,
|
||||||
|
CustomerStatement,
|
||||||
|
DebitNote,
|
||||||
|
PayableInvoice,
|
||||||
|
ReceivableInvoice,
|
||||||
|
Settlement,
|
||||||
|
SettlementAllocation,
|
||||||
|
SupplierAccount,
|
||||||
|
SupplierStatement,
|
||||||
|
)
|
||||||
|
from app.models.reporting import ( # noqa: F401
|
||||||
|
Dashboard,
|
||||||
|
DashboardWidget,
|
||||||
|
FinancialReport,
|
||||||
|
KPI,
|
||||||
|
ReportConfiguration,
|
||||||
|
ReportExport,
|
||||||
|
ReportHistory,
|
||||||
|
ReportSnapshot,
|
||||||
|
ReportTemplate,
|
||||||
|
ScheduledReport,
|
||||||
|
)
|
||||||
|
from app.models.sales_accounting import ( # noqa: F401
|
||||||
|
AccountingPreview,
|
||||||
|
AccountingProfile,
|
||||||
|
PostingRule,
|
||||||
|
PostingSimulation,
|
||||||
|
RevenueRecognition,
|
||||||
|
RevenueSchedule,
|
||||||
|
SalesAccountingConfiguration,
|
||||||
|
SalesPostingProfile,
|
||||||
|
)
|
||||||
|
from app.models.treasury import ( # noqa: F401
|
||||||
|
Bank,
|
||||||
|
BankAccount,
|
||||||
|
BankTransaction,
|
||||||
|
CashBox,
|
||||||
|
CashTransaction,
|
||||||
|
Cheque,
|
||||||
|
ChequeBook,
|
||||||
|
PaymentVoucher,
|
||||||
|
ReceiptVoucher,
|
||||||
|
Reconciliation,
|
||||||
|
Transfer,
|
||||||
|
TreasurySettings,
|
||||||
|
)
|
||||||
32
backend/services/accounting/app/models/base.py
Normal file
32
backend/services/accounting/app/models/base.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""Shared model mixins."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.types import GUID
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDPrimaryKeyMixin:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantMixin:
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
186
backend/services/accounting/app/models/compliance.py
Normal file
186
backend/services/accounting/app/models/compliance.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"""Phase 5.11 — Compliance, Audit & Governance domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, DateTime, Index, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import ApprovalStatus, GUID, PolicyViolationSeverity, RiskLevel
|
||||||
|
|
||||||
|
|
||||||
|
class AuditRecord(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "audit_records"
|
||||||
|
|
||||||
|
actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
source_module: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
before_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
after_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
request_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
is_immutable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_audit_records_tenant_created", "tenant_id", "created_at"),
|
||||||
|
Index("ix_audit_records_resource", "tenant_id", "resource_type", "resource_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "audit_history"
|
||||||
|
|
||||||
|
audit_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
event_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CompliancePolicy(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "compliance_policies"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
policy_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
country_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||||
|
rules_config: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
effective_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
effective_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_compliance_policy_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceRule(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "governance_rules"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
rule_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
condition_config: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
action_config: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_governance_rule_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalWorkflow(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "approval_workflows"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
min_amount: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
max_amount: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_approval_workflow_tenant_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStep(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "approval_steps"
|
||||||
|
|
||||||
|
workflow_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
step_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
approver_role: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
is_required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRequest(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "approval_requests"
|
||||||
|
|
||||||
|
workflow_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
requested_by: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
current_step: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
status: Mapped[ApprovalStatus] = mapped_column(default=ApprovalStatus.PENDING, nullable=False)
|
||||||
|
approved_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_approval_requests_resource", "tenant_id", "resource_type", "resource_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Delegation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "delegations"
|
||||||
|
|
||||||
|
delegator_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
delegate_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
permission_scope: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskRecord(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "risk_records"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
risk_category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
risk_level: Mapped[RiskLevel] = mapped_column(nullable=False)
|
||||||
|
risk_score: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
mitigation_plan: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_risk_record_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyViolation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "policy_violations"
|
||||||
|
|
||||||
|
policy_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
severity: Mapped[PolicyViolationSeverity] = mapped_column(nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
is_resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Evidence(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "evidence"
|
||||||
|
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
uploaded_by: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RetentionPolicy(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "retention_policies"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
retention_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_retention_policy_tenant_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlDefinition(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "control_definitions"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
control_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
validation_config: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_control_def_tenant_code"),)
|
||||||
171
backend/services/accounting/app/models/fixed_assets.py
Normal file
171
backend/services/accounting/app/models/fixed_assets.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"""Phase 5.8 — Fixed Assets domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, Index, Integer, Numeric, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import AssetStatus, DepreciationMethod, GUID
|
||||||
|
|
||||||
|
|
||||||
|
class AssetCategory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_categories"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
default_useful_life_months: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
|
||||||
|
default_depreciation_method: Mapped[DepreciationMethod] = mapped_column(
|
||||||
|
default=DepreciationMethod.STRAIGHT_LINE, nullable=False
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_asset_category_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetGroup(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_groups"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_asset_group_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class Asset(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "assets"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
group_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
serial_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
acquisition_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
acquisition_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
current_book_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
residual_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
useful_life_months: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
|
||||||
|
depreciation_method: Mapped[DepreciationMethod] = mapped_column(
|
||||||
|
default=DepreciationMethod.STRAIGHT_LINE, nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[AssetStatus] = mapped_column(default=AssetStatus.DRAFT, nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_asset_tenant_code"),
|
||||||
|
Index("ix_assets_tenant_status", "tenant_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetDepreciation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_depreciations"
|
||||||
|
|
||||||
|
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
depreciation_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
accumulated_depreciation: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
book_value_after: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_asset_depreciation_asset", "tenant_id", "asset_id"),)
|
||||||
|
|
||||||
|
|
||||||
|
class DepreciationSchedule(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "depreciation_schedules"
|
||||||
|
|
||||||
|
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
period_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
schedule_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
is_posted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetTransfer(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_transfers"
|
||||||
|
|
||||||
|
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
transfer_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
from_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
to_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
from_department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
to_department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetDisposal(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_disposals"
|
||||||
|
|
||||||
|
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
disposal_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
disposal_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
sale_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
|
||||||
|
gain_loss: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetRevaluation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_revaluations"
|
||||||
|
|
||||||
|
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
revaluation_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
old_book_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
new_book_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
difference: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetImpairment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_impairments"
|
||||||
|
|
||||||
|
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
impairment_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
impairment_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetAccountingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_accounting_profiles"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
asset_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
depreciation_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
accumulated_depreciation_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
disposal_gain_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
disposal_loss_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_history"
|
||||||
|
|
||||||
|
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
event_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
before_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
after_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_asset_history_asset", "tenant_id", "asset_id"),)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetLocation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "asset_locations"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_asset_location_tenant_code"),)
|
||||||
230
backend/services/accounting/app/models/foundation.py
Normal file
230
backend/services/accounting/app/models/foundation.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
"""Phase 5.1 — Accounting foundation domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import (
|
||||||
|
AccountCategory,
|
||||||
|
AccountStatus,
|
||||||
|
AccountType,
|
||||||
|
FiscalPeriodStatus,
|
||||||
|
GUID,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChartOfAccounts(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "chart_of_accounts"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
accounts: Mapped[list["Account"]] = relationship(back_populates="chart")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_coa_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "accounts"
|
||||||
|
|
||||||
|
chart_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("chart_of_accounts.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
parent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
GUID(), ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
account_type: Mapped[AccountType] = mapped_column(nullable=False)
|
||||||
|
account_category: Mapped[AccountCategory] = mapped_column(nullable=False)
|
||||||
|
level: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
is_group: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_postable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
status: Mapped[AccountStatus] = mapped_column(
|
||||||
|
default=AccountStatus.ACTIVE, nullable=False
|
||||||
|
)
|
||||||
|
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
chart: Mapped["ChartOfAccounts"] = relationship(back_populates="accounts")
|
||||||
|
parent: Mapped["Account | None"] = relationship(remote_side="Account.id")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_account_tenant_code"),
|
||||||
|
Index("ix_accounts_chart_id", "chart_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Currency(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "currencies"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(3), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
symbol: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||||
|
decimal_places: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
|
||||||
|
is_base: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_currency_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRate(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "exchange_rates"
|
||||||
|
|
||||||
|
from_currency_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
to_currency_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
rate: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
|
||||||
|
effective_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_exchange_rates_tenant_date", "tenant_id", "effective_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalYear(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "fiscal_years"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
end_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
is_current: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_closed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
periods: Mapped[list["FiscalPeriod"]] = relationship(back_populates="fiscal_year")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "name", name="uq_fiscal_year_tenant_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalPeriod(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "fiscal_periods"
|
||||||
|
|
||||||
|
fiscal_year_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("fiscal_years.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
period_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
end_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
status: Mapped[FiscalPeriodStatus] = mapped_column(
|
||||||
|
default=FiscalPeriodStatus.OPEN, nullable=False
|
||||||
|
)
|
||||||
|
is_current: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
fiscal_year: Mapped["FiscalYear"] = relationship(back_populates="periods")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "fiscal_year_id", "period_number",
|
||||||
|
name="uq_fiscal_period_tenant_year_num",
|
||||||
|
),
|
||||||
|
Index("ix_fiscal_periods_tenant_status", "tenant_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CostCenter(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "cost_centers"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
parent_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_cost_center_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_project_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Dimension(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "dimensions"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
dimension_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_dimension_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingSettings(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "accounting_settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
value_type: Mapped[str] = mapped_column(String(20), default="string", nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "key", name="uq_accounting_settings_tenant_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentNumberSequence(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "document_number_sequences"
|
||||||
|
|
||||||
|
document_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
prefix: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
next_number: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
padding: Mapped[int] = mapped_column(Integer, default=6, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "document_type", name="uq_doc_seq_tenant_type"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantAccountingConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "tenant_accounting_configurations"
|
||||||
|
|
||||||
|
base_currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
default_chart_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
fiscal_year_start_month: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
allow_multi_currency: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
require_cost_center: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
require_project: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", name="uq_tenant_accounting_config_tenant"),
|
||||||
|
)
|
||||||
146
backend/services/accounting/app/models/ledger.py
Normal file
146
backend/services/accounting/app/models/ledger.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""Phase 5.3 — General Ledger & fiscal management models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import GUID
|
||||||
|
|
||||||
|
|
||||||
|
class GeneralLedger(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "general_ledgers"
|
||||||
|
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
journal_entry_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
running_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_gl_tenant_account_period", "tenant_id", "account_id", "fiscal_period_id"),
|
||||||
|
Index("ix_gl_tenant_date", "tenant_id", "entry_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerBalance(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "ledger_balances"
|
||||||
|
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
opening_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
debit_total: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
credit_total: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
closing_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
current_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "account_id", "fiscal_period_id",
|
||||||
|
name="uq_ledger_balance_tenant_account_period",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrialBalanceSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "trial_balance_snapshots"
|
||||||
|
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
generated_at: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
total_debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
total_credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
difference: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
is_balanced: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
snapshot_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_trial_balance_tenant_period", "tenant_id", "fiscal_period_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "balance_snapshots"
|
||||||
|
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
snapshot_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_balance_snapshot_tenant_account", "tenant_id", "account_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ClosingEntry(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "closing_entries"
|
||||||
|
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
entry_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_closing_entries_period", "tenant_id", "fiscal_period_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningEntry(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "opening_entries"
|
||||||
|
|
||||||
|
fiscal_year_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
is_debit: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_opening_entries_year", "tenant_id", "fiscal_year_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingCalendar(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "accounting_calendars"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
fiscal_year_start_month: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
periods_per_year: Mapped[int] = mapped_column(Integer, default=12, nullable=False)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "name", name="uq_accounting_calendar_tenant_name"),
|
||||||
|
)
|
||||||
202
backend/services/accounting/app/models/payroll.py
Normal file
202
backend/services/accounting/app/models/payroll.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
"""Phase 5.9 — HCM & Payroll domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, Index, Integer, Numeric, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import EmploymentStatus, GUID, PayrollPeriodStatus, PayrollStatus
|
||||||
|
|
||||||
|
|
||||||
|
class Department(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "departments"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
parent_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_department_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class Position(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "positions"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_position_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class Employee(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "employees"
|
||||||
|
|
||||||
|
employee_code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
position_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
hire_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
employment_status: Mapped[EmploymentStatus] = mapped_column(
|
||||||
|
default=EmploymentStatus.ACTIVE, nullable=False
|
||||||
|
)
|
||||||
|
base_salary: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "employee_code", name="uq_employee_tenant_code"),
|
||||||
|
Index("ix_employees_tenant_dept", "tenant_id", "department_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmploymentContract(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "employment_contracts"
|
||||||
|
|
||||||
|
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
contract_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
base_salary: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SalaryComponent(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "salary_components"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
component_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
is_taxable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_salary_component_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollPeriod(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "payroll_periods"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
end_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
status: Mapped[PayrollPeriodStatus] = mapped_column(
|
||||||
|
default=PayrollPeriodStatus.OPEN, nullable=False
|
||||||
|
)
|
||||||
|
is_current: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_payroll_period_tenant_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class Payroll(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "payrolls"
|
||||||
|
|
||||||
|
payroll_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
gross_salary: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
total_deductions: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
total_benefits: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
net_salary: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
employer_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
status: Mapped[PayrollStatus] = mapped_column(default=PayrollStatus.DRAFT, nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_payroll_period_employee", "tenant_id", "payroll_period_id", "employee_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollItem(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "payroll_items"
|
||||||
|
|
||||||
|
payroll_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
component_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
item_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Benefit(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "benefits"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_benefit_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class Allowance(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "allowances"
|
||||||
|
|
||||||
|
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
component_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
effective_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Deduction(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "deductions"
|
||||||
|
|
||||||
|
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
component_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
effective_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeLoan(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "employee_loans"
|
||||||
|
|
||||||
|
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
loan_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
monthly_deduction: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeAdvance(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "employee_advances"
|
||||||
|
|
||||||
|
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
advance_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollAccountingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "payroll_accounting_profiles"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
salary_expense_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
employer_contribution_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
payable_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
deduction_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "payroll_history"
|
||||||
|
|
||||||
|
payroll_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollSettlement(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "payroll_settlements"
|
||||||
|
|
||||||
|
payroll_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
settlement_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
198
backend/services/accounting/app/models/posting.py
Normal file
198
backend/services/accounting/app/models/posting.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
"""Phase 5.2 — Double-entry posting domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import (
|
||||||
|
GUID,
|
||||||
|
JournalStatus,
|
||||||
|
PostingStatus,
|
||||||
|
VoucherStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Voucher(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "vouchers"
|
||||||
|
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
voucher_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
voucher_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
status: Mapped[VoucherStatus] = mapped_column(
|
||||||
|
default=VoucherStatus.DRAFT, nullable=False
|
||||||
|
)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
source_module: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
total_debit: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
total_credit: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
posted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
posted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
reversed_voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
lines: Mapped[list["VoucherLine"]] = relationship(
|
||||||
|
back_populates="voucher", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
journal_entries: Mapped[list["JournalEntry"]] = relationship(back_populates="voucher")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "voucher_number", name="uq_voucher_tenant_number"),
|
||||||
|
Index("ix_vouchers_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_vouchers_fiscal_period", "tenant_id", "fiscal_period_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherLine(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "voucher_lines"
|
||||||
|
|
||||||
|
voucher_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("vouchers.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
line_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
|
||||||
|
|
||||||
|
voucher: Mapped["Voucher"] = relationship(back_populates="lines")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_voucher_lines_voucher_id", "voucher_id"),
|
||||||
|
Index("ix_voucher_lines_account_id", "tenant_id", "account_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Journal(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "journals"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_journal_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntry(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
"""Immutable after posting — only created via Posting Engine."""
|
||||||
|
|
||||||
|
__tablename__ = "journal_entries"
|
||||||
|
|
||||||
|
voucher_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("vouchers.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
journal_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
entry_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
status: Mapped[JournalStatus] = mapped_column(
|
||||||
|
default=JournalStatus.DRAFT, nullable=False
|
||||||
|
)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
posted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
posted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
voucher: Mapped["Voucher"] = relationship(back_populates="journal_entries")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_journal_entries_tenant_account", "tenant_id", "account_id"),
|
||||||
|
Index("ix_journal_entries_voucher", "voucher_id"),
|
||||||
|
Index("ix_journal_entries_period", "tenant_id", "fiscal_period_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostingLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "posting_logs"
|
||||||
|
|
||||||
|
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
operation: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
status: Mapped[PostingStatus] = mapped_column(nullable=False)
|
||||||
|
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
source_module: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_posting_logs_voucher", "tenant_id", "voucher_id"),)
|
||||||
|
|
||||||
|
|
||||||
|
class PostingError(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "posting_errors"
|
||||||
|
|
||||||
|
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
error_code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
error_message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
field_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_posting_errors_voucher", "tenant_id", "voucher_id"),)
|
||||||
|
|
||||||
|
|
||||||
|
class PostingReference(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "posting_references"
|
||||||
|
|
||||||
|
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
source_module: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
source_document_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
source_document_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
is_posted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "source_module", "source_document_type", "source_document_id",
|
||||||
|
name="uq_posting_ref_source",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "accounting_audit_logs"
|
||||||
|
|
||||||
|
operation: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
source_module: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
voucher_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
before_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
after_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_audit_logs_tenant_created", "tenant_id", "created_at"),
|
||||||
|
)
|
||||||
124
backend/services/accounting/app/models/purchase_inventory.py
Normal file
124
backend/services/accounting/app/models/purchase_inventory.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""Phase 5.7 — Purchase & Inventory accounting domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, Index, Integer, Numeric, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import GUID, InventoryValuationMethod
|
||||||
|
|
||||||
|
|
||||||
|
class PurchasePostingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "purchase_posting_profiles"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
inventory_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
grni_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
expense_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
liability_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
discount_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
adjustment_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_purchase_profile_tenant_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryPostingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "inventory_posting_profiles"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
inventory_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
cogs_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
adjustment_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
gain_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
loss_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
write_off_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_inventory_profile_tenant_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryValuationMethodConfig(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "inventory_valuation_methods"
|
||||||
|
|
||||||
|
item_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
warehouse_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
method: Mapped[InventoryValuationMethod] = mapped_column(nullable=False)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_inv_valuation_tenant_item", "tenant_id", "item_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryValuationHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "inventory_valuation_history"
|
||||||
|
|
||||||
|
item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
warehouse_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
valuation_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
method: Mapped[InventoryValuationMethod] = mapped_column(nullable=False)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
unit_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
total_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_inv_val_history_item", "tenant_id", "item_id", "valuation_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryCostAdjustment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "inventory_cost_adjustments"
|
||||||
|
|
||||||
|
item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
warehouse_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
adjustment_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
old_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
new_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
difference: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseAccountingConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "purchase_accounting_configurations"
|
||||||
|
|
||||||
|
default_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
auto_post_receipts: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
auto_post_invoices: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", name="uq_purchase_accounting_config_tenant"),)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryAccountingConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "inventory_accounting_configurations"
|
||||||
|
|
||||||
|
default_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
default_valuation_method: Mapped[InventoryValuationMethod] = mapped_column(
|
||||||
|
default=InventoryValuationMethod.WEIGHTED_AVERAGE, nullable=False
|
||||||
|
)
|
||||||
|
auto_post_issues: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", name="uq_inventory_accounting_config_tenant"),)
|
||||||
|
|
||||||
|
|
||||||
|
class InventorySnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "inventory_snapshots"
|
||||||
|
|
||||||
|
item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
warehouse_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
unit_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
total_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_inventory_snapshot_item", "tenant_id", "item_id", "snapshot_date"),
|
||||||
|
)
|
||||||
238
backend/services/accounting/app/models/receivable_payable.py
Normal file
238
backend/services/accounting/app/models/receivable_payable.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"""Phase 5.5 — Accounts Receivable & Payable domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import GUID, InvoiceStatus, SettlementStatus
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerAccount(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "customer_accounts"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
credit_limit: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
|
||||||
|
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
outstanding_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
invoices: Mapped[list["ReceivableInvoice"]] = relationship(back_populates="customer")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_customer_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierAccount(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "supplier_accounts"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
outstanding_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
invoices: Mapped[list["PayableInvoice"]] = relationship(back_populates="supplier")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_supplier_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReceivableInvoice(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "receivable_invoices"
|
||||||
|
|
||||||
|
customer_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("customer_accounts.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
invoice_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
invoice_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
paid_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
status: Mapped[InvoiceStatus] = mapped_column(nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
customer: Mapped["CustomerAccount"] = relationship(back_populates="invoices")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "invoice_number", name="uq_receivable_inv_tenant_number"),
|
||||||
|
Index("ix_receivable_inv_customer", "tenant_id", "customer_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PayableInvoice(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "payable_invoices"
|
||||||
|
|
||||||
|
supplier_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("supplier_accounts.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
invoice_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
invoice_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
paid_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
status: Mapped[InvoiceStatus] = mapped_column(nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
supplier: Mapped["SupplierAccount"] = relationship(back_populates="invoices")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "invoice_number", name="uq_payable_inv_tenant_number"),
|
||||||
|
Index("ix_payable_inv_supplier", "tenant_id", "supplier_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Settlement(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "settlements"
|
||||||
|
|
||||||
|
settlement_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
settlement_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
settlement_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
party_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
party_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
status: Mapped[SettlementStatus] = mapped_column(nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
allocations: Mapped[list["SettlementAllocation"]] = relationship(
|
||||||
|
back_populates="settlement", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "settlement_number", name="uq_settlement_tenant_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementAllocation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "settlement_allocations"
|
||||||
|
|
||||||
|
settlement_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("settlements.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
invoice_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
invoice_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
allocated_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
|
||||||
|
settlement: Mapped["Settlement"] = relationship(back_populates="allocations")
|
||||||
|
|
||||||
|
|
||||||
|
class CreditNote(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "credit_notes"
|
||||||
|
|
||||||
|
note_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
note_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
party_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
party_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "note_number", name="uq_credit_note_tenant_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DebitNote(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "debit_notes"
|
||||||
|
|
||||||
|
note_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
note_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
party_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
party_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "note_number", name="uq_debit_note_tenant_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdvancePayment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "advance_payments"
|
||||||
|
|
||||||
|
supplier_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
payment_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AdvanceReceipt(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "advance_receipts"
|
||||||
|
|
||||||
|
customer_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
receipt_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerStatement(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "customer_statements"
|
||||||
|
|
||||||
|
customer_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
statement_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
opening_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
closing_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
statement_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierStatement(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "supplier_statements"
|
||||||
|
|
||||||
|
supplier_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
statement_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
opening_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
closing_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
statement_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AgingSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "aging_snapshots"
|
||||||
|
|
||||||
|
party_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
party_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
current_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
days_1_30: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
days_31_60: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
days_61_90: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
days_90_plus: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
total_outstanding: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_aging_snapshot_party", "tenant_id", "party_type", "party_id"),
|
||||||
|
)
|
||||||
124
backend/services/accounting/app/models/reporting.py
Normal file
124
backend/services/accounting/app/models/reporting.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""Phase 5.10 — Financial Reporting & BI domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, DateTime, Index, Integer, Numeric, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import GUID, ReportExportFormat, ReportStatus, ReportType
|
||||||
|
|
||||||
|
|
||||||
|
class FinancialReport(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "financial_reports"
|
||||||
|
|
||||||
|
report_type: Mapped[ReportType] = mapped_column(nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
fiscal_period_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
fiscal_year_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
status: Mapped[ReportStatus] = mapped_column(default=ReportStatus.GENERATED, nullable=False)
|
||||||
|
report_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
generated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_financial_reports_tenant_type", "tenant_id", "report_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportTemplate(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "report_templates"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
report_type: Mapped[ReportType] = mapped_column(nullable=False)
|
||||||
|
template_config: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_report_template_tenant_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class Dashboard(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "dashboards"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
dashboard_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
layout_config: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_dashboard_tenant_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidget(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "dashboard_widgets"
|
||||||
|
|
||||||
|
dashboard_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
widget_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
config: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class KPI(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "kpis"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
kpi_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
current_value: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
|
||||||
|
target_value: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
|
||||||
|
calculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_kpi_tenant_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "report_snapshots"
|
||||||
|
|
||||||
|
report_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
snapshot_data: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledReport(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "scheduled_reports"
|
||||||
|
|
||||||
|
template_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
report_type: Mapped[ReportType] = mapped_column(nullable=False)
|
||||||
|
schedule_cron: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
recipients: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExport(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "report_exports"
|
||||||
|
|
||||||
|
report_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
export_format: Mapped[ReportExportFormat] = mapped_column(nullable=False)
|
||||||
|
file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
exported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
exported_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "report_history"
|
||||||
|
|
||||||
|
report_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "report_configurations"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "key", name="uq_report_config_tenant_key"),)
|
||||||
144
backend/services/accounting/app/models/sales_accounting.py
Normal file
144
backend/services/accounting/app/models/sales_accounting.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""Phase 5.6 — Sales accounting integration domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import GUID, RevenueRecognitionMethod, SalesDocumentType
|
||||||
|
|
||||||
|
|
||||||
|
class SalesPostingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "sales_posting_profiles"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
document_type: Mapped[SalesDocumentType] = mapped_column(nullable=False)
|
||||||
|
revenue_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
receivable_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
cash_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
bank_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
discount_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
tax_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
deferred_revenue_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
rounding_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "name", name="uq_sales_profile_tenant_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "accounting_profiles"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
profile_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
entity_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
sales_posting_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
config_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_accounting_profile_entity", "tenant_id", "entity_type", "entity_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostingRule(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "posting_rules"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
document_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
source_module: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
debit_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
credit_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
condition_expression: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_posting_rules_tenant_module", "tenant_id", "source_module"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SalesAccountingConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "sales_accounting_configurations"
|
||||||
|
|
||||||
|
default_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
auto_post_invoices: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
auto_post_returns: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
default_revenue_recognition: Mapped[RevenueRecognitionMethod] = mapped_column(
|
||||||
|
default=RevenueRecognitionMethod.IMMEDIATE, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", name="uq_sales_accounting_config_tenant"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueRecognition(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "revenue_recognitions"
|
||||||
|
|
||||||
|
source_document_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
source_document_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
method: Mapped[RevenueRecognitionMethod] = mapped_column(nullable=False)
|
||||||
|
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
recognized_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
deferred_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
recognition_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_revenue_recognition_source", "tenant_id", "source_document_type", "source_document_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueSchedule(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "revenue_schedules"
|
||||||
|
|
||||||
|
revenue_recognition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
schedule_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
is_recognized: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingPreview(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "accounting_previews"
|
||||||
|
|
||||||
|
source_module: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
source_document_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
source_document_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
preview_data: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
is_balanced: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
total_debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
total_credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PostingSimulation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "posting_simulations"
|
||||||
|
|
||||||
|
source_module: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
simulation_data: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
result_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_successful: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
236
backend/services/accounting/app/models/treasury.py
Normal file
236
backend/services/accounting/app/models/treasury.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"""Phase 5.4 — Treasury management domain models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import (
|
||||||
|
ChequeStatus,
|
||||||
|
GUID,
|
||||||
|
ReconciliationStatus,
|
||||||
|
TreasuryTransactionType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TreasurySettings(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "treasury_settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "key", name="uq_treasury_settings_tenant_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CashBox(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "cash_boxes"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
opening_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
current_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
transactions: Mapped[list["CashTransaction"]] = relationship(back_populates="cash_box")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_cash_box_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CashTransaction(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "cash_transactions"
|
||||||
|
|
||||||
|
cash_box_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("cash_boxes.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
transaction_type: Mapped[TreasuryTransactionType] = mapped_column(nullable=False)
|
||||||
|
transaction_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
cash_box: Mapped["CashBox"] = relationship(back_populates="transactions")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_cash_tx_tenant_box", "tenant_id", "cash_box_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Bank(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "banks"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
swift_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
accounts: Mapped[list["BankAccount"]] = relationship(back_populates="bank")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_bank_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BankAccount(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "bank_accounts"
|
||||||
|
|
||||||
|
bank_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("banks.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
account_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
iban: Mapped[str | None] = mapped_column(String(34), nullable=True)
|
||||||
|
card_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
opening_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
current_balance: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(18, 4), default=Decimal("0"), nullable=False
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
bank: Mapped["Bank"] = relationship(back_populates="accounts")
|
||||||
|
transactions: Mapped[list["BankTransaction"]] = relationship(back_populates="bank_account")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "account_number", name="uq_bank_account_tenant_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BankTransaction(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "bank_transactions"
|
||||||
|
|
||||||
|
bank_account_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), ForeignKey("bank_accounts.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
transaction_type: Mapped[TreasuryTransactionType] = mapped_column(nullable=False)
|
||||||
|
transaction_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
bank_account: Mapped["BankAccount"] = relationship(back_populates="transactions")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_bank_tx_tenant_account", "tenant_id", "bank_account_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChequeBook(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "cheque_books"
|
||||||
|
|
||||||
|
bank_account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
serial_from: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
serial_to: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Cheque(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "cheques"
|
||||||
|
|
||||||
|
cheque_book_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
bank_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
cheque_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
issue_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
status: Mapped[ChequeStatus] = mapped_column(nullable=False)
|
||||||
|
is_incoming: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "cheque_number", name="uq_cheque_tenant_number"),
|
||||||
|
Index("ix_cheques_tenant_status", "tenant_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Transfer(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "transfers"
|
||||||
|
|
||||||
|
transfer_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
from_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
from_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
to_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
to_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_transfers_tenant_date", "tenant_id", "transfer_date"),)
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptVoucher(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "receipt_vouchers"
|
||||||
|
|
||||||
|
receipt_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
receipt_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
payment_method: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
cash_box_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
bank_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "receipt_number", name="uq_receipt_tenant_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentVoucher(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "payment_vouchers"
|
||||||
|
|
||||||
|
payment_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
payment_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
payment_method: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
cash_box_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
bank_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "payment_number", name="uq_payment_tenant_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Reconciliation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
__tablename__ = "reconciliations"
|
||||||
|
|
||||||
|
bank_account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
statement_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
statement_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
book_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
difference: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
status: Mapped[ReconciliationStatus] = mapped_column(nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_reconciliation_tenant_account", "tenant_id", "bank_account_id"),
|
||||||
|
)
|
||||||
277
backend/services/accounting/app/models/types.py
Normal file
277
backend/services/accounting/app/models/types.py
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.types import CHAR, TypeDecorator
|
||||||
|
|
||||||
|
|
||||||
|
class GUID(TypeDecorator):
|
||||||
|
impl = CHAR
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def load_dialect_impl(self, dialect):
|
||||||
|
if dialect.name == "postgresql":
|
||||||
|
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||||
|
return dialect.type_descriptor(CHAR(36))
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
if dialect.name == "postgresql":
|
||||||
|
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return value
|
||||||
|
return uuid.UUID(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Foundation enums (Phase 5.1) ---
|
||||||
|
|
||||||
|
|
||||||
|
class AccountType(str, enum.Enum):
|
||||||
|
ASSET = "asset"
|
||||||
|
LIABILITY = "liability"
|
||||||
|
EQUITY = "equity"
|
||||||
|
REVENUE = "revenue"
|
||||||
|
EXPENSE = "expense"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCategory(str, enum.Enum):
|
||||||
|
CURRENT_ASSET = "current_asset"
|
||||||
|
NON_CURRENT_ASSET = "non_current_asset"
|
||||||
|
CURRENT_LIABILITY = "current_liability"
|
||||||
|
NON_CURRENT_LIABILITY = "non_current_liability"
|
||||||
|
EQUITY = "equity"
|
||||||
|
OPERATING_REVENUE = "operating_revenue"
|
||||||
|
OTHER_REVENUE = "other_revenue"
|
||||||
|
OPERATING_EXPENSE = "operating_expense"
|
||||||
|
OTHER_EXPENSE = "other_expense"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalPeriodStatus(str, enum.Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
LOCKED = "locked"
|
||||||
|
CLOSED = "closed"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
VALIDATED = "validated"
|
||||||
|
POSTED = "posted"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
REVERSED = "reversed"
|
||||||
|
|
||||||
|
|
||||||
|
class JournalStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
POSTED = "posted"
|
||||||
|
REVERSED = "reversed"
|
||||||
|
|
||||||
|
|
||||||
|
class PostingStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Treasury enums (Phase 5.4) ---
|
||||||
|
|
||||||
|
|
||||||
|
class ChequeStatus(str, enum.Enum):
|
||||||
|
ISSUED = "issued"
|
||||||
|
RECEIVED = "received"
|
||||||
|
DEPOSITED = "deposited"
|
||||||
|
CLEARED = "cleared"
|
||||||
|
BOUNCED = "bounced"
|
||||||
|
TRANSFERRED = "transferred"
|
||||||
|
RETURNED = "returned"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class TreasuryTransactionType(str, enum.Enum):
|
||||||
|
CASH_RECEIPT = "cash_receipt"
|
||||||
|
CASH_PAYMENT = "cash_payment"
|
||||||
|
BANK_DEPOSIT = "bank_deposit"
|
||||||
|
BANK_WITHDRAWAL = "bank_withdrawal"
|
||||||
|
CHEQUE_DEPOSIT = "cheque_deposit"
|
||||||
|
CHEQUE_CLEARANCE = "cheque_clearance"
|
||||||
|
TRANSFER = "transfer"
|
||||||
|
|
||||||
|
|
||||||
|
class ReconciliationStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# --- AR/AP enums (Phase 5.5) ---
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
ISSUED = "issued"
|
||||||
|
PARTIALLY_PAID = "partially_paid"
|
||||||
|
PAID = "paid"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
WRITTEN_OFF = "written_off"
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
REVERSED = "reversed"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sales accounting enums (Phase 5.6) ---
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueRecognitionMethod(str, enum.Enum):
|
||||||
|
IMMEDIATE = "immediate"
|
||||||
|
DEFERRED = "deferred"
|
||||||
|
PARTIAL = "partial"
|
||||||
|
MILESTONE = "milestone"
|
||||||
|
|
||||||
|
|
||||||
|
class SalesDocumentType(str, enum.Enum):
|
||||||
|
INVOICE = "invoice"
|
||||||
|
RETURN = "return"
|
||||||
|
CREDIT_NOTE = "credit_note"
|
||||||
|
DEBIT_NOTE = "debit_note"
|
||||||
|
PAYMENT = "payment"
|
||||||
|
DISCOUNT = "discount"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Purchase/Inventory enums (Phase 5.7) ---
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryValuationMethod(str, enum.Enum):
|
||||||
|
FIFO = "fifo"
|
||||||
|
WEIGHTED_AVERAGE = "weighted_average"
|
||||||
|
MOVING_AVERAGE = "moving_average"
|
||||||
|
SPECIFIC_IDENTIFICATION = "specific_identification"
|
||||||
|
STANDARD_COST = "standard_cost"
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseDocumentType(str, enum.Enum):
|
||||||
|
GOODS_RECEIPT = "goods_receipt"
|
||||||
|
SUPPLIER_INVOICE = "supplier_invoice"
|
||||||
|
PURCHASE_RETURN = "purchase_return"
|
||||||
|
COST_ADJUSTMENT = "cost_adjustment"
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryDocumentType(str, enum.Enum):
|
||||||
|
RECEIPT = "receipt"
|
||||||
|
ISSUE = "issue"
|
||||||
|
TRANSFER = "transfer"
|
||||||
|
ADJUSTMENT = "adjustment"
|
||||||
|
WRITE_OFF = "write_off"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fixed Assets enums (Phase 5.8) ---
|
||||||
|
|
||||||
|
|
||||||
|
class AssetStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
ACTIVE = "active"
|
||||||
|
DEPRECIATING = "depreciating"
|
||||||
|
FULLY_DEPRECIATED = "fully_depreciated"
|
||||||
|
DISPOSED = "disposed"
|
||||||
|
RETIRED = "retired"
|
||||||
|
|
||||||
|
|
||||||
|
class DepreciationMethod(str, enum.Enum):
|
||||||
|
STRAIGHT_LINE = "straight_line"
|
||||||
|
DECLINING_BALANCE = "declining_balance"
|
||||||
|
DOUBLE_DECLINING = "double_declining"
|
||||||
|
UNITS_OF_PRODUCTION = "units_of_production"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Payroll enums (Phase 5.9) ---
|
||||||
|
|
||||||
|
|
||||||
|
class EmploymentStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
ON_LEAVE = "on_leave"
|
||||||
|
TERMINATED = "terminated"
|
||||||
|
SUSPENDED = "suspended"
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollPeriodStatus(str, enum.Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
APPROVED = "approved"
|
||||||
|
LOCKED = "locked"
|
||||||
|
PAID = "paid"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
CALCULATED = "calculated"
|
||||||
|
APPROVED = "approved"
|
||||||
|
PAID = "paid"
|
||||||
|
REVERSED = "reversed"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Reporting enums (Phase 5.10) ---
|
||||||
|
|
||||||
|
|
||||||
|
class ReportType(str, enum.Enum):
|
||||||
|
TRIAL_BALANCE = "trial_balance"
|
||||||
|
GENERAL_LEDGER = "general_ledger"
|
||||||
|
BALANCE_SHEET = "balance_sheet"
|
||||||
|
INCOME_STATEMENT = "income_statement"
|
||||||
|
CASH_FLOW = "cash_flow"
|
||||||
|
EQUITY_STATEMENT = "equity_statement"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class ReportStatus(str, enum.Enum):
|
||||||
|
GENERATING = "generating"
|
||||||
|
GENERATED = "generated"
|
||||||
|
FAILED = "failed"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExportFormat(str, enum.Enum):
|
||||||
|
PDF = "pdf"
|
||||||
|
EXCEL = "excel"
|
||||||
|
CSV = "csv"
|
||||||
|
JSON = "json"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Compliance enums (Phase 5.11) ---
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevel(str, enum.Enum):
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyViolationSeverity(str, enum.Enum):
|
||||||
|
INFO = "info"
|
||||||
|
WARNING = "warning"
|
||||||
|
ERROR = "error"
|
||||||
|
CRITICAL = "critical"
|
||||||
133
backend/services/accounting/app/permissions/definitions.py
Normal file
133
backend/services/accounting/app/permissions/definitions.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"""Accounting permission definitions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Phase 5.1 — Foundation
|
||||||
|
ACCOUNTING_VIEW = "accounting.view"
|
||||||
|
ACCOUNTING_CREATE = "accounting.create"
|
||||||
|
ACCOUNTING_UPDATE = "accounting.update"
|
||||||
|
ACCOUNTING_DELETE = "accounting.delete"
|
||||||
|
ACCOUNTING_MANAGE = "accounting.manage"
|
||||||
|
|
||||||
|
# Phase 5.2 — Posting
|
||||||
|
ACCOUNTING_POST = "accounting.post"
|
||||||
|
ACCOUNTING_VALIDATE = "accounting.validate"
|
||||||
|
ACCOUNTING_REVERSE = "accounting.reverse"
|
||||||
|
ACCOUNTING_CANCEL = "accounting.cancel"
|
||||||
|
ACCOUNTING_VIEW_AUDIT = "accounting.view_audit"
|
||||||
|
ACCOUNTING_MANAGE_POSTING = "accounting.manage_posting"
|
||||||
|
|
||||||
|
# Phase 5.3 — Ledger & Fiscal
|
||||||
|
ACCOUNTING_LEDGER_VIEW = "accounting.ledger.view"
|
||||||
|
ACCOUNTING_LEDGER_MANAGE = "accounting.ledger.manage"
|
||||||
|
ACCOUNTING_FISCAL_VIEW = "accounting.fiscal.view"
|
||||||
|
ACCOUNTING_FISCAL_MANAGE = "accounting.fiscal.manage"
|
||||||
|
ACCOUNTING_PERIOD_CLOSE = "accounting.period.close"
|
||||||
|
ACCOUNTING_PERIOD_REOPEN = "accounting.period.reopen"
|
||||||
|
ACCOUNTING_TRIAL_BALANCE_VIEW = "accounting.trial_balance.view"
|
||||||
|
|
||||||
|
# Phase 5.4 — Treasury
|
||||||
|
TREASURY_VIEW = "treasury.view"
|
||||||
|
TREASURY_MANAGE = "treasury.manage"
|
||||||
|
TREASURY_CASH = "treasury.cash"
|
||||||
|
TREASURY_BANK = "treasury.bank"
|
||||||
|
TREASURY_CHEQUE = "treasury.cheque"
|
||||||
|
TREASURY_PAYMENT = "treasury.payment"
|
||||||
|
TREASURY_TRANSFER = "treasury.transfer"
|
||||||
|
TREASURY_RECONCILIATION = "treasury.reconciliation"
|
||||||
|
|
||||||
|
# Phase 5.5 — AR/AP
|
||||||
|
RECEIVABLE_VIEW = "receivable.view"
|
||||||
|
RECEIVABLE_MANAGE = "receivable.manage"
|
||||||
|
RECEIVABLE_RECEIVE_PAYMENT = "receivable.receive_payment"
|
||||||
|
RECEIVABLE_STATEMENT = "receivable.statement"
|
||||||
|
PAYABLE_VIEW = "payable.view"
|
||||||
|
PAYABLE_MANAGE = "payable.manage"
|
||||||
|
PAYABLE_MAKE_PAYMENT = "payable.make_payment"
|
||||||
|
PAYABLE_STATEMENT = "payable.statement"
|
||||||
|
SETTLEMENT_MANAGE = "settlement.manage"
|
||||||
|
|
||||||
|
# Phase 5.6 — Sales Accounting
|
||||||
|
SALES_ACCOUNTING_VIEW = "sales_accounting.view"
|
||||||
|
SALES_ACCOUNTING_POST = "sales_accounting.post"
|
||||||
|
SALES_ACCOUNTING_CONFIGURE = "sales_accounting.configure"
|
||||||
|
SALES_ACCOUNTING_REVENUE_RECOGNITION = "sales_accounting.revenue_recognition"
|
||||||
|
SALES_ACCOUNTING_PREVIEW = "sales_accounting.preview"
|
||||||
|
SALES_ACCOUNTING_MANAGE = "sales_accounting.manage"
|
||||||
|
|
||||||
|
# Phase 5.7 — Purchase/Inventory Accounting
|
||||||
|
PURCHASE_ACCOUNTING_VIEW = "purchase_accounting.view"
|
||||||
|
PURCHASE_ACCOUNTING_POST = "purchase_accounting.post"
|
||||||
|
PURCHASE_ACCOUNTING_CONFIGURE = "purchase_accounting.configure"
|
||||||
|
INVENTORY_ACCOUNTING_VIEW = "inventory_accounting.view"
|
||||||
|
INVENTORY_ACCOUNTING_POST = "inventory_accounting.post"
|
||||||
|
INVENTORY_ACCOUNTING_CONFIGURE = "inventory_accounting.configure"
|
||||||
|
INVENTORY_VALUATION_MANAGE = "inventory_valuation.manage"
|
||||||
|
POSTING_SIMULATION_EXECUTE = "posting_simulation.execute"
|
||||||
|
|
||||||
|
# Phase 5.8 — Fixed Assets
|
||||||
|
ASSETS_VIEW = "assets.view"
|
||||||
|
ASSETS_CREATE = "assets.create"
|
||||||
|
ASSETS_UPDATE = "assets.update"
|
||||||
|
ASSETS_DEPRECIATE = "assets.depreciate"
|
||||||
|
ASSETS_TRANSFER = "assets.transfer"
|
||||||
|
ASSETS_DISPOSE = "assets.dispose"
|
||||||
|
ASSETS_REVALUE = "assets.revalue"
|
||||||
|
ASSETS_MANAGE = "assets.manage"
|
||||||
|
|
||||||
|
# Phase 5.9 — Payroll
|
||||||
|
HR_VIEW = "hr.view"
|
||||||
|
HR_MANAGE = "hr.manage"
|
||||||
|
PAYROLL_VIEW = "payroll.view"
|
||||||
|
PAYROLL_CALCULATE = "payroll.calculate"
|
||||||
|
PAYROLL_APPROVE = "payroll.approve"
|
||||||
|
PAYROLL_PAY = "payroll.pay"
|
||||||
|
PAYROLL_REVERSE = "payroll.reverse"
|
||||||
|
PAYROLL_CONFIGURE = "payroll.configure"
|
||||||
|
|
||||||
|
# Phase 5.10 — Reporting
|
||||||
|
REPORTS_VIEW = "reports.view"
|
||||||
|
REPORTS_EXPORT = "reports.export"
|
||||||
|
REPORTS_SCHEDULE = "reports.schedule"
|
||||||
|
REPORTS_BUILDER = "reports.builder"
|
||||||
|
DASHBOARD_VIEW = "dashboard.view"
|
||||||
|
DASHBOARD_MANAGE = "dashboard.manage"
|
||||||
|
ANALYTICS_VIEW = "analytics.view"
|
||||||
|
FINANCIAL_STATEMENTS_VIEW = "financial_statements.view"
|
||||||
|
|
||||||
|
# Phase 5.11 — Compliance
|
||||||
|
AUDIT_VIEW = "audit.view"
|
||||||
|
AUDIT_EXPORT = "audit.export"
|
||||||
|
COMPLIANCE_VIEW = "compliance.view"
|
||||||
|
COMPLIANCE_MANAGE = "compliance.manage"
|
||||||
|
GOVERNANCE_VIEW = "governance.view"
|
||||||
|
GOVERNANCE_MANAGE = "governance.manage"
|
||||||
|
APPROVAL_EXECUTE = "approval.execute"
|
||||||
|
RISK_VIEW = "risk.view"
|
||||||
|
RISK_MANAGE = "risk.manage"
|
||||||
|
|
||||||
|
ALL_PERMISSIONS = [
|
||||||
|
ACCOUNTING_VIEW, ACCOUNTING_CREATE, ACCOUNTING_UPDATE, ACCOUNTING_DELETE,
|
||||||
|
ACCOUNTING_MANAGE, ACCOUNTING_POST, ACCOUNTING_VALIDATE, ACCOUNTING_REVERSE,
|
||||||
|
ACCOUNTING_CANCEL, ACCOUNTING_VIEW_AUDIT, ACCOUNTING_MANAGE_POSTING,
|
||||||
|
ACCOUNTING_LEDGER_VIEW, ACCOUNTING_LEDGER_MANAGE, ACCOUNTING_FISCAL_VIEW,
|
||||||
|
ACCOUNTING_FISCAL_MANAGE, ACCOUNTING_PERIOD_CLOSE, ACCOUNTING_PERIOD_REOPEN,
|
||||||
|
ACCOUNTING_TRIAL_BALANCE_VIEW, TREASURY_VIEW, TREASURY_MANAGE, TREASURY_CASH,
|
||||||
|
TREASURY_BANK, TREASURY_CHEQUE, TREASURY_PAYMENT, TREASURY_TRANSFER,
|
||||||
|
TREASURY_RECONCILIATION, RECEIVABLE_VIEW, RECEIVABLE_MANAGE,
|
||||||
|
RECEIVABLE_RECEIVE_PAYMENT, RECEIVABLE_STATEMENT, PAYABLE_VIEW, PAYABLE_MANAGE,
|
||||||
|
PAYABLE_MAKE_PAYMENT, PAYABLE_STATEMENT, SETTLEMENT_MANAGE,
|
||||||
|
SALES_ACCOUNTING_VIEW, SALES_ACCOUNTING_POST, SALES_ACCOUNTING_CONFIGURE,
|
||||||
|
SALES_ACCOUNTING_REVENUE_RECOGNITION, SALES_ACCOUNTING_PREVIEW,
|
||||||
|
SALES_ACCOUNTING_MANAGE,
|
||||||
|
PURCHASE_ACCOUNTING_VIEW, PURCHASE_ACCOUNTING_POST, PURCHASE_ACCOUNTING_CONFIGURE,
|
||||||
|
INVENTORY_ACCOUNTING_VIEW, INVENTORY_ACCOUNTING_POST, INVENTORY_ACCOUNTING_CONFIGURE,
|
||||||
|
INVENTORY_VALUATION_MANAGE, POSTING_SIMULATION_EXECUTE,
|
||||||
|
ASSETS_VIEW, ASSETS_CREATE, ASSETS_UPDATE, ASSETS_DEPRECIATE, ASSETS_TRANSFER,
|
||||||
|
ASSETS_DISPOSE, ASSETS_REVALUE, ASSETS_MANAGE,
|
||||||
|
HR_VIEW, HR_MANAGE, PAYROLL_VIEW, PAYROLL_CALCULATE, PAYROLL_APPROVE,
|
||||||
|
PAYROLL_PAY, PAYROLL_REVERSE, PAYROLL_CONFIGURE,
|
||||||
|
REPORTS_VIEW, REPORTS_EXPORT, REPORTS_SCHEDULE, REPORTS_BUILDER,
|
||||||
|
DASHBOARD_VIEW, DASHBOARD_MANAGE, ANALYTICS_VIEW, FINANCIAL_STATEMENTS_VIEW,
|
||||||
|
AUDIT_VIEW, AUDIT_EXPORT, COMPLIANCE_VIEW, COMPLIANCE_MANAGE,
|
||||||
|
GOVERNANCE_VIEW, GOVERNANCE_MANAGE, APPROVAL_EXECUTE, RISK_VIEW, RISK_MANAGE,
|
||||||
|
]
|
||||||
57
backend/services/accounting/app/repositories/base.py
Normal file
57
backend/services/accounting/app/repositories/base.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""Tenant-aware base repository."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generic, Sequence, TypeVar
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
ModelT = TypeVar("ModelT", bound=Base)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantBaseRepository(Generic[ModelT]):
|
||||||
|
model: type[ModelT]
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
|
||||||
|
self.model.id == entity_id, # type: ignore[attr-defined]
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def add(self, entity: ModelT) -> ModelT:
|
||||||
|
self.session.add(entity)
|
||||||
|
await self.session.flush()
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def delete(self, entity: ModelT) -> None:
|
||||||
|
await self.session.delete(entity)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def list_by_tenant(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||||
|
) -> Sequence[ModelT]:
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(self.model.tenant_id == tenant_id) # type: ignore[attr-defined]
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_by_tenant(self, tenant_id: UUID) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(self.model)
|
||||||
|
.where(self.model.tenant_id == tenant_id) # type: ignore[attr-defined]
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
107
backend/services/accounting/app/repositories/foundation.py
Normal file
107
backend/services/accounting/app/repositories/foundation.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""Foundation repositories."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.foundation import (
|
||||||
|
Account,
|
||||||
|
ChartOfAccounts,
|
||||||
|
CostCenter,
|
||||||
|
Currency,
|
||||||
|
FiscalPeriod,
|
||||||
|
FiscalYear,
|
||||||
|
Project,
|
||||||
|
TenantAccountingConfiguration,
|
||||||
|
)
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ChartOfAccountsRepository(TenantBaseRepository[ChartOfAccounts]):
|
||||||
|
model = ChartOfAccounts
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> ChartOfAccounts | None:
|
||||||
|
stmt = select(ChartOfAccounts).where(
|
||||||
|
ChartOfAccounts.tenant_id == tenant_id,
|
||||||
|
ChartOfAccounts.code == code,
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class AccountRepository(TenantBaseRepository[Account]):
|
||||||
|
model = Account
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> Account | None:
|
||||||
|
stmt = select(Account).where(
|
||||||
|
Account.tenant_id == tenant_id, Account.code == code
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_by_chart(
|
||||||
|
self, tenant_id: UUID, chart_id: UUID, *, offset: int = 0, limit: int = 100
|
||||||
|
):
|
||||||
|
stmt = (
|
||||||
|
select(Account)
|
||||||
|
.where(Account.tenant_id == tenant_id, Account.chart_id == chart_id)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyRepository(TenantBaseRepository[Currency]):
|
||||||
|
model = Currency
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalYearRepository(TenantBaseRepository[FiscalYear]):
|
||||||
|
model = FiscalYear
|
||||||
|
|
||||||
|
async def get_current(self, tenant_id: UUID) -> FiscalYear | None:
|
||||||
|
stmt = select(FiscalYear).where(
|
||||||
|
FiscalYear.tenant_id == tenant_id, FiscalYear.is_current.is_(True)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalPeriodRepository(TenantBaseRepository[FiscalPeriod]):
|
||||||
|
model = FiscalPeriod
|
||||||
|
|
||||||
|
async def get_current(self, tenant_id: UUID) -> FiscalPeriod | None:
|
||||||
|
stmt = select(FiscalPeriod).where(
|
||||||
|
FiscalPeriod.tenant_id == tenant_id, FiscalPeriod.is_current.is_(True)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_by_year(self, tenant_id: UUID, fiscal_year_id: UUID):
|
||||||
|
stmt = select(FiscalPeriod).where(
|
||||||
|
FiscalPeriod.tenant_id == tenant_id,
|
||||||
|
FiscalPeriod.fiscal_year_id == fiscal_year_id,
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class CostCenterRepository(TenantBaseRepository[CostCenter]):
|
||||||
|
model = CostCenter
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectRepository(TenantBaseRepository[Project]):
|
||||||
|
model = Project
|
||||||
|
|
||||||
|
|
||||||
|
class TenantAccountingConfigRepository(TenantBaseRepository[TenantAccountingConfiguration]):
|
||||||
|
model = TenantAccountingConfiguration
|
||||||
|
|
||||||
|
async def get_for_tenant(self, tenant_id: UUID) -> TenantAccountingConfiguration | None:
|
||||||
|
stmt = select(TenantAccountingConfiguration).where(
|
||||||
|
TenantAccountingConfiguration.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
59
backend/services/accounting/app/repositories/ledger.py
Normal file
59
backend/services/accounting/app/repositories/ledger.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""Ledger repositories."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.ledger import (
|
||||||
|
GeneralLedger,
|
||||||
|
LedgerBalance,
|
||||||
|
TrialBalanceSnapshot,
|
||||||
|
)
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class GeneralLedgerRepository(TenantBaseRepository[GeneralLedger]):
|
||||||
|
model = GeneralLedger
|
||||||
|
|
||||||
|
async def list_by_account_period(
|
||||||
|
self, tenant_id: UUID, account_id: UUID, fiscal_period_id: UUID
|
||||||
|
):
|
||||||
|
stmt = (
|
||||||
|
select(GeneralLedger)
|
||||||
|
.where(
|
||||||
|
GeneralLedger.tenant_id == tenant_id,
|
||||||
|
GeneralLedger.account_id == account_id,
|
||||||
|
GeneralLedger.fiscal_period_id == fiscal_period_id,
|
||||||
|
)
|
||||||
|
.order_by(GeneralLedger.entry_date)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerBalanceRepository(TenantBaseRepository[LedgerBalance]):
|
||||||
|
model = LedgerBalance
|
||||||
|
|
||||||
|
async def get_by_account_period(
|
||||||
|
self, tenant_id: UUID, account_id: UUID, fiscal_period_id: UUID
|
||||||
|
) -> LedgerBalance | None:
|
||||||
|
stmt = select(LedgerBalance).where(
|
||||||
|
LedgerBalance.tenant_id == tenant_id,
|
||||||
|
LedgerBalance.account_id == account_id,
|
||||||
|
LedgerBalance.fiscal_period_id == fiscal_period_id,
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_by_period(self, tenant_id: UUID, fiscal_period_id: UUID):
|
||||||
|
stmt = select(LedgerBalance).where(
|
||||||
|
LedgerBalance.tenant_id == tenant_id,
|
||||||
|
LedgerBalance.fiscal_period_id == fiscal_period_id,
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class TrialBalanceSnapshotRepository(TenantBaseRepository[TrialBalanceSnapshot]):
|
||||||
|
model = TrialBalanceSnapshot
|
||||||
108
backend/services/accounting/app/repositories/posting.py
Normal file
108
backend/services/accounting/app/repositories/posting.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"""Posting repositories."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.posting import (
|
||||||
|
AccountingAuditLog,
|
||||||
|
JournalEntry,
|
||||||
|
PostingError,
|
||||||
|
PostingLog,
|
||||||
|
PostingReference,
|
||||||
|
Voucher,
|
||||||
|
VoucherLine,
|
||||||
|
)
|
||||||
|
from app.models.types import VoucherStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherRepository(TenantBaseRepository[Voucher]):
|
||||||
|
model = Voucher
|
||||||
|
|
||||||
|
async def get_with_lines(self, tenant_id: UUID, voucher_id: UUID) -> Voucher | None:
|
||||||
|
stmt = (
|
||||||
|
select(Voucher)
|
||||||
|
.options(selectinload(Voucher.lines))
|
||||||
|
.where(Voucher.tenant_id == tenant_id, Voucher.id == voucher_id)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_number(self, tenant_id: UUID, voucher_number: str) -> Voucher | None:
|
||||||
|
stmt = select(Voucher).where(
|
||||||
|
Voucher.tenant_id == tenant_id, Voucher.voucher_number == voucher_number
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_by_status(self, tenant_id: UUID, status: VoucherStatus, *, limit: int = 50):
|
||||||
|
stmt = (
|
||||||
|
select(Voucher)
|
||||||
|
.where(Voucher.tenant_id == tenant_id, Voucher.status == status)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherLineRepository(TenantBaseRepository[VoucherLine]):
|
||||||
|
model = VoucherLine
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryRepository(TenantBaseRepository[JournalEntry]):
|
||||||
|
model = JournalEntry
|
||||||
|
|
||||||
|
async def list_by_voucher(self, tenant_id: UUID, voucher_id: UUID):
|
||||||
|
stmt = select(JournalEntry).where(
|
||||||
|
JournalEntry.tenant_id == tenant_id,
|
||||||
|
JournalEntry.voucher_id == voucher_id,
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def list_by_account(self, tenant_id: UUID, account_id: UUID, *, limit: int = 100):
|
||||||
|
stmt = (
|
||||||
|
select(JournalEntry)
|
||||||
|
.where(
|
||||||
|
JournalEntry.tenant_id == tenant_id,
|
||||||
|
JournalEntry.account_id == account_id,
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class PostingLogRepository(TenantBaseRepository[PostingLog]):
|
||||||
|
model = PostingLog
|
||||||
|
|
||||||
|
|
||||||
|
class PostingErrorRepository(TenantBaseRepository[PostingError]):
|
||||||
|
model = PostingError
|
||||||
|
|
||||||
|
|
||||||
|
class PostingReferenceRepository(TenantBaseRepository[PostingReference]):
|
||||||
|
model = PostingReference
|
||||||
|
|
||||||
|
async def get_by_source(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
source_module: str,
|
||||||
|
source_document_type: str,
|
||||||
|
source_document_id: str,
|
||||||
|
) -> PostingReference | None:
|
||||||
|
stmt = select(PostingReference).where(
|
||||||
|
PostingReference.tenant_id == tenant_id,
|
||||||
|
PostingReference.source_module == source_module,
|
||||||
|
PostingReference.source_document_type == source_document_type,
|
||||||
|
PostingReference.source_document_id == source_document_id,
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogRepository(TenantBaseRepository[AccountingAuditLog]):
|
||||||
|
model = AccountingAuditLog
|
||||||
20
backend/services/accounting/app/schemas/common.py
Normal file
20
backend/services/accounting/app/schemas/common.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""Common schemas."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ORMBase(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class IdResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
message: str
|
||||||
243
backend/services/accounting/app/schemas/foundation.py
Normal file
243
backend/services/accounting/app/schemas/foundation.py
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
"""Foundation DTOs."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.types import AccountCategory, AccountStatus, AccountType, FiscalPeriodStatus
|
||||||
|
from app.schemas.common import ORMBase
|
||||||
|
|
||||||
|
|
||||||
|
class ChartOfAccountsCreate(BaseModel):
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
description: str | None = None
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ChartOfAccountsUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
is_default: bool | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChartOfAccountsRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
description: str | None
|
||||||
|
is_default: bool
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCreate(BaseModel):
|
||||||
|
chart_id: UUID
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
account_type: AccountType
|
||||||
|
account_category: AccountCategory
|
||||||
|
level: int = 1
|
||||||
|
is_group: bool = False
|
||||||
|
is_postable: bool = True
|
||||||
|
currency_id: UUID | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountUpdate(BaseModel):
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
account_type: AccountType | None = None
|
||||||
|
account_category: AccountCategory | None = None
|
||||||
|
level: int | None = None
|
||||||
|
is_group: bool | None = None
|
||||||
|
is_postable: bool | None = None
|
||||||
|
currency_id: UUID | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: AccountStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
chart_id: UUID
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
account_type: AccountType
|
||||||
|
account_category: AccountCategory
|
||||||
|
level: int
|
||||||
|
is_group: bool
|
||||||
|
is_postable: bool
|
||||||
|
status: AccountStatus
|
||||||
|
description: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyCreate(BaseModel):
|
||||||
|
code: str = Field(max_length=3)
|
||||||
|
name: str = Field(max_length=100)
|
||||||
|
symbol: str | None = None
|
||||||
|
decimal_places: int = 2
|
||||||
|
is_base: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, max_length=100)
|
||||||
|
symbol: str | None = None
|
||||||
|
decimal_places: int | None = None
|
||||||
|
is_base: bool | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
symbol: str | None
|
||||||
|
is_base: bool
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalYearCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
is_current: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalYearUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
is_current: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalYearRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
name: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
is_current: bool
|
||||||
|
is_closed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalPeriodCreate(BaseModel):
|
||||||
|
fiscal_year_id: UUID
|
||||||
|
name: str
|
||||||
|
period_number: int
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
is_current: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalPeriodUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
is_current: bool | None = None
|
||||||
|
status: FiscalPeriodStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalPeriodRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
fiscal_year_id: UUID
|
||||||
|
name: str
|
||||||
|
period_number: int
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
status: FiscalPeriodStatus
|
||||||
|
is_current: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CostCenterCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CostCenterUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CostCenterRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SetupStatusRead(BaseModel):
|
||||||
|
has_chart: bool
|
||||||
|
has_accounts: bool
|
||||||
|
has_currency: bool
|
||||||
|
has_base_currency: bool
|
||||||
|
has_fiscal_year: bool
|
||||||
|
has_fiscal_period: bool
|
||||||
|
has_cash_box: bool
|
||||||
|
has_customer: bool
|
||||||
|
has_voucher: bool
|
||||||
|
completed_steps: int
|
||||||
|
total_steps: int
|
||||||
|
percent: int
|
||||||
|
|
||||||
|
|
||||||
|
class CoaTemplateAccount(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
account_type: AccountType
|
||||||
|
account_category: AccountCategory
|
||||||
|
level: int = 1
|
||||||
|
is_group: bool = False
|
||||||
|
is_postable: bool = True
|
||||||
|
parent_code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CoaTemplateRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
business_type: str
|
||||||
|
accounts: list[CoaTemplateAccount]
|
||||||
|
|
||||||
|
|
||||||
|
class CoaTemplateImportRequest(BaseModel):
|
||||||
|
chart_id: UUID | None = None
|
||||||
|
chart_name: str | None = None
|
||||||
|
chart_code: str | None = None
|
||||||
81
backend/services/accounting/app/schemas/posting.py
Normal file
81
backend/services/accounting/app/schemas/posting.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""Posting DTOs."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.types import VoucherStatus
|
||||||
|
from app.schemas.common import ORMBase
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherLineCreate(BaseModel):
|
||||||
|
account_id: UUID
|
||||||
|
debit: Decimal = Decimal("0")
|
||||||
|
credit: Decimal = Decimal("0")
|
||||||
|
description: str | None = None
|
||||||
|
cost_center_id: UUID | None = None
|
||||||
|
project_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherCreate(BaseModel):
|
||||||
|
fiscal_period_id: UUID
|
||||||
|
voucher_number: str = Field(max_length=50)
|
||||||
|
voucher_date: date
|
||||||
|
description: str | None = None
|
||||||
|
reference_number: str | None = None
|
||||||
|
source_module: str | None = None
|
||||||
|
lines: list[VoucherLineCreate] = Field(min_length=2)
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherUpdate(BaseModel):
|
||||||
|
voucher_date: date | None = None
|
||||||
|
description: str | None = None
|
||||||
|
reference_number: str | None = None
|
||||||
|
lines: list[VoucherLineCreate] | None = Field(default=None, min_length=2)
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherLineRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
line_number: int
|
||||||
|
account_id: UUID
|
||||||
|
debit: Decimal
|
||||||
|
credit: Decimal
|
||||||
|
description: str | None
|
||||||
|
cost_center_id: UUID | None = None
|
||||||
|
project_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
fiscal_period_id: UUID
|
||||||
|
voucher_number: str
|
||||||
|
voucher_date: date
|
||||||
|
status: VoucherStatus
|
||||||
|
description: str | None
|
||||||
|
total_debit: Decimal
|
||||||
|
total_credit: Decimal
|
||||||
|
posted_at: datetime | None
|
||||||
|
lines: list[VoucherLineRead] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PostRequest(BaseModel):
|
||||||
|
source_module: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReverseRequest(BaseModel):
|
||||||
|
reversal_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
operation: str
|
||||||
|
actor_user_id: str
|
||||||
|
voucher_number: str | None
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
created_at: datetime
|
||||||
133
backend/services/accounting/app/services/aging_engine.py
Normal file
133
backend/services/accounting/app/services/aging_engine.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"""Aging Engine for AR/AP."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.receivable_payable import (
|
||||||
|
AgingSnapshot,
|
||||||
|
PayableInvoice,
|
||||||
|
ReceivableInvoice,
|
||||||
|
)
|
||||||
|
from app.models.types import InvoiceStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class AgingSnapshotRepository(TenantBaseRepository[AgingSnapshot]):
|
||||||
|
model = AgingSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
class AgingEngine:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.snapshot_repo = AgingSnapshotRepository(session)
|
||||||
|
|
||||||
|
async def calculate_customer_aging(
|
||||||
|
self, tenant_id: UUID, customer_id: UUID, as_of: date | None = None
|
||||||
|
) -> AgingSnapshot:
|
||||||
|
as_of = as_of or date.today()
|
||||||
|
stmt = select(ReceivableInvoice).where(
|
||||||
|
ReceivableInvoice.tenant_id == tenant_id,
|
||||||
|
ReceivableInvoice.customer_id == customer_id,
|
||||||
|
ReceivableInvoice.status.in_([
|
||||||
|
InvoiceStatus.ISSUED,
|
||||||
|
InvoiceStatus.PARTIALLY_PAID,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
invoices = result.scalars().all()
|
||||||
|
|
||||||
|
buckets = {
|
||||||
|
"current": Decimal("0"),
|
||||||
|
"1_30": Decimal("0"),
|
||||||
|
"31_60": Decimal("0"),
|
||||||
|
"61_90": Decimal("0"),
|
||||||
|
"90_plus": Decimal("0"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for inv in invoices:
|
||||||
|
due = inv.due_date or inv.invoice_date
|
||||||
|
days = (as_of - due).days
|
||||||
|
amount = inv.remaining_amount
|
||||||
|
if days <= 0:
|
||||||
|
buckets["current"] += amount
|
||||||
|
elif days <= 30:
|
||||||
|
buckets["1_30"] += amount
|
||||||
|
elif days <= 60:
|
||||||
|
buckets["31_60"] += amount
|
||||||
|
elif days <= 90:
|
||||||
|
buckets["61_90"] += amount
|
||||||
|
else:
|
||||||
|
buckets["90_plus"] += amount
|
||||||
|
|
||||||
|
total = sum(buckets.values())
|
||||||
|
snapshot = AgingSnapshot(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
party_type="customer",
|
||||||
|
party_id=customer_id,
|
||||||
|
snapshot_date=as_of,
|
||||||
|
current_amount=buckets["current"],
|
||||||
|
days_1_30=buckets["1_30"],
|
||||||
|
days_31_60=buckets["31_60"],
|
||||||
|
days_61_90=buckets["61_90"],
|
||||||
|
days_90_plus=buckets["90_plus"],
|
||||||
|
total_outstanding=total,
|
||||||
|
)
|
||||||
|
return await self.snapshot_repo.add(snapshot)
|
||||||
|
|
||||||
|
async def calculate_supplier_aging(
|
||||||
|
self, tenant_id: UUID, supplier_id: UUID, as_of: date | None = None
|
||||||
|
) -> AgingSnapshot:
|
||||||
|
as_of = as_of or date.today()
|
||||||
|
stmt = select(PayableInvoice).where(
|
||||||
|
PayableInvoice.tenant_id == tenant_id,
|
||||||
|
PayableInvoice.supplier_id == supplier_id,
|
||||||
|
PayableInvoice.status.in_([
|
||||||
|
InvoiceStatus.ISSUED,
|
||||||
|
InvoiceStatus.PARTIALLY_PAID,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
invoices = result.scalars().all()
|
||||||
|
|
||||||
|
buckets = {
|
||||||
|
"current": Decimal("0"),
|
||||||
|
"1_30": Decimal("0"),
|
||||||
|
"31_60": Decimal("0"),
|
||||||
|
"61_90": Decimal("0"),
|
||||||
|
"90_plus": Decimal("0"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for inv in invoices:
|
||||||
|
due = inv.due_date or inv.invoice_date
|
||||||
|
days = (as_of - due).days
|
||||||
|
amount = inv.remaining_amount
|
||||||
|
if days <= 0:
|
||||||
|
buckets["current"] += amount
|
||||||
|
elif days <= 30:
|
||||||
|
buckets["1_30"] += amount
|
||||||
|
elif days <= 60:
|
||||||
|
buckets["31_60"] += amount
|
||||||
|
elif days <= 90:
|
||||||
|
buckets["61_90"] += amount
|
||||||
|
else:
|
||||||
|
buckets["90_plus"] += amount
|
||||||
|
|
||||||
|
total = sum(buckets.values())
|
||||||
|
snapshot = AgingSnapshot(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
party_type="supplier",
|
||||||
|
party_id=supplier_id,
|
||||||
|
snapshot_date=as_of,
|
||||||
|
current_amount=buckets["current"],
|
||||||
|
days_1_30=buckets["1_30"],
|
||||||
|
days_31_60=buckets["31_60"],
|
||||||
|
days_61_90=buckets["61_90"],
|
||||||
|
days_90_plus=buckets["90_plus"],
|
||||||
|
total_outstanding=total,
|
||||||
|
)
|
||||||
|
return await self.snapshot_repo.add(snapshot)
|
||||||
175
backend/services/accounting/app/services/asset_service.py
Normal file
175
backend/services/accounting/app/services/asset_service.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
"""Phase 5.8 — Depreciation Engine & Asset accounting."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.fixed_assets import Asset, AssetDepreciation, AssetHistory, DepreciationSchedule
|
||||||
|
from app.models.posting import Voucher, VoucherLine
|
||||||
|
from app.models.types import AssetStatus, DepreciationMethod, VoucherStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.repositories.foundation import FiscalPeriodRepository
|
||||||
|
from app.repositories.posting import VoucherRepository
|
||||||
|
from app.services.posting_engine import PostingEngine
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class AssetRepo(TenantBaseRepository[Asset]):
|
||||||
|
model = Asset
|
||||||
|
|
||||||
|
|
||||||
|
class DepreciationScheduleRepo(TenantBaseRepository[DepreciationSchedule]):
|
||||||
|
model = DepreciationSchedule
|
||||||
|
|
||||||
|
|
||||||
|
class AssetDepreciationRepo(TenantBaseRepository[AssetDepreciation]):
|
||||||
|
model = AssetDepreciation
|
||||||
|
|
||||||
|
|
||||||
|
class AssetAccountingError(AppError):
|
||||||
|
status_code = 422
|
||||||
|
error_code = "asset_accounting_error"
|
||||||
|
|
||||||
|
|
||||||
|
class DepreciationEngine:
|
||||||
|
"""Centralized depreciation calculation engine."""
|
||||||
|
|
||||||
|
def calculate(
|
||||||
|
self,
|
||||||
|
asset: Asset,
|
||||||
|
*,
|
||||||
|
period_months: int = 1,
|
||||||
|
) -> Decimal:
|
||||||
|
depreciable = asset.acquisition_cost - asset.residual_value
|
||||||
|
if depreciable <= 0 or asset.useful_life_months <= 0:
|
||||||
|
return Decimal("0")
|
||||||
|
|
||||||
|
if asset.depreciation_method == DepreciationMethod.STRAIGHT_LINE:
|
||||||
|
monthly = depreciable / asset.useful_life_months
|
||||||
|
return (monthly * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
if asset.depreciation_method == DepreciationMethod.DECLINING_BALANCE:
|
||||||
|
rate = Decimal("2") / asset.useful_life_months
|
||||||
|
return (asset.current_book_value * rate * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
if asset.depreciation_method == DepreciationMethod.DOUBLE_DECLINING:
|
||||||
|
rate = Decimal("2") / asset.useful_life_months * 2
|
||||||
|
return (asset.current_book_value * rate * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
return (depreciable / asset.useful_life_months * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
def generate_schedule(self, asset: Asset) -> list[dict]:
|
||||||
|
schedule = []
|
||||||
|
book_value = asset.acquisition_cost
|
||||||
|
for period in range(1, asset.useful_life_months + 1):
|
||||||
|
amount = self.calculate(asset, period_months=1)
|
||||||
|
if book_value - amount < asset.residual_value:
|
||||||
|
amount = book_value - asset.residual_value
|
||||||
|
if amount <= 0:
|
||||||
|
break
|
||||||
|
schedule.append({"period": period, "amount": amount, "book_value_after": book_value - amount})
|
||||||
|
book_value -= amount
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
class AssetAccountingService:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.asset_repo = AssetRepo(session)
|
||||||
|
self.schedule_repo = DepreciationScheduleRepo(session)
|
||||||
|
self.depreciation_repo = AssetDepreciationRepo(session)
|
||||||
|
self.voucher_repo = VoucherRepository(session)
|
||||||
|
self.period_repo = FiscalPeriodRepository(session)
|
||||||
|
self.posting_engine = PostingEngine(session)
|
||||||
|
self.depreciation_engine = DepreciationEngine()
|
||||||
|
|
||||||
|
async def activate_asset(self, tenant_id: UUID, asset_id: UUID, actor_user_id: str) -> Asset:
|
||||||
|
asset = await self.asset_repo.get(tenant_id, asset_id)
|
||||||
|
if asset is None:
|
||||||
|
raise NotFoundError("دارایی یافت نشد", error_code="asset_not_found")
|
||||||
|
if asset.status != AssetStatus.DRAFT:
|
||||||
|
raise AssetAccountingError("فقط داراییهای پیشنویس قابل فعالسازی هستند")
|
||||||
|
asset.status = AssetStatus.ACTIVE
|
||||||
|
asset.current_book_value = asset.acquisition_cost
|
||||||
|
self._record_history(tenant_id, asset_id, "activated", asset.acquisition_cost, asset.acquisition_cost)
|
||||||
|
return asset
|
||||||
|
|
||||||
|
async def depreciate_asset(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
*,
|
||||||
|
expense_account_id: UUID,
|
||||||
|
accumulated_account_id: UUID,
|
||||||
|
actor_user_id: str,
|
||||||
|
) -> AssetDepreciation:
|
||||||
|
asset = await self.asset_repo.get(tenant_id, asset_id)
|
||||||
|
if asset is None:
|
||||||
|
raise NotFoundError("دارایی یافت نشد", error_code="asset_not_found")
|
||||||
|
if asset.status not in (AssetStatus.ACTIVE, AssetStatus.DEPRECIATING):
|
||||||
|
raise AssetAccountingError("دارایی فعال نیست")
|
||||||
|
|
||||||
|
amount = self.depreciation_engine.calculate(asset)
|
||||||
|
if amount <= 0:
|
||||||
|
raise AssetAccountingError("مبلغ استهلاک صفر است")
|
||||||
|
|
||||||
|
period = await self.period_repo.get_current(tenant_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
|
||||||
|
|
||||||
|
voucher = Voucher(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fiscal_period_id=period.id,
|
||||||
|
voucher_number=f"DEP-{asset.code}-{date.today().isoformat()}",
|
||||||
|
voucher_date=date.today(),
|
||||||
|
status=VoucherStatus.DRAFT,
|
||||||
|
description=f"Depreciation {asset.code}",
|
||||||
|
source_module="assets",
|
||||||
|
reference_number=str(asset_id),
|
||||||
|
created_by=actor_user_id,
|
||||||
|
)
|
||||||
|
await self.voucher_repo.add(voucher)
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
|
||||||
|
account_id=expense_account_id, debit=amount, credit=Decimal("0"),
|
||||||
|
))
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
|
||||||
|
account_id=accumulated_account_id, debit=Decimal("0"), credit=amount,
|
||||||
|
))
|
||||||
|
await self.session.flush()
|
||||||
|
posted = await self.posting_engine.post_voucher(
|
||||||
|
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
accumulated = asset.acquisition_cost - asset.current_book_value + amount
|
||||||
|
asset.current_book_value -= amount
|
||||||
|
asset.status = AssetStatus.DEPRECIATING if asset.current_book_value > asset.residual_value else AssetStatus.FULLY_DEPRECIATED
|
||||||
|
|
||||||
|
dep = AssetDepreciation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
fiscal_period_id=period.id,
|
||||||
|
depreciation_date=date.today(),
|
||||||
|
amount=amount,
|
||||||
|
accumulated_depreciation=accumulated,
|
||||||
|
book_value_after=asset.current_book_value,
|
||||||
|
voucher_id=posted.id,
|
||||||
|
)
|
||||||
|
return await self.depreciation_repo.add(dep)
|
||||||
|
|
||||||
|
def _record_history(
|
||||||
|
self, tenant_id: UUID, asset_id: UUID, event_type: str,
|
||||||
|
before: Decimal, after: Decimal,
|
||||||
|
) -> None:
|
||||||
|
self.session.add(AssetHistory(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
event_type=event_type,
|
||||||
|
event_date=date.today(),
|
||||||
|
before_value=str(before),
|
||||||
|
after_value=str(after),
|
||||||
|
))
|
||||||
152
backend/services/accounting/app/services/balance_engine.py
Normal file
152
backend/services/accounting/app/services/balance_engine.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
"""Balance Engine — centralized balance calculations."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.ledger import GeneralLedger, LedgerBalance, TrialBalanceSnapshot
|
||||||
|
from app.models.posting import JournalEntry
|
||||||
|
from app.models.types import JournalStatus
|
||||||
|
from app.repositories.foundation import AccountRepository
|
||||||
|
from app.repositories.ledger import (
|
||||||
|
GeneralLedgerRepository,
|
||||||
|
LedgerBalanceRepository,
|
||||||
|
TrialBalanceSnapshotRepository,
|
||||||
|
)
|
||||||
|
from app.repositories.posting import JournalEntryRepository
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceEngine:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.gl_repo = GeneralLedgerRepository(session)
|
||||||
|
self.balance_repo = LedgerBalanceRepository(session)
|
||||||
|
self.journal_repo = JournalEntryRepository(session)
|
||||||
|
self.account_repo = AccountRepository(session)
|
||||||
|
self.trial_repo = TrialBalanceSnapshotRepository(session)
|
||||||
|
|
||||||
|
async def update_ledger_from_posting(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
voucher_id: UUID,
|
||||||
|
fiscal_period_id: UUID,
|
||||||
|
entries: list[JournalEntry],
|
||||||
|
) -> None:
|
||||||
|
for entry in entries:
|
||||||
|
balance = await self.balance_repo.get_by_account_period(
|
||||||
|
tenant_id, entry.account_id, fiscal_period_id
|
||||||
|
)
|
||||||
|
current = balance.current_balance if balance else Decimal("0")
|
||||||
|
running = current + entry.debit - entry.credit
|
||||||
|
|
||||||
|
await self.gl_repo.add(
|
||||||
|
GeneralLedger(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=entry.account_id,
|
||||||
|
fiscal_period_id=fiscal_period_id,
|
||||||
|
journal_entry_id=entry.id,
|
||||||
|
voucher_id=voucher_id,
|
||||||
|
entry_date=entry.entry_date,
|
||||||
|
debit=entry.debit,
|
||||||
|
credit=entry.credit,
|
||||||
|
running_balance=running,
|
||||||
|
description=entry.description,
|
||||||
|
cost_center_id=entry.cost_center_id,
|
||||||
|
project_id=entry.project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if balance is None:
|
||||||
|
balance = LedgerBalance(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=entry.account_id,
|
||||||
|
fiscal_period_id=fiscal_period_id,
|
||||||
|
opening_balance=Decimal("0"),
|
||||||
|
debit_total=entry.debit,
|
||||||
|
credit_total=entry.credit,
|
||||||
|
closing_balance=running,
|
||||||
|
current_balance=running,
|
||||||
|
)
|
||||||
|
await self.balance_repo.add(balance)
|
||||||
|
else:
|
||||||
|
balance.debit_total += entry.debit
|
||||||
|
balance.credit_total += entry.credit
|
||||||
|
balance.current_balance = running
|
||||||
|
balance.closing_balance = running
|
||||||
|
|
||||||
|
async def get_account_balance(
|
||||||
|
self, tenant_id: UUID, account_id: UUID, fiscal_period_id: UUID
|
||||||
|
) -> Decimal:
|
||||||
|
balance = await self.balance_repo.get_by_account_period(
|
||||||
|
tenant_id, account_id, fiscal_period_id
|
||||||
|
)
|
||||||
|
return balance.current_balance if balance else Decimal("0")
|
||||||
|
|
||||||
|
async def generate_trial_balance(
|
||||||
|
self, tenant_id: UUID, fiscal_period_id: UUID
|
||||||
|
) -> TrialBalanceSnapshot:
|
||||||
|
balances = await self.balance_repo.list_by_period(tenant_id, fiscal_period_id)
|
||||||
|
total_debit = sum(b.debit_total for b in balances)
|
||||||
|
total_credit = sum(b.credit_total for b in balances)
|
||||||
|
difference = total_debit - total_credit
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
snapshot = TrialBalanceSnapshot(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fiscal_period_id=fiscal_period_id,
|
||||||
|
generated_at=date.today(),
|
||||||
|
total_debit=total_debit,
|
||||||
|
total_credit=total_credit,
|
||||||
|
difference=difference,
|
||||||
|
is_balanced=difference == Decimal("0"),
|
||||||
|
)
|
||||||
|
return await self.trial_repo.add(snapshot)
|
||||||
|
|
||||||
|
async def recalculate_period(
|
||||||
|
self, tenant_id: UUID, fiscal_period_id: UUID
|
||||||
|
) -> int:
|
||||||
|
"""Rebuild ledger balances from posted journal entries."""
|
||||||
|
entries = await self.journal_repo.list_by_tenant(tenant_id, limit=10000)
|
||||||
|
posted = [
|
||||||
|
e for e in entries
|
||||||
|
if e.fiscal_period_id == fiscal_period_id and e.status == JournalStatus.POSTED
|
||||||
|
]
|
||||||
|
|
||||||
|
account_totals: dict[UUID, dict] = {}
|
||||||
|
for entry in posted:
|
||||||
|
if entry.account_id not in account_totals:
|
||||||
|
account_totals[entry.account_id] = {
|
||||||
|
"debit": Decimal("0"),
|
||||||
|
"credit": Decimal("0"),
|
||||||
|
}
|
||||||
|
account_totals[entry.account_id]["debit"] += entry.debit
|
||||||
|
account_totals[entry.account_id]["credit"] += entry.credit
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for account_id, totals in account_totals.items():
|
||||||
|
balance = await self.balance_repo.get_by_account_period(
|
||||||
|
tenant_id, account_id, fiscal_period_id
|
||||||
|
)
|
||||||
|
current = totals["debit"] - totals["credit"]
|
||||||
|
if balance is None:
|
||||||
|
balance = LedgerBalance(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
fiscal_period_id=fiscal_period_id,
|
||||||
|
debit_total=totals["debit"],
|
||||||
|
credit_total=totals["credit"],
|
||||||
|
current_balance=current,
|
||||||
|
closing_balance=current,
|
||||||
|
)
|
||||||
|
await self.balance_repo.add(balance)
|
||||||
|
else:
|
||||||
|
balance.debit_total = totals["debit"]
|
||||||
|
balance.credit_total = totals["credit"]
|
||||||
|
balance.current_balance = current
|
||||||
|
balance.closing_balance = current
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
303
backend/services/accounting/app/services/coa_templates.py
Normal file
303
backend/services/accounting/app/services/coa_templates.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
"""Predefined Chart of Accounts templates + import."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.foundation import Account, ChartOfAccounts
|
||||||
|
from app.models.types import AccountCategory, AccountStatus, AccountType
|
||||||
|
from app.repositories.foundation import AccountRepository, ChartOfAccountsRepository
|
||||||
|
from app.schemas.foundation import CoaTemplateAccount, CoaTemplateRead
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
|
||||||
|
COA_TEMPLATES: dict[str, CoaTemplateRead] = {
|
||||||
|
"general-trading": CoaTemplateRead(
|
||||||
|
id="general-trading",
|
||||||
|
name="بازرگانی عمومی",
|
||||||
|
description="چارت استاندارد برای کسبوکارهای بازرگانی و خردهفروشی",
|
||||||
|
business_type="trading",
|
||||||
|
accounts=[
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="1",
|
||||||
|
name="داراییها",
|
||||||
|
account_type=AccountType.ASSET,
|
||||||
|
account_category=AccountCategory.CURRENT_ASSET,
|
||||||
|
level=1,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="11",
|
||||||
|
name="داراییهای جاری",
|
||||||
|
account_type=AccountType.ASSET,
|
||||||
|
account_category=AccountCategory.CURRENT_ASSET,
|
||||||
|
level=2,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
parent_code="1",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="1101",
|
||||||
|
name="صندوق",
|
||||||
|
account_type=AccountType.ASSET,
|
||||||
|
account_category=AccountCategory.CURRENT_ASSET,
|
||||||
|
level=3,
|
||||||
|
parent_code="11",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="1102",
|
||||||
|
name="بانک",
|
||||||
|
account_type=AccountType.ASSET,
|
||||||
|
account_category=AccountCategory.CURRENT_ASSET,
|
||||||
|
level=3,
|
||||||
|
parent_code="11",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="1103",
|
||||||
|
name="حسابهای دریافتنی",
|
||||||
|
account_type=AccountType.ASSET,
|
||||||
|
account_category=AccountCategory.CURRENT_ASSET,
|
||||||
|
level=3,
|
||||||
|
parent_code="11",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="2",
|
||||||
|
name="بدهیها",
|
||||||
|
account_type=AccountType.LIABILITY,
|
||||||
|
account_category=AccountCategory.CURRENT_LIABILITY,
|
||||||
|
level=1,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="21",
|
||||||
|
name="بدهیهای جاری",
|
||||||
|
account_type=AccountType.LIABILITY,
|
||||||
|
account_category=AccountCategory.CURRENT_LIABILITY,
|
||||||
|
level=2,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
parent_code="2",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="2101",
|
||||||
|
name="حسابهای پرداختنی",
|
||||||
|
account_type=AccountType.LIABILITY,
|
||||||
|
account_category=AccountCategory.CURRENT_LIABILITY,
|
||||||
|
level=3,
|
||||||
|
parent_code="21",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="3",
|
||||||
|
name="حقوق صاحبان سهام",
|
||||||
|
account_type=AccountType.EQUITY,
|
||||||
|
account_category=AccountCategory.EQUITY,
|
||||||
|
level=1,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="3101",
|
||||||
|
name="سرمایه",
|
||||||
|
account_type=AccountType.EQUITY,
|
||||||
|
account_category=AccountCategory.EQUITY,
|
||||||
|
level=2,
|
||||||
|
parent_code="3",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="4",
|
||||||
|
name="درآمدها",
|
||||||
|
account_type=AccountType.REVENUE,
|
||||||
|
account_category=AccountCategory.OPERATING_REVENUE,
|
||||||
|
level=1,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="4101",
|
||||||
|
name="فروش کالا",
|
||||||
|
account_type=AccountType.REVENUE,
|
||||||
|
account_category=AccountCategory.OPERATING_REVENUE,
|
||||||
|
level=2,
|
||||||
|
parent_code="4",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="5",
|
||||||
|
name="هزینهها",
|
||||||
|
account_type=AccountType.EXPENSE,
|
||||||
|
account_category=AccountCategory.OPERATING_EXPENSE,
|
||||||
|
level=1,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="5101",
|
||||||
|
name="بهای تمامشده کالای فروشرفته",
|
||||||
|
account_type=AccountType.EXPENSE,
|
||||||
|
account_category=AccountCategory.OPERATING_EXPENSE,
|
||||||
|
level=2,
|
||||||
|
parent_code="5",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="5201",
|
||||||
|
name="هزینههای اداری",
|
||||||
|
account_type=AccountType.EXPENSE,
|
||||||
|
account_category=AccountCategory.OPERATING_EXPENSE,
|
||||||
|
level=2,
|
||||||
|
parent_code="5",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"services": CoaTemplateRead(
|
||||||
|
id="services",
|
||||||
|
name="خدمات حرفهای",
|
||||||
|
description="چارت ساده برای شرکتهای خدماتی و مشاوره",
|
||||||
|
business_type="services",
|
||||||
|
accounts=[
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="1",
|
||||||
|
name="داراییها",
|
||||||
|
account_type=AccountType.ASSET,
|
||||||
|
account_category=AccountCategory.CURRENT_ASSET,
|
||||||
|
level=1,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="1101",
|
||||||
|
name="صندوق و بانک",
|
||||||
|
account_type=AccountType.ASSET,
|
||||||
|
account_category=AccountCategory.CURRENT_ASSET,
|
||||||
|
level=2,
|
||||||
|
parent_code="1",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="1102",
|
||||||
|
name="مطالبات مشتریان",
|
||||||
|
account_type=AccountType.ASSET,
|
||||||
|
account_category=AccountCategory.CURRENT_ASSET,
|
||||||
|
level=2,
|
||||||
|
parent_code="1",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="2",
|
||||||
|
name="بدهیها",
|
||||||
|
account_type=AccountType.LIABILITY,
|
||||||
|
account_category=AccountCategory.CURRENT_LIABILITY,
|
||||||
|
level=1,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="2101",
|
||||||
|
name="بدهیهای جاری",
|
||||||
|
account_type=AccountType.LIABILITY,
|
||||||
|
account_category=AccountCategory.CURRENT_LIABILITY,
|
||||||
|
level=2,
|
||||||
|
parent_code="2",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="3",
|
||||||
|
name="حقوق صاحبان سهام",
|
||||||
|
account_type=AccountType.EQUITY,
|
||||||
|
account_category=AccountCategory.EQUITY,
|
||||||
|
level=1,
|
||||||
|
is_group=True,
|
||||||
|
is_postable=False,
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="3101",
|
||||||
|
name="سرمایه",
|
||||||
|
account_type=AccountType.EQUITY,
|
||||||
|
account_category=AccountCategory.EQUITY,
|
||||||
|
level=2,
|
||||||
|
parent_code="3",
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="4",
|
||||||
|
name="درآمد خدمات",
|
||||||
|
account_type=AccountType.REVENUE,
|
||||||
|
account_category=AccountCategory.OPERATING_REVENUE,
|
||||||
|
level=1,
|
||||||
|
is_group=False,
|
||||||
|
is_postable=True,
|
||||||
|
),
|
||||||
|
CoaTemplateAccount(
|
||||||
|
code="5",
|
||||||
|
name="هزینههای عملیاتی",
|
||||||
|
account_type=AccountType.EXPENSE,
|
||||||
|
account_category=AccountCategory.OPERATING_EXPENSE,
|
||||||
|
level=1,
|
||||||
|
is_group=False,
|
||||||
|
is_postable=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def import_coa_template(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: UUID,
|
||||||
|
template_id: str,
|
||||||
|
chart_id: UUID | None = None,
|
||||||
|
chart_name: str | None = None,
|
||||||
|
chart_code: str | None = None,
|
||||||
|
) -> list[Account]:
|
||||||
|
tpl = COA_TEMPLATES.get(template_id)
|
||||||
|
if tpl is None:
|
||||||
|
raise NotFoundError("قالب یافت نشد", error_code="template_not_found")
|
||||||
|
|
||||||
|
chart_repo = ChartOfAccountsRepository(session)
|
||||||
|
account_repo = AccountRepository(session)
|
||||||
|
|
||||||
|
if chart_id is not None:
|
||||||
|
chart = await chart_repo.get(tenant_id, chart_id)
|
||||||
|
if chart is None:
|
||||||
|
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
|
||||||
|
else:
|
||||||
|
code = chart_code or f"TPL-{template_id[:12].upper()}"
|
||||||
|
existing = await chart_repo.get_by_code(tenant_id, code)
|
||||||
|
if existing is not None:
|
||||||
|
raise AppError("کد دفتر حساب تکراری است", status_code=409, error_code="chart_code_exists")
|
||||||
|
chart = ChartOfAccounts(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=chart_name or tpl.name,
|
||||||
|
code=code,
|
||||||
|
description=tpl.description,
|
||||||
|
is_default=False,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
await chart_repo.add(chart)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
created: list[Account] = []
|
||||||
|
code_to_id: dict[str, UUID] = {}
|
||||||
|
for item in sorted(tpl.accounts, key=lambda a: a.level):
|
||||||
|
parent_id = code_to_id.get(item.parent_code) if item.parent_code else None
|
||||||
|
if item.parent_code and parent_id is None:
|
||||||
|
raise AppError(
|
||||||
|
f"والد حساب {item.code} یافت نشد",
|
||||||
|
status_code=422,
|
||||||
|
error_code="parent_missing",
|
||||||
|
)
|
||||||
|
acc = Account(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
chart_id=chart.id,
|
||||||
|
parent_id=parent_id,
|
||||||
|
code=item.code,
|
||||||
|
name=item.name,
|
||||||
|
account_type=item.account_type,
|
||||||
|
account_category=item.account_category,
|
||||||
|
level=item.level,
|
||||||
|
is_group=item.is_group,
|
||||||
|
is_postable=item.is_postable,
|
||||||
|
status=AccountStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
await account_repo.add(acc)
|
||||||
|
await session.flush()
|
||||||
|
code_to_id[item.code] = acc.id
|
||||||
|
created.append(acc)
|
||||||
|
return created
|
||||||
237
backend/services/accounting/app/services/compliance_service.py
Normal file
237
backend/services/accounting/app/services/compliance_service.py
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
"""Phase 5.11 — Compliance, Audit & Governance services."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.compliance import (
|
||||||
|
ApprovalRequest,
|
||||||
|
ApprovalStep,
|
||||||
|
ApprovalWorkflow,
|
||||||
|
AuditRecord,
|
||||||
|
CompliancePolicy,
|
||||||
|
GovernanceRule,
|
||||||
|
PolicyViolation,
|
||||||
|
RiskRecord,
|
||||||
|
)
|
||||||
|
from app.models.types import ApprovalStatus, PolicyViolationSeverity, RiskLevel
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class ComplianceError(AppError):
|
||||||
|
status_code = 422
|
||||||
|
error_code = "compliance_error"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditRecordRepo(TenantBaseRepository[AuditRecord]):
|
||||||
|
model = AuditRecord
|
||||||
|
|
||||||
|
|
||||||
|
class CompliancePolicyRepo(TenantBaseRepository[CompliancePolicy]):
|
||||||
|
model = CompliancePolicy
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceRuleRepo(TenantBaseRepository[GovernanceRule]):
|
||||||
|
model = GovernanceRule
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalWorkflowRepo(TenantBaseRepository[ApprovalWorkflow]):
|
||||||
|
model = ApprovalWorkflow
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRequestRepo(TenantBaseRepository[ApprovalRequest]):
|
||||||
|
model = ApprovalRequest
|
||||||
|
|
||||||
|
|
||||||
|
class RiskRecordRepo(TenantBaseRepository[RiskRecord]):
|
||||||
|
model = RiskRecord
|
||||||
|
|
||||||
|
|
||||||
|
class AuditFramework:
|
||||||
|
"""Immutable audit trail framework."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.audit_repo = AuditRecordRepo(session)
|
||||||
|
|
||||||
|
async def record(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
actor_user_id: str,
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
source_module: str | None = None,
|
||||||
|
before_value: str | None = None,
|
||||||
|
after_value: str | None = None,
|
||||||
|
reason: str | None = None,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
) -> AuditRecord:
|
||||||
|
record = AuditRecord(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
source_module=source_module,
|
||||||
|
before_value=before_value,
|
||||||
|
after_value=after_value,
|
||||||
|
reason=reason,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
is_immutable=True,
|
||||||
|
)
|
||||||
|
return await self.audit_repo.add(record)
|
||||||
|
|
||||||
|
|
||||||
|
class ComplianceEngine:
|
||||||
|
"""Configurable compliance validation engine."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.policy_repo = CompliancePolicyRepo(session)
|
||||||
|
|
||||||
|
async def validate_policy(
|
||||||
|
self, tenant_id: UUID, policy_code: str, context: dict
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
stmt = select(CompliancePolicy).where(
|
||||||
|
CompliancePolicy.tenant_id == tenant_id,
|
||||||
|
CompliancePolicy.code == policy_code,
|
||||||
|
CompliancePolicy.is_active.is_(True),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
policy = result.scalar_one_or_none()
|
||||||
|
if policy is None:
|
||||||
|
return True, None
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceService:
|
||||||
|
"""Approval workflows and segregation of duties."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.workflow_repo = ApprovalWorkflowRepo(session)
|
||||||
|
self.request_repo = ApprovalRequestRepo(session)
|
||||||
|
self.audit = AuditFramework(session)
|
||||||
|
|
||||||
|
async def request_approval(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
requested_by: str,
|
||||||
|
) -> ApprovalRequest:
|
||||||
|
workflow = await self.workflow_repo.get(tenant_id, workflow_id)
|
||||||
|
if workflow is None:
|
||||||
|
raise NotFoundError("گردش کار تأیید یافت نشد", error_code="workflow_not_found")
|
||||||
|
|
||||||
|
request = ApprovalRequest(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
requested_by=requested_by,
|
||||||
|
status=ApprovalStatus.PENDING,
|
||||||
|
)
|
||||||
|
await self.request_repo.add(request)
|
||||||
|
await self.audit.record(
|
||||||
|
tenant_id,
|
||||||
|
actor_user_id=requested_by,
|
||||||
|
action="approval_requested",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
source_module="governance",
|
||||||
|
)
|
||||||
|
return request
|
||||||
|
|
||||||
|
async def approve(
|
||||||
|
self, tenant_id: UUID, request_id: UUID, *, approver_user_id: str
|
||||||
|
) -> ApprovalRequest:
|
||||||
|
request = await self.request_repo.get(tenant_id, request_id)
|
||||||
|
if request is None:
|
||||||
|
raise NotFoundError("درخواست تأیید یافت نشد", error_code="approval_not_found")
|
||||||
|
if request.status != ApprovalStatus.PENDING:
|
||||||
|
raise ComplianceError("درخواست قبلاً پردازش شده است")
|
||||||
|
if request.requested_by == approver_user_id:
|
||||||
|
raise ComplianceError("ایجادکننده نمیتواند تأییدکننده باشد (SoD)")
|
||||||
|
|
||||||
|
request.status = ApprovalStatus.APPROVED
|
||||||
|
request.approved_by = approver_user_id
|
||||||
|
request.approved_at = datetime.now(timezone.utc)
|
||||||
|
await self.audit.record(
|
||||||
|
tenant_id,
|
||||||
|
actor_user_id=approver_user_id,
|
||||||
|
action="approval_completed",
|
||||||
|
resource_type=request.resource_type,
|
||||||
|
resource_id=request.resource_id,
|
||||||
|
source_module="governance",
|
||||||
|
)
|
||||||
|
return request
|
||||||
|
|
||||||
|
async def reject(
|
||||||
|
self, tenant_id: UUID, request_id: UUID, *, approver_user_id: str, reason: str
|
||||||
|
) -> ApprovalRequest:
|
||||||
|
request = await self.request_repo.get(tenant_id, request_id)
|
||||||
|
if request is None:
|
||||||
|
raise NotFoundError("درخواست تأیید یافت نشد", error_code="approval_not_found")
|
||||||
|
request.status = ApprovalStatus.REJECTED
|
||||||
|
request.approved_by = approver_user_id
|
||||||
|
request.approved_at = datetime.now(timezone.utc)
|
||||||
|
request.rejection_reason = reason
|
||||||
|
return request
|
||||||
|
|
||||||
|
async def record_violation(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
policy_id: UUID,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
severity: PolicyViolationSeverity,
|
||||||
|
description: str,
|
||||||
|
) -> PolicyViolation:
|
||||||
|
violation = PolicyViolation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
policy_id=policy_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
severity=severity,
|
||||||
|
description=description,
|
||||||
|
detected_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
self.session.add(violation)
|
||||||
|
await self.session.flush()
|
||||||
|
return violation
|
||||||
|
|
||||||
|
async def create_risk(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
title: str,
|
||||||
|
risk_category: str,
|
||||||
|
risk_level: RiskLevel,
|
||||||
|
risk_score: int,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> RiskRecord:
|
||||||
|
risk = RiskRecord(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
code=code,
|
||||||
|
title=title,
|
||||||
|
risk_category=risk_category,
|
||||||
|
risk_level=risk_level,
|
||||||
|
risk_score=risk_score,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
repo = RiskRecordRepo(self.session)
|
||||||
|
return await repo.add(risk)
|
||||||
128
backend/services/accounting/app/services/fiscal_service.py
Normal file
128
backend/services/accounting/app/services/fiscal_service.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"""Fiscal period management service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from calendar import monthrange
|
||||||
|
from datetime import date
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.foundation import FiscalPeriod, FiscalYear
|
||||||
|
from app.models.types import FiscalPeriodStatus
|
||||||
|
from app.repositories.foundation import FiscalPeriodRepository, FiscalYearRepository
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalManagementError(AppError):
|
||||||
|
status_code = 422
|
||||||
|
error_code = "fiscal_management_error"
|
||||||
|
|
||||||
|
|
||||||
|
class FiscalManagementService:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.year_repo = FiscalYearRepository(session)
|
||||||
|
self.period_repo = FiscalPeriodRepository(session)
|
||||||
|
|
||||||
|
async def lock_period(self, tenant_id: UUID, period_id: UUID) -> FiscalPeriod:
|
||||||
|
period = await self.period_repo.get(tenant_id, period_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
|
||||||
|
if period.status != FiscalPeriodStatus.OPEN:
|
||||||
|
raise FiscalManagementError("فقط دورههای باز قابل قفل هستند")
|
||||||
|
period.status = FiscalPeriodStatus.LOCKED
|
||||||
|
return period
|
||||||
|
|
||||||
|
async def close_period(self, tenant_id: UUID, period_id: UUID) -> FiscalPeriod:
|
||||||
|
period = await self.period_repo.get(tenant_id, period_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
|
||||||
|
period.status = FiscalPeriodStatus.CLOSED
|
||||||
|
period.is_current = False
|
||||||
|
return period
|
||||||
|
|
||||||
|
async def reopen_period(self, tenant_id: UUID, period_id: UUID) -> FiscalPeriod:
|
||||||
|
period = await self.period_repo.get(tenant_id, period_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
|
||||||
|
if period.status == FiscalPeriodStatus.ARCHIVED:
|
||||||
|
raise FiscalManagementError("دوره بایگانیشده قابل بازگشایی نیست")
|
||||||
|
period.status = FiscalPeriodStatus.OPEN
|
||||||
|
return period
|
||||||
|
|
||||||
|
async def close_fiscal_year(self, tenant_id: UUID, year_id: UUID) -> FiscalYear:
|
||||||
|
year = await self.year_repo.get(tenant_id, year_id)
|
||||||
|
if year is None:
|
||||||
|
raise NotFoundError("سال مالی یافت نشد", error_code="year_not_found")
|
||||||
|
periods = await self.period_repo.list_by_year(tenant_id, year_id)
|
||||||
|
for p in periods:
|
||||||
|
if p.status == FiscalPeriodStatus.OPEN:
|
||||||
|
p.status = FiscalPeriodStatus.CLOSED
|
||||||
|
p.is_current = False
|
||||||
|
year.is_closed = True
|
||||||
|
year.is_current = False
|
||||||
|
return year
|
||||||
|
|
||||||
|
async def set_current_year(self, tenant_id: UUID, year_id: UUID) -> FiscalYear:
|
||||||
|
year = await self.year_repo.get(tenant_id, year_id)
|
||||||
|
if year is None:
|
||||||
|
raise NotFoundError("سال مالی یافت نشد", error_code="year_not_found")
|
||||||
|
if year.is_closed:
|
||||||
|
raise FiscalManagementError("سال بستهشده نمیتواند جاری باشد")
|
||||||
|
for y in await self.year_repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
y.is_current = y.id == year_id
|
||||||
|
return year
|
||||||
|
|
||||||
|
async def set_current_period(self, tenant_id: UUID, period_id: UUID) -> FiscalPeriod:
|
||||||
|
period = await self.period_repo.get(tenant_id, period_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
|
||||||
|
if period.status == FiscalPeriodStatus.CLOSED:
|
||||||
|
raise FiscalManagementError("دوره بستهشده نمیتواند جاری باشد")
|
||||||
|
for p in await self.period_repo.list_by_tenant(tenant_id, limit=500):
|
||||||
|
p.is_current = p.id == period_id
|
||||||
|
return period
|
||||||
|
|
||||||
|
async def generate_monthly_periods(
|
||||||
|
self, tenant_id: UUID, year_id: UUID
|
||||||
|
) -> list[FiscalPeriod]:
|
||||||
|
year = await self.year_repo.get(tenant_id, year_id)
|
||||||
|
if year is None:
|
||||||
|
raise NotFoundError("سال مالی یافت نشد", error_code="year_not_found")
|
||||||
|
existing = await self.period_repo.list_by_year(tenant_id, year_id)
|
||||||
|
if existing:
|
||||||
|
raise FiscalManagementError("برای این سال قبلاً دوره تعریف شده است")
|
||||||
|
|
||||||
|
periods: list[FiscalPeriod] = []
|
||||||
|
cursor = year.start_date.replace(day=1)
|
||||||
|
end = year.end_date
|
||||||
|
num = 1
|
||||||
|
while cursor <= end:
|
||||||
|
last_day = monthrange(cursor.year, cursor.month)[1]
|
||||||
|
period_end = date(cursor.year, cursor.month, last_day)
|
||||||
|
if period_end > end:
|
||||||
|
period_end = end
|
||||||
|
period_start = year.start_date if num == 1 else cursor
|
||||||
|
if period_start > period_end:
|
||||||
|
break
|
||||||
|
entity = FiscalPeriod(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fiscal_year_id=year_id,
|
||||||
|
name=f"دوره {num}",
|
||||||
|
period_number=num,
|
||||||
|
start_date=period_start,
|
||||||
|
end_date=period_end,
|
||||||
|
status=FiscalPeriodStatus.OPEN,
|
||||||
|
is_current=num == 1 and year.is_current,
|
||||||
|
)
|
||||||
|
await self.period_repo.add(entity)
|
||||||
|
periods.append(entity)
|
||||||
|
num += 1
|
||||||
|
if period_end >= end:
|
||||||
|
break
|
||||||
|
if cursor.month == 12:
|
||||||
|
cursor = date(cursor.year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
cursor = date(cursor.year, cursor.month + 1, 1)
|
||||||
|
await self.session.flush()
|
||||||
|
return periods
|
||||||
185
backend/services/accounting/app/services/payroll_service.py
Normal file
185
backend/services/accounting/app/services/payroll_service.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"""Phase 5.9 — Payroll Engine & accounting integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.payroll import (
|
||||||
|
Employee,
|
||||||
|
EmployeeAdvance,
|
||||||
|
EmployeeLoan,
|
||||||
|
Payroll,
|
||||||
|
PayrollAccountingProfile,
|
||||||
|
PayrollItem,
|
||||||
|
PayrollPeriod,
|
||||||
|
)
|
||||||
|
from app.models.posting import Voucher, VoucherLine
|
||||||
|
from app.models.types import PayrollPeriodStatus, PayrollStatus, VoucherStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.repositories.foundation import FiscalPeriodRepository
|
||||||
|
from app.repositories.posting import VoucherRepository
|
||||||
|
from app.services.posting_engine import PostingEngine
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollError(AppError):
|
||||||
|
status_code = 422
|
||||||
|
error_code = "payroll_error"
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeRepo(TenantBaseRepository[Employee]):
|
||||||
|
model = Employee
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollPeriodRepo(TenantBaseRepository[PayrollPeriod]):
|
||||||
|
model = PayrollPeriod
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollRepo(TenantBaseRepository[Payroll]):
|
||||||
|
model = Payroll
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollProfileRepo(TenantBaseRepository[PayrollAccountingProfile]):
|
||||||
|
model = PayrollAccountingProfile
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollEngine:
|
||||||
|
"""Centralized payroll calculation engine."""
|
||||||
|
|
||||||
|
def calculate(
|
||||||
|
self,
|
||||||
|
employee: Employee,
|
||||||
|
*,
|
||||||
|
benefits: Decimal = Decimal("0"),
|
||||||
|
deductions: Decimal = Decimal("0"),
|
||||||
|
overtime: Decimal = Decimal("0"),
|
||||||
|
) -> dict:
|
||||||
|
gross = employee.base_salary + benefits + overtime
|
||||||
|
net = gross - deductions
|
||||||
|
employer_cost = gross + benefits * Decimal("0.23")
|
||||||
|
return {
|
||||||
|
"gross_salary": gross,
|
||||||
|
"total_benefits": benefits,
|
||||||
|
"total_deductions": deductions,
|
||||||
|
"net_salary": net,
|
||||||
|
"employer_cost": employer_cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollAccountingService:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.employee_repo = EmployeeRepo(session)
|
||||||
|
self.period_repo = PayrollPeriodRepo(session)
|
||||||
|
self.payroll_repo = PayrollRepo(session)
|
||||||
|
self.profile_repo = PayrollProfileRepo(session)
|
||||||
|
self.fiscal_period_repo = FiscalPeriodRepository(session)
|
||||||
|
self.voucher_repo = VoucherRepository(session)
|
||||||
|
self.posting_engine = PostingEngine(session)
|
||||||
|
self.payroll_engine = PayrollEngine()
|
||||||
|
|
||||||
|
async def calculate_payroll(
|
||||||
|
self, tenant_id: UUID, payroll_period_id: UUID, employee_id: UUID
|
||||||
|
) -> Payroll:
|
||||||
|
employee = await self.employee_repo.get(tenant_id, employee_id)
|
||||||
|
if employee is None:
|
||||||
|
raise NotFoundError("کارمند یافت نشد", error_code="employee_not_found")
|
||||||
|
|
||||||
|
period = await self.period_repo.get(tenant_id, payroll_period_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره حقوق یافت نشد", error_code="payroll_period_not_found")
|
||||||
|
if period.status not in (PayrollPeriodStatus.OPEN, PayrollPeriodStatus.PROCESSING):
|
||||||
|
raise PayrollError("دوره حقوق قابل محاسبه نیست")
|
||||||
|
|
||||||
|
calc = self.payroll_engine.calculate(employee)
|
||||||
|
payroll = Payroll(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payroll_period_id=payroll_period_id,
|
||||||
|
employee_id=employee_id,
|
||||||
|
gross_salary=calc["gross_salary"],
|
||||||
|
total_deductions=calc["total_deductions"],
|
||||||
|
total_benefits=calc["total_benefits"],
|
||||||
|
net_salary=calc["net_salary"],
|
||||||
|
employer_cost=calc["employer_cost"],
|
||||||
|
status=PayrollStatus.CALCULATED,
|
||||||
|
)
|
||||||
|
return await self.payroll_repo.add(payroll)
|
||||||
|
|
||||||
|
async def post_payroll(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
payroll_id: UUID,
|
||||||
|
*,
|
||||||
|
actor_user_id: str,
|
||||||
|
profile_id: UUID | None = None,
|
||||||
|
) -> Payroll:
|
||||||
|
payroll = await self.payroll_repo.get(tenant_id, payroll_id)
|
||||||
|
if payroll is None:
|
||||||
|
raise NotFoundError("حقوق یافت نشد", error_code="payroll_not_found")
|
||||||
|
if payroll.status != PayrollStatus.APPROVED:
|
||||||
|
raise PayrollError("حقوق باید تأیید شده باشد")
|
||||||
|
|
||||||
|
profile = await self._resolve_profile(tenant_id, profile_id)
|
||||||
|
fiscal_period = await self.fiscal_period_repo.get_current(tenant_id)
|
||||||
|
if fiscal_period is None:
|
||||||
|
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
|
||||||
|
|
||||||
|
voucher = Voucher(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fiscal_period_id=fiscal_period.id,
|
||||||
|
voucher_number=f"PAY-{payroll_id}",
|
||||||
|
voucher_date=date.today(),
|
||||||
|
status=VoucherStatus.DRAFT,
|
||||||
|
description=f"Payroll posting {payroll_id}",
|
||||||
|
source_module="payroll",
|
||||||
|
reference_number=str(payroll_id),
|
||||||
|
created_by=actor_user_id,
|
||||||
|
)
|
||||||
|
await self.voucher_repo.add(voucher)
|
||||||
|
|
||||||
|
if profile.salary_expense_account_id and profile.payable_account_id:
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
|
||||||
|
account_id=profile.salary_expense_account_id,
|
||||||
|
debit=payroll.gross_salary, credit=Decimal("0"),
|
||||||
|
))
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
|
||||||
|
account_id=profile.payable_account_id,
|
||||||
|
debit=Decimal("0"), credit=payroll.net_salary,
|
||||||
|
))
|
||||||
|
if payroll.total_deductions > 0 and profile.deduction_account_id:
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=3,
|
||||||
|
account_id=profile.deduction_account_id,
|
||||||
|
debit=Decimal("0"), credit=payroll.total_deductions,
|
||||||
|
))
|
||||||
|
await self.session.flush()
|
||||||
|
posted = await self.posting_engine.post_voucher(
|
||||||
|
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="payroll"
|
||||||
|
)
|
||||||
|
payroll.status = PayrollStatus.PAID
|
||||||
|
payroll.voucher_id = posted.id
|
||||||
|
return payroll
|
||||||
|
|
||||||
|
async def _resolve_profile(
|
||||||
|
self, tenant_id: UUID, profile_id: UUID | None
|
||||||
|
) -> PayrollAccountingProfile:
|
||||||
|
if profile_id:
|
||||||
|
profile = await self.profile_repo.get(tenant_id, profile_id)
|
||||||
|
if profile is None:
|
||||||
|
raise NotFoundError("پروفایل حقوق یافت نشد", error_code="profile_not_found")
|
||||||
|
return profile
|
||||||
|
stmt = select(PayrollAccountingProfile).where(
|
||||||
|
PayrollAccountingProfile.tenant_id == tenant_id,
|
||||||
|
PayrollAccountingProfile.is_default.is_(True),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
if profile is None:
|
||||||
|
raise NotFoundError("پروفایل پیشفرض حقوق یافت نشد", error_code="profile_not_found")
|
||||||
|
return profile
|
||||||
273
backend/services/accounting/app/services/posting_engine.py
Normal file
273
backend/services/accounting/app/services/posting_engine.py
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
"""Centralized Posting Engine — sole creator of Journal Entries (ADR-010)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.foundation import Account, FiscalPeriod
|
||||||
|
from app.models.posting import (
|
||||||
|
AccountingAuditLog,
|
||||||
|
JournalEntry,
|
||||||
|
PostingError,
|
||||||
|
PostingLog,
|
||||||
|
Voucher,
|
||||||
|
VoucherLine,
|
||||||
|
)
|
||||||
|
from app.models.types import (
|
||||||
|
AccountStatus,
|
||||||
|
FiscalPeriodStatus,
|
||||||
|
JournalStatus,
|
||||||
|
PostingStatus,
|
||||||
|
VoucherStatus,
|
||||||
|
)
|
||||||
|
from app.repositories.foundation import AccountRepository, FiscalPeriodRepository
|
||||||
|
from app.repositories.posting import (
|
||||||
|
AuditLogRepository,
|
||||||
|
JournalEntryRepository,
|
||||||
|
PostingErrorRepository,
|
||||||
|
PostingLogRepository,
|
||||||
|
VoucherRepository,
|
||||||
|
)
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class PostingValidationError(AppError):
|
||||||
|
status_code = 422
|
||||||
|
error_code = "posting_validation_failed"
|
||||||
|
|
||||||
|
|
||||||
|
class PostingEngine:
|
||||||
|
"""The ONLY component allowed to create Journal Entries."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.voucher_repo = VoucherRepository(session)
|
||||||
|
self.account_repo = AccountRepository(session)
|
||||||
|
self.period_repo = FiscalPeriodRepository(session)
|
||||||
|
self.journal_repo = JournalEntryRepository(session)
|
||||||
|
self.log_repo = PostingLogRepository(session)
|
||||||
|
self.error_repo = PostingErrorRepository(session)
|
||||||
|
self.audit_repo = AuditLogRepository(session)
|
||||||
|
|
||||||
|
async def validate_voucher(self, tenant_id: UUID, voucher_id: UUID) -> Voucher:
|
||||||
|
voucher = await self.voucher_repo.get_with_lines(tenant_id, voucher_id)
|
||||||
|
if voucher is None:
|
||||||
|
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
|
||||||
|
|
||||||
|
errors: list[PostingError] = []
|
||||||
|
|
||||||
|
if voucher.status not in (VoucherStatus.DRAFT, VoucherStatus.VALIDATED):
|
||||||
|
errors.append(self._make_error(tenant_id, voucher_id, "invalid_status", "وضعیت سند نامعتبر است"))
|
||||||
|
|
||||||
|
period = await self.period_repo.get(tenant_id, voucher.fiscal_period_id)
|
||||||
|
if period is None:
|
||||||
|
errors.append(self._make_error(tenant_id, voucher_id, "period_not_found", "دوره مالی یافت نشد"))
|
||||||
|
elif period.status != FiscalPeriodStatus.OPEN:
|
||||||
|
errors.append(self._make_error(tenant_id, voucher_id, "period_locked", "دوره مالی بسته یا قفل است"))
|
||||||
|
|
||||||
|
if not voucher.lines:
|
||||||
|
errors.append(self._make_error(tenant_id, voucher_id, "no_lines", "سند فاقد سطر است"))
|
||||||
|
|
||||||
|
total_debit = Decimal("0")
|
||||||
|
total_credit = Decimal("0")
|
||||||
|
for line in voucher.lines:
|
||||||
|
total_debit += line.debit
|
||||||
|
total_credit += line.credit
|
||||||
|
account = await self.account_repo.get(tenant_id, line.account_id)
|
||||||
|
if account is None:
|
||||||
|
errors.append(self._make_error(tenant_id, voucher_id, "account_not_found", f"حساب {line.account_id} یافت نشد", "account_id"))
|
||||||
|
elif account.status != AccountStatus.ACTIVE:
|
||||||
|
errors.append(self._make_error(tenant_id, voucher_id, "account_inactive", f"حساب {account.code} غیرفعال است", "account_id"))
|
||||||
|
elif not account.is_postable:
|
||||||
|
errors.append(self._make_error(tenant_id, voucher_id, "account_not_postable", f"حساب {account.code} قابل ثبت نیست", "account_id"))
|
||||||
|
|
||||||
|
if total_debit != total_credit:
|
||||||
|
errors.append(self._make_error(tenant_id, voucher_id, "unbalanced", f"عدم تراز: بدهکار={total_debit} بستانکار={total_credit}"))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
await self.error_repo.add(err)
|
||||||
|
await self._log(tenant_id, voucher_id, "validate", PostingStatus.FAILED, "Validation failed")
|
||||||
|
raise PostingValidationError("اعتبارسنجی سند ناموفق بود", details={"errors": len(errors)})
|
||||||
|
|
||||||
|
voucher.total_debit = total_debit
|
||||||
|
voucher.total_credit = total_credit
|
||||||
|
voucher.status = VoucherStatus.VALIDATED
|
||||||
|
await self._log(tenant_id, voucher_id, "validate", PostingStatus.COMPLETED, "Validated")
|
||||||
|
return voucher
|
||||||
|
|
||||||
|
async def post_voucher(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
voucher_id: UUID,
|
||||||
|
*,
|
||||||
|
actor_user_id: str,
|
||||||
|
source_module: str | None = None,
|
||||||
|
) -> Voucher:
|
||||||
|
voucher = await self.validate_voucher(tenant_id, voucher_id)
|
||||||
|
|
||||||
|
if voucher.status == VoucherStatus.POSTED:
|
||||||
|
raise PostingValidationError("سند قبلاً ثبت شده است")
|
||||||
|
|
||||||
|
entry_num = 0
|
||||||
|
for line in voucher.lines:
|
||||||
|
entry_num += 1
|
||||||
|
entry = JournalEntry(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
voucher_id=voucher.id,
|
||||||
|
fiscal_period_id=voucher.fiscal_period_id,
|
||||||
|
entry_number=f"{voucher.voucher_number}-{entry_num}",
|
||||||
|
entry_date=voucher.voucher_date,
|
||||||
|
status=JournalStatus.POSTED,
|
||||||
|
account_id=line.account_id,
|
||||||
|
debit=line.debit,
|
||||||
|
credit=line.credit,
|
||||||
|
description=line.description,
|
||||||
|
cost_center_id=line.cost_center_id,
|
||||||
|
project_id=line.project_id,
|
||||||
|
posted_at=datetime.now(timezone.utc),
|
||||||
|
posted_by=actor_user_id,
|
||||||
|
)
|
||||||
|
await self.journal_repo.add(entry)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
voucher.status = VoucherStatus.POSTED
|
||||||
|
voucher.posted_at = now
|
||||||
|
voucher.posted_by = actor_user_id
|
||||||
|
if source_module:
|
||||||
|
voucher.source_module = source_module
|
||||||
|
|
||||||
|
await self._audit(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
operation="post",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
voucher=voucher,
|
||||||
|
source_module=source_module,
|
||||||
|
)
|
||||||
|
await self._log(tenant_id, voucher_id, "post", PostingStatus.COMPLETED, "Posted", actor_user_id)
|
||||||
|
return voucher
|
||||||
|
|
||||||
|
async def reverse_voucher(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
voucher_id: UUID,
|
||||||
|
*,
|
||||||
|
actor_user_id: str,
|
||||||
|
reversal_date: date | None = None,
|
||||||
|
) -> Voucher:
|
||||||
|
original = await self.voucher_repo.get_with_lines(tenant_id, voucher_id)
|
||||||
|
if original is None:
|
||||||
|
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
|
||||||
|
if original.status != VoucherStatus.POSTED:
|
||||||
|
raise PostingValidationError("فقط اسناد ثبتشده قابل برگشت هستند")
|
||||||
|
|
||||||
|
rev_number = f"REV-{original.voucher_number}"
|
||||||
|
reversal = Voucher(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fiscal_period_id=original.fiscal_period_id,
|
||||||
|
voucher_number=rev_number,
|
||||||
|
voucher_date=reversal_date or date.today(),
|
||||||
|
status=VoucherStatus.DRAFT,
|
||||||
|
description=f"برگشت سند {original.voucher_number}",
|
||||||
|
reference_number=original.voucher_number,
|
||||||
|
source_module=original.source_module,
|
||||||
|
reversed_voucher_id=original.id,
|
||||||
|
created_by=actor_user_id,
|
||||||
|
)
|
||||||
|
await self.voucher_repo.add(reversal)
|
||||||
|
|
||||||
|
for i, line in enumerate(original.lines, start=1):
|
||||||
|
rev_line = VoucherLine(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
voucher_id=reversal.id,
|
||||||
|
line_number=i,
|
||||||
|
account_id=line.account_id,
|
||||||
|
debit=line.credit,
|
||||||
|
credit=line.debit,
|
||||||
|
description=f"برگشت: {line.description or ''}",
|
||||||
|
cost_center_id=line.cost_center_id,
|
||||||
|
project_id=line.project_id,
|
||||||
|
)
|
||||||
|
self.session.add(rev_line)
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
reversal = await self.voucher_repo.get_with_lines(tenant_id, reversal.id)
|
||||||
|
await self.post_voucher(tenant_id, reversal.id, actor_user_id=actor_user_id)
|
||||||
|
|
||||||
|
original.status = VoucherStatus.REVERSED
|
||||||
|
await self._audit(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
operation="reverse",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
voucher=original,
|
||||||
|
)
|
||||||
|
return reversal
|
||||||
|
|
||||||
|
async def cancel_voucher(
|
||||||
|
self, tenant_id: UUID, voucher_id: UUID, *, actor_user_id: str
|
||||||
|
) -> Voucher:
|
||||||
|
voucher = await self.voucher_repo.get(tenant_id, voucher_id)
|
||||||
|
if voucher is None:
|
||||||
|
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
|
||||||
|
if voucher.status == VoucherStatus.POSTED:
|
||||||
|
raise PostingValidationError("سند ثبتشده قابل لغو نیست — از برگشت استفاده کنید")
|
||||||
|
voucher.status = VoucherStatus.CANCELLED
|
||||||
|
await self._audit(tenant_id=tenant_id, operation="cancel", actor_user_id=actor_user_id, voucher=voucher)
|
||||||
|
return voucher
|
||||||
|
|
||||||
|
def _make_error(
|
||||||
|
self, tenant_id: UUID, voucher_id: UUID, code: str, message: str, field: str | None = None
|
||||||
|
) -> PostingError:
|
||||||
|
return PostingError(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
voucher_id=voucher_id,
|
||||||
|
error_code=code,
|
||||||
|
error_message=message,
|
||||||
|
field_name=field,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _log(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
voucher_id: UUID,
|
||||||
|
operation: str,
|
||||||
|
status: PostingStatus,
|
||||||
|
message: str,
|
||||||
|
actor: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
await self.log_repo.add(
|
||||||
|
PostingLog(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
voucher_id=voucher_id,
|
||||||
|
operation=operation,
|
||||||
|
status=status,
|
||||||
|
actor_user_id=actor,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _audit(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: UUID,
|
||||||
|
operation: str,
|
||||||
|
actor_user_id: str,
|
||||||
|
voucher: Voucher,
|
||||||
|
source_module: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
await self.audit_repo.add(
|
||||||
|
AccountingAuditLog(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
operation=operation,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
source_module=source_module or voucher.source_module,
|
||||||
|
voucher_number=voucher.voucher_number,
|
||||||
|
reference_number=voucher.reference_number,
|
||||||
|
resource_type="voucher",
|
||||||
|
resource_id=str(voucher.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -0,0 +1,188 @@
|
|||||||
|
"""Phase 5.7 — Purchase & Inventory accounting services."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.posting import Voucher, VoucherLine
|
||||||
|
from app.models.purchase_inventory import (
|
||||||
|
InventoryAccountingConfiguration,
|
||||||
|
InventoryCostAdjustment,
|
||||||
|
InventoryPostingProfile,
|
||||||
|
InventoryValuationHistory,
|
||||||
|
PurchasePostingProfile,
|
||||||
|
)
|
||||||
|
from app.models.sales_accounting import AccountingPreview
|
||||||
|
from app.models.types import InventoryDocumentType, InventoryValuationMethod, PurchaseDocumentType, VoucherStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.repositories.foundation import FiscalPeriodRepository
|
||||||
|
from app.repositories.posting import VoucherRepository
|
||||||
|
from app.services.posting_engine import PostingEngine
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseProfileRepo(TenantBaseRepository[PurchasePostingProfile]):
|
||||||
|
model = PurchasePostingProfile
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryProfileRepo(TenantBaseRepository[InventoryPostingProfile]):
|
||||||
|
model = InventoryPostingProfile
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryValuationHistoryRepo(TenantBaseRepository[InventoryValuationHistory]):
|
||||||
|
model = InventoryValuationHistory
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryCostAdjustmentRepo(TenantBaseRepository[InventoryCostAdjustment]):
|
||||||
|
model = InventoryCostAdjustment
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryValuationEngine:
|
||||||
|
"""Valuation calculations separated from Posting Engine."""
|
||||||
|
|
||||||
|
def calculate_fifo(self, layers: list[tuple[Decimal, Decimal]], issue_qty: Decimal) -> Decimal:
|
||||||
|
remaining = issue_qty
|
||||||
|
total_cost = Decimal("0")
|
||||||
|
for qty, cost in layers:
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
take = min(remaining, qty)
|
||||||
|
total_cost += take * cost
|
||||||
|
remaining -= take
|
||||||
|
return total_cost
|
||||||
|
|
||||||
|
def calculate_weighted_average(self, total_qty: Decimal, total_value: Decimal, new_qty: Decimal, new_cost: Decimal) -> Decimal:
|
||||||
|
if total_qty + new_qty == 0:
|
||||||
|
return Decimal("0")
|
||||||
|
return (total_value + new_qty * new_cost) / (total_qty + new_qty)
|
||||||
|
|
||||||
|
def calculate_moving_average(self, current_avg: Decimal, current_qty: Decimal, new_qty: Decimal, new_cost: Decimal) -> Decimal:
|
||||||
|
return self.calculate_weighted_average(current_qty, current_avg * current_qty, new_qty, new_cost)
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseInventoryAccountingService:
|
||||||
|
"""Purchase and inventory accounting via Posting Engine only."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.purchase_profile_repo = PurchaseProfileRepo(session)
|
||||||
|
self.inventory_profile_repo = InventoryProfileRepo(session)
|
||||||
|
self.voucher_repo = VoucherRepository(session)
|
||||||
|
self.period_repo = FiscalPeriodRepository(session)
|
||||||
|
self.posting_engine = PostingEngine(session)
|
||||||
|
self.valuation_engine = InventoryValuationEngine()
|
||||||
|
self.valuation_history_repo = InventoryValuationHistoryRepo(session)
|
||||||
|
self.cost_adjustment_repo = InventoryCostAdjustmentRepo(session)
|
||||||
|
|
||||||
|
async def preview_goods_receipt(
|
||||||
|
self, tenant_id: UUID, amount: Decimal, profile_id: UUID | None = None
|
||||||
|
) -> AccountingPreview:
|
||||||
|
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
|
||||||
|
lines = []
|
||||||
|
if profile.inventory_account_id:
|
||||||
|
lines.append({"account_id": str(profile.inventory_account_id), "debit": str(amount), "credit": "0"})
|
||||||
|
if profile.grni_account_id:
|
||||||
|
lines.append({"account_id": str(profile.grni_account_id), "debit": "0", "credit": str(amount)})
|
||||||
|
total_debit = sum(Decimal(l["debit"]) for l in lines)
|
||||||
|
total_credit = sum(Decimal(l["credit"]) for l in lines)
|
||||||
|
preview = AccountingPreview(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_module="purchase",
|
||||||
|
source_document_type=PurchaseDocumentType.GOODS_RECEIPT.value,
|
||||||
|
source_document_id="preview",
|
||||||
|
preview_data=json.dumps(lines),
|
||||||
|
is_balanced=total_debit == total_credit,
|
||||||
|
total_debit=total_debit,
|
||||||
|
total_credit=total_credit,
|
||||||
|
)
|
||||||
|
self.session.add(preview)
|
||||||
|
await self.session.flush()
|
||||||
|
return preview
|
||||||
|
|
||||||
|
async def post_goods_receipt(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
source_document_id: str,
|
||||||
|
amount: Decimal,
|
||||||
|
actor_user_id: str,
|
||||||
|
profile_id: UUID | None = None,
|
||||||
|
) -> Voucher:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
|
||||||
|
period = await self.period_repo.get_current(tenant_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
|
||||||
|
|
||||||
|
voucher = Voucher(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fiscal_period_id=period.id,
|
||||||
|
voucher_number=f"GR-{source_document_id}",
|
||||||
|
voucher_date=date.today(),
|
||||||
|
status=VoucherStatus.DRAFT,
|
||||||
|
description=f"Goods receipt {source_document_id}",
|
||||||
|
source_module="purchase",
|
||||||
|
reference_number=source_document_id,
|
||||||
|
created_by=actor_user_id,
|
||||||
|
)
|
||||||
|
await self.voucher_repo.add(voucher)
|
||||||
|
|
||||||
|
if profile.inventory_account_id and profile.grni_account_id:
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
|
||||||
|
account_id=profile.inventory_account_id, debit=amount, credit=Decimal("0"),
|
||||||
|
))
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
|
||||||
|
account_id=profile.grni_account_id, debit=Decimal("0"), credit=amount,
|
||||||
|
))
|
||||||
|
await self.session.flush()
|
||||||
|
return await self.posting_engine.post_voucher(
|
||||||
|
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def record_valuation(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
item_id: UUID,
|
||||||
|
quantity: Decimal,
|
||||||
|
unit_cost: Decimal,
|
||||||
|
method: InventoryValuationMethod,
|
||||||
|
warehouse_id: UUID | None = None,
|
||||||
|
) -> InventoryValuationHistory:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
record = InventoryValuationHistory(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
item_id=item_id,
|
||||||
|
warehouse_id=warehouse_id,
|
||||||
|
valuation_date=date.today(),
|
||||||
|
method=method,
|
||||||
|
quantity=quantity,
|
||||||
|
unit_cost=unit_cost,
|
||||||
|
total_value=quantity * unit_cost,
|
||||||
|
)
|
||||||
|
return await self.valuation_history_repo.add(record)
|
||||||
|
|
||||||
|
async def _resolve_purchase_profile(
|
||||||
|
self, tenant_id: UUID, profile_id: UUID | None
|
||||||
|
) -> PurchasePostingProfile:
|
||||||
|
if profile_id:
|
||||||
|
profile = await self.purchase_profile_repo.get(tenant_id, profile_id)
|
||||||
|
if profile is None:
|
||||||
|
raise NotFoundError("پروفایل خرید یافت نشد", error_code="profile_not_found")
|
||||||
|
return profile
|
||||||
|
stmt = select(PurchasePostingProfile).where(
|
||||||
|
PurchasePostingProfile.tenant_id == tenant_id,
|
||||||
|
PurchasePostingProfile.is_default.is_(True),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
if profile is None:
|
||||||
|
raise NotFoundError("پروفایل پیشفرض خرید یافت نشد", error_code="profile_not_found")
|
||||||
|
return profile
|
||||||
117
backend/services/accounting/app/services/reporting_service.py
Normal file
117
backend/services/accounting/app/services/reporting_service.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""Phase 5.10 — Financial Report Engine."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.reporting import FinancialReport, ReportExport, ReportSnapshot
|
||||||
|
from app.models.types import ReportExportFormat, ReportStatus, ReportType
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.repositories.ledger import LedgerBalanceRepository
|
||||||
|
from app.services.balance_engine import BalanceEngine
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class FinancialReportRepo(TenantBaseRepository[FinancialReport]):
|
||||||
|
model = FinancialReport
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExportRepo(TenantBaseRepository[ReportExport]):
|
||||||
|
model = ReportExport
|
||||||
|
|
||||||
|
|
||||||
|
class FinancialReportEngine:
|
||||||
|
"""Generates financial reports from posted accounting data only."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.report_repo = FinancialReportRepo(session)
|
||||||
|
self.balance_repo = LedgerBalanceRepository(session)
|
||||||
|
self.balance_engine = BalanceEngine(session)
|
||||||
|
self.export_repo = ReportExportRepo(session)
|
||||||
|
|
||||||
|
async def generate_trial_balance_report(
|
||||||
|
self, tenant_id: UUID, fiscal_period_id: UUID, *, generated_by: str
|
||||||
|
) -> FinancialReport:
|
||||||
|
snapshot = await self.balance_engine.generate_trial_balance(tenant_id, fiscal_period_id)
|
||||||
|
report = FinancialReport(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
report_type=ReportType.TRIAL_BALANCE,
|
||||||
|
name=f"Trial Balance {fiscal_period_id}",
|
||||||
|
fiscal_period_id=fiscal_period_id,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
status=ReportStatus.GENERATED,
|
||||||
|
report_data=json.dumps({
|
||||||
|
"total_debit": str(snapshot.total_debit),
|
||||||
|
"total_credit": str(snapshot.total_credit),
|
||||||
|
"difference": str(snapshot.difference),
|
||||||
|
"is_balanced": snapshot.is_balanced,
|
||||||
|
}),
|
||||||
|
generated_by=generated_by,
|
||||||
|
)
|
||||||
|
return await self.report_repo.add(report)
|
||||||
|
|
||||||
|
async def generate_balance_sheet(
|
||||||
|
self, tenant_id: UUID, fiscal_period_id: UUID, *, generated_by: str
|
||||||
|
) -> FinancialReport:
|
||||||
|
balances = await self.balance_repo.list_by_period(tenant_id, fiscal_period_id)
|
||||||
|
total_assets = sum(b.closing_balance for b in balances if b.closing_balance > 0)
|
||||||
|
report = FinancialReport(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
report_type=ReportType.BALANCE_SHEET,
|
||||||
|
name=f"Balance Sheet {fiscal_period_id}",
|
||||||
|
fiscal_period_id=fiscal_period_id,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
status=ReportStatus.GENERATED,
|
||||||
|
report_data=json.dumps({"total_assets": str(total_assets), "account_count": len(balances)}),
|
||||||
|
generated_by=generated_by,
|
||||||
|
)
|
||||||
|
return await self.report_repo.add(report)
|
||||||
|
|
||||||
|
async def generate_income_statement(
|
||||||
|
self, tenant_id: UUID, fiscal_period_id: UUID, *, generated_by: str
|
||||||
|
) -> FinancialReport:
|
||||||
|
balances = await self.balance_repo.list_by_period(tenant_id, fiscal_period_id)
|
||||||
|
total_debit = sum(b.debit_total for b in balances)
|
||||||
|
total_credit = sum(b.credit_total for b in balances)
|
||||||
|
report = FinancialReport(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
report_type=ReportType.INCOME_STATEMENT,
|
||||||
|
name=f"Income Statement {fiscal_period_id}",
|
||||||
|
fiscal_period_id=fiscal_period_id,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
status=ReportStatus.GENERATED,
|
||||||
|
report_data=json.dumps({
|
||||||
|
"total_revenue": str(total_credit),
|
||||||
|
"total_expense": str(total_debit),
|
||||||
|
"net": str(total_credit - total_debit),
|
||||||
|
}),
|
||||||
|
generated_by=generated_by,
|
||||||
|
)
|
||||||
|
return await self.report_repo.add(report)
|
||||||
|
|
||||||
|
async def export_report(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
report_id: UUID,
|
||||||
|
export_format: ReportExportFormat,
|
||||||
|
*,
|
||||||
|
exported_by: str,
|
||||||
|
) -> ReportExport:
|
||||||
|
report = await self.report_repo.get(tenant_id, report_id)
|
||||||
|
if report is None:
|
||||||
|
raise NotFoundError("گزارش یافت نشد", error_code="report_not_found")
|
||||||
|
|
||||||
|
export = ReportExport(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
report_id=report_id,
|
||||||
|
export_format=export_format,
|
||||||
|
file_path=f"/exports/{report_id}.{export_format.value}",
|
||||||
|
exported_at=datetime.now(timezone.utc),
|
||||||
|
exported_by=exported_by,
|
||||||
|
)
|
||||||
|
return await self.export_repo.add(export)
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
"""Sales accounting integration service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.posting import Voucher, VoucherLine
|
||||||
|
from app.models.sales_accounting import AccountingPreview, SalesPostingProfile
|
||||||
|
from app.models.types import SalesDocumentType, VoucherStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.repositories.foundation import AccountRepository, FiscalPeriodRepository
|
||||||
|
from app.repositories.posting import VoucherRepository
|
||||||
|
from app.services.posting_engine import PostingEngine
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class SalesPostingProfileRepository(TenantBaseRepository[SalesPostingProfile]):
|
||||||
|
model = SalesPostingProfile
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingPreviewRepository(TenantBaseRepository[AccountingPreview]):
|
||||||
|
model = AccountingPreview
|
||||||
|
|
||||||
|
|
||||||
|
class SalesAccountingService:
|
||||||
|
"""Generates accounting entries for sales documents via Posting Engine only."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.profile_repo = SalesPostingProfileRepository(session)
|
||||||
|
self.preview_repo = AccountingPreviewRepository(session)
|
||||||
|
self.voucher_repo = VoucherRepository(session)
|
||||||
|
self.period_repo = FiscalPeriodRepository(session)
|
||||||
|
self.account_repo = AccountRepository(session)
|
||||||
|
self.posting_engine = PostingEngine(session)
|
||||||
|
|
||||||
|
async def preview_sales_invoice(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
document_type: SalesDocumentType,
|
||||||
|
total_amount: Decimal,
|
||||||
|
discount_amount: Decimal = Decimal("0"),
|
||||||
|
tax_amount: Decimal = Decimal("0"),
|
||||||
|
profile_id: UUID | None = None,
|
||||||
|
) -> AccountingPreview:
|
||||||
|
profile = await self._resolve_profile(tenant_id, document_type, profile_id)
|
||||||
|
net_revenue = total_amount - discount_amount - tax_amount
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if profile.receivable_account_id:
|
||||||
|
lines.append({"account_id": str(profile.receivable_account_id), "debit": str(total_amount), "credit": "0"})
|
||||||
|
if profile.revenue_account_id:
|
||||||
|
lines.append({"account_id": str(profile.revenue_account_id), "debit": "0", "credit": str(net_revenue)})
|
||||||
|
if discount_amount > 0 and profile.discount_account_id:
|
||||||
|
lines.append({"account_id": str(profile.discount_account_id), "debit": str(discount_amount), "credit": "0"})
|
||||||
|
if tax_amount > 0 and profile.tax_account_id:
|
||||||
|
lines.append({"account_id": str(profile.tax_account_id), "debit": "0", "credit": str(tax_amount)})
|
||||||
|
|
||||||
|
total_debit = sum(Decimal(l["debit"]) for l in lines)
|
||||||
|
total_credit = sum(Decimal(l["credit"]) for l in lines)
|
||||||
|
|
||||||
|
preview = AccountingPreview(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_module="sales",
|
||||||
|
source_document_type=document_type.value,
|
||||||
|
source_document_id="preview",
|
||||||
|
preview_data=json.dumps(lines),
|
||||||
|
is_balanced=total_debit == total_credit,
|
||||||
|
total_debit=total_debit,
|
||||||
|
total_credit=total_credit,
|
||||||
|
)
|
||||||
|
return await self.preview_repo.add(preview)
|
||||||
|
|
||||||
|
async def post_sales_invoice(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
document_type: SalesDocumentType,
|
||||||
|
source_document_id: str,
|
||||||
|
total_amount: Decimal,
|
||||||
|
discount_amount: Decimal = Decimal("0"),
|
||||||
|
tax_amount: Decimal = Decimal("0"),
|
||||||
|
profile_id: UUID | None = None,
|
||||||
|
actor_user_id: str,
|
||||||
|
voucher_date=None,
|
||||||
|
) -> Voucher:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
profile = await self._resolve_profile(tenant_id, document_type, profile_id)
|
||||||
|
period = await self.period_repo.get_current(tenant_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
|
||||||
|
|
||||||
|
voucher_number = f"SINV-{source_document_id}"
|
||||||
|
voucher = Voucher(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fiscal_period_id=period.id,
|
||||||
|
voucher_number=voucher_number,
|
||||||
|
voucher_date=voucher_date or date.today(),
|
||||||
|
status=VoucherStatus.DRAFT,
|
||||||
|
description=f"Sales {document_type.value} {source_document_id}",
|
||||||
|
source_module="sales",
|
||||||
|
reference_number=source_document_id,
|
||||||
|
created_by=actor_user_id,
|
||||||
|
)
|
||||||
|
await self.voucher_repo.add(voucher)
|
||||||
|
|
||||||
|
net_revenue = total_amount - discount_amount - tax_amount
|
||||||
|
line_num = 0
|
||||||
|
|
||||||
|
if profile.receivable_account_id:
|
||||||
|
line_num += 1
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=line_num,
|
||||||
|
account_id=profile.receivable_account_id, debit=total_amount, credit=Decimal("0"),
|
||||||
|
))
|
||||||
|
if profile.revenue_account_id:
|
||||||
|
line_num += 1
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=line_num,
|
||||||
|
account_id=profile.revenue_account_id, debit=Decimal("0"), credit=net_revenue,
|
||||||
|
))
|
||||||
|
if discount_amount > 0 and profile.discount_account_id:
|
||||||
|
line_num += 1
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=line_num,
|
||||||
|
account_id=profile.discount_account_id, debit=discount_amount, credit=Decimal("0"),
|
||||||
|
))
|
||||||
|
if tax_amount > 0 and profile.tax_account_id:
|
||||||
|
line_num += 1
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=line_num,
|
||||||
|
account_id=profile.tax_account_id, debit=Decimal("0"), credit=tax_amount,
|
||||||
|
))
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return await self.posting_engine.post_voucher(
|
||||||
|
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="sales"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resolve_profile(
|
||||||
|
self, tenant_id: UUID, document_type: SalesDocumentType, profile_id: UUID | None
|
||||||
|
) -> SalesPostingProfile:
|
||||||
|
if profile_id:
|
||||||
|
profile = await self.profile_repo.get(tenant_id, profile_id)
|
||||||
|
if profile is None:
|
||||||
|
raise NotFoundError("پروفایل ثبت فروش یافت نشد", error_code="profile_not_found")
|
||||||
|
return profile
|
||||||
|
from sqlalchemy import select
|
||||||
|
stmt = select(SalesPostingProfile).where(
|
||||||
|
SalesPostingProfile.tenant_id == tenant_id,
|
||||||
|
SalesPostingProfile.document_type == document_type,
|
||||||
|
SalesPostingProfile.is_default.is_(True),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
if profile is None:
|
||||||
|
raise NotFoundError("پروفایل پیشفرض ثبت فروش یافت نشد", error_code="profile_not_found")
|
||||||
|
return profile
|
||||||
124
backend/services/accounting/app/services/settlement_engine.py
Normal file
124
backend/services/accounting/app/services/settlement_engine.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""Settlement Engine for AR/AP."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.receivable_payable import (
|
||||||
|
ReceivableInvoice,
|
||||||
|
PayableInvoice,
|
||||||
|
Settlement,
|
||||||
|
SettlementAllocation,
|
||||||
|
)
|
||||||
|
from app.models.types import InvoiceStatus, SettlementStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementError(AppError):
|
||||||
|
status_code = 422
|
||||||
|
error_code = "settlement_error"
|
||||||
|
|
||||||
|
|
||||||
|
class ReceivableInvoiceRepository(TenantBaseRepository[ReceivableInvoice]):
|
||||||
|
model = ReceivableInvoice
|
||||||
|
|
||||||
|
|
||||||
|
class PayableInvoiceRepository(TenantBaseRepository[PayableInvoice]):
|
||||||
|
model = PayableInvoice
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementRepository(TenantBaseRepository[Settlement]):
|
||||||
|
model = Settlement
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementEngine:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.receivable_repo = ReceivableInvoiceRepository(session)
|
||||||
|
self.payable_repo = PayableInvoiceRepository(session)
|
||||||
|
self.settlement_repo = SettlementRepository(session)
|
||||||
|
|
||||||
|
async def allocate_receivable(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
settlement: Settlement,
|
||||||
|
allocations: list[dict],
|
||||||
|
) -> Settlement:
|
||||||
|
if settlement.status == SettlementStatus.COMPLETED:
|
||||||
|
raise SettlementError("تسویه قبلاً انجام شده است")
|
||||||
|
|
||||||
|
total_allocated = Decimal("0")
|
||||||
|
for alloc in allocations:
|
||||||
|
invoice = await self.receivable_repo.get(tenant_id, alloc["invoice_id"])
|
||||||
|
if invoice is None:
|
||||||
|
raise NotFoundError("فاکتور دریافتنی یافت نشد", error_code="invoice_not_found")
|
||||||
|
amount = Decimal(str(alloc["amount"]))
|
||||||
|
if amount > invoice.remaining_amount:
|
||||||
|
raise SettlementError(f"مبلغ تخصیص بیش از مانده فاکتور {invoice.invoice_number}")
|
||||||
|
|
||||||
|
self.session.add(
|
||||||
|
SettlementAllocation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
settlement_id=settlement.id,
|
||||||
|
invoice_type="receivable",
|
||||||
|
invoice_id=invoice.id,
|
||||||
|
allocated_amount=amount,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invoice.paid_amount += amount
|
||||||
|
invoice.remaining_amount -= amount
|
||||||
|
if invoice.remaining_amount == 0:
|
||||||
|
invoice.status = InvoiceStatus.PAID
|
||||||
|
else:
|
||||||
|
invoice.status = InvoiceStatus.PARTIALLY_PAID
|
||||||
|
total_allocated += amount
|
||||||
|
|
||||||
|
if total_allocated != settlement.total_amount:
|
||||||
|
raise SettlementError("مجموع تخصیص با مبلغ تسویه برابر نیست")
|
||||||
|
|
||||||
|
settlement.status = SettlementStatus.COMPLETED
|
||||||
|
return settlement
|
||||||
|
|
||||||
|
async def allocate_payable(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
settlement: Settlement,
|
||||||
|
allocations: list[dict],
|
||||||
|
) -> Settlement:
|
||||||
|
if settlement.status == SettlementStatus.COMPLETED:
|
||||||
|
raise SettlementError("تسویه قبلاً انجام شده است")
|
||||||
|
|
||||||
|
total_allocated = Decimal("0")
|
||||||
|
for alloc in allocations:
|
||||||
|
invoice = await self.payable_repo.get(tenant_id, alloc["invoice_id"])
|
||||||
|
if invoice is None:
|
||||||
|
raise NotFoundError("فاکتور پرداختنی یافت نشد", error_code="invoice_not_found")
|
||||||
|
amount = Decimal(str(alloc["amount"]))
|
||||||
|
if amount > invoice.remaining_amount:
|
||||||
|
raise SettlementError(f"مبلغ تخصیص بیش از مانده فاکتور {invoice.invoice_number}")
|
||||||
|
|
||||||
|
self.session.add(
|
||||||
|
SettlementAllocation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
settlement_id=settlement.id,
|
||||||
|
invoice_type="payable",
|
||||||
|
invoice_id=invoice.id,
|
||||||
|
allocated_amount=amount,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invoice.paid_amount += amount
|
||||||
|
invoice.remaining_amount -= amount
|
||||||
|
if invoice.remaining_amount == 0:
|
||||||
|
invoice.status = InvoiceStatus.PAID
|
||||||
|
else:
|
||||||
|
invoice.status = InvoiceStatus.PARTIALLY_PAID
|
||||||
|
total_allocated += amount
|
||||||
|
|
||||||
|
if total_allocated != settlement.total_amount:
|
||||||
|
raise SettlementError("مجموع تخصیص با مبلغ تسویه برابر نیست")
|
||||||
|
|
||||||
|
settlement.status = SettlementStatus.COMPLETED
|
||||||
|
return settlement
|
||||||
173
backend/services/accounting/app/services/treasury_service.py
Normal file
173
backend/services/accounting/app/services/treasury_service.py
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"""Treasury service — integrates with Posting Engine only."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.posting import Voucher, VoucherLine
|
||||||
|
from app.models.treasury import CashBox, CashTransaction, BankAccount, BankTransaction
|
||||||
|
from app.models.types import TreasuryTransactionType, VoucherStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.repositories.foundation import FiscalPeriodRepository
|
||||||
|
from app.repositories.posting import VoucherRepository
|
||||||
|
from app.services.posting_engine import PostingEngine
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class TreasuryError(AppError):
|
||||||
|
status_code = 422
|
||||||
|
error_code = "treasury_error"
|
||||||
|
|
||||||
|
|
||||||
|
class CashBoxRepository(TenantBaseRepository[CashBox]):
|
||||||
|
model = CashBox
|
||||||
|
|
||||||
|
|
||||||
|
class CashTransactionRepository(TenantBaseRepository[CashTransaction]):
|
||||||
|
model = CashTransaction
|
||||||
|
|
||||||
|
|
||||||
|
class BankAccountRepository(TenantBaseRepository[BankAccount]):
|
||||||
|
model = BankAccount
|
||||||
|
|
||||||
|
|
||||||
|
class TreasuryService:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.cash_box_repo = CashBoxRepository(session)
|
||||||
|
self.cash_tx_repo = CashTransactionRepository(session)
|
||||||
|
self.bank_account_repo = BankAccountRepository(session)
|
||||||
|
self.voucher_repo = VoucherRepository(session)
|
||||||
|
self.period_repo = FiscalPeriodRepository(session)
|
||||||
|
self.posting_engine = PostingEngine(session)
|
||||||
|
|
||||||
|
async def record_cash_receipt(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
cash_box_id: UUID,
|
||||||
|
amount: Decimal,
|
||||||
|
*,
|
||||||
|
debit_account_id: UUID,
|
||||||
|
credit_account_id: UUID,
|
||||||
|
actor_user_id: str,
|
||||||
|
description: str | None = None,
|
||||||
|
transaction_date: date | None = None,
|
||||||
|
) -> CashTransaction:
|
||||||
|
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
|
||||||
|
if cash_box is None:
|
||||||
|
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
|
||||||
|
|
||||||
|
period = await self.period_repo.get_current(tenant_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
|
||||||
|
|
||||||
|
tx_date = transaction_date or date.today()
|
||||||
|
ref = f"CASH-R-{tx_date.isoformat()}-{cash_box.code}"
|
||||||
|
|
||||||
|
voucher = await self._create_treasury_voucher(
|
||||||
|
tenant_id, period.id, ref, tx_date,
|
||||||
|
debit_account_id, credit_account_id, amount,
|
||||||
|
description or "دریافت نقدی", actor_user_id, "treasury",
|
||||||
|
)
|
||||||
|
|
||||||
|
tx = CashTransaction(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
cash_box_id=cash_box_id,
|
||||||
|
transaction_type=TreasuryTransactionType.CASH_RECEIPT,
|
||||||
|
transaction_date=tx_date,
|
||||||
|
amount=amount,
|
||||||
|
description=description,
|
||||||
|
voucher_id=voucher.id,
|
||||||
|
reference_number=ref,
|
||||||
|
)
|
||||||
|
await self.cash_tx_repo.add(tx)
|
||||||
|
cash_box.current_balance += amount
|
||||||
|
return tx
|
||||||
|
|
||||||
|
async def record_cash_payment(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
cash_box_id: UUID,
|
||||||
|
amount: Decimal,
|
||||||
|
*,
|
||||||
|
debit_account_id: UUID,
|
||||||
|
credit_account_id: UUID,
|
||||||
|
actor_user_id: str,
|
||||||
|
description: str | None = None,
|
||||||
|
transaction_date: date | None = None,
|
||||||
|
) -> CashTransaction:
|
||||||
|
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
|
||||||
|
if cash_box is None:
|
||||||
|
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
|
||||||
|
if cash_box.current_balance < amount:
|
||||||
|
raise TreasuryError("موجودی صندوق کافی نیست")
|
||||||
|
|
||||||
|
period = await self.period_repo.get_current(tenant_id)
|
||||||
|
if period is None:
|
||||||
|
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
|
||||||
|
|
||||||
|
tx_date = transaction_date or date.today()
|
||||||
|
ref = f"CASH-P-{tx_date.isoformat()}-{cash_box.code}"
|
||||||
|
|
||||||
|
voucher = await self._create_treasury_voucher(
|
||||||
|
tenant_id, period.id, ref, tx_date,
|
||||||
|
debit_account_id, credit_account_id, amount,
|
||||||
|
description or "پرداخت نقدی", actor_user_id, "treasury",
|
||||||
|
)
|
||||||
|
|
||||||
|
tx = CashTransaction(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
cash_box_id=cash_box_id,
|
||||||
|
transaction_type=TreasuryTransactionType.CASH_PAYMENT,
|
||||||
|
transaction_date=tx_date,
|
||||||
|
amount=amount,
|
||||||
|
description=description,
|
||||||
|
voucher_id=voucher.id,
|
||||||
|
reference_number=ref,
|
||||||
|
)
|
||||||
|
await self.cash_tx_repo.add(tx)
|
||||||
|
cash_box.current_balance -= amount
|
||||||
|
return tx
|
||||||
|
|
||||||
|
async def _create_treasury_voucher(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
period_id: UUID,
|
||||||
|
voucher_number: str,
|
||||||
|
voucher_date: date,
|
||||||
|
debit_account_id: UUID,
|
||||||
|
credit_account_id: UUID,
|
||||||
|
amount: Decimal,
|
||||||
|
description: str,
|
||||||
|
actor_user_id: str,
|
||||||
|
source_module: str,
|
||||||
|
) -> Voucher:
|
||||||
|
voucher = Voucher(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fiscal_period_id=period_id,
|
||||||
|
voucher_number=voucher_number,
|
||||||
|
voucher_date=voucher_date,
|
||||||
|
status=VoucherStatus.DRAFT,
|
||||||
|
description=description,
|
||||||
|
source_module=source_module,
|
||||||
|
created_by=actor_user_id,
|
||||||
|
)
|
||||||
|
await self.voucher_repo.add(voucher)
|
||||||
|
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
|
||||||
|
account_id=debit_account_id, debit=amount, credit=Decimal("0"),
|
||||||
|
description=description,
|
||||||
|
))
|
||||||
|
self.session.add(VoucherLine(
|
||||||
|
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
|
||||||
|
account_id=credit_account_id, debit=Decimal("0"), credit=amount,
|
||||||
|
description=description,
|
||||||
|
))
|
||||||
|
await self.session.flush()
|
||||||
|
return await self.posting_engine.post_voucher(
|
||||||
|
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module=source_module
|
||||||
|
)
|
||||||
39
backend/services/accounting/app/tests/conftest.py
Normal file
39
backend/services/accounting/app/tests/conftest.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
os.environ["ENVIRONMENT"] = "test"
|
||||||
|
os.environ["AUTH_REQUIRED"] = "false"
|
||||||
|
os.environ["ACCOUNTING_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
os.environ["ACCOUNTING_DATABASE_URL_SYNC"] = "sqlite:///:memory:"
|
||||||
|
os.environ["JWT_VERIFY_SIGNATURE"] = "false"
|
||||||
|
|
||||||
|
from app.core.database import Base, engine # noqa: E402
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
|
||||||
|
TENANT_A = uuid.uuid4()
|
||||||
|
TENANT_B = uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def db_setup():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(db_setup):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]:
|
||||||
|
return {"X-Tenant-ID": str(tenant_id)}
|
||||||
228
backend/services/accounting/app/tests/test_api.py
Normal file
228
backend/services/accounting/app/tests/test_api.py
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health(client):
|
||||||
|
resp = await client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["service"] == "accounting-service"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_chart_of_accounts(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/accounts/charts",
|
||||||
|
json={"name": "Default COA", "code": "COA-001", "is_default": True},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["code"] == "COA-001"
|
||||||
|
assert data["tenant_id"] == str(TENANT_A)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tenant_isolation_accounts(client):
|
||||||
|
chart_resp = await client.post(
|
||||||
|
"/api/v1/accounts/charts",
|
||||||
|
json={"name": "Tenant A COA", "code": "COA-A"},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert chart_resp.status_code == 201
|
||||||
|
chart_id = chart_resp.json()["id"]
|
||||||
|
|
||||||
|
acc_resp = await client.post(
|
||||||
|
"/api/v1/accounts",
|
||||||
|
json={
|
||||||
|
"chart_id": chart_id,
|
||||||
|
"code": "1000",
|
||||||
|
"name": "Test Account",
|
||||||
|
"account_type": "asset",
|
||||||
|
"account_category": "current_asset",
|
||||||
|
},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert acc_resp.status_code == 201
|
||||||
|
account_id = acc_resp.json()["id"]
|
||||||
|
|
||||||
|
resp_b_list = await client.get(
|
||||||
|
"/api/v1/accounts",
|
||||||
|
headers=tenant_headers(TENANT_B),
|
||||||
|
)
|
||||||
|
assert resp_b_list.status_code == 200
|
||||||
|
assert len(resp_b_list.json()) == 0
|
||||||
|
|
||||||
|
resp_b_get = await client.get(
|
||||||
|
f"/api/v1/accounts/{account_id}",
|
||||||
|
headers=tenant_headers(TENANT_B),
|
||||||
|
)
|
||||||
|
assert resp_b_get.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_posting_engine_balanced_voucher(client):
|
||||||
|
year_resp = await client.post(
|
||||||
|
"/api/v1/fiscal/years",
|
||||||
|
json={"name": "1403", "start_date": "2024-03-20", "end_date": "2025-03-19", "is_current": True},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert year_resp.status_code == 201
|
||||||
|
year_id = year_resp.json()["id"]
|
||||||
|
|
||||||
|
period_resp = await client.post(
|
||||||
|
"/api/v1/fiscal/periods",
|
||||||
|
json={
|
||||||
|
"fiscal_year_id": year_id,
|
||||||
|
"name": "Month 1",
|
||||||
|
"period_number": 1,
|
||||||
|
"start_date": "2024-03-20",
|
||||||
|
"end_date": "2024-04-19",
|
||||||
|
"is_current": True,
|
||||||
|
},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert period_resp.status_code == 201
|
||||||
|
period_id = period_resp.json()["id"]
|
||||||
|
|
||||||
|
chart_resp = await client.post(
|
||||||
|
"/api/v1/accounts/charts",
|
||||||
|
json={"name": "COA", "code": "COA-POST"},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
chart_id = chart_resp.json()["id"]
|
||||||
|
|
||||||
|
acc1_resp = await client.post(
|
||||||
|
"/api/v1/accounts",
|
||||||
|
json={
|
||||||
|
"chart_id": chart_id,
|
||||||
|
"code": "1101",
|
||||||
|
"name": "Cash",
|
||||||
|
"account_type": "asset",
|
||||||
|
"account_category": "current_asset",
|
||||||
|
},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
acc2_resp = await client.post(
|
||||||
|
"/api/v1/accounts",
|
||||||
|
json={
|
||||||
|
"chart_id": chart_id,
|
||||||
|
"code": "4101",
|
||||||
|
"name": "Revenue",
|
||||||
|
"account_type": "revenue",
|
||||||
|
"account_category": "operating_revenue",
|
||||||
|
},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
acc1_id = acc1_resp.json()["id"]
|
||||||
|
acc2_id = acc2_resp.json()["id"]
|
||||||
|
|
||||||
|
voucher_resp = await client.post(
|
||||||
|
"/api/v1/posting/vouchers",
|
||||||
|
json={
|
||||||
|
"fiscal_period_id": period_id,
|
||||||
|
"voucher_number": "V-001",
|
||||||
|
"voucher_date": str(date.today()),
|
||||||
|
"description": "Test voucher",
|
||||||
|
"lines": [
|
||||||
|
{"account_id": acc1_id, "debit": "1000.0000", "credit": "0"},
|
||||||
|
{"account_id": acc2_id, "debit": "0", "credit": "1000.0000"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert voucher_resp.status_code == 201
|
||||||
|
voucher_id = voucher_resp.json()["id"]
|
||||||
|
|
||||||
|
post_resp = await client.post(
|
||||||
|
f"/api/v1/posting/vouchers/{voucher_id}/post",
|
||||||
|
json={},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert post_resp.status_code == 200
|
||||||
|
assert post_resp.json()["status"] == "posted"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unbalanced_voucher_rejected(client):
|
||||||
|
year_resp = await client.post(
|
||||||
|
"/api/v1/fiscal/years",
|
||||||
|
json={"name": "1404", "start_date": "2025-03-20", "end_date": "2026-03-19"},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
year_id = year_resp.json()["id"]
|
||||||
|
period_resp = await client.post(
|
||||||
|
"/api/v1/fiscal/periods",
|
||||||
|
json={
|
||||||
|
"fiscal_year_id": year_id,
|
||||||
|
"name": "P1",
|
||||||
|
"period_number": 1,
|
||||||
|
"start_date": "2025-03-20",
|
||||||
|
"end_date": "2025-04-19",
|
||||||
|
"is_current": True,
|
||||||
|
},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
period_id = period_resp.json()["id"]
|
||||||
|
|
||||||
|
chart_resp = await client.post(
|
||||||
|
"/api/v1/accounts/charts",
|
||||||
|
json={"name": "COA2", "code": "COA-UNBAL"},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
chart_id = chart_resp.json()["id"]
|
||||||
|
|
||||||
|
acc_resp = await client.post(
|
||||||
|
"/api/v1/accounts",
|
||||||
|
json={
|
||||||
|
"chart_id": chart_id,
|
||||||
|
"code": "1102",
|
||||||
|
"name": "Bank",
|
||||||
|
"account_type": "asset",
|
||||||
|
"account_category": "current_asset",
|
||||||
|
},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
acc_id = acc_resp.json()["id"]
|
||||||
|
|
||||||
|
voucher_resp = await client.post(
|
||||||
|
"/api/v1/posting/vouchers",
|
||||||
|
json={
|
||||||
|
"fiscal_period_id": period_id,
|
||||||
|
"voucher_number": "V-UNBAL",
|
||||||
|
"voucher_date": str(date.today()),
|
||||||
|
"lines": [
|
||||||
|
{"account_id": acc_id, "debit": "500", "credit": "0"},
|
||||||
|
{"account_id": acc_id, "debit": "0", "credit": "300"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
voucher_id = voucher_resp.json()["id"]
|
||||||
|
|
||||||
|
validate_resp = await client.post(
|
||||||
|
f"/api/v1/posting/vouchers/{voucher_id}/validate",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert validate_resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trial_balance(client):
|
||||||
|
tb_resp = await client.post(
|
||||||
|
"/api/v1/ledger/trial-balance",
|
||||||
|
params={"fiscal_period_id": str(uuid.uuid4())},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert tb_resp.status_code == 200
|
||||||
|
assert "is_balanced" in tb_resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_requires_tenant_header(client):
|
||||||
|
resp = await client.get("/api/v1/accounts")
|
||||||
|
assert resp.status_code == 400
|
||||||
45
backend/services/accounting/app/tests/test_architecture.py
Normal file
45
backend/services/accounting/app/tests/test_architecture.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"""Architecture tests — module boundary enforcement."""
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_direct_journal_creation_outside_posting_engine():
|
||||||
|
"""ADR-010: JournalEntry creation must go through PostingEngine."""
|
||||||
|
from app.services.posting_engine import PostingEngine
|
||||||
|
|
||||||
|
assert hasattr(PostingEngine, "post_voucher")
|
||||||
|
assert hasattr(PostingEngine, "reverse_voucher")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_models_have_tenant_id():
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
skip = {"alembic_version"}
|
||||||
|
for table in Base.metadata.tables.values():
|
||||||
|
if table.name in skip:
|
||||||
|
continue
|
||||||
|
assert "tenant_id" in table.columns, f"{table.name} missing tenant_id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_permissions_defined():
|
||||||
|
from app.permissions.definitions import ALL_PERMISSIONS
|
||||||
|
|
||||||
|
assert "accounting.view" in ALL_PERMISSIONS
|
||||||
|
assert "purchase_accounting.post" in ALL_PERMISSIONS
|
||||||
|
assert "assets.depreciate" in ALL_PERMISSIONS
|
||||||
|
assert "payroll.calculate" in ALL_PERMISSIONS
|
||||||
|
assert "reports.view" in ALL_PERMISSIONS
|
||||||
|
assert "audit.view" in ALL_PERMISSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_defined():
|
||||||
|
from app.events.types import AccountingEventType
|
||||||
|
|
||||||
|
assert AccountingEventType.GOODS_RECEIVED.value == "goods.received"
|
||||||
|
assert AccountingEventType.ASSET_DEPRECIATED.value == "asset.depreciated"
|
||||||
|
assert AccountingEventType.PAYROLL_POSTED.value == "payroll.posted"
|
||||||
|
assert AccountingEventType.BALANCE_SHEET_GENERATED.value == "balance_sheet.generated"
|
||||||
|
assert AccountingEventType.AUDIT_RECORD_CREATED.value == "audit_record.created"
|
||||||
142
backend/services/accounting/app/tests/test_phases_57_511.py
Normal file
142
backend/services/accounting/app/tests/test_phases_57_511.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""Tests for phases 5.7–5.11."""
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.tests.conftest import TENANT_A, tenant_headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_purchase_profile(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/purchase-inventory/posting-profiles",
|
||||||
|
json={"name": "Default Purchase", "is_default": True},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_asset_category(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/assets/categories",
|
||||||
|
json={"code": "VEH", "name": "Vehicles"},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_asset(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/assets/assets",
|
||||||
|
json={"code": "AST-001", "name": "Office Desk", "acquisition_cost": "5000000"},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["code"] == "AST-001"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_depreciation_engine():
|
||||||
|
from app.models.fixed_assets import Asset
|
||||||
|
from app.models.types import DepreciationMethod
|
||||||
|
from app.services.asset_service import DepreciationEngine
|
||||||
|
|
||||||
|
asset = Asset(
|
||||||
|
tenant_id=uuid.uuid4(),
|
||||||
|
code="T1",
|
||||||
|
name="Test",
|
||||||
|
acquisition_cost=Decimal("1200000"),
|
||||||
|
current_book_value=Decimal("1200000"),
|
||||||
|
residual_value=Decimal("0"),
|
||||||
|
useful_life_months=12,
|
||||||
|
depreciation_method=DepreciationMethod.STRAIGHT_LINE,
|
||||||
|
)
|
||||||
|
engine = DepreciationEngine()
|
||||||
|
amount = engine.calculate(asset)
|
||||||
|
assert amount == Decimal("100000.0000")
|
||||||
|
schedule = engine.generate_schedule(asset)
|
||||||
|
assert len(schedule) == 12
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_employee(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/payroll/employees",
|
||||||
|
json={"employee_code": "EMP-001", "first_name": "Ali", "last_name": "Rezaei", "base_salary": "50000000"},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_payroll_calculation():
|
||||||
|
from app.models.payroll import Employee
|
||||||
|
from app.models.types import EmploymentStatus
|
||||||
|
from app.services.payroll_service import PayrollEngine
|
||||||
|
|
||||||
|
employee = Employee(
|
||||||
|
tenant_id=uuid.uuid4(),
|
||||||
|
employee_code="E1",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
base_salary=Decimal("10000000"),
|
||||||
|
employment_status=EmploymentStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
engine = PayrollEngine()
|
||||||
|
result = engine.calculate(employee, benefits=Decimal("1000000"), deductions=Decimal("500000"))
|
||||||
|
assert result["gross_salary"] == Decimal("11000000")
|
||||||
|
assert result["net_salary"] == Decimal("10500000")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_trial_balance_report(client):
|
||||||
|
period_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/reporting/trial-balance",
|
||||||
|
params={"fiscal_period_id": period_id},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "report_type" in resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_audit_record(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/compliance/audit-records",
|
||||||
|
json={
|
||||||
|
"action": "voucher.posted",
|
||||||
|
"resource_type": "voucher",
|
||||||
|
"resource_id": str(uuid.uuid4()),
|
||||||
|
"source_module": "accounting",
|
||||||
|
},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inventory_valuation_engine():
|
||||||
|
from app.services.purchase_inventory_service import InventoryValuationEngine
|
||||||
|
|
||||||
|
engine = InventoryValuationEngine()
|
||||||
|
avg = engine.calculate_weighted_average(Decimal("10"), Decimal("10000"), Decimal("5"), Decimal("1200"))
|
||||||
|
assert avg == Decimal("10000") / Decimal("15") + Decimal("6000") / Decimal("15")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tenant_isolation_assets(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/assets/assets",
|
||||||
|
json={"code": "ISO-001", "name": "Isolated Asset"},
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
asset_id = resp.json()["id"]
|
||||||
|
|
||||||
|
other_tenant = uuid.uuid4()
|
||||||
|
list_resp = await client.get("/api/v1/assets/assets", headers=tenant_headers(other_tenant))
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
assert len(list_resp.json()) == 0
|
||||||
6
backend/services/accounting/pytest.ini
Normal file
6
backend/services/accounting/pytest.ini
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = app/tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
14
backend/services/accounting/requirements.txt
Normal file
14
backend/services/accounting/requirements.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.30.1
|
||||||
|
pydantic[email]==2.7.4
|
||||||
|
pydantic-settings==2.3.4
|
||||||
|
sqlalchemy==2.0.31
|
||||||
|
alembic==1.13.2
|
||||||
|
asyncpg==0.29.0
|
||||||
|
psycopg[binary]==3.2.1
|
||||||
|
httpx==0.27.0
|
||||||
|
pyjwt[crypto]==2.8.0
|
||||||
|
-e ../../shared-lib
|
||||||
|
pytest==8.2.2
|
||||||
|
pytest-asyncio==0.23.7
|
||||||
|
aiosqlite==0.20.0
|
||||||
41
backend/services/accounting/scripts/ensure_db.py
Normal file
41
backend/services/accounting/scripts/ensure_db.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"""Ensure accounting_db exists before migration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
sync_url = os.environ.get("ACCOUNTING_DATABASE_URL_SYNC", "")
|
||||||
|
if not sync_url:
|
||||||
|
print("ACCOUNTING_DATABASE_URL_SYNC not set", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed = urlparse(sync_url.replace("+psycopg", ""))
|
||||||
|
db_name = (parsed.path or "").lstrip("/") or "accounting_db"
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
conn = psycopg.connect(
|
||||||
|
host=parsed.hostname or "localhost",
|
||||||
|
port=parsed.port or 5432,
|
||||||
|
user=parsed.username,
|
||||||
|
password=parsed.password,
|
||||||
|
dbname="postgres",
|
||||||
|
autocommit=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,))
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute(f'CREATE DATABASE "{db_name}"')
|
||||||
|
print(f"Created database: {db_name}")
|
||||||
|
else:
|
||||||
|
print(f"Database exists: {db_name}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -13,3 +13,10 @@
|
|||||||
- دیتابیس مستقل (`ai_assistant_db`) با `tenant_id` در جداول بیزینسی.
|
- دیتابیس مستقل (`ai_assistant_db`) با `tenant_id` در جداول بیزینسی.
|
||||||
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
||||||
- بررسی دسترسی قابلیتها از Core (`ai_assistant.*`).
|
- بررسی دسترسی قابلیتها از Core (`ai_assistant.*`).
|
||||||
|
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [Module Registry](../../../docs/module-registry.md#ai_assistant)
|
||||||
|
- [Services Contracts](../../../docs/reference/services-contracts.md)
|
||||||
|
- [Architecture](../../../docs/architecture/architecture.md)
|
||||||
|
|||||||
@ -16,3 +16,10 @@
|
|||||||
- دیتابیس مستقل (`crm_db`) با `tenant_id` در جداول بیزینسی.
|
- دیتابیس مستقل (`crm_db`) با `tenant_id` در جداول بیزینسی.
|
||||||
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
||||||
- بررسی دسترسی قابلیتها از Core (`crm.*`).
|
- بررسی دسترسی قابلیتها از Core (`crm.*`).
|
||||||
|
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [Module Registry](../../../docs/module-registry.md#crm)
|
||||||
|
- [Services Contracts](../../../docs/reference/services-contracts.md)
|
||||||
|
- [Architecture](../../../docs/architecture/architecture.md)
|
||||||
|
|||||||
@ -17,3 +17,10 @@
|
|||||||
- دیتابیس مستقل (`ecommerce_db`) با `tenant_id` در جداول بیزینسی.
|
- دیتابیس مستقل (`ecommerce_db`) با `tenant_id` در جداول بیزینسی.
|
||||||
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
||||||
- بررسی دسترسی قابلیتها از Core (`ecommerce.*`).
|
- بررسی دسترسی قابلیتها از Core (`ecommerce.*`).
|
||||||
|
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [Module Registry](../../../docs/module-registry.md#ecommerce)
|
||||||
|
- [Services Contracts](../../../docs/reference/services-contracts.md)
|
||||||
|
- [Architecture](../../../docs/architecture/architecture.md)
|
||||||
|
|||||||
@ -12,3 +12,10 @@
|
|||||||
- دیتابیس مستقل (`file_storage_db`) با `tenant_id` در جداول بیزینسی.
|
- دیتابیس مستقل (`file_storage_db`) با `tenant_id` در جداول بیزینسی.
|
||||||
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
||||||
- بررسی دسترسی قابلیتها از Core (`file_storage.*`).
|
- بررسی دسترسی قابلیتها از Core (`file_storage.*`).
|
||||||
|
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [Module Registry](../../../docs/module-registry.md#file_storage)
|
||||||
|
- [Services Contracts](../../../docs/reference/services-contracts.md)
|
||||||
|
- [Architecture](../../../docs/architecture/architecture.md)
|
||||||
|
|||||||
@ -1,27 +1,47 @@
|
|||||||
# Identity & Access Service
|
# Identity & Access Service
|
||||||
|
|
||||||
سرویس احراز هویت و دسترسی (فاز ۲) — SSO مرکزی با Keycloak.
|
سرویس احراز هویت و دسترسی — SSO مرکزی با Keycloak.
|
||||||
|
|
||||||
## مسئولیتها
|
Canonical contracts: [`docs/reference/services-contracts.md`](../../../docs/reference/services-contracts.md) §8
|
||||||
- مدیریت پروفایل کاربر و عضویت tenant
|
Architecture: [`docs/architecture/identity-architecture.md`](../../../docs/architecture/identity-architecture.md)
|
||||||
- ارائه پیکربندی OIDC به frontend
|
Registry: [`docs/module-registry.md`](../../../docs/module-registry.md#identity-access)
|
||||||
- تبدیل authorization code به token (BFF)
|
|
||||||
- همگامسازی کاربران با Keycloak Admin API
|
|
||||||
|
|
||||||
## دیتابیس
|
## Responsibilities
|
||||||
`identity_access_db` (مستقل از Core — database-per-service)
|
|
||||||
|
|
||||||
## APIهای اصلی
|
- User profile + identity-layer tenant membership
|
||||||
| متد | مسیر | توضیح |
|
- OIDC config for frontend
|
||||||
|
- Authorization code → token (BFF)
|
||||||
|
- Mobile OTP start/complete + session handoff redeem
|
||||||
|
- Keycloak Admin sync
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
`identity_access_db` (database-per-service; not Core)
|
||||||
|
|
||||||
|
> Operational workspace roles live in Core `tenant_memberships` — see [ADR-007](../../../docs/architecture/adr/ADR-007.md).
|
||||||
|
|
||||||
|
## Main APIs
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/health` | سلامت سرویس |
|
| GET | `/health` | Public |
|
||||||
| GET | `/api/v1/auth/config` | پیکربندی OIDC (عمومی) |
|
| GET | `/api/v1/auth/config` | OIDC config |
|
||||||
| POST | `/api/v1/auth/token` | تبدیل code به token |
|
| GET | `/api/v1/auth/login-url` | Login redirect helper |
|
||||||
| GET | `/api/v1/auth/me` | کاربر جاری |
|
| POST | `/api/v1/auth/token` | Code exchange |
|
||||||
| POST | `/api/v1/users` | ثبت کاربر (admin) |
|
| GET | `/api/v1/auth/me` | Current user |
|
||||||
| POST | `/api/v1/tenants/{id}/members` | افزودن عضو tenant |
|
| POST | `/api/v1/auth/mobile/start` | Unified mobile start |
|
||||||
|
| POST | `/api/v1/auth/mobile/complete` | Unified mobile complete |
|
||||||
|
| POST | `/api/v1/auth/session/redeem` | Handoff redeem |
|
||||||
|
| POST | `/api/v1/auth/mobile/request` | SSO user mobile verify request |
|
||||||
|
| POST | `/api/v1/auth/mobile/verify` | SSO user mobile verify |
|
||||||
|
| POST | `/api/v1/auth/register*` | Legacy register flows |
|
||||||
|
| POST | `/api/v1/users` | platform_admin |
|
||||||
|
| POST/GET | `/api/v1/tenants/{id}/members` | platform_admin |
|
||||||
|
|
||||||
|
Full table: services-contracts §8.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
## اجرا
|
|
||||||
```bash
|
```bash
|
||||||
cd backend/services/identity-access
|
cd backend/services/identity-access
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
@ -29,14 +49,8 @@ alembic upgrade head
|
|||||||
uvicorn app.main:app --reload --port 8001
|
uvicorn app.main:app --reload --port 8001
|
||||||
```
|
```
|
||||||
|
|
||||||
## تست
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest -q
|
pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
## SSO Flow
|
|
||||||
1. Frontend از `/api/v1/auth/config` آدرس Keycloak را میگیرد.
|
|
||||||
2. کاربر به Keycloak redirect میشود.
|
|
||||||
3. پس از login، callback با `code` به frontend برمیگردد.
|
|
||||||
4. Frontend کد را به `/api/v1/auth/token` میفرستد و token دریافت میکند.
|
|
||||||
5. همه سرویسها (Core، CRM، ...) همان JWT را validate میکنند.
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ from app.schemas.auth import (
|
|||||||
PasswordChangeRequest,
|
PasswordChangeRequest,
|
||||||
PasswordChangeResponse,
|
PasswordChangeResponse,
|
||||||
ProfileUpdateRequest,
|
ProfileUpdateRequest,
|
||||||
|
RefreshTokenRequest,
|
||||||
RegisterCompleteResponse,
|
RegisterCompleteResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
TokenExchangeRequest,
|
TokenExchangeRequest,
|
||||||
@ -53,6 +54,14 @@ async def exchange_token(
|
|||||||
return await AuthService(db).exchange_token(payload)
|
return await AuthService(db).exchange_token(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh_token(
|
||||||
|
payload: RefreshTokenRequest, db: AsyncSession = Depends(get_db)
|
||||||
|
) -> TokenResponse:
|
||||||
|
"""تمدید access token با refresh_token تا نشست کاربر قطع نشود."""
|
||||||
|
return await AuthService(db).refresh_token(payload.refresh_token)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=MeResponse)
|
@router.get("/me", response_model=MeResponse)
|
||||||
async def get_me(
|
async def get_me(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|||||||
@ -32,6 +32,10 @@ class TokenExchangeRequest(BaseModel):
|
|||||||
code_verifier: str | None = Field(default=None, min_length=43, max_length=128)
|
code_verifier: str | None = Field(default=None, min_length=43, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenRequest(BaseModel):
|
||||||
|
refresh_token: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str | None = None
|
refresh_token: str | None = None
|
||||||
|
|||||||
@ -83,6 +83,15 @@ class AuthService:
|
|||||||
token_type=raw.get("token_type", "Bearer"),
|
token_type=raw.get("token_type", "Bearer"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def refresh_token(self, refresh_token: str) -> TokenResponse:
|
||||||
|
raw = await self.keycloak.refresh_tokens(refresh_token)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=raw["access_token"],
|
||||||
|
refresh_token=raw.get("refresh_token") or refresh_token,
|
||||||
|
expires_in=raw.get("expires_in", 3600),
|
||||||
|
token_type=raw.get("token_type", "Bearer"),
|
||||||
|
)
|
||||||
|
|
||||||
async def issue_session_for_profile(
|
async def issue_session_for_profile(
|
||||||
self,
|
self,
|
||||||
profile: UserProfile,
|
profile: UserProfile,
|
||||||
|
|||||||
@ -319,3 +319,23 @@ class KeycloakAdminClient:
|
|||||||
logger.warning("keycloak_token_exchange_failed", extra={"detail": detail})
|
logger.warning("keycloak_token_exchange_failed", extra={"detail": detail})
|
||||||
raise UnauthorizedError(f"تبادل token با Keycloak ناموفق بود: {detail}")
|
raise UnauthorizedError(f"تبادل token با Keycloak ناموفق بود: {detail}")
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
async def refresh_tokens(self, refresh_token: str) -> dict[str, Any]:
|
||||||
|
"""تمدید access token با refresh_token (BFF برای frontend)."""
|
||||||
|
data = {
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"client_id": settings.keycloak_frontend_client_id,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(settings.oidc_token_url, data=data)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
detail = resp.text
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
detail = payload.get("error_description") or payload.get("error") or detail
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.warning("keycloak_token_refresh_failed", extra={"detail": detail})
|
||||||
|
raise UnauthorizedError(f"تمدید نشست ناموفق بود: {detail}")
|
||||||
|
return resp.json()
|
||||||
|
|||||||
@ -112,7 +112,7 @@ class MobileAuthService:
|
|||||||
email = f"{data.mobile[1:]}@mobile.torbatyar.local"
|
email = f"{data.mobile[1:]}@mobile.torbatyar.local"
|
||||||
password = mobile_keycloak_password(data.mobile)
|
password = mobile_keycloak_password(data.mobile)
|
||||||
|
|
||||||
core_result = await self.otp.verify_otp(data.mobile, data.code, email=email)
|
# اول Keycloak تا sub داشته باشیم؛ بعد OTP در Core با لینک SSO
|
||||||
sub = await self.keycloak.create_user(
|
sub = await self.keycloak.create_user(
|
||||||
email=email,
|
email=email,
|
||||||
username=username,
|
username=username,
|
||||||
@ -122,6 +122,18 @@ class MobileAuthService:
|
|||||||
)
|
)
|
||||||
await self.keycloak.ensure_user_login_ready(sub)
|
await self.keycloak.ensure_user_login_ready(sub)
|
||||||
|
|
||||||
|
try:
|
||||||
|
core_result = await self.otp.verify_otp(
|
||||||
|
data.mobile,
|
||||||
|
data.code,
|
||||||
|
email=email,
|
||||||
|
keycloak_sub=sub,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# اگر OTP رد شد، کاربر نیمهکاره Keycloak را پاک نکنیم تا
|
||||||
|
# retry ممکن باشد؛ فقط خطا را بالا میفرستیم.
|
||||||
|
raise
|
||||||
|
|
||||||
profile = await self.users.get_by_sub(sub)
|
profile = await self.users.get_by_sub(sub)
|
||||||
if profile is None:
|
if profile is None:
|
||||||
profile = UserProfile(
|
profile = UserProfile(
|
||||||
|
|||||||
@ -122,12 +122,6 @@ class RegistrationService:
|
|||||||
email = f"{data.mobile[1:]}@mobile.torbatyar.local"
|
email = f"{data.mobile[1:]}@mobile.torbatyar.local"
|
||||||
temp_password = mobile_keycloak_password(data.mobile)
|
temp_password = mobile_keycloak_password(data.mobile)
|
||||||
|
|
||||||
core_result = await self.otp.verify_otp(
|
|
||||||
data.mobile,
|
|
||||||
data.code,
|
|
||||||
email=email,
|
|
||||||
)
|
|
||||||
|
|
||||||
sub = await self.keycloak.create_user(
|
sub = await self.keycloak.create_user(
|
||||||
email=email,
|
email=email,
|
||||||
username=username,
|
username=username,
|
||||||
@ -136,6 +130,13 @@ class RegistrationService:
|
|||||||
mobile=data.mobile,
|
mobile=data.mobile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
core_result = await self.otp.verify_otp(
|
||||||
|
data.mobile,
|
||||||
|
data.code,
|
||||||
|
email=email,
|
||||||
|
keycloak_sub=sub,
|
||||||
|
)
|
||||||
|
|
||||||
profile = await self.users.get_by_sub(sub)
|
profile = await self.users.get_by_sub(sub)
|
||||||
if profile is None:
|
if profile is None:
|
||||||
profile = UserProfile(
|
profile = UserProfile(
|
||||||
|
|||||||
@ -13,3 +13,10 @@
|
|||||||
- دیتابیس مستقل (`link_shortener_db`) با `tenant_id` در جداول بیزینسی.
|
- دیتابیس مستقل (`link_shortener_db`) با `tenant_id` در جداول بیزینسی.
|
||||||
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
||||||
- بررسی دسترسی قابلیتها از Core (`link_shortener.*`).
|
- بررسی دسترسی قابلیتها از Core (`link_shortener.*`).
|
||||||
|
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [Module Registry](../../../docs/module-registry.md#link_shortener)
|
||||||
|
- [Services Contracts](../../../docs/reference/services-contracts.md)
|
||||||
|
- [Architecture](../../../docs/architecture/architecture.md)
|
||||||
|
|||||||
@ -13,3 +13,10 @@
|
|||||||
- دیتابیس مستقل (`live_chat_db`) با `tenant_id` در جداول بیزینسی.
|
- دیتابیس مستقل (`live_chat_db`) با `tenant_id` در جداول بیزینسی.
|
||||||
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
- ارتباط با سایر سرویسها فقط از طریق API/Event.
|
||||||
- بررسی دسترسی قابلیتها از Core (`live_chat.*`).
|
- بررسی دسترسی قابلیتها از Core (`live_chat.*`).
|
||||||
|
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [Module Registry](../../../docs/module-registry.md#live_chat)
|
||||||
|
- [Services Contracts](../../../docs/reference/services-contracts.md)
|
||||||
|
- [Architecture](../../../docs/architecture/architecture.md)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user