Compare commits

...

17 Commits

Author SHA1 Message Date
Mortezakoohjani
ea5fabaa7c fix(platform): make commercial runtime seed docker-safe
Allow the seed script to run from the mounted core-service app path used in deployment containers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:25:53 +03:30
Mortezakoohjani
0d424c500a feat(platform): complete commercial runtime consolidation
Unify commercial runtime ownership across backend and frontend so platform, experience, and hospitality modules use the shared commercial source of truth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:11:53 +03:30
Mortezakoohjani
091b33a638 feat(platform): finalize commercial runtime, platform shell and hospitality production frontend
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 20:39:10 +03:30
Mortezakoohjani
ef048219cd docs: record official platform deploy on 192.168.10.162
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:10:50 +03:30
Mortezakoohjani
0eec9f729b feat(experience): deploy Experience frontend FE-11.0-11.5 portal
Commit completed Torbat Pages admin frontend module, BFF proxy, routes, and validation artifacts for official platform deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:01:50 +03:30
Mortezakoohjani
72077908f1 feat(delivery): deploy backend phases 10.2-10.8 through settlement
Ship fleet, availability, pricing, dispatch, routing, tracking, and settlement on delivery-service 0.10.8.0 with migrations and phase tests green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:01:23 +03:30
Mortezakoohjani
4451b32a33 feat(payment-frontend): ship Torbat Pay admin portal for MVP 14.0-14.5
Add payment module, BFF proxy, hub/dashboard/ops/PSP/merchant screens wired to real APIs, and mark payment-frontend complete in project status.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:35:07 +03:30
Mortezakoohjani
9fac160258 feat(payment): ship Torbat Pay MVP phases 14.0-14.5
Add independent payment-service (port 8012, payment_db) with foundation licensing, BYO-PSP, merchant accounts, idempotent requests, callbacks, and immutable ledger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:57:04 +03:30
Mortezakoohjani
047cd17afd fix(frontend): pass all BFF service URLs to frontend container
Wire CRM, loyalty, communication, and delivery service URLs into docker-compose
so Next.js API proxies reach Docker network hosts instead of localhost fallbacks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:28:50 +03:30
Mortezakoohjani
fb05937790 fix(frontend): wire sports-center service URL into frontend container
The BFF proxy defaulted to localhost:8006 inside Docker, causing fetch failed on all /api/sports-center routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:23:27 +03:30
Mortezakoohjani
57ea594334 Fix experience-service migration revision IDs exceeding Alembic varchar(32) limit.
Shorten four revision slugs so startup alembic upgrade head succeeds on production.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:42:32 +03:30
Mortezakoohjani
5c6a2e78cf feat(platform): seed service registry, deploy all modules, and fix homepage catalog.
Register all active platform services and base features in core DB so admin can manage them, add delivery/hospitality/sports-center backend phases, update apps catalog and production deploy tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:39:51 +03:30
Mortezakoohjani
7978970783 feat(delivery): add Torbat Driver frontend module with real API wiring.
Ship the delivery platform UI under modules/delivery with thin app routes, BFF proxy, CRUD for phase 10.0-10.1 APIs, and capability-gated future screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:02:01 +03:30
Mortezakoohjani
203671a7bf feat(experience): ship Experience Platform phases 11.0-11.10
Add the experience service with sites through analytics/AI hooks, migrations through 0011, and phase docs/manifests marking the track complete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:43:10 +03:30
Mortezakoohjani
625275e55b feat(healthcare): add backend service and finalize frontend build
Add the Healthcare platform backend (phases 13.0–13.7) with Alembic migration revision fix, production deploy script, validation reports, shared UI exports, and loyalty list page client directives so Next.js build succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:38:31 +03:30
Mortezakoohjani
f89ca52e02 feat(loyalty): register Loyalty app in catalog and architecture docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:17:07 +03:30
Mortezakoohjani
10c3c43a75 feat(communication): add SMS MVP frontend with governance artifacts.
Ship the full Communication portal (real SMS API, feature-locked future channels), BFF proxy, docs, snapshot, and phase handover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:11:25 +03:30
2071 changed files with 152314 additions and 4628 deletions

View File

@ -164,6 +164,15 @@ NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
NEXT_PUBLIC_KEYCLOAK_REALM=superapp NEXT_PUBLIC_KEYCLOAK_REALM=superapp
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002 NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002
NEXT_PUBLIC_CRM_API_URL=http://localhost:8003
NEXT_PUBLIC_LOYALTY_API_URL=http://localhost:8004
NEXT_PUBLIC_COMMUNICATION_API_URL=http://localhost:8005
NEXT_PUBLIC_SPORTS_CENTER_API_URL=http://localhost:8006
NEXT_PUBLIC_DELIVERY_API_URL=http://localhost:8007
NEXT_PUBLIC_EXPERIENCE_API_URL=http://localhost:8008
NEXT_PUBLIC_HOSPITALITY_API_URL=http://localhost:8009
NEXT_PUBLIC_HEALTHCARE_API_URL=http://localhost:8010
NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL=http://localhost:8011
INTERNAL_TOKEN_SECRET=change-me-internal-secret INTERNAL_TOKEN_SECRET=change-me-internal-secret

View File

@ -15,7 +15,7 @@
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts | | [`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/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry | | [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
| [`docs/ai-framework/`](./docs/ai-framework/) | AI Development Framework (implementation lifecycle) | | [`docs/ai-framework/`](./docs/ai-framework/) | Enterprise / AI Development Framework (implementation lifecycle; production-ready by default) |
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry | | [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
| [`docs/glossary.md`](./docs/glossary.md) | Glossary | | [`docs/glossary.md`](./docs/glossary.md) | Glossary |
| [`docs/progress.md`](./docs/progress.md) | Completed work | | [`docs/progress.md`](./docs/progress.md) | Completed work |
@ -62,6 +62,7 @@ docker compose up -d --build
- **Loyalty API:** http://localhost:8004/docs - **Loyalty API:** http://localhost:8004/docs
- **Communication API:** http://localhost:8005/docs - **Communication API:** http://localhost:8005/docs
- **Sports Center API:** http://localhost:8006/docs - **Sports Center API:** http://localhost:8006/docs
- **Hospitality API:** http://localhost:8009/docs
- **Keycloak:** http://localhost:8080 - **Keycloak:** http://localhost:8080
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md). Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).

480
adr-reference.md Normal file
View File

@ -0,0 +1,480 @@
====================
FILE: F:\TorbatYar\docs\architecture\adr\ADR-001.md
====================
# ADR-001: Database-per-Service
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2025-01-01 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
## Context
TorbatYar is a multi-tenant SuperApp that will grow to many business modules. Shared databases create tight coupling, migration contention, and cross-team schema conflicts.
## Decision
Every service owns exactly one database. Cross-service data access is forbidden. Services communicate only via REST API, Webhook, Async Event, and Outbox/Inbox.
## Consequences
### Positive
- Independent schema evolution per service
- Clear ownership boundaries
- Microservice-ready from day one
### Negative
- No cross-DB joins; eventual consistency required
- Duplicate reference data must be synchronized via events/APIs
### Neutral
- Core Platform database (`core_platform_db`) remains the registry for tenants, plans, entitlements, and service discovery
## Alternatives Considered
1. Shared monolithic database with schema prefixes
2. Schema-per-tenant in one database
## Related Documents
- [Database Architecture](../database-architecture.md)
- [Service Architecture](../service-architecture.md)
- [Services Contracts](../../reference/services-contracts.md)
====================
FILE: F:\TorbatYar\docs\architecture\adr\ADR-002.md
====================
# ADR-002: Strict Frontend / Backend Separation
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2025-01-01 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
## Context
Mixing UI and server logic in one deployable unit blocks independent scaling, white-label hosting, and multi-client UIs.
## Decision
Backend lives only under `backend/`. Frontend lives only under `frontend/`. Communication is exclusively through versioned REST APIs (and future WebSocket if needed). No shared source except contracts, schemas, generated SDKs, and type definitions.
## Consequences
### Positive
- Independent deploy and scale
- Clear ownership of business logic (backend only)
- Multiple UIs can share the same APIs
### Negative
- Requires disciplined API versioning
- Local DX needs Docker or two runtimes
## Alternatives Considered
1. Monorepo full-stack Next.js with API routes as primary backend
2. Server-rendered Django templates
## Related Documents
- [Architecture Overview](../architecture.md)
- [Module Boundaries](../module-boundaries.md)
- [Developer Guide](../../development/developer-guide.md)
====================
FILE: F:\TorbatYar\docs\architecture\adr\ADR-003.md
====================
# ADR-003: Row-Level Multi-Tenancy with tenant_id
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2025-01-01 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
## Context
Every business module must isolate tenant data. Database-per-tenant is operationally heavy for early phases.
## Decision
Use shared databases per service with mandatory `tenant_id` on every business table. Tenant resolution order: `X-Tenant-ID` → `X-Tenant-Slug` → subdomain → custom domain → user `current_tenant_id`. Cross-tenant queries are forbidden.
## Consequences
### Positive
- Simple ops on a single DB per service
- Consistent middleware and repository filters
- Compatible with subdomain and custom-domain white-label
### Negative
- Requires rigorous tenant filters in every query
- Noise-neighbor risk at very large scale (future mitigation possible)
## Alternatives Considered
1. Database-per-tenant
2. Schema-per-tenant
## Related Documents
- [Multi-Tenant Architecture](../multi-tenant-architecture.md)
- [Security Architecture](../security-architecture.md)
- [Project Principles](../../development/project-principles.md)
====================
FILE: F:\TorbatYar\docs\architecture\adr\ADR-006.md
====================
# ADR-006: Transactional Outbox / Inbox for Events
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2025-01-01 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
## Context
Services must publish domain events reliably without dual-write failures between DB commits and message brokers.
## Decision
Every service that emits events writes to `outbox_events` in the same business transaction. A worker processes pending outbox rows. Consumers record `inbox_events` by `event_id` for idempotency. Event envelope is `shared.events.EventEnvelope`.
## Consequences
### Positive
- Atomic business + event persistence
- Idempotent consumption
- Message-bus swap later without rewriting producers
### Negative
- Near-real-time, not hard real-time
- Requires Celery/worker ops
## Alternatives Considered
1. Direct broker publish in request path
2. Change-data-capture only
## Related Documents
- [Event-Driven Architecture](../event-driven-architecture.md)
- [Event Catalog](../../reference/event-catalog.md)
- [Services Contracts](../../reference/services-contracts.md)
====================
FILE: F:\TorbatYar\docs\architecture\adr\ADR-008.md
====================
# ADR-008: White-Label Branding via Config and Tenant Profile
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2025-01-01 |
| Deciders | Product + Platform Architecture |
| Supersedes | — |
| Superseded by | — |
## Context
The platform must serve many tenant brands without hardcoding colors, logos, or names in source.
## Decision
Platform brand defaults come from environment / `theme.config.json`. Tenant brand fields live on `tenants` (`primary_color`, `secondary_color`, `logo_url`, `favicon_url`, …). Frontend applies CSS variables. Public tenant-site APIs resolve theme by host for subdomain/custom-domain experiences.
## Consequences
### Positive
- Brand changes without rebuild
- Tenant onboarding captures brand early
### Negative
- Runtime theme loading must stay cacheable and tenant-safe
## Alternatives Considered
1. Hardcoded themes per tenant in frontend builds
2. Separate theme microservice from day one
## Related Documents
- [Multi-Tenant Architecture](../multi-tenant-architecture.md)
- [Deployment Architecture](../deployment-architecture.md)
- [Next Steps](../../next-steps.md)
====================
FILE: F:\TorbatYar\docs\architecture\adr\ADR-011.md
====================
# ADR-011: Independent Enterprise Loyalty Platform Service
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2026-07-24 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
## Context
Multiple business modules (Restaurant, Marketplace, Ecommerce, Academy, Booking, Healthcare, Salon, Gym, and future modules) need loyalty, points, rewards, campaigns, referrals, wallets, and gift cards. Embedding loyalty inside CRM or any single business module would couple unrelated domains and prevent reuse.
## Decision
Loyalty is an **independent shared platform service** (`loyalty-service`) with:
1. Dedicated database `loyalty_db` (ADR-001)
2. Permission prefix `loyalty.*`
3. Publish-only domain events `loyalty.*`
4. Row-level multi-tenancy via `tenant_id` (ADR-003)
5. Immutable point ledger as the sole source of balances (Phase 7.2+) — direct balance mutation is forbidden
6. Consumption by business modules via REST API + Events only — never via shared tables or CRM ownership
Loyalty must **not** belong to CRM, Restaurant, or Ecommerce.
## Consequences
### Positive
- Reusable loyalty across all business modules
- Clear ownership and independent schema evolution
- Enforceable ledger / audit invariants
### Negative
- Modules must integrate asynchronously for eventual consistency of customer 360 views
- Cross-module references use external IDs / refs only
### Neutral
- Phase 7.0 ships foundation aggregates; engines (membership, points, rewards, campaigns, referral, wallet, gift card, analytics, public APIs) land in Phases 7.1–7.9
## Alternatives Considered
1. Loyalty subsystem inside CRM — rejected (CRM is Sales-only; Loyalty is cross-module)
2. Per-module loyalty tables — rejected (duplication, inconsistent rules)
3. Shared monolith database schema — rejected (ADR-001)
## Related Documents
- [Module Boundaries](../module-boundaries.md)
- [Database Architecture](../database-architecture.md)
- [ADR-001](ADR-001.md) آ· [ADR-003](ADR-003.md) آ· [ADR-006](ADR-006.md)
- [Loyalty Phase 7.0](../../loyalty-phase-7-0.md)
- [Module Registry — loyalty](../../module-registry.md#loyalty)
====================
FILE: F:\TorbatYar\docs\architecture\adr\ADR-012.md
====================
# ADR-012: Independent Enterprise Communication Platform
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2026-07-24 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
## Context
Business modules (CRM, Loyalty, Restaurant, etc.) need SMS and future channels. Putting providers inside each module would duplicate credentials, break failover consistency, and couple tenants to modules they may not subscribe to.
Core already sends auth OTP via Payamak — that auth path stays separate for now, but platform messaging must be a shared infrastructure service.
## Decision
1. Introduce `communication-service` with sole ownership of `communication_db` (port 8005).
2. All outbound messaging (SMS first; email/push/social/voice later) goes through this service’s APIs and events.
3. Provider adapters live only inside Communication.
4. Dynamic contact sources resolve contacts via remote APIs — never copy business-module rows.
5. Tenants may activate Communication independently of other business modules.
6. Failures are tracked asynchronously; they must not abort calling business transactions.
## Consequences
### Positive
- Single provider registry, failover, templates, OTP, audit, and monitoring
- Clear module boundary for CRM `CommunicationProvider` contract fulfillment
- Extensible channel model without rewriting consumers
### Negative
- Additional deployable service and database
- Eventual consistency for delivery status
### Neutral
- Core auth OTP may later hand off to Communication when explicitly migrated
## Alternatives Considered
1. Expand `sms_panel` / `notification` scaffolds separately — rejected; unified Communication owns multi-channel routing.
2. Keep providers in each business module — rejected; violates independence and DRY.
## Related Documents
- [communication-phase-8.md](../../communication-phase-8.md)
- [Module Registry](../../module-registry.md)
- [ADR-001](ADR-001.md)
- [Module Boundaries](../module-boundaries.md)
====================
FILE: F:\TorbatYar\docs\architecture\adr\ADR-018.md
====================
# ADR-018: Enterprise Completeness Mandate for the AI Development Framework
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2026-07-25 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
| Extends | [ADR-013](ADR-013.md) |
## Context
ADR-013 established the permanent AI Development Framework under `docs/ai-framework/`. Subsequent service phases still risked shipping CRUD-shaped slices without full production readiness (commands/queries, policies, specifications, OpenAPI, metrics, enterprise completeness verification) unless each phase prompt restated those expectations.
The framework must become the **single source of truth** so every future phase of every future service is production-ready **by default**, without additional prompting.
This ADR does **not** change completed business phases or modify business implementations. It upgrades process documentation and execution rules only.
## Decision
1. Elevate the AI Development Framework into an **Enterprise Development Framework** (same folder `docs/ai-framework/` for backward compatibility; “AI Development Framework†remains the historical name).
2. Every implementation phase **must** satisfy the mandatory [Definition of Done](../../ai-framework/definition-of-done.md).
3. Every implementation phase **automatically** includes the [Mandatory Phase Artifacts](../../ai-framework/mandatory-phase-artifacts.md) (Business Analysis through Self Audit), deriving missing technical components from high-level roadmap text while remaining inside the current phase.
4. **CRUD is never sufficient** for phase completion.
5. Before Complete, verify all dimensions in [Enterprise Completeness](../../ai-framework/enterprise-completeness.md); missing required work is implemented before status change.
6. Enforce [Boundary Rules](../../ai-framework/boundary-rules.md): no foreign service ownership, no logic duplication, no cross-DB access, integrate only via APIs/Events/Providers, never pull future-phase features forward.
7. Quality gates, development loop, phase template, handover, testing/documentation templates, master prompt, and Cursor guidelines are updated to encode these rules.
8. Completed business phases and existing business code are **not** retroactively rewritten by this ADR.
## Consequences
### Positive
- Production readiness becomes the default outcome of every phase
- Phase prompts stay thin; permanent rules live once in the framework
- Clear Completeness / DoD / Boundary vocabulary for agents and reviewers
### Negative
- Higher documentation and verification effort per phase (intentional)
### Neutral
- Path `docs/ai-framework/` retained for compatibility; enterprise docs added alongside
- ADR-013 remains Accepted; this ADR extends it
## Alternatives Considered
1. Keep restating enterprise rules in every phase prompt — rejected (drift, omission)
2. Retroactively refactor all completed services to new package layouts — rejected (out of scope; compatibility)
3. Treat CRUD + tests as enough for “foundation†phases — rejected (not production-ready)
## Related Documents
- [ADR-013](ADR-013.md)
- [AI / Enterprise Framework README](../../ai-framework/README.md)
- [Definition of Done](../../ai-framework/definition-of-done.md)
- [Mandatory Phase Artifacts](../../ai-framework/mandatory-phase-artifacts.md)
- [Enterprise Completeness](../../ai-framework/enterprise-completeness.md)
- [Boundary Rules](../../ai-framework/boundary-rules.md)
- [Quality Gates](../../ai-framework/quality-gates.md)
- [Development Loop](../../ai-framework/development-loop.md)
- [Enterprise Phase Discovery](../../ai-framework/enterprise-phase-discovery.md)
- [Project Principles](../../development/project-principles.md)
- [ADR-019 — Mandatory Enterprise Phase Discovery](ADR-019.md)
====================
FILE: F:\TorbatYar\docs\architecture\adr\ADR-019.md
====================
# ADR-019: Mandatory Enterprise Phase Discovery
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2026-07-26 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
| Extends | [ADR-018](ADR-018.md), [ADR-013](ADR-013.md) |
## Context
ADR-018 made enterprise production readiness the default via Definition of Done, mandatory artifacts, completeness verification, and boundary rules. Agents could still begin implementation from a thin phase brief and under-derive scope—shipping CRUD-shaped work while missing policies, events, list UX, ops surfaces, or other current-phase responsibilities that already existed partially in the codebase or roadmap.
The Framework needs an explicit, mandatory **Enterprise Phase Discovery** stage that inspects roadmap, architecture, boundaries, prior handover, and the live codebase/APIs/domain/events/permissions/tests/docs before coding, then promotes every missing current-phase production capability into Scope.
This ADR does **not** implement business features and does **not** retrofit completed business phases.
## Decision
1. Adopt [Enterprise Phase Discovery](../../ai-framework/enterprise-phase-discovery.md) as a **mandatory** stage before Implementation for every phase.
2. Discovery MUST use the full input set: Current Roadmap, Current Architecture, Service Boundaries, Previous Phase Handover, Existing Codebase, Existing APIs, Existing Domain Model, Existing Events, Existing Permissions, Existing Aggregates, Existing Tests, Existing Documentation.
3. Discovery MUST derive every missing production-ready capability for the **current phase** and MUST NOT assume CRUD is sufficient.
4. Missing current-phase capabilities automatically become implementation work before Complete.
5. Discovery MUST NOT pull future-phase responsibilities, violate service boundaries, or duplicate another service.
6. A Discovery Record is required in the phase document; quality gates and Definition of Done fail without it (implementation phases).
7. Framework loop, gates, templates, master prompt, and Cursor guidelines are updated accordingly.
## Consequences
### Positive
- Phase responsibility is evidence-based (docs + code), not prompt-guessed
- Gaps are closed inside the phase instead of deferred as TODOs
- Boundary and anti-duplication checks happen before code is written
### Negative
- Slightly longer pre-implementation analysis (intentional)
### Neutral
- Docs-only phases still produce a Discovery Record against documentation/manifests
- ADR-013 and ADR-018 remain Accepted; this ADR extends them
## Alternatives Considered
1. Rely only on mandatory-artifact checklists without codebase inventory — rejected (misses drift vs existing code)
2. Optional discovery “when unclear†— rejected (agents skip under time pressure)
3. Expand every phase prompt with full discovery instructions — rejected (framework is single source of truth)
## Related Documents
- [Enterprise Phase Discovery](../../ai-framework/enterprise-phase-discovery.md)
- [ADR-018](ADR-018.md)
- [ADR-013](ADR-013.md)
- [Development Loop](../../ai-framework/development-loop.md)
- [Quality Gates](../../ai-framework/quality-gates.md)
- [Definition of Done](../../ai-framework/definition-of-done.md)
- [Boundary Rules](../../ai-framework/boundary-rules.md)

247
architecture-reference.md Normal file
View File

@ -0,0 +1,247 @@
====================
FILE: F:\TorbatYar\docs\architecture\module-boundaries.md
====================
# Module Boundaries
> Architecture only. Module inventory → [module-registry.md](../module-registry.md)
## Core Platform
**Owns:** tenants, domains, plans, features, subscriptions, entitlement checks, service/module registry, internal service tokens, outbox/inbox (core), audit log, core users, tenant memberships (operational), onboarding, public tenant-site resolution.
**Must not own:** business journals, CRM entities, restaurant menus, file blobs, SMS campaigns.
## Identity & Access
**Owns:** user profiles linked to Keycloak, identity-layer memberships, OIDC BFF (config/token/me), mobile OTP handoff/session redeem, Keycloak admin sync.
**Must not own:** workspace onboarding lifecycle, plan/subscription, operational tenant roles source of truth (Core).
## Frontend
**Owns:** UI, theme application, client-side auth redirects, dashboards, onboarding wizard UX.
**Must not own:** business rules, direct DB access, SQLAlchemy/Alembic, entitlement computation.
## Future Business Modules
Each module (Accounting, CRM, Restaurant, Ecommerce, …) owns its database and domain APIs. Cross-module coupling is API/event only. Financial postings go only through Accounting Posting Engine ([ADR-010](adr/ADR-010.md)).
### CRM (Sales CRM)
**Owns:** Lead, Contact, Organization (business account), Opportunity, Pipeline, PipelineStage, SalesActivity, Task, Meeting, CallLog, Sales Timeline, Comment/Mention, Bookmark, Sales Team, Playbook, Forecast, Goal, Target, Win/Loss, Quote (sales), CRM publish events.
**Must not own:** Automation/workflows, Customer360, Marketing, Notification delivery, Messaging, Communication, Helpdesk, Analytics, AI, Identity, Accounting, Inventory, Restaurant, Marketplace, Files, Search, Loyalty, Sports Center, Delivery, Experience.
### Loyalty (Enterprise Loyalty Platform)
**Owns:** LoyaltyProgram, MembershipTier, Member, PointAccount (shell; ledger in later phases), Reward catalog shell, Campaign shell, Loyalty audit, Loyalty publish events.
**Must not own:** CRM sales entities, Accounting postings, Notification delivery, Identity, Wallet UI (frontend), Restaurant/Marketplace/Ecommerce/Sports Center/Delivery/Experience domain data — those modules consume Loyalty via API/Events only.
### Communication (Enterprise Communication Platform)
**Owns:** Provider configs, sender numbers, message templates, manual contacts, dynamic contact-source configs, messages, queue/DLQ, delivery timeline, provider logs, OTP challenges, webhook receipts, communication audit; outbound provider adapters.
**Must not own:** CRM/Loyalty/Restaurant/Marketplace/Sports Center/Delivery/Experience business entities; must not be embedded inside those modules. Auth OTP path in Core remains separate until explicitly migrated.
### Sports Center Platform
**Owns:** Sports members, memberships/membership types, coaches, attendance/access devices, bookings, facilities/courts/equipment, programs/workouts, competitions/sports events, locker/medical/nutrition shells as scoped, sports reports/analytics shells, sports_center publish events, integrations adapters (client-side only).
**Must not own:** Accounting journals/Posting Engine, Sales CRM aggregates, Loyalty ledger/campaigns, Communication providers, Core tenant membership, Restaurant/Ecommerce domains, Delivery logistics domain, Experience page/site/theme ownership, product AI platform, Automation engine.
### Delivery & Fleet Platform
**Owns:** Drivers, fleets, vehicle types/vehicles, availability/shifts/working zones, pricing/capabilities/bundles, dispatch engine, routing/optimization, tracking, proof of delivery, settlement intents, merchant connector contracts, driver/dispatcher API surfaces, fleet analytics shells, delivery publish events, routing/fleet provider adapters.
**Must not own:** Accounting journals/Posting Engine, Sales CRM aggregates, Loyalty ledger/campaigns, Communication providers or message delivery timeline, Core tenant membership, Restaurant/Marketplace/Clinic/Sports Center order/menu/catalog domains, Experience page/site ownership, product AI platform, Automation engine, Driver App / Dispatcher Panel UI (frontend).
### Experience Platform
**Owns:** Sites, page resources and page types, versioned components, themes, layouts, templates, locales/RTL-LTR shells, media references (not binaries), forms/surveys/appointment page shells, publishing workflows, custom domain binding refs, SEO/PWA shells, capability bundles and feature toggles, widgets, consumer connector contracts, experience analytics/AI hooks shells, experience publish events.
**Must not own:** Accounting journals/Posting Engine, Sales CRM aggregates, Loyalty ledger/campaigns, Communication providers, Core tenant membership / white-label brand source of truth, File Storage binaries, Hospitality menu/item or Marketplace product catalogs as source of truth, Delivery logistics, product AI platform, Automation engine, Page Builder / public site UI (frontend).
### Hospitality Platform
**Owns:** Venues (cafe/restaurant/bakery/… formats), branches, dining areas/tables, menus/categories/items shells, hospitality roles/permissions, bundle definitions, tenant bundle activation, feature toggles, hospitality configurations/settings/events/audit, hospitality publish events, connector contracts (client-side only).
**Must not own:** Accounting journals/Posting Engine, Sales CRM aggregates, Loyalty ledger/campaigns, Communication providers, Core tenant membership, Delivery logistics domain, Experience page/site/theme ownership (menus-as-pages are Experience; menu/item catalog source of truth is Hospitality), product AI platform, Automation engine, POS/kitchen/ordering engines until their phases.
## Shared Library (`backend/shared-lib`)
**Owns:** JWT validation helpers, phone normalization, event envelope types, shared exceptions/responses.
**Must not own:** tenant business workflows or service-specific repositories.
## Boundary Rules
1. No cross-database foreign keys.
2. No importing another service's models.
3. Feature gates use Core entitlement API.
4. Frontend talks to public/versioned APIs only.
5. Providers are integrated behind module/provider adapters — see [integration-architecture.md](integration-architecture.md).
## Related Documents
- [Service Architecture](service-architecture.md)
- [ADR-001](adr/ADR-001.md) آ· [ADR-002](adr/ADR-002.md) آ· [ADR-007](adr/ADR-007.md) آ· [ADR-014](adr/ADR-014.md) آ· [ADR-015](adr/ADR-015.md) آ· [ADR-016](adr/ADR-016.md) آ· [ADR-017](adr/ADR-017.md)
- [Module Registry](../module-registry.md)
- [Sports Center Roadmap](../sports-center-roadmap.md)
- [Delivery Roadmap](../delivery-roadmap.md)
- [Experience Roadmap](../experience-roadmap.md)
- [Hospitality Roadmap](../hospitality-roadmap.md)
====================
FILE: F:\TorbatYar\docs\architecture\service-architecture.md
====================
# Service Architecture
## Internal Layering (every backend service)
```
API (routers) → Services (business logic) → Repositories → Models (DB)
↑
Schemas (Pydantic DTOs)
```
| Layer | Allowed | Forbidden |
| --- | --- | --- |
| API / Views | Auth deps, validation, HTTP mapping | Business rules, raw SQL |
| Services | Domain logic, orchestration, events | HTTP concerns, UI |
| Repositories | Queries/persistence | Business decisions |
| Models | Schema mapping | Business workflows |
## Core Package Layout
- `core/` — config, database, cache, security, logging
- `middlewares/` — tenant resolution
- `workers/` — Celery tasks (outbox, SSL provision, …)
- `api/v1/` — versioned routers
- `tests/` — automated tests
## Adding a Service
1. New folder under `backend/services/<name>/`
2. Independent database + Alembic
3. Register in Core service/module registry
4. Document in [module-registry.md](../module-registry.md)
5. UI only in `frontend/`, API-only access
6. Follow [coding-standards.md](../development/coding-standards.md)
## Related Documents
- [Module Boundaries](module-boundaries.md)
- [Developer Guide](../development/developer-guide.md)
- [Project Principles](../development/project-principles.md)
====================
FILE: F:\TorbatYar\docs\architecture\database-architecture.md
====================
# Database Architecture
> Architecture only. Column-level reference → [database-schema.md](../reference/database-schema.md)
## Pattern
**Database-per-service** ([ADR-001](adr/ADR-001.md)).
| Service | Database |
| --- | --- |
| Core Platform | `core_platform_db` |
| Identity & Access | `identity_access_db` |
| Accounting (future) | `accounting_db` |
| CRM | `crm_db` |
| Loyalty | `loyalty_db` |
| Communication | `communication_db` |
| Sports Center | `sports_center_db` |
| Delivery (planned) | `delivery_db` |
| Experience Platform (registered) | `experience_db` |
| Hospitality | `hospitality_db` |
| Ecommerce (future) | `ecommerce_db` |
| Website Builder (historical scaffold; prefer `experience_db`) | `website_builder_db` |
| Live Chat (future) | `live_chat_db` |
| AI Assistant (future) | `ai_assistant_db` |
| Smart Messenger (future) | `smart_messenger_db` |
| SMS Panel (future) | `sms_panel_db` |
| Link Shortener (future) | `link_shortener_db` |
| Notification (future) | `notification_db` |
| File Storage (future) | `file_storage_db` |
## Hard Rules
1. No direct queries across service databases.
2. No cross-DB foreign keys.
3. Every business table includes `tenant_id` ([ADR-003](adr/ADR-003.md)).
4. IDs are UUID; timestamps are timezone-aware.
5. Migrations via Alembic per service; never edit applied migrations in production.
6. Conceptual schemas for future services are documented in reference docs until migrations exist.
## Core Platform Ownership
Tenants, domains, plans/features/subscriptions, registries, internal tokens, outbox/inbox, audit logs, core users, operational `tenant_memberships`.
## Identity Ownership
`user_profiles`, identity-layer `tenant_memberships` (not the Core table — [ADR-007](adr/ADR-007.md)).
## Dual Membership Clarification
| Database | Table | Role |
| --- | --- | --- |
| `core_platform_db` | `tenant_memberships` | Workspace authorization source of truth |
| `identity_access_db` | `tenant_memberships` | SSO membership listing |
## Related Documents
- [Database Schema](../reference/database-schema.md)
- [Multi-Tenant Architecture](multi-tenant-architecture.md)
- [ADR-001](adr/ADR-001.md) آ· [ADR-007](adr/ADR-007.md)
====================
FILE: F:\TorbatYar\docs\architecture\event-driven-architecture.md
====================
# Event-Driven Architecture
## Envelope
All events use `shared.events.EventEnvelope`:
```json
{
"event_id": "uuid",
"event_type": "tenant.created",
"aggregate_type": "tenant",
"aggregate_id": "uuid",
"tenant_id": "uuid|null",
"source_service": "core-service",
"payload": {},
"occurred_at": "ISO-8601"
}
```
## Outbox → Inbox ([ADR-006](adr/ADR-006.md))
1. Producer writes business row + `outbox_events` in one transaction.
2. Worker (`process_outbox_events`) publishes pending rows.
3. Consumer inserts `inbox_events` keyed by `event_id` (idempotency) then handles payload.
## Naming
`{aggregate}.{past_tense_verb}` — e.g. `tenant.created`, `subscription.updated`, `user.registered`.
## Catalog
Canonical list → [event-catalog.md](../reference/event-catalog.md)
## Future
Replace in-process/Celery-only publish with a real message bus without changing envelope or outbox ownership.
## Related Documents
- [Services Contracts](../reference/services-contracts.md)
- [Service Architecture](service-architecture.md)
- [ADR-006](adr/ADR-006.md)

View File

@ -0,0 +1,103 @@
"""seed platform service registry and base features
Revision ID: 0006_platform_catalog_seed
Revises: 0005_tenant_onboarding
Create Date: 2026-07-27
"""
from __future__ import annotations
import uuid
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0006_platform_catalog_seed"
down_revision: Union[str, None] = "0005_tenant_onboarding"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SERVICES = [
("accounting", "حسابداری آنلاین", "http://accounting-service:8002"),
("healthcare", "تربت هلث", "http://healthcare-service:8010"),
("beauty", "تربت بیوتی", "http://beauty-business-service:8011"),
("crm", "سیستم CRM", "http://crm-service:8003"),
("loyalty", "تربت وفاداری", "http://loyalty-service:8004"),
("communication", "ارتباطات و پیامک", "http://communication-service:8005"),
("sports_center", "مرکز ورزشی", "http://sports-center-service:8006"),
("delivery", "تربت درایور", "http://delivery-service:8007"),
("experience", "پلتفرم تجربه", "http://experience-service:8008"),
("hospitality", "تربت فود", "http://hospitality-service:8009"),
("identity", "هویت و دسترسی", "http://identity-access-service:8001"),
]
FEATURES = [
("accounting.access", "دسترسی حسابداری", "accounting"),
("healthcare.access", "دسترسی سلامت", "healthcare"),
("beauty.access", "دسترسی زیبایی", "beauty"),
("crm.access", "دسترسی CRM", "crm"),
("loyalty.access", "دسترسی وفاداری", "loyalty"),
("communication.access", "دسترسی ارتباطات", "communication"),
("sports_center.access", "دسترسی مرکز ورزشی", "sports_center"),
("delivery.access", "دسترسی لجستیک", "delivery"),
("experience.access", "دسترسی تجربه", "experience"),
("hospitality.access", "دسترسی رستوران", "hospitality"),
]
def upgrade() -> None:
for service_key, name, base_url in SERVICES:
op.execute(
sa.text(
"""
INSERT INTO service_registry (id, service_key, name, base_url, health_check_url, status)
VALUES (CAST(:id AS uuid), :service_key, :name, :base_url, :health_url, 'active')
ON CONFLICT (service_key) DO NOTHING
"""
).bindparams(
id=str(uuid.uuid4()),
service_key=service_key,
name=name,
base_url=base_url,
health_url=f"{base_url}/health",
)
)
for feature_key, name, service_key in FEATURES:
op.execute(
sa.text(
"""
INSERT INTO features (id, feature_key, name, description, service_key, is_active)
VALUES (CAST(:id AS uuid), :feature_key, :name, :description, :service_key, true)
ON CONFLICT (feature_key) DO NOTHING
"""
).bindparams(
id=str(uuid.uuid4()),
feature_key=feature_key,
name=name,
description=f"دسترسی پایه به سرویس {service_key}",
service_key=service_key,
)
)
for feature_key, _, _ in FEATURES:
op.execute(
sa.text(
"""
INSERT INTO plan_features (id, plan_id, feature_id, is_enabled)
SELECT CAST(:id AS uuid), p.id, f.id, true
FROM plans p
CROSS JOIN features f
WHERE p.code = 'FREE' AND f.feature_key = :feature_key
ON CONFLICT (plan_id, feature_id) DO NOTHING
"""
).bindparams(id=str(uuid.uuid4()), feature_key=feature_key)
)
def downgrade() -> None:
feature_keys = ", ".join(f"'{key}'" for key, _, _ in FEATURES)
service_keys = ", ".join(f"'{key}'" for key, _, _ in SERVICES)
op.execute(f"DELETE FROM plan_features WHERE feature_id IN (SELECT id FROM features WHERE feature_key IN ({feature_keys}))")
op.execute(f"DELETE FROM features WHERE feature_key IN ({feature_keys})")
op.execute(f"DELETE FROM service_registry WHERE service_key IN ({service_keys})")

View File

@ -0,0 +1,165 @@
"""Commercial Runtime tables
Revision ID: 0007_commercial_runtime
Revises: 0006_platform_catalog_seed
Create Date: 2026-07-28
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0007_commercial_runtime"
down_revision: Union[str, None] = "0006_platform_catalog_seed"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"commercial_registry_kinds",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("kind", sa.String(length=100), nullable=False),
sa.Column("path", sa.String(length=100), nullable=False),
sa.Column("display_name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("code_field", sa.String(length=100), nullable=False),
sa.Column("list_envelope", sa.String(length=100), nullable=False),
sa.Column("public_read", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("kind"),
sa.UniqueConstraint("path"),
)
op.create_table(
"commercial_registry_objects",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("kind", sa.String(length=100), nullable=False),
sa.Column("slug", sa.String(length=200), nullable=False),
sa.Column("stable_code", sa.String(length=200), nullable=False),
sa.Column("version_code", sa.String(length=64), nullable=False),
sa.Column("display_name", sa.String(length=300), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("visibility", sa.String(length=40), nullable=False),
sa.Column("tenant_id", sa.Uuid(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.Column("updated_by", sa.Uuid(), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("clone_of_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"kind",
"stable_code",
"version_code",
"tenant_id",
name="uq_commercial_obj_kind_code_ver_tenant",
),
)
op.create_index("ix_commercial_registry_objects_kind", "commercial_registry_objects", ["kind"])
op.create_index("ix_commercial_registry_objects_status", "commercial_registry_objects", ["status"])
op.create_index("ix_commercial_registry_objects_tenant_id", "commercial_registry_objects", ["tenant_id"])
op.create_index("ix_commercial_registry_objects_is_deleted", "commercial_registry_objects", ["is_deleted"])
op.create_index("ix_commercial_obj_kind_status", "commercial_registry_objects", ["kind", "status"])
op.create_index("ix_commercial_obj_slug", "commercial_registry_objects", ["kind", "slug"])
op.create_index("ix_commercial_obj_search", "commercial_registry_objects", ["kind", "display_name"])
op.create_table(
"commercial_tenant_subscriptions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("tenant_id", sa.Uuid(), nullable=False),
sa.Column("plan_code", sa.String(length=200), nullable=True),
sa.Column("bundle_code", sa.String(length=200), nullable=True),
sa.Column("pricing_item_code", sa.String(length=200), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("trial_ends_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("current_period_start", sa.DateTime(timezone=True), nullable=True),
sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancel_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_commercial_tenant_subscriptions_tenant_id",
"commercial_tenant_subscriptions",
["tenant_id"],
)
op.create_index(
"ix_commercial_sub_tenant_status",
"commercial_tenant_subscriptions",
["tenant_id", "status"],
)
op.create_table(
"commercial_tenant_licenses",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("tenant_id", sa.Uuid(), nullable=False),
sa.Column("license_code", sa.String(length=200), nullable=False),
sa.Column("display_name", sa.String(length=300), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("capability_codes", sa.JSON(), nullable=False),
sa.Column("product_codes", sa.JSON(), nullable=False),
sa.Column("bundle_code", sa.String(length=200), nullable=True),
sa.Column("quota", sa.JSON(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "license_code", name="uq_commercial_license_tenant_code"),
)
op.create_index(
"ix_commercial_tenant_licenses_tenant_id",
"commercial_tenant_licenses",
["tenant_id"],
)
op.create_table(
"commercial_activation_states",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("tenant_id", sa.Uuid(), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("completion_percent", sa.Integer(), nullable=False, server_default="0"),
sa.Column("steps", sa.JSON(), nullable=False),
sa.Column("checklist", sa.JSON(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id"),
)
def downgrade() -> None:
op.drop_table("commercial_activation_states")
op.drop_index("ix_commercial_tenant_licenses_tenant_id", table_name="commercial_tenant_licenses")
op.drop_table("commercial_tenant_licenses")
op.drop_index("ix_commercial_sub_tenant_status", table_name="commercial_tenant_subscriptions")
op.drop_index("ix_commercial_tenant_subscriptions_tenant_id", table_name="commercial_tenant_subscriptions")
op.drop_table("commercial_tenant_subscriptions")
op.drop_index("ix_commercial_obj_search", table_name="commercial_registry_objects")
op.drop_index("ix_commercial_obj_slug", table_name="commercial_registry_objects")
op.drop_index("ix_commercial_obj_kind_status", table_name="commercial_registry_objects")
op.drop_index("ix_commercial_registry_objects_is_deleted", table_name="commercial_registry_objects")
op.drop_index("ix_commercial_registry_objects_tenant_id", table_name="commercial_registry_objects")
op.drop_index("ix_commercial_registry_objects_status", table_name="commercial_registry_objects")
op.drop_index("ix_commercial_registry_objects_kind", table_name="commercial_registry_objects")
op.drop_table("commercial_registry_objects")
op.drop_table("commercial_registry_kinds")

View File

@ -0,0 +1,35 @@
"""Drop legacy Core commercial tables (plans/features/subscriptions).
Commercial Runtime (`commercial_*`) is the sole commercial SoT.
Revision ID: 0008_drop_legacy_commercial
Revises: 0007_commercial_runtime
Create Date: 2026-07-28
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
revision: str = "0008_drop_legacy_commercial"
down_revision: Union[str, None] = "0007_commercial_runtime"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Order: dependents first. IF EXISTS for environments partially migrated.
op.execute("DROP TABLE IF EXISTS tenant_feature_access CASCADE")
op.execute("DROP TABLE IF EXISTS tenant_subscriptions CASCADE")
op.execute("DROP TABLE IF EXISTS plan_features CASCADE")
op.execute("DROP TABLE IF EXISTS features CASCADE")
op.execute("DROP TABLE IF EXISTS plans CASCADE")
op.execute("DROP TYPE IF EXISTS subscription_status")
def downgrade() -> None:
# Intentionally irreversible — restore from Commercial Runtime / backups if needed.
raise NotImplementedError(
"Legacy Core commercial tables cannot be restored; use Commercial Runtime."
)

View File

@ -1,3 +1,3 @@
"""Core Platform Service - بسته اصلی برنامه.""" """Core Platform Service - بسته اصلی برنامه."""
__version__ = "0.1.0" __version__ = "0.2.0"

View File

@ -3,15 +3,13 @@ from fastapi import APIRouter
from app.api.v1 import ( from app.api.v1 import (
auth, auth,
commercial,
domains, domains,
features,
health, health,
me, me,
onboarding, onboarding,
plans,
public_tenant, public_tenant,
service_registry, service_registry,
subscriptions,
tenant_context, tenant_context,
tenants, tenants,
) )
@ -19,18 +17,15 @@ from app.api.v1.admin import tenants as admin_tenants
api_router = APIRouter() api_router = APIRouter()
# health خارج از prefix نسخه هم در main اضافه می‌شود؛ اینجا برای کامل بودن.
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(public_tenant.router) api_router.include_router(public_tenant.router)
api_router.include_router(admin_tenants.router) api_router.include_router(admin_tenants.router)
api_router.include_router(tenants.router) api_router.include_router(tenants.router)
api_router.include_router(domains.router) api_router.include_router(domains.router)
api_router.include_router(plans.router)
api_router.include_router(features.router)
api_router.include_router(subscriptions.router)
api_router.include_router(service_registry.router) api_router.include_router(service_registry.router)
api_router.include_router(me.router) api_router.include_router(me.router)
api_router.include_router(onboarding.router) api_router.include_router(onboarding.router)
api_router.include_router(tenant_context.router) api_router.include_router(tenant_context.router)
api_router.include_router(commercial.router)
__all__ = ["api_router", "health"] __all__ = ["api_router", "health"]

View File

@ -0,0 +1,558 @@
"""Commercial Runtime API — discovery, generic registries, engines, tenant ops."""
from __future__ import annotations
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Body, Depends, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination
from app.commercial.recommendation import AssessmentEngine, RecommendationEngine
from app.commercial.resolvers import BundleResolver, LicenseResolver, PlanResolver
from app.commercial.service import CommercialRegistryService
from app.core.security import (
get_optional_user,
require_authenticated,
require_platform_admin,
)
from app.models.commercial import CommercialActivationState
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter(prefix="/commercial", tags=["commercial"])
def _actor_id(user: CurrentUser | None) -> UUID | None:
if user is None:
return None
try:
return UUID(str(user.user_id))
except Exception: # noqa: BLE001
return None
# ── Discovery ──────────────────────────────────────────────────────────────
@router.get("/catalog")
async def commercial_catalog(db: AsyncSession = Depends(get_db)) -> dict[str, Any]:
return await CommercialRegistryService(db).discovery_catalog()
@router.get("/health")
async def commercial_health() -> dict[str, str]:
return {"status": "ok", "domain": "commercial-runtime", "version": "1.0.0"}
# ── Assessment / Recommendations ───────────────────────────────────────────
@router.get("/assessment/schema")
async def assessment_schema(
schema_code: str | None = None,
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
return await AssessmentEngine(db).get_schema(schema_code)
@router.post("/recommendations")
async def recommendations(
body: dict[str, Any] = Body(default_factory=dict),
db: AsyncSession = Depends(get_db),
_user=Depends(get_optional_user),
) -> dict[str, Any]:
return await RecommendationEngine(db).evaluate(body)
@router.get("/recommendations/tenant")
async def tenant_recommendations(
tenant_id: UUID = Query(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
# Tenant feed = evaluate with subscription context hints
lic = LicenseResolver(db)
sub = await lic.get_subscription(tenant_id)
req = {
"tenant_id": str(tenant_id),
"bundle_code": sub.bundle_code if sub else None,
"plan_code": sub.plan_code if sub else None,
}
result = await RecommendationEngine(db).evaluate(req)
return {
"recommended_products": result.get("recommended_products") or [],
"recommended_bundles": result.get("recommended_bundles") or [],
"recommended_services": result.get("recommended_services") or [],
"recommended_upgrades": result.get("recommended_pricing") or [],
"recommended_automation_packs": result.get("recommended_automation_packs") or [],
"recommended_extensions": [],
"rule_trace": result.get("rule_trace") or [],
}
# ── Resolvers ──────────────────────────────────────────────────────────────
@router.get("/bundles/{bundle_code}/resolve")
async def resolve_bundle(bundle_code: str, db: AsyncSession = Depends(get_db)) -> dict[str, Any]:
return await BundleResolver(db).resolve(bundle_code)
@router.get("/plans/{plan_code}/resolve")
async def resolve_plan(plan_code: str, db: AsyncSession = Depends(get_db)) -> dict[str, Any]:
return await PlanResolver(db).resolve(plan_code)
@router.post("/entitlements/check")
async def entitlements_check(
body: dict[str, Any] = Body(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
tenant_id = UUID(str(body.get("tenant_id")))
feature_key = str(body.get("feature_key") or body.get("capability_code") or "")
return await LicenseResolver(db).check_entitlement(tenant_id, feature_key)
@router.post("/subscriptions")
async def create_subscription(
body: dict[str, Any] = Body(...),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_authenticated),
) -> dict[str, Any]:
tenant_id = UUID(str(body.get("tenant_id")))
return await LicenseResolver(db).activate(
tenant_id,
plan_code=body.get("plan_code"),
bundle_code=body.get("bundle_code"),
pricing_item_code=body.get("pricing_item_code"),
status=str(body.get("status") or "trialing"),
actor=_actor_id(user),
)
@router.get("/licenses")
async def list_licenses(
tenant_id: UUID = Query(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
rows = await LicenseResolver(db).list_licenses(tenant_id)
items = [
{
"license_code": r.license_code,
"display_name": r.display_name,
"status": r.status,
"capability_codes": r.capability_codes,
"product_codes": r.product_codes,
"bundle_code": r.bundle_code,
"quota": r.quota,
"expires_at": r.expires_at.isoformat() if r.expires_at else None,
}
for r in rows
]
return {"items": items, "licenses": items}
@router.get("/dashboard")
async def commercial_dashboard(
tenant_id: UUID = Query(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
lic = LicenseResolver(db)
sub = await lic.get_subscription(tenant_id)
licenses = await lic.list_licenses(tenant_id)
return {
"tenant_id": str(tenant_id),
"subscription": {
"status": sub.status if sub else None,
"plan_code": sub.plan_code if sub else None,
"bundle_code": sub.bundle_code if sub else None,
"trial_ends_at": sub.trial_ends_at.isoformat() if sub and sub.trial_ends_at else None,
}
if sub
else None,
"licenses": [
{
"license_code": r.license_code,
"display_name": r.display_name,
"status": r.status,
}
for r in licenses
],
}
@router.get("/activation")
async def activation_pipeline(
tenant_id: UUID = Query(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
stmt = select(CommercialActivationState).where(
CommercialActivationState.tenant_id == tenant_id
)
state = (await db.execute(stmt)).scalar_one_or_none()
if state is None:
return {"status": "pending", "completion_percent": 0, "steps": []}
return {
"status": state.status,
"completion_percent": state.completion_percent,
"steps": state.steps,
}
@router.get("/checklist")
async def setup_checklist(
tenant_id: UUID = Query(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
stmt = select(CommercialActivationState).where(
CommercialActivationState.tenant_id == tenant_id
)
state = (await db.execute(stmt)).scalar_one_or_none()
items = state.checklist if state else []
done = sum(1 for i in items if i.get("done"))
pct = int((done / len(items)) * 100) if items else 0
return {"items": items, "completion_percent": pct}
@router.get("/usage")
async def usage_counters(
tenant_id: UUID = Query(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
licenses = await LicenseResolver(db).list_licenses(tenant_id)
counters = []
for lic in licenses:
for key, val in (lic.quota or {}).items():
if isinstance(val, dict):
counters.append(
{
"key": key,
"label": val.get("label") or key,
"used": val.get("used", 0),
"limit": val.get("limit"),
"unit": val.get("unit"),
}
)
else:
counters.append({"key": key, "label": key, "used": 0, "limit": val})
return {"items": counters}
@router.get("/invoices")
async def invoices_placeholder(
tenant_id: UUID = Query(...),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
# Commercial owns subscription/license truth; payment invoices stay in Payment.
# Empty envelope — never fake invoices.
return {
"items": [],
"message": "فاکتورهای مالی در سرویس Payment هستند؛ Commercial فقط refs نگه‌می‌دارد.",
}
@router.get("/transactions")
async def transactions_placeholder(
tenant_id: UUID = Query(...),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
return {
"items": [],
"message": "تراکنش‌های پرداخت در سرویس Payment هستند.",
}
@router.post("/policies/evaluate")
async def evaluate_policy(
body: dict[str, Any] = Body(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> dict[str, Any]:
policy_code = str(body.get("policy_code") or body.get("code") or "domain.custom")
svc = CommercialRegistryService(db)
rows, _ = await svc.repo.list_objects("policies", status="published", platform_only=True, limit=200)
matched = None
for r in rows:
if r.stable_code == policy_code or (r.payload or {}).get("target") == body.get("target"):
matched = r
break
if matched is None:
return {
"custom_domain_allowed": False,
"subdomain_only": True,
"reason": "سیاست دامنه منتشرشده یافت نشد — پیش‌فرض subdomain_only",
"source": "commercial.default",
}
payload = dict(matched.payload or {})
return {
"custom_domain_allowed": bool(payload.get("custom_domain_allowed", False)),
"subdomain_only": bool(payload.get("subdomain_only", True)),
"reason": payload.get("reason") or matched.description,
"parameters": payload.get("parameters") or {},
"source": matched.stable_code,
"ssl_ready": payload.get("ssl_ready"),
"dns_instructions": payload.get("dns_instructions") or [],
"verification_state": payload.get("verification_state"),
}
# ── Generic registry CRUD ──────────────────────────────────────────────────
async def _list_registry(
path: str,
*,
db: AsyncSession,
pagination: PaginationParams,
q: str | None,
status_filter: str | None,
sort: str,
order: str,
admin: bool,
) -> dict[str, Any]:
return await CommercialRegistryService(db).list(
path,
q=q,
status=status_filter,
sort=sort,
order=order,
offset=pagination.offset,
limit=pagination.limit,
admin=admin,
)
def _register_kind_routes(path: str) -> None:
@router.get(f"/{path}")
async def list_items( # type: ignore[no-redef]
pagination: PaginationParams = Depends(get_pagination),
q: str | None = None,
status: str | None = Query(default="published", alias="status"),
sort: str = "display_name",
order: str = "asc",
db: AsyncSession = Depends(get_db),
user: CurrentUser | None = Depends(get_optional_user),
_path: str = path,
) -> dict[str, Any]:
admin = bool(user and user.has_role("platform_admin"))
return await _list_registry(
_path,
db=db,
pagination=pagination,
q=q,
status_filter=status,
sort=sort,
order=order,
admin=admin,
)
@router.get(f"/{path}/{{object_id}}")
async def get_item( # type: ignore[no-redef]
object_id: UUID,
db: AsyncSession = Depends(get_db),
_path: str = path,
) -> dict[str, Any]:
return await CommercialRegistryService(db).get(_path, object_id)
@router.post(f"/{path}", status_code=status.HTTP_201_CREATED)
async def create_item( # type: ignore[no-redef]
body: dict[str, Any] = Body(...),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
_path: str = path,
) -> dict[str, Any]:
return await CommercialRegistryService(db).create(_path, body, actor=_actor_id(user))
@router.patch(f"/{path}/{{object_id}}")
async def patch_item( # type: ignore[no-redef]
object_id: UUID,
body: dict[str, Any] = Body(...),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
_path: str = path,
) -> dict[str, Any]:
return await CommercialRegistryService(db).update(_path, object_id, body, actor=_actor_id(user))
@router.put(f"/{path}/{{object_id}}")
async def put_item( # type: ignore[no-redef]
object_id: UUID,
body: dict[str, Any] = Body(...),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
_path: str = path,
) -> dict[str, Any]:
return await CommercialRegistryService(db).update(_path, object_id, body, actor=_actor_id(user))
@router.delete(f"/{path}/{{object_id}}", status_code=status.HTTP_200_OK)
async def delete_item( # type: ignore[no-redef]
object_id: UUID,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
_path: str = path,
) -> dict[str, Any]:
return await CommercialRegistryService(db).lifecycle(
_path, object_id, "delete", actor=_actor_id(user)
)
@router.post(f"/{path}/{{object_id}}/{{action}}")
async def lifecycle_item( # type: ignore[no-redef]
object_id: UUID,
action: str,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
_path: str = path,
) -> dict[str, Any]:
return await CommercialRegistryService(db).lifecycle(
_path, object_id, action, actor=_actor_id(user)
)
# Built-in FE-compatible paths
for _path in (
"products",
"bundles",
"pricing",
"plans",
"trials",
"capabilities",
"policies",
"assets",
"extensions",
"automation-packs",
"metadata",
"recommendation-rules",
"assessment-schemas",
"notifications",
"usage-plans",
"coupons",
"promotions",
"taxes",
"currencies",
"regions",
"industries",
"business-types",
"announcements",
"languages",
):
_register_kind_routes(_path)
# Generic unlimited path for future kinds without code change to this list:
@router.get("/registries/{kind}")
async def list_generic_registry(
kind: str,
pagination: PaginationParams = Depends(get_pagination),
q: str | None = None,
status: str | None = Query(default="published", alias="status"),
sort: str = "display_name",
order: str = "asc",
db: AsyncSession = Depends(get_db),
user: CurrentUser | None = Depends(get_optional_user),
) -> dict[str, Any]:
admin = bool(user and user.has_role("platform_admin"))
return await _list_registry(
kind,
db=db,
pagination=pagination,
q=q,
status_filter=status,
sort=sort,
order=order,
admin=admin,
)
@router.post("/registries/{kind}", status_code=status.HTTP_201_CREATED)
async def create_generic_registry(
kind: str,
body: dict[str, Any] = Body(...),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
) -> dict[str, Any]:
return await CommercialRegistryService(db).create(kind, body, actor=_actor_id(user))
@router.get("/registries/{kind}/{object_id}")
async def get_generic_registry(
kind: str,
object_id: UUID,
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
return await CommercialRegistryService(db).get(kind, object_id)
@router.patch("/registries/{kind}/{object_id}")
async def patch_generic_registry(
kind: str,
object_id: UUID,
body: dict[str, Any] = Body(...),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
) -> dict[str, Any]:
return await CommercialRegistryService(db).update(kind, object_id, body, actor=_actor_id(user))
@router.delete("/registries/{kind}/{object_id}")
async def delete_generic_registry(
kind: str,
object_id: UUID,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
) -> dict[str, Any]:
return await CommercialRegistryService(db).lifecycle(
kind, object_id, "delete", actor=_actor_id(user)
)
@router.post("/registries/{kind}/{object_id}/{action}")
async def lifecycle_generic_registry(
kind: str,
object_id: UUID,
action: str,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
) -> dict[str, Any]:
return await CommercialRegistryService(db).lifecycle(
kind, object_id, action, actor=_actor_id(user)
)
@router.post("/admin/kinds", status_code=status.HTTP_201_CREATED)
async def register_kind(
body: dict[str, Any] = Body(...),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_platform_admin),
) -> dict[str, Any]:
"""Register a brand-new registry kind (unlimited future expansion)."""
from app.models.commercial import CommercialRegistryKind
svc = CommercialRegistryService(db)
kind = await svc.repo.upsert_kind(
CommercialRegistryKind(
kind=str(body["kind"]),
path=str(body.get("path") or body["kind"]),
display_name=str(body.get("display_name") or body["kind"]),
description=body.get("description"),
code_field=str(body.get("code_field") or "code"),
list_envelope=str(body.get("list_envelope") or "items"),
public_read=bool(body.get("public_read", True)),
enabled=bool(body.get("enabled", True)),
sort_order=int(body.get("sort_order") or 1000),
meta_data=body.get("metadata"),
)
)
await db.commit()
return {
"kind": kind.kind,
"path": kind.path,
"href": f"/api/v1/commercial/registries/{kind.kind}",
"display_name": kind.display_name,
}

View File

@ -1,37 +0,0 @@
"""APIهای مدیریت قابلیت‌ها (Features)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination
from app.core.security import require_authenticated, require_platform_admin
from app.schemas.plan import FeatureCreate, FeatureRead
from app.services.plan_service import PlanService
from shared.pagination import Page, PaginationParams
router = APIRouter(prefix="/features", tags=["features"])
@router.post("", response_model=FeatureRead, status_code=status.HTTP_201_CREATED)
async def create_feature(
payload: FeatureCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> FeatureRead:
feature = await PlanService(db).create_feature(payload)
return FeatureRead.model_validate(feature)
@router.get("", response_model=Page[FeatureRead])
async def list_features(
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> Page[FeatureRead]:
items, total = await PlanService(db).list_features(
offset=pagination.offset, limit=pagination.limit
)
return Page.create(
[FeatureRead.model_validate(f) for f in items], total, pagination
)

View File

@ -1,57 +0,0 @@
"""APIهای مدیریت پلن‌ها و اتصال قابلیت به پلن."""
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
from app.core.security import require_authenticated, require_platform_admin
from app.schemas.plan import (
PlanCreate,
PlanFeatureCreate,
PlanFeatureRead,
PlanRead,
)
from app.services.plan_service import PlanService
from shared.pagination import Page, PaginationParams
router = APIRouter(prefix="/plans", tags=["plans"])
@router.post("", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
async def create_plan(
payload: PlanCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> PlanRead:
plan = await PlanService(db).create_plan(payload)
return PlanRead.model_validate(plan)
@router.get("", response_model=Page[PlanRead])
async def list_plans(
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> Page[PlanRead]:
items, total = await PlanService(db).list_plans(
offset=pagination.offset, limit=pagination.limit
)
return Page.create([PlanRead.model_validate(p) for p in items], total, pagination)
@router.post(
"/{plan_id}/features",
response_model=PlanFeatureRead,
status_code=status.HTTP_201_CREATED,
)
async def attach_feature_to_plan(
plan_id: UUID,
payload: PlanFeatureCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> PlanFeatureRead:
plan_feature = await PlanService(db).attach_feature(plan_id, payload)
return PlanFeatureRead.model_validate(plan_feature)

View File

@ -1,61 +0,0 @@
"""APIهای اشتراک و بررسی دسترسی قابلیت."""
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
from app.core.security import require_authenticated, require_tenant_admin
from app.schemas.subscription import (
FeatureCheckRequest,
FeatureCheckResponse,
SubscriptionCreate,
SubscriptionRead,
)
from app.services.entitlement_service import EntitlementService
from app.services.subscription_service import SubscriptionService
router = APIRouter(prefix="/tenants/{tenant_id}", tags=["subscription"])
@router.post(
"/subscription",
response_model=SubscriptionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_subscription(
tenant_id: UUID,
payload: SubscriptionCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_tenant_admin),
) -> SubscriptionRead:
subscription = await SubscriptionService(db).create(tenant_id, payload)
return SubscriptionRead.model_validate(subscription)
@router.get("/subscription", response_model=SubscriptionRead)
async def get_subscription(
tenant_id: UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> SubscriptionRead:
subscription = await SubscriptionService(db).get_current(tenant_id)
return SubscriptionRead.model_validate(subscription)
@router.post("/features/check", response_model=FeatureCheckResponse)
async def check_feature(
tenant_id: UUID,
payload: FeatureCheckRequest,
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> FeatureCheckResponse:
result = await EntitlementService(db).evaluate(tenant_id, payload.feature_key)
return FeatureCheckResponse(
tenant_id=tenant_id,
feature_key=payload.feature_key,
has_access=result.has_access,
reason=result.reason,
)

View File

@ -0,0 +1,6 @@
"""Commercial Runtime package."""
from app.commercial.kinds import BUILTIN_REGISTRY_KINDS
from app.commercial.service import CommercialRegistryService
__all__ = ["BUILTIN_REGISTRY_KINDS", "CommercialRegistryService"]

View File

@ -0,0 +1,54 @@
"""Commercial Runtime — open registry kind catalog.
Kinds are data-driven. Built-ins seed the meta-registry; future kinds
require only DB rows (zero code change for CRUD via /registries/{kind}).
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RegistryKindSpec:
kind: str
path: str
display_name: str
description: str
code_field: str
list_envelope: str
public_read: bool = True
# Built-in aliases matching Commercial FE adapters + open expansion.
BUILTIN_REGISTRY_KINDS: tuple[RegistryKindSpec, ...] = (
RegistryKindSpec("products", "products", "Products", "Platform product registry", "product_code", "products"),
RegistryKindSpec("bundles", "bundles", "Bundles", "Business bundle registry", "bundle_code", "bundles"),
RegistryKindSpec("pricing", "pricing", "Pricing", "Pricing catalog items", "pricing_item_code", "items"),
RegistryKindSpec("plans", "plans", "Plans", "Subscription plans", "plan_code", "plans"),
RegistryKindSpec("trials", "trials", "Trials", "Trial rule definitions", "trial_code", "trials"),
RegistryKindSpec("capabilities", "capabilities", "Capabilities", "Capability registry", "capability_code", "capabilities"),
RegistryKindSpec("policies", "policies", "Policies", "Policy definitions", "policy_code", "policies"),
RegistryKindSpec("assets", "assets", "Assets", "Asset registry", "asset_code", "assets"),
RegistryKindSpec("extensions", "extensions", "Extensions", "Extension registry", "extension_code", "extensions"),
RegistryKindSpec("automation_packs", "automation-packs", "Automation Packs", "Automation catalog packs", "automation_pack_code", "items"),
RegistryKindSpec("metadata", "metadata", "Metadata", "Metadata key registry", "metadata_key", "items"),
RegistryKindSpec("recommendation_rules", "recommendation-rules", "Recommendation Rules", "Rule-based recommendation definitions", "rule_id", "rules"),
RegistryKindSpec("assessment_schemas", "assessment-schemas", "Assessment Schemas", "Configurable assessment schemas", "schema_code", "schemas"),
RegistryKindSpec("notifications", "notifications", "Notifications", "Notification templates / feed defs", "notification_code", "items"),
RegistryKindSpec("usage_plans", "usage-plans", "Usage Plans", "Usage / quota plan definitions", "usage_plan_code", "items"),
RegistryKindSpec("coupons", "coupons", "Coupons", "Coupon definitions", "coupon_code", "items"),
RegistryKindSpec("promotions", "promotions", "Promotions", "Promotion definitions", "promotion_code", "items"),
RegistryKindSpec("taxes", "taxes", "Taxes", "Tax class definitions", "tax_code", "items"),
RegistryKindSpec("currencies", "currencies", "Currencies", "Currency catalog", "currency_code", "items"),
RegistryKindSpec("regions", "regions", "Regions", "Region catalog", "region_code", "items"),
RegistryKindSpec("industries", "industries", "Industries", "Industry catalog", "industry_code", "items"),
RegistryKindSpec("business_types", "business-types", "Business Types", "Business type catalog", "business_type_code", "items"),
RegistryKindSpec("announcements", "announcements", "Announcements", "Commercial announcements", "announcement_code", "items"),
RegistryKindSpec("languages", "languages", "Languages", "Language catalog", "language_code", "items"),
)
KIND_BY_PATH = {s.path: s for s in BUILTIN_REGISTRY_KINDS}
KIND_BY_KIND = {s.kind: s for s in BUILTIN_REGISTRY_KINDS}
OBJECT_STATUSES = ("draft", "published", "archived", "deleted")
VISIBILITIES = ("public", "authenticated", "admin", "hidden")

View File

@ -0,0 +1,247 @@
"""Rule-based recommendation + assessment engines (no AI)."""
from __future__ import annotations
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.commercial.repository import CommercialRegistryRepository
from app.commercial.service import CommercialRegistryService
def _match_rule(match: dict[str, Any], req: dict[str, Any]) -> bool:
if not match:
return True
for key, expected in match.items():
if key.endswith("_gte"):
field = key[:-4]
actual = req.get(field)
try:
if actual is None or float(actual) < float(expected):
return False
except (TypeError, ValueError):
return False
elif key.endswith("_in"):
field = key[:-3]
actual = req.get(field)
if actual not in (expected or []):
return False
elif key == "needs_contains":
needs = req.get("needs") or []
if expected not in needs:
return False
elif key.startswith("flag_"):
field = key[5:]
if bool(req.get(field)) is not bool(expected):
return False
else:
if req.get(key) != expected:
# also support {eq: x} objects
if isinstance(expected, dict) and "eq" in expected:
if req.get(key) != expected["eq"]:
return False
elif isinstance(expected, dict) and "in" in expected:
if req.get(key) not in expected["in"]:
return False
elif isinstance(expected, dict) and "gte" in expected:
try:
if float(req.get(key) or 0) < float(expected["gte"]):
return False
except (TypeError, ValueError):
return False
else:
return False
return True
def _rank_add(
bucket: dict[str, dict[str, Any]], code: str, score: float, reason: str
) -> None:
if not code:
return
cur = bucket.get(code)
if cur is None or score > cur["score"]:
bucket[code] = {"code": code, "score": score, "reason": reason}
class RecommendationEngine:
def __init__(self, db: AsyncSession) -> None:
self.db = db
self.repo = CommercialRegistryRepository(db)
self.registry = CommercialRegistryService(db)
async def evaluate(self, request: dict[str, Any]) -> dict[str, Any]:
rules_rows, _ = await self.repo.list_objects(
"recommendation_rules",
status="published",
platform_only=True,
limit=500,
)
# Normalize request aliases from FE assessment answers
req = dict(request)
if "business_type_code" not in req and request.get("answers"):
answers = request["answers"]
if isinstance(answers, dict):
req.update({k: v for k, v in answers.items() if not isinstance(v, (dict, list)) or True})
for k, v in answers.items():
req[k] = v
products: dict[str, dict[str, Any]] = {}
bundles: dict[str, dict[str, Any]] = {}
capabilities: dict[str, dict[str, Any]] = {}
pricing: dict[str, dict[str, Any]] = {}
plans: dict[str, dict[str, Any]] = {}
services: dict[str, dict[str, Any]] = {}
automation: dict[str, dict[str, Any]] = {}
assets: dict[str, dict[str, Any]] = {}
rule_trace: list[str] = []
# Sort by priority desc
def priority(row) -> int:
return int((row.payload or {}).get("priority") or 0)
for row in sorted(rules_rows, key=priority, reverse=True):
payload = dict(row.payload or {})
if payload.get("active") is False:
continue
match = payload.get("match") or {}
if not _match_rule(match, req):
continue
emit = payload.get("emit") or {}
reason = emit.get("reason") or payload.get("reason") or row.display_name
score = float(emit.get("score") or 50)
rule_trace.append(f"{row.stable_code}: matched → {reason}")
for code in emit.get("products") or emit.get("recommended_products") or []:
_rank_add(products, str(code), score, reason)
for code in emit.get("bundles") or emit.get("recommended_bundles") or []:
_rank_add(bundles, str(code), score, reason)
for code in emit.get("capabilities") or []:
_rank_add(capabilities, str(code), score, reason)
for code in emit.get("pricing") or emit.get("recommended_pricing") or []:
_rank_add(pricing, str(code), score, reason)
for code in emit.get("plans") or emit.get("recommended_plans") or []:
_rank_add(plans, str(code), score, reason)
if emit.get("plan_code"):
_rank_add(plans, str(emit["plan_code"]), score, reason)
for code in emit.get("services") or []:
_rank_add(services, str(code), score, reason)
for code in emit.get("automation_packs") or []:
_rank_add(automation, str(code), score, reason)
for code in emit.get("assets") or []:
_rank_add(assets, str(code), score, reason)
# single-code shorthand
if emit.get("bundle_code"):
_rank_add(bundles, str(emit["bundle_code"]), score, reason)
if emit.get("product_code"):
_rank_add(products, str(emit["product_code"]), score, reason)
if payload.get("stop"):
rule_trace.append(f"{row.stable_code}: stop")
break
# Heuristic fallback if no rules published yet
if not rule_trace:
bt = str(req.get("business_type_code") or req.get("business_type") or "")
rule_trace.append("fallback: no published recommendation_rules; heuristic")
if bt:
bundles_rows, _ = await self.repo.list_objects(
"bundles", status="published", platform_only=True, limit=200
)
for b in bundles_rows:
types = (b.payload or {}).get("business_types") or []
if bt in types or not types:
_rank_add(bundles, b.stable_code, 60 if bt in types else 20, f"business_type={bt}")
products_rows, _ = await self.repo.list_objects(
"products", status="published", platform_only=True, limit=200
)
for p in products_rows:
industries = (p.payload or {}).get("recommended_industries") or []
if bt in industries or not industries:
_rank_add(products, p.stable_code, 55 if bt in industries else 15, f"industry/type={bt}")
def ranked(bucket: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(bucket.values(), key=lambda x: x["score"], reverse=True)
return {
"version": "recommendation.v1",
"recommended_products": ranked(products),
"recommended_bundles": ranked(bundles),
"recommended_capabilities": ranked(capabilities),
"recommended_pricing": ranked(pricing),
"recommended_plans": ranked(plans),
"recommended_services": ranked(services),
"recommended_automation_packs": ranked(automation),
"recommended_assets": ranked(assets),
"rule_trace": rule_trace,
}
class AssessmentEngine:
def __init__(self, db: AsyncSession) -> None:
self.db = db
self.repo = CommercialRegistryRepository(db)
self.registry = CommercialRegistryService(db)
async def get_schema(self, schema_code: str | None = None) -> dict[str, Any]:
if schema_code:
row = await self.repo.get_by_code("assessment_schemas", schema_code)
else:
rows, _ = await self.repo.list_objects(
"assessment_schemas", status="published", platform_only=True, limit=20
)
row = rows[0] if rows else None
if row is None:
# Default configurable schema (still stored shape; not FE-hardcoded questions forever —
# returned only when registry empty so admin can replace via API)
return {
"schema_code": "default.business.assessment",
"title": "ارزیابی کسب‌وکار",
"version": "1",
"questions": [
{
"question_id": "business_type_code",
"type": "select",
"label": "نوع کسب‌وکار",
"required": True,
"options_source": "business_types",
},
{
"question_id": "company_size",
"type": "select",
"label": "اندازه سازمان",
"required": True,
"options": [
{"value": "solo", "label": "تک‌نفره"},
{"value": "small", "label": "کوچک"},
{"value": "medium", "label": "متوسط"},
{"value": "large", "label": "بزرگ"},
{"value": "enterprise", "label": "سازمانی"},
],
},
{
"question_id": "goals",
"type": "multi_select",
"label": "اهداف",
"required": False,
"options": [
{"value": "online_sales", "label": "فروش آنلاین"},
{"value": "delivery", "label": "پیک"},
{"value": "crm", "label": "CRM"},
{"value": "loyalty", "label": "وفاداری"},
{"value": "website", "label": "وب‌سایت"},
],
},
],
"source": "builtin_default_until_registry_seeded",
}
payload = dict(row.payload or {})
return {
"schema_code": row.stable_code,
"title": row.display_name,
"version": row.version_code,
"questions": payload.get("questions") or [],
"metadata": row.meta_data or {},
"source": "registry",
}

View File

@ -0,0 +1,178 @@
"""Commercial registry repository — generic CRUD for all kinds."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Select, and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.commercial import CommercialRegistryKind, CommercialRegistryObject
class CommercialRegistryRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_kinds(self, *, enabled_only: bool = True) -> list[CommercialRegistryKind]:
stmt: Select = select(CommercialRegistryKind).order_by(
CommercialRegistryKind.sort_order, CommercialRegistryKind.kind
)
if enabled_only:
stmt = stmt.where(CommercialRegistryKind.enabled.is_(True))
return list((await self.db.execute(stmt)).scalars().all())
async def get_kind(self, kind: str) -> CommercialRegistryKind | None:
stmt = select(CommercialRegistryKind).where(CommercialRegistryKind.kind == kind)
return (await self.db.execute(stmt)).scalar_one_or_none()
async def get_kind_by_path(self, path: str) -> CommercialRegistryKind | None:
stmt = select(CommercialRegistryKind).where(CommercialRegistryKind.path == path)
return (await self.db.execute(stmt)).scalar_one_or_none()
async def upsert_kind(self, kind: CommercialRegistryKind) -> CommercialRegistryKind:
existing = await self.get_kind(kind.kind)
if existing:
existing.path = kind.path
existing.display_name = kind.display_name
existing.description = kind.description
existing.code_field = kind.code_field
existing.list_envelope = kind.list_envelope
existing.public_read = kind.public_read
existing.enabled = kind.enabled
existing.sort_order = kind.sort_order
existing.meta_data = kind.meta_data
await self.db.flush()
return existing
self.db.add(kind)
await self.db.flush()
return kind
def _base_query(
self,
kind: str,
*,
tenant_id: uuid.UUID | None,
include_deleted: bool,
status: str | None,
visibility: str | None,
q: str | None,
platform_only: bool,
) -> Select:
stmt = select(CommercialRegistryObject).where(CommercialRegistryObject.kind == kind)
if not include_deleted:
stmt = stmt.where(CommercialRegistryObject.is_deleted.is_(False))
if status:
stmt = stmt.where(CommercialRegistryObject.status == status)
if visibility:
stmt = stmt.where(CommercialRegistryObject.visibility == visibility)
if platform_only:
stmt = stmt.where(CommercialRegistryObject.tenant_id.is_(None))
elif tenant_id is not None:
stmt = stmt.where(
or_(
CommercialRegistryObject.tenant_id.is_(None),
CommercialRegistryObject.tenant_id == tenant_id,
)
)
if q:
like = f"%{q}%"
stmt = stmt.where(
or_(
CommercialRegistryObject.display_name.ilike(like),
CommercialRegistryObject.stable_code.ilike(like),
CommercialRegistryObject.slug.ilike(like),
CommercialRegistryObject.description.ilike(like),
)
)
return stmt
async def list_objects(
self,
kind: str,
*,
tenant_id: uuid.UUID | None = None,
include_deleted: bool = False,
status: str | None = None,
visibility: str | None = None,
q: str | None = None,
sort: str = "display_name",
order: str = "asc",
offset: int = 0,
limit: int = 50,
platform_only: bool = True,
) -> tuple[list[CommercialRegistryObject], int]:
stmt = self._base_query(
kind,
tenant_id=tenant_id,
include_deleted=include_deleted,
status=status,
visibility=visibility,
q=q,
platform_only=platform_only,
)
count_stmt = select(func.count()).select_from(stmt.subquery())
total = int((await self.db.execute(count_stmt)).scalar_one())
sort_col = getattr(CommercialRegistryObject, sort, CommercialRegistryObject.display_name)
stmt = stmt.order_by(sort_col.desc() if order.lower() == "desc" else sort_col.asc())
stmt = stmt.offset(offset).limit(limit)
rows = list((await self.db.execute(stmt)).scalars().all())
return rows, total
async def get(self, object_id: uuid.UUID) -> CommercialRegistryObject | None:
stmt = select(CommercialRegistryObject).where(CommercialRegistryObject.id == object_id)
return (await self.db.execute(stmt)).scalar_one_or_none()
async def get_by_code(
self,
kind: str,
stable_code: str,
*,
version_code: str | None = None,
tenant_id: uuid.UUID | None = None,
) -> CommercialRegistryObject | None:
stmt = select(CommercialRegistryObject).where(
and_(
CommercialRegistryObject.kind == kind,
CommercialRegistryObject.stable_code == stable_code,
CommercialRegistryObject.is_deleted.is_(False),
)
)
if version_code:
stmt = stmt.where(CommercialRegistryObject.version_code == version_code)
if tenant_id is None:
stmt = stmt.where(CommercialRegistryObject.tenant_id.is_(None))
else:
stmt = stmt.where(
or_(
CommercialRegistryObject.tenant_id == tenant_id,
CommercialRegistryObject.tenant_id.is_(None),
)
)
stmt = stmt.order_by(CommercialRegistryObject.updated_at.desc())
return (await self.db.execute(stmt)).scalars().first()
async def create(self, obj: CommercialRegistryObject) -> CommercialRegistryObject:
self.db.add(obj)
await self.db.flush()
return obj
async def save(self, obj: CommercialRegistryObject) -> CommercialRegistryObject:
await self.db.flush()
return obj
async def soft_delete(self, obj: CommercialRegistryObject, actor: uuid.UUID | None) -> None:
now = datetime.now(timezone.utc)
obj.is_deleted = True
obj.deleted_at = now
obj.status = "deleted"
obj.updated_by = actor
await self.db.flush()
async def restore(self, obj: CommercialRegistryObject, actor: uuid.UUID | None) -> None:
obj.is_deleted = False
obj.deleted_at = None
obj.status = "draft"
obj.updated_by = actor
await self.db.flush()

View File

@ -0,0 +1,322 @@
"""Commercial resolvers — bundle / plan / license / entitlement."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.commercial.kinds import KIND_BY_KIND
from app.commercial.repository import CommercialRegistryRepository
from app.commercial.service import CommercialRegistryService
from app.models.commercial import (
CommercialActivationState,
CommercialTenantLicense,
CommercialTenantSubscription,
)
from app.models.events import OutboxEvent
from shared.events import EventStatus
from shared.exceptions import NotFoundError, ValidationAppError
def _aware(dt: datetime | None) -> datetime | None:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
class BundleResolver:
def __init__(self, db: AsyncSession) -> None:
self.db = db
self.repo = CommercialRegistryRepository(db)
self.registry = CommercialRegistryService(db)
async def resolve(self, bundle_code: str) -> dict[str, Any]:
bundle = await self.repo.get_by_code("bundles", bundle_code)
if bundle is None or bundle.status != "published":
raise NotFoundError(f"باندل «{bundle_code}» یافت نشد")
payload = dict(bundle.payload or {})
product_codes = list(payload.get("product_codes") or [])
capability_codes = list(payload.get("capability_codes") or [])
pricing_refs = list(payload.get("pricing_refs") or [])
extension_codes = list(payload.get("extension_codes") or [])
asset_codes = list(payload.get("asset_codes") or [])
automation_codes = list(payload.get("automation_pack_codes") or [])
policy_codes = list(payload.get("policy_codes") or [])
services = list(payload.get("services") or [])
async def load_many(kind: str, codes: list[str]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
code_field = (KIND_BY_KIND.get(kind).code_field if kind in KIND_BY_KIND else "code")
for code in codes:
if isinstance(code, dict):
code = (
code.get("code")
or code.get("product_code")
or code.get("capability_code")
or code.get(code_field)
)
if not code:
continue
obj = await self.repo.get_by_code(kind, str(code))
if obj and obj.status == "published":
out.append(self.registry.serialize(obj, code_field))
return out
return {
"bundle_code": bundle.stable_code,
"display_name": bundle.display_name,
"version_code": bundle.version_code,
"products": await load_many("products", product_codes),
"capabilities": await load_many("capabilities", capability_codes),
"pricing": await load_many(
"pricing",
[
(r.get("pricing_item_code") if isinstance(r, dict) else r)
for r in pricing_refs
],
),
"extensions": await load_many("extensions", extension_codes),
"assets": await load_many("assets", asset_codes),
"automation_packs": await load_many("automation_packs", automation_codes),
"policies": await load_many("policies", policy_codes),
"services": services,
"payload": payload,
"metadata": bundle.meta_data or {},
}
class PlanResolver:
def __init__(self, db: AsyncSession) -> None:
self.db = db
self.repo = CommercialRegistryRepository(db)
self.registry = CommercialRegistryService(db)
async def resolve(self, plan_code: str) -> dict[str, Any]:
plan = await self.repo.get_by_code("plans", plan_code)
if plan is None:
# fallback: treat pricing item as plan shell
plan = await self.repo.get_by_code("pricing", plan_code)
if plan is None or plan.status != "published":
raise NotFoundError(f"پلن «{plan_code}» یافت نشد")
payload = dict(plan.payload or {})
trial = payload.get("trial") or {}
return {
"plan_code": plan.stable_code,
"display_name": plan.display_name,
"trial": {
"eligible": bool(payload.get("trial_eligible") or trial.get("eligible")),
"days": trial.get("days") or payload.get("trial_days") or 14,
},
"limits": payload.get("limits") or payload.get("quotas") or {},
"price": {
"amount_minor": payload.get("amount_minor"),
"currency": payload.get("currency"),
"pricing_model": payload.get("pricing_model"),
"pricing_item_code": payload.get("pricing_item_code") or plan.stable_code,
},
"renewal": payload.get("renewal") or {"interval": payload.get("interval") or "monthly"},
"visibility": plan.visibility,
"bundle_code": payload.get("bundle_code"),
"payload": payload,
}
class LicenseResolver:
def __init__(self, db: AsyncSession) -> None:
self.db = db
self.bundle_resolver = BundleResolver(db)
self.plan_resolver = PlanResolver(db)
async def get_subscription(self, tenant_id: uuid.UUID) -> CommercialTenantSubscription | None:
stmt = (
select(CommercialTenantSubscription)
.where(CommercialTenantSubscription.tenant_id == tenant_id)
.order_by(CommercialTenantSubscription.updated_at.desc())
)
return (await self.db.execute(stmt)).scalars().first()
async def list_licenses(self, tenant_id: uuid.UUID) -> list[CommercialTenantLicense]:
stmt = select(CommercialTenantLicense).where(
CommercialTenantLicense.tenant_id == tenant_id
)
return list((await self.db.execute(stmt)).scalars().all())
async def activate(
self,
tenant_id: uuid.UUID,
*,
plan_code: str | None = None,
bundle_code: str | None = None,
pricing_item_code: str | None = None,
status: str = "trialing",
actor: uuid.UUID | None = None,
) -> dict[str, Any]:
if not any([plan_code, bundle_code, pricing_item_code]):
raise ValidationAppError("یکی از plan_code / bundle_code / pricing_item_code لازم است")
resolved_bundle = None
resolved_plan = None
if bundle_code:
resolved_bundle = await self.bundle_resolver.resolve(bundle_code)
if plan_code or pricing_item_code:
resolved_plan = await self.plan_resolver.resolve(plan_code or pricing_item_code) # type: ignore[arg-type]
now = datetime.now(timezone.utc)
trial_days = 0
if resolved_plan:
trial = resolved_plan.get("trial") or {}
trial_days = int(trial.get("days") or resolved_plan.get("trial_days") or 0)
trial_ends = now + timedelta(days=trial_days) if status == "trialing" and trial_days > 0 else None
sub = await self.get_subscription(tenant_id)
if sub is None:
sub = CommercialTenantSubscription(tenant_id=tenant_id)
self.db.add(sub)
sub.plan_code = plan_code or (resolved_plan or {}).get("plan_code")
sub.bundle_code = bundle_code or (resolved_plan or {}).get("bundle_code")
sub.pricing_item_code = pricing_item_code
sub.status = status
sub.trial_ends_at = trial_ends
sub.current_period_start = now
sub.current_period_end = trial_ends or (now + timedelta(days=30))
sub.created_by = actor
sub.meta_data = {
"bundle": resolved_bundle,
"plan": resolved_plan,
}
capability_codes: list[str] = []
product_codes: list[str] = []
if resolved_bundle:
capability_codes = [c.get("capability_code") or c.get("stable_code") for c in resolved_bundle.get("capabilities", [])]
product_codes = [p.get("product_code") or p.get("stable_code") for p in resolved_bundle.get("products", [])]
capability_codes = [c for c in capability_codes if c]
product_codes = [p for p in product_codes if p]
license_code = f"lic.{tenant_id}.{sub.bundle_code or sub.plan_code or 'default'}"
lic_stmt = select(CommercialTenantLicense).where(
CommercialTenantLicense.tenant_id == tenant_id,
CommercialTenantLicense.license_code == license_code,
)
lic = (await self.db.execute(lic_stmt)).scalar_one_or_none()
if lic is None:
lic = CommercialTenantLicense(
tenant_id=tenant_id,
license_code=license_code,
)
self.db.add(lic)
lic.display_name = (resolved_bundle or {}).get("display_name") or (resolved_plan or {}).get("display_name")
lic.status = "active"
lic.capability_codes = capability_codes
lic.product_codes = product_codes
lic.bundle_code = sub.bundle_code
lic.quota = (resolved_plan or {}).get("limits") or {}
lic.expires_at = sub.current_period_end
await self._ensure_activation(tenant_id)
self.db.add(
OutboxEvent(
event_type="commercial.subscription.activated",
aggregate_type="commercial.subscription",
aggregate_id=str(sub.id) if sub.id else str(tenant_id),
tenant_id=tenant_id,
payload={
"tenant_id": str(tenant_id),
"status": status,
"bundle_code": sub.bundle_code,
"plan_code": sub.plan_code,
},
status=EventStatus.PENDING,
)
)
await self.db.commit()
await self.db.refresh(sub)
return {
"subscription": {
"id": str(sub.id),
"tenant_id": str(tenant_id),
"status": sub.status,
"plan_code": sub.plan_code,
"bundle_code": sub.bundle_code,
"pricing_item_code": sub.pricing_item_code,
"trial_ends_at": sub.trial_ends_at.isoformat() if sub.trial_ends_at else None,
"current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None,
},
"license": {
"license_code": lic.license_code,
"status": lic.status,
"capability_codes": lic.capability_codes,
"product_codes": lic.product_codes,
},
}
async def check_entitlement(
self, tenant_id: uuid.UUID, feature_key: str
) -> dict[str, Any]:
licenses = await self.list_licenses(tenant_id)
sub = await self.get_subscription(tenant_id)
active_caps: set[str] = set()
required_bundle = None
for lic in licenses:
if lic.status != "active":
continue
if lic.expires_at and _aware(lic.expires_at) < datetime.now(timezone.utc):
continue
active_caps.update(str(c) for c in (lic.capability_codes or []))
required_bundle = lic.bundle_code or required_bundle
# Also accept feature_key / product_code / hospitality.* style
has = (
feature_key in active_caps
or feature_key.replace("hospitality.", "") in active_caps
or any(feature_key.endswith(c) or c.endswith(feature_key) for c in active_caps)
)
if not licenses and sub and sub.status in ("trialing", "active"):
# trial without detailed caps → allow unless explicitly denied later
has = True
reason = "اشتراک/trial فعال است"
elif has:
reason = "مجوز فعال"
else:
reason = "قابلیت در entitlement فعال نیست"
return {
"has_access": has,
"allowed": has,
"reason": reason,
"required_plan": sub.plan_code if sub and not has else None,
"required_capability": feature_key if not has else None,
"required_bundle": required_bundle if not has else None,
}
async def _ensure_activation(self, tenant_id: uuid.UUID) -> CommercialActivationState:
stmt = select(CommercialActivationState).where(
CommercialActivationState.tenant_id == tenant_id
)
state = (await self.db.execute(stmt)).scalar_one_or_none()
if state is None:
steps = [
{"step_key": "workspace", "label": "Workspace", "status": "done"},
{"step_key": "bundle", "label": "Bundle", "status": "done"},
{"step_key": "trial", "label": "Trial", "status": "done"},
{"step_key": "dashboard", "label": "Dashboard", "status": "pending", "href": "/dashboard"},
]
state = CommercialActivationState(
tenant_id=tenant_id,
status="in_progress",
completion_percent=75,
steps=steps,
checklist=[
{"id": "workspace", "label": "Workspace", "done": True},
{"id": "bundle", "label": "انتخاب باندل", "done": True},
{"id": "billing", "label": "Billing", "done": False, "href": "/billing"},
{"id": "domain", "label": "دامنه", "done": False, "href": "/domains"},
],
)
self.db.add(state)
return state

View File

@ -0,0 +1,411 @@
"""Commercial registry service — CRUD, lifecycle, serialization, events."""
from __future__ import annotations
import re
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.commercial.kinds import BUILTIN_REGISTRY_KINDS, KIND_BY_KIND, KIND_BY_PATH
from app.commercial.repository import CommercialRegistryRepository
from app.models.audit import AuditLog
from app.models.commercial import CommercialRegistryKind, CommercialRegistryObject
from app.models.events import OutboxEvent
from shared.events import EventStatus
from shared.exceptions import ConflictError, NotFoundError, ValidationAppError
def _slugify(value: str) -> str:
s = value.strip().lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")[:180] or str(uuid.uuid4())
class CommercialRegistryService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
self.repo = CommercialRegistryRepository(db)
async def ensure_builtin_kinds(self) -> None:
for i, spec in enumerate(BUILTIN_REGISTRY_KINDS):
await self.repo.upsert_kind(
CommercialRegistryKind(
kind=spec.kind,
path=spec.path,
display_name=spec.display_name,
description=spec.description,
code_field=spec.code_field,
list_envelope=spec.list_envelope,
public_read=spec.public_read,
enabled=True,
sort_order=i * 10,
)
)
await self.db.commit()
async def resolve_kind(self, path_or_kind: str) -> CommercialRegistryKind:
kind = await self.repo.get_kind_by_path(path_or_kind)
if kind is None:
kind = await self.repo.get_kind(path_or_kind)
if kind is None:
# fall back to builtins without requiring prior seed
spec = KIND_BY_PATH.get(path_or_kind) or KIND_BY_KIND.get(path_or_kind)
if spec:
kind = await self.repo.upsert_kind(
CommercialRegistryKind(
kind=spec.kind,
path=spec.path,
display_name=spec.display_name,
description=spec.description,
code_field=spec.code_field,
list_envelope=spec.list_envelope,
public_read=spec.public_read,
enabled=True,
)
)
await self.db.commit()
if kind is None or not kind.enabled:
raise NotFoundError(f"رجیستری «{path_or_kind}» یافت نشد")
return kind
async def discovery_catalog(self) -> dict[str, Any]:
kinds = await self.repo.list_kinds(enabled_only=True)
if not kinds:
await self.ensure_builtin_kinds()
kinds = await self.repo.list_kinds(enabled_only=True)
return {
"contract_version": "commercial.v1.2",
"registries": [
{
"kind": k.kind,
"path": k.path,
"href": f"/api/v1/commercial/{k.path}",
"generic_href": f"/api/v1/commercial/registries/{k.kind}",
"display_name": k.display_name,
"description": k.description,
"code_field": k.code_field,
"list_envelope": k.list_envelope,
"public_read": k.public_read,
}
for k in kinds
],
}
def serialize(self, obj: CommercialRegistryObject, code_field: str) -> dict[str, Any]:
data = {
"registry_object_id": str(obj.id),
"id": str(obj.id),
"kind": obj.kind,
"slug": obj.slug,
"stable_code": obj.stable_code,
code_field: obj.stable_code,
"version_code": obj.version_code,
"version": obj.version_code,
"display_name": obj.display_name,
"description": obj.description,
"status": obj.status,
"visibility": obj.visibility,
"tenant_id": str(obj.tenant_id) if obj.tenant_id else None,
"metadata": obj.meta_data or {},
"created_by": str(obj.created_by) if obj.created_by else None,
"updated_by": str(obj.updated_by) if obj.updated_by else None,
"created_at": obj.created_at.isoformat() if obj.created_at else None,
"updated_at": obj.updated_at.isoformat() if obj.updated_at else None,
"published_at": obj.published_at.isoformat() if obj.published_at else None,
"archived_at": obj.archived_at.isoformat() if obj.archived_at else None,
"is_deleted": obj.is_deleted,
"clone_of_id": str(obj.clone_of_id) if obj.clone_of_id else None,
}
# Flatten payload for FE adapters (product_code, default_route, …)
payload = dict(obj.payload or {})
for key, value in payload.items():
if key not in data:
data[key] = value
data["payload"] = payload
return data
async def list(
self,
path_or_kind: str,
*,
q: str | None = None,
status: str | None = "published",
visibility: str | None = None,
include_deleted: bool = False,
sort: str = "display_name",
order: str = "asc",
offset: int = 0,
limit: int = 100,
tenant_id: uuid.UUID | None = None,
admin: bool = False,
) -> dict[str, Any]:
kind = await self.resolve_kind(path_or_kind)
# Public list defaults to published; admins may pass status=all
status_filter = None if (admin and status in (None, "all")) else status
rows, total = await self.repo.list_objects(
kind.kind,
tenant_id=tenant_id,
include_deleted=include_deleted and admin,
status=status_filter,
visibility=visibility,
q=q,
sort=sort,
order=order,
offset=offset,
limit=limit,
platform_only=tenant_id is None,
)
items = [self.serialize(r, kind.code_field) for r in rows]
envelope = kind.list_envelope or "items"
return {
envelope: items,
"items": items,
"total": total,
"kind": kind.kind,
"path": kind.path,
}
async def get(self, path_or_kind: str, object_id: uuid.UUID) -> dict[str, Any]:
kind = await self.resolve_kind(path_or_kind)
obj = await self.repo.get(object_id)
if obj is None or obj.kind != kind.kind:
raise NotFoundError("شیء رجیستری یافت نشد")
return self.serialize(obj, kind.code_field)
async def create(
self,
path_or_kind: str,
body: dict[str, Any],
*,
actor: uuid.UUID | None,
tenant_id: uuid.UUID | None = None,
) -> dict[str, Any]:
kind = await self.resolve_kind(path_or_kind)
code = (
body.get(kind.code_field)
or body.get("stable_code")
or body.get("code")
or body.get("slug")
)
if not code:
raise ValidationAppError(f"فیلد «{kind.code_field}» الزامی است")
code = str(code)
display_name = str(body.get("display_name") or body.get("name") or code)
version_code = str(body.get("version_code") or body.get("version") or "1")
existing = await self.repo.get_by_code(
kind.kind, code, version_code=version_code, tenant_id=tenant_id
)
if existing and not existing.is_deleted:
raise ConflictError(f"کد «{code}» نسخه {version_code} از قبل وجود دارد")
reserved = {
"registry_object_id",
"id",
"kind",
"slug",
"stable_code",
kind.code_field,
"version_code",
"version",
"display_name",
"description",
"status",
"visibility",
"tenant_id",
"metadata",
"payload",
"created_by",
"updated_by",
}
payload = dict(body.get("payload") or {})
for k, v in body.items():
if k not in reserved:
payload[k] = v
obj = CommercialRegistryObject(
kind=kind.kind,
slug=_slugify(str(body.get("slug") or code)),
stable_code=code,
version_code=version_code,
display_name=display_name,
description=body.get("description"),
status=str(body.get("status") or "draft"),
visibility=str(body.get("visibility") or "public"),
tenant_id=tenant_id,
payload=payload,
meta_data=dict(body.get("metadata") or {}),
created_by=actor,
updated_by=actor,
)
if obj.status == "published":
obj.published_at = datetime.now(timezone.utc)
await self.repo.create(obj)
await self._audit(actor, "commercial.registry.created", obj)
await self._event("commercial.registry.created", obj)
await self.db.commit()
await self.db.refresh(obj)
return self.serialize(obj, kind.code_field)
async def update(
self,
path_or_kind: str,
object_id: uuid.UUID,
body: dict[str, Any],
*,
actor: uuid.UUID | None,
) -> dict[str, Any]:
kind = await self.resolve_kind(path_or_kind)
obj = await self.repo.get(object_id)
if obj is None or obj.kind != kind.kind or obj.is_deleted:
raise NotFoundError("شیء رجیستری یافت نشد")
if "display_name" in body:
obj.display_name = str(body["display_name"])
if "description" in body:
obj.description = body.get("description")
if "visibility" in body:
obj.visibility = str(body["visibility"])
if "version_code" in body or "version" in body:
obj.version_code = str(body.get("version_code") or body.get("version"))
if "slug" in body:
obj.slug = _slugify(str(body["slug"]))
if "metadata" in body and isinstance(body["metadata"], dict):
obj.meta_data = dict(body["metadata"])
reserved = {
"registry_object_id",
"id",
"kind",
"slug",
"stable_code",
kind.code_field,
"version_code",
"version",
"display_name",
"description",
"status",
"visibility",
"tenant_id",
"metadata",
"payload",
"created_by",
"updated_by",
"created_at",
"updated_at",
}
payload = dict(obj.payload or {})
if isinstance(body.get("payload"), dict):
payload.update(body["payload"])
for k, v in body.items():
if k not in reserved:
payload[k] = v
obj.payload = payload
obj.updated_by = actor
await self.repo.save(obj)
await self._audit(actor, "commercial.registry.updated", obj)
await self._event("commercial.registry.updated", obj)
await self.db.commit()
await self.db.refresh(obj)
return self.serialize(obj, kind.code_field)
async def lifecycle(
self,
path_or_kind: str,
object_id: uuid.UUID,
action: str,
*,
actor: uuid.UUID | None,
) -> dict[str, Any]:
kind = await self.resolve_kind(path_or_kind)
obj = await self.repo.get(object_id)
if obj is None or obj.kind != kind.kind:
raise NotFoundError("شیء رجیستری یافت نشد")
now = datetime.now(timezone.utc)
action = action.lower()
if action == "publish":
obj.status = "published"
obj.published_at = now
obj.is_deleted = False
event = "commercial.registry.published"
elif action == "draft":
obj.status = "draft"
event = "commercial.registry.updated"
elif action == "archive":
obj.status = "archived"
obj.archived_at = now
event = "commercial.registry.archived"
elif action == "delete":
await self.repo.soft_delete(obj, actor)
event = "commercial.registry.deleted"
elif action == "restore":
await self.repo.restore(obj, actor)
event = "commercial.registry.updated"
elif action == "clone":
clone = CommercialRegistryObject(
kind=obj.kind,
slug=_slugify(f"{obj.slug}-copy"),
stable_code=f"{obj.stable_code}.copy.{uuid.uuid4().hex[:6]}",
version_code=obj.version_code,
display_name=f"{obj.display_name} (copy)",
description=obj.description,
status="draft",
visibility=obj.visibility,
tenant_id=obj.tenant_id,
payload=dict(obj.payload or {}),
meta_data=dict(obj.meta_data or {}),
created_by=actor,
updated_by=actor,
clone_of_id=obj.id,
)
await self.repo.create(clone)
await self._audit(actor, "commercial.registry.cloned", clone)
await self._event("commercial.registry.created", clone)
await self.db.commit()
await self.db.refresh(clone)
return self.serialize(clone, kind.code_field)
else:
raise ValidationAppError(f"اکشن «{action}» پشتیبانی نمی‌شود")
obj.updated_by = actor
await self.repo.save(obj)
await self._audit(actor, event, obj)
await self._event(event, obj)
await self.db.commit()
await self.db.refresh(obj)
return self.serialize(obj, kind.code_field)
async def _audit(
self, actor: uuid.UUID | None, action: str, obj: CommercialRegistryObject
) -> None:
self.db.add(
AuditLog(
tenant_id=obj.tenant_id,
actor_user_id=actor,
action=action,
resource_type=f"commercial.{obj.kind}",
resource_id=str(obj.id),
meta_data={
"stable_code": obj.stable_code,
"version_code": obj.version_code,
"status": obj.status,
},
)
)
async def _event(self, event_type: str, obj: CommercialRegistryObject) -> None:
self.db.add(
OutboxEvent(
event_type=event_type,
aggregate_type=f"commercial.{obj.kind}",
aggregate_id=str(obj.id),
tenant_id=obj.tenant_id,
payload={
"registry_object_id": str(obj.id),
"kind": obj.kind,
"stable_code": obj.stable_code,
"version_code": obj.version_code,
"status": obj.status,
},
status=EventStatus.PENDING,
)
)

View File

@ -101,6 +101,9 @@ class CacheClient:
return False return False
async def close(self) -> None: async def close(self) -> None:
if not self._enabled:
self._client = None
return
if self._client is not None: if self._client is not None:
try: try:
await self._client.aclose() await self._client.aclose()

View File

@ -23,6 +23,18 @@ logger = get_logger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("service_starting", extra={"service": settings.service_name}) logger.info("service_starting", extra={"service": settings.service_name})
# Best-effort: ensure commercial meta-registry kinds exist (idempotent).
# Skip in unit tests — discovery_catalog / seed handle kinds there.
if settings.environment != "test":
try:
from app.commercial.service import CommercialRegistryService
from app.core.database import AsyncSessionLocal
async with AsyncSessionLocal() as session:
await CommercialRegistryService(session).ensure_builtin_kinds()
logger.info("commercial_registry_kinds_ready")
except Exception as exc: # noqa: BLE001
logger.warning("commercial_kinds_bootstrap_skipped", extra={"error": str(exc)})
yield yield
await cache.close() await cache.close()
logger.info("service_stopped") logger.info("service_stopped")

View File

@ -4,17 +4,22 @@
همه جدولها را بشناسند. همه جدولها را بشناسند.
""" """
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.commercial import (
CommercialActivationState,
CommercialRegistryKind,
CommercialRegistryObject,
CommercialTenantLicense,
CommercialTenantSubscription,
)
from app.models.domain import Domain from app.models.domain import Domain
from app.models.events import InboxEvent, OutboxEvent from app.models.events import InboxEvent, OutboxEvent
from app.models.membership import TenantMembership from app.models.membership import TenantMembership
from app.models.plan import Feature, Plan, PlanFeature
from app.models.registry import ( from app.models.registry import (
InternalServiceToken, InternalServiceToken,
ModuleRegistry, ModuleRegistry,
ServiceRegistry, ServiceRegistry,
TenantModuleAccess, TenantModuleAccess,
) )
from app.models.subscription import TenantFeatureAccess, TenantSubscription
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.models.user import User from app.models.user import User
@ -23,11 +28,6 @@ __all__ = [
"User", "User",
"TenantMembership", "TenantMembership",
"Domain", "Domain",
"Plan",
"Feature",
"PlanFeature",
"TenantSubscription",
"TenantFeatureAccess",
"ServiceRegistry", "ServiceRegistry",
"ModuleRegistry", "ModuleRegistry",
"TenantModuleAccess", "TenantModuleAccess",
@ -35,4 +35,9 @@ __all__ = [
"OutboxEvent", "OutboxEvent",
"InboxEvent", "InboxEvent",
"AuditLog", "AuditLog",
"CommercialRegistryKind",
"CommercialRegistryObject",
"CommercialTenantSubscription",
"CommercialTenantLicense",
"CommercialActivationState",
] ]

View File

@ -0,0 +1,123 @@
"""Commercial Runtime SQLAlchemy models — universal registry SoT."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import GUID
class CommercialRegistryKind(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Meta-registry of commercial object kinds (unlimited future kinds)."""
__tablename__ = "commercial_registry_kinds"
kind: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
path: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
code_field: Mapped[str] = mapped_column(String(100), nullable=False, default="code")
list_envelope: Mapped[str] = mapped_column(String(100), nullable=False, default="items")
public_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# meta_data avoids reserved name collision
meta_data: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
class CommercialRegistryObject(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Universal commercial registry object (registry_object_id = id)."""
__tablename__ = "commercial_registry_objects"
kind: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(200), nullable=False)
stable_code: Mapped[str] = mapped_column(String(200), nullable=False)
version_code: Mapped[str] = mapped_column(String(64), nullable=False, default="1")
display_name: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="draft", index=True)
visibility: Mapped[str] = mapped_column(String(40), nullable=False, default="public")
# Platform catalogs: tenant_id NULL. Tenant-scoped commercial rows set tenant_id.
tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True, index=True)
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
meta_data: Mapped[dict] = mapped_column("metadata", JSON, nullable=False, default=dict)
created_by: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
updated_by: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
clone_of_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (
UniqueConstraint(
"kind", "stable_code", "version_code", "tenant_id",
name="uq_commercial_obj_kind_code_ver_tenant",
),
Index("ix_commercial_obj_kind_status", "kind", "status"),
Index("ix_commercial_obj_slug", "kind", "slug"),
Index("ix_commercial_obj_search", "kind", "display_name"),
)
class CommercialTenantSubscription(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Tenant subscription instance (commercial lifecycle — not Payment ledger)."""
__tablename__ = "commercial_tenant_subscriptions"
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, index=True)
plan_code: Mapped[str | None] = mapped_column(String(200), nullable=True)
bundle_code: Mapped[str | None] = mapped_column(String(200), nullable=True)
pricing_item_code: Mapped[str | None] = mapped_column(String(200), nullable=True)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="trialing")
trial_ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
current_period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancel_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
meta_data: Mapped[dict] = mapped_column("metadata", JSON, nullable=False, default=dict)
created_by: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (
Index("ix_commercial_sub_tenant_status", "tenant_id", "status"),
)
class CommercialTenantLicense(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""License / entitlement projection for a tenant."""
__tablename__ = "commercial_tenant_licenses"
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, index=True)
license_code: Mapped[str] = mapped_column(String(200), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(300), nullable=True)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="active")
capability_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
product_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
bundle_code: Mapped[str | None] = mapped_column(String(200), nullable=True)
quota: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
meta_data: Mapped[dict] = mapped_column("metadata", JSON, nullable=False, default=dict)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "license_code", name="uq_commercial_license_tenant_code"),
)
class CommercialActivationState(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Tenant activation pipeline progress (event-contracted steps)."""
__tablename__ = "commercial_activation_states"
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, unique=True)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending")
completion_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
steps: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
checklist: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
meta_data: Mapped[dict] = mapped_column("metadata", JSON, nullable=False, default=dict)

View File

@ -43,23 +43,6 @@ class DomainType(str, enum.Enum):
CUSTOM_DOMAIN = "custom_domain" CUSTOM_DOMAIN = "custom_domain"
class SubscriptionStatus(str, enum.Enum):
TRIALING = "trialing"
ACTIVE = "active"
PAST_DUE = "past_due"
CANCELED = "canceled"
EXPIRED = "expired"
class LimitPeriod(str, enum.Enum):
"""دوره سقف مصرف یک قابلیت."""
DAY = "day"
MONTH = "month"
YEAR = "year"
TOTAL = "total" # بدون بازنشانی دوره‌ای
class ServiceStatus(str, enum.Enum): class ServiceStatus(str, enum.Enum):
ACTIVE = "active" ACTIVE = "active"
INACTIVE = "inactive" INACTIVE = "inactive"

View File

@ -1,74 +0,0 @@
"""مدل‌های Plan، Feature و PlanFeature.
Plan: بسته اشتراک.
Feature: یک قابلیت قابل کنترل (مثلاً accounting.invoice.create).
PlanFeature: نگاشت قابلیتها به هر پلن به همراه سقف مصرف.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime
from sqlalchemy import Enum as SAEnum
from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import UUIDPrimaryKeyMixin
from app.models.enums import LimitPeriod
from app.models.types import GUID
class Plan(UUIDPrimaryKeyMixin, Base):
__tablename__ = "plans"
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
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)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (Index("ix_plans_code", "code", unique=True),)
class Feature(UUIDPrimaryKeyMixin, Base):
__tablename__ = "features"
# کلید یکتای قابلیت، مثال: accounting.invoice.create
feature_key: Mapped[str] = mapped_column(String(150), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# سرویسی که این قابلیت به آن تعلق دارد، مثال: accounting، crm.
service_key: Mapped[str] = mapped_column(String(100), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
Index("ix_features_feature_key", "feature_key", unique=True),
Index("ix_features_service_key", "service_key"),
)
class PlanFeature(UUIDPrimaryKeyMixin, Base):
__tablename__ = "plan_features"
plan_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("plans.id", ondelete="CASCADE"), nullable=False
)
feature_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
)
# سقف مصرف؛ None یعنی نامحدود.
limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
limit_period: Mapped[LimitPeriod | None] = mapped_column(
SAEnum(LimitPeriod, name="limit_period"), nullable=True
)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("plan_id", "feature_id", name="uq_plan_feature"),
Index("ix_plan_features_plan_id", "plan_id"),
Index("ix_plan_features_feature_id", "feature_id"),
)

View File

@ -1,66 +0,0 @@
"""مدل‌های TenantSubscription و TenantFeatureAccess."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime
from sqlalchemy import Enum as SAEnum
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import UUIDPrimaryKeyMixin
from app.models.enums import SubscriptionStatus
from app.models.types import GUID
class TenantSubscription(UUIDPrimaryKeyMixin, Base):
__tablename__ = "tenant_subscriptions"
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
plan_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[SubscriptionStatus] = mapped_column(
SAEnum(SubscriptionStatus, name="subscription_status"),
default=SubscriptionStatus.ACTIVE,
nullable=False,
)
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
trial_ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (
Index("ix_tenant_subscriptions_tenant_id", "tenant_id"),
Index("ix_tenant_subscriptions_status", "status"),
)
class TenantFeatureAccess(UUIDPrimaryKeyMixin, Base):
"""دسترسی سفارشی/override یک tenant به یک قابلیت (فراتر از پلن)."""
__tablename__ = "tenant_feature_access"
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
feature_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# سقف سفارشی؛ در صورت وجود، جایگزین سقف پلن می‌شود. None یعنی نامحدود.
custom_limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
used_value: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "feature_id", name="uq_tenant_feature_access"),
Index("ix_tenant_feature_access_tenant_id", "tenant_id"),
Index("ix_tenant_feature_access_feature_id", "feature_id"),
)

View File

@ -1,57 +0,0 @@
"""Repository مربوط به Plan، Feature و PlanFeature."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.plan import Feature, Plan, PlanFeature
from app.repositories.base import BaseRepository
class PlanRepository(BaseRepository[Plan]):
model = Plan
async def get_by_code(self, code: str) -> Plan | None:
stmt = select(Plan).where(Plan.code == code)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def code_exists(self, code: str) -> bool:
stmt = select(func.count()).select_from(Plan).where(Plan.code == code)
result = await self.session.execute(stmt)
return int(result.scalar_one()) > 0
class FeatureRepository(BaseRepository[Feature]):
model = Feature
async def get_by_key(self, feature_key: str) -> Feature | None:
stmt = select(Feature).where(Feature.feature_key == feature_key)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def key_exists(self, feature_key: str) -> bool:
stmt = select(func.count()).select_from(Feature).where(
Feature.feature_key == feature_key
)
result = await self.session.execute(stmt)
return int(result.scalar_one()) > 0
class PlanFeatureRepository(BaseRepository[PlanFeature]):
model = PlanFeature
async def get(self, plan_id: UUID, feature_id: UUID) -> PlanFeature | None:
stmt = select(PlanFeature).where(
PlanFeature.plan_id == plan_id,
PlanFeature.feature_id == feature_id,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_plan(self, plan_id: UUID) -> Sequence[PlanFeature]:
stmt = select(PlanFeature).where(PlanFeature.plan_id == plan_id)
result = await self.session.execute(stmt)
return result.scalars().all()

View File

@ -1,39 +0,0 @@
"""Repository مربوط به اشتراک و دسترسی قابلیت tenant."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from app.models.subscription import TenantFeatureAccess, TenantSubscription
from app.repositories.base import BaseRepository
class SubscriptionRepository(BaseRepository[TenantSubscription]):
model = TenantSubscription
async def get_active_by_tenant(self, tenant_id: UUID) -> TenantSubscription | None:
"""آخرین اشتراک tenant را برمی‌گرداند (جدیدترین بر اساس created_at)."""
stmt = (
select(TenantSubscription)
.where(TenantSubscription.tenant_id == tenant_id)
.order_by(TenantSubscription.created_at.desc())
.limit(1)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class TenantFeatureAccessRepository(BaseRepository[TenantFeatureAccess]):
model = TenantFeatureAccess
async def get(
self, tenant_id: UUID, feature_id: UUID
) -> TenantFeatureAccess | None:
stmt = select(TenantFeatureAccess).where(
TenantFeatureAccess.tenant_id == tenant_id,
TenantFeatureAccess.feature_id == feature_id,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

View File

@ -1,62 +0,0 @@
"""اسکیماهای Plan، Feature و PlanFeature."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.models.enums import LimitPeriod
from app.schemas.common import ORMModel
# ---- Plan ----
class PlanCreate(BaseModel):
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
is_active: bool = True
class PlanRead(ORMModel):
id: UUID
code: str
name: str
description: str | None
is_active: bool
created_at: datetime
# ---- Feature ----
class FeatureCreate(BaseModel):
feature_key: str = Field(..., min_length=1, max_length=150)
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
service_key: str = Field(..., min_length=1, max_length=100)
is_active: bool = True
class FeatureRead(ORMModel):
id: UUID
feature_key: str
name: str
description: str | None
service_key: str
is_active: bool
# ---- PlanFeature ----
class PlanFeatureCreate(BaseModel):
feature_id: UUID
limit_value: int | None = Field(default=None, ge=0)
limit_period: LimitPeriod | None = None
is_enabled: bool = True
class PlanFeatureRead(ORMModel):
id: UUID
plan_id: UUID
feature_id: UUID
limit_value: int | None
limit_period: LimitPeriod | None
is_enabled: bool

View File

@ -1,40 +0,0 @@
"""اسکیماهای اشتراک و بررسی دسترسی قابلیت."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
from app.models.enums import SubscriptionStatus
from app.schemas.common import ORMModel
class SubscriptionCreate(BaseModel):
plan_id: UUID
status: SubscriptionStatus = SubscriptionStatus.ACTIVE
starts_at: datetime | None = None
ends_at: datetime | None = None
trial_ends_at: datetime | None = None
class SubscriptionRead(ORMModel):
id: UUID
tenant_id: UUID
plan_id: UUID
status: SubscriptionStatus
starts_at: datetime | None
ends_at: datetime | None
trial_ends_at: datetime | None
created_at: datetime
class FeatureCheckRequest(BaseModel):
feature_key: str
class FeatureCheckResponse(BaseModel):
tenant_id: UUID
feature_key: str
has_access: bool
reason: str | None = None

View File

@ -1,117 +0,0 @@
"""سرویس Entitlement: بررسی دسترسی tenant به یک قابلیت.
قوانین بررسی (به ترتیب):
1. ابتدا Redis cache بررسی میشود.
2. اگر در cache نبود، از دیتابیس محاسبه میشود.
3. نتیجه با TTL مشخص در Redis ذخیره میشود.
4. اگر اشتراک tenant فعال نبود دسترسی false.
5. اگر قابلیت در پلن فعال نبود false.
6. اگر دسترسی سفارشی (custom access) وجود داشت، آن اولویت دارد.
"""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import cache, feature_access_key
from app.core.config import settings
from app.models.enums import SubscriptionStatus, TenantStatus
from app.repositories.plan import FeatureRepository, PlanFeatureRepository
from app.repositories.subscription import (
SubscriptionRepository,
TenantFeatureAccessRepository,
)
from app.repositories.tenant import TenantRepository
_ALLOWED_SUBSCRIPTION_STATUSES = {
SubscriptionStatus.ACTIVE,
SubscriptionStatus.TRIALING,
}
@dataclass
class AccessResult:
has_access: bool
reason: str
class EntitlementService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.tenant_repo = TenantRepository(session)
self.feature_repo = FeatureRepository(session)
self.plan_feature_repo = PlanFeatureRepository(session)
self.subscription_repo = SubscriptionRepository(session)
self.feature_access_repo = TenantFeatureAccessRepository(session)
async def check_feature_access(
self, tenant_id: UUID, feature_key: str
) -> bool:
"""بررسی ساده دسترسی؛ فقط مقدار boolean برمی‌گرداند."""
result = await self.evaluate(tenant_id, feature_key)
return result.has_access
async def evaluate(self, tenant_id: UUID, feature_key: str) -> AccessResult:
"""بررسی کامل دسترسی همراه با دلیل، با استفاده از cache."""
cache_key = feature_access_key(str(tenant_id), feature_key)
cached = await cache.get(cache_key)
if cached is not None:
return AccessResult(has_access=cached == "1", reason="cache")
result = await self._evaluate_from_db(tenant_id, feature_key)
await cache.set(
cache_key,
"1" if result.has_access else "0",
ttl=settings.feature_access_cache_ttl,
)
return result
async def _evaluate_from_db(
self, tenant_id: UUID, feature_key: str
) -> AccessResult:
# ۱) tenant باید وجود داشته و فعال باشد.
tenant = await self.tenant_repo.get(tenant_id)
if tenant is None:
return AccessResult(False, "tenant_not_found")
if tenant.status != TenantStatus.ACTIVE:
return AccessResult(False, "tenant_inactive")
# ۲) قابلیت باید وجود داشته و فعال باشد.
feature = await self.feature_repo.get_by_key(feature_key)
if feature is None:
return AccessResult(False, "feature_not_found")
if not feature.is_active:
return AccessResult(False, "feature_inactive")
# ۳) بررسی override سفارشی tenant (بالاترین اولویت).
custom = await self.feature_access_repo.get(tenant_id, feature.id)
if custom is not None:
if not custom.is_enabled:
return AccessResult(False, "custom_disabled")
# اگر سقف سفارشی تعریف شده و مصرف از آن گذشته باشد → عدم دسترسی.
if (
custom.custom_limit_value is not None
and custom.used_value >= custom.custom_limit_value
):
return AccessResult(False, "custom_limit_exceeded")
return AccessResult(True, "custom_enabled")
# ۴) اشتراک tenant باید فعال باشد.
subscription = await self.subscription_repo.get_active_by_tenant(tenant_id)
if subscription is None:
return AccessResult(False, "no_subscription")
if subscription.status not in _ALLOWED_SUBSCRIPTION_STATUSES:
return AccessResult(False, "subscription_inactive")
# ۵) قابلیت باید در پلن اشتراک فعال باشد.
plan_feature = await self.plan_feature_repo.get(
subscription.plan_id, feature.id
)
if plan_feature is None or not plan_feature.is_enabled:
return AccessResult(False, "feature_not_in_plan")
return AccessResult(True, "plan_enabled")

View File

@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.models.domain import Domain from app.models.domain import Domain
from app.models.enums import DomainType, DomainVerificationStatus, SubscriptionStatus, TenantStatus from app.models.enums import DomainType, DomainVerificationStatus, TenantStatus
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.models.user import User from app.models.user import User
from app.repositories.tenant import DomainRepository, TenantRepository from app.repositories.tenant import DomainRepository, TenantRepository
@ -23,13 +23,10 @@ from app.schemas.onboarding import (
OnboardingDomainUpdate, OnboardingDomainUpdate,
OnboardingTenantCreate, OnboardingTenantCreate,
) )
from app.schemas.subscription import SubscriptionCreate
from app.schemas.tenant import TenantCreate from app.schemas.tenant import TenantCreate
from app.services.event_service import EventService from app.services.event_service import EventService
from app.services.membership_service import MembershipService from app.services.membership_service import MembershipService
from app.services.plan_service import PlanService
from app.services.ssl_provision import enqueue_domain_ssl from app.services.ssl_provision import enqueue_domain_ssl
from app.services.subscription_service import SubscriptionService
from app.services.tenant_service import TenantService from app.services.tenant_service import TenantService
from shared.events import CoreEventType from shared.events import CoreEventType
from shared.exceptions import ConflictError, ValidationAppError from shared.exceptions import ConflictError, ValidationAppError
@ -42,8 +39,6 @@ class OnboardingService:
self.tenant_repo = TenantRepository(session) self.tenant_repo = TenantRepository(session)
self.domain_repo = DomainRepository(session) self.domain_repo = DomainRepository(session)
self.membership_service = MembershipService(session) self.membership_service = MembershipService(session)
self.plan_service = PlanService(session)
self.subscription_service = SubscriptionService(session)
self.events = EventService(session) self.events = EventService(session)
async def create_tenant(self, core_user: User, data: OnboardingTenantCreate) -> Tenant: async def create_tenant(self, core_user: User, data: OnboardingTenantCreate) -> Tenant:
@ -64,11 +59,9 @@ class OnboardingService:
await self.membership_service.create_owner_membership(tenant.id, core_user.id) await self.membership_service.create_owner_membership(tenant.id, core_user.id)
plan = await self.plan_service.ensure_default_plan() # Commercial Runtime owns plans/subscriptions — FE activates via
await self.subscription_service.create( # POST /api/v1/commercial/subscriptions (bundle/plan/pricing from registry).
tenant.id, # No legacy FREE/STARTER plan assignment.
SubscriptionCreate(plan_id=plan.id, status=SubscriptionStatus.ACTIVE),
)
await self._create_default_subdomain(tenant) await self._create_default_subdomain(tenant)

View File

@ -1,127 +0,0 @@
"""سرویس مدیریت پلن‌ها، قابلیت‌ها و اتصال آن‌ها."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.plan import Feature, Plan, PlanFeature
from app.repositories.plan import (
FeatureRepository,
PlanFeatureRepository,
PlanRepository,
)
from app.schemas.plan import FeatureCreate, PlanCreate, PlanFeatureCreate
from shared.exceptions import ConflictError, NotFoundError
class PlanService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.plan_repo = PlanRepository(session)
self.feature_repo = FeatureRepository(session)
self.plan_feature_repo = PlanFeatureRepository(session)
# ---- Plans ----
async def create_plan(self, data: PlanCreate) -> Plan:
if await self.plan_repo.code_exists(data.code):
raise ConflictError(f"کد پلن تکراری است: {data.code}", error_code="plan_code_taken")
plan = Plan(
code=data.code,
name=data.name,
description=data.description,
is_active=data.is_active,
)
await self.plan_repo.add(plan)
await self.session.commit()
await self.session.refresh(plan)
return plan
async def list_plans(self, *, offset: int, limit: int) -> tuple[Sequence[Plan], int]:
items = await self.plan_repo.list(offset=offset, limit=limit)
total = await self.plan_repo.count()
return items, total
async def get_plan(self, plan_id: UUID) -> Plan:
plan = await self.plan_repo.get(plan_id)
if plan is None:
raise NotFoundError(f"پلن یافت نشد: {plan_id}")
return plan
async def ensure_default_plan(
self, *, code: str = "FREE", name: str = "رایگان"
) -> Plan:
"""پلن پیش‌فرض را برمی‌گرداند؛ در صورت نبود، آن را می‌سازد (idempotent).
برای اطمینان از وجود پلن پیشفرض هنگام onboarding استفاده میشود؛ در
کنار seed مربوط به migration (که در محیط production زودتر اجرا میشود).
"""
plan = await self.plan_repo.get_by_code(code)
if plan is not None:
return plan
plan = Plan(
code=code,
name=name,
description="پلن پیش‌فرض تخصیص‌یافته در زمان onboarding",
is_active=True,
)
await self.plan_repo.add(plan)
await self.session.commit()
await self.session.refresh(plan)
return plan
# ---- Features ----
async def create_feature(self, data: FeatureCreate) -> Feature:
if await self.feature_repo.key_exists(data.feature_key):
raise ConflictError(
f"feature_key تکراری است: {data.feature_key}",
error_code="feature_key_taken",
)
feature = Feature(
feature_key=data.feature_key,
name=data.name,
description=data.description,
service_key=data.service_key,
is_active=data.is_active,
)
await self.feature_repo.add(feature)
await self.session.commit()
await self.session.refresh(feature)
return feature
async def list_features(self, *, offset: int, limit: int) -> tuple[Sequence[Feature], int]:
items = await self.feature_repo.list(offset=offset, limit=limit)
total = await self.feature_repo.count()
return items, total
# ---- Plan <-> Feature ----
async def attach_feature(self, plan_id: UUID, data: PlanFeatureCreate) -> PlanFeature:
plan = await self.plan_repo.get(plan_id)
if plan is None:
raise NotFoundError(f"پلن یافت نشد: {plan_id}")
feature = await self.feature_repo.get(data.feature_id)
if feature is None:
raise NotFoundError(f"قابلیت یافت نشد: {data.feature_id}")
existing = await self.plan_feature_repo.get(plan_id, data.feature_id)
if existing is not None:
# به‌روزرسانی تنظیمات موجود به‌جای ایجاد رکورد تکراری.
existing.limit_value = data.limit_value
existing.limit_period = data.limit_period
existing.is_enabled = data.is_enabled
await self.session.commit()
await self.session.refresh(existing)
return existing
plan_feature = PlanFeature(
plan_id=plan_id,
feature_id=data.feature_id,
limit_value=data.limit_value,
limit_period=data.limit_period,
is_enabled=data.is_enabled,
)
await self.plan_feature_repo.add(plan_feature)
await self.session.commit()
await self.session.refresh(plan_feature)
return plan_feature

View File

@ -1,62 +0,0 @@
"""سرویس مدیریت اشتراک tenant."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import cache
from app.models.subscription import TenantSubscription
from app.repositories.plan import PlanRepository
from app.repositories.subscription import SubscriptionRepository
from app.repositories.tenant import TenantRepository
from app.schemas.subscription import SubscriptionCreate
from app.services.event_service import EventService
from shared.events import CoreEventType
from shared.exceptions import NotFoundError
class SubscriptionService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = SubscriptionRepository(session)
self.tenant_repo = TenantRepository(session)
self.plan_repo = PlanRepository(session)
self.events = EventService(session)
async def create(self, tenant_id: UUID, data: SubscriptionCreate) -> TenantSubscription:
tenant = await self.tenant_repo.get(tenant_id)
if tenant is None:
raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
plan = await self.plan_repo.get(data.plan_id)
if plan is None:
raise NotFoundError(f"پلن یافت نشد: {data.plan_id}")
subscription = TenantSubscription(
tenant_id=tenant_id,
plan_id=data.plan_id,
status=data.status,
starts_at=data.starts_at,
ends_at=data.ends_at,
trial_ends_at=data.trial_ends_at,
)
await self.repo.add(subscription)
await self.events.record(
event_type=CoreEventType.SUBSCRIPTION_CREATED.value,
aggregate_type="subscription",
aggregate_id=str(subscription.id),
tenant_id=tenant_id,
payload={"plan_id": str(data.plan_id), "status": data.status.value},
)
await self.session.commit()
await self.session.refresh(subscription)
# با تغییر اشتراک، cache دسترسی قابلیت‌ها باید باطل شود.
await cache.delete_pattern(f"feature_access:{tenant_id}:*")
await cache.delete(f"tenant:{tenant_id}:features")
return subscription
async def get_current(self, tenant_id: UUID) -> TenantSubscription:
subscription = await self.repo.get_active_by_tenant(tenant_id)
if subscription is None:
raise NotFoundError(f"اشتراکی برای tenant یافت نشد: {tenant_id}")
return subscription

View File

@ -1,15 +1,15 @@
"""ساخت TenantContext کامل (tenant + پلن + دامنه‌ها + نقش کاربر) برای frontend.""" """ساخت TenantContext کامل (tenant + commercial plan + دامنه‌ها + نقش کاربر) برای frontend."""
from __future__ import annotations from __future__ import annotations
from uuid import UUID from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.commercial import CommercialTenantSubscription
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.models.user import User from app.models.user import User
from app.repositories.membership import TenantMembershipRepository from app.repositories.membership import TenantMembershipRepository
from app.repositories.plan import PlanRepository
from app.repositories.subscription import SubscriptionRepository
from app.repositories.tenant import DomainRepository, TenantRepository from app.repositories.tenant import DomainRepository, TenantRepository
from app.schemas.domain import DomainRead from app.schemas.domain import DomainRead
from app.schemas.onboarding import MembershipSummary, TenantContextRead from app.schemas.onboarding import MembershipSummary, TenantContextRead
@ -23,8 +23,6 @@ class TenantContextService:
self.tenant_repo = TenantRepository(session) self.tenant_repo = TenantRepository(session)
self.domain_repo = DomainRepository(session) self.domain_repo = DomainRepository(session)
self.membership_repo = TenantMembershipRepository(session) self.membership_repo = TenantMembershipRepository(session)
self.subscription_repo = SubscriptionRepository(session)
self.plan_repo = PlanRepository(session)
async def build(self, tenant: Tenant, core_user: User | None) -> TenantContextRead: async def build(self, tenant: Tenant, core_user: User | None) -> TenantContextRead:
membership = None membership = None
@ -38,16 +36,32 @@ class TenantContextService:
if primary is None and domains: if primary is None and domains:
primary = domains[0] primary = domains[0]
subscription = await self.subscription_repo.get_active_by_tenant(tenant.id) commercial_sub = (
plan = await self.plan_repo.get(subscription.plan_id) if subscription else None await self.session.execute(
select(CommercialTenantSubscription)
.where(CommercialTenantSubscription.tenant_id == tenant.id)
.order_by(CommercialTenantSubscription.created_at.desc())
)
).scalars().first()
plan_code = commercial_sub.plan_code if commercial_sub else None
if commercial_sub:
plan_name = (
commercial_sub.plan_code
or commercial_sub.bundle_code
or commercial_sub.pricing_item_code
)
else:
plan_name = None
subscription_status = commercial_sub.status if commercial_sub else None
return TenantContextRead( return TenantContextRead(
tenant=TenantRead.model_validate(tenant), tenant=TenantRead.model_validate(tenant),
role=membership.role if membership else None, role=membership.role if membership else None,
is_owner=bool(membership.is_owner) if membership else False, is_owner=bool(membership.is_owner) if membership else False,
plan_code=plan.code if plan else None, plan_code=plan_code,
plan_name=plan.name if plan else None, plan_name=plan_name,
subscription_status=subscription.status.value if subscription else None, subscription_status=subscription_status,
domains=[DomainRead.model_validate(d) for d in domains], domains=[DomainRead.model_validate(d) for d in domains],
primary_domain=primary.domain if primary else None, primary_domain=primary.domain if primary else None,
) )

View File

@ -7,6 +7,7 @@ engine و session با SQLite ساخته شوند.
from __future__ import annotations from __future__ import annotations
import os import os
from contextlib import asynccontextmanager
# --- تنظیم محیط تست پیش از هر import از برنامه --- # --- تنظیم محیط تست پیش از هر import از برنامه ---
os.environ["ENVIRONMENT"] = "test" os.environ["ENVIRONMENT"] = "test"
@ -29,6 +30,16 @@ from app.main import app # noqa: E402
cache.disable() cache.disable()
@asynccontextmanager
async def _test_lifespan(_app):
"""No-op lifespan so ASGITransport teardown does not hang."""
yield
# Override FastAPI lifespan for in-process tests (httpx 0.27 has no lifespan=off).
app.router.lifespan_context = _test_lifespan
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_setup(): async def db_setup():
"""ساخت و پاک‌سازی اسکیمای دیتابیس برای هر تست (ایزوله).""" """ساخت و پاک‌سازی اسکیمای دیتابیس برای هر تست (ایزوله)."""
@ -37,6 +48,8 @@ async def db_setup():
yield yield
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
# Prevent aiosqlite connection hang on Windows teardown
await engine.dispose()
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -45,6 +58,7 @@ async def client(db_setup):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac: async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
yield ac yield ac
await engine.dispose()
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -52,3 +66,4 @@ async def session(db_setup):
"""یک AsyncSession برای تست‌های سطح سرویس/repository.""" """یک AsyncSession برای تست‌های سطح سرویس/repository."""
async with AsyncSessionLocal() as s: async with AsyncSessionLocal() as s:
yield s yield s
await engine.dispose()

View File

@ -0,0 +1,343 @@
"""Commercial Runtime Backend — registry, discovery, engines, resolvers."""
from __future__ import annotations
import uuid
import pytest
@pytest.mark.asyncio
async def test_commercial_health(client):
resp = await client.get("/api/v1/commercial/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["domain"] == "commercial-runtime"
@pytest.mark.asyncio
async def test_discovery_catalog_lists_builtin_kinds(client):
resp = await client.get("/api/v1/commercial/catalog")
assert resp.status_code == 200
body = resp.json()
assert body["contract_version"] == "commercial.v1.2"
kinds = {r["kind"] for r in body["registries"]}
assert "products" in kinds
assert "bundles" in kinds
assert "capabilities" in kinds
assert "notifications" in kinds
assert "business_types" in kinds
# FE must discover via href — every entry has href
for r in body["registries"]:
assert r["href"].startswith("/api/v1/commercial/")
assert r["generic_href"].startswith("/api/v1/commercial/registries/")
@pytest.mark.asyncio
async def test_registry_crud_lifecycle_and_search(client):
create = await client.post(
"/api/v1/commercial/products",
json={
"product_code": "marketplace",
"display_name": "Torbat Marketplace",
"description": "Future product via registry only",
"default_route": "/marketplace",
"status": "draft",
"visibility": "public",
"category": "platform",
},
)
assert create.status_code == 201, create.text
obj = create.json()
oid = obj["registry_object_id"]
assert obj["product_code"] == "marketplace"
assert obj["status"] == "draft"
assert obj["version"]
# draft not in public published list
listed = await client.get("/api/v1/commercial/products", params={"status": "published"})
assert listed.status_code == 200
assert all(i["product_code"] != "marketplace" for i in listed.json()["products"])
pub = await client.post(f"/api/v1/commercial/products/{oid}/publish")
assert pub.status_code == 200
assert pub.json()["status"] == "published"
listed2 = await client.get("/api/v1/commercial/products", params={"q": "Marketplace"})
assert listed2.status_code == 200
codes = [i["product_code"] for i in listed2.json()["items"]]
assert "marketplace" in codes
patched = await client.patch(
f"/api/v1/commercial/products/{oid}",
json={"display_name": "Torbat Marketplace Pro", "trial_eligible": True},
)
assert patched.status_code == 200
assert patched.json()["display_name"] == "Torbat Marketplace Pro"
assert patched.json()["trial_eligible"] is True
cloned = await client.post(f"/api/v1/commercial/products/{oid}/clone")
assert cloned.status_code == 200
assert cloned.json()["status"] == "draft"
assert cloned.json()["clone_of_id"] == oid
archived = await client.post(f"/api/v1/commercial/products/{oid}/archive")
assert archived.status_code == 200
assert archived.json()["status"] == "archived"
deleted = await client.delete(f"/api/v1/commercial/products/{oid}")
assert deleted.status_code == 200
assert deleted.json()["is_deleted"] is True
restored = await client.post(f"/api/v1/commercial/products/{oid}/restore")
assert restored.status_code == 200
assert restored.json()["status"] == "draft"
assert restored.json()["is_deleted"] is False
@pytest.mark.asyncio
async def test_unlimited_future_kind_via_admin(client):
kind = await client.post(
"/api/v1/commercial/admin/kinds",
json={
"kind": "qr_products",
"path": "qr-products",
"display_name": "QR Products",
"code_field": "qr_product_code",
"list_envelope": "items",
},
)
assert kind.status_code == 201, kind.text
assert kind.json()["kind"] == "qr_products"
catalog = await client.get("/api/v1/commercial/catalog")
kinds = {r["kind"] for r in catalog.json()["registries"]}
assert "qr_products" in kinds
created = await client.post(
"/api/v1/commercial/registries/qr_products",
json={
"qr_product_code": "qr.menu.v1",
"display_name": "QR Menu",
"status": "published",
},
)
assert created.status_code == 201, created.text
listed = await client.get("/api/v1/commercial/registries/qr_products")
assert listed.status_code == 200
assert any(i["qr_product_code"] == "qr.menu.v1" for i in listed.json()["items"])
@pytest.mark.asyncio
async def test_bundle_plan_license_resolvers(client):
await client.post(
"/api/v1/commercial/products",
json={
"product_code": "hospitality",
"display_name": "Torbat Food",
"status": "published",
"default_route": "/hospitality",
},
)
await client.post(
"/api/v1/commercial/capabilities",
json={
"capability_code": "hospitality.digital_menu",
"display_name": "Digital Menu",
"status": "published",
"service_key": "hospitality",
},
)
await client.post(
"/api/v1/commercial/pricing",
json={
"pricing_item_code": "price.restaurant.starter.monthly",
"display_name": "Restaurant Starter Monthly",
"status": "published",
"currency": "IRR",
"interval": "monthly",
"trial_eligible": True,
"trial_days": 14,
"bundle_code": "bundle.restaurant.starter",
},
)
await client.post(
"/api/v1/commercial/plans",
json={
"plan_code": "plan.restaurant.starter.monthly",
"display_name": "Restaurant Starter",
"status": "published",
"pricing_item_code": "price.restaurant.starter.monthly",
"bundle_code": "bundle.restaurant.starter",
"trial_eligible": True,
"trial_days": 14,
"limits": {"sites": {"limit": 1, "used": 0}},
},
)
await client.post(
"/api/v1/commercial/bundles",
json={
"bundle_code": "bundle.restaurant.starter",
"display_name": "Restaurant Starter",
"status": "published",
"business_types": ["restaurant"],
"product_codes": ["hospitality"],
"capability_codes": ["hospitality.digital_menu"],
"pricing_refs": [{"pricing_item_code": "price.restaurant.starter.monthly"}],
},
)
resolved = await client.get("/api/v1/commercial/bundles/bundle.restaurant.starter/resolve")
assert resolved.status_code == 200, resolved.text
body = resolved.json()
assert body["bundle_code"] == "bundle.restaurant.starter"
assert any(p["product_code"] == "hospitality" for p in body["products"])
assert any(c["capability_code"] == "hospitality.digital_menu" for c in body["capabilities"])
plan = await client.get("/api/v1/commercial/plans/plan.restaurant.starter.monthly/resolve")
assert plan.status_code == 200
assert plan.json()["trial"]["days"] == 14
assert plan.json()["renewal"]["interval"] == "monthly" or plan.json()["price"]
tenant_id = str(uuid.uuid4())
activated = await client.post(
"/api/v1/commercial/subscriptions",
json={
"tenant_id": tenant_id,
"bundle_code": "bundle.restaurant.starter",
"plan_code": "plan.restaurant.starter.monthly",
"status": "trialing",
},
)
assert activated.status_code == 200, activated.text
assert activated.json()["subscription"]["status"] == "trialing"
assert "hospitality.digital_menu" in activated.json()["license"]["capability_codes"]
ent = await client.post(
"/api/v1/commercial/entitlements/check",
json={"tenant_id": tenant_id, "feature_key": "hospitality.digital_menu"},
)
assert ent.status_code == 200
assert ent.json()["has_access"] is True
denied = await client.post(
"/api/v1/commercial/entitlements/check",
json={"tenant_id": tenant_id, "feature_key": "marketplace.dispatch"},
)
assert denied.status_code == 200
assert denied.json()["has_access"] is False
licenses = await client.get("/api/v1/commercial/licenses", params={"tenant_id": tenant_id})
assert licenses.status_code == 200
assert len(licenses.json()["items"]) >= 1
@pytest.mark.asyncio
async def test_recommendation_and_assessment_engines(client):
await client.post(
"/api/v1/commercial/recommendation-rules",
json={
"rule_id": "r.restaurant.base",
"display_name": "Restaurant base",
"status": "published",
"priority": 100,
"active": True,
"match": {"business_type_code": "restaurant"},
"emit": {
"bundle_code": "bundle.restaurant.starter",
"score": 100,
"reason": "نوع رستوران",
"products": ["hospitality", "payment"],
"plans": ["plan.restaurant.starter.monthly"],
"automation_packs": ["auto.hospitality.starter"],
},
},
)
await client.post(
"/api/v1/commercial/assessment-schemas",
json={
"schema_code": "default.business.assessment",
"display_name": "ارزیابی کسب‌وکار",
"status": "published",
"questions": [
{
"question_id": "business_type_code",
"type": "select",
"label": "نوع کسب‌وکار",
"required": True,
"options_source": "business_types",
}
],
},
)
schema = await client.get("/api/v1/commercial/assessment/schema")
assert schema.status_code == 200
assert schema.json()["source"] == "registry"
assert schema.json()["questions"]
reco = await client.post(
"/api/v1/commercial/recommendations",
json={"business_type_code": "restaurant", "company_size": "small"},
)
assert reco.status_code == 200, reco.text
body = reco.json()
assert any(b["code"] == "bundle.restaurant.starter" for b in body["recommended_bundles"])
assert any(p["code"] == "hospitality" for p in body["recommended_products"])
assert any(p["code"] == "plan.restaurant.starter.monthly" for p in body["recommended_plans"])
assert body["rule_trace"]
assert "r.restaurant.base" in body["rule_trace"][0]
@pytest.mark.asyncio
async def test_openapi_includes_commercial_paths(client):
resp = await client.get("/openapi.json")
assert resp.status_code == 200
paths = resp.json()["paths"]
assert "/api/v1/commercial/catalog" in paths
assert "/api/v1/commercial/products" in paths
assert "/api/v1/commercial/recommendations" in paths
assert "/api/v1/commercial/bundles/{bundle_code}/resolve" in paths
assert "/api/v1/commercial/registries/{kind}" in paths
assert "/api/v1/commercial/admin/kinds" in paths
@pytest.mark.asyncio
async def test_notifications_registry(client):
created = await client.post(
"/api/v1/commercial/notifications",
json={
"notification_code": "notify.welcome.email",
"display_name": "Welcome email",
"status": "published",
"channel": "email",
"subject": "خوش آمدید",
"body_template": "سلام {{name}}",
},
)
assert created.status_code == 201
listed = await client.get("/api/v1/commercial/notifications")
assert listed.status_code == 200
assert any(i["notification_code"] == "notify.welcome.email" for i in listed.json()["items"])
@pytest.mark.asyncio
async def test_policy_evaluate(client):
await client.post(
"/api/v1/commercial/policies",
json={
"policy_code": "domain.custom",
"display_name": "Domain",
"status": "published",
"custom_domain_allowed": True,
"subdomain_only": False,
"target": "domain",
"reason": "allowed",
},
)
resp = await client.post(
"/api/v1/commercial/policies/evaluate",
json={"policy_code": "domain.custom", "target": "domain"},
)
assert resp.status_code == 200
assert resp.json()["custom_domain_allowed"] is True

View File

@ -1,107 +0,0 @@
"""تست‌های قابلیت‌ها و بررسی دسترسی (Entitlement)."""
from __future__ import annotations
async def _setup_tenant_with_plan(client, *, slug, feature_key, attach=True, active=True):
"""ساخت tenant، پلن، قابلیت، اتصال و اشتراک برای سناریوی تست."""
tenant_id = (
await client.post("/api/v1/tenants", json={"name": "T", "slug": slug})
).json()["id"]
plan_id = (
await client.post(
"/api/v1/plans", json={"code": f"plan-{slug}", "name": "Plan"}
)
).json()["id"]
feature_id = (
await client.post(
"/api/v1/features",
json={
"feature_key": feature_key,
"name": "Feature",
"service_key": feature_key.split(".")[0],
},
)
).json()["id"]
if attach:
await client.post(
f"/api/v1/plans/{plan_id}/features",
json={"feature_id": feature_id, "is_enabled": True},
)
await client.post(
f"/api/v1/tenants/{tenant_id}/subscription",
json={"plan_id": plan_id, "status": "active" if active else "expired"},
)
return tenant_id
async def test_create_feature(client):
resp = await client.post(
"/api/v1/features",
json={
"feature_key": "accounting.invoice.create",
"name": "ساخت فاکتور",
"service_key": "accounting",
},
)
assert resp.status_code == 201
assert resp.json()["feature_key"] == "accounting.invoice.create"
async def test_feature_key_unique(client):
payload = {
"feature_key": "crm.lead.create",
"name": "Lead",
"service_key": "crm",
}
first = await client.post("/api/v1/features", json=payload)
assert first.status_code == 201
second = await client.post("/api/v1/features", json=payload)
assert second.status_code == 409
async def test_check_feature_access_granted(client):
tenant_id = await _setup_tenant_with_plan(
client, slug="grant", feature_key="ecommerce.product.create"
)
resp = await client.post(
f"/api/v1/tenants/{tenant_id}/features/check",
json={"feature_key": "ecommerce.product.create"},
)
assert resp.status_code == 200
body = resp.json()
assert body["has_access"] is True
assert body["reason"] == "plan_enabled"
async def test_check_feature_access_denied_when_not_in_plan(client):
tenant_id = await _setup_tenant_with_plan(
client,
slug="denyplan",
feature_key="live_chat.widget.enable",
attach=False,
)
resp = await client.post(
f"/api/v1/tenants/{tenant_id}/features/check",
json={"feature_key": "live_chat.widget.enable"},
)
assert resp.status_code == 200
assert resp.json()["has_access"] is False
async def test_check_feature_access_denied_when_subscription_inactive(client):
tenant_id = await _setup_tenant_with_plan(
client,
slug="expired",
feature_key="ai_assistant.chat.reply",
active=False,
)
resp = await client.post(
f"/api/v1/tenants/{tenant_id}/features/check",
json={"feature_key": "ai_assistant.chat.reply"},
)
assert resp.status_code == 200
assert resp.json()["has_access"] is False

View File

@ -29,6 +29,9 @@ pyjwt[crypto]==2.8.0
# ---- کتابخانه مشترک پلتفرم ---- # ---- کتابخانه مشترک پلتفرم ----
-e ../shared-lib -e ../shared-lib
# ---- Catalog seed (YAML) ----
PyYAML==6.0.1
# ---- Testing ---- # ---- Testing ----
pytest==8.2.2 pytest==8.2.2
pytest-asyncio==0.23.7 pytest-asyncio==0.23.7

View File

@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""Seed Commercial Runtime registries from docs/reference catalogs (idempotent)."""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# Allow running from the repo checkout or from the mounted /app directory in Docker.
SCRIPT_PATH = Path(__file__).resolve()
CORE_SERVICE_ROOT = SCRIPT_PATH.parents[1]
ROOT = next((parent for parent in SCRIPT_PATH.parents if (parent / "docs").exists()), CORE_SERVICE_ROOT)
sys.path.insert(0, str(CORE_SERVICE_ROOT))
from app.commercial.kinds import BUILTIN_REGISTRY_KINDS
from app.commercial.service import CommercialRegistryService
from app.core.config import settings
from app.models.commercial import CommercialRegistryKind, CommercialRegistryObject
async def ensure_kinds(session: AsyncSession) -> None:
svc = CommercialRegistryService(session)
for i, spec in enumerate(BUILTIN_REGISTRY_KINDS):
await svc.repo.upsert_kind(
CommercialRegistryKind(
kind=spec.kind,
path=spec.path,
display_name=spec.display_name,
description=spec.description,
code_field=spec.code_field,
list_envelope=spec.list_envelope,
public_read=spec.public_read,
enabled=True,
sort_order=i * 10,
)
)
await session.commit()
async def upsert_object(
session: AsyncSession,
*,
kind: str,
code: str,
display_name: str,
payload: dict,
description: str | None = None,
version_code: str = "2026.07.1",
) -> bool:
existing = (
await session.execute(
select(CommercialRegistryObject).where(
CommercialRegistryObject.kind == kind,
CommercialRegistryObject.stable_code == code,
CommercialRegistryObject.version_code == version_code,
CommercialRegistryObject.tenant_id.is_(None),
)
)
).scalar_one_or_none()
if existing:
existing.display_name = display_name
existing.description = description
existing.payload = payload
existing.status = "published"
existing.visibility = "public"
existing.is_deleted = False
await session.flush()
return False
from datetime import datetime, timezone
import re
import uuid
slug = re.sub(r"[^a-z0-9]+", "-", code.lower()).strip("-")
session.add(
CommercialRegistryObject(
id=uuid.uuid4(),
kind=kind,
slug=slug,
stable_code=code,
version_code=version_code,
display_name=display_name,
description=description,
status="published",
visibility="public",
tenant_id=None,
payload=payload,
meta_data={},
published_at=datetime.now(timezone.utc),
)
)
await session.flush()
return True
async def seed(session: AsyncSession) -> dict[str, int]:
await ensure_kinds(session)
counts = {"kinds": len(BUILTIN_REGISTRY_KINDS), "created": 0}
catalog_path = ROOT / "docs" / "reference" / "business-bundles.catalog.yaml"
data = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) if catalog_path.exists() else {}
bundles = data.get("bundles") or []
# Products derived from services referenced by bundles (open-ended)
products = {
"hospitality": ("Torbat Food", "/hospitality", "hospitality"),
"payment": ("Torbat Pay", "/payment/hub", "payment"),
"experience": ("Torbat Pages", "/experience/hub", "experience"),
"delivery": ("Torbat Driver", "/delivery/hub", "delivery"),
"loyalty": ("Torbat Loyalty", "/loyalty/hub", "loyalty"),
"communication": ("Torbat Communication", "/communication", "communication"),
"accounting": ("Torbat Accounting", "/accounting", "accounting"),
"crm": ("Torbat CRM", "/crm/hub", "crm"),
"sports_center": ("Torbat Sports", "/sports", "sports_center"),
"identity-access": ("Torbat Identity", "/dashboard", "identity"),
}
for code, (name, route, service_key) in products.items():
if await upsert_object(
session,
kind="products",
code=code,
display_name=name,
description=f"Platform product {name}",
payload={
"product_code": code,
"default_route": route,
"service_key": service_key,
"category": "platform",
"trial_eligible": True,
"visibility": "public",
},
):
counts["created"] += 1
for b in bundles:
code = b["bundle_code"]
services = b.get("services") or []
product_codes = [s["service_identifier"] for s in services if s.get("required")]
product_codes += [
s["service_identifier"]
for s in services
if not s.get("required") and s["service_identifier"] not in product_codes
]
product_codes = [p.replace("sports-center", "sports_center").replace("identity-access", "identity-access") for p in product_codes]
# Map identity-access product code used in products dict
product_codes = [("identity-access" if p == "identity-access" else p) for p in product_codes]
pricing_refs = [p.get("pricing_item_code") for p in (b.get("pricing_refs") or []) if isinstance(p, dict)]
capability_codes: list[str] = []
if "hospitality" in product_codes:
capability_codes = [
"hospitality.digital_menu",
"hospitality.pos_lite",
"hospitality.kitchen",
"hospitality.reservation",
"hospitality.analytics",
]
if await upsert_object(
session,
kind="bundles",
code=code,
display_name=b.get("display_name") or code,
version_code=str(b.get("version_code") or "2026.07.1"),
payload={
"bundle_code": code,
"business_types": b.get("business_types") or [],
"product_codes": product_codes,
"capability_codes": capability_codes,
"pricing_refs": [{"pricing_item_code": p} for p in pricing_refs],
"services": services,
"visibility": b.get("visibility") or {},
"lifecycle": b.get("lifecycle") or {},
"trial_eligible": True,
},
):
counts["created"] += 1
for pref in b.get("pricing_refs") or []:
pcode = pref.get("pricing_item_code") if isinstance(pref, dict) else None
if not pcode:
continue
interval = "yearly" if "yearly" in pcode else "monthly"
if await upsert_object(
session,
kind="pricing",
code=pcode,
display_name=pcode,
payload={
"pricing_item_code": pcode,
"pricing_model": "subscription",
"interval": interval,
"currency": "IRR",
"amount_minor": None,
"bundle_code": code,
"trial_eligible": True,
"trial_days": 14,
},
):
counts["created"] += 1
# Mirror pricing as plan shell for plan resolver
plan_code = pcode.replace("price.", "plan.", 1) if pcode.startswith("price.") else f"plan.{pcode}"
if await upsert_object(
session,
kind="plans",
code=plan_code,
display_name=plan_code,
payload={
"plan_code": plan_code,
"pricing_item_code": pcode,
"bundle_code": code,
"trial_eligible": True,
"trial_days": 14,
"interval": interval,
"currency": "IRR",
"limits": {},
"renewal": {"interval": interval},
},
):
counts["created"] += 1
business_types = [
("restaurant", "Restaurant", "رستوران", ["bundle.restaurant.starter"]),
("cafe", "Cafe", "کافه", ["bundle.cafe.starter"]),
("clinic", "Clinic", "کلینیک", ["bundle.clinic.starter"]),
("beauty_salon", "Beauty Salon", "سالن زیبایی", ["bundle.beauty.starter"]),
("sports_center", "Sports Center", "مرکز ورزشی", ["bundle.sports.starter"]),
("retail_shop", "Retail Shop", "فروشگاه", ["bundle.retail.starter"]),
("corporate_company", "Corporate", "شرکت سازمانی", ["bundle.corporate.crm"]),
("freelancer", "Freelancer", "فریلنسر", ["bundle.freelancer.starter"]),
]
for code, en, fa, rec in business_types:
if await upsert_object(
session,
kind="business_types",
code=code,
display_name=fa,
payload={
"business_type_code": code,
"display_name": en,
"display_name_fa": fa,
"recommended_bundle_codes": rec,
"active": True,
},
):
counts["created"] += 1
# Recommendation rules
rules = [
(
"r.restaurant.base",
"Restaurant base",
{"business_type_code": "restaurant"},
{"bundle_code": "bundle.restaurant.starter", "score": 100, "reason": "نوع رستوران", "products": ["hospitality", "payment", "experience"]},
),
(
"r.cafe.base",
"Cafe base",
{"business_type_code": "cafe"},
{"bundle_code": "bundle.cafe.starter", "score": 100, "reason": "نوع کافه", "products": ["hospitality", "payment"]},
),
(
"r.delivery.need",
"Delivery need",
{"delivery": True},
{"score": 80, "reason": "نیاز به پیک", "services": ["delivery"], "products": ["delivery"]},
),
(
"r.crm.need",
"CRM need",
{"crm": True},
{"score": 80, "reason": "نیاز به CRM", "services": ["crm"], "products": ["crm"]},
),
]
for code, name, match, emit in rules:
if await upsert_object(
session,
kind="recommendation_rules",
code=code,
display_name=name,
payload={"rule_id": code, "priority": 100, "active": True, "match": match, "emit": emit},
):
counts["created"] += 1
# Assessment schema
if await upsert_object(
session,
kind="assessment_schemas",
code="default.business.assessment",
display_name="ارزیابی کسب‌وکار",
payload={
"questions": [
{
"question_id": "business_type_code",
"type": "select",
"label": "نوع کسب‌وکار",
"required": True,
"options_source": "business_types",
},
{
"question_id": "company_size",
"type": "select",
"label": "اندازه",
"required": True,
"options": [
{"value": "solo", "label": "تک‌نفره"},
{"value": "small", "label": "کوچک"},
{"value": "medium", "label": "متوسط"},
{"value": "large", "label": "بزرگ"},
{"value": "enterprise", "label": "سازمانی"},
],
},
{
"question_id": "delivery",
"type": "boolean",
"label": "نیاز به پیک؟",
"required": False,
},
{
"question_id": "crm",
"type": "boolean",
"label": "نیاز به CRM؟",
"required": False,
},
]
},
):
counts["created"] += 1
# Capabilities sample (open-ended)
for cap in ["digital_menu", "pos_lite", "kitchen", "reservation", "analytics"]:
if await upsert_object(
session,
kind="capabilities",
code=f"hospitality.{cap}",
display_name=cap,
payload={"capability_code": f"hospitality.{cap}", "service_key": "hospitality"},
):
counts["created"] += 1
# Domain policy
if await upsert_object(
session,
kind="policies",
code="domain.custom",
display_name="Custom domain policy",
payload={
"custom_domain_allowed": True,
"subdomain_only": False,
"target": "domain",
"reason": "طبق سیاست دامنه تجاری",
"dns_instructions": ["CNAME → edge.torbatyar.ir"],
"ssl_ready": True,
},
):
counts["created"] += 1
# Notification templates (email / sms / push / in_app)
for channel, code, subject in [
("email", "notify.welcome.email", "خوش آمدید"),
("sms", "notify.welcome.sms", "خوش آمدید"),
("push", "notify.trial.expiring.push", "پایان trial"),
("in_app", "notify.activation.in_app", "فعال‌سازی"),
]:
if await upsert_object(
session,
kind="notifications",
code=code,
display_name=subject,
payload={
"notification_code": code,
"channel": channel,
"subject": subject,
"body_template": "{{tenant_name}} — {{message}}",
"locale": "fa-IR",
"provider": "platform",
},
):
counts["created"] += 1
if await upsert_object(
session,
kind="currencies",
code="IRR",
display_name="ریال ایران",
payload={"currency_code": "IRR", "symbol": "", "decimals": 0},
):
counts["created"] += 1
if await upsert_object(
session,
kind="regions",
code="IR",
display_name="ایران",
payload={"region_code": "IR", "country_code": "IR"},
):
counts["created"] += 1
await session.commit()
return counts
async def main() -> None:
engine = create_async_engine(settings.database_url, future=True)
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with Session() as session:
counts = await seed(session)
print(f"commercial seed complete: {counts}")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Idempotent seed for service_registry only (commercial SoT is Commercial Runtime)."""
from __future__ import annotations
import asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
from app.models.enums import ServiceStatus
from app.models.registry import ServiceRegistry
PLATFORM_SERVICES: list[dict[str, str | None]] = [
{
"service_key": "accounting",
"name": "حسابداری آنلاین",
"base_url": "http://accounting-service:8002",
"health_check_url": "http://accounting-service:8002/health",
},
{
"service_key": "healthcare",
"name": "تربت هلث",
"base_url": "http://healthcare-service:8010",
"health_check_url": "http://healthcare-service:8010/health",
},
{
"service_key": "beauty",
"name": "تربت بیوتی",
"base_url": "http://beauty-business-service:8011",
"health_check_url": "http://beauty-business-service:8011/health",
},
{
"service_key": "crm",
"name": "سیستم CRM",
"base_url": "http://crm-service:8003",
"health_check_url": "http://crm-service:8003/health",
},
{
"service_key": "loyalty",
"name": "تربت وفاداری",
"base_url": "http://loyalty-service:8004",
"health_check_url": "http://loyalty-service:8004/health",
},
{
"service_key": "communication",
"name": "ارتباطات و پیامک",
"base_url": "http://communication-service:8005",
"health_check_url": "http://communication-service:8005/health",
},
{
"service_key": "sports_center",
"name": "مرکز ورزشی",
"base_url": "http://sports-center-service:8006",
"health_check_url": "http://sports-center-service:8006/health",
},
{
"service_key": "delivery",
"name": "تربت درایور",
"base_url": "http://delivery-service:8007",
"health_check_url": "http://delivery-service:8007/health",
},
{
"service_key": "experience",
"name": "پلتفرم تجربه",
"base_url": "http://experience-service:8008",
"health_check_url": "http://experience-service:8008/health",
},
{
"service_key": "hospitality",
"name": "تربت فود",
"base_url": "http://hospitality-service:8009",
"health_check_url": "http://hospitality-service:8009/health",
},
{
"service_key": "identity",
"name": "هویت و دسترسی",
"base_url": "http://identity-access-service:8001",
"health_check_url": "http://identity-access-service:8001/health",
},
]
async def seed(session: AsyncSession) -> tuple[int, int]:
services_added = 0
for row in PLATFORM_SERVICES:
existing = await session.scalar(
select(ServiceRegistry).where(ServiceRegistry.service_key == row["service_key"])
)
if existing is None:
session.add(
ServiceRegistry(
service_key=str(row["service_key"]),
name=str(row["name"]),
base_url=row["base_url"],
health_check_url=row["health_check_url"],
status=ServiceStatus.ACTIVE,
)
)
services_added += 1
await session.commit()
return services_added, 0
async def main() -> None:
engine = create_async_engine(settings.database_url)
Session = async_sessionmaker(engine, expire_on_commit=False)
async with Session() as session:
services_added, _ = await seed(session)
print(f"seed_platform_catalog: services_added={services_added}")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,11 +1,12 @@
# Communication Service # Communication Service
Independent **Enterprise Communication Platform** for TorbatYar SuperApp. Independent **Enterprise Communication Platform** (**Torbat Communication**) for TorbatYar SuperApp.
- Database: `communication_db` (sole owner) - Database: `communication_db` (sole owner)
- Port: `8005` - Port: `8005`
- Version: `0.8.10.1` - Version: `0.8.10.1`
- Permission prefix: `communication.*` - Permission prefix: `communication.*`
- Status: **Production Ready (SMS MVP)**
## Boundaries ## Boundaries
@ -15,10 +16,14 @@ Consumers interact only via HTTP APIs and events. No module may call external SM
## Channels ## Channels
Designed for: SMS, Email, Push, WhatsApp, Telegram, Rubika, Voice, Future. | Channel | Status |
| --- | --- |
**Initially active:** SMS (mock + Payamak adapters). | SMS (mock + Payamak) | **Production Ready** |
| Email, Push, WhatsApp, Telegram, Rubika, Voice | Architecture-ready stubs — see [communication-roadmap.md](../../../docs/communication-roadmap.md) |
## Docs ## Docs
See `docs/communication-phase-8.md`. - [communication-phase-8.md](../../../docs/communication-phase-8.md)
- [communication-roadmap.md](../../../docs/communication-roadmap.md)
- [phase-handover/phase-8.md](../../../docs/phase-handover/phase-8.md)
- [service-snapshots/communication.yaml](../../../docs/service-snapshots/communication.yaml)

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/delivery/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 8007

View File

@ -0,0 +1,63 @@
# Delivery & Fleet Platform (Torbat Driver)
| Field | Value |
| --- | --- |
| Service | `delivery-service` |
| Database | `delivery_db` |
| Port | 8007 |
| Permission prefix | `delivery.*` |
| Version | 0.10.1.0 |
| Phase | 10.1 Driver Management |
| ADR | [ADR-015](../../../docs/architecture/adr/ADR-015.md) |
## Owns
- Delivery organizations, hubs, configuration/settings shells
- External provider / routing-engine **registrations** (adapters later)
- **Drivers**: profiles, lifecycle, credential/document refs, lifecycle history
- Delivery audit log + transactional outbox for driver events
- Health / capabilities / metrics discovery
- Publish-only `delivery.*` events
## Does not own
- Accounting journals / Posting Engine
- Communication SMS/email providers or message delivery timeline
- Loyalty ledger
- Vertical order aggregates (Hospitality, Marketplace, Store, Pharmacy, Clinic, Sports Center)
- Fleet / vehicles / dispatch / routing / tracking **engines** (later phases)
- Identity user admin / CRM contact master / Storage blobs
- Driver App / Dispatcher Panel UI (frontend)
## APIs
| Method | Path |
| --- | --- |
| GET | `/health` |
| GET | `/capabilities` |
| GET | `/metrics` |
| CRUD | `/api/v1/organizations` |
| CRUD | `/api/v1/hubs` |
| CRUD | `/api/v1/external-providers` |
| CRUD | `/api/v1/routing-engines` |
| CRUD | `/api/v1/configurations` |
| PUT/GET | `/api/v1/settings` |
| GET | `/api/v1/audit` |
| CRUD + lifecycle | `/api/v1/drivers` |
| GET | `/api/v1/permissions/catalog` |
## Run (dev)
```bash
export DELIVERY_DATABASE_URL=postgresql+asyncpg://...
export DELIVERY_DATABASE_URL_SYNC=postgresql+psycopg://...
python scripts/ensure_db.py
alembic upgrade head
uvicorn app.main:app --host 0.0.0.0 --port 8007 --reload
```
## Tests
```bash
pytest
```

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

View File

@ -0,0 +1,19 @@
"""Initial Delivery schema — Phase 10.0 foundation."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
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,30 @@
"""Phase 10.1 — Driver Management schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0002_phase_101_drivers"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"drivers",
"driver_credentials",
"driver_documents",
"driver_lifecycle_events",
"outbox_events",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,29 @@
"""Phase 10.2 — Fleet & Vehicle Types schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0003_phase_102_fleet"
down_revision = "0002_phase_101_drivers"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"fleets",
"vehicle_types",
"vehicles",
"vehicle_assignments",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,29 @@
"""Phase 10.3 — Availability, Shifts & Working Zones schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0004_phase_103_availability"
down_revision = "0003_phase_102_fleet"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"driver_availabilities",
"shifts",
"shift_assignments",
"working_zones",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,28 @@
"""Phase 10.4 — Pricing, Capabilities & Bundles schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0005_phase_104_pricing"
down_revision = "0004_phase_103_availability"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"pricing_rules",
"capability_definitions",
"capability_bundles",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,27 @@
"""Phase 10.5 — Dispatch Engine schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0006_phase_105_dispatch"
down_revision = "0005_phase_104_pricing"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"dispatch_jobs",
"job_assignments",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,28 @@
"""Phase 10.6 — Routing & Optimization schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0007_phase_106_routing"
down_revision = "0006_phase_105_dispatch"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"route_plans",
"route_stops",
"optimization_runs",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,31 @@
"""Phase 10.7+10.8 — Tracking, POD & Settlement schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0008_phase_107_108_tracking_settlement"
down_revision = "0007_phase_106_routing"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"tracking_sessions",
"tracking_points",
"proof_of_delivery",
"customer_tracking_tokens",
"settlement_intents",
"settlement_lines",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

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

View File

@ -0,0 +1,39 @@
"""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
from shared.tenant import STATE_TENANT_ID
__all__ = [
"get_db",
"get_pagination",
"require_tenant",
"get_current_user",
"AsyncSession",
"CurrentUser",
]
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, STATE_TENANT_ID, None)
if tenant_id is None:
raise TenantNotResolvedError(
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
)
return tenant_id

View File

@ -0,0 +1,48 @@
"""Permission enforcement helpers for Delivery APIs."""
from __future__ import annotations
from collections.abc import Callable
from fastapi import Depends
from app.core.config import settings
from app.core.security import get_current_user
from shared.exceptions import ForbiddenError
from shared.security import CurrentUser
_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"})
def user_has_permission(user: CurrentUser, permission: str) -> bool:
if any(role in _ADMIN_ROLES for role in user.roles):
return True
roles = set(user.roles)
if "delivery.manage" in roles or permission in roles:
return True
if "delivery.view" in roles and permission.endswith(".view"):
return True
parts = permission.split(".")
if len(parts) >= 3:
manage = f"{parts[0]}.{parts[1]}.manage"
if manage in roles:
return True
return False
def require_permissions(*permissions: str) -> Callable:
"""Deny unless AUTH is off, user is admin, or any listed permission is present."""
async def _dependency(
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if not settings.auth_required:
return user
if any(user_has_permission(user, perm) for perm in permissions):
return user
raise ForbiddenError(
"دسترسی مجاز نیست",
error_code="permission_denied",
details={"required": list(permissions)},
)
return _dependency

View File

@ -0,0 +1,98 @@
from fastapi import APIRouter
from app.api.v1 import (
audit,
availability,
bundles,
capabilities,
configurations,
customer_tracking,
dispatch_assignments,
dispatch_jobs,
drivers,
external_providers,
fleets,
hubs,
optimization,
organizations,
permissions,
pricing_rules,
proof_of_delivery,
routes,
routing_engines,
settings,
settlements,
shifts,
tracking,
vehicle_types,
vehicles,
working_zones,
)
api_router = APIRouter()
api_router.include_router(
organizations.router, prefix="/organizations", tags=["organizations"]
)
api_router.include_router(hubs.router, prefix="/hubs", tags=["hubs"])
api_router.include_router(
external_providers.router,
prefix="/external-providers",
tags=["external-providers"],
)
api_router.include_router(
routing_engines.router, prefix="/routing-engines", tags=["routing-engines"]
)
api_router.include_router(
configurations.router, prefix="/configurations", tags=["configurations"]
)
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
api_router.include_router(drivers.router, prefix="/drivers", tags=["drivers"])
api_router.include_router(
availability.router, prefix="/drivers", tags=["driver-availability"]
)
api_router.include_router(fleets.router, prefix="/fleets", tags=["fleets"])
api_router.include_router(
vehicle_types.router, prefix="/vehicle-types", tags=["vehicle-types"]
)
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"])
api_router.include_router(shifts.router, prefix="/shifts", tags=["shifts"])
api_router.include_router(
working_zones.router, prefix="/working-zones", tags=["working-zones"]
)
api_router.include_router(
pricing_rules.router, prefix="/pricing-rules", tags=["pricing-rules"]
)
api_router.include_router(
capabilities.router, prefix="/capabilities", tags=["capabilities"]
)
api_router.include_router(bundles.router, prefix="/bundles", tags=["bundles"])
api_router.include_router(
dispatch_jobs.router, prefix="/dispatch/jobs", tags=["dispatch-jobs"]
)
api_router.include_router(
dispatch_assignments.router,
prefix="/dispatch/assignments",
tags=["dispatch-assignments"],
)
api_router.include_router(routes.router, prefix="/routes", tags=["routes"])
api_router.include_router(
optimization.router, prefix="/optimization", tags=["optimization"]
)
api_router.include_router(tracking.router, prefix="/tracking", tags=["tracking"])
api_router.include_router(
proof_of_delivery.router,
prefix="/proof-of-delivery",
tags=["proof-of-delivery"],
)
api_router.include_router(
customer_tracking.router,
prefix="/customer-tracking",
tags=["customer-tracking"],
)
api_router.include_router(
settlements.router, prefix="/settlements", tags=["settlements"]
)
api_router.include_router(
permissions.router, prefix="/permissions", tags=["permissions"]
)

View File

@ -0,0 +1,30 @@
"""Delivery audit APIs — Phase 10.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import AUDIT_VIEW
from app.schemas.foundation import DeliveryAuditLogRead
from app.services.audit_service import AuditService
from shared.security import CurrentUser
router = APIRouter()
@router.get("", response_model=list[DeliveryAuditLogRead])
async def list_audit(
entity_type: str = Query(...),
entity_id: UUID = Query(...),
limit: int = Query(default=100, ge=1, le=500),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AUDIT_VIEW)),
):
return await AuditService(db).list_for_entity(
tenant_id, entity_type, entity_id, limit=limit
)

View File

@ -0,0 +1,40 @@
"""Driver availability APIs — Phase 10.3."""
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.api.permissions import require_permissions
from app.commands.availability import AvailabilityCommands
from app.permissions.definitions import AVAILABILITY_MANAGE, AVAILABILITY_VIEW
from app.queries.availability import AvailabilityQueries
from app.schemas.availability import DriverAvailabilityRead, DriverAvailabilityUpsert
from shared.security import CurrentUser
router = APIRouter()
@router.get("/{driver_id}/availability", response_model=DriverAvailabilityRead)
async def get_availability(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AVAILABILITY_VIEW)),
):
return await AvailabilityQueries(db).get_availability(tenant_id, driver_id)
@router.put("/{driver_id}/availability", response_model=DriverAvailabilityRead)
async def upsert_availability(
driver_id: UUID,
body: DriverAvailabilityUpsert,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AVAILABILITY_MANAGE)),
):
return await AvailabilityCommands(db).upsert_availability(
tenant_id, driver_id, body, actor=user
)

View File

@ -0,0 +1,100 @@
"""Capability bundle APIs — Phase 10.4."""
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.api.permissions import require_permissions
from app.commands.pricing import PricingCommands
from app.models.types import CapabilityBundleStatus
from app.permissions.definitions import (
BUNDLES_CREATE,
BUNDLES_DELETE,
BUNDLES_UPDATE,
BUNDLES_VIEW,
)
from app.queries.pricing import PricingQueries
from app.schemas.pricing import (
BundleCreate,
BundleListResponse,
BundleRead,
BundleUpdate,
)
from app.specifications.pricing import BundleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: CapabilityBundleStatus | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> BundleListSpec:
return BundleListSpec(
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
)
@router.post("", response_model=BundleRead, status_code=status.HTTP_201_CREATED)
async def create_bundle(
body: BundleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE)),
):
return await PricingCommands(db).create_bundle(tenant_id, body, actor=user)
@router.get("", response_model=BundleListResponse)
async def list_bundles(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: BundleListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
):
items, total = await PricingQueries(db).list_bundles(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return BundleListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{bundle_id}", response_model=BundleRead)
async def get_bundle(
bundle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
):
return await PricingQueries(db).get_bundle(tenant_id, bundle_id)
@router.patch("/{bundle_id}", response_model=BundleRead)
async def update_bundle(
bundle_id: UUID,
body: BundleUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE)),
):
return await PricingCommands(db).update_bundle(
tenant_id, bundle_id, body, actor=user
)
@router.post("/{bundle_id}/delete", response_model=BundleRead)
async def delete_bundle(
bundle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE)),
):
return await PricingCommands(db).delete_bundle(tenant_id, bundle_id, actor=user)

View File

@ -0,0 +1,100 @@
"""Capability APIs — Phase 10.4."""
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.api.permissions import require_permissions
from app.commands.pricing import PricingCommands
from app.models.types import CapabilityStatus
from app.permissions.definitions import (
CAPABILITIES_CREATE,
CAPABILITIES_DELETE,
CAPABILITIES_UPDATE,
CAPABILITIES_VIEW,
)
from app.queries.pricing import PricingQueries
from app.schemas.pricing import (
CapabilityCreate,
CapabilityListResponse,
CapabilityRead,
CapabilityUpdate,
)
from app.specifications.pricing import CapabilityListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: CapabilityStatus | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> CapabilityListSpec:
return CapabilityListSpec(
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
)
@router.post("", response_model=CapabilityRead, status_code=status.HTTP_201_CREATED)
async def create_capability(
body: CapabilityCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CAPABILITIES_CREATE)),
):
return await PricingCommands(db).create_capability(tenant_id, body, actor=user)
@router.get("", response_model=CapabilityListResponse)
async def list_capabilities(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: CapabilityListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
):
items, total = await PricingQueries(db).list_capabilities(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return CapabilityListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{cap_id}", response_model=CapabilityRead)
async def get_capability(
cap_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
):
return await PricingQueries(db).get_capability(tenant_id, cap_id)
@router.patch("/{cap_id}", response_model=CapabilityRead)
async def update_capability(
cap_id: UUID,
body: CapabilityUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CAPABILITIES_UPDATE)),
):
return await PricingCommands(db).update_capability(
tenant_id, cap_id, body, actor=user
)
@router.post("/{cap_id}/delete", response_model=CapabilityRead)
async def delete_capability(
cap_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CAPABILITIES_DELETE)),
):
return await PricingCommands(db).delete_capability(tenant_id, cap_id, actor=user)

View File

@ -0,0 +1,83 @@
"""Delivery configuration APIs — Phase 10.0."""
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.api.permissions import require_permissions
from app.permissions.definitions import (
CONFIGURATIONS_CREATE,
CONFIGURATIONS_DELETE,
CONFIGURATIONS_UPDATE,
CONFIGURATIONS_VIEW,
)
from app.schemas.foundation import (
DeliveryConfigurationCreate,
DeliveryConfigurationRead,
DeliveryConfigurationUpdate,
)
from app.services.foundation import DeliveryConfigurationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=DeliveryConfigurationRead, status_code=status.HTTP_201_CREATED)
async def create_configuration(
body: DeliveryConfigurationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_CREATE)),
):
return await DeliveryConfigurationService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[DeliveryConfigurationRead])
async def list_configurations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
):
return await DeliveryConfigurationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{configuration_id}", response_model=DeliveryConfigurationRead)
async def get_configuration(
configuration_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
):
return await DeliveryConfigurationService(db).get(tenant_id, configuration_id)
@router.patch("/{configuration_id}", response_model=DeliveryConfigurationRead)
async def update_configuration(
configuration_id: UUID,
body: DeliveryConfigurationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_UPDATE)),
):
return await DeliveryConfigurationService(db).update(
tenant_id, configuration_id, body, actor=user
)
@router.post("/{configuration_id}/delete", response_model=DeliveryConfigurationRead)
async def soft_delete_configuration(
configuration_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_DELETE)),
):
return await DeliveryConfigurationService(db).soft_delete(
tenant_id, configuration_id, actor=user
)

View File

@ -0,0 +1,88 @@
"""Customer tracking token APIs — Phase 10.7."""
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.api.permissions import require_permissions
from app.commands.tracking import TrackingCommands
from app.models.types import CustomerTrackingTokenStatus
from app.permissions.definitions import (
CUSTOMER_TRACKING_CREATE,
CUSTOMER_TRACKING_REVOKE,
CUSTOMER_TRACKING_VIEW,
)
from app.queries.tracking import TrackingQueries
from app.schemas.tracking import (
CustomerTrackingTokenCreate,
CustomerTrackingTokenListResponse,
CustomerTrackingTokenRead,
)
from app.specifications.tracking import CustomerTrackingTokenListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: CustomerTrackingTokenStatus | None = Query(default=None, alias="status"),
dispatch_job_id: UUID | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> CustomerTrackingTokenListSpec:
return CustomerTrackingTokenListSpec(
status=status_filter,
dispatch_job_id=dispatch_job_id,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("/tokens", response_model=CustomerTrackingTokenRead, status_code=status.HTTP_201_CREATED)
async def create_token(
body: CustomerTrackingTokenCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_CREATE)),
):
return await TrackingCommands(db).create_token(tenant_id, body, actor=user)
@router.get("/tokens", response_model=CustomerTrackingTokenListResponse)
async def list_tokens(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: CustomerTrackingTokenListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
):
items, total = await TrackingQueries(db).list_tokens(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return CustomerTrackingTokenListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/tokens/{token_id}", response_model=CustomerTrackingTokenRead)
async def get_token(
token_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
):
return await TrackingQueries(db).get_token(tenant_id, token_id)
@router.post("/tokens/{token_id}/revoke", response_model=CustomerTrackingTokenRead)
async def revoke_token(
token_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_REVOKE)),
):
return await TrackingCommands(db).revoke_token(tenant_id, token_id, actor=user)

View File

@ -0,0 +1,94 @@
"""Dispatch assignment APIs — Phase 10.5."""
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.api.permissions import require_permissions
from app.commands.dispatch import DispatchCommands
from app.models.types import JobAssignmentStatus
from app.permissions.definitions import (
DISPATCH_ASSIGNMENTS_CREATE,
DISPATCH_ASSIGNMENTS_UPDATE,
DISPATCH_ASSIGNMENTS_VIEW,
)
from app.queries.dispatch import DispatchQueries
from app.schemas.dispatch import (
JobAssignmentCreate,
JobAssignmentListResponse,
JobAssignmentRead,
JobAssignmentUpdate,
)
from app.specifications.dispatch import JobAssignmentListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
dispatch_job_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
status_filter: JobAssignmentStatus | None = Query(default=None, alias="status"),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> JobAssignmentListSpec:
return JobAssignmentListSpec(
dispatch_job_id=dispatch_job_id,
driver_id=driver_id,
status=status_filter,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=JobAssignmentRead, status_code=status.HTTP_201_CREATED)
async def create_assignment(
body: JobAssignmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_CREATE)),
):
return await DispatchCommands(db).create_assignment(tenant_id, body, actor=user)
@router.get("", response_model=JobAssignmentListResponse)
async def list_assignments(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: JobAssignmentListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
):
items, total = await DispatchQueries(db).list_assignments(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return JobAssignmentListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{assignment_id}", response_model=JobAssignmentRead)
async def get_assignment(
assignment_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
):
return await DispatchQueries(db).get_assignment(tenant_id, assignment_id)
@router.patch("/{assignment_id}", response_model=JobAssignmentRead)
async def update_assignment(
assignment_id: UUID,
body: JobAssignmentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_UPDATE)),
):
return await DispatchCommands(db).update_assignment(
tenant_id, assignment_id, body, actor=user
)

View File

@ -0,0 +1,120 @@
"""Dispatch job APIs — Phase 10.5."""
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.api.permissions import require_permissions
from app.commands.dispatch import DispatchCommands
from app.models.types import DispatchJobStatus
from app.permissions.definitions import (
DISPATCH_JOBS_CREATE,
DISPATCH_JOBS_DELETE,
DISPATCH_JOBS_STATUS,
DISPATCH_JOBS_UPDATE,
DISPATCH_JOBS_VIEW,
)
from app.queries.dispatch import DispatchQueries
from app.schemas.dispatch import (
DispatchJobCreate,
DispatchJobListResponse,
DispatchJobRead,
DispatchJobStatusRequest,
DispatchJobUpdate,
)
from app.specifications.dispatch import DispatchJobListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: DispatchJobStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> DispatchJobListSpec:
return DispatchJobListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=DispatchJobRead, status_code=status.HTTP_201_CREATED)
async def create_dispatch_job(
body: DispatchJobCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_CREATE)),
):
return await DispatchCommands(db).create_job(tenant_id, body, actor=user)
@router.get("", response_model=DispatchJobListResponse)
async def list_dispatch_jobs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: DispatchJobListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
):
items, total = await DispatchQueries(db).list_jobs(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return DispatchJobListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{job_id}", response_model=DispatchJobRead)
async def get_dispatch_job(
job_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
):
return await DispatchQueries(db).get_job(tenant_id, job_id)
@router.patch("/{job_id}", response_model=DispatchJobRead)
async def update_dispatch_job(
job_id: UUID,
body: DispatchJobUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_UPDATE)),
):
return await DispatchCommands(db).update_job(tenant_id, job_id, body, actor=user)
@router.post("/{job_id}/status", response_model=DispatchJobRead)
async def apply_dispatch_status(
job_id: UUID,
body: DispatchJobStatusRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_STATUS)),
):
return await DispatchCommands(db).apply_status(
tenant_id, job_id, body, actor=user
)
@router.post("/{job_id}/delete", response_model=DispatchJobRead)
async def delete_dispatch_job(
job_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_DELETE)),
):
return await DispatchCommands(db).delete_job(tenant_id, job_id, actor=user)

View File

@ -0,0 +1,273 @@
"""Driver management APIs — Phase 10.1."""
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.api.permissions import require_permissions
from app.commands.drivers import DriverCommands
from app.models.types import DriverStatus
from app.permissions.definitions import (
DRIVERS_ACTIVATE,
DRIVERS_ARCHIVE,
DRIVERS_BLOCK,
DRIVERS_CREATE,
DRIVERS_CREDENTIALS_MANAGE,
DRIVERS_CREDENTIALS_VIEW,
DRIVERS_DEACTIVATE,
DRIVERS_DELETE,
DRIVERS_DOCUMENTS_MANAGE,
DRIVERS_DOCUMENTS_VIEW,
DRIVERS_LIFECYCLE_VIEW,
DRIVERS_RESUME,
DRIVERS_SUSPEND,
DRIVERS_UNBLOCK,
DRIVERS_UPDATE,
DRIVERS_VIEW,
)
from app.queries.drivers import DriverQueries
from app.schemas.drivers import (
DriverCreate,
DriverCredentialCreate,
DriverCredentialRead,
DriverDocumentCreate,
DriverDocumentRead,
DriverLifecycleEventRead,
DriverLifecycleRequest,
DriverListResponse,
DriverRead,
DriverUpdate,
)
from app.specifications.drivers import ALLOWED_SORT_FIELDS, DriverListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: DriverStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> DriverListSpec:
if sort_by not in ALLOWED_SORT_FIELDS:
sort_by = "created_at"
return DriverListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=DriverRead, status_code=status.HTTP_201_CREATED)
async def create_driver(
body: DriverCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_CREATE)),
):
return await DriverCommands(db).create(tenant_id, body, actor=user)
@router.get("", response_model=DriverListResponse)
async def list_drivers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: DriverListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_VIEW)),
):
items, total = await DriverQueries(db).list(
tenant_id,
offset=pagination.offset,
limit=pagination.page_size,
spec=spec,
)
return DriverListResponse(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
@router.get("/{driver_id}", response_model=DriverRead)
async def get_driver(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_VIEW)),
):
return await DriverQueries(db).get(tenant_id, driver_id)
@router.patch("/{driver_id}", response_model=DriverRead)
async def update_driver(
driver_id: UUID,
body: DriverUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_UPDATE)),
):
return await DriverCommands(db).update(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/activate", response_model=DriverRead)
async def activate_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_ACTIVATE)),
):
return await DriverCommands(db).activate(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/suspend", response_model=DriverRead)
async def suspend_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_SUSPEND)),
):
return await DriverCommands(db).suspend(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/resume", response_model=DriverRead)
async def resume_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_RESUME)),
):
return await DriverCommands(db).resume(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/deactivate", response_model=DriverRead)
async def deactivate_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_DEACTIVATE)),
):
return await DriverCommands(db).deactivate(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/block", response_model=DriverRead)
async def block_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_BLOCK)),
):
return await DriverCommands(db).block(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/unblock", response_model=DriverRead)
async def unblock_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_UNBLOCK)),
):
return await DriverCommands(db).unblock(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/archive", response_model=DriverRead)
async def archive_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_ARCHIVE)),
):
return await DriverCommands(db).archive(tenant_id, driver_id, body, actor=user)
@router.get("/{driver_id}/lifecycle", response_model=list[DriverLifecycleEventRead])
async def list_lifecycle(
driver_id: UUID,
limit: int = Query(default=100, ge=1, le=500),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_LIFECYCLE_VIEW)),
):
return await DriverQueries(db).lifecycle(tenant_id, driver_id, limit=limit)
@router.post(
"/{driver_id}/credentials",
response_model=DriverCredentialRead,
status_code=status.HTTP_201_CREATED,
)
async def add_credential(
driver_id: UUID,
body: DriverCredentialCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_CREDENTIALS_MANAGE)),
):
return await DriverCommands(db).add_credential(
tenant_id, driver_id, body, actor=user
)
@router.get("/{driver_id}/credentials", response_model=list[DriverCredentialRead])
async def list_credentials(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_CREDENTIALS_VIEW)),
):
return await DriverQueries(db).credentials(tenant_id, driver_id)
@router.post(
"/{driver_id}/documents",
response_model=DriverDocumentRead,
status_code=status.HTTP_201_CREATED,
)
async def add_document(
driver_id: UUID,
body: DriverDocumentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_DOCUMENTS_MANAGE)),
):
return await DriverCommands(db).add_document(
tenant_id, driver_id, body, actor=user
)
@router.get("/{driver_id}/documents", response_model=list[DriverDocumentRead])
async def list_documents(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_DOCUMENTS_VIEW)),
):
return await DriverQueries(db).documents(tenant_id, driver_id)
@router.post("/{driver_id}/delete", response_model=DriverRead)
async def soft_delete_driver(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_DELETE)),
):
return await DriverCommands(db).soft_delete(tenant_id, driver_id, actor=user)

View File

@ -0,0 +1,83 @@
"""External provider config APIs — Phase 10.0."""
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.api.permissions import require_permissions
from app.permissions.definitions import (
EXTERNAL_PROVIDERS_CREATE,
EXTERNAL_PROVIDERS_DELETE,
EXTERNAL_PROVIDERS_UPDATE,
EXTERNAL_PROVIDERS_VIEW,
)
from app.schemas.foundation import (
ExternalProviderConfigCreate,
ExternalProviderConfigRead,
ExternalProviderConfigUpdate,
)
from app.services.foundation import ExternalProviderConfigService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=ExternalProviderConfigRead, status_code=status.HTTP_201_CREATED)
async def create_provider(
body: ExternalProviderConfigCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_CREATE)),
):
return await ExternalProviderConfigService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[ExternalProviderConfigRead])
async def list_providers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
):
return await ExternalProviderConfigService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{provider_id}", response_model=ExternalProviderConfigRead)
async def get_provider(
provider_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
):
return await ExternalProviderConfigService(db).get(tenant_id, provider_id)
@router.patch("/{provider_id}", response_model=ExternalProviderConfigRead)
async def update_provider(
provider_id: UUID,
body: ExternalProviderConfigUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_UPDATE)),
):
return await ExternalProviderConfigService(db).update(
tenant_id, provider_id, body, actor=user
)
@router.post("/{provider_id}/delete", response_model=ExternalProviderConfigRead)
async def soft_delete_provider(
provider_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_DELETE)),
):
return await ExternalProviderConfigService(db).soft_delete(
tenant_id, provider_id, actor=user
)

View File

@ -0,0 +1,102 @@
"""Fleet management APIs — Phase 10.2."""
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.api.permissions import require_permissions
from app.commands.fleet import FleetCommands
from app.models.types import FleetStatus, VehicleStatus
from app.permissions.definitions import (
FLEETS_CREATE,
FLEETS_DELETE,
FLEETS_UPDATE,
FLEETS_VIEW,
)
from app.queries.fleet import FleetQueries
from app.schemas.fleet import FleetCreate, FleetListResponse, FleetRead, FleetUpdate
from app.specifications.fleet import FLEET_SORT, FleetListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: FleetStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> FleetListSpec:
if sort_by not in FLEET_SORT:
sort_by = "created_at"
return FleetListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=FleetRead, status_code=status.HTTP_201_CREATED)
async def create_fleet(
body: FleetCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FLEETS_CREATE)),
):
return await FleetCommands(db).create_fleet(tenant_id, body, actor=user)
@router.get("", response_model=FleetListResponse)
async def list_fleets(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: FleetListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
):
items, total = await FleetQueries(db).list_fleets(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return FleetListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{fleet_id}", response_model=FleetRead)
async def get_fleet(
fleet_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
):
return await FleetQueries(db).get_fleet(tenant_id, fleet_id)
@router.patch("/{fleet_id}", response_model=FleetRead)
async def update_fleet(
fleet_id: UUID,
body: FleetUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FLEETS_UPDATE)),
):
return await FleetCommands(db).update_fleet(tenant_id, fleet_id, body, actor=user)
@router.post("/{fleet_id}/delete", response_model=FleetRead)
async def delete_fleet(
fleet_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FLEETS_DELETE)),
):
return await FleetCommands(db).delete_fleet(tenant_id, fleet_id, actor=user)

View File

@ -0,0 +1,100 @@
from fastapi import APIRouter
from app import __version__
from app.core.config import settings
router = APIRouter()
@router.get("/health")
async def health_check():
return {
"status": "ok",
"service": settings.service_name,
"version": __version__,
}
@router.get("/capabilities")
async def capabilities():
return {
"service": settings.service_name,
"version": __version__,
"phase": "10.8",
"commercial_product": "Torbat Driver",
"features": {
"foundation": True,
"health": True,
"capabilities": True,
"metrics": True,
"external_providers_ready": True,
"future_routing_engines_ready": True,
"drivers": True,
"driver_lifecycle": True,
"driver_credentials": True,
"driver_documents": True,
"fleet": True,
"vehicle_types": True,
"vehicles": True,
"vehicle_assignments": True,
"driver_availability": True,
"shifts": True,
"working_zones": True,
"pricing_rules": True,
"capabilities_catalog": True,
"capability_bundles": True,
"dispatch_engine": True,
"routing_engine": True,
"optimization_runs": True,
"tracking": True,
"proof_of_delivery": True,
"customer_tracking": True,
"settlement": True,
"ai": False,
},
"independence": {
"standalone": True,
"superapp": True,
"integrations": [
"accounting",
"communication",
"loyalty",
"crm",
"hospitality",
"marketplace",
"sports_center",
"clinic",
"pharmacy",
],
"integration_mode": "api_and_events_only",
"accounting_mode": "settlement_refs_only",
"no_journal_entries_in_delivery_db": True,
},
}
@router.get("/metrics")
async def metrics():
"""Phase 10.8 metrics discovery — tenant-scoped counters."""
return {
"service": settings.service_name,
"version": __version__,
"phase": "10.8",
"metrics": {
"organizations": "tenant_scoped",
"hubs": "tenant_scoped",
"drivers": "tenant_scoped",
"fleets": "tenant_scoped",
"vehicles": "tenant_scoped",
"shifts": "tenant_scoped",
"pricing_rules": "tenant_scoped",
"dispatch_jobs": "tenant_scoped",
"route_plans": "tenant_scoped",
"optimization_runs": "tenant_scoped",
"tracking_sessions": "tenant_scoped",
"proof_of_delivery": "tenant_scoped",
"settlement_intents": "tenant_scoped",
"outbox_events": "tenant_scoped",
},
"note": "Full delivery platform through phase 10.8; settlement uses AccountingProvider refs only",
}

View File

@ -0,0 +1,70 @@
"""Delivery hub APIs — Phase 10.0."""
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.api.permissions import require_permissions
from app.permissions.definitions import HUBS_CREATE, HUBS_DELETE, HUBS_UPDATE, HUBS_VIEW
from app.schemas.foundation import DeliveryHubCreate, DeliveryHubRead, DeliveryHubUpdate
from app.services.foundation import DeliveryHubService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=DeliveryHubRead, status_code=status.HTTP_201_CREATED)
async def create_hub(
body: DeliveryHubCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(HUBS_CREATE)),
):
return await DeliveryHubService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[DeliveryHubRead])
async def list_hubs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(HUBS_VIEW)),
):
return await DeliveryHubService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{hub_id}", response_model=DeliveryHubRead)
async def get_hub(
hub_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(HUBS_VIEW)),
):
return await DeliveryHubService(db).get(tenant_id, hub_id)
@router.patch("/{hub_id}", response_model=DeliveryHubRead)
async def update_hub(
hub_id: UUID,
body: DeliveryHubUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(HUBS_UPDATE)),
):
return await DeliveryHubService(db).update(tenant_id, hub_id, body, actor=user)
@router.post("/{hub_id}/delete", response_model=DeliveryHubRead)
async def soft_delete_hub(
hub_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(HUBS_DELETE)),
):
return await DeliveryHubService(db).soft_delete(tenant_id, hub_id, actor=user)

View File

@ -0,0 +1,74 @@
"""Optimization run APIs — Phase 10.6."""
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.api.permissions import require_permissions
from app.commands.routing import RoutingCommands
from app.models.types import OptimizationRunStatus
from app.permissions.definitions import OPTIMIZATION_RUNS_CREATE, OPTIMIZATION_RUNS_VIEW
from app.queries.routing import RoutingQueries
from app.schemas.routing import (
OptimizationRunCreate,
OptimizationRunListResponse,
OptimizationRunRead,
)
from app.specifications.routing import OptimizationRunListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
route_plan_id: UUID | None = Query(default=None),
status_filter: OptimizationRunStatus | None = Query(default=None, alias="status"),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> OptimizationRunListSpec:
return OptimizationRunListSpec(
route_plan_id=route_plan_id,
status=status_filter,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("/runs", response_model=OptimizationRunRead, status_code=status.HTTP_201_CREATED)
async def start_optimization(
body: OptimizationRunCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_CREATE)),
):
return await RoutingCommands(db).start_optimization(tenant_id, body, actor=user)
@router.get("/runs", response_model=OptimizationRunListResponse)
async def list_optimization_runs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: OptimizationRunListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
):
items, total = await RoutingQueries(db).list_optimization_runs(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return OptimizationRunListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/runs/{run_id}", response_model=OptimizationRunRead)
async def get_optimization_run(
run_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
):
return await RoutingQueries(db).get_optimization_run(tenant_id, run_id)

View File

@ -0,0 +1,83 @@
"""Delivery organization APIs — Phase 10.0."""
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.api.permissions import require_permissions
from app.permissions.definitions import (
ORGANIZATIONS_CREATE,
ORGANIZATIONS_DELETE,
ORGANIZATIONS_UPDATE,
ORGANIZATIONS_VIEW,
)
from app.schemas.foundation import (
DeliveryOrganizationCreate,
DeliveryOrganizationRead,
DeliveryOrganizationUpdate,
)
from app.services.foundation import DeliveryOrganizationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=DeliveryOrganizationRead, status_code=status.HTTP_201_CREATED)
async def create_organization(
body: DeliveryOrganizationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_CREATE)),
):
return await DeliveryOrganizationService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[DeliveryOrganizationRead])
async def list_organizations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)),
):
return await DeliveryOrganizationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{organization_id}", response_model=DeliveryOrganizationRead)
async def get_organization(
organization_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)),
):
return await DeliveryOrganizationService(db).get(tenant_id, organization_id)
@router.patch("/{organization_id}", response_model=DeliveryOrganizationRead)
async def update_organization(
organization_id: UUID,
body: DeliveryOrganizationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_UPDATE)),
):
return await DeliveryOrganizationService(db).update(
tenant_id, organization_id, body, actor=user
)
@router.post("/{organization_id}/delete", response_model=DeliveryOrganizationRead)
async def soft_delete_organization(
organization_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_DELETE)),
):
return await DeliveryOrganizationService(db).soft_delete(
tenant_id, organization_id, actor=user
)

View File

@ -0,0 +1,27 @@
"""Permission catalog discovery API — Phase 10.1."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.api.permissions import require_permissions
from app.permissions.definitions import (
ALL_PERMISSIONS,
DELIVERY_VIEW,
PERMISSION_PREFIXES,
)
from shared.security import CurrentUser
router = APIRouter()
@router.get("/catalog")
async def permission_catalog(
_user: CurrentUser = Depends(require_permissions(DELIVERY_VIEW)),
):
"""Static permission discovery for Identity / admin consoles."""
return {
"prefix": "delivery.",
"prefixes": list(PERMISSION_PREFIXES),
"permissions": list(ALL_PERMISSIONS),
"count": len(ALL_PERMISSIONS),
}

View File

@ -0,0 +1,103 @@
"""Pricing rule APIs — Phase 10.4."""
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.api.permissions import require_permissions
from app.commands.pricing import PricingCommands
from app.models.types import PricingRuleStatus
from app.permissions.definitions import (
PRICING_RULES_CREATE,
PRICING_RULES_DELETE,
PRICING_RULES_UPDATE,
PRICING_RULES_VIEW,
)
from app.queries.pricing import PricingQueries
from app.schemas.pricing import (
PricingRuleCreate,
PricingRuleListResponse,
PricingRuleRead,
PricingRuleUpdate,
)
from app.specifications.pricing import PricingRuleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: PricingRuleStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> PricingRuleListSpec:
return PricingRuleListSpec(
status=status_filter,
organization_id=organization_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=PricingRuleRead, status_code=status.HTTP_201_CREATED)
async def create_pricing_rule(
body: PricingRuleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PRICING_RULES_CREATE)),
):
return await PricingCommands(db).create_rule(tenant_id, body, actor=user)
@router.get("", response_model=PricingRuleListResponse)
async def list_pricing_rules(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: PricingRuleListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
):
items, total = await PricingQueries(db).list_rules(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return PricingRuleListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{rule_id}", response_model=PricingRuleRead)
async def get_pricing_rule(
rule_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
):
return await PricingQueries(db).get_rule(tenant_id, rule_id)
@router.patch("/{rule_id}", response_model=PricingRuleRead)
async def update_pricing_rule(
rule_id: UUID,
body: PricingRuleUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PRICING_RULES_UPDATE)),
):
return await PricingCommands(db).update_rule(tenant_id, rule_id, body, actor=user)
@router.post("/{rule_id}/delete", response_model=PricingRuleRead)
async def delete_pricing_rule(
rule_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PRICING_RULES_DELETE)),
):
return await PricingCommands(db).delete_rule(tenant_id, rule_id, actor=user)

View File

@ -0,0 +1,90 @@
"""Proof of delivery APIs — Phase 10.7."""
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.api.permissions import require_permissions
from app.commands.tracking import TrackingCommands
from app.models.types import ProofOfDeliveryStatus
from app.permissions.definitions import (
PROOF_OF_DELIVERY_CREATE,
PROOF_OF_DELIVERY_UPDATE,
PROOF_OF_DELIVERY_VIEW,
)
from app.queries.tracking import TrackingQueries
from app.schemas.tracking import (
ProofOfDeliveryCreate,
ProofOfDeliveryListResponse,
ProofOfDeliveryRead,
ProofOfDeliveryUpdate,
)
from app.specifications.tracking import ProofOfDeliveryListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: ProofOfDeliveryStatus | None = Query(default=None, alias="status"),
dispatch_job_id: UUID | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ProofOfDeliveryListSpec:
return ProofOfDeliveryListSpec(
status=status_filter,
dispatch_job_id=dispatch_job_id,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=ProofOfDeliveryRead, status_code=status.HTTP_201_CREATED)
async def capture_pod(
body: ProofOfDeliveryCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_CREATE)),
):
return await TrackingCommands(db).capture_pod(tenant_id, body, actor=user)
@router.get("", response_model=ProofOfDeliveryListResponse)
async def list_pods(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ProofOfDeliveryListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
):
items, total = await TrackingQueries(db).list_pods(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ProofOfDeliveryListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{pod_id}", response_model=ProofOfDeliveryRead)
async def get_pod(
pod_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
):
return await TrackingQueries(db).get_pod(tenant_id, pod_id)
@router.patch("/{pod_id}", response_model=ProofOfDeliveryRead)
async def update_pod(
pod_id: UUID,
body: ProofOfDeliveryUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_UPDATE)),
):
return await TrackingCommands(db).update_pod(tenant_id, pod_id, body, actor=user)

View File

@ -0,0 +1,134 @@
"""Route plan APIs — Phase 10.6."""
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.api.permissions import require_permissions
from app.commands.routing import RoutingCommands
from app.models.types import RoutePlanStatus
from app.permissions.definitions import (
ROUTES_CREATE,
ROUTES_DELETE,
ROUTES_STOPS_MANAGE,
ROUTES_STOPS_VIEW,
ROUTES_UPDATE,
ROUTES_VIEW,
)
from app.queries.routing import RoutingQueries
from app.schemas.routing import (
RoutePlanCreate,
RoutePlanListResponse,
RoutePlanRead,
RoutePlanUpdate,
RouteStopCreate,
RouteStopRead,
)
from app.specifications.routing import RoutePlanListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: RoutePlanStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> RoutePlanListSpec:
return RoutePlanListSpec(
status=status_filter,
organization_id=organization_id,
driver_id=driver_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=RoutePlanRead, status_code=status.HTTP_201_CREATED)
async def create_route(
body: RoutePlanCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_CREATE)),
):
return await RoutingCommands(db).create_route(tenant_id, body, actor=user)
@router.get("", response_model=RoutePlanListResponse)
async def list_routes(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: RoutePlanListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
):
items, total = await RoutingQueries(db).list_routes(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return RoutePlanListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{plan_id}", response_model=RoutePlanRead)
async def get_route(
plan_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
):
return await RoutingQueries(db).get_route(tenant_id, plan_id)
@router.patch("/{plan_id}", response_model=RoutePlanRead)
async def update_route(
plan_id: UUID,
body: RoutePlanUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_UPDATE)),
):
return await RoutingCommands(db).update_route(tenant_id, plan_id, body, actor=user)
@router.post("/{plan_id}/delete", response_model=RoutePlanRead)
async def delete_route(
plan_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_DELETE)),
):
return await RoutingCommands(db).delete_route(tenant_id, plan_id, actor=user)
@router.post(
"/{plan_id}/stops",
response_model=RouteStopRead,
status_code=status.HTTP_201_CREATED,
)
async def add_stop(
plan_id: UUID,
body: RouteStopCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_MANAGE)),
):
return await RoutingCommands(db).add_stop(tenant_id, plan_id, body, actor=user)
@router.get("/{plan_id}/stops", response_model=list[RouteStopRead])
async def list_stops(
plan_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_VIEW)),
):
return await RoutingQueries(db).list_stops(tenant_id, plan_id)

View File

@ -0,0 +1,83 @@
"""Routing engine registration APIs — Phase 10.0."""
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.api.permissions import require_permissions
from app.permissions.definitions import (
ROUTING_ENGINES_CREATE,
ROUTING_ENGINES_DELETE,
ROUTING_ENGINES_UPDATE,
ROUTING_ENGINES_VIEW,
)
from app.schemas.foundation import (
RoutingEngineRegistrationCreate,
RoutingEngineRegistrationRead,
RoutingEngineRegistrationUpdate,
)
from app.services.foundation import RoutingEngineRegistrationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=RoutingEngineRegistrationRead, status_code=status.HTTP_201_CREATED)
async def create_engine(
body: RoutingEngineRegistrationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_CREATE)),
):
return await RoutingEngineRegistrationService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[RoutingEngineRegistrationRead])
async def list_engines(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_VIEW)),
):
return await RoutingEngineRegistrationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{engine_id}", response_model=RoutingEngineRegistrationRead)
async def get_engine(
engine_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_VIEW)),
):
return await RoutingEngineRegistrationService(db).get(tenant_id, engine_id)
@router.patch("/{engine_id}", response_model=RoutingEngineRegistrationRead)
async def update_engine(
engine_id: UUID,
body: RoutingEngineRegistrationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_UPDATE)),
):
return await RoutingEngineRegistrationService(db).update(
tenant_id, engine_id, body, actor=user
)
@router.post("/{engine_id}/delete", response_model=RoutingEngineRegistrationRead)
async def soft_delete_engine(
engine_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_DELETE)),
):
return await RoutingEngineRegistrationService(db).soft_delete(
tenant_id, engine_id, actor=user
)

View File

@ -0,0 +1,39 @@
"""Delivery settings APIs — Phase 10.0."""
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.api.permissions import require_permissions
from app.permissions.definitions import SETTINGS_MANAGE, SETTINGS_VIEW
from app.schemas.foundation import DeliverySettingRead, DeliverySettingUpsert
from app.services.foundation import DeliverySettingService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.put("", response_model=DeliverySettingRead, status_code=status.HTTP_200_OK)
async def upsert_setting(
body: DeliverySettingUpsert,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTINGS_MANAGE)),
):
return await DeliverySettingService(db).upsert(tenant_id, body, actor=user)
@router.get("", response_model=list[DeliverySettingRead])
async def list_settings(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTINGS_VIEW)),
):
return await DeliverySettingService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)

View File

@ -0,0 +1,169 @@
"""Settlement APIs — Phase 10.8."""
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.api.permissions import require_permissions
from app.commands.settlement import SettlementCommands
from app.models.types import SettlementIntentStatus
from app.permissions.definitions import (
SETTLEMENTS_CREATE,
SETTLEMENTS_LINES_MANAGE,
SETTLEMENTS_LINES_VIEW,
SETTLEMENTS_STATUS,
SETTLEMENTS_UPDATE,
SETTLEMENTS_VIEW,
)
from app.queries.settlement import SettlementQueries
from app.schemas.settlement import (
SettlementActionRequest,
SettlementIntentCreate,
SettlementIntentListResponse,
SettlementIntentRead,
SettlementIntentUpdate,
SettlementLineCreate,
SettlementLineRead,
)
from app.specifications.settlement import SettlementIntentListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: SettlementIntentStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> SettlementIntentListSpec:
return SettlementIntentListSpec(
status=status_filter,
organization_id=organization_id,
driver_id=driver_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=SettlementIntentRead, status_code=status.HTTP_201_CREATED)
async def create_settlement(
body: SettlementIntentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_CREATE)),
):
return await SettlementCommands(db).create(tenant_id, body, actor=user)
@router.get("", response_model=SettlementIntentListResponse)
async def list_settlements(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: SettlementIntentListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
):
items, total = await SettlementQueries(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return SettlementIntentListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{intent_id}", response_model=SettlementIntentRead)
async def get_settlement(
intent_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
):
return await SettlementQueries(db).get(tenant_id, intent_id)
@router.patch("/{intent_id}", response_model=SettlementIntentRead)
async def update_settlement(
intent_id: UUID,
body: SettlementIntentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_UPDATE)),
):
return await SettlementCommands(db).update(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/submit", response_model=SettlementIntentRead)
async def submit_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).submit(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/settle", response_model=SettlementIntentRead)
async def settle_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).settle(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/fail", response_model=SettlementIntentRead)
async def fail_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).fail(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/cancel", response_model=SettlementIntentRead)
async def cancel_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).cancel(tenant_id, intent_id, body, actor=user)
@router.post(
"/{intent_id}/lines",
response_model=SettlementLineRead,
status_code=status.HTTP_201_CREATED,
)
async def add_settlement_line(
intent_id: UUID,
body: SettlementLineCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_MANAGE)),
):
return await SettlementCommands(db).add_line(tenant_id, intent_id, body, actor=user)
@router.get("/{intent_id}/lines", response_model=list[SettlementLineRead])
async def list_settlement_lines(
intent_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_VIEW)),
):
return await SettlementQueries(db).lines(tenant_id, intent_id)

View File

@ -0,0 +1,156 @@
"""Shift APIs — Phase 10.3."""
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.api.permissions import require_permissions
from app.commands.availability import AvailabilityCommands
from app.models.types import ShiftStatus
from app.permissions.definitions import (
SHIFTS_ASSIGNMENTS_MANAGE,
SHIFTS_ASSIGNMENTS_VIEW,
SHIFTS_CREATE,
SHIFTS_DELETE,
SHIFTS_UPDATE,
SHIFTS_VIEW,
)
from app.queries.availability import AvailabilityQueries
from app.schemas.availability import (
ShiftAssignmentCreate,
ShiftAssignmentRead,
ShiftAssignmentUpdate,
ShiftCreate,
ShiftListResponse,
ShiftRead,
ShiftUpdate,
)
from app.specifications.availability import ShiftListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: ShiftStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="starts_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ShiftListSpec:
return ShiftListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=ShiftRead, status_code=status.HTTP_201_CREATED)
async def create_shift(
body: ShiftCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_CREATE)),
):
return await AvailabilityCommands(db).create_shift(tenant_id, body, actor=user)
@router.get("", response_model=ShiftListResponse)
async def list_shifts(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ShiftListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
):
items, total = await AvailabilityQueries(db).list_shifts(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ShiftListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{shift_id}", response_model=ShiftRead)
async def get_shift(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
):
return await AvailabilityQueries(db).get_shift(tenant_id, shift_id)
@router.patch("/{shift_id}", response_model=ShiftRead)
async def update_shift(
shift_id: UUID,
body: ShiftUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_UPDATE)),
):
return await AvailabilityCommands(db).update_shift(
tenant_id, shift_id, body, actor=user
)
@router.post("/{shift_id}/delete", response_model=ShiftRead)
async def delete_shift(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_DELETE)),
):
return await AvailabilityCommands(db).delete_shift(tenant_id, shift_id, actor=user)
@router.post(
"/{shift_id}/assignments",
response_model=ShiftAssignmentRead,
status_code=status.HTTP_201_CREATED,
)
async def assign_shift(
shift_id: UUID,
body: ShiftAssignmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
):
return await AvailabilityCommands(db).assign_shift(
tenant_id, shift_id, body, actor=user
)
@router.get("/{shift_id}/assignments", response_model=list[ShiftAssignmentRead])
async def list_shift_assignments(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_VIEW)),
):
return await AvailabilityQueries(db).list_shift_assignments(tenant_id, shift_id)
@router.patch(
"/{shift_id}/assignments/{assignment_id}",
response_model=ShiftAssignmentRead,
)
async def update_shift_assignment(
shift_id: UUID,
assignment_id: UUID,
body: ShiftAssignmentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
):
return await AvailabilityCommands(db).update_shift_assignment(
tenant_id, shift_id, assignment_id, body, actor=user
)

View File

@ -0,0 +1,126 @@
"""Tracking session APIs — Phase 10.7."""
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.api.permissions import require_permissions
from app.commands.tracking import TrackingCommands
from app.models.types import TrackingSessionStatus
from app.permissions.definitions import (
TRACKING_POINTS_MANAGE,
TRACKING_POINTS_VIEW,
TRACKING_SESSIONS_CREATE,
TRACKING_SESSIONS_UPDATE,
TRACKING_SESSIONS_VIEW,
)
from app.queries.tracking import TrackingQueries
from app.schemas.tracking import (
TrackingPointCreate,
TrackingPointRead,
TrackingSessionCreate,
TrackingSessionListResponse,
TrackingSessionRead,
TrackingSessionUpdate,
)
from app.specifications.tracking import TrackingSessionListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: TrackingSessionStatus | None = Query(default=None, alias="status"),
dispatch_job_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> TrackingSessionListSpec:
return TrackingSessionListSpec(
status=status_filter,
dispatch_job_id=dispatch_job_id,
driver_id=driver_id,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("/sessions", response_model=TrackingSessionRead, status_code=status.HTTP_201_CREATED)
async def start_session(
body: TrackingSessionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_CREATE)),
):
return await TrackingCommands(db).start_session(tenant_id, body, actor=user)
@router.get("/sessions", response_model=TrackingSessionListResponse)
async def list_sessions(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: TrackingSessionListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
):
items, total = await TrackingQueries(db).list_sessions(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return TrackingSessionListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/sessions/{session_id}", response_model=TrackingSessionRead)
async def get_session(
session_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
):
return await TrackingQueries(db).get_session(tenant_id, session_id)
@router.patch("/sessions/{session_id}", response_model=TrackingSessionRead)
async def update_session(
session_id: UUID,
body: TrackingSessionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_UPDATE)),
):
return await TrackingCommands(db).update_session(
tenant_id, session_id, body, actor=user
)
@router.post(
"/sessions/{session_id}/points",
response_model=TrackingPointRead,
status_code=status.HTTP_201_CREATED,
)
async def record_point(
session_id: UUID,
body: TrackingPointCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_MANAGE)),
):
return await TrackingCommands(db).record_point(
tenant_id, session_id, body, actor=user
)
@router.get("/sessions/{session_id}/points", response_model=list[TrackingPointRead])
async def list_points(
session_id: UUID,
limit: int = Query(default=500, ge=1, le=2000),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_VIEW)),
):
return await TrackingQueries(db).list_points(tenant_id, session_id, limit=limit)

View File

@ -0,0 +1,96 @@
"""Vehicle type APIs — Phase 10.2."""
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.api.permissions import require_permissions
from app.commands.fleet import FleetCommands
from app.permissions.definitions import (
VEHICLE_TYPES_CREATE,
VEHICLE_TYPES_DELETE,
VEHICLE_TYPES_UPDATE,
VEHICLE_TYPES_VIEW,
)
from app.queries.fleet import FleetQueries
from app.schemas.fleet import (
VehicleTypeCreate,
VehicleTypeListResponse,
VehicleTypeRead,
VehicleTypeUpdate,
)
from app.specifications.fleet import VehicleTypeListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> VehicleTypeListSpec:
return VehicleTypeListSpec(q=q, sort_by=sort_by, sort_dir=sort_dir.lower())
@router.post("", response_model=VehicleTypeRead, status_code=status.HTTP_201_CREATED)
async def create_vehicle_type(
body: VehicleTypeCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_CREATE)),
):
return await FleetCommands(db).create_vehicle_type(tenant_id, body, actor=user)
@router.get("", response_model=VehicleTypeListResponse)
async def list_vehicle_types(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: VehicleTypeListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
):
items, total = await FleetQueries(db).list_vehicle_types(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return VehicleTypeListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{type_id}", response_model=VehicleTypeRead)
async def get_vehicle_type(
type_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
):
return await FleetQueries(db).get_vehicle_type(tenant_id, type_id)
@router.patch("/{type_id}", response_model=VehicleTypeRead)
async def update_vehicle_type(
type_id: UUID,
body: VehicleTypeUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_UPDATE)),
):
return await FleetCommands(db).update_vehicle_type(
tenant_id, type_id, body, actor=user
)
@router.post("/{type_id}/delete", response_model=VehicleTypeRead)
async def delete_vehicle_type(
type_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_DELETE)),
):
return await FleetCommands(db).delete_vehicle_type(tenant_id, type_id, actor=user)

View File

@ -0,0 +1,154 @@
"""Vehicle APIs — Phase 10.2."""
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.api.permissions import require_permissions
from app.commands.fleet import FleetCommands
from app.models.types import VehicleStatus
from app.permissions.definitions import (
VEHICLES_ASSIGNMENTS_MANAGE,
VEHICLES_ASSIGNMENTS_VIEW,
VEHICLES_CREATE,
VEHICLES_DELETE,
VEHICLES_UPDATE,
VEHICLES_VIEW,
)
from app.queries.fleet import FleetQueries
from app.schemas.fleet import (
VehicleAssignmentCreate,
VehicleAssignmentRead,
VehicleAssignmentUpdate,
VehicleCreate,
VehicleListResponse,
VehicleRead,
VehicleUpdate,
)
from app.specifications.fleet import VehicleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
fleet_id: UUID | None = Query(default=None),
status_filter: VehicleStatus | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> VehicleListSpec:
return VehicleListSpec(
fleet_id=fleet_id,
status=status_filter,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
async def create_vehicle(
body: VehicleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_CREATE)),
):
return await FleetCommands(db).create_vehicle(tenant_id, body, actor=user)
@router.get("", response_model=VehicleListResponse)
async def list_vehicles(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: VehicleListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
):
items, total = await FleetQueries(db).list_vehicles(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return VehicleListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{vehicle_id}", response_model=VehicleRead)
async def get_vehicle(
vehicle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
):
return await FleetQueries(db).get_vehicle(tenant_id, vehicle_id)
@router.patch("/{vehicle_id}", response_model=VehicleRead)
async def update_vehicle(
vehicle_id: UUID,
body: VehicleUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_UPDATE)),
):
return await FleetCommands(db).update_vehicle(
tenant_id, vehicle_id, body, actor=user
)
@router.post("/{vehicle_id}/delete", response_model=VehicleRead)
async def delete_vehicle(
vehicle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_DELETE)),
):
return await FleetCommands(db).delete_vehicle(tenant_id, vehicle_id, actor=user)
@router.post(
"/{vehicle_id}/assignments",
response_model=VehicleAssignmentRead,
status_code=status.HTTP_201_CREATED,
)
async def create_assignment(
vehicle_id: UUID,
body: VehicleAssignmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
):
return await FleetCommands(db).create_assignment(
tenant_id, vehicle_id, body, actor=user
)
@router.get("/{vehicle_id}/assignments", response_model=list[VehicleAssignmentRead])
async def list_assignments(
vehicle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_VIEW)),
):
return await FleetQueries(db).list_assignments(tenant_id, vehicle_id)
@router.patch(
"/{vehicle_id}/assignments/{assignment_id}",
response_model=VehicleAssignmentRead,
)
async def update_assignment(
vehicle_id: UUID,
assignment_id: UUID,
body: VehicleAssignmentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
):
return await FleetCommands(db).update_assignment(
tenant_id, vehicle_id, assignment_id, body, actor=user
)

View File

@ -0,0 +1,105 @@
"""Working zone APIs — Phase 10.3."""
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.api.permissions import require_permissions
from app.commands.availability import AvailabilityCommands
from app.models.types import WorkingZoneStatus
from app.permissions.definitions import (
WORKING_ZONES_CREATE,
WORKING_ZONES_DELETE,
WORKING_ZONES_UPDATE,
WORKING_ZONES_VIEW,
)
from app.queries.availability import AvailabilityQueries
from app.schemas.availability import (
WorkingZoneCreate,
WorkingZoneListResponse,
WorkingZoneRead,
WorkingZoneUpdate,
)
from app.specifications.availability import WorkingZoneListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: WorkingZoneStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> WorkingZoneListSpec:
return WorkingZoneListSpec(
status=status_filter,
organization_id=organization_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=WorkingZoneRead, status_code=status.HTTP_201_CREATED)
async def create_zone(
body: WorkingZoneCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_CREATE)),
):
return await AvailabilityCommands(db).create_zone(tenant_id, body, actor=user)
@router.get("", response_model=WorkingZoneListResponse)
async def list_zones(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: WorkingZoneListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
):
items, total = await AvailabilityQueries(db).list_zones(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return WorkingZoneListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{zone_id}", response_model=WorkingZoneRead)
async def get_zone(
zone_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
):
return await AvailabilityQueries(db).get_zone(tenant_id, zone_id)
@router.patch("/{zone_id}", response_model=WorkingZoneRead)
async def update_zone(
zone_id: UUID,
body: WorkingZoneUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_UPDATE)),
):
return await AvailabilityCommands(db).update_zone(
tenant_id, zone_id, body, actor=user
)
@router.post("/{zone_id}/delete", response_model=WorkingZoneRead)
async def delete_zone(
zone_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_DELETE)),
):
return await AvailabilityCommands(db).delete_zone(tenant_id, zone_id, actor=user)

View File

@ -0,0 +1,4 @@
"""Driver commands package."""
from app.commands.drivers import DriverCommands
__all__ = ["DriverCommands"]

View File

@ -0,0 +1,90 @@
"""Availability command handlers — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.availability import (
DriverAvailabilityUpsert,
ShiftAssignmentCreate,
ShiftAssignmentUpdate,
ShiftCreate,
ShiftUpdate,
WorkingZoneCreate,
WorkingZoneUpdate,
)
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
from shared.security import CurrentUser
class AvailabilityCommands:
def __init__(self, session: AsyncSession) -> None:
self.availability = AvailabilityService(session)
self.shifts = ShiftService(session)
self.zones = WorkingZoneService(session)
async def upsert_availability(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverAvailabilityUpsert,
*,
actor: CurrentUser | None,
):
return await self.availability.upsert_for_driver(
tenant_id, driver_id, body, actor=actor
)
async def create_shift(
self, tenant_id: UUID, body: ShiftCreate, *, actor: CurrentUser | None
):
return await self.shifts.create(tenant_id, body, actor=actor)
async def update_shift(
self, tenant_id: UUID, shift_id: UUID, body: ShiftUpdate, *, actor: CurrentUser | None
):
return await self.shifts.update(tenant_id, shift_id, body, actor=actor)
async def delete_shift(
self, tenant_id: UUID, shift_id: UUID, *, actor: CurrentUser | None
):
return await self.shifts.soft_delete(tenant_id, shift_id, actor=actor)
async def assign_shift(
self,
tenant_id: UUID,
shift_id: UUID,
body: ShiftAssignmentCreate,
*,
actor: CurrentUser | None,
):
return await self.shifts.assign_driver(tenant_id, shift_id, body, actor=actor)
async def update_shift_assignment(
self,
tenant_id: UUID,
shift_id: UUID,
assignment_id: UUID,
body: ShiftAssignmentUpdate,
*,
actor: CurrentUser | None,
):
return await self.shifts.update_assignment(
tenant_id, shift_id, assignment_id, body, actor=actor
)
async def create_zone(
self, tenant_id: UUID, body: WorkingZoneCreate, *, actor: CurrentUser | None
):
return await self.zones.create(tenant_id, body, actor=actor)
async def update_zone(
self, tenant_id: UUID, zone_id: UUID, body: WorkingZoneUpdate, *, actor: CurrentUser | None
):
return await self.zones.update(tenant_id, zone_id, body, actor=actor)
async def delete_zone(
self, tenant_id: UUID, zone_id: UUID, *, actor: CurrentUser | None
):
return await self.zones.soft_delete(tenant_id, zone_id, actor=actor)

View File

@ -0,0 +1,62 @@
"""Dispatch command handlers — Phase 10.5."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.dispatch import (
DispatchJobCreate,
DispatchJobStatusRequest,
DispatchJobUpdate,
JobAssignmentCreate,
JobAssignmentUpdate,
)
from app.services.dispatch import DispatchJobService, JobAssignmentService
from shared.security import CurrentUser
class DispatchCommands:
def __init__(self, session: AsyncSession) -> None:
self.jobs = DispatchJobService(session)
self.assignments = JobAssignmentService(session)
async def create_job(
self, tenant_id: UUID, body: DispatchJobCreate, *, actor: CurrentUser | None
):
return await self.jobs.create(tenant_id, body, actor=actor)
async def update_job(
self, tenant_id: UUID, job_id: UUID, body: DispatchJobUpdate, *, actor: CurrentUser | None
):
return await self.jobs.update(tenant_id, job_id, body, actor=actor)
async def apply_status(
self,
tenant_id: UUID,
job_id: UUID,
body: DispatchJobStatusRequest,
*,
actor: CurrentUser | None,
):
return await self.jobs.apply_status(tenant_id, job_id, body, actor=actor)
async def delete_job(
self, tenant_id: UUID, job_id: UUID, *, actor: CurrentUser | None
):
return await self.jobs.soft_delete(tenant_id, job_id, actor=actor)
async def create_assignment(
self, tenant_id: UUID, body: JobAssignmentCreate, *, actor: CurrentUser | None
):
return await self.assignments.create(tenant_id, body, actor=actor)
async def update_assignment(
self,
tenant_id: UUID,
assignment_id: UUID,
body: JobAssignmentUpdate,
*,
actor: CurrentUser | None,
):
return await self.assignments.update(tenant_id, assignment_id, body, actor=actor)

View File

@ -0,0 +1,148 @@
"""Driver command handlers — Phase 10.1."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.types import DriverLifecycleAction
from app.schemas.drivers import (
DriverCreate,
DriverCredentialCreate,
DriverDocumentCreate,
DriverLifecycleRequest,
DriverUpdate,
)
from app.services.drivers import DriverService
from shared.security import CurrentUser
class DriverCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = DriverService(session)
async def create(
self, tenant_id: UUID, body: DriverCreate, *, actor: CurrentUser | None
):
return await self.service.create(tenant_id, body, actor=actor)
async def update(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update(tenant_id, driver_id, body, actor=actor)
async def activate(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.ACTIVATE, body, actor=actor
)
async def suspend(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.SUSPEND, body, actor=actor
)
async def resume(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.RESUME, body, actor=actor
)
async def deactivate(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.DEACTIVATE, body, actor=actor
)
async def block(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.BLOCK, body, actor=actor
)
async def unblock(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.UNBLOCK, body, actor=actor
)
async def archive(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.ARCHIVE, body, actor=actor
)
async def soft_delete(
self, tenant_id: UUID, driver_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete(tenant_id, driver_id, actor=actor)
async def add_credential(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverCredentialCreate,
*,
actor: CurrentUser | None,
):
return await self.service.add_credential(
tenant_id, driver_id, body, actor=actor
)
async def add_document(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverDocumentCreate,
*,
actor: CurrentUser | None,
):
return await self.service.add_document(tenant_id, driver_id, body, actor=actor)

View File

@ -0,0 +1,92 @@
"""Fleet command handlers — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.fleet import (
FleetCreate,
FleetUpdate,
VehicleAssignmentCreate,
VehicleAssignmentUpdate,
VehicleCreate,
VehicleTypeCreate,
VehicleTypeUpdate,
VehicleUpdate,
)
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
from shared.security import CurrentUser
class FleetCommands:
def __init__(self, session: AsyncSession) -> None:
self.fleets = FleetService(session)
self.types = VehicleTypeService(session)
self.vehicles = VehicleService(session)
async def create_fleet(self, tenant_id: UUID, body: FleetCreate, *, actor: CurrentUser | None):
return await self.fleets.create(tenant_id, body, actor=actor)
async def update_fleet(
self, tenant_id: UUID, fleet_id: UUID, body: FleetUpdate, *, actor: CurrentUser | None
):
return await self.fleets.update(tenant_id, fleet_id, body, actor=actor)
async def delete_fleet(self, tenant_id: UUID, fleet_id: UUID, *, actor: CurrentUser | None):
return await self.fleets.soft_delete(tenant_id, fleet_id, actor=actor)
async def create_vehicle_type(
self, tenant_id: UUID, body: VehicleTypeCreate, *, actor: CurrentUser | None
):
return await self.types.create(tenant_id, body, actor=actor)
async def update_vehicle_type(
self, tenant_id: UUID, type_id: UUID, body: VehicleTypeUpdate, *, actor: CurrentUser | None
):
return await self.types.update(tenant_id, type_id, body, actor=actor)
async def delete_vehicle_type(
self, tenant_id: UUID, type_id: UUID, *, actor: CurrentUser | None
):
return await self.types.soft_delete(tenant_id, type_id, actor=actor)
async def create_vehicle(
self, tenant_id: UUID, body: VehicleCreate, *, actor: CurrentUser | None
):
return await self.vehicles.create(tenant_id, body, actor=actor)
async def update_vehicle(
self, tenant_id: UUID, vehicle_id: UUID, body: VehicleUpdate, *, actor: CurrentUser | None
):
return await self.vehicles.update(tenant_id, vehicle_id, body, actor=actor)
async def delete_vehicle(
self, tenant_id: UUID, vehicle_id: UUID, *, actor: CurrentUser | None
):
return await self.vehicles.soft_delete(tenant_id, vehicle_id, actor=actor)
async def create_assignment(
self,
tenant_id: UUID,
vehicle_id: UUID,
body: VehicleAssignmentCreate,
*,
actor: CurrentUser | None,
):
return await self.vehicles.create_assignment(
tenant_id, vehicle_id, body, actor=actor
)
async def update_assignment(
self,
tenant_id: UUID,
vehicle_id: UUID,
assignment_id: UUID,
body: VehicleAssignmentUpdate,
*,
actor: CurrentUser | None,
):
return await self.vehicles.update_assignment(
tenant_id, vehicle_id, assignment_id, body, actor=actor
)

View File

@ -0,0 +1,69 @@
"""Pricing command handlers — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.pricing import (
BundleCreate,
BundleUpdate,
CapabilityCreate,
CapabilityUpdate,
PricingRuleCreate,
PricingRuleUpdate,
)
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
from shared.security import CurrentUser
class PricingCommands:
def __init__(self, session: AsyncSession) -> None:
self.rules = PricingRuleService(session)
self.capabilities = CapabilityService(session)
self.bundles = BundleService(session)
async def create_rule(
self, tenant_id: UUID, body: PricingRuleCreate, *, actor: CurrentUser | None
):
return await self.rules.create(tenant_id, body, actor=actor)
async def update_rule(
self, tenant_id: UUID, rule_id: UUID, body: PricingRuleUpdate, *, actor: CurrentUser | None
):
return await self.rules.update(tenant_id, rule_id, body, actor=actor)
async def delete_rule(
self, tenant_id: UUID, rule_id: UUID, *, actor: CurrentUser | None
):
return await self.rules.soft_delete(tenant_id, rule_id, actor=actor)
async def create_capability(
self, tenant_id: UUID, body: CapabilityCreate, *, actor: CurrentUser | None
):
return await self.capabilities.create(tenant_id, body, actor=actor)
async def update_capability(
self, tenant_id: UUID, cap_id: UUID, body: CapabilityUpdate, *, actor: CurrentUser | None
):
return await self.capabilities.update(tenant_id, cap_id, body, actor=actor)
async def delete_capability(
self, tenant_id: UUID, cap_id: UUID, *, actor: CurrentUser | None
):
return await self.capabilities.soft_delete(tenant_id, cap_id, actor=actor)
async def create_bundle(
self, tenant_id: UUID, body: BundleCreate, *, actor: CurrentUser | None
):
return await self.bundles.create(tenant_id, body, actor=actor)
async def update_bundle(
self, tenant_id: UUID, bundle_id: UUID, body: BundleUpdate, *, actor: CurrentUser | None
):
return await self.bundles.update(tenant_id, bundle_id, body, actor=actor)
async def delete_bundle(
self, tenant_id: UUID, bundle_id: UUID, *, actor: CurrentUser | None
):
return await self.bundles.soft_delete(tenant_id, bundle_id, actor=actor)

View File

@ -0,0 +1,51 @@
"""Routing command handlers — Phase 10.6."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.routing import (
OptimizationRunCreate,
RoutePlanCreate,
RoutePlanUpdate,
RouteStopCreate,
)
from app.services.routing import OptimizationRunService, RoutePlanService
from shared.security import CurrentUser
class RoutingCommands:
def __init__(self, session: AsyncSession) -> None:
self.routes = RoutePlanService(session)
self.optimization = OptimizationRunService(session)
async def create_route(
self, tenant_id: UUID, body: RoutePlanCreate, *, actor: CurrentUser | None
):
return await self.routes.create(tenant_id, body, actor=actor)
async def update_route(
self, tenant_id: UUID, plan_id: UUID, body: RoutePlanUpdate, *, actor: CurrentUser | None
):
return await self.routes.update(tenant_id, plan_id, body, actor=actor)
async def delete_route(
self, tenant_id: UUID, plan_id: UUID, *, actor: CurrentUser | None
):
return await self.routes.soft_delete(tenant_id, plan_id, actor=actor)
async def add_stop(
self,
tenant_id: UUID,
plan_id: UUID,
body: RouteStopCreate,
*,
actor: CurrentUser | None,
):
return await self.routes.add_stop(tenant_id, plan_id, body, actor=actor)
async def start_optimization(
self, tenant_id: UUID, body: OptimizationRunCreate, *, actor: CurrentUser | None
):
return await self.optimization.start(tenant_id, body, actor=actor)

View File

@ -0,0 +1,85 @@
"""Settlement command handlers — Phase 10.8."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.settlement import (
SettlementActionRequest,
SettlementIntentCreate,
SettlementIntentUpdate,
SettlementLineCreate,
)
from app.services.settlement import SettlementService
from shared.security import CurrentUser
class SettlementCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = SettlementService(session)
async def create(
self, tenant_id: UUID, body: SettlementIntentCreate, *, actor: CurrentUser | None
):
return await self.service.create(tenant_id, body, actor=actor)
async def update(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementIntentUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update(tenant_id, intent_id, body, actor=actor)
async def submit(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.submit(tenant_id, intent_id, body, actor=actor)
async def settle(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.settle(tenant_id, intent_id, body, actor=actor)
async def fail(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.fail(tenant_id, intent_id, body, actor=actor)
async def cancel(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.cancel(tenant_id, intent_id, body, actor=actor)
async def add_line(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementLineCreate,
*,
actor: CurrentUser | None,
):
return await self.service.add_line(tenant_id, intent_id, body, actor=actor)

View File

@ -0,0 +1,84 @@
"""Tracking command handlers — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.tracking import (
CustomerTrackingTokenCreate,
ProofOfDeliveryCreate,
ProofOfDeliveryUpdate,
TrackingPointCreate,
TrackingSessionCreate,
TrackingSessionUpdate,
)
from app.services.tracking import (
CustomerTrackingTokenService,
ProofOfDeliveryService,
TrackingSessionService,
)
from shared.security import CurrentUser
class TrackingCommands:
def __init__(self, session: AsyncSession) -> None:
self.sessions = TrackingSessionService(session)
self.pod = ProofOfDeliveryService(session)
self.tokens = CustomerTrackingTokenService(session)
async def start_session(
self, tenant_id: UUID, body: TrackingSessionCreate, *, actor: CurrentUser | None
):
return await self.sessions.start(tenant_id, body, actor=actor)
async def update_session(
self,
tenant_id: UUID,
session_id: UUID,
body: TrackingSessionUpdate,
*,
actor: CurrentUser | None,
):
return await self.sessions.update(tenant_id, session_id, body, actor=actor)
async def record_point(
self,
tenant_id: UUID,
session_id: UUID,
body: TrackingPointCreate,
*,
actor: CurrentUser | None,
):
return await self.sessions.record_point(
tenant_id, session_id, body, actor=actor
)
async def capture_pod(
self, tenant_id: UUID, body: ProofOfDeliveryCreate, *, actor: CurrentUser | None
):
return await self.pod.capture(tenant_id, body, actor=actor)
async def update_pod(
self,
tenant_id: UUID,
pod_id: UUID,
body: ProofOfDeliveryUpdate,
*,
actor: CurrentUser | None,
):
return await self.pod.update(tenant_id, pod_id, body, actor=actor)
async def create_token(
self,
tenant_id: UUID,
body: CustomerTrackingTokenCreate,
*,
actor: CurrentUser | None,
):
return await self.tokens.create(tenant_id, body, actor=actor)
async def revoke_token(
self, tenant_id: UUID, token_id: UUID, *, actor: CurrentUser | None
):
return await self.tokens.revoke(tenant_id, token_id, actor=actor)

View File

@ -0,0 +1,75 @@
"""Delivery Service settings."""
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="delivery-service", validation_alias="DELIVERY_SERVICE_NAME"
)
api_v1_prefix: str = "/api/v1"
database_url: str = Field(
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/delivery_db",
validation_alias="DELIVERY_DATABASE_URL",
)
database_url_sync: str = Field(
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/delivery_db",
validation_alias="DELIVERY_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,27 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import StaticPool
from app.core.config import settings
class Base(DeclarativeBase):
pass
_engine_kwargs: dict = {"pool_pre_ping": True, "future": True}
if settings.database_url.startswith("sqlite"):
_engine_kwargs["poolclass"] = StaticPool
_engine_kwargs["connect_args"] = {"check_same_thread": False}
engine = create_async_engine(settings.database_url, **_engine_kwargs)
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 Delivery 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,121 @@
"""Delivery event publisher — in-memory + transactional outbox (ADR-006)."""
from __future__ import annotations
from typing import Any, Protocol
from uuid import UUID, uuid4
from sqlalchemy.ext.asyncio import AsyncSession
from shared.events import EventEnvelope, EventStatus
from app.core.config import settings
from app.events.types import DeliveryEventType
from app.models.outbox import OutboxEvent
class EventPublisher(Protocol):
def publish(
self,
*,
event_type: DeliveryEventType,
aggregate_type: str,
aggregate_id: UUID,
tenant_id: UUID,
payload: dict[str, Any] | None = None,
) -> EventEnvelope: ...
class InMemoryEventPublisher:
"""Records published envelopes for tests and local verification."""
def __init__(self) -> None:
self.published: list[EventEnvelope] = []
def publish(
self,
*,
event_type: DeliveryEventType,
aggregate_type: str,
aggregate_id: UUID,
tenant_id: UUID,
payload: dict[str, Any] | None = None,
) -> EventEnvelope:
envelope = EventEnvelope(
event_id=uuid4(),
event_type=event_type.value,
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id),
tenant_id=tenant_id,
source_service=settings.service_name,
payload=payload or {},
)
self.published.append(envelope)
return envelope
def record(self, envelope: EventEnvelope) -> EventEnvelope:
self.published.append(envelope)
return envelope
class TransactionalEventPublisher:
"""Persist outbox row in the current transaction; mirror to memory in tests."""
def __init__(
self, session: AsyncSession, memory: InMemoryEventPublisher | None = None
) -> None:
self.session = session
if memory is not None:
self.memory = memory
elif settings.environment == "test":
self.memory = get_event_publisher()
else:
self.memory = None
async def publish(
self,
*,
event_type: DeliveryEventType,
aggregate_type: str,
aggregate_id: UUID,
tenant_id: UUID,
payload: dict[str, Any] | None = None,
) -> EventEnvelope:
envelope = EventEnvelope(
event_id=uuid4(),
event_type=event_type.value,
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id),
tenant_id=tenant_id,
source_service=settings.service_name,
payload=payload or {},
)
row = OutboxEvent(
tenant_id=tenant_id,
event_type=envelope.event_type,
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id),
payload={
"event_id": str(envelope.event_id),
"source_service": settings.service_name,
**(payload or {}),
},
status=EventStatus.PENDING,
)
self.session.add(row)
await self.session.flush()
if self.memory is not None:
self.memory.record(envelope)
return envelope
_default_publisher = InMemoryEventPublisher()
def get_event_publisher() -> InMemoryEventPublisher:
return _default_publisher
def reset_event_publisher() -> InMemoryEventPublisher:
global _default_publisher
_default_publisher = InMemoryEventPublisher()
return _default_publisher

View File

@ -0,0 +1,105 @@
"""Delivery event type contracts (publish-only)."""
from __future__ import annotations
import enum
class DeliveryEventType(str, enum.Enum):
ORGANIZATION_CREATED = "delivery.organization.created"
ORGANIZATION_UPDATED = "delivery.organization.updated"
HUB_CREATED = "delivery.hub.created"
HUB_UPDATED = "delivery.hub.updated"
EXTERNAL_PROVIDER_REGISTERED = "delivery.external_provider.registered"
EXTERNAL_PROVIDER_UPDATED = "delivery.external_provider.updated"
ROUTING_ENGINE_REGISTERED = "delivery.routing_engine.registered"
ROUTING_ENGINE_UPDATED = "delivery.routing_engine.updated"
CONFIGURATION_CREATED = "delivery.configuration.created"
CONFIGURATION_UPDATED = "delivery.configuration.updated"
SETTING_UPSERTED = "delivery.setting.upserted"
# Phase 10.1 — Driver Management
DRIVER_CREATED = "delivery.driver.created"
DRIVER_UPDATED = "delivery.driver.updated"
DRIVER_ACTIVATED = "delivery.driver.activated"
DRIVER_SUSPENDED = "delivery.driver.suspended"
DRIVER_RESUMED = "delivery.driver.resumed"
DRIVER_DEACTIVATED = "delivery.driver.deactivated"
DRIVER_BLOCKED = "delivery.driver.blocked"
DRIVER_UNBLOCKED = "delivery.driver.unblocked"
DRIVER_ARCHIVED = "delivery.driver.archived"
DRIVER_DELETED = "delivery.driver.deleted"
DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added"
DRIVER_DOCUMENT_ATTACHED = "delivery.driver.document_attached"
DRIVER_STATUS_CHANGED = "delivery.driver.status_changed"
# Phase 10.2 — Fleet & Vehicle Types
FLEET_CREATED = "delivery.fleet.created"
FLEET_UPDATED = "delivery.fleet.updated"
FLEET_DELETED = "delivery.fleet.deleted"
VEHICLE_TYPE_CREATED = "delivery.vehicle_type.created"
VEHICLE_TYPE_UPDATED = "delivery.vehicle_type.updated"
VEHICLE_TYPE_DELETED = "delivery.vehicle_type.deleted"
VEHICLE_CREATED = "delivery.vehicle.created"
VEHICLE_UPDATED = "delivery.vehicle.updated"
VEHICLE_DELETED = "delivery.vehicle.deleted"
VEHICLE_ASSIGNMENT_CREATED = "delivery.vehicle_assignment.created"
VEHICLE_ASSIGNMENT_UPDATED = "delivery.vehicle_assignment.updated"
VEHICLE_ASSIGNMENT_ENDED = "delivery.vehicle_assignment.ended"
# Phase 10.3 — Availability, Shifts & Working Zones
DRIVER_AVAILABILITY_UPDATED = "delivery.driver_availability.updated"
SHIFT_CREATED = "delivery.shift.created"
SHIFT_UPDATED = "delivery.shift.updated"
SHIFT_DELETED = "delivery.shift.deleted"
SHIFT_ASSIGNMENT_CREATED = "delivery.shift_assignment.created"
SHIFT_ASSIGNMENT_UPDATED = "delivery.shift_assignment.updated"
WORKING_ZONE_CREATED = "delivery.working_zone.created"
WORKING_ZONE_UPDATED = "delivery.working_zone.updated"
WORKING_ZONE_DELETED = "delivery.working_zone.deleted"
# Phase 10.4 — Pricing, Capabilities & Bundles
PRICING_RULE_CREATED = "delivery.pricing_rule.created"
PRICING_RULE_UPDATED = "delivery.pricing_rule.updated"
PRICING_RULE_DELETED = "delivery.pricing_rule.deleted"
CAPABILITY_CREATED = "delivery.capability.created"
CAPABILITY_UPDATED = "delivery.capability.updated"
CAPABILITY_DELETED = "delivery.capability.deleted"
BUNDLE_CREATED = "delivery.bundle.created"
BUNDLE_UPDATED = "delivery.bundle.updated"
BUNDLE_DELETED = "delivery.bundle.deleted"
# Phase 10.5 — Dispatch Engine
DISPATCH_JOB_CREATED = "delivery.dispatch_job.created"
DISPATCH_JOB_UPDATED = "delivery.dispatch_job.updated"
DISPATCH_JOB_STATUS_CHANGED = "delivery.dispatch_job.status_changed"
DISPATCH_JOB_DELETED = "delivery.dispatch_job.deleted"
JOB_ASSIGNMENT_CREATED = "delivery.job_assignment.created"
JOB_ASSIGNMENT_UPDATED = "delivery.job_assignment.updated"
JOB_ASSIGNMENT_STATUS_CHANGED = "delivery.job_assignment.status_changed"
# Phase 10.6 — Routing & Optimization
ROUTE_PLAN_CREATED = "delivery.route_plan.created"
ROUTE_PLAN_UPDATED = "delivery.route_plan.updated"
ROUTE_PLAN_DELETED = "delivery.route_plan.deleted"
ROUTE_STOP_ADDED = "delivery.route_stop.added"
OPTIMIZATION_RUN_STARTED = "delivery.optimization_run.started"
OPTIMIZATION_RUN_COMPLETED = "delivery.optimization_run.completed"
OPTIMIZATION_RUN_FAILED = "delivery.optimization_run.failed"
# Phase 10.7 — Tracking & Proof of Delivery
TRACKING_SESSION_STARTED = "delivery.tracking_session.started"
TRACKING_SESSION_UPDATED = "delivery.tracking_session.updated"
TRACKING_SESSION_ENDED = "delivery.tracking_session.ended"
TRACKING_POINT_RECORDED = "delivery.tracking_point.recorded"
PROOF_OF_DELIVERY_CAPTURED = "delivery.proof_of_delivery.captured"
PROOF_OF_DELIVERY_UPDATED = "delivery.proof_of_delivery.updated"
CUSTOMER_TRACKING_TOKEN_CREATED = "delivery.customer_tracking_token.created"
CUSTOMER_TRACKING_TOKEN_REVOKED = "delivery.customer_tracking_token.revoked"
# Phase 10.8 — Settlement
SETTLEMENT_INTENT_CREATED = "delivery.settlement_intent.created"
SETTLEMENT_INTENT_UPDATED = "delivery.settlement_intent.updated"
SETTLEMENT_INTENT_SUBMITTED = "delivery.settlement_intent.submitted"
SETTLEMENT_INTENT_SETTLED = "delivery.settlement_intent.settled"
SETTLEMENT_INTENT_FAILED = "delivery.settlement_intent.failed"
SETTLEMENT_LINE_ADDED = "delivery.settlement_line.added"

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