Compare commits

...

18 Commits

Author SHA1 Message Date
Mortezakoohjani
065c053c16 Add complete Torbat Food hospitality frontend connected to backend APIs.
Introduces modules/hospitality with 54 routes, BFF proxy, capability-gated nav, CRUD factory, and architecture validation docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:54:26 +03:30
Mortezakoohjani
9e1fcd9176 Auto-create default purchase posting profile and complete prepayment treasury fields.
Ensures GR/returns no longer fail without a profile; cash/bank selection appears by payment method.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:03:39 +03:30
Mortezakoohjani
97a363fc53 Wire purchase returns, inventory issues, and GL transfers with auto vouchers.
Goods receipts and warehouse receipts now capture party vs warehouse separately; purchase returns link to invoices and reduce AP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:51:32 +03:30
Mortezakoohjani
320d9cd547 Replace opaque company settings form with a real company profile page.
Users now fill legal name, national ID, economic code, address and contacts instead of generic setting key/value ops documents.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:37:50 +03:30
Mortezakoohjani
cdb69b014b Avoid tenant loading flash when me session is already cached.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:31:47 +03:30
Mortezakoohjani
86b37c1de9 Speed up accounting sidebar navigation with shared session cache and prefetch.
Stop refetching /me on every page mount, warm shared accounting queries, prefetch nav routes, and keep the shell visible during soft SPA transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:31:24 +03:30
Mortezakoohjani
8caed2cfd2 Fix live voucher balance totals and show system-posted vouchers in the list.
RHF was caching 0/0 via useMemo on in-place form arrays; MoneyInput now drives Controller. Voucher list exposes source filters for manual vs automatic GL docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:22:40 +03:30
Mortezakoohjani
d92e1df332 Add automatic GL posting policy with inline voucher balance validation.
Enable tenant-controlled auto-post for sales/purchase/treasury via Posting Engine, block unbalanced voucher lines on FE and BE, and expose international posting controls in settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 17:55:37 +03:30
Mortezakoohjani
7953e47f8b Ship accounting UX: Rial integers, auto doc numbers, reverse-and-edit, real reports, customers nav.
Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:52:06 +03:30
Mortezakoohjani
91f50fbb5f Refresh module registry after platform services sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:35:48 +03:30
Mortezakoohjani
e41ecfad4c Sync platform docs, infra, and module services with Accounting integration.
Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:35:23 +03:30
Mortezakoohjani
067b499193 Connect Accounting frontend to domain APIs for phases 5.1-5.11.
Wire sales/purchase posting, settlements, compliance, payroll employees, and ops edit flows; document the integration report.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:17:41 +03:30
Mortezakoohjani
735f343e0d Mark CRM production deploy verified and harden health wait.
Document successful CRM 6.3 prod smoke checks and retry CRM /health during deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 20:37:12 +03:30
Mortezakoohjani
064d67f099 Ship CRM Core Platform (phases 6.0–6.3) with docs and prod deploy.
Add the Sales CRM service through collaboration, sync architecture/registry docs, expose crm.torbatyar.ir, and include a production deploy script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 20:32:00 +03:30
Mortezakoohjani
4ae54abb26 Keep accounting sessions alive with Keycloak lifespan and forced token refresh.
Raise realm access/SSO timeouts and stop sending expired JWTs when refresh fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:29:36 +03:30
Mortezakoohjani
7e1d728322 Fix prod deploy extract permissions and wire frontend accounting BFF upstream.
Allow sudo chown/overwrite on remote tree and ensure ACCOUNTING_SERVICE_URL reaches the frontend container.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:17:58 +03:30
Mortezakoohjani
7d66932da8 Complete Accounting sidebar modules with ops CRUD, BFF proxy, and treasury APIs.
Wire nested nav pages for phases 5.1-5.11 to real list/create endpoints, fix COA import connectivity via same-origin proxy, and add operational migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:10:06 +03:30
Mortezakoohjani
12c8615615 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>
2026-07-24 15:26:43 +03:30
1016 changed files with 98533 additions and 1910 deletions

View File

@ -20,6 +20,13 @@ PLATFORM_SECONDARY_COLOR=#0f172a
# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً <slug>.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) ----
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
@ -71,6 +78,36 @@ 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
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
# ---- CRM Service ----
CRM_SERVICE_URL=http://crm-service:8003
CRM_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/crm_db
CRM_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db
CRM_SERVICE_NAME=crm-service
# ---- Loyalty Service ----
LOYALTY_SERVICE_URL=http://loyalty-service:8004
LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db
LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db
LOYALTY_SERVICE_NAME=loyalty-service
# ---- Communication Service (Phase 8) ----
COMMUNICATION_SERVICE_URL=http://communication-service:8005
COMMUNICATION_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db
COMMUNICATION_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db
COMMUNICATION_SERVICE_NAME=communication-service
# ---- Sports Center Service (Phase 9.0) ----
SPORTS_CENTER_SERVICE_URL=http://sports-center-service:8006
SPORTS_CENTER_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/sports_center_db
SPORTS_CENTER_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db
SPORTS_CENTER_SERVICE_NAME=sports-center-service
# ---- OTP / Payamak SMS (Core Service) ----
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملی‌پیامک)
PAYAMAK_USERNAME=9155105404
@ -90,12 +127,14 @@ PLATFORM_ADMIN_MOBILES=09155105404
# ---- Frontend (Next.js) ----
FRONTEND_PORT=3000
NEXT_PUBLIC_PLATFORM_BASE_DOMAIN=torbatyar.ir
NEXT_PUBLIC_BACKEND_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_KEYCLOAK_URL=https://auth.torbatyar.ir
NEXT_PUBLIC_KEYCLOAK_REALM=superapp
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002
INTERNAL_TOKEN_SECRET=change-me-internal-secret

1
.gitignore vendored
View File

@ -37,6 +37,7 @@ Thumbs.db
# Deploy temp artifacts
scripts/_deploy_bundle.tar.gz
scripts/_deploy_*.tar.gz
scripts/_tmp_*
scripts/_check_*
scripts/_fix_*

179
README.md
View File

@ -1,101 +1,78 @@
# SuperApp SaaS Platform
پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
این مخزن شامل **فاز ۱ (Core Platform)** و **فاز ۲ (Identity & Access + SSO)** است.
> برند، رنگ‌ها و تنظیمات هیچ‌کدام در کد hardcode نشده‌اند و همگی از `.env` / `config` / دیتابیس خوانده می‌شوند.
## معماری در یک نگاه
- **جداسازی اجباری Frontend/Backend:** دو اپلیکیشن کاملاً مستقل (`backend/` و `frontend/`).
- **الگوی دیتابیس:** Database-per-service. هر سرویس فقط دیتابیس خودش را می‌شناسد.
- **ارتباط بین سرویس‌ها:** فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
- **چندمستأجری:** همه جداول بیزینسی ستون `tenant_id` دارند.
جزئیات کامل در پوشه [`docs/`](./docs) موجود است:
| فایل | توضیح |
| --- | --- |
| [`docs/architecture.md`](./docs/architecture.md) | معماری کلان و جداسازی Frontend/Backend |
| [`docs/database_schema.md`](./docs/database_schema.md) | مدل دیتابیس Core |
| [`docs/services_contracts.md`](./docs/services_contracts.md) | قراردادهای ارتباطی سرویس‌ها |
| [`docs/developer_guide.md`](./docs/developer_guide.md) | راهنمای توسعه‌دهنده |
| [`docs/progress.md`](./docs/progress.md) | چک‌لیست پیشرفت فازها |
| [`docs/last_step.md`](./docs/last_step.md) | آخرین وضعیت |
## ساختار پروژه
```
superapp-platform/
├── backend/
│ ├── core-service/ # FastAPI Core Platform
│ ├── shared-lib/ # کتابخانه مشترک backend
│ └── services/ # placeholder سرویس‌های آینده
├── frontend/ # Next.js (کاملاً جدا)
│ ├── app/
│ ├── components/
│ ├── lib/
│ ├── hooks/
│ ├── styles/
│ └── public/
├── docs/
├── docker-compose.yml
├── .env.example
└── README.md
```
## راه‌اندازی سریع (Docker)
```bash
cp .env.example .env
docker compose up -d --build
```
سایت: http://localhost:3000
سرویس‌ها پس از بالا آمدن (یک دستور: `docker compose up -d --build`):
- **Frontend:** http://localhost:3000 (login: http://localhost:3000/login)
- 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
cd backend/core-service
pytest -q
```
## فازهای بعدی
سرویس‌های آینده در [`backend/services/`](./backend/services) به‌صورت placeholder آماده‌اند.
UI کامل در [`frontend/`](./frontend) توسعه می‌یابد.
# TorbatYar SuperApp SaaS Platform
پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
> برند، رنگ‌ها و تنظیمات هیچ‌کدام در کد hardcode نشده‌اند و همگی از `.env` / `config` / دیتابیس خوانده می‌شوند.
## Documentation (source of truth)
شروع از [`docs/README.md`](./docs/README.md).
| Document | Responsibility |
| --- | --- |
| [`docs/architecture/architecture.md`](./docs/architecture/architecture.md) | Architecture overview |
| [`docs/reference/database-schema.md`](./docs/reference/database-schema.md) | Database schema reference |
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
| [`docs/ai-framework/`](./docs/ai-framework/) | AI Development Framework (implementation lifecycle) |
| [`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
```
TorbatYar/
├── backend/
│ ├── core-service/ # FastAPI Core Platform
│ ├── shared-lib/ # Shared backend library
│ └── services/ # Identity, Accounting, CRM, Loyalty, Communication, Sports Center (+ future)
├── frontend/ # Next.js
├── docs/ # Documentation architecture (canonical)
├── infrastructure/ # Nginx, Keycloak, deploy env samples
├── scripts/ # Ops / verification scripts
├── docker-compose.yml
├── .env.example
└── README.md
```
## Quick start (Docker)
```bash
cp .env.example .env
docker compose up -d --build
```
- **Frontend:** http://localhost:3000
- **Core API:** http://localhost:8000/docs
- **Identity API:** http://localhost:8001/docs
- **Accounting API:** http://localhost:8002/docs
- **CRM API:** http://localhost:8003/docs
- **Loyalty API:** http://localhost:8004/docs
- **Communication API:** http://localhost:8005/docs
- **Sports Center API:** http://localhost:8006/docs
- **Keycloak:** http://localhost:8080
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
## Tests (Backend)
```bash
cd backend/core-service
pytest -q
```
## Current status
See [`docs/progress.md`](./docs/progress.md). Next milestone: [`docs/next-steps.md`](./docs/next-steps.md).

View File

@ -2,20 +2,26 @@
تمام کد منبع backend فقط در این پوشه قرار دارد.
## ساختار
## Structure
```
backend/
├── core-service/ # سرویس Core Platform (FastAPI)
├── shared-lib/ # کتابخانه مشترک بین سرویس‌های backend
└── services/ # placeholder سرویس‌های آینده
├── core-service/ # Core Platform (FastAPI) — Active
├── shared-lib/ # Shared backend library
└── services/ # identity-access (Active) + future module placeholders
```
## قوانین (اجباری)
- **هیچ** کد React، Next.js، Tailwind، UI Component یا HTML در backend نیست.
- frontend در پوشه جداگانه `frontend/` قرار دارد.
- ارتباط با frontend فقط از طریق REST API.
## Rules (mandatory)
- **No** React, Next.js, Tailwind, UI components, or HTML pages in backend.
- 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
cd backend/core-service
python -m venv .venv
@ -24,8 +30,16 @@ alembic upgrade head
uvicorn app.main:app --reload
```
## تست‌ها
## Tests
```bash
cd backend/core-service
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)

View File

@ -16,3 +16,6 @@ alembic downgrade -1
```
> آدرس دیتابیس از `settings.database_url_sync` (متغیر محیطی `DATABASE_URL_SYNC`) خوانده می‌شود.
Related: [Database Architecture](../../../docs/architecture/database-architecture.md), [Coding Standards — migrations](../../../docs/development/coding-standards.md).

View File

@ -9,6 +9,7 @@ from app.api.v1 import (
me,
onboarding,
plans,
public_tenant,
service_registry,
subscriptions,
tenant_context,
@ -20,6 +21,7 @@ api_router = APIRouter()
# health خارج از prefix نسخه هم در main اضافه می‌شود؛ اینجا برای کامل بودن.
api_router.include_router(auth.router)
api_router.include_router(public_tenant.router)
api_router.include_router(admin_tenants.router)
api_router.include_router(tenants.router)
api_router.include_router(domains.router)

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

View File

@ -35,6 +35,16 @@ class Settings(BaseSettings):
# دامنه پایه برای تخصیص زیردامنه خودکار در onboarding (مثلاً tenant.slug + "." + این مقدار)
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 ----
# آدرس async برای برنامه و sync برای Alembic.
database_url: str = "postgresql+asyncpg://superapp:superapp_password@localhost:5432/core_platform_db"

View File

@ -10,6 +10,7 @@ from app.models.domain import Domain
from app.repositories.tenant import DomainRepository, TenantRepository
from app.schemas.domain import DomainCreate
from app.services.event_service import EventService
from app.services.ssl_provision import enqueue_domain_ssl
from shared.events import CoreEventType
from shared.exceptions import ConflictError, NotFoundError
@ -45,6 +46,7 @@ class DomainService:
)
await self.session.commit()
await self.session.refresh(domain)
enqueue_domain_ssl(domain.domain)
return domain
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:

View File

@ -28,6 +28,7 @@ from app.schemas.tenant import TenantCreate
from app.services.event_service import EventService
from app.services.membership_service import MembershipService
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.tenant_service import TenantService
from shared.events import CoreEventType
@ -102,6 +103,7 @@ class OnboardingService:
payload={"domain": host, "type": "subdomain", "auto_assigned": True},
)
await self.session.commit()
enqueue_domain_ssl(host)
async def update_branding(
self, tenant_id: UUID, data: OnboardingBrandingUpdate
@ -143,6 +145,8 @@ class OnboardingService:
payload={"domain": data.custom_domain, "type": "custom"},
)
await self.session.commit()
# فقط اگر DNS از قبل به nginx اشاره کند certbot موفق می‌شود
enqueue_domain_ssl(data.custom_domain)
await self.session.refresh(tenant)
return tenant

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

View File

@ -3,6 +3,10 @@
توکن ممکن است از دو منبع باشد:
۱. JWT محلی OTP (HS256) — sub برابر با users.id است.
۲. JWT مرکزی Keycloak (RS256) — sub برابر با users.keycloak_sub است.
اگر کاربر SSO هنوز به رکورد Core لینک نشده باشد، با موبایل/نام‌کاربری
(JIT) لینک یا ساخته می‌شود — نه اینکه پیام «تأیید موبایل» بدهد وقتی موبایل
قبلاً تأیید شده است.
"""
from __future__ import annotations
@ -10,8 +14,11 @@ from uuid import UUID
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.repositories.user import UserRepository
from app.utils.phone import normalize_mobile
from shared.exceptions import ForbiddenError
from shared.security import CurrentUser
@ -22,12 +29,7 @@ class UserService:
self.repo = UserRepository(session)
async def resolve_current(self, current_user: CurrentUser) -> User:
"""کاربر Core متناظر با هویت جاری را پیدا می‌کند.
اگر کاربر SSO هنوز به یک رکورد Core لینک نشده باشد (مثلاً موبایل
تأیید نشده)، خطای قابل مدیریت در frontend برمی‌گرداند تا کاربر به
جریان تکمیل موبایل هدایت شود.
"""
"""کاربر Core متناظر با هویت جاری را پیدا می‌کند (با JIT link در صورت نیاز)."""
candidate_id: UUID | None = None
try:
candidate_id = UUID(str(current_user.user_id))
@ -39,11 +41,76 @@ class UserService:
if user is not None:
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:
return user
# JIT: لینک/ساخت از روی موبایل یا username (معمولاً همان موبایل)
linked = await self._jit_link_or_create(current_user)
if linked is not None:
return linked
raise ForbiddenError(
"پروفایل کاربری هسته یافت نشد. ابتدا شماره موبایل خود را تأیید کنید.",
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

View File

@ -70,3 +70,67 @@ def process_outbox_events() -> dict:
result = asyncio.run(_run_process_outbox())
logger.info("outbox_processed", extra=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)

View File

@ -20,6 +20,9 @@ celery==5.4.0
httpx==0.27.0
requests==2.32.3
# ---- SSH برای صدور خودکار SSL روی nginx ----
paramiko==3.4.1
# ---- Auth / JWT ----
pyjwt[crypto]==2.8.0

View File

@ -1,13 +1,18 @@
# services/ — سرویس‌های آینده (Placeholder)
# services/ — Backend Modules
این پوشه شامل placeholder سرویس‌های آینده پلتفرم است. در **فاز ۱** هیچ‌کدام
پیاده‌سازی نشده‌اند و فقط README مسئولیت‌ها موجود است.
This folder holds deployable backend services beyond Core.
هر سرویس در فازهای بعدی طبق اصول معماری زیر ساخته می‌شود:
| 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:** هر سرویس دیتابیس مستقل خود را دارد.
- همه جداول بیزینسی ستون `tenant_id` دارند.
- ارتباط بین سرویس‌ها فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
- ارتباط مستقیم بین دیتابیس سرویس‌ها **ممنوع** است.
## Architecture rules
جزئیات قراردادها در [`../../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)

View 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

View File

@ -1,19 +1,52 @@
# Accounting Service (Placeholder)
# Accounting Service
> وضعیت: طراحی‌شده در معماری، پیاده‌سازی در فازهای بعدی.
> Status: **Active** — Phases 5.1 through 5.11 implemented (v0.5.11.0).
## مسئولیت‌ها
- حسابداری ۴ سطحی
- اسناد دوبل (Double-entry)
- مرکز هزینه
- پروژه
- فاکتور
- پرداخت
- مالیات
- سامانه مؤدیان
- گزارش‌های مالی
## Responsibilities
## اصول
- دیتابیس مستقل (`accounting_db`) با `tenant_id` در جداول بیزینسی.
- ارتباط با سایر سرویس‌ها فقط از طریق API/Event.
- بررسی دسترسی قابلیت‌ها از Core (`accounting.*`).
- حسابداری ۴ سطحی، موتور ثبت مرکزی (ADR-010)
- دفتر کل، خزانه، AR/AP، یکپارچه‌سازی فروش/خرید/موجودی
- دارایی ثابت، حقوق و دستمزد
- گزارش‌گیری مالی، انطباق و حاکمیت
## 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)

View 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

View 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()

View 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)

View File

@ -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

View File

@ -0,0 +1,73 @@
"""Alembic: operational docs, inventory items, warehouses."""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from app.models.types import GUID
revision = "0003_operational"
down_revision = "0002_phases_57_511"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"business_documents",
sa.Column("id", GUID(), primary_key=True),
sa.Column("tenant_id", GUID(), nullable=False),
sa.Column("module", sa.String(40), nullable=False),
sa.Column("doc_type", sa.String(50), nullable=False),
sa.Column("number", sa.String(50), nullable=False),
sa.Column("doc_date", sa.Date(), nullable=False),
sa.Column("party_name", sa.String(255), nullable=True),
sa.Column("party_id", GUID(), nullable=True),
sa.Column("amount", sa.Numeric(18, 4), nullable=False, server_default="0"),
sa.Column("status", sa.String(30), nullable=False, server_default="draft"),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("meta_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "module", "number", name="uq_biz_doc_tenant_module_number"),
)
op.create_index(
"ix_biz_doc_tenant_module_type",
"business_documents",
["tenant_id", "module", "doc_type"],
)
op.create_table(
"inventory_items",
sa.Column("id", GUID(), primary_key=True),
sa.Column("tenant_id", GUID(), nullable=False),
sa.Column("code", sa.String(50), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("item_type", sa.String(30), nullable=False, server_default="goods"),
sa.Column("unit", sa.String(30), nullable=False, server_default="عدد"),
sa.Column("quantity_on_hand", sa.Numeric(18, 4), nullable=False, server_default="0"),
sa.Column("unit_cost", sa.Numeric(18, 4), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "code", name="uq_inventory_item_tenant_code"),
)
op.create_table(
"warehouses",
sa.Column("id", GUID(), primary_key=True),
sa.Column("tenant_id", GUID(), nullable=False),
sa.Column("code", sa.String(50), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "code", name="uq_warehouse_tenant_code"),
)
def downgrade() -> None:
op.drop_table("warehouses")
op.drop_table("inventory_items")
op.drop_index("ix_biz_doc_tenant_module_type", table_name="business_documents")
op.drop_table("business_documents")

View File

@ -0,0 +1 @@
__version__ = "0.5.11.0"

View 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=500),
) -> 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

View File

@ -0,0 +1,35 @@
from fastapi import APIRouter
from app.api.v1 import (
accounts,
compliance,
fiscal,
fixed_assets,
health,
ledger,
operational,
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"])
api_router.include_router(operational.router, prefix="/ops", tags=["ops"])

View 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

View File

@ -0,0 +1,445 @@
"""Phase 5.11 — Compliance, Audit & Governance API."""
from __future__ import annotations
from datetime import datetime, timezone
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 ApprovalRequest, ApprovalWorkflow, CompliancePolicy, ControlDefinition, GovernanceRule, PolicyViolation
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.get("/policies")
async def list_compliance_policies(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await CompliancePolicyRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(p.id),
"code": p.code,
"name": p.name,
"policy_type": p.policy_type,
"is_active": getattr(p, "is_active", True),
}
for p in rows
]
@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.get("/risks")
async def list_risks(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.models.compliance import RiskRecord
from sqlalchemy import select
stmt = (
select(RiskRecord)
.where(RiskRecord.tenant_id == tenant_id)
.order_by(RiskRecord.created_at.desc())
.limit(200)
)
rows = list((await db.execute(stmt)).scalars().all())
return [
{
"id": str(r.id),
"code": r.code,
"title": r.title,
"risk_category": r.risk_category,
"risk_level": r.risk_level.value,
"risk_score": r.risk_score,
"is_resolved": r.is_resolved,
}
for r in rows
]
@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}
class WorkflowCreate(BaseModel):
name: str
resource_type: str
min_amount: str | None = None
max_amount: str | None = None
class GovernanceRuleCreate(BaseModel):
code: str
name: str
rule_type: str = "sod"
condition_config: str
action_config: str | None = None
priority: int = 0
class ControlCreate(BaseModel):
code: str
name: str
control_type: str
description: str | None = None
validation_config: str | None = None
class ViolationCreate(BaseModel):
policy_id: UUID
resource_type: str
resource_id: str
severity: PolicyViolationSeverity
description: str
class WorkflowRepo(TenantBaseRepository[ApprovalWorkflow]):
model = ApprovalWorkflow
class ApprovalRequestRepo(TenantBaseRepository[ApprovalRequest]):
model = ApprovalRequest
class GovernanceRuleRepo(TenantBaseRepository[GovernanceRule]):
model = GovernanceRule
class ControlRepo(TenantBaseRepository[ControlDefinition]):
model = ControlDefinition
class ViolationRepo(TenantBaseRepository[PolicyViolation]):
model = PolicyViolation
@router.get("/workflows")
async def list_workflows(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await WorkflowRepo(db).list_by_tenant(tenant_id, limit=100)
return [
{
"id": str(r.id),
"name": r.name,
"resource_type": r.resource_type,
"min_amount": r.min_amount,
"max_amount": r.max_amount,
"is_active": r.is_active,
}
for r in rows
]
@router.post("/workflows", status_code=status.HTTP_201_CREATED)
async def create_workflow(
body: WorkflowCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = ApprovalWorkflow(tenant_id=tenant_id, **body.model_dump())
await WorkflowRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/approvals")
async def list_approvals(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ApprovalRequestRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"workflow_id": str(r.workflow_id),
"resource_type": r.resource_type,
"resource_id": r.resource_id,
"requested_by": r.requested_by,
"status": r.status.value,
"current_step": r.current_step,
"rejection_reason": r.rejection_reason,
}
for r in rows
]
@router.get("/governance-rules")
async def list_governance_rules(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await GovernanceRuleRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"code": r.code,
"name": r.name,
"rule_type": r.rule_type,
"condition_config": r.condition_config,
"priority": r.priority,
"is_active": r.is_active,
}
for r in rows
]
@router.post("/governance-rules", status_code=status.HTTP_201_CREATED)
async def create_governance_rule(
body: GovernanceRuleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = GovernanceRule(tenant_id=tenant_id, **body.model_dump())
await GovernanceRuleRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/controls")
async def list_controls(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ControlRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"code": r.code,
"name": r.name,
"control_type": r.control_type,
"description": r.description,
"is_active": r.is_active,
}
for r in rows
]
@router.post("/controls", status_code=status.HTTP_201_CREATED)
async def create_control(
body: ControlCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = ControlDefinition(tenant_id=tenant_id, **body.model_dump())
await ControlRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/violations")
async def list_violations(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ViolationRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"policy_id": str(r.policy_id),
"resource_type": r.resource_type,
"resource_id": r.resource_id,
"severity": r.severity.value,
"description": r.description,
"detected_at": r.detected_at.isoformat(),
"is_resolved": r.is_resolved,
}
for r in rows
]
@router.post("/violations", status_code=status.HTTP_201_CREATED)
async def create_violation(
body: ViolationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = PolicyViolation(
tenant_id=tenant_id,
policy_id=body.policy_id,
resource_type=body.resource_type,
resource_id=body.resource_id,
severity=body.severity,
description=body.description,
detected_at=datetime.now(timezone.utc),
)
await ViolationRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}

View 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

View File

@ -0,0 +1,293 @@
"""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, AssetDisposal, AssetRevaluation, AssetTransfer
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)}
class AssetTransferCreate(BaseModel):
asset_id: UUID
transfer_date: date
from_location: str | None = None
to_location: str | None = None
class AssetDisposalCreate(BaseModel):
asset_id: UUID
disposal_date: date
disposal_type: str = "sale"
sale_amount: Decimal | None = None
class AssetRevaluationCreate(BaseModel):
asset_id: UUID
revaluation_date: date
old_book_value: Decimal
new_book_value: Decimal
class AssetTransferRepo(TenantBaseRepository[AssetTransfer]):
model = AssetTransfer
class AssetDisposalRepo(TenantBaseRepository[AssetDisposal]):
model = AssetDisposal
class AssetRevaluationRepo(TenantBaseRepository[AssetRevaluation]):
model = AssetRevaluation
@router.get("/transfers")
async def list_transfers(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await AssetTransferRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"asset_id": str(r.asset_id),
"transfer_date": r.transfer_date.isoformat(),
"from_location": r.from_location,
"to_location": r.to_location,
}
for r in rows
]
@router.post("/transfers", status_code=status.HTTP_201_CREATED)
async def create_transfer(
body: AssetTransferCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = AssetTransfer(tenant_id=tenant_id, **body.model_dump())
await AssetTransferRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/disposals")
async def list_disposals(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await AssetDisposalRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"asset_id": str(r.asset_id),
"disposal_date": r.disposal_date.isoformat(),
"disposal_type": r.disposal_type,
"sale_amount": str(r.sale_amount) if r.sale_amount is not None else None,
"gain_loss": str(r.gain_loss) if r.gain_loss is not None else None,
}
for r in rows
]
@router.post("/disposals", status_code=status.HTTP_201_CREATED)
async def create_disposal(
body: AssetDisposalCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = AssetDisposal(tenant_id=tenant_id, **body.model_dump())
await AssetDisposalRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/revaluations")
async def list_revaluations(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await AssetRevaluationRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"asset_id": str(r.asset_id),
"revaluation_date": r.revaluation_date.isoformat(),
"old_book_value": str(r.old_book_value),
"new_book_value": str(r.new_book_value),
"difference": str(r.difference),
}
for r in rows
]
@router.post("/revaluations", status_code=status.HTTP_201_CREATED)
async def create_revaluation(
body: AssetRevaluationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
diff = body.new_book_value - body.old_book_value
entity = AssetRevaluation(
tenant_id=tenant_id,
asset_id=body.asset_id,
revaluation_date=body.revaluation_date,
old_book_value=body.old_book_value,
new_book_value=body.new_book_value,
difference=diff,
)
await AssetRevaluationRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id), "difference": str(diff)}

View 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__}

View 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}

View File

@ -0,0 +1,364 @@
"""Operational documents + inventory masters API."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import select
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.operational import BusinessDocument, InventoryItem, Warehouse
from app.repositories.base import TenantBaseRepository
from shared.exceptions import NotFoundError
from shared.security import CurrentUser
router = APIRouter()
class BizDocCreate(BaseModel):
module: str = Field(max_length=40)
doc_type: str = Field(max_length=50)
number: str | None = Field(default=None, max_length=50)
doc_date: date
party_name: str | None = None
party_id: UUID | None = None
amount: Decimal = Decimal("0")
status: str = "draft"
description: str | None = None
meta_json: str | None = None
class BizDocUpdate(BaseModel):
party_name: str | None = None
party_id: UUID | None = None
amount: Decimal | None = None
status: str | None = None
description: str | None = None
doc_date: date | None = None
meta_json: str | None = None
class BizDocRead(BaseModel):
id: UUID
tenant_id: UUID
module: str
doc_type: str
number: str
doc_date: date
party_name: str | None
party_id: UUID | None = None
amount: Decimal
status: str
description: str | None
meta_json: str | None = None
model_config = {"from_attributes": True}
class ItemCreate(BaseModel):
code: str
name: str
item_type: str = "goods"
unit: str = "عدد"
quantity_on_hand: Decimal = Decimal("0")
unit_cost: Decimal = Decimal("0")
class ItemRead(BaseModel):
id: UUID
code: str
name: str
item_type: str
unit: str
quantity_on_hand: Decimal
unit_cost: Decimal
is_active: bool
model_config = {"from_attributes": True}
class WarehouseCreate(BaseModel):
code: str
name: str
class WarehouseRead(BaseModel):
id: UUID
code: str
name: str
is_active: bool
model_config = {"from_attributes": True}
class BizDocRepo(TenantBaseRepository[BusinessDocument]):
model = BusinessDocument
class ItemRepo(TenantBaseRepository[InventoryItem]):
model = InventoryItem
class WarehouseRepo(TenantBaseRepository[Warehouse]):
model = Warehouse
@router.get("/documents", response_model=list[BizDocRead])
async def list_documents(
module: str | None = Query(default=None),
doc_type: str | None = Query(default=None),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
stmt = select(BusinessDocument).where(BusinessDocument.tenant_id == tenant_id)
if module:
stmt = stmt.where(BusinessDocument.module == module)
if doc_type:
stmt = stmt.where(BusinessDocument.doc_type == doc_type)
stmt = stmt.order_by(BusinessDocument.doc_date.desc()).limit(200)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("/documents", response_model=BizDocRead, status_code=status.HTTP_201_CREATED)
async def create_document(
body: BizDocCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.services.document_number_service import DocumentNumberService, ops_document_type
data = body.model_dump()
svc = DocumentNumberService(db)
data["number"] = await svc.allocate_if_blank(
tenant_id, ops_document_type(body.module, body.doc_type), body.number
)
entity = BusinessDocument(tenant_id=tenant_id, **data)
await BizDocRepo(db).add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.patch("/documents/{doc_id}", response_model=BizDocRead)
async def update_document(
doc_id: UUID,
body: BizDocUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = BizDocRepo(db)
entity = await repo.get(tenant_id, doc_id)
if entity is None:
raise NotFoundError("سند یافت نشد", error_code="document_not_found")
for k, v in body.model_dump(exclude_unset=True).items():
setattr(entity, k, v)
await db.commit()
await db.refresh(entity)
return entity
@router.post("/documents/{doc_id}/confirm", response_model=BizDocRead)
async def confirm_document(
doc_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
"""Confirm ops document; when posting-policy auto_post is on, also post to GL."""
from decimal import Decimal
from app.services.posting_policy_service import PostingPolicyService
from app.services.purchase_inventory_service import PurchaseInventoryAccountingService
from app.services.sales_accounting_service import SalesAccountingService
from app.models.types import SalesDocumentType
from shared.exceptions import AppError
entity = await BizDocRepo(db).get(tenant_id, doc_id)
if entity is None:
raise NotFoundError("سند یافت نشد", error_code="document_not_found")
policy_svc = PostingPolicyService(db)
policy = await policy_svc.get_policy(tenant_id)
should_auto = policy_svc.is_auto_post_enabled(policy, entity.module, entity.doc_type)
voucher_info: dict | None = None
if should_auto:
amount = Decimal(str(entity.amount or 0))
if amount <= 0:
raise AppError(
"برای ثبت خودکار حسابداری مبلغ سند باید بزرگ‌تر از صفر باشد",
status_code=422,
error_code="auto_post_amount_required",
)
try:
if entity.module == "sales" and entity.doc_type in ("invoice", "return"):
doc_type = (
SalesDocumentType.RETURN
if entity.doc_type == "return"
else SalesDocumentType.INVOICE
)
voucher = await SalesAccountingService(db).post_sales_invoice(
tenant_id,
document_type=doc_type,
source_document_id=str(entity.id),
total_amount=amount,
actor_user_id=user.user_id,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type in (
"goods_receipt",
"goods-receipts",
):
voucher = await PurchaseInventoryAccountingService(db).post_goods_receipt(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type == "invoice":
voucher = await PurchaseInventoryAccountingService(db).post_purchase_invoice(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
party_id=entity.party_id,
party_name=entity.party_name,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type == "return":
import json as _json
meta: dict = {}
if entity.meta_json:
try:
meta = _json.loads(entity.meta_json)
except Exception:
meta = {}
lines = meta.get("lines") or []
lines_summary = None
if isinstance(lines, list) and lines:
bits = []
for ln in lines[:5]:
if not isinstance(ln, dict):
continue
title = str(ln.get("title") or "")
qty = str(ln.get("qty") or "")
if title:
bits.append(f"{title}×{qty}" if qty else title)
if bits:
lines_summary = "، ".join(bits)
voucher = await PurchaseInventoryAccountingService(db).post_purchase_return(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
source_invoice_id=str(meta.get("source_invoice_id") or "") or None,
source_invoice_number=str(meta.get("source_invoice_number") or "") or None,
party_id=entity.party_id,
party_name=entity.party_name or (str(meta.get("party_name") or "") or None),
lines_summary=lines_summary,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
else:
# Policy on but no engine mapping — leave as confirmed only
voucher_info = None
except AppError:
raise
except Exception as exc: # noqa: BLE001
raise AppError(
f"ثبت خودکار حسابداری ناموفق بود: {exc}",
status_code=422,
error_code="auto_post_failed",
) from exc
entity.status = "posted" if voucher_info else "confirmed"
if voucher_info and entity.meta_json:
import json
try:
meta = json.loads(entity.meta_json)
except Exception:
meta = {}
meta["accounting_voucher"] = voucher_info
entity.meta_json = json.dumps(meta, ensure_ascii=False)
elif voucher_info:
import json
entity.meta_json = json.dumps({"accounting_voucher": voucher_info}, ensure_ascii=False)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/items", response_model=list[ItemRead])
async def list_items(
q: str | None = Query(default=None, description="Search code/name"),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ItemRepo(db).list_by_tenant(tenant_id, limit=200)
if q:
needle = q.strip().lower()
rows = [r for r in rows if needle in (r.code or "").lower() or needle in (r.name or "").lower()]
return rows
@router.post("/items", response_model=ItemRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: ItemCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = InventoryItem(tenant_id=tenant_id, **body.model_dump())
await ItemRepo(db).add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/warehouses", response_model=list[WarehouseRead])
async def list_warehouses(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await WarehouseRepo(db).list_by_tenant(tenant_id, limit=100)
@router.post("/warehouses", response_model=WarehouseRead, status_code=status.HTTP_201_CREATED)
async def create_warehouse(
body: WarehouseCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = Warehouse(tenant_id=tenant_id, **body.model_dump())
await WarehouseRepo(db).add(entity)
await db.commit()
await db.refresh(entity)
return entity

View File

@ -0,0 +1,300 @@
"""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, EmploymentContract, PayrollPeriod, SalaryComponent
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)}
class ContractCreate(BaseModel):
employee_id: UUID
contract_type: str = "permanent"
start_date: date
end_date: date | None = None
base_salary: Decimal = Decimal("0")
class SalaryComponentCreate(BaseModel):
code: str
name: str
component_type: str = "earning"
is_taxable: bool = True
class ContractRepo(TenantBaseRepository[EmploymentContract]):
model = EmploymentContract
class SalaryComponentRepo(TenantBaseRepository[SalaryComponent]):
model = SalaryComponent
@router.get("/contracts")
async def list_contracts(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ContractRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"employee_id": str(r.employee_id),
"contract_type": r.contract_type,
"start_date": r.start_date.isoformat(),
"end_date": r.end_date.isoformat() if r.end_date else None,
"base_salary": str(r.base_salary),
"is_active": r.is_active,
}
for r in rows
]
@router.post("/contracts", status_code=status.HTTP_201_CREATED)
async def create_contract(
body: ContractCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = EmploymentContract(tenant_id=tenant_id, **body.model_dump())
await ContractRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/salary-components")
async def list_salary_components(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await SalaryComponentRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"code": r.code,
"name": r.name,
"component_type": r.component_type,
"is_taxable": r.is_taxable,
"is_active": r.is_active,
}
for r in rows
]
@router.post("/salary-components", status_code=status.HTTP_201_CREATED)
async def create_salary_component(
body: SalaryComponentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = SalaryComponent(tenant_id=tenant_id, **body.model_dump())
await SalaryComponentRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/payrolls")
async def list_payrolls(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.models.payroll import Payroll
from sqlalchemy import select
stmt = select(Payroll).where(Payroll.tenant_id == tenant_id).order_by(Payroll.created_at.desc()).limit(200)
rows = list((await db.execute(stmt)).scalars().all())
return [
{
"id": str(r.id),
"payroll_period_id": str(r.payroll_period_id),
"employee_id": str(r.employee_id),
"gross_salary": str(r.gross_salary),
"net_salary": str(r.net_salary),
"status": r.status.value,
"voucher_id": str(r.voucher_id) if r.voucher_id else None,
}
for r in rows
]

View File

@ -0,0 +1,287 @@
"""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()
def _assert_lines_balanced(lines: list) -> None:
from decimal import Decimal
if len(lines) < 2:
raise AppError(
"حداقل دو ردیف برای سند لازم است",
status_code=422,
error_code="voucher_lines_min",
)
total_debit = Decimal("0")
total_credit = Decimal("0")
for i, line in enumerate(lines, start=1):
data = line.model_dump() if hasattr(line, "model_dump") else dict(line)
debit = Decimal(str(data.get("debit") or 0))
credit = Decimal(str(data.get("credit") or 0))
if debit > 0 and credit > 0:
raise AppError(
f"ردیف {i}: نمی‌توان همزمان بدهکار و بستانکار داشت",
status_code=422,
error_code="voucher_line_both_sides",
)
if debit == 0 and credit == 0:
raise AppError(
f"ردیف {i}: یکی از بدهکار یا بستانکار باید مقدار داشته باشد",
status_code=422,
error_code="voucher_line_empty",
)
total_debit += debit
total_credit += credit
if total_debit != total_credit:
diff = abs(total_debit - total_credit)
raise AppError(
f"سند نامتوازن است — جمع بدهکار {total_debit} و بستانکار {total_credit} "
f"(اختلاف {diff}). ثبت مجاز نیست.",
status_code=422,
error_code="voucher_unbalanced",
)
@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),
status: str | None = None,
source: str | None = None,
):
"""List all vouchers including automatic/system-posted (sales/purchase/treasury/...)."""
from sqlalchemy import or_, select
from sqlalchemy.orm import selectinload
stmt = (
select(Voucher)
.options(selectinload(Voucher.lines))
.where(Voucher.tenant_id == tenant_id)
)
if status:
try:
stmt = stmt.where(Voucher.status == VoucherStatus(status))
except ValueError:
pass
if source == "manual":
stmt = stmt.where(
or_(Voucher.source_module.is_(None), Voucher.source_module.in_(["", "manual"]))
)
elif source == "auto":
stmt = stmt.where(
Voucher.source_module.is_not(None),
Voucher.source_module.notin_(["", "manual"]),
)
elif source:
stmt = stmt.where(Voucher.source_module == source)
stmt = (
stmt.order_by(Voucher.created_at.desc())
.offset(pagination.offset)
.limit(pagination.page_size)
)
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),
):
from app.services.document_number_service import DocumentNumberService
_assert_lines_balanced(body.lines)
repo = VoucherRepository(db)
number = await DocumentNumberService(db).allocate_if_blank(
tenant_id, "voucher", body.voucher_number
)
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=body.fiscal_period_id,
voucher_number=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)
if lines_data is not None:
_assert_lines_balanced(lines_data)
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}/reverse-and-edit", response_model=VoucherRead)
async def reverse_and_edit_voucher(
voucher_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
"""برگشت سند قطعی + ایجاد پیش‌نویس قابل ویرایش از همان محتوا."""
engine = PostingEngine(db)
draft = await engine.reverse_and_reopen_for_edit(
tenant_id, voucher_id, actor_user_id=user.user_id
)
await db.commit()
return await VoucherRepository(db).get_with_lines(tenant_id, draft.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)

View File

@ -0,0 +1,320 @@
"""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
expense_account_id: UUID | None = None
is_default: bool = False
class PurchaseProfileUpdate(BaseModel):
name: str | None = None
inventory_account_id: UUID | None = None
grni_account_id: UUID | None = None
liability_account_id: UUID | None = None
expense_account_id: UUID | None = None
is_default: bool | None = None
is_active: bool | 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)
data = body.model_dump()
if data.get("is_default"):
rows = await repo.list_by_tenant(tenant_id, limit=200)
for r in rows:
r.is_default = False
elif not await repo.list_by_tenant(tenant_id, limit=1):
data["is_default"] = True
entity = PurchasePostingProfile(tenant_id=tenant_id, **data)
await repo.add(entity)
await db.commit()
return {"id": str(entity.id), "name": entity.name}
@router.get("/posting-profiles")
async def list_purchase_profiles(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await PurchaseProfileRepo(db).list_by_tenant(tenant_id, limit=100)
return [
{
"id": str(r.id),
"name": r.name,
"is_default": r.is_default,
"is_active": r.is_active,
"inventory_account_id": str(r.inventory_account_id) if r.inventory_account_id else None,
"grni_account_id": str(r.grni_account_id) if r.grni_account_id else None,
"liability_account_id": str(r.liability_account_id) if r.liability_account_id else None,
"expense_account_id": str(r.expense_account_id) if r.expense_account_id else None,
}
for r in rows
]
@router.post("/posting-profiles/ensure-default")
async def ensure_default_purchase_profile(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
profile = await svc.ensure_default_purchase_profile(tenant_id)
await db.commit()
return {
"id": str(profile.id),
"name": profile.name,
"is_default": profile.is_default,
"inventory_account_id": str(profile.inventory_account_id) if profile.inventory_account_id else None,
"grni_account_id": str(profile.grni_account_id) if profile.grni_account_id else None,
"liability_account_id": str(profile.liability_account_id) if profile.liability_account_id else None,
"expense_account_id": str(profile.expense_account_id) if profile.expense_account_id else None,
}
@router.patch("/posting-profiles/{profile_id}")
async def update_purchase_profile(
profile_id: UUID,
body: PurchaseProfileUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from shared.exceptions import NotFoundError
repo = PurchaseProfileRepo(db)
entity = await repo.get(tenant_id, profile_id)
if entity is None:
raise NotFoundError("پروفایل خرید یافت نشد", error_code="profile_not_found")
data = body.model_dump(exclude_unset=True)
if data.get("is_default"):
rows = await repo.list_by_tenant(tenant_id, limit=200)
for r in rows:
if r.id != entity.id:
r.is_default = False
for k, v in data.items():
setattr(entity, k, v)
await db.commit()
return {"id": str(entity.id), "name": entity.name, "is_default": entity.is_default}
class GoodsReceiptRequest(BaseModel):
source_document_id: str
amount: Decimal
profile_id: UUID | None = None
credit_mode: str = "supplier" # supplier | grni
credit_account_id: UUID | None = None
warehouse_name: str | None = None
source_note: str | None = None
party_name: str | None = None
class ValuationRequest(BaseModel):
item_id: UUID
quantity: Decimal
unit_cost: Decimal
method: InventoryValuationMethod = InventoryValuationMethod.WEIGHTED_AVERAGE
warehouse_id: UUID | None = None
@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,
credit_mode=body.credit_mode,
credit_account_id=body.credit_account_id,
warehouse_name=body.warehouse_name,
source_note=body.source_note,
party_name=body.party_name,
)
await db.commit()
return {"voucher_id": str(voucher.id), "voucher_number": voucher.voucher_number, "status": voucher.status.value}
@router.post("/preview/purchase-invoice")
async def preview_purchase_invoice(
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_purchase_invoice(tenant_id, body.amount, body.profile_id)
await db.commit()
return {
"is_balanced": preview.is_balanced,
"total_debit": str(preview.total_debit),
"total_credit": str(preview.total_credit),
}
@router.post("/post/purchase-invoice")
async def post_purchase_invoice(
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_purchase_invoice(
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),
"voucher_number": voucher.voucher_number,
"status": voucher.status.value,
}
class PurchaseReturnRequest(BaseModel):
source_document_id: str
amount: Decimal
profile_id: UUID | None = None
source_invoice_id: str | None = None
source_invoice_number: str | None = None
party_id: UUID | None = None
party_name: str | None = None
lines_summary: str | None = None
@router.post("/post/purchase-return")
async def post_purchase_return(
body: PurchaseReturnRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
voucher = await svc.post_purchase_return(
tenant_id,
source_document_id=body.source_document_id,
amount=body.amount,
actor_user_id=user.user_id,
profile_id=body.profile_id,
source_invoice_id=body.source_invoice_id,
source_invoice_number=body.source_invoice_number,
party_id=body.party_id,
party_name=body.party_name,
lines_summary=body.lines_summary,
)
await db.commit()
return {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
"status": voucher.status.value,
}
class InventoryIssueRequest(BaseModel):
source_document_id: str
amount: Decimal
reason: str = "expense" # expense | charity | consumption | barter
debit_account_id: UUID
credit_account_id: UUID | None = None
profile_id: UUID | None = None
barter_debit_account_id: UUID | None = None
barter_amount: Decimal | None = None
@router.post("/post/inventory-issue")
async def post_inventory_issue(
body: InventoryIssueRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
voucher = await svc.post_inventory_issue(
tenant_id,
source_document_id=body.source_document_id,
amount=body.amount,
reason=body.reason,
debit_account_id=body.debit_account_id,
actor_user_id=user.user_id,
profile_id=body.profile_id,
credit_account_id=body.credit_account_id,
barter_debit_account_id=body.barter_debit_account_id,
barter_amount=body.barter_amount,
)
await db.commit()
return {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
"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)}

View File

@ -0,0 +1,332 @@
"""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 | None = None
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 | None = None
invoice_date: date
due_date: date | None = None
total_amount: Decimal
class SettlementCreate(BaseModel):
settlement_number: str | None = None
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),
):
from app.services.document_number_service import DocumentNumberService
repo = CustomerRepo(db)
code = await DocumentNumberService(db).allocate_if_blank(tenant_id, "customer", body.code)
entity = CustomerAccount(
tenant_id=tenant_id,
code=code,
name=body.name,
credit_limit=body.credit_limit,
)
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/customers", response_model=list[CustomerRead])
async def list_customers(
q: str | None = None,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CustomerRepo(db)
rows = await repo.list_by_tenant(tenant_id, limit=200)
if q:
needle = q.strip().lower()
rows = [r for r in rows if needle in (r.code or "").lower() or needle in (r.name or "").lower()]
return rows
@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(
q: str | None = None,
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=200)
if q:
needle = q.strip().lower()
suppliers = [
s for s in suppliers if needle in (s.code or "").lower() or needle in (s.name or "").lower()
]
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),
):
from app.services.document_number_service import DocumentNumberService
repo = SupplierRepo(db)
code = await DocumentNumberService(db).allocate_if_blank(tenant_id, "supplier", body.code)
entity = SupplierAccount(tenant_id=tenant_id, code=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),
):
from app.services.document_number_service import DocumentNumberService
repo = ReceivableRepo(db)
invoice_number = await DocumentNumberService(db).allocate_if_blank(
tenant_id, "ar_invoice", body.invoice_number
)
entity = ReceivableInvoice(
tenant_id=tenant_id,
customer_id=body.customer_id,
invoice_number=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), "invoice_number": entity.invoice_number}
@router.get("/settlements")
async def list_settlements(
party_type: str | None = None,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.models.receivable_payable import Settlement
from sqlalchemy import select
stmt = select(Settlement).where(Settlement.tenant_id == tenant_id)
if party_type:
stmt = stmt.where(Settlement.party_type == party_type)
stmt = stmt.order_by(Settlement.settlement_date.desc()).limit(200)
rows = list((await db.execute(stmt)).scalars().all())
return [
{
"id": str(s.id),
"settlement_number": s.settlement_number,
"settlement_date": s.settlement_date.isoformat(),
"settlement_type": s.settlement_type,
"party_type": s.party_type,
"party_id": str(s.party_id),
"total_amount": str(s.total_amount),
"status": s.status.value,
}
for s in rows
]
@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
from app.services.document_number_service import DocumentNumberService
settlement_number = await DocumentNumberService(db).allocate_if_blank(
tenant_id, "settlement", body.settlement_number
)
settlement = Settlement(
tenant_id=tenant_id,
settlement_number=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),
"settlement_number": settlement.settlement_number,
"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),
}

View File

@ -0,0 +1,210 @@
"""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("/subsidiary-ledger")
async def generate_subsidiary_ledger(
fiscal_period_id: UUID,
account_id: UUID | None = None,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
"""گزارش معین — مانده/گردش حساب‌های سطح معین از داده دفتر."""
engine = FinancialReportEngine(db)
report = await engine.generate_level_ledger_report(
tenant_id,
fiscal_period_id,
mode="subsidiary",
account_id=account_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("/detail-ledger")
async def generate_detail_ledger(
fiscal_period_id: UUID,
account_id: UUID | None = None,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
"""گزارش تفصیلی — مانده/گردش حساب‌های سطح تفصیلی از داده دفتر."""
engine = FinancialReportEngine(db)
report = await engine.generate_level_ledger_report(
tenant_id,
fiscal_period_id,
mode="detail",
account_id=account_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("/analytical")
async def generate_analytical(
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_analytical_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("/cash-flow")
async def generate_cash_flow(
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_cash_flow_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("/equity-statement")
async def generate_equity_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_equity_statement(
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("/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)}

View 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}

View File

@ -0,0 +1,192 @@
"""Setup progress + document number sequences API."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
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 app.services.document_number_service import DocumentNumberService
from shared.security import CurrentUser
router = APIRouter()
class AllocateRequest(BaseModel):
document_type: str = Field(min_length=1, max_length=50)
class SequencePreview(BaseModel):
document_type: str
prefix: str
next_number: int
padding: int
preview: str
@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,
)
@router.get("/sequences/next", response_model=SequencePreview)
async def peek_next_number(
document_type: str = Query(min_length=1, max_length=50),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
"""Preview next number without consuming it (for form autofill)."""
svc = DocumentNumberService(db)
data = await svc.peek(tenant_id, document_type)
await db.commit()
return SequencePreview(**data)
@router.post("/sequences/allocate")
async def allocate_number(
body: AllocateRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
"""Consume and return the next number for a document type."""
svc = DocumentNumberService(db)
number = await svc.allocate(tenant_id, body.document_type)
await db.commit()
return {"document_type": body.document_type, "number": number}
class PostingPolicyUpdate(BaseModel):
auto_post: dict[str, bool] | None = None
require_balanced: bool | None = None
require_source_reference: bool | None = None
lock_after_post: bool | None = None
sequential_numbers: bool | None = None
idempotent_source: bool | None = None
posting_mode: str | None = None
@router.get("/posting-policy")
async def get_posting_policy(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.services.posting_policy_service import PostingPolicyService
return await PostingPolicyService(db).get_policy(tenant_id)
@router.put("/posting-policy")
async def update_posting_policy(
body: PostingPolicyUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.services.posting_policy_service import PostingPolicyService
policy = await PostingPolicyService(db).update_policy(
tenant_id, body.model_dump(exclude_none=True)
)
await db.commit()
return policy
class CompanyProfileUpdate(BaseModel):
legal_name: str | None = None
trade_name: str | None = None
national_id: str | None = None
economic_code: str | None = None
registration_number: str | None = None
postal_code: str | None = None
province: str | None = None
city: str | None = None
address: str | None = None
phone: str | None = None
mobile: str | None = None
email: str | None = None
website: str | None = None
ceo_name: str | None = None
accountant_name: str | None = None
@router.get("/company-profile")
async def get_company_profile(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.services.company_profile_service import CompanyProfileService
return await CompanyProfileService(db).get_profile(tenant_id)
@router.put("/company-profile")
async def update_company_profile(
body: CompanyProfileUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.services.company_profile_service import CompanyProfileService
profile = await CompanyProfileService(db).update_profile(
tenant_id, body.model_dump(exclude_none=False)
)
await db.commit()
return profile

View File

@ -0,0 +1,716 @@
"""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)
# ── Cheques / transfers / receipts / payments / reconciliation ───────────────
from app.models.treasury import Cheque, PaymentVoucher, ReceiptVoucher, Reconciliation, Transfer
from app.models.types import ChequeStatus, ReconciliationStatus
class ChequeRepo(TenantBaseRepository[Cheque]):
model = Cheque
class TransferRepo(TenantBaseRepository[Transfer]):
model = Transfer
class ReceiptRepo(TenantBaseRepository[ReceiptVoucher]):
model = ReceiptVoucher
class PaymentRepo(TenantBaseRepository[PaymentVoucher]):
model = PaymentVoucher
class ReconciliationRepo(TenantBaseRepository[Reconciliation]):
model = Reconciliation
class ChequeCreate(BaseModel):
cheque_number: str
amount: Decimal
issue_date: date
due_date: date | None = None
is_incoming: bool = True
payee: str | None = None
bank_account_id: UUID | None = None
status: ChequeStatus = ChequeStatus.RECEIVED
class TransferCreate(BaseModel):
transfer_date: date
amount: Decimal
from_type: str
from_id: UUID
to_type: str
to_id: UUID
description: str | None = None
class ReceiptCreate(BaseModel):
receipt_number: str | None = None
receipt_date: date
amount: Decimal
payment_method: str = "cash"
cash_box_id: UUID | None = None
bank_account_id: UUID | None = None
counter_account_id: UUID | None = None
description: str | None = None
class PaymentCreate(BaseModel):
payment_number: str | None = None
payment_date: date
amount: Decimal
payment_method: str = "cash"
cash_box_id: UUID | None = None
bank_account_id: UUID | None = None
counter_account_id: UUID | None = None
description: str | None = None
class ReconciliationCreate(BaseModel):
bank_account_id: UUID
statement_date: date
statement_balance: Decimal
book_balance: Decimal
@router.get("/cheques")
async def list_cheques(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ChequeRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(c.id),
"cheque_number": c.cheque_number,
"amount": str(c.amount),
"issue_date": c.issue_date.isoformat(),
"due_date": c.due_date.isoformat() if c.due_date else None,
"status": c.status.value,
"is_incoming": c.is_incoming,
"payee": c.payee,
}
for c in rows
]
@router.post("/cheques", status_code=status.HTTP_201_CREATED)
async def create_cheque(
body: ChequeCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = Cheque(tenant_id=tenant_id, **body.model_dump())
await ChequeRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id), "cheque_number": entity.cheque_number}
@router.get("/transfers")
async def list_transfers(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await TransferRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(t.id),
"transfer_date": t.transfer_date.isoformat(),
"amount": str(t.amount),
"from_type": t.from_type,
"from_id": str(t.from_id),
"to_type": t.to_type,
"to_id": str(t.to_id),
"description": t.description,
"voucher_id": str(t.voucher_id) if t.voucher_id else None,
}
for t in rows
]
async def _resolve_transfer_gl_account(
db: AsyncSession,
tenant_id: UUID,
side_type: str,
side_id: UUID,
) -> UUID:
from shared.exceptions import AppError
if side_type == "account":
return side_id
if side_type == "cash":
box = await CashBoxRepo(db).get(tenant_id, side_id)
if box is None or box.account_id is None:
raise AppError(
"صندوق باید به حساب دفتر کل متصل باشد",
status_code=422,
error_code="cash_box_account_missing",
)
return box.account_id
if side_type == "bank":
bank = await BankAccountRepo(db).get(tenant_id, side_id)
if bank is None or bank.account_id is None:
raise AppError(
"حساب بانکی باید به حساب دفتر کل متصل باشد",
status_code=422,
error_code="bank_account_gl_missing",
)
return bank.account_id
raise AppError(
f"نوع مبدأ/مقصد نامعتبر است: {side_type} (account|cash|bank)",
status_code=422,
error_code="transfer_type_invalid",
)
@router.post("/transfers", status_code=status.HTTP_201_CREATED)
async def create_transfer(
body: TransferCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
"""Transfer between cash/bank/any GL account — auto-posts Dr to / Cr from."""
from app.services.posting_policy_service import PostingPolicyService
from app.services.source_posting_guard import SourcePostingGuard
entity = Transfer(tenant_id=tenant_id, **body.model_dump())
await TransferRepo(db).add(entity)
from_gl = await _resolve_transfer_gl_account(db, tenant_id, body.from_type, body.from_id)
to_gl = await _resolve_transfer_gl_account(db, tenant_id, body.to_type, body.to_id)
policy = await PostingPolicyService(db).get_policy(tenant_id)
voucher_id = None
voucher_number = None
# Transfers always need a GL document when accounts resolve — honor policy flag if present.
auto = bool((policy.get("auto_post") or {}).get("treasury_transfer", True))
if auto:
guard = SourcePostingGuard(db)
await guard.assert_can_post(
tenant_id,
source_module="treasury",
source_document_type="transfer",
source_document_id=str(entity.id),
)
svc = TreasuryService(db)
voucher = await svc.post_gl_transfer(
tenant_id,
amount=body.amount,
from_account_id=from_gl,
to_account_id=to_gl,
actor_user_id=user.user_id,
description=body.description or "انتقال بین حساب‌ها",
transaction_date=body.transfer_date,
reference=str(entity.id),
)
entity.voucher_id = voucher.id
voucher_id = str(voucher.id)
voucher_number = voucher.voucher_number
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="treasury",
source_document_type="transfer",
source_document_id=str(entity.id),
)
# Update cash balances when sides are cash boxes
if body.from_type == "cash":
box = await CashBoxRepo(db).get(tenant_id, body.from_id)
if box is not None:
box.current_balance -= body.amount
if body.to_type == "cash":
box = await CashBoxRepo(db).get(tenant_id, body.to_id)
if box is not None:
box.current_balance += body.amount
await db.commit()
return {
"id": str(entity.id),
"voucher_id": voucher_id,
"voucher_number": voucher_number,
}
@router.get("/receipts")
async def list_receipts(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ReceiptRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"receipt_number": r.receipt_number,
"receipt_date": r.receipt_date.isoformat(),
"amount": str(r.amount),
"payment_method": r.payment_method,
"description": r.description,
"voucher_id": str(r.voucher_id) if r.voucher_id else None,
}
for r in rows
]
@router.post("/receipts", status_code=status.HTTP_201_CREATED)
async def create_receipt(
body: ReceiptCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
from app.services.document_number_service import DocumentNumberService
from app.services.posting_policy_service import PostingPolicyService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
data = body.model_dump()
counter_account_id = data.pop("counter_account_id", None)
data["receipt_number"] = await DocumentNumberService(db).allocate_if_blank(
tenant_id, "receipt", body.receipt_number
)
entity = ReceiptVoucher(tenant_id=tenant_id, **data)
await ReceiptRepo(db).add(entity)
policy = await PostingPolicyService(db).get_policy(tenant_id)
voucher_id = None
voucher_number = None
if policy.get("auto_post", {}).get("treasury_receipt", True):
if counter_account_id is None:
raise AppError(
"برای ثبت خودکار سند خزانه، حساب مقابل (طرف بستانکار) الزامی است",
status_code=422,
error_code="counter_account_required",
)
treasury_account_id = await _resolve_treasury_gl_account(
db, tenant_id, body.payment_method, body.cash_box_id, body.bank_account_id
)
guard = SourcePostingGuard(db)
await guard.assert_can_post(
tenant_id,
source_module="treasury",
source_document_type="receipt",
source_document_id=str(entity.id),
)
svc = TreasuryService(db)
voucher = await svc.post_receipt_payment_voucher(
tenant_id,
amount=body.amount,
treasury_account_id=treasury_account_id,
counter_account_id=counter_account_id,
is_receipt=True,
actor_user_id=user.user_id,
description=body.description or f"دریافت {entity.receipt_number}",
transaction_date=body.receipt_date,
reference=entity.receipt_number,
cash_box_id=body.cash_box_id,
)
entity.voucher_id = voucher.id
voucher_id = str(voucher.id)
voucher_number = voucher.voucher_number
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="treasury",
source_document_type="receipt",
source_document_id=str(entity.id),
)
await db.commit()
return {
"id": str(entity.id),
"receipt_number": entity.receipt_number,
"voucher_id": voucher_id,
"voucher_number": voucher_number,
}
@router.get("/payments")
async def list_payments(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await PaymentRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(p.id),
"payment_number": p.payment_number,
"payment_date": p.payment_date.isoformat(),
"amount": str(p.amount),
"payment_method": p.payment_method,
"description": p.description,
"voucher_id": str(p.voucher_id) if p.voucher_id else None,
}
for p in rows
]
@router.post("/payments", status_code=status.HTTP_201_CREATED)
async def create_payment(
body: PaymentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
from app.services.document_number_service import DocumentNumberService
from app.services.posting_policy_service import PostingPolicyService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
data = body.model_dump()
counter_account_id = data.pop("counter_account_id", None)
data["payment_number"] = await DocumentNumberService(db).allocate_if_blank(
tenant_id, "payment", body.payment_number
)
entity = PaymentVoucher(tenant_id=tenant_id, **data)
await PaymentRepo(db).add(entity)
policy = await PostingPolicyService(db).get_policy(tenant_id)
voucher_id = None
voucher_number = None
if policy.get("auto_post", {}).get("treasury_payment", True):
if counter_account_id is None:
raise AppError(
"برای ثبت خودکار سند خزانه، حساب مقابل (طرف بدهکار) الزامی است",
status_code=422,
error_code="counter_account_required",
)
treasury_account_id = await _resolve_treasury_gl_account(
db, tenant_id, body.payment_method, body.cash_box_id, body.bank_account_id
)
guard = SourcePostingGuard(db)
await guard.assert_can_post(
tenant_id,
source_module="treasury",
source_document_type="payment",
source_document_id=str(entity.id),
)
svc = TreasuryService(db)
voucher = await svc.post_receipt_payment_voucher(
tenant_id,
amount=body.amount,
treasury_account_id=treasury_account_id,
counter_account_id=counter_account_id,
is_receipt=False,
actor_user_id=user.user_id,
description=body.description or f"پرداخت {entity.payment_number}",
transaction_date=body.payment_date,
reference=entity.payment_number,
cash_box_id=body.cash_box_id,
)
entity.voucher_id = voucher.id
voucher_id = str(voucher.id)
voucher_number = voucher.voucher_number
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="treasury",
source_document_type="payment",
source_document_id=str(entity.id),
)
await db.commit()
return {
"id": str(entity.id),
"payment_number": entity.payment_number,
"voucher_id": voucher_id,
"voucher_number": voucher_number,
}
async def _resolve_treasury_gl_account(
db: AsyncSession,
tenant_id: UUID,
payment_method: str,
cash_box_id: UUID | None,
bank_account_id: UUID | None,
) -> UUID:
from shared.exceptions import AppError
if payment_method == "cash" or cash_box_id:
if cash_box_id is None:
raise AppError(
"برای دریافت/پرداخت نقدی انتخاب صندوق الزامی است",
status_code=422,
error_code="cash_box_required",
)
box = await CashBoxRepo(db).get(tenant_id, cash_box_id)
if box is None or box.account_id is None:
raise AppError(
"صندوق باید به حساب دفتر کل متصل باشد",
status_code=422,
error_code="cash_box_account_missing",
)
return box.account_id
if bank_account_id is None:
raise AppError(
"برای حواله بانکی انتخاب حساب بانکی الزامی است",
status_code=422,
error_code="bank_account_required",
)
bank = await BankAccountRepo(db).get(tenant_id, bank_account_id)
if bank is None or bank.account_id is None:
raise AppError(
"حساب بانکی باید به حساب دفتر کل متصل باشد",
status_code=422,
error_code="bank_account_gl_missing",
)
return bank.account_id
@router.get("/reconciliations")
async def list_reconciliations(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ReconciliationRepo(db).list_by_tenant(tenant_id, limit=100)
return [
{
"id": str(r.id),
"bank_account_id": str(r.bank_account_id),
"statement_date": r.statement_date.isoformat(),
"statement_balance": str(r.statement_balance),
"book_balance": str(r.book_balance),
"difference": str(r.difference),
"status": r.status.value,
}
for r in rows
]
@router.post("/reconciliations", status_code=status.HTTP_201_CREATED)
async def create_reconciliation(
body: ReconciliationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
diff = body.statement_balance - body.book_balance
entity = Reconciliation(
tenant_id=tenant_id,
bank_account_id=body.bank_account_id,
statement_date=body.statement_date,
statement_balance=body.statement_balance,
book_balance=body.book_balance,
difference=diff,
status=ReconciliationStatus.PENDING,
)
await ReconciliationRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id), "difference": str(diff)}

View 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()

View 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

View 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)

View 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)

View 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"

View 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()

View 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)

View File

@ -0,0 +1,147 @@
"""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,
)
from app.models.operational import ( # noqa: F401
BusinessDocument,
InventoryItem,
Warehouse,
)

View 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)

View 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"),)

View 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"),)

View 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"),
)

View 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"),
)

View File

@ -0,0 +1,63 @@
"""Phase ops — lightweight business documents for Sales/Purchase/Inventory UI."""
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import Boolean, Date, Index, 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 BusinessDocument(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
"""Generic operational document used by Sales / Purchase / Inventory screens."""
__tablename__ = "business_documents"
module: Mapped[str] = mapped_column(String(40), nullable=False)
doc_type: Mapped[str] = mapped_column(String(50), nullable=False)
number: Mapped[str] = mapped_column(String(50), nullable=False)
doc_date: Mapped[date] = mapped_column(Date, nullable=False)
party_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
party_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="draft", nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
meta_json: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "module", "number", name="uq_biz_doc_tenant_module_number"),
Index("ix_biz_doc_tenant_module_type", "tenant_id", "module", "doc_type"),
)
class InventoryItem(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "inventory_items"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
item_type: Mapped[str] = mapped_column(String(30), default="goods", nullable=False)
unit: Mapped[str] = mapped_column(String(30), default="عدد", nullable=False)
quantity_on_hand: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
unit_cost: 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_inventory_item_tenant_code"),
)
class Warehouse(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "warehouses"
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_warehouse_tenant_code"),
)

View 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)

View 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"),
)

View 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"),
)

View 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"),
)

View 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"),)

View 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)

View 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"),
)

View 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"

View 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,
]

View 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())

View 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()

View 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

View 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

View 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

View 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

View 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 | None = Field(default=None, 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

View 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)

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

View 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

View File

@ -0,0 +1,308 @@
"""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",
)
existing_acc = await account_repo.get_by_code(tenant_id, item.code)
if existing_acc is not None:
# Reuse existing account code so re-import / overlapping COA does not crash
code_to_id[item.code] = existing_acc.id
continue
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

View File

@ -0,0 +1,68 @@
"""Tenant company profile stored in accounting_settings (company.* keys)."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import AccountingSettings
COMPANY_FIELDS: dict[str, str] = {
"legal_name": "",
"trade_name": "",
"national_id": "",
"economic_code": "",
"registration_number": "",
"postal_code": "",
"province": "",
"city": "",
"address": "",
"phone": "",
"mobile": "",
"email": "",
"website": "",
"ceo_name": "",
"accountant_name": "",
}
class CompanyProfileService:
PREFIX = "company."
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_profile(self, tenant_id: UUID) -> dict[str, str]:
stmt = select(AccountingSettings).where(
AccountingSettings.tenant_id == tenant_id,
AccountingSettings.key.like(f"{self.PREFIX}%"),
)
rows = list((await self.session.execute(stmt)).scalars().all())
stored = {r.key[len(self.PREFIX) :]: (r.value or "") for r in rows}
return {**COMPANY_FIELDS, **{k: stored.get(k, v) for k, v in COMPANY_FIELDS.items()}}
async def update_profile(self, tenant_id: UUID, body: dict) -> dict[str, str]:
for field in COMPANY_FIELDS:
if field not in body:
continue
value = "" if body[field] is None else str(body[field]).strip()
full_key = f"{self.PREFIX}{field}"
stmt = select(AccountingSettings).where(
AccountingSettings.tenant_id == tenant_id,
AccountingSettings.key == full_key,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
if row is None:
self.session.add(
AccountingSettings(
tenant_id=tenant_id,
key=full_key,
value=value,
value_type="string",
)
)
else:
row.value = value
await self.session.flush()
return await self.get_profile(tenant_id)

View 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)

View File

@ -0,0 +1,191 @@
"""Document number allocation — Phase 5.1 DocumentNumberSequence."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import DocumentNumberSequence
from app.models.operational import BusinessDocument
from app.models.posting import Voucher
from app.models.receivable_payable import CustomerAccount, ReceivableInvoice, Settlement, SupplierAccount
from app.models.treasury import PaymentVoucher, ReceiptVoucher
# document_type → (prefix, padding)
_DEFAULTS: dict[str, tuple[str, int]] = {
"voucher": ("V-", 6),
"customer": ("C-", 5),
"supplier": ("S-", 5),
"ar_invoice": ("ARI-", 6),
"settlement": ("STL-", 6),
"receipt": ("RCV-", 6),
"payment": ("PAY-", 6),
"inventory_item": ("ITM-", 5),
"warehouse": ("WH-", 4),
}
_OPS_PREFIX: dict[tuple[str, str], str] = {
("sales", "proforma"): "PF-",
("sales", "invoice"): "SI-",
("sales", "order"): "SO-",
("sales", "return"): "SR-",
("sales", "pre_receipt"): "PR-",
("sales", "opportunity"): "OPP-",
("purchase", "request"): "PRQ-",
("purchase", "order"): "PO-",
("purchase", "invoice"): "PI-",
("purchase", "goods_receipt"): "GR-",
("purchase", "return"): "PRT-",
("purchase", "prepayment"): "PP-",
("inventory", "transfer"): "TR-",
("inventory", "adjustment"): "ADJ-",
("inventory", "receipt"): "INR-",
("inventory", "issue"): "INI-",
("treasury", "receipt"): "RCV-",
("treasury", "payment"): "PAY-",
}
def ops_document_type(module: str, doc_type: str) -> str:
key = f"ops.{module}.{doc_type}"
return key[:50]
def ops_prefix(module: str, doc_type: str) -> str:
if (module, doc_type) in _OPS_PREFIX:
return _OPS_PREFIX[(module, doc_type)]
slug = "".join(ch for ch in doc_type.upper() if ch.isalnum())[:4] or "DOC"
return f"{slug}-"
class DocumentNumberService:
"""Allocate sequential human-facing numbers per tenant + document_type."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def peek(self, tenant_id: UUID, document_type: str) -> dict:
seq = await self._get_or_create(tenant_id, document_type, for_update=False)
return {
"document_type": seq.document_type,
"prefix": seq.prefix or "",
"next_number": seq.next_number,
"padding": seq.padding,
"preview": self._format(seq),
}
async def allocate(self, tenant_id: UUID, document_type: str) -> str:
seq = await self._get_or_create(tenant_id, document_type, for_update=True)
for _ in range(1000):
candidate = self._format(seq)
seq.next_number += 1
if not await self._exists(tenant_id, document_type, candidate):
await self.session.flush()
return candidate
raise RuntimeError(f"Could not allocate number for {document_type}")
async def allocate_if_blank(
self, tenant_id: UUID, document_type: str, provided: str | None
) -> str:
value = (provided or "").strip()
if value:
return value
return await self.allocate(tenant_id, document_type)
def _defaults_for(self, document_type: str) -> tuple[str, int]:
if document_type in _DEFAULTS:
return _DEFAULTS[document_type]
if document_type.startswith("ops."):
parts = document_type.split(".", 2)
if len(parts) == 3:
return ops_prefix(parts[1], parts[2]), 6
return ("DOC-", 6)
async def _get_or_create(
self, tenant_id: UUID, document_type: str, *, for_update: bool
) -> DocumentNumberSequence:
stmt = select(DocumentNumberSequence).where(
DocumentNumberSequence.tenant_id == tenant_id,
DocumentNumberSequence.document_type == document_type,
)
if for_update:
stmt = stmt.with_for_update()
result = await self.session.execute(stmt)
seq = result.scalar_one_or_none()
if seq is not None:
return seq
prefix, padding = self._defaults_for(document_type)
seq = DocumentNumberSequence(
tenant_id=tenant_id,
document_type=document_type,
prefix=prefix,
next_number=1,
padding=padding,
)
self.session.add(seq)
await self.session.flush()
if for_update:
# Re-select locked row after insert (concurrent creators)
stmt2 = (
select(DocumentNumberSequence)
.where(
DocumentNumberSequence.tenant_id == tenant_id,
DocumentNumberSequence.document_type == document_type,
)
.with_for_update()
)
result2 = await self.session.execute(stmt2)
locked = result2.scalar_one()
return locked
return seq
@staticmethod
def _format(seq: DocumentNumberSequence) -> str:
prefix = seq.prefix or ""
return f"{prefix}{str(seq.next_number).zfill(seq.padding)}"
async def _exists(self, tenant_id: UUID, document_type: str, number: str) -> bool:
if document_type == "voucher":
stmt = select(Voucher.id).where(
Voucher.tenant_id == tenant_id, Voucher.voucher_number == number
)
elif document_type == "customer":
stmt = select(CustomerAccount.id).where(
CustomerAccount.tenant_id == tenant_id, CustomerAccount.code == number
)
elif document_type == "supplier":
stmt = select(SupplierAccount.id).where(
SupplierAccount.tenant_id == tenant_id, SupplierAccount.code == number
)
elif document_type == "ar_invoice":
stmt = select(ReceivableInvoice.id).where(
ReceivableInvoice.tenant_id == tenant_id,
ReceivableInvoice.invoice_number == number,
)
elif document_type == "settlement":
stmt = select(Settlement.id).where(
Settlement.tenant_id == tenant_id, Settlement.settlement_number == number
)
elif document_type == "receipt":
stmt = select(ReceiptVoucher.id).where(
ReceiptVoucher.tenant_id == tenant_id, ReceiptVoucher.receipt_number == number
)
elif document_type == "payment":
stmt = select(PaymentVoucher.id).where(
PaymentVoucher.tenant_id == tenant_id, PaymentVoucher.payment_number == number
)
elif document_type.startswith("ops."):
parts = document_type.split(".", 2)
module = parts[1] if len(parts) >= 2 else ""
stmt = select(BusinessDocument.id).where(
BusinessDocument.tenant_id == tenant_id,
BusinessDocument.module == module,
BusinessDocument.number == number,
)
else:
return False
result = await self.session.execute(stmt.limit(1))
return result.scalar_one_or_none() is not None

View 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

View 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

View File

@ -0,0 +1,327 @@
"""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 reverse_and_reopen_for_edit(
self,
tenant_id: UUID,
voucher_id: UUID,
*,
actor_user_id: str,
) -> Voucher:
"""Reverse a posted voucher and open a new draft copy for correction."""
from app.services.document_number_service import DocumentNumberService
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("فقط اسناد ثبت‌شده قابل برگشت و ویرایش هستند")
await self.reverse_voucher(tenant_id, voucher_id, actor_user_id=actor_user_id)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
draft = Voucher(
tenant_id=tenant_id,
fiscal_period_id=original.fiscal_period_id,
voucher_number=number,
voucher_date=original.voucher_date,
status=VoucherStatus.DRAFT,
description=original.description or f"اصلاح سند {original.voucher_number}",
reference_number=original.voucher_number,
source_module=original.source_module,
created_by=actor_user_id,
)
await self.voucher_repo.add(draft)
for i, line in enumerate(original.lines, start=1):
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=draft.id,
line_number=i,
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,
)
)
await self.session.flush()
await self._audit(
tenant_id=tenant_id,
operation="reopen_for_edit",
actor_user_id=actor_user_id,
voucher=draft,
)
return await self.voucher_repo.get_with_lines(tenant_id, draft.id)
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),
)
)

View File

@ -0,0 +1,157 @@
"""Tenant posting policy for automatic GL documents (intl. controls)."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import AccountingSettings
# Defaults follow conservative international practice:
# balanced vouchers, immutable posted docs, sequential numbers, source refs,
# idempotent re-post prevention. Auto-post modules default OFF until enabled.
DEFAULT_POLICY: dict[str, str] = {
"auto_post.sales.invoice": "false",
"auto_post.sales.return": "false",
"auto_post.purchase.goods_receipt": "false",
"auto_post.purchase.invoice": "false",
"auto_post.purchase.return": "true",
"auto_post.treasury.receipt": "true",
"auto_post.treasury.payment": "true",
"auto_post.treasury.transfer": "true",
"auto_post.inventory.issue": "true",
"auto_post.payroll": "false",
"auto_post.assets.depreciation": "false",
"policy.require_balanced": "true",
"policy.require_source_reference": "true",
"policy.lock_after_post": "true",
"policy.sequential_numbers": "true",
"policy.idempotent_source": "true",
"policy.posting_mode": "direct_post", # direct_post | draft_then_post
}
BOOL_TRUE = {"1", "true", "yes", "on"}
def _as_bool(value: str | None, default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in BOOL_TRUE
class PostingPolicyService:
PREFIX = "posting."
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_policy(self, tenant_id: UUID) -> dict:
stmt = select(AccountingSettings).where(
AccountingSettings.tenant_id == tenant_id,
AccountingSettings.key.like(f"{self.PREFIX}%"),
)
rows = list((await self.session.execute(stmt)).scalars().all())
stored = {r.key[len(self.PREFIX) :]: (r.value or "") for r in rows}
merged = {**DEFAULT_POLICY, **stored}
auto_post = {
"sales_invoice": _as_bool(merged.get("auto_post.sales.invoice")),
"sales_return": _as_bool(merged.get("auto_post.sales.return")),
"purchase_goods_receipt": _as_bool(merged.get("auto_post.purchase.goods_receipt")),
"purchase_invoice": _as_bool(merged.get("auto_post.purchase.invoice")),
"purchase_return": _as_bool(merged.get("auto_post.purchase.return")),
"treasury_receipt": _as_bool(merged.get("auto_post.treasury.receipt"), True),
"treasury_payment": _as_bool(merged.get("auto_post.treasury.payment"), True),
"treasury_transfer": _as_bool(merged.get("auto_post.treasury.transfer"), True),
"inventory_issue": _as_bool(merged.get("auto_post.inventory.issue"), True),
"payroll": _as_bool(merged.get("auto_post.payroll")),
"assets_depreciation": _as_bool(merged.get("auto_post.assets.depreciation")),
}
return {
"auto_post": auto_post,
"require_balanced": _as_bool(merged.get("policy.require_balanced"), True),
"require_source_reference": _as_bool(
merged.get("policy.require_source_reference"), True
),
"lock_after_post": _as_bool(merged.get("policy.lock_after_post"), True),
"sequential_numbers": _as_bool(merged.get("policy.sequential_numbers"), True),
"idempotent_source": _as_bool(merged.get("policy.idempotent_source"), True),
"posting_mode": merged.get("policy.posting_mode") or "direct_post",
"raw": merged,
}
async def update_policy(self, tenant_id: UUID, body: dict) -> dict:
updates: dict[str, str] = {}
auto = body.get("auto_post") or {}
mapping = {
"sales_invoice": "auto_post.sales.invoice",
"sales_return": "auto_post.sales.return",
"purchase_goods_receipt": "auto_post.purchase.goods_receipt",
"purchase_invoice": "auto_post.purchase.invoice",
"purchase_return": "auto_post.purchase.return",
"treasury_receipt": "auto_post.treasury.receipt",
"treasury_payment": "auto_post.treasury.payment",
"treasury_transfer": "auto_post.treasury.transfer",
"inventory_issue": "auto_post.inventory.issue",
"payroll": "auto_post.payroll",
"assets_depreciation": "auto_post.assets.depreciation",
}
for fe_key, store_key in mapping.items():
if fe_key in auto:
updates[store_key] = "true" if bool(auto[fe_key]) else "false"
for key in (
"require_balanced",
"require_source_reference",
"lock_after_post",
"sequential_numbers",
"idempotent_source",
):
if key in body:
updates[f"policy.{key}"] = "true" if bool(body[key]) else "false"
if "posting_mode" in body and body["posting_mode"] in ("direct_post", "draft_then_post"):
updates["policy.posting_mode"] = str(body["posting_mode"])
for short_key, value in updates.items():
full_key = f"{self.PREFIX}{short_key}"
stmt = select(AccountingSettings).where(
AccountingSettings.tenant_id == tenant_id,
AccountingSettings.key == full_key,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
if row is None:
row = AccountingSettings(
tenant_id=tenant_id,
key=full_key,
value=value,
value_type="boolean" if short_key.startswith("auto_post") or short_key.startswith("policy.") and short_key != "policy.posting_mode" else "string",
)
self.session.add(row)
else:
row.value = value
await self.session.flush()
return await self.get_policy(tenant_id)
def is_auto_post_enabled(self, policy: dict, module: str, doc_type: str) -> bool:
auto = policy.get("auto_post") or {}
key = f"{module}.{doc_type}".replace("-", "_")
aliases = {
"sales.invoice": "sales_invoice",
"sales.return": "sales_return",
"purchase.goods_receipt": "purchase_goods_receipt",
"purchase.invoice": "purchase_invoice",
"purchase.return": "purchase_return",
"treasury.receipt": "treasury_receipt",
"treasury.payment": "treasury_payment",
"treasury.transfer": "treasury_transfer",
"inventory.issue": "inventory_issue",
"payroll.payroll": "payroll",
"assets.depreciation": "assets_depreciation",
}
mapped = aliases.get(key) or aliases.get(f"{module}.{doc_type}")
if mapped:
return bool(auto.get(mapped))
# snake variants
snake = key.replace(".", "_")
return bool(auto.get(snake))

View File

@ -0,0 +1,866 @@
"""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 (
AccountCategory,
AccountType,
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,
credit_mode: str = "supplier",
credit_account_id: UUID | None = None,
warehouse_name: str | None = None,
source_note: str | None = None,
party_name: str | None = None,
) -> Voucher:
from datetime import date
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="purchase",
source_document_type=PurchaseDocumentType.GOODS_RECEIPT.value,
source_document_id=source_document_id,
)
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")
if profile.inventory_account_id is None:
raise AppError(
"حساب موجودی در پروفایل خرید مشخص نیست",
status_code=422,
error_code="inventory_account_missing",
)
mode = (credit_mode or "grni").strip().lower()
if credit_account_id is not None:
credit_id = credit_account_id
credit_label = "حساب انتخاب‌شده"
elif mode == "supplier":
credit_id = profile.liability_account_id
credit_label = "تأمین‌کننده / حساب پرداختنی"
if credit_id is None:
raise AppError(
"حساب پرداختنی (بدهی تأمین‌کننده) در پروفایل خرید مشخص نیست",
status_code=422,
error_code="liability_account_missing",
)
else:
credit_id = profile.grni_account_id or profile.liability_account_id
credit_label = "GRNI" if profile.grni_account_id else "تأمین‌کننده / حساب پرداختنی"
if credit_id is None:
raise AppError(
"حساب GRNI یا حساب پرداختنی در پروفایل خرید مشخص نیست",
status_code=422,
error_code="credit_account_missing",
)
parts = [f"رسید کالا {source_document_id}"]
if party_name:
parts.append(f"از: {party_name}")
if warehouse_name:
parts.append(f"انبار: {warehouse_name}")
parts.append(f"بستانکار: {credit_label}")
if source_note:
parts.append(source_note)
description = " — ".join(parts)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=description,
source_module="purchase",
reference_number=source_document_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=profile.inventory_account_id,
debit=amount,
credit=Decimal("0"),
description=f"ورود به انبار{f' — {warehouse_name}' if warehouse_name else ''}",
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=credit_id,
debit=Decimal("0"),
credit=amount,
description=f"بستانکار {credit_label}"
+ (f" — {party_name}" if party_name else ""),
)
)
await self.session.flush()
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="purchase",
source_document_type=PurchaseDocumentType.GOODS_RECEIPT.value,
source_document_id=source_document_id,
)
return voucher
async def preview_purchase_invoice(
self, tenant_id: UUID, amount: Decimal, profile_id: UUID | None = None
) -> AccountingPreview:
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
lines = []
# Clear GRNI / expense and credit AP liability
debit_account = profile.grni_account_id or profile.expense_account_id or profile.inventory_account_id
credit_account = profile.liability_account_id
if debit_account:
lines.append({"account_id": str(debit_account), "debit": str(amount), "credit": "0"})
if credit_account:
lines.append({"account_id": str(credit_account), "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.SUPPLIER_INVOICE.value,
source_document_id="preview",
preview_data=json.dumps(lines),
is_balanced=total_debit == total_credit and len(lines) >= 2,
total_debit=total_debit,
total_credit=total_credit,
)
self.session.add(preview)
await self.session.flush()
return preview
async def post_purchase_invoice(
self,
tenant_id: UUID,
*,
source_document_id: str,
amount: Decimal,
actor_user_id: str,
profile_id: UUID | None = None,
party_id: UUID | None = None,
party_name: str | None = None,
) -> Voucher:
from datetime import date
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="purchase",
source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value,
source_document_id=source_document_id,
)
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")
debit_account = profile.grni_account_id or profile.expense_account_id or profile.inventory_account_id
credit_account = profile.liability_account_id
if not debit_account or not credit_account:
raise AppError(
"پروفایل خرید ناقص است: حساب بدهکار (GRNI/هزینه) و بستانکار (بدهی) لازم است",
status_code=422,
error_code="purchase_profile_incomplete",
)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
desc = f"فاکتور خرید {source_document_id}"
if party_name:
desc = f"فاکتور خرید — تأمین‌کننده: {party_name}"
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=desc,
source_module="purchase",
reference_number=source_document_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=debit_account,
debit=amount,
credit=Decimal("0"),
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=credit_account,
debit=Decimal("0"),
credit=amount,
description=f"بدهی تأمین‌کننده" + (f" — {party_name}" if party_name else ""),
)
)
await self.session.flush()
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="purchase",
source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value,
source_document_id=source_document_id,
)
if party_id is not None:
await self._adjust_supplier_outstanding(tenant_id, party_id, amount)
return voucher
async def post_purchase_return(
self,
tenant_id: UUID,
*,
source_document_id: str,
amount: Decimal,
actor_user_id: str,
profile_id: UUID | None = None,
source_invoice_id: str | None = None,
source_invoice_number: str | None = None,
party_id: UUID | None = None,
party_name: str | None = None,
lines_summary: str | None = None,
) -> Voucher:
"""Purchase return: Dr AP liability (reduce debt) / Cr inventory.
Must be linked to a purchase invoice so party and items are traceable.
"""
from datetime import date
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
doc_type_value = PurchaseDocumentType.PURCHASE_RETURN.value
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="purchase",
source_document_type=doc_type_value,
source_document_id=source_document_id,
)
if amount <= 0:
raise AppError("مبلغ مرجوعی باید بزرگ‌تر از صفر باشد", status_code=422, error_code="return_amount")
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")
debit_account = profile.liability_account_id
credit_account = profile.inventory_account_id or profile.grni_account_id or profile.expense_account_id
if not debit_account or not credit_account:
raise AppError(
"پروفایل خرید ناقص است: حساب بدهی و موجودی/GRNI برای مرجوعی لازم است",
status_code=422,
error_code="purchase_profile_incomplete",
)
parts = ["مرجوعی خرید"]
if source_invoice_number:
parts.append(f"فاکتور {source_invoice_number}")
elif source_invoice_id:
parts.append(f"فاکتور {source_invoice_id}")
if party_name:
parts.append(f"تأمین‌کننده: {party_name}")
if lines_summary:
parts.append(lines_summary)
description = " — ".join(parts)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=description,
source_module="purchase",
reference_number=source_invoice_id or source_document_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=debit_account,
debit=amount,
credit=Decimal("0"),
description=f"کاهش بدهی تأمین‌کننده"
+ (f" — {party_name}" if party_name else ""),
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=credit_account,
debit=Decimal("0"),
credit=amount,
description="خروج موجودی بابت مرجوعی"
+ (f" — {lines_summary}" if lines_summary else ""),
)
)
await self.session.flush()
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="purchase",
source_document_type=doc_type_value,
source_document_id=source_document_id,
)
# Reduce supplier payable balance (our debt to them goes down)
if party_id is not None:
await self._adjust_supplier_outstanding(tenant_id, party_id, -amount)
return voucher
async def _adjust_supplier_outstanding(
self, tenant_id: UUID, supplier_id: UUID, delta: Decimal
) -> None:
from sqlalchemy import select
from app.models.receivable_payable import SupplierAccount
result = await self.session.execute(
select(SupplierAccount).where(
SupplierAccount.tenant_id == tenant_id,
SupplierAccount.id == supplier_id,
)
)
supplier = result.scalar_one_or_none()
if supplier is None:
return
supplier.outstanding_balance = (supplier.outstanding_balance or Decimal("0")) + delta
if supplier.outstanding_balance < 0:
supplier.outstanding_balance = Decimal("0")
async def post_inventory_issue(
self,
tenant_id: UUID,
*,
source_document_id: str,
amount: Decimal,
reason: str,
debit_account_id: UUID,
actor_user_id: str,
profile_id: UUID | None = None,
credit_account_id: UUID | None = None,
barter_debit_account_id: UUID | None = None,
barter_amount: Decimal | None = None,
):
"""Issue inventory to expense/charity/consumption/barter via Posting Engine.
Standard: Dr expense (or barter inventory) / Cr inventory.
Barter optional second pair when receiving another item of different value.
"""
from datetime import date
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="inventory",
source_document_type=f"issue_{reason}",
source_document_id=source_document_id,
)
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
inventory_credit = credit_account_id or profile.inventory_account_id
if inventory_credit is None:
raise AppError(
"حساب موجودی برای خروج کالا مشخص نیست (پروفایل خرید یا انتخاب دستی)",
status_code=422,
error_code="inventory_account_missing",
)
if amount <= 0:
raise AppError("مبلغ خروج باید بزرگ‌تر از صفر باشد", status_code=422, error_code="issue_amount")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
reason_fa = {
"expense": "هزینه",
"charity": "خیرات/اهدایی",
"consumption": "مصرف داخلی",
"barter": "تهاتر/معاوضه",
}.get(reason, reason)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"خروج موجودی — {reason_fa} — {source_document_id}",
source_module="inventory",
reference_number=source_document_id,
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
if reason == "barter" and barter_debit_account_id and barter_amount and barter_amount > 0:
# Give stock (Cr inventory), receive other stock/value (Dr), difference to expense/income account
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=1,
account_id=inventory_credit,
debit=Decimal("0"),
credit=amount,
description=f"خروج کالا — {reason_fa}",
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=barter_debit_account_id,
debit=barter_amount,
credit=Decimal("0"),
description="کالای/ارزش دریافتی تهاتر",
)
)
diff = amount - barter_amount
if diff > 0:
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=3,
account_id=debit_account_id,
debit=diff,
credit=Decimal("0"),
description="مابه‌التفاوت تهاتر (هزینه)",
)
)
elif diff < 0:
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=3,
account_id=debit_account_id,
debit=Decimal("0"),
credit=-diff,
description="مابه‌التفاوت تهاتر (درآمد/تخفیف)",
)
)
else:
# expense / charity / consumption: Dr target account, Cr inventory
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=reason_fa,
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=inventory_credit,
debit=Decimal("0"),
credit=amount,
description=reason_fa,
)
)
await self.session.flush()
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="inventory"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="inventory",
source_document_type=f"issue_{reason}",
source_document_id=source_document_id,
)
return voucher
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),
PurchasePostingProfile.is_active.is_(True),
)
result = await self.session.execute(stmt)
profile = result.scalar_one_or_none()
if profile is not None:
return profile
# Any active profile → promote as default
any_stmt = (
select(PurchasePostingProfile)
.where(
PurchasePostingProfile.tenant_id == tenant_id,
PurchasePostingProfile.is_active.is_(True),
)
.order_by(PurchasePostingProfile.created_at.asc())
.limit(1)
)
any_result = await self.session.execute(any_stmt)
existing = any_result.scalar_one_or_none()
if existing is not None:
existing.is_default = True
await self.session.flush()
return existing
return await self.ensure_default_purchase_profile(tenant_id)
async def ensure_default_purchase_profile(self, tenant_id: UUID) -> PurchasePostingProfile:
"""Create (or return) a default purchase posting profile with best-effort GL mapping."""
stmt = select(PurchasePostingProfile).where(
PurchasePostingProfile.tenant_id == tenant_id,
PurchasePostingProfile.is_default.is_(True),
)
result = await self.session.execute(stmt)
existing = result.scalar_one_or_none()
if existing is not None:
return existing
inventory_id = await self._find_account_id(
tenant_id,
name_needles=("موجودی کالا", "موجودی", "انبار", "کالا"),
account_types=(AccountType.ASSET,),
)
liability_id = await self._find_account_id(
tenant_id,
name_needles=("حساب‌های پرداختنی", "حسابهای پرداختنی", "پرداختنی", "بستانکاران"),
account_types=(AccountType.LIABILITY,),
)
grni_id = await self._find_account_id(
tenant_id,
name_needles=("GRNI", "کالای در راه", "رسید در راه", "خرید در راه"),
account_types=(AccountType.LIABILITY, AccountType.ASSET),
)
expense_id = await self._find_account_id(
tenant_id,
name_needles=("بهای تمام‌شده", "بهای تمام شده", "خرید کالا", "هزینه خرید"),
account_types=(AccountType.EXPENSE,),
)
# Create missing core accounts on default chart when possible
if inventory_id is None:
inventory_id = await self._create_system_account(
tenant_id,
code="1190",
name="موجودی کالا",
account_type=AccountType.ASSET,
account_category=AccountCategory.CURRENT_ASSET,
)
if liability_id is None:
liability_id = await self._create_system_account(
tenant_id,
code="2101",
name="حساب‌های پرداختنی",
account_type=AccountType.LIABILITY,
account_category=AccountCategory.CURRENT_LIABILITY,
)
if grni_id is None:
grni_id = await self._create_system_account(
tenant_id,
code="2105",
name="کالای در راه (GRNI)",
account_type=AccountType.LIABILITY,
account_category=AccountCategory.CURRENT_LIABILITY,
)
profile = PurchasePostingProfile(
tenant_id=tenant_id,
name="پروفایل پیش‌فرض خرید",
inventory_account_id=inventory_id,
grni_account_id=grni_id or liability_id,
expense_account_id=expense_id,
liability_account_id=liability_id,
is_default=True,
is_active=True,
)
await self.purchase_profile_repo.add(profile)
await self.session.flush()
return profile
async def _find_account_id(
self,
tenant_id: UUID,
*,
name_needles: tuple[str, ...],
account_types: tuple[AccountType, ...],
) -> UUID | None:
from app.models.foundation import Account
from app.models.types import AccountStatus
stmt = select(Account).where(
Account.tenant_id == tenant_id,
Account.is_postable.is_(True),
Account.is_group.is_(False),
Account.status == AccountStatus.ACTIVE,
Account.account_type.in_(account_types),
)
result = await self.session.execute(stmt)
rows = list(result.scalars().all())
lowered = [(a, (a.name or "").lower()) for a in rows]
for needle in name_needles:
n = needle.lower()
for acc, name in lowered:
if n in name:
return acc.id
return rows[0].id if rows else None
async def _create_system_account(
self,
tenant_id: UUID,
*,
code: str,
name: str,
account_type: AccountType,
account_category: AccountCategory,
) -> UUID | None:
from app.models.foundation import Account, ChartOfAccounts
from app.models.types import AccountStatus
chart_stmt = select(ChartOfAccounts).where(
ChartOfAccounts.tenant_id == tenant_id,
ChartOfAccounts.is_active.is_(True),
).order_by(ChartOfAccounts.is_default.desc()).limit(1)
chart = (await self.session.execute(chart_stmt)).scalar_one_or_none()
if chart is None:
return None
existing = (
await self.session.execute(
select(Account).where(Account.tenant_id == tenant_id, Account.code == code)
)
).scalar_one_or_none()
if existing is not None:
return existing.id
# Avoid unique name collisions by suffixing if needed
final_code = code
for i in range(0, 20):
candidate = code if i == 0 else f"{code}-{i}"
clash = (
await self.session.execute(
select(Account.id).where(Account.tenant_id == tenant_id, Account.code == candidate)
)
).scalar_one_or_none()
if clash is None:
final_code = candidate
break
acc = Account(
tenant_id=tenant_id,
chart_id=chart.id,
code=final_code,
name=name,
account_type=account_type,
account_category=account_category,
level=3,
is_group=False,
is_postable=True,
status=AccountStatus.ACTIVE,
description="ایجاد خودکار برای پروفایل خرید",
)
self.session.add(acc)
await self.session.flush()
return acc.id

View File

@ -0,0 +1,535 @@
"""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 import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import Account
from app.models.posting import JournalEntry
from app.models.reporting import FinancialReport, ReportExport
from app.models.types import (
AccountCategory,
AccountType,
JournalStatus,
ReportExportFormat,
ReportStatus,
ReportType,
)
from app.repositories.base import TenantBaseRepository
from app.repositories.ledger import GeneralLedgerRepository, LedgerBalanceRepository
from app.services.balance_engine import BalanceEngine
from shared.exceptions import NotFoundError
_ZERO = Decimal("0")
_CASH_TOKENS = ("نقد", "صندوق", "بانک", "bank", "cash", "treasury", "تنخواه")
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.gl_repo = GeneralLedgerRepository(session)
self.balance_engine = BalanceEngine(session)
self.export_repo = ReportExportRepo(session)
async def _list_accounts(self, tenant_id: UUID) -> list[Account]:
stmt = (
select(Account)
.where(Account.tenant_id == tenant_id)
.order_by(Account.code)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
@staticmethod
def _filter_accounts_by_mode(accounts: list[Account], mode: str) -> list[Account]:
"""Iranian COA: level 3 = معین, level >= 4 = تفصیلی; with safe fallbacks."""
if mode == "subsidiary":
hit = [a for a in accounts if a.level == 3]
if hit:
return hit
hit = [
a
for a in accounts
if a.is_postable and not a.is_group and a.level <= 3
]
if hit:
return hit
return [a for a in accounts if a.is_postable and not a.is_group]
if mode == "detail":
hit = [a for a in accounts if a.level >= 4]
if hit:
return hit
return [a for a in accounts if a.is_postable and not a.is_group]
return [a for a in accounts if a.is_postable and not a.is_group]
@staticmethod
def _empty_balance_row(account: Account, fiscal_period_id: UUID) -> dict:
return {
"account_id": str(account.id),
"account_code": account.code,
"account_name": account.name,
"account_type": account.account_type.value
if hasattr(account.account_type, "value")
else str(account.account_type),
"account_category": account.account_category.value
if hasattr(account.account_category, "value")
else str(account.account_category),
"level": account.level,
"fiscal_period_id": str(fiscal_period_id),
"opening_balance": "0",
"debit_total": "0",
"credit_total": "0",
"closing_balance": "0",
"current_balance": "0",
}
async def _balance_rows_for_accounts(
self,
tenant_id: UUID,
fiscal_period_id: UUID,
accounts: list[Account],
*,
include_zero: bool = True,
) -> list[dict]:
balances = await self.balance_repo.list_by_period(tenant_id, fiscal_period_id)
by_account = {b.account_id: b for b in balances}
rows: list[dict] = []
for account in accounts:
bal = by_account.get(account.id)
if bal is None:
if not include_zero:
continue
rows.append(self._empty_balance_row(account, fiscal_period_id))
continue
if (
not include_zero
and bal.opening_balance == _ZERO
and bal.debit_total == _ZERO
and bal.credit_total == _ZERO
and bal.closing_balance == _ZERO
):
continue
rows.append(
{
"account_id": str(account.id),
"account_code": account.code,
"account_name": account.name,
"account_type": account.account_type.value
if hasattr(account.account_type, "value")
else str(account.account_type),
"account_category": account.account_category.value
if hasattr(account.account_category, "value")
else str(account.account_category),
"level": account.level,
"fiscal_period_id": str(fiscal_period_id),
"opening_balance": str(bal.opening_balance),
"debit_total": str(bal.debit_total),
"credit_total": str(bal.credit_total),
"closing_balance": str(bal.closing_balance),
"current_balance": str(bal.current_balance),
}
)
return rows
async def _statement_lines(
self, tenant_id: UUID, fiscal_period_id: UUID, account_id: UUID
) -> list[dict]:
gl_lines = await self.gl_repo.list_by_account_period(
tenant_id, account_id, fiscal_period_id
)
if gl_lines:
running = _ZERO
out: list[dict] = []
for line in gl_lines:
running = line.running_balance
out.append(
{
"id": str(line.id),
"entry_date": line.entry_date.isoformat(),
"voucher_id": str(line.voucher_id),
"journal_entry_id": str(line.journal_entry_id),
"description": line.description,
"debit": str(line.debit),
"credit": str(line.credit),
"running_balance": str(running),
}
)
return out
stmt = (
select(JournalEntry)
.where(
JournalEntry.tenant_id == tenant_id,
JournalEntry.fiscal_period_id == fiscal_period_id,
JournalEntry.account_id == account_id,
JournalEntry.status == JournalStatus.POSTED,
)
.order_by(JournalEntry.entry_date, JournalEntry.entry_number)
)
result = await self.session.execute(stmt)
entries = list(result.scalars().all())
running = _ZERO
out = []
for e in entries:
running = running + e.debit - e.credit
out.append(
{
"id": str(e.id),
"entry_date": e.entry_date.isoformat(),
"voucher_id": str(e.voucher_id),
"entry_number": e.entry_number,
"description": e.description,
"debit": str(e.debit),
"credit": str(e.credit),
"running_balance": str(running),
}
)
return out
@staticmethod
def _sum_rows(rows: list[dict]) -> dict:
opening = sum(Decimal(r["opening_balance"]) for r in rows)
debit = sum(Decimal(r["debit_total"]) for r in rows)
credit = sum(Decimal(r["credit_total"]) for r in rows)
closing = sum(Decimal(r["closing_balance"]) for r in rows)
return {
"opening_balance": str(opening),
"debit_total": str(debit),
"credit_total": str(credit),
"closing_balance": str(closing),
"row_count": len(rows),
}
async def _persist(
self,
*,
tenant_id: UUID,
report_type: ReportType,
name: str,
fiscal_period_id: UUID,
data: dict,
generated_by: str,
) -> FinancialReport:
report = FinancialReport(
tenant_id=tenant_id,
report_type=report_type,
name=name,
fiscal_period_id=fiscal_period_id,
generated_at=datetime.now(timezone.utc),
status=ReportStatus.GENERATED,
report_data=json.dumps(data, ensure_ascii=False),
generated_by=generated_by,
)
return await self.report_repo.add(report)
async def generate_level_ledger_report(
self,
tenant_id: UUID,
fiscal_period_id: UUID,
*,
mode: str,
account_id: UUID | None,
generated_by: str,
) -> FinancialReport:
accounts = await self._list_accounts(tenant_id)
account_map = {a.id: a for a in accounts}
selected = account_map.get(account_id) if account_id else None
if account_id and selected is None:
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
if selected is not None:
rows = await self._balance_rows_for_accounts(
tenant_id, fiscal_period_id, [selected], include_zero=True
)
lines = await self._statement_lines(tenant_id, fiscal_period_id, selected.id)
data = {
"mode": "statement",
"level_mode": mode,
"account": {
"id": str(selected.id),
"code": selected.code,
"name": selected.name,
"level": selected.level,
},
"rows": rows,
"lines": lines,
"totals": self._sum_rows(rows),
}
else:
filtered = self._filter_accounts_by_mode(accounts, mode)
rows = await self._balance_rows_for_accounts(
tenant_id, fiscal_period_id, filtered, include_zero=False
)
# If no movements yet, still show the chart accounts so UI is useful
if not rows and filtered:
rows = await self._balance_rows_for_accounts(
tenant_id, fiscal_period_id, filtered, include_zero=True
)
data = {
"mode": "summary",
"level_mode": mode,
"rows": rows,
"lines": [],
"totals": self._sum_rows(rows),
}
label = "معین" if mode == "subsidiary" else "تفصیلی"
return await self._persist(
tenant_id=tenant_id,
report_type=ReportType.GENERAL_LEDGER,
name=f"گزارش {label} {fiscal_period_id}",
fiscal_period_id=fiscal_period_id,
data=data,
generated_by=generated_by,
)
async def generate_analytical_report(
self,
tenant_id: UUID,
fiscal_period_id: UUID,
*,
generated_by: str,
) -> FinancialReport:
accounts = await self._list_accounts(tenant_id)
postable = [a for a in accounts if a.is_postable and not a.is_group]
rows = await self._balance_rows_for_accounts(
tenant_id, fiscal_period_id, postable, include_zero=False
)
by_type: dict[str, dict] = {}
by_category: dict[str, dict] = {}
for row in rows:
for bucket, key in (
(by_type, row["account_type"]),
(by_category, row["account_category"]),
):
agg = bucket.setdefault(
key,
{
"key": key,
"opening_balance": _ZERO,
"debit_total": _ZERO,
"credit_total": _ZERO,
"closing_balance": _ZERO,
"account_count": 0,
},
)
agg["opening_balance"] += Decimal(row["opening_balance"])
agg["debit_total"] += Decimal(row["debit_total"])
agg["credit_total"] += Decimal(row["credit_total"])
agg["closing_balance"] += Decimal(row["closing_balance"])
agg["account_count"] += 1
def _serialize(bucket: dict[str, dict]) -> list[dict]:
out = []
for item in bucket.values():
out.append(
{
"key": item["key"],
"opening_balance": str(item["opening_balance"]),
"debit_total": str(item["debit_total"]),
"credit_total": str(item["credit_total"]),
"closing_balance": str(item["closing_balance"]),
"account_count": item["account_count"],
}
)
return sorted(out, key=lambda x: x["key"])
data = {
"mode": "analytical",
"by_type": _serialize(by_type),
"by_category": _serialize(by_category),
"rows": rows,
"totals": self._sum_rows(rows),
}
return await self._persist(
tenant_id=tenant_id,
report_type=ReportType.CUSTOM,
name=f"گزارش تحلیلی {fiscal_period_id}",
fiscal_period_id=fiscal_period_id,
data=data,
generated_by=generated_by,
)
@staticmethod
def _is_cash_like(account: Account) -> bool:
blob = f"{account.code} {account.name}".lower()
return any(token in blob for token in _CASH_TOKENS)
async def generate_cash_flow_report(
self,
tenant_id: UUID,
fiscal_period_id: UUID,
*,
generated_by: str,
) -> FinancialReport:
accounts = await self._list_accounts(tenant_id)
cash_accounts = [
a
for a in accounts
if a.is_postable
and not a.is_group
and a.account_type == AccountType.ASSET
and self._is_cash_like(a)
]
if not cash_accounts:
cash_accounts = [
a
for a in accounts
if a.is_postable
and not a.is_group
and a.account_category == AccountCategory.CURRENT_ASSET
]
rows = await self._balance_rows_for_accounts(
tenant_id, fiscal_period_id, cash_accounts, include_zero=True
)
totals = self._sum_rows(rows)
data = {
"mode": "cash_flow",
"method": "cash_accounts_movement",
"rows": rows,
"totals": totals,
"net_cash_change": str(
Decimal(totals["closing_balance"]) - Decimal(totals["opening_balance"])
),
"inflows": totals["debit_total"],
"outflows": totals["credit_total"],
}
return await self._persist(
tenant_id=tenant_id,
report_type=ReportType.CASH_FLOW,
name=f"جریان وجوه نقد {fiscal_period_id}",
fiscal_period_id=fiscal_period_id,
data=data,
generated_by=generated_by,
)
async def generate_equity_statement(
self,
tenant_id: UUID,
fiscal_period_id: UUID,
*,
generated_by: str,
) -> FinancialReport:
accounts = await self._list_accounts(tenant_id)
equity_accounts = [
a
for a in accounts
if a.is_postable
and not a.is_group
and a.account_type == AccountType.EQUITY
]
rows = await self._balance_rows_for_accounts(
tenant_id, fiscal_period_id, equity_accounts, include_zero=True
)
data = {
"mode": "equity_statement",
"rows": rows,
"totals": self._sum_rows(rows),
}
return await self._persist(
tenant_id=tenant_id,
report_type=ReportType.EQUITY_STATEMENT,
name=f"تغییرات حقوق صاحبان سهام {fiscal_period_id}",
fiscal_period_id=fiscal_period_id,
data=data,
generated_by=generated_by,
)
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)

View File

@ -0,0 +1,182 @@
"""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
from app.services.source_posting_guard import SourcePostingGuard
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="sales",
source_document_type=document_type.value,
source_document_id=source_document_id,
)
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()
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="sales"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="sales",
source_document_type=document_type.value,
source_document_id=source_document_id,
)
return voucher
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

View 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

View File

@ -0,0 +1,70 @@
"""Idempotent source-document posting guard (intl. no double-post)."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.posting import PostingReference
from app.repositories.posting import PostingReferenceRepository
from app.services.posting_policy_service import PostingPolicyService
from shared.exceptions import AppError
class SourcePostingGuard:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.ref_repo = PostingReferenceRepository(session)
self.policy_svc = PostingPolicyService(session)
async def assert_can_post(
self,
tenant_id: UUID,
*,
source_module: str,
source_document_type: str,
source_document_id: str,
) -> None:
policy = await self.policy_svc.get_policy(tenant_id)
if not policy.get("idempotent_source", True):
return
existing = await self.ref_repo.get_by_source(
tenant_id, source_module, source_document_type, source_document_id
)
if existing is not None and existing.is_posted:
raise AppError(
"این سند مبدأ قبلاً در دفتر کل ثبت شده است (جلوگیری از ثبت تکراری).",
status_code=409,
error_code="source_already_posted",
)
async def mark_posted(
self,
tenant_id: UUID,
*,
voucher_id: UUID,
source_module: str,
source_document_type: str,
source_document_id: str,
) -> PostingReference:
existing = await self.ref_repo.get_by_source(
tenant_id, source_module, source_document_type, source_document_id
)
if existing is None:
ref = PostingReference(
tenant_id=tenant_id,
voucher_id=voucher_id,
source_module=source_module,
source_document_type=source_document_type,
source_document_id=source_document_id,
is_posted=True,
)
return await self.ref_repo.add(ref)
existing.voucher_id = voucher_id
existing.is_posted = True
await self.session.flush()
return existing
async def should_post_immediately(self, tenant_id: UUID) -> bool:
policy = await self.policy_svc.get_policy(tenant_id)
return (policy.get("posting_mode") or "direct_post") != "draft_then_post"

View File

@ -0,0 +1,282 @@
"""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 post_gl_transfer(
self,
tenant_id: UUID,
*,
amount: Decimal,
from_account_id: UUID,
to_account_id: UUID,
actor_user_id: str,
description: str,
transaction_date: date | None = None,
reference: str | None = None,
) -> Voucher:
"""Any GL → any GL: Dr destination (to), Cr source (from)."""
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
if from_account_id == to_account_id:
raise AppError(
"حساب مبدأ و مقصد نباید یکسان باشند",
status_code=422,
error_code="transfer_same_account",
)
if amount <= 0:
raise AppError("مبلغ انتقال باید بزرگ‌تر از صفر باشد", status_code=422, error_code="transfer_amount")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
return await self._create_treasury_voucher(
tenant_id,
period.id,
number,
transaction_date or date.today(),
to_account_id,
from_account_id,
amount,
description or "انتقال بین حساب‌ها",
actor_user_id,
"treasury",
post_immediately=await SourcePostingGuard(self.session).should_post_immediately(tenant_id),
reference_number=reference,
)
async def post_receipt_payment_voucher(
self,
tenant_id: UUID,
*,
amount: Decimal,
treasury_account_id: UUID,
counter_account_id: UUID,
is_receipt: bool,
actor_user_id: str,
description: str,
transaction_date: date | None = None,
reference: str | None = None,
cash_box_id: UUID | None = None,
) -> Voucher:
"""Post GL for treasury receipt (Dr treasury / Cr counter) or payment (Dr counter / Cr treasury)."""
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
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()
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
if is_receipt:
debit_id, credit_id = treasury_account_id, counter_account_id
else:
debit_id, credit_id = counter_account_id, treasury_account_id
if cash_box_id is not None:
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
if cash_box is not None and cash_box.current_balance < amount:
raise TreasuryError("موجودی صندوق کافی نیست")
voucher = await self._create_treasury_voucher(
tenant_id,
period.id,
number,
tx_date,
debit_id,
credit_id,
amount,
description,
actor_user_id,
"treasury",
post_immediately=await SourcePostingGuard(self.session).should_post_immediately(tenant_id),
reference_number=reference,
)
if cash_box_id is not None:
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
if cash_box is not None:
if is_receipt:
cash_box.current_balance += amount
else:
cash_box.current_balance -= amount
return voucher
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,
*,
post_immediately: bool = True,
reference_number: str | None = None,
) -> 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,
reference_number=reference_number,
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()
if not post_immediately:
return voucher
return await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module=source_module
)

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

View File

@ -0,0 +1,298 @@
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_subsidiary_ledger_report(client):
year_resp = await client.post(
"/api/v1/fiscal/years",
json={
"name": "1404-sub",
"start_date": "2025-03-21",
"end_date": "2026-03-20",
"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": "Farvardin",
"period_number": 1,
"start_date": "2025-03-21",
"end_date": "2025-04-20",
"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 Sub", "code": "COA-SUB"},
headers=tenant_headers(TENANT_A),
)
chart_id = chart_resp.json()["id"]
await client.post(
"/api/v1/accounts",
json={
"chart_id": chart_id,
"code": "1101",
"name": "صندوق",
"account_type": "asset",
"account_category": "current_asset",
"level": 3,
"is_postable": True,
"is_group": False,
},
headers=tenant_headers(TENANT_A),
)
resp = await client.post(
"/api/v1/reporting/subsidiary-ledger",
params={"fiscal_period_id": period_id},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 200
body = resp.json()
assert "id" in body
data = body["data"]
if isinstance(data, str):
import json
data = json.loads(data)
assert data["mode"] == "summary"
assert data["level_mode"] == "subsidiary"
assert isinstance(data["rows"], list)
assert any(r.get("account_code") == "1101" for r in data["rows"])
@pytest.mark.asyncio
async def test_requires_tenant_header(client):
resp = await client.get("/api/v1/accounts")
assert resp.status_code == 400

View 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"

View File

@ -0,0 +1,28 @@
"""Smoke: new accounting integration list routes import + OpenAPI presence."""
from __future__ import annotations
from fastapi.routing import APIRoute
from app.main import app
def test_settlements_list_route_registered():
paths = {r.path for r in app.routes if isinstance(r, APIRoute)}
assert "/api/v1/ar-ap/settlements" in paths
def test_compliance_risks_list_route_registered():
paths = {r.path for r in app.routes if isinstance(r, APIRoute)}
assert "/api/v1/compliance/risks" in paths
def test_purchase_profiles_list_route_registered():
paths = {r.path for r in app.routes if isinstance(r, APIRoute)}
assert "/api/v1/purchase-inventory/posting-profiles" in paths
def test_ops_and_sales_accounting_present():
paths = {r.path for r in app.routes if isinstance(r, APIRoute)}
assert "/api/v1/ops/documents" in paths
assert "/api/v1/sales-accounting/post" in paths
assert "/api/v1/purchase-inventory/post/goods-receipt" in paths

View 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

View File

@ -0,0 +1,6 @@
[pytest]
asyncio_mode = auto
testpaths = app/tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

View 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

View 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()

View File

@ -13,3 +13,10 @@
- دیتابیس مستقل (`ai_assistant_db`) با `tenant_id` در جداول بیزینسی.
- ارتباط با سایر سرویس‌ها فقط از طریق API/Event.
- بررسی دسترسی قابلیت‌ها از 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)

View 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/communication/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 8005

View File

@ -0,0 +1,24 @@
# Communication Service
Independent **Enterprise Communication Platform** for TorbatYar SuperApp.
- Database: `communication_db` (sole owner)
- Port: `8005`
- Version: `0.8.10.1`
- Permission prefix: `communication.*`
## Boundaries
This service is shared infrastructure. It does **not** belong to CRM, Loyalty, Restaurant, or any business module.
Consumers interact only via HTTP APIs and events. No module may call external SMS/email/push providers directly.
## Channels
Designed for: SMS, Email, Push, WhatsApp, Telegram, Rubika, Voice, Future.
**Initially active:** SMS (mock + Payamak adapters).
## Docs
See `docs/communication-phase-8.md`.

View File

@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
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

View 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: F401
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()

Some files were not shown because too many files have changed in this diff Show More