Compare commits
No commits in common. "cursor/communication-frontend-sms-mvp" and "master" have entirely different histories.
cursor/com
...
master
@ -164,15 +164,6 @@ NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
|
||||
NEXT_PUBLIC_KEYCLOAK_REALM=superapp
|
||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
|
||||
NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002
|
||||
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
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
|
||||
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
|
||||
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
|
||||
| [`docs/ai-framework/`](./docs/ai-framework/) | Enterprise / AI Development Framework (implementation lifecycle; production-ready by default) |
|
||||
| [`docs/ai-framework/`](./docs/ai-framework/) | AI Development Framework (implementation lifecycle) |
|
||||
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
|
||||
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
||||
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
||||
@ -62,7 +62,6 @@ docker compose up -d --build
|
||||
- **Loyalty API:** http://localhost:8004/docs
|
||||
- **Communication API:** http://localhost:8005/docs
|
||||
- **Sports Center API:** http://localhost:8006/docs
|
||||
- **Hospitality API:** http://localhost:8009/docs
|
||||
- **Keycloak:** http://localhost:8080
|
||||
|
||||
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
||||
|
||||
480
adr-reference.md
480
adr-reference.md
@ -1,480 +0,0 @@
|
||||
====================
|
||||
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)
|
||||
|
||||
@ -1,247 +0,0 @@
|
||||
====================
|
||||
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)
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
"""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})")
|
||||
@ -1,165 +0,0 @@
|
||||
"""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")
|
||||
@ -1,35 +0,0 @@
|
||||
"""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."
|
||||
)
|
||||
@ -1,3 +1,3 @@
|
||||
"""Core Platform Service - بسته اصلی برنامه."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@ -3,13 +3,15 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
auth,
|
||||
commercial,
|
||||
domains,
|
||||
features,
|
||||
health,
|
||||
me,
|
||||
onboarding,
|
||||
plans,
|
||||
public_tenant,
|
||||
service_registry,
|
||||
subscriptions,
|
||||
tenant_context,
|
||||
tenants,
|
||||
)
|
||||
@ -17,15 +19,18 @@ from app.api.v1.admin import tenants as admin_tenants
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# health خارج از prefix نسخه هم در main اضافه میشود؛ اینجا برای کامل بودن.
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(public_tenant.router)
|
||||
api_router.include_router(admin_tenants.router)
|
||||
api_router.include_router(tenants.router)
|
||||
api_router.include_router(domains.router)
|
||||
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(me.router)
|
||||
api_router.include_router(onboarding.router)
|
||||
api_router.include_router(tenant_context.router)
|
||||
api_router.include_router(commercial.router)
|
||||
|
||||
__all__ = ["api_router", "health"]
|
||||
|
||||
@ -1,558 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
37
backend/core-service/app/api/v1/features.py
Normal file
37
backend/core-service/app/api/v1/features.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""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
|
||||
)
|
||||
57
backend/core-service/app/api/v1/plans.py
Normal file
57
backend/core-service/app/api/v1/plans.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""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)
|
||||
61
backend/core-service/app/api/v1/subscriptions.py
Normal file
61
backend/core-service/app/api/v1/subscriptions.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""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,
|
||||
)
|
||||
@ -1,6 +0,0 @@
|
||||
"""Commercial Runtime package."""
|
||||
|
||||
from app.commercial.kinds import BUILTIN_REGISTRY_KINDS
|
||||
from app.commercial.service import CommercialRegistryService
|
||||
|
||||
__all__ = ["BUILTIN_REGISTRY_KINDS", "CommercialRegistryService"]
|
||||
@ -1,54 +0,0 @@
|
||||
"""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")
|
||||
@ -1,247 +0,0 @@
|
||||
"""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",
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
"""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()
|
||||
@ -1,322 +0,0 @@
|
||||
"""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
|
||||
@ -1,411 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
)
|
||||
@ -101,9 +101,6 @@ class CacheClient:
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
if not self._enabled:
|
||||
self._client = None
|
||||
return
|
||||
if self._client is not None:
|
||||
try:
|
||||
await self._client.aclose()
|
||||
|
||||
@ -23,18 +23,6 @@ logger = get_logger(__name__)
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
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
|
||||
await cache.close()
|
||||
logger.info("service_stopped")
|
||||
|
||||
@ -4,22 +4,17 @@
|
||||
همه جدولها را بشناسند.
|
||||
"""
|
||||
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.events import InboxEvent, OutboxEvent
|
||||
from app.models.membership import TenantMembership
|
||||
from app.models.plan import Feature, Plan, PlanFeature
|
||||
from app.models.registry import (
|
||||
InternalServiceToken,
|
||||
ModuleRegistry,
|
||||
ServiceRegistry,
|
||||
TenantModuleAccess,
|
||||
)
|
||||
from app.models.subscription import TenantFeatureAccess, TenantSubscription
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
|
||||
@ -28,6 +23,11 @@ __all__ = [
|
||||
"User",
|
||||
"TenantMembership",
|
||||
"Domain",
|
||||
"Plan",
|
||||
"Feature",
|
||||
"PlanFeature",
|
||||
"TenantSubscription",
|
||||
"TenantFeatureAccess",
|
||||
"ServiceRegistry",
|
||||
"ModuleRegistry",
|
||||
"TenantModuleAccess",
|
||||
@ -35,9 +35,4 @@ __all__ = [
|
||||
"OutboxEvent",
|
||||
"InboxEvent",
|
||||
"AuditLog",
|
||||
"CommercialRegistryKind",
|
||||
"CommercialRegistryObject",
|
||||
"CommercialTenantSubscription",
|
||||
"CommercialTenantLicense",
|
||||
"CommercialActivationState",
|
||||
]
|
||||
|
||||
@ -1,123 +0,0 @@
|
||||
"""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)
|
||||
@ -43,6 +43,23 @@ class DomainType(str, enum.Enum):
|
||||
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):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
74
backend/core-service/app/models/plan.py
Normal file
74
backend/core-service/app/models/plan.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""مدلهای 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"),
|
||||
)
|
||||
66
backend/core-service/app/models/subscription.py
Normal file
66
backend/core-service/app/models/subscription.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""مدلهای 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"),
|
||||
)
|
||||
57
backend/core-service/app/repositories/plan.py
Normal file
57
backend/core-service/app/repositories/plan.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""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()
|
||||
39
backend/core-service/app/repositories/subscription.py
Normal file
39
backend/core-service/app/repositories/subscription.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""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()
|
||||
62
backend/core-service/app/schemas/plan.py
Normal file
62
backend/core-service/app/schemas/plan.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""اسکیماهای 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
|
||||
40
backend/core-service/app/schemas/subscription.py
Normal file
40
backend/core-service/app/schemas/subscription.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""اسکیماهای اشتراک و بررسی دسترسی قابلیت."""
|
||||
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
|
||||
117
backend/core-service/app/services/entitlement_service.py
Normal file
117
backend/core-service/app/services/entitlement_service.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""سرویس 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")
|
||||
@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.domain import Domain
|
||||
from app.models.enums import DomainType, DomainVerificationStatus, TenantStatus
|
||||
from app.models.enums import DomainType, DomainVerificationStatus, SubscriptionStatus, TenantStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.repositories.tenant import DomainRepository, TenantRepository
|
||||
@ -23,10 +23,13 @@ from app.schemas.onboarding import (
|
||||
OnboardingDomainUpdate,
|
||||
OnboardingTenantCreate,
|
||||
)
|
||||
from app.schemas.subscription import SubscriptionCreate
|
||||
from app.schemas.tenant import TenantCreate
|
||||
from app.services.event_service import EventService
|
||||
from app.services.membership_service import MembershipService
|
||||
from app.services.plan_service import PlanService
|
||||
from app.services.ssl_provision import enqueue_domain_ssl
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.tenant_service import TenantService
|
||||
from shared.events import CoreEventType
|
||||
from shared.exceptions import ConflictError, ValidationAppError
|
||||
@ -39,6 +42,8 @@ class OnboardingService:
|
||||
self.tenant_repo = TenantRepository(session)
|
||||
self.domain_repo = DomainRepository(session)
|
||||
self.membership_service = MembershipService(session)
|
||||
self.plan_service = PlanService(session)
|
||||
self.subscription_service = SubscriptionService(session)
|
||||
self.events = EventService(session)
|
||||
|
||||
async def create_tenant(self, core_user: User, data: OnboardingTenantCreate) -> Tenant:
|
||||
@ -59,9 +64,11 @@ class OnboardingService:
|
||||
|
||||
await self.membership_service.create_owner_membership(tenant.id, core_user.id)
|
||||
|
||||
# Commercial Runtime owns plans/subscriptions — FE activates via
|
||||
# POST /api/v1/commercial/subscriptions (bundle/plan/pricing from registry).
|
||||
# No legacy FREE/STARTER plan assignment.
|
||||
plan = await self.plan_service.ensure_default_plan()
|
||||
await self.subscription_service.create(
|
||||
tenant.id,
|
||||
SubscriptionCreate(plan_id=plan.id, status=SubscriptionStatus.ACTIVE),
|
||||
)
|
||||
|
||||
await self._create_default_subdomain(tenant)
|
||||
|
||||
|
||||
127
backend/core-service/app/services/plan_service.py
Normal file
127
backend/core-service/app/services/plan_service.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""سرویس مدیریت پلنها، قابلیتها و اتصال آنها."""
|
||||
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
|
||||
62
backend/core-service/app/services/subscription_service.py
Normal file
62
backend/core-service/app/services/subscription_service.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""سرویس مدیریت اشتراک 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
|
||||
@ -1,15 +1,15 @@
|
||||
"""ساخت TenantContext کامل (tenant + commercial plan + دامنهها + نقش کاربر) برای frontend."""
|
||||
"""ساخت TenantContext کامل (tenant + پلن + دامنهها + نقش کاربر) برای frontend."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.commercial import CommercialTenantSubscription
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
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.schemas.domain import DomainRead
|
||||
from app.schemas.onboarding import MembershipSummary, TenantContextRead
|
||||
@ -23,6 +23,8 @@ class TenantContextService:
|
||||
self.tenant_repo = TenantRepository(session)
|
||||
self.domain_repo = DomainRepository(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:
|
||||
membership = None
|
||||
@ -36,32 +38,16 @@ class TenantContextService:
|
||||
if primary is None and domains:
|
||||
primary = domains[0]
|
||||
|
||||
commercial_sub = (
|
||||
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
|
||||
subscription = await self.subscription_repo.get_active_by_tenant(tenant.id)
|
||||
plan = await self.plan_repo.get(subscription.plan_id) if subscription else None
|
||||
|
||||
return TenantContextRead(
|
||||
tenant=TenantRead.model_validate(tenant),
|
||||
role=membership.role if membership else None,
|
||||
is_owner=bool(membership.is_owner) if membership else False,
|
||||
plan_code=plan_code,
|
||||
plan_name=plan_name,
|
||||
subscription_status=subscription_status,
|
||||
plan_code=plan.code if plan else None,
|
||||
plan_name=plan.name if plan else None,
|
||||
subscription_status=subscription.status.value if subscription else None,
|
||||
domains=[DomainRead.model_validate(d) for d in domains],
|
||||
primary_domain=primary.domain if primary else None,
|
||||
)
|
||||
|
||||
@ -7,7 +7,6 @@ engine و session با SQLite ساخته شوند.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# --- تنظیم محیط تست پیش از هر import از برنامه ---
|
||||
os.environ["ENVIRONMENT"] = "test"
|
||||
@ -30,16 +29,6 @@ from app.main import app # noqa: E402
|
||||
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
|
||||
async def db_setup():
|
||||
"""ساخت و پاکسازی اسکیمای دیتابیس برای هر تست (ایزوله)."""
|
||||
@ -48,8 +37,6 @@ async def db_setup():
|
||||
yield
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
# Prevent aiosqlite connection hang on Windows teardown
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@ -58,7 +45,6 @@ async def client(db_setup):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||
yield ac
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@ -66,4 +52,3 @@ async def session(db_setup):
|
||||
"""یک AsyncSession برای تستهای سطح سرویس/repository."""
|
||||
async with AsyncSessionLocal() as s:
|
||||
yield s
|
||||
await engine.dispose()
|
||||
|
||||
@ -1,343 +0,0 @@
|
||||
"""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
|
||||
107
backend/core-service/app/tests/test_features.py
Normal file
107
backend/core-service/app/tests/test_features.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""تستهای قابلیتها و بررسی دسترسی (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
|
||||
@ -29,9 +29,6 @@ pyjwt[crypto]==2.8.0
|
||||
# ---- کتابخانه مشترک پلتفرم ----
|
||||
-e ../shared-lib
|
||||
|
||||
# ---- Catalog seed (YAML) ----
|
||||
PyYAML==6.0.1
|
||||
|
||||
# ---- Testing ----
|
||||
pytest==8.2.2
|
||||
pytest-asyncio==0.23.7
|
||||
|
||||
@ -1,414 +0,0 @@
|
||||
#!/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())
|
||||
@ -1,117 +0,0 @@
|
||||
#!/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())
|
||||
@ -1,12 +1,11 @@
|
||||
# Communication Service
|
||||
|
||||
Independent **Enterprise Communication Platform** (**Torbat Communication**) for TorbatYar SuperApp.
|
||||
Independent **Enterprise Communication Platform** for TorbatYar SuperApp.
|
||||
|
||||
- Database: `communication_db` (sole owner)
|
||||
- Port: `8005`
|
||||
- Version: `0.8.10.1`
|
||||
- Permission prefix: `communication.*`
|
||||
- Status: **Production Ready (SMS MVP)**
|
||||
|
||||
## Boundaries
|
||||
|
||||
@ -16,14 +15,10 @@ Consumers interact only via HTTP APIs and events. No module may call external SM
|
||||
|
||||
## Channels
|
||||
|
||||
| Channel | Status |
|
||||
| --- | --- |
|
||||
| SMS (mock + Payamak) | **Production Ready** |
|
||||
| Email, Push, WhatsApp, Telegram, Rubika, Voice | Architecture-ready stubs — see [communication-roadmap.md](../../../docs/communication-roadmap.md) |
|
||||
Designed for: SMS, Email, Push, WhatsApp, Telegram, Rubika, Voice, Future.
|
||||
|
||||
**Initially active:** SMS (mock + Payamak adapters).
|
||||
|
||||
## Docs
|
||||
|
||||
- [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)
|
||||
See `docs/communication-phase-8.md`.
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
# 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
|
||||
@ -1,63 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,41 +0,0 @@
|
||||
[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
|
||||
@ -1,42 +0,0 @@
|
||||
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()
|
||||
@ -1,19 +0,0 @@
|
||||
"""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)
|
||||
@ -1,30 +0,0 @@
|
||||
"""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)
|
||||
@ -1,29 +0,0 @@
|
||||
"""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)
|
||||
@ -1,29 +0,0 @@
|
||||
"""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)
|
||||
@ -1,28 +0,0 @@
|
||||
"""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)
|
||||
@ -1,27 +0,0 @@
|
||||
"""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)
|
||||
@ -1,28 +0,0 @@
|
||||
"""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)
|
||||
@ -1,31 +0,0 @@
|
||||
"""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)
|
||||
@ -1 +0,0 @@
|
||||
__version__ = "0.10.8.0"
|
||||
@ -1,39 +0,0 @@
|
||||
"""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
|
||||
@ -1,48 +0,0 @@
|
||||
"""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
|
||||
@ -1,98 +0,0 @@
|
||||
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"]
|
||||
)
|
||||
@ -1,30 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,40 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,100 +0,0 @@
|
||||
"""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)
|
||||
@ -1,100 +0,0 @@
|
||||
"""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)
|
||||
@ -1,83 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,88 +0,0 @@
|
||||
"""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)
|
||||
@ -1,94 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,120 +0,0 @@
|
||||
"""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)
|
||||
@ -1,273 +0,0 @@
|
||||
"""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)
|
||||
@ -1,83 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,102 +0,0 @@
|
||||
"""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)
|
||||
@ -1,100 +0,0 @@
|
||||
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",
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
"""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)
|
||||
@ -1,74 +0,0 @@
|
||||
"""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)
|
||||
@ -1,83 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,27 +0,0 @@
|
||||
"""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),
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
"""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)
|
||||
@ -1,90 +0,0 @@
|
||||
"""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)
|
||||
@ -1,134 +0,0 @@
|
||||
"""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)
|
||||
@ -1,83 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,39 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,169 +0,0 @@
|
||||
"""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)
|
||||
@ -1,156 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,126 +0,0 @@
|
||||
"""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)
|
||||
@ -1,96 +0,0 @@
|
||||
"""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)
|
||||
@ -1,154 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,105 +0,0 @@
|
||||
"""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)
|
||||
@ -1,4 +0,0 @@
|
||||
"""Driver commands package."""
|
||||
from app.commands.drivers import DriverCommands
|
||||
|
||||
__all__ = ["DriverCommands"]
|
||||
@ -1,90 +0,0 @@
|
||||
"""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)
|
||||
@ -1,62 +0,0 @@
|
||||
"""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)
|
||||
@ -1,148 +0,0 @@
|
||||
"""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)
|
||||
@ -1,92 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@ -1,69 +0,0 @@
|
||||
"""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)
|
||||
@ -1,51 +0,0 @@
|
||||
"""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)
|
||||
@ -1,85 +0,0 @@
|
||||
"""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)
|
||||
@ -1,84 +0,0 @@
|
||||
"""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)
|
||||
@ -1,75 +0,0 @@
|
||||
"""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()
|
||||
@ -1,27 +0,0 @@
|
||||
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
|
||||
@ -1,14 +0,0 @@
|
||||
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)
|
||||
@ -1,49 +0,0 @@
|
||||
"""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)
|
||||
@ -1,121 +0,0 @@
|
||||
"""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
|
||||
@ -1,105 +0,0 @@
|
||||
"""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
Loading…
Reference in New Issue
Block a user