From 5c6a2e78cf1f47bcd4c4d8d1237a99e291fa2558 Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Mon, 27 Jul 2026 12:39:51 +0330 Subject: [PATCH] feat(platform): seed service registry, deploy all modules, and fix homepage catalog. Register all active platform services and base features in core DB so admin can manage them, add delivery/hospitality/sports-center backend phases, update apps catalog and production deploy tooling. Co-authored-by: Cursor --- README.md | 3 +- .../versions/0006_platform_catalog_seed.py | 103 ++ .../scripts/seed_platform_catalog.py | 175 +++ backend/services/delivery/Dockerfile.dev | 22 + backend/services/delivery/README.md | 63 + backend/services/delivery/alembic.ini | 41 + backend/services/delivery/alembic/env.py | 42 + .../delivery/alembic/versions/0001_initial.py | 19 + .../versions/0002_phase_101_drivers.py | 30 + backend/services/delivery/app/__init__.py | 1 + backend/services/delivery/app/api/deps.py | 39 + .../services/delivery/app/api/permissions.py | 48 + .../services/delivery/app/api/v1/__init__.py | 36 + backend/services/delivery/app/api/v1/audit.py | 30 + .../delivery/app/api/v1/configurations.py | 83 ++ .../services/delivery/app/api/v1/drivers.py | 273 ++++ .../delivery/app/api/v1/external_providers.py | 83 ++ .../services/delivery/app/api/v1/health.py | 86 ++ backend/services/delivery/app/api/v1/hubs.py | 70 + .../delivery/app/api/v1/organizations.py | 83 ++ .../delivery/app/api/v1/permissions.py | 27 + .../delivery/app/api/v1/routing_engines.py | 83 ++ .../services/delivery/app/api/v1/settings.py | 39 + .../delivery/app/commands/__init__.py | 4 + .../services/delivery/app/commands/drivers.py | 148 ++ backend/services/delivery/app/core/config.py | 75 + .../services/delivery/app/core/database.py | 27 + backend/services/delivery/app/core/logging.py | 14 + .../services/delivery/app/core/security.py | 49 + .../services/delivery/app/events/publisher.py | 121 ++ backend/services/delivery/app/events/types.py | 33 + backend/services/delivery/app/main.py | 69 + .../delivery/app/middlewares/tenant.py | 24 + .../services/delivery/app/models/__init__.py | 19 + backend/services/delivery/app/models/base.py | 51 + .../services/delivery/app/models/drivers.py | 167 +++ .../delivery/app/models/foundation.py | 303 ++++ .../services/delivery/app/models/outbox.py | 44 + backend/services/delivery/app/models/types.py | 118 ++ .../delivery/app/permissions/definitions.py | 148 ++ .../delivery/app/policies/__init__.py | 4 + .../services/delivery/app/policies/drivers.py | 35 + .../delivery/app/providers/__init__.py | 13 + .../delivery/app/providers/contracts.py | 96 ++ .../services/delivery/app/queries/__init__.py | 4 + .../services/delivery/app/queries/drivers.py | 38 + .../delivery/app/repositories/base.py | 78 + .../delivery/app/repositories/drivers.py | 112 ++ .../delivery/app/repositories/foundation.py | 142 ++ .../services/delivery/app/schemas/common.py | 18 + .../services/delivery/app/schemas/drivers.py | 164 ++ .../delivery/app/schemas/foundation.py | 294 ++++ .../delivery/app/services/audit_service.py | 51 + .../services/delivery/app/services/drivers.py | 478 ++++++ .../delivery/app/services/foundation.py | 779 ++++++++++ .../delivery/app/specifications/__init__.py | 4 + .../delivery/app/specifications/drivers.py | 62 + .../services/delivery/app/tests/conftest.py | 62 + .../services/delivery/app/tests/test_api.py | 136 ++ .../delivery/app/tests/test_architecture.py | 190 +++ .../delivery/app/tests/test_dependency.py | 34 + .../services/delivery/app/tests/test_docs.py | 39 + .../delivery/app/tests/test_migration.py | 36 + .../delivery/app/tests/test_performance.py | 22 + .../delivery/app/tests/test_permissions.py | 29 + .../delivery/app/tests/test_phase_101.py | 291 ++++ .../delivery/app/tests/test_security.py | 41 + .../delivery/app/validators/__init__.py | 14 + .../delivery/app/validators/drivers.py | 140 ++ .../delivery/app/validators/foundation.py | 55 + backend/services/delivery/pytest.ini | 8 + backend/services/delivery/requirements.txt | 14 + .../services/delivery/scripts/ensure_db.py | 41 + backend/services/hospitality/Dockerfile.dev | 2 +- backend/services/hospitality/README.md | 45 + backend/services/hospitality/alembic/env.py | 42 + .../alembic/versions/0001_initial.py | 19 + .../versions/0006_phase_125_pos_pro.py | 28 + .../versions/0007_phase_126_kitchen.py | 26 + .../versions/0008_phase_127_connectors.py | 25 + .../versions/0009_phase_128_analytics.py | 25 + backend/services/hospitality/app/__init__.py | 2 +- backend/services/hospitality/app/api/deps.py | 39 + .../hospitality/app/api/v1/__init__.py | 77 + .../hospitality/app/api/v1/analytics.py | 90 ++ .../hospitality/app/api/v1/branches.py | 72 + .../app/api/v1/bundle_definitions.py | 49 + .../hospitality/app/api/v1/catalog.py | 269 ++++ .../hospitality/app/api/v1/configurations.py | 61 + .../hospitality/app/api/v1/connectors.py | 92 ++ .../hospitality/app/api/v1/dining_areas.py | 49 + .../services/hospitality/app/api/v1/events.py | 49 + .../hospitality/app/api/v1/feature_toggles.py | 46 + .../services/hospitality/app/api/v1/health.py | 72 + .../hospitality/app/api/v1/kitchen.py | 159 ++ .../hospitality/app/api/v1/menu_categories.py | 49 + .../hospitality/app/api/v1/menu_items.py | 49 + .../services/hospitality/app/api/v1/menus.py | 72 + .../hospitality/app/api/v1/permissions.py | 49 + .../hospitality/app/api/v1/pos_lite.py | 183 +++ .../hospitality/app/api/v1/pos_pro.py | 199 +++ backend/services/hospitality/app/api/v1/qr.py | 207 +++ .../services/hospitality/app/api/v1/roles.py | 49 + .../hospitality/app/api/v1/settings.py | 46 + .../hospitality/app/api/v1/table_service.py | 215 +++ .../services/hospitality/app/api/v1/tables.py | 49 + .../hospitality/app/api/v1/tenant_bundles.py | 56 + .../services/hospitality/app/api/v1/venues.py | 72 + .../hospitality/app/commands/__init__.py | 0 .../hospitality/app/events/publisher.py | 63 + .../services/hospitality/app/events/types.py | 82 + backend/services/hospitality/app/main.py | 69 + .../hospitality/app/models/__init__.py | 49 + .../hospitality/app/models/analytics.py | 90 ++ .../hospitality/app/models/catalog.py | 308 ++++ .../hospitality/app/models/connectors.py | 104 ++ .../hospitality/app/models/foundation.py | 13 +- .../hospitality/app/models/kitchen.py | 120 ++ .../hospitality/app/models/pos_lite.py | 148 ++ .../hospitality/app/models/pos_pro.py | 165 +++ backend/services/hospitality/app/models/qr.py | 203 +++ .../hospitality/app/models/table_service.py | 176 +++ .../services/hospitality/app/models/types.py | 180 +++ .../app/permissions/definitions.py | 293 ++++ .../hospitality/app/policies/__init__.py | 0 .../hospitality/app/providers/__init__.py | 14 + .../hospitality/app/providers/contracts.py | 94 ++ .../hospitality/app/providers/mocks.py | 59 + .../hospitality/app/queries/__init__.py | 0 .../hospitality/app/repositories/analytics.py | 44 + .../hospitality/app/repositories/catalog.py | 147 ++ .../app/repositories/connectors.py | 42 + .../app/repositories/foundation.py | 303 ++++ .../hospitality/app/repositories/kitchen.py | 45 + .../hospitality/app/repositories/pos_lite.py | 61 + .../hospitality/app/repositories/pos_pro.py | 65 + .../hospitality/app/repositories/qr.py | 92 ++ .../app/repositories/table_service.py | 78 + .../hospitality/app/schemas/analytics.py | 58 + .../hospitality/app/schemas/catalog.py | 248 ++++ .../hospitality/app/schemas/common.py | 18 + .../hospitality/app/schemas/connectors.py | 68 + .../hospitality/app/schemas/foundation.py | 483 ++++++ .../hospitality/app/schemas/kitchen.py | 94 ++ .../hospitality/app/schemas/pos_lite.py | 116 ++ .../hospitality/app/schemas/pos_pro.py | 132 ++ .../services/hospitality/app/schemas/qr.py | 165 +++ .../hospitality/app/schemas/table_service.py | 146 ++ .../hospitality/app/services/analytics.py | 222 +++ .../hospitality/app/services/audit_service.py | 57 + .../hospitality/app/services/catalog.py | 402 +++++ .../hospitality/app/services/connectors.py | 235 +++ .../hospitality/app/services/foundation.py | 800 ++++++++++ .../hospitality/app/services/kitchen.py | 304 ++++ .../hospitality/app/services/pos_lite.py | 369 +++++ .../hospitality/app/services/pos_pro.py | 362 +++++ .../services/hospitality/app/services/qr.py | 477 ++++++ .../hospitality/app/services/table_service.py | 490 ++++++ .../app/specifications/__init__.py | 0 .../hospitality/app/tests/conftest.py | 62 + .../hospitality/app/tests/test_analytics.py | 120 ++ .../hospitality/app/tests/test_api.py | 207 +++ .../app/tests/test_architecture.py | 375 +++++ .../hospitality/app/tests/test_bundles.py | 40 + .../hospitality/app/tests/test_catalog.py | 259 ++++ .../hospitality/app/tests/test_connectors.py | 142 ++ .../hospitality/app/tests/test_dependency.py | 47 + .../hospitality/app/tests/test_docs.py | 133 ++ .../hospitality/app/tests/test_kitchen.py | 159 ++ .../hospitality/app/tests/test_migration.py | 73 + .../hospitality/app/tests/test_performance.py | 16 + .../hospitality/app/tests/test_permissions.py | 14 + .../hospitality/app/tests/test_pos_lite.py | 179 +++ .../hospitality/app/tests/test_pos_pro.py | 172 +++ .../services/hospitality/app/tests/test_qr.py | 282 ++++ .../hospitality/app/tests/test_security.py | 25 + .../app/tests/test_table_service.py | 343 +++++ .../hospitality/app/validators/__init__.py | 68 + .../hospitality/app/validators/foundation.py | 409 +++++ .../services/hospitality/scripts/ensure_db.py | 41 + backend/services/restaurant/README.md | 28 +- backend/services/sports_center/README.md | 8 +- .../versions/0004_phase_93_coach_staff.py | 50 + .../alembic/versions/0005_phase_94_booking.py | 28 + .../versions/0006_phase_95_attendance.py | 22 + .../versions/0007_phase_96_training.py | 34 + .../versions/0008_phase_97_competitions.py | 33 + .../services/sports_center/app/__init__.py | 2 +- .../sports_center/app/api/v1/__init__.py | 40 + .../sports_center/app/api/v1/attendance.py | 50 + .../sports_center/app/api/v1/booking.py | 85 ++ .../sports_center/app/api/v1/competitions.py | 126 ++ .../sports_center/app/api/v1/health.py | 11 +- .../sports_center/app/api/v1/staff.py | 92 ++ .../sports_center/app/api/v1/training.py | 124 ++ .../sports_center/app/events/types.py | 45 + .../sports_center/app/models/__init__.py | 43 + .../sports_center/app/models/attendance.py | 83 ++ .../sports_center/app/models/booking.py | 204 +++ .../sports_center/app/models/competitions.py | 319 ++++ .../sports_center/app/models/foundation.py | 9 + .../sports_center/app/models/staff.py | 156 ++ .../sports_center/app/models/training.py | 323 ++++ .../sports_center/app/models/types.py | 134 ++ .../app/permissions/definitions.py | 174 +++ .../app/repositories/attendance.py | 65 + .../sports_center/app/repositories/booking.py | 119 ++ .../app/repositories/competitions.py | 106 ++ .../sports_center/app/repositories/staff.py | 105 ++ .../app/repositories/training.py | 111 ++ .../sports_center/app/schemas/attendance.py | 86 ++ .../sports_center/app/schemas/booking.py | 191 +++ .../sports_center/app/schemas/competitions.py | 275 ++++ .../sports_center/app/schemas/foundation.py | 16 + .../sports_center/app/schemas/staff.py | 189 +++ .../sports_center/app/schemas/training.py | 335 +++++ .../sports_center/app/services/attendance.py | 249 ++++ .../sports_center/app/services/booking.py | 446 ++++++ .../app/services/competitions.py | 520 +++++++ .../sports_center/app/services/foundation.py | 2 + .../sports_center/app/services/staff.py | 409 +++++ .../sports_center/app/services/training.py | 532 +++++++ .../app/tests/test_architecture.py | 86 ++ .../app/tests/test_attendance.py | 78 + .../sports_center/app/tests/test_booking.py | 112 ++ .../app/tests/test_competitions.py | 181 +++ .../sports_center/app/tests/test_docs.py | 29 +- .../sports_center/app/tests/test_migration.py | 39 +- .../sports_center/app/tests/test_staff.py | 73 + .../sports_center/app/tests/test_training.py | 107 ++ .../sports_center/app/validators/staff.py | 41 + .../sports_center/scripts/fix_indent.py | 15 + .../sports_center/scripts/fix_services.py | 17 + .../ai-framework/definition-of-done.md | 115 ++ docs-runtime/ai-framework/development-loop.md | 163 ++ .../enterprise-phase-discovery.md | 159 ++ docs-runtime/ai-framework/phase-manifest.yaml | 1315 +++++++++++++++++ docs-runtime/ai-framework/quality-gates.md | 238 +++ .../ai-framework/service-manifest.yaml | 904 +++++++++++ docs-runtime/glossary.md | 217 +++ docs-runtime/module-registry.md | 899 +++++++++++ docs-runtime/next-steps.md | 46 + docs-runtime/progress.md | 555 +++++++ docs/README.md | 335 +++-- docs/ai-framework/README.md | 123 +- docs/ai-framework/api-template.md | 31 +- docs/ai-framework/boundary-rules.md | 56 + docs/ai-framework/context-cache-policy.md | 135 ++ docs/ai-framework/cursor-guidelines.md | 86 +- docs/ai-framework/definition-of-done.md | 115 ++ docs/ai-framework/development-loop.md | 176 ++- docs/ai-framework/documentation-template.md | 18 +- docs/ai-framework/enterprise-completeness.md | 85 ++ .../enterprise-phase-discovery.md | 167 +++ docs/ai-framework/entity-template.md | 14 +- docs/ai-framework/event-template.md | 145 +- .../ai-framework/mandatory-phase-artifacts.md | 99 ++ docs/ai-framework/master-prompt.md | 90 +- docs/ai-framework/module-template.md | 24 +- docs/ai-framework/phase-handover.md | 120 +- docs/ai-framework/phase-template.md | 161 +- docs/ai-framework/prompt-rules.md | 143 +- docs/ai-framework/quality-gates.md | 167 ++- docs/ai-framework/repository-template.md | 8 +- docs/ai-framework/runtime-read-policy.md | 162 ++ docs/ai-framework/service-layer-template.md | 52 +- docs/ai-framework/service-snapshot-policy.md | 128 ++ docs/ai-framework/service-template.md | 16 +- docs/ai-framework/testing-template.md | 37 +- docs/architecture/adr/ADR-013.md | 1 + docs/architecture/adr/ADR-015.md | 2 +- docs/architecture/adr/ADR-017.md | 70 + docs/architecture/adr/ADR-018.md | 66 + docs/architecture/adr/ADR-019.md | 61 + docs/architecture/adr/README.md | 57 +- docs/architecture/ai-architecture.md | 2 + docs/architecture/architecture.md | 2 +- docs/architecture/database-architecture.md | 6 +- docs/architecture/module-boundaries.md | 161 +- docs/delivery-phase-10-0.md | 133 ++ docs/delivery-phase-10-1.md | 184 +++ docs/delivery-roadmap.md | 318 ++-- docs/development/coding-standards.md | 11 + docs/development/project-principles.md | 11 +- docs/development/testing-strategy.md | 20 +- docs/frontend/README.md | 1 + docs/frontend/component-library.md | 17 + docs/frontend/design-system.md | 4 + docs/frontend/form-patterns.md | 4 + docs/frontend/healthcare-design-system.md | 64 + docs/glossary.md | 366 +++-- docs/healthcare-roadmap.md | 165 +++ docs/hospitality-phase-12-0.md | 102 ++ docs/hospitality-phase-12-1.md | 80 + docs/hospitality-phase-12-2.md | 40 + docs/hospitality-phase-12-3.md | 42 + docs/hospitality-phase-12-4.md | 42 + docs/hospitality-phase-12-5.md | 41 + docs/hospitality-phase-12-6.md | 42 + docs/hospitality-phase-12-7.md | 49 + docs/hospitality-phase-12-8.md | 45 + docs/hospitality-roadmap.md | 58 + docs/phase-handover/phase-10-0.md | 110 ++ docs/phase-handover/phase-10-1.md | 164 ++ docs/phase-handover/phase-12-0.md | 68 + docs/phase-handover/phase-12-1.md | 114 ++ docs/phase-handover/phase-12-2.md | 48 + docs/phase-handover/phase-12-3.md | 49 + docs/phase-handover/phase-12-4.md | 50 + docs/phase-handover/phase-12-5.md | 51 + docs/phase-handover/phase-12-6.md | 49 + docs/phase-handover/phase-12-7.md | 49 + docs/phase-handover/phase-12-8.md | 48 + docs/phase-handover/phase-13-0.md | 19 + docs/phase-handover/phase-13-1.md | 20 + docs/phase-handover/phase-13-2.md | 19 + docs/phase-handover/phase-13-3.md | 18 + docs/phase-handover/phase-13-4.md | 19 + docs/phase-handover/phase-13-5.md | 19 + docs/phase-handover/phase-13-6.md | 19 + docs/phase-handover/phase-13-7.md | 24 + docs/phase-handover/phase-9-3.md | 16 + docs/phase-handover/phase-9-4.md | 16 + docs/phase-handover/phase-9-5.md | 16 + docs/phase-handover/phase-9-6.md | 24 + docs/phase-handover/phase-9-7.md | 24 + .../phase-af-context-cache-policy.md | 146 ++ docs/phase-handover/phase-af-discovery.md | 181 +++ docs/phase-handover/phase-af-enterprise.md | 199 +++ docs/phase-handover/phase-af-project-index.md | 154 ++ .../phase-af-runtime-read-policy.md | 146 ++ .../phase-af-service-snapshot.md | 166 +++ docs/phase-handover/phase-dp-reg.md | 208 +-- docs/phases/Delivery/README.md | 64 +- docs/phases/Future/README.md | 3 +- docs/phases/Healthcare/README.md | 45 + .../phase-13-0-healthcare-foundation.md | 208 +++ .../phase-13-1-appointment-engine.md | 117 ++ .../Healthcare/phase-13-2-doctor-panel.md | 89 ++ .../phase-13-3-clinic-management.md | 91 ++ .../Healthcare/phase-13-4-patient-portal.md | 95 ++ .../Healthcare/phase-13-5-medical-record.md | 90 ++ .../Healthcare/phase-13-6-pharmacy-network.md | 87 ++ .../phase-13-7-delivery-integration.md | 110 ++ docs/phases/Hospitality/README.md | 29 + docs/phases/Restaurant/README.md | 20 +- docs/phases/SportsCenter/README.md | 21 +- docs/provider-registry.md | 38 +- docs/reference/database-schema.md | 600 ++++---- docs/service-snapshots/README.md | 10 + docs/service-snapshots/_template.yaml | 33 + docs/service-snapshots/healthcare.yaml | 115 ++ docs/service-snapshots/hospitality.yaml | 239 +++ docs/service-snapshots/sports-center.yaml | 105 ++ docs/sports-center-phase-9-3.md | 38 + docs/sports-center-phase-9-4.md | 30 + docs/sports-center-phase-9-5.md | 29 + docs/sports-center-phase-9-6.md | 24 + docs/sports-center-phase-9-7.md | 40 + docs/sports-center-roadmap.md | 12 +- frontend/lib/apps-catalog.ts | 18 + infrastructure/deploy/.env.production.example | 30 + infrastructure/nginx/torbatyar.ir.conf | 102 +- infrastructure/postgres/init-dbs.sql | 2 + scripts/deploy_platform_full.py | 281 ++++ 365 files changed, 40535 insertions(+), 1430 deletions(-) create mode 100644 backend/core-service/alembic/versions/0006_platform_catalog_seed.py create mode 100644 backend/core-service/scripts/seed_platform_catalog.py create mode 100644 backend/services/delivery/Dockerfile.dev create mode 100644 backend/services/delivery/README.md create mode 100644 backend/services/delivery/alembic.ini create mode 100644 backend/services/delivery/alembic/env.py create mode 100644 backend/services/delivery/alembic/versions/0001_initial.py create mode 100644 backend/services/delivery/alembic/versions/0002_phase_101_drivers.py create mode 100644 backend/services/delivery/app/__init__.py create mode 100644 backend/services/delivery/app/api/deps.py create mode 100644 backend/services/delivery/app/api/permissions.py create mode 100644 backend/services/delivery/app/api/v1/__init__.py create mode 100644 backend/services/delivery/app/api/v1/audit.py create mode 100644 backend/services/delivery/app/api/v1/configurations.py create mode 100644 backend/services/delivery/app/api/v1/drivers.py create mode 100644 backend/services/delivery/app/api/v1/external_providers.py create mode 100644 backend/services/delivery/app/api/v1/health.py create mode 100644 backend/services/delivery/app/api/v1/hubs.py create mode 100644 backend/services/delivery/app/api/v1/organizations.py create mode 100644 backend/services/delivery/app/api/v1/permissions.py create mode 100644 backend/services/delivery/app/api/v1/routing_engines.py create mode 100644 backend/services/delivery/app/api/v1/settings.py create mode 100644 backend/services/delivery/app/commands/__init__.py create mode 100644 backend/services/delivery/app/commands/drivers.py create mode 100644 backend/services/delivery/app/core/config.py create mode 100644 backend/services/delivery/app/core/database.py create mode 100644 backend/services/delivery/app/core/logging.py create mode 100644 backend/services/delivery/app/core/security.py create mode 100644 backend/services/delivery/app/events/publisher.py create mode 100644 backend/services/delivery/app/events/types.py create mode 100644 backend/services/delivery/app/main.py create mode 100644 backend/services/delivery/app/middlewares/tenant.py create mode 100644 backend/services/delivery/app/models/__init__.py create mode 100644 backend/services/delivery/app/models/base.py create mode 100644 backend/services/delivery/app/models/drivers.py create mode 100644 backend/services/delivery/app/models/foundation.py create mode 100644 backend/services/delivery/app/models/outbox.py create mode 100644 backend/services/delivery/app/models/types.py create mode 100644 backend/services/delivery/app/permissions/definitions.py create mode 100644 backend/services/delivery/app/policies/__init__.py create mode 100644 backend/services/delivery/app/policies/drivers.py create mode 100644 backend/services/delivery/app/providers/__init__.py create mode 100644 backend/services/delivery/app/providers/contracts.py create mode 100644 backend/services/delivery/app/queries/__init__.py create mode 100644 backend/services/delivery/app/queries/drivers.py create mode 100644 backend/services/delivery/app/repositories/base.py create mode 100644 backend/services/delivery/app/repositories/drivers.py create mode 100644 backend/services/delivery/app/repositories/foundation.py create mode 100644 backend/services/delivery/app/schemas/common.py create mode 100644 backend/services/delivery/app/schemas/drivers.py create mode 100644 backend/services/delivery/app/schemas/foundation.py create mode 100644 backend/services/delivery/app/services/audit_service.py create mode 100644 backend/services/delivery/app/services/drivers.py create mode 100644 backend/services/delivery/app/services/foundation.py create mode 100644 backend/services/delivery/app/specifications/__init__.py create mode 100644 backend/services/delivery/app/specifications/drivers.py create mode 100644 backend/services/delivery/app/tests/conftest.py create mode 100644 backend/services/delivery/app/tests/test_api.py create mode 100644 backend/services/delivery/app/tests/test_architecture.py create mode 100644 backend/services/delivery/app/tests/test_dependency.py create mode 100644 backend/services/delivery/app/tests/test_docs.py create mode 100644 backend/services/delivery/app/tests/test_migration.py create mode 100644 backend/services/delivery/app/tests/test_performance.py create mode 100644 backend/services/delivery/app/tests/test_permissions.py create mode 100644 backend/services/delivery/app/tests/test_phase_101.py create mode 100644 backend/services/delivery/app/tests/test_security.py create mode 100644 backend/services/delivery/app/validators/__init__.py create mode 100644 backend/services/delivery/app/validators/drivers.py create mode 100644 backend/services/delivery/app/validators/foundation.py create mode 100644 backend/services/delivery/pytest.ini create mode 100644 backend/services/delivery/requirements.txt create mode 100644 backend/services/delivery/scripts/ensure_db.py create mode 100644 backend/services/hospitality/README.md create mode 100644 backend/services/hospitality/alembic/env.py create mode 100644 backend/services/hospitality/alembic/versions/0001_initial.py create mode 100644 backend/services/hospitality/alembic/versions/0006_phase_125_pos_pro.py create mode 100644 backend/services/hospitality/alembic/versions/0007_phase_126_kitchen.py create mode 100644 backend/services/hospitality/alembic/versions/0008_phase_127_connectors.py create mode 100644 backend/services/hospitality/alembic/versions/0009_phase_128_analytics.py create mode 100644 backend/services/hospitality/app/api/deps.py create mode 100644 backend/services/hospitality/app/api/v1/__init__.py create mode 100644 backend/services/hospitality/app/api/v1/analytics.py create mode 100644 backend/services/hospitality/app/api/v1/branches.py create mode 100644 backend/services/hospitality/app/api/v1/bundle_definitions.py create mode 100644 backend/services/hospitality/app/api/v1/catalog.py create mode 100644 backend/services/hospitality/app/api/v1/configurations.py create mode 100644 backend/services/hospitality/app/api/v1/connectors.py create mode 100644 backend/services/hospitality/app/api/v1/dining_areas.py create mode 100644 backend/services/hospitality/app/api/v1/events.py create mode 100644 backend/services/hospitality/app/api/v1/feature_toggles.py create mode 100644 backend/services/hospitality/app/api/v1/health.py create mode 100644 backend/services/hospitality/app/api/v1/kitchen.py create mode 100644 backend/services/hospitality/app/api/v1/menu_categories.py create mode 100644 backend/services/hospitality/app/api/v1/menu_items.py create mode 100644 backend/services/hospitality/app/api/v1/menus.py create mode 100644 backend/services/hospitality/app/api/v1/permissions.py create mode 100644 backend/services/hospitality/app/api/v1/pos_lite.py create mode 100644 backend/services/hospitality/app/api/v1/pos_pro.py create mode 100644 backend/services/hospitality/app/api/v1/qr.py create mode 100644 backend/services/hospitality/app/api/v1/roles.py create mode 100644 backend/services/hospitality/app/api/v1/settings.py create mode 100644 backend/services/hospitality/app/api/v1/table_service.py create mode 100644 backend/services/hospitality/app/api/v1/tables.py create mode 100644 backend/services/hospitality/app/api/v1/tenant_bundles.py create mode 100644 backend/services/hospitality/app/api/v1/venues.py create mode 100644 backend/services/hospitality/app/commands/__init__.py create mode 100644 backend/services/hospitality/app/events/publisher.py create mode 100644 backend/services/hospitality/app/events/types.py create mode 100644 backend/services/hospitality/app/main.py create mode 100644 backend/services/hospitality/app/models/analytics.py create mode 100644 backend/services/hospitality/app/models/catalog.py create mode 100644 backend/services/hospitality/app/models/connectors.py create mode 100644 backend/services/hospitality/app/models/kitchen.py create mode 100644 backend/services/hospitality/app/models/pos_lite.py create mode 100644 backend/services/hospitality/app/models/pos_pro.py create mode 100644 backend/services/hospitality/app/models/qr.py create mode 100644 backend/services/hospitality/app/models/table_service.py create mode 100644 backend/services/hospitality/app/permissions/definitions.py create mode 100644 backend/services/hospitality/app/policies/__init__.py create mode 100644 backend/services/hospitality/app/providers/__init__.py create mode 100644 backend/services/hospitality/app/providers/contracts.py create mode 100644 backend/services/hospitality/app/providers/mocks.py create mode 100644 backend/services/hospitality/app/queries/__init__.py create mode 100644 backend/services/hospitality/app/repositories/analytics.py create mode 100644 backend/services/hospitality/app/repositories/catalog.py create mode 100644 backend/services/hospitality/app/repositories/connectors.py create mode 100644 backend/services/hospitality/app/repositories/foundation.py create mode 100644 backend/services/hospitality/app/repositories/kitchen.py create mode 100644 backend/services/hospitality/app/repositories/pos_lite.py create mode 100644 backend/services/hospitality/app/repositories/pos_pro.py create mode 100644 backend/services/hospitality/app/repositories/qr.py create mode 100644 backend/services/hospitality/app/repositories/table_service.py create mode 100644 backend/services/hospitality/app/schemas/analytics.py create mode 100644 backend/services/hospitality/app/schemas/catalog.py create mode 100644 backend/services/hospitality/app/schemas/common.py create mode 100644 backend/services/hospitality/app/schemas/connectors.py create mode 100644 backend/services/hospitality/app/schemas/foundation.py create mode 100644 backend/services/hospitality/app/schemas/kitchen.py create mode 100644 backend/services/hospitality/app/schemas/pos_lite.py create mode 100644 backend/services/hospitality/app/schemas/pos_pro.py create mode 100644 backend/services/hospitality/app/schemas/qr.py create mode 100644 backend/services/hospitality/app/schemas/table_service.py create mode 100644 backend/services/hospitality/app/services/analytics.py create mode 100644 backend/services/hospitality/app/services/audit_service.py create mode 100644 backend/services/hospitality/app/services/catalog.py create mode 100644 backend/services/hospitality/app/services/connectors.py create mode 100644 backend/services/hospitality/app/services/foundation.py create mode 100644 backend/services/hospitality/app/services/kitchen.py create mode 100644 backend/services/hospitality/app/services/pos_lite.py create mode 100644 backend/services/hospitality/app/services/pos_pro.py create mode 100644 backend/services/hospitality/app/services/qr.py create mode 100644 backend/services/hospitality/app/services/table_service.py create mode 100644 backend/services/hospitality/app/specifications/__init__.py create mode 100644 backend/services/hospitality/app/tests/conftest.py create mode 100644 backend/services/hospitality/app/tests/test_analytics.py create mode 100644 backend/services/hospitality/app/tests/test_api.py create mode 100644 backend/services/hospitality/app/tests/test_architecture.py create mode 100644 backend/services/hospitality/app/tests/test_bundles.py create mode 100644 backend/services/hospitality/app/tests/test_catalog.py create mode 100644 backend/services/hospitality/app/tests/test_connectors.py create mode 100644 backend/services/hospitality/app/tests/test_dependency.py create mode 100644 backend/services/hospitality/app/tests/test_docs.py create mode 100644 backend/services/hospitality/app/tests/test_kitchen.py create mode 100644 backend/services/hospitality/app/tests/test_migration.py create mode 100644 backend/services/hospitality/app/tests/test_performance.py create mode 100644 backend/services/hospitality/app/tests/test_permissions.py create mode 100644 backend/services/hospitality/app/tests/test_pos_lite.py create mode 100644 backend/services/hospitality/app/tests/test_pos_pro.py create mode 100644 backend/services/hospitality/app/tests/test_qr.py create mode 100644 backend/services/hospitality/app/tests/test_security.py create mode 100644 backend/services/hospitality/app/tests/test_table_service.py create mode 100644 backend/services/hospitality/app/validators/__init__.py create mode 100644 backend/services/hospitality/app/validators/foundation.py create mode 100644 backend/services/hospitality/scripts/ensure_db.py create mode 100644 backend/services/sports_center/alembic/versions/0004_phase_93_coach_staff.py create mode 100644 backend/services/sports_center/alembic/versions/0005_phase_94_booking.py create mode 100644 backend/services/sports_center/alembic/versions/0006_phase_95_attendance.py create mode 100644 backend/services/sports_center/alembic/versions/0007_phase_96_training.py create mode 100644 backend/services/sports_center/alembic/versions/0008_phase_97_competitions.py create mode 100644 backend/services/sports_center/app/api/v1/attendance.py create mode 100644 backend/services/sports_center/app/api/v1/booking.py create mode 100644 backend/services/sports_center/app/api/v1/competitions.py create mode 100644 backend/services/sports_center/app/api/v1/staff.py create mode 100644 backend/services/sports_center/app/api/v1/training.py create mode 100644 backend/services/sports_center/app/models/attendance.py create mode 100644 backend/services/sports_center/app/models/booking.py create mode 100644 backend/services/sports_center/app/models/competitions.py create mode 100644 backend/services/sports_center/app/models/staff.py create mode 100644 backend/services/sports_center/app/models/training.py create mode 100644 backend/services/sports_center/app/repositories/attendance.py create mode 100644 backend/services/sports_center/app/repositories/booking.py create mode 100644 backend/services/sports_center/app/repositories/competitions.py create mode 100644 backend/services/sports_center/app/repositories/staff.py create mode 100644 backend/services/sports_center/app/repositories/training.py create mode 100644 backend/services/sports_center/app/schemas/attendance.py create mode 100644 backend/services/sports_center/app/schemas/booking.py create mode 100644 backend/services/sports_center/app/schemas/competitions.py create mode 100644 backend/services/sports_center/app/schemas/staff.py create mode 100644 backend/services/sports_center/app/schemas/training.py create mode 100644 backend/services/sports_center/app/services/attendance.py create mode 100644 backend/services/sports_center/app/services/booking.py create mode 100644 backend/services/sports_center/app/services/competitions.py create mode 100644 backend/services/sports_center/app/services/staff.py create mode 100644 backend/services/sports_center/app/services/training.py create mode 100644 backend/services/sports_center/app/tests/test_attendance.py create mode 100644 backend/services/sports_center/app/tests/test_booking.py create mode 100644 backend/services/sports_center/app/tests/test_competitions.py create mode 100644 backend/services/sports_center/app/tests/test_staff.py create mode 100644 backend/services/sports_center/app/tests/test_training.py create mode 100644 backend/services/sports_center/app/validators/staff.py create mode 100644 backend/services/sports_center/scripts/fix_indent.py create mode 100644 backend/services/sports_center/scripts/fix_services.py create mode 100644 docs-runtime/ai-framework/definition-of-done.md create mode 100644 docs-runtime/ai-framework/development-loop.md create mode 100644 docs-runtime/ai-framework/enterprise-phase-discovery.md create mode 100644 docs-runtime/ai-framework/phase-manifest.yaml create mode 100644 docs-runtime/ai-framework/quality-gates.md create mode 100644 docs-runtime/ai-framework/service-manifest.yaml create mode 100644 docs-runtime/glossary.md create mode 100644 docs-runtime/module-registry.md create mode 100644 docs-runtime/next-steps.md create mode 100644 docs-runtime/progress.md create mode 100644 docs/ai-framework/boundary-rules.md create mode 100644 docs/ai-framework/context-cache-policy.md create mode 100644 docs/ai-framework/definition-of-done.md create mode 100644 docs/ai-framework/enterprise-completeness.md create mode 100644 docs/ai-framework/enterprise-phase-discovery.md create mode 100644 docs/ai-framework/mandatory-phase-artifacts.md create mode 100644 docs/ai-framework/runtime-read-policy.md create mode 100644 docs/ai-framework/service-snapshot-policy.md create mode 100644 docs/architecture/adr/ADR-017.md create mode 100644 docs/architecture/adr/ADR-018.md create mode 100644 docs/architecture/adr/ADR-019.md create mode 100644 docs/delivery-phase-10-0.md create mode 100644 docs/delivery-phase-10-1.md create mode 100644 docs/frontend/healthcare-design-system.md create mode 100644 docs/healthcare-roadmap.md create mode 100644 docs/hospitality-phase-12-0.md create mode 100644 docs/hospitality-phase-12-1.md create mode 100644 docs/hospitality-phase-12-2.md create mode 100644 docs/hospitality-phase-12-3.md create mode 100644 docs/hospitality-phase-12-4.md create mode 100644 docs/hospitality-phase-12-5.md create mode 100644 docs/hospitality-phase-12-6.md create mode 100644 docs/hospitality-phase-12-7.md create mode 100644 docs/hospitality-phase-12-8.md create mode 100644 docs/hospitality-roadmap.md create mode 100644 docs/phase-handover/phase-10-0.md create mode 100644 docs/phase-handover/phase-10-1.md create mode 100644 docs/phase-handover/phase-12-0.md create mode 100644 docs/phase-handover/phase-12-1.md create mode 100644 docs/phase-handover/phase-12-2.md create mode 100644 docs/phase-handover/phase-12-3.md create mode 100644 docs/phase-handover/phase-12-4.md create mode 100644 docs/phase-handover/phase-12-5.md create mode 100644 docs/phase-handover/phase-12-6.md create mode 100644 docs/phase-handover/phase-12-7.md create mode 100644 docs/phase-handover/phase-12-8.md create mode 100644 docs/phase-handover/phase-13-0.md create mode 100644 docs/phase-handover/phase-13-1.md create mode 100644 docs/phase-handover/phase-13-2.md create mode 100644 docs/phase-handover/phase-13-3.md create mode 100644 docs/phase-handover/phase-13-4.md create mode 100644 docs/phase-handover/phase-13-5.md create mode 100644 docs/phase-handover/phase-13-6.md create mode 100644 docs/phase-handover/phase-13-7.md create mode 100644 docs/phase-handover/phase-9-3.md create mode 100644 docs/phase-handover/phase-9-4.md create mode 100644 docs/phase-handover/phase-9-5.md create mode 100644 docs/phase-handover/phase-9-6.md create mode 100644 docs/phase-handover/phase-9-7.md create mode 100644 docs/phase-handover/phase-af-context-cache-policy.md create mode 100644 docs/phase-handover/phase-af-discovery.md create mode 100644 docs/phase-handover/phase-af-enterprise.md create mode 100644 docs/phase-handover/phase-af-project-index.md create mode 100644 docs/phase-handover/phase-af-runtime-read-policy.md create mode 100644 docs/phase-handover/phase-af-service-snapshot.md create mode 100644 docs/phases/Healthcare/README.md create mode 100644 docs/phases/Healthcare/phase-13-0-healthcare-foundation.md create mode 100644 docs/phases/Healthcare/phase-13-1-appointment-engine.md create mode 100644 docs/phases/Healthcare/phase-13-2-doctor-panel.md create mode 100644 docs/phases/Healthcare/phase-13-3-clinic-management.md create mode 100644 docs/phases/Healthcare/phase-13-4-patient-portal.md create mode 100644 docs/phases/Healthcare/phase-13-5-medical-record.md create mode 100644 docs/phases/Healthcare/phase-13-6-pharmacy-network.md create mode 100644 docs/phases/Healthcare/phase-13-7-delivery-integration.md create mode 100644 docs/phases/Hospitality/README.md create mode 100644 docs/service-snapshots/README.md create mode 100644 docs/service-snapshots/_template.yaml create mode 100644 docs/service-snapshots/healthcare.yaml create mode 100644 docs/service-snapshots/hospitality.yaml create mode 100644 docs/service-snapshots/sports-center.yaml create mode 100644 docs/sports-center-phase-9-3.md create mode 100644 docs/sports-center-phase-9-4.md create mode 100644 docs/sports-center-phase-9-5.md create mode 100644 docs/sports-center-phase-9-6.md create mode 100644 docs/sports-center-phase-9-7.md create mode 100644 scripts/deploy_platform_full.py diff --git a/README.md b/README.md index 6bfbc4d..744bc5d 100644 --- a/README.md +++ b/README.md @@ -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/) | AI Development Framework (implementation lifecycle) | +| [`docs/ai-framework/`](./docs/ai-framework/) | Enterprise / AI Development Framework (implementation lifecycle; production-ready by default) | | [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry | | [`docs/glossary.md`](./docs/glossary.md) | Glossary | | [`docs/progress.md`](./docs/progress.md) | Completed work | @@ -62,6 +62,7 @@ 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). diff --git a/backend/core-service/alembic/versions/0006_platform_catalog_seed.py b/backend/core-service/alembic/versions/0006_platform_catalog_seed.py new file mode 100644 index 0000000..6c9e0b4 --- /dev/null +++ b/backend/core-service/alembic/versions/0006_platform_catalog_seed.py @@ -0,0 +1,103 @@ +"""seed platform service registry and base features + +Revision ID: 0006_platform_catalog_seed +Revises: 0005_tenant_onboarding +Create Date: 2026-07-27 +""" +from __future__ import annotations + +import uuid +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0006_platform_catalog_seed" +down_revision: Union[str, None] = "0005_tenant_onboarding" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SERVICES = [ + ("accounting", "حسابداری آنلاین", "http://accounting-service:8002"), + ("healthcare", "تربت هلث", "http://healthcare-service:8010"), + ("beauty", "تربت بیوتی", "http://beauty-business-service:8011"), + ("crm", "سیستم CRM", "http://crm-service:8003"), + ("loyalty", "تربت وفاداری", "http://loyalty-service:8004"), + ("communication", "ارتباطات و پیامک", "http://communication-service:8005"), + ("sports_center", "مرکز ورزشی", "http://sports-center-service:8006"), + ("delivery", "تربت درایور", "http://delivery-service:8007"), + ("experience", "پلتفرم تجربه", "http://experience-service:8008"), + ("hospitality", "تربت فود", "http://hospitality-service:8009"), + ("identity", "هویت و دسترسی", "http://identity-access-service:8001"), +] + +FEATURES = [ + ("accounting.access", "دسترسی حسابداری", "accounting"), + ("healthcare.access", "دسترسی سلامت", "healthcare"), + ("beauty.access", "دسترسی زیبایی", "beauty"), + ("crm.access", "دسترسی CRM", "crm"), + ("loyalty.access", "دسترسی وفاداری", "loyalty"), + ("communication.access", "دسترسی ارتباطات", "communication"), + ("sports_center.access", "دسترسی مرکز ورزشی", "sports_center"), + ("delivery.access", "دسترسی لجستیک", "delivery"), + ("experience.access", "دسترسی تجربه", "experience"), + ("hospitality.access", "دسترسی رستوران", "hospitality"), +] + + +def upgrade() -> None: + for service_key, name, base_url in SERVICES: + op.execute( + sa.text( + """ + INSERT INTO service_registry (id, service_key, name, base_url, health_check_url, status) + VALUES (CAST(:id AS uuid), :service_key, :name, :base_url, :health_url, 'active') + ON CONFLICT (service_key) DO NOTHING + """ + ).bindparams( + id=str(uuid.uuid4()), + service_key=service_key, + name=name, + base_url=base_url, + health_url=f"{base_url}/health", + ) + ) + + for feature_key, name, service_key in FEATURES: + op.execute( + sa.text( + """ + INSERT INTO features (id, feature_key, name, description, service_key, is_active) + VALUES (CAST(:id AS uuid), :feature_key, :name, :description, :service_key, true) + ON CONFLICT (feature_key) DO NOTHING + """ + ).bindparams( + id=str(uuid.uuid4()), + feature_key=feature_key, + name=name, + description=f"دسترسی پایه به سرویس {service_key}", + service_key=service_key, + ) + ) + + for feature_key, _, _ in FEATURES: + op.execute( + sa.text( + """ + INSERT INTO plan_features (id, plan_id, feature_id, is_enabled) + SELECT CAST(:id AS uuid), p.id, f.id, true + FROM plans p + CROSS JOIN features f + WHERE p.code = 'FREE' AND f.feature_key = :feature_key + ON CONFLICT (plan_id, feature_id) DO NOTHING + """ + ).bindparams(id=str(uuid.uuid4()), feature_key=feature_key) + ) + + +def downgrade() -> None: + feature_keys = ", ".join(f"'{key}'" for key, _, _ in FEATURES) + service_keys = ", ".join(f"'{key}'" for key, _, _ in SERVICES) + op.execute(f"DELETE FROM plan_features WHERE feature_id IN (SELECT id FROM features WHERE feature_key IN ({feature_keys}))") + op.execute(f"DELETE FROM features WHERE feature_key IN ({feature_keys})") + op.execute(f"DELETE FROM service_registry WHERE service_key IN ({service_keys})") diff --git a/backend/core-service/scripts/seed_platform_catalog.py b/backend/core-service/scripts/seed_platform_catalog.py new file mode 100644 index 0000000..98444b2 --- /dev/null +++ b/backend/core-service/scripts/seed_platform_catalog.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Idempotent seed for service_registry and base platform features.""" +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.plan import Feature, Plan, PlanFeature +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", + }, +] + +PLATFORM_FEATURES: list[dict[str, str]] = [ + {"feature_key": "accounting.access", "name": "دسترسی حسابداری", "service_key": "accounting"}, + {"feature_key": "healthcare.access", "name": "دسترسی سلامت", "service_key": "healthcare"}, + {"feature_key": "beauty.access", "name": "دسترسی زیبایی", "service_key": "beauty"}, + {"feature_key": "crm.access", "name": "دسترسی CRM", "service_key": "crm"}, + {"feature_key": "loyalty.access", "name": "دسترسی وفاداری", "service_key": "loyalty"}, + {"feature_key": "communication.access", "name": "دسترسی ارتباطات", "service_key": "communication"}, + {"feature_key": "sports_center.access", "name": "دسترسی مرکز ورزشی", "service_key": "sports_center"}, + {"feature_key": "delivery.access", "name": "دسترسی لجستیک", "service_key": "delivery"}, + {"feature_key": "experience.access", "name": "دسترسی تجربه", "service_key": "experience"}, + {"feature_key": "hospitality.access", "name": "دسترسی رستوران", "service_key": "hospitality"}, +] + + +async def seed(session: AsyncSession) -> tuple[int, int]: + services_added = 0 + features_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 + + for row in PLATFORM_FEATURES: + existing = await session.scalar( + select(Feature).where(Feature.feature_key == row["feature_key"]) + ) + if existing is None: + session.add( + Feature( + feature_key=row["feature_key"], + name=row["name"], + description=f"دسترسی پایه به سرویس {row['service_key']}", + service_key=row["service_key"], + is_active=True, + ) + ) + features_added += 1 + + await session.flush() + + free_plan = await session.scalar(select(Plan).where(Plan.code == "FREE")) + if free_plan is not None: + for row in PLATFORM_FEATURES: + feature = await session.scalar( + select(Feature).where(Feature.feature_key == row["feature_key"]) + ) + if feature is None: + continue + link = await session.scalar( + select(PlanFeature).where( + PlanFeature.plan_id == free_plan.id, + PlanFeature.feature_id == feature.id, + ) + ) + if link is None: + session.add( + PlanFeature( + plan_id=free_plan.id, + feature_id=feature.id, + is_enabled=True, + ) + ) + + await session.commit() + return services_added, features_added + + +async def main() -> None: + engine = create_async_engine(settings.database_url, echo=False) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + services_added, features_added = await seed(session) + await engine.dispose() + print( + f"seed_platform_catalog: services_added={services_added}, features_added={features_added}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/services/delivery/Dockerfile.dev b/backend/services/delivery/Dockerfile.dev new file mode 100644 index 0000000..60b0f5a --- /dev/null +++ b/backend/services/delivery/Dockerfile.dev @@ -0,0 +1,22 @@ +# Dev image: dependencies only — code mounted with uvicorn --reload +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/shared-lib/ /shared-lib/ +COPY backend/services/delivery/requirements.txt /app/requirements.txt + +RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \ + && pip install --upgrade pip \ + && pip install -r requirements.txt + +EXPOSE 8007 diff --git a/backend/services/delivery/README.md b/backend/services/delivery/README.md new file mode 100644 index 0000000..e176778 --- /dev/null +++ b/backend/services/delivery/README.md @@ -0,0 +1,63 @@ +# Delivery & Fleet Platform (Torbat Driver) + +| Field | Value | +| --- | --- | +| Service | `delivery-service` | +| Database | `delivery_db` | +| Port | 8007 | +| Permission prefix | `delivery.*` | +| Version | 0.10.1.0 | +| Phase | 10.1 Driver Management | +| ADR | [ADR-015](../../../docs/architecture/adr/ADR-015.md) | + +## Owns + +- Delivery organizations, hubs, configuration/settings shells +- External provider / routing-engine **registrations** (adapters later) +- **Drivers**: profiles, lifecycle, credential/document refs, lifecycle history +- Delivery audit log + transactional outbox for driver events +- Health / capabilities / metrics discovery +- Publish-only `delivery.*` events + +## Does not own + +- Accounting journals / Posting Engine +- Communication SMS/email providers or message delivery timeline +- Loyalty ledger +- Vertical order aggregates (Hospitality, Marketplace, Store, Pharmacy, Clinic, Sports Center) +- Fleet / vehicles / dispatch / routing / tracking **engines** (later phases) +- Identity user admin / CRM contact master / Storage blobs +- Driver App / Dispatcher Panel UI (frontend) + +## APIs + +| Method | Path | +| --- | --- | +| GET | `/health` | +| GET | `/capabilities` | +| GET | `/metrics` | +| CRUD | `/api/v1/organizations` | +| CRUD | `/api/v1/hubs` | +| CRUD | `/api/v1/external-providers` | +| CRUD | `/api/v1/routing-engines` | +| CRUD | `/api/v1/configurations` | +| PUT/GET | `/api/v1/settings` | +| GET | `/api/v1/audit` | +| CRUD + lifecycle | `/api/v1/drivers` | +| GET | `/api/v1/permissions/catalog` | + +## Run (dev) + +```bash +export DELIVERY_DATABASE_URL=postgresql+asyncpg://... +export DELIVERY_DATABASE_URL_SYNC=postgresql+psycopg://... +python scripts/ensure_db.py +alembic upgrade head +uvicorn app.main:app --host 0.0.0.0 --port 8007 --reload +``` + +## Tests + +```bash +pytest +``` diff --git a/backend/services/delivery/alembic.ini b/backend/services/delivery/alembic.ini new file mode 100644 index 0000000..545b0df --- /dev/null +++ b/backend/services/delivery/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +path_separator = os +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/services/delivery/alembic/env.py b/backend/services/delivery/alembic/env.py new file mode 100644 index 0000000..ba24b6c --- /dev/null +++ b/backend/services/delivery/alembic/env.py @@ -0,0 +1,42 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.core.database import Base +import app.models # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url_sync) +if config.config_file_name: + fileConfig(config.config_file_name) +target_metadata = Base.metadata + + +def run_migrations_offline(): + context.configure( + url=settings.database_url_sync, + target_metadata=target_metadata, + literal_binds=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/services/delivery/alembic/versions/0001_initial.py b/backend/services/delivery/alembic/versions/0001_initial.py new file mode 100644 index 0000000..a0d966d --- /dev/null +++ b/backend/services/delivery/alembic/versions/0001_initial.py @@ -0,0 +1,19 @@ +"""Initial Delivery schema — Phase 10.0 foundation.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + Base.metadata.drop_all(bind=bind) diff --git a/backend/services/delivery/alembic/versions/0002_phase_101_drivers.py b/backend/services/delivery/alembic/versions/0002_phase_101_drivers.py new file mode 100644 index 0000000..04177cb --- /dev/null +++ b/backend/services/delivery/alembic/versions/0002_phase_101_drivers.py @@ -0,0 +1,30 @@ +"""Phase 10.1 — Driver Management schema (additive).""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0002_phase_101_drivers" +down_revision = "0001_initial" +branch_labels = None +depends_on = None + +_NEW_TABLES = ( + "drivers", + "driver_credentials", + "driver_documents", + "driver_lifecycle_events", + "outbox_events", +) + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for name in reversed(_NEW_TABLES): + table = Base.metadata.tables.get(name) + if table is not None: + table.drop(bind=bind, checkfirst=True) diff --git a/backend/services/delivery/app/__init__.py b/backend/services/delivery/app/__init__.py new file mode 100644 index 0000000..c0bbe9d --- /dev/null +++ b/backend/services/delivery/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.10.1.0" diff --git a/backend/services/delivery/app/api/deps.py b/backend/services/delivery/app/api/deps.py new file mode 100644 index 0000000..56d4194 --- /dev/null +++ b/backend/services/delivery/app/api/deps.py @@ -0,0 +1,39 @@ +"""Common API dependencies.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user +from shared.exceptions import TenantNotResolvedError +from shared.pagination import PaginationParams +from shared.security import CurrentUser +from shared.tenant import STATE_TENANT_ID + +__all__ = [ + "get_db", + "get_pagination", + "require_tenant", + "get_current_user", + "AsyncSession", + "CurrentUser", +] + + +def get_pagination( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=500), +) -> PaginationParams: + return PaginationParams(page=page, page_size=page_size) + + +def require_tenant(request: Request) -> UUID: + tenant_id = getattr(request.state, STATE_TENANT_ID, None) + if tenant_id is None: + raise TenantNotResolvedError( + "tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید." + ) + return tenant_id diff --git a/backend/services/delivery/app/api/permissions.py b/backend/services/delivery/app/api/permissions.py new file mode 100644 index 0000000..357a025 --- /dev/null +++ b/backend/services/delivery/app/api/permissions.py @@ -0,0 +1,48 @@ +"""Permission enforcement helpers for Delivery APIs.""" +from __future__ import annotations + +from collections.abc import Callable + +from fastapi import Depends + +from app.core.config import settings +from app.core.security import get_current_user +from shared.exceptions import ForbiddenError +from shared.security import CurrentUser + +_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"}) + + +def user_has_permission(user: CurrentUser, permission: str) -> bool: + if any(role in _ADMIN_ROLES for role in user.roles): + return True + roles = set(user.roles) + if "delivery.manage" in roles or permission in roles: + return True + if "delivery.view" in roles and permission.endswith(".view"): + return True + parts = permission.split(".") + if len(parts) >= 3: + manage = f"{parts[0]}.{parts[1]}.manage" + if manage in roles: + return True + return False + + +def require_permissions(*permissions: str) -> Callable: + """Deny unless AUTH is off, user is admin, or any listed permission is present.""" + + async def _dependency( + user: CurrentUser = Depends(get_current_user), + ) -> CurrentUser: + if not settings.auth_required: + return user + if any(user_has_permission(user, perm) for perm in permissions): + return user + raise ForbiddenError( + "دسترسی مجاز نیست", + error_code="permission_denied", + details={"required": list(permissions)}, + ) + + return _dependency diff --git a/backend/services/delivery/app/api/v1/__init__.py b/backend/services/delivery/app/api/v1/__init__.py new file mode 100644 index 0000000..826473c --- /dev/null +++ b/backend/services/delivery/app/api/v1/__init__.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter + +from app.api.v1 import ( + audit, + configurations, + drivers, + external_providers, + hubs, + organizations, + permissions, + routing_engines, + settings, +) + +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( + permissions.router, prefix="/permissions", tags=["permissions"] +) diff --git a/backend/services/delivery/app/api/v1/audit.py b/backend/services/delivery/app/api/v1/audit.py new file mode 100644 index 0000000..d734ace --- /dev/null +++ b/backend/services/delivery/app/api/v1/audit.py @@ -0,0 +1,30 @@ +"""Delivery audit APIs — Phase 10.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import AUDIT_VIEW +from app.schemas.foundation import DeliveryAuditLogRead +from app.services.audit_service import AuditService +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("", response_model=list[DeliveryAuditLogRead]) +async def list_audit( + entity_type: str = Query(...), + entity_id: UUID = Query(...), + limit: int = Query(default=100, ge=1, le=500), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(AUDIT_VIEW)), +): + return await AuditService(db).list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/delivery/app/api/v1/configurations.py b/backend/services/delivery/app/api/v1/configurations.py new file mode 100644 index 0000000..ac3aa65 --- /dev/null +++ b/backend/services/delivery/app/api/v1/configurations.py @@ -0,0 +1,83 @@ +"""Delivery configuration APIs — Phase 10.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + CONFIGURATIONS_CREATE, + CONFIGURATIONS_DELETE, + CONFIGURATIONS_UPDATE, + CONFIGURATIONS_VIEW, +) +from app.schemas.foundation import ( + DeliveryConfigurationCreate, + DeliveryConfigurationRead, + DeliveryConfigurationUpdate, +) +from app.services.foundation import DeliveryConfigurationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DeliveryConfigurationRead, status_code=status.HTTP_201_CREATED) +async def create_configuration( + body: DeliveryConfigurationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_CREATE)), +): + return await DeliveryConfigurationService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DeliveryConfigurationRead]) +async def list_configurations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)), +): + return await DeliveryConfigurationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{configuration_id}", response_model=DeliveryConfigurationRead) +async def get_configuration( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)), +): + return await DeliveryConfigurationService(db).get(tenant_id, configuration_id) + + +@router.patch("/{configuration_id}", response_model=DeliveryConfigurationRead) +async def update_configuration( + configuration_id: UUID, + body: DeliveryConfigurationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_UPDATE)), +): + return await DeliveryConfigurationService(db).update( + tenant_id, configuration_id, body, actor=user + ) + + +@router.post("/{configuration_id}/delete", response_model=DeliveryConfigurationRead) +async def soft_delete_configuration( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_DELETE)), +): + return await DeliveryConfigurationService(db).soft_delete( + tenant_id, configuration_id, actor=user + ) diff --git a/backend/services/delivery/app/api/v1/drivers.py b/backend/services/delivery/app/api/v1/drivers.py new file mode 100644 index 0000000..dce6532 --- /dev/null +++ b/backend/services/delivery/app/api/v1/drivers.py @@ -0,0 +1,273 @@ +"""Driver management APIs — Phase 10.1.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.drivers import DriverCommands +from app.models.types import DriverStatus +from app.permissions.definitions import ( + DRIVERS_ACTIVATE, + DRIVERS_ARCHIVE, + DRIVERS_BLOCK, + DRIVERS_CREATE, + DRIVERS_CREDENTIALS_MANAGE, + DRIVERS_CREDENTIALS_VIEW, + DRIVERS_DEACTIVATE, + DRIVERS_DELETE, + DRIVERS_DOCUMENTS_MANAGE, + DRIVERS_DOCUMENTS_VIEW, + DRIVERS_LIFECYCLE_VIEW, + DRIVERS_RESUME, + DRIVERS_SUSPEND, + DRIVERS_UNBLOCK, + DRIVERS_UPDATE, + DRIVERS_VIEW, +) +from app.queries.drivers import DriverQueries +from app.schemas.drivers import ( + DriverCreate, + DriverCredentialCreate, + DriverCredentialRead, + DriverDocumentCreate, + DriverDocumentRead, + DriverLifecycleEventRead, + DriverLifecycleRequest, + DriverListResponse, + DriverRead, + DriverUpdate, +) +from app.specifications.drivers import ALLOWED_SORT_FIELDS, DriverListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _list_spec( + status_filter: DriverStatus | None = Query(default=None, alias="status"), + organization_id: UUID | None = Query(default=None), + hub_id: UUID | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> DriverListSpec: + if sort_by not in ALLOWED_SORT_FIELDS: + sort_by = "created_at" + return DriverListSpec( + status=status_filter, + organization_id=organization_id, + hub_id=hub_id, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post("", response_model=DriverRead, status_code=status.HTTP_201_CREATED) +async def create_driver( + body: DriverCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_CREATE)), +): + return await DriverCommands(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=DriverListResponse) +async def list_drivers( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: DriverListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DRIVERS_VIEW)), +): + items, total = await DriverQueries(db).list( + tenant_id, + offset=pagination.offset, + limit=pagination.page_size, + spec=spec, + ) + return DriverListResponse( + items=items, + total=total, + page=pagination.page, + page_size=pagination.page_size, + ) + + +@router.get("/{driver_id}", response_model=DriverRead) +async def get_driver( + driver_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DRIVERS_VIEW)), +): + return await DriverQueries(db).get(tenant_id, driver_id) + + +@router.patch("/{driver_id}", response_model=DriverRead) +async def update_driver( + driver_id: UUID, + body: DriverUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_UPDATE)), +): + return await DriverCommands(db).update(tenant_id, driver_id, body, actor=user) + + +@router.post("/{driver_id}/activate", response_model=DriverRead) +async def activate_driver( + driver_id: UUID, + body: DriverLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_ACTIVATE)), +): + return await DriverCommands(db).activate(tenant_id, driver_id, body, actor=user) + + +@router.post("/{driver_id}/suspend", response_model=DriverRead) +async def suspend_driver( + driver_id: UUID, + body: DriverLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_SUSPEND)), +): + return await DriverCommands(db).suspend(tenant_id, driver_id, body, actor=user) + + +@router.post("/{driver_id}/resume", response_model=DriverRead) +async def resume_driver( + driver_id: UUID, + body: DriverLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_RESUME)), +): + return await DriverCommands(db).resume(tenant_id, driver_id, body, actor=user) + + +@router.post("/{driver_id}/deactivate", response_model=DriverRead) +async def deactivate_driver( + driver_id: UUID, + body: DriverLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_DEACTIVATE)), +): + return await DriverCommands(db).deactivate(tenant_id, driver_id, body, actor=user) + + +@router.post("/{driver_id}/block", response_model=DriverRead) +async def block_driver( + driver_id: UUID, + body: DriverLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_BLOCK)), +): + return await DriverCommands(db).block(tenant_id, driver_id, body, actor=user) + + +@router.post("/{driver_id}/unblock", response_model=DriverRead) +async def unblock_driver( + driver_id: UUID, + body: DriverLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_UNBLOCK)), +): + return await DriverCommands(db).unblock(tenant_id, driver_id, body, actor=user) + + +@router.post("/{driver_id}/archive", response_model=DriverRead) +async def archive_driver( + driver_id: UUID, + body: DriverLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_ARCHIVE)), +): + return await DriverCommands(db).archive(tenant_id, driver_id, body, actor=user) + + +@router.get("/{driver_id}/lifecycle", response_model=list[DriverLifecycleEventRead]) +async def list_lifecycle( + driver_id: UUID, + limit: int = Query(default=100, ge=1, le=500), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DRIVERS_LIFECYCLE_VIEW)), +): + return await DriverQueries(db).lifecycle(tenant_id, driver_id, limit=limit) + + +@router.post( + "/{driver_id}/credentials", + response_model=DriverCredentialRead, + status_code=status.HTTP_201_CREATED, +) +async def add_credential( + driver_id: UUID, + body: DriverCredentialCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_CREDENTIALS_MANAGE)), +): + return await DriverCommands(db).add_credential( + tenant_id, driver_id, body, actor=user + ) + + +@router.get("/{driver_id}/credentials", response_model=list[DriverCredentialRead]) +async def list_credentials( + driver_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DRIVERS_CREDENTIALS_VIEW)), +): + return await DriverQueries(db).credentials(tenant_id, driver_id) + + +@router.post( + "/{driver_id}/documents", + response_model=DriverDocumentRead, + status_code=status.HTTP_201_CREATED, +) +async def add_document( + driver_id: UUID, + body: DriverDocumentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_DOCUMENTS_MANAGE)), +): + return await DriverCommands(db).add_document( + tenant_id, driver_id, body, actor=user + ) + + +@router.get("/{driver_id}/documents", response_model=list[DriverDocumentRead]) +async def list_documents( + driver_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DRIVERS_DOCUMENTS_VIEW)), +): + return await DriverQueries(db).documents(tenant_id, driver_id) + + +@router.post("/{driver_id}/delete", response_model=DriverRead) +async def soft_delete_driver( + driver_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DRIVERS_DELETE)), +): + return await DriverCommands(db).soft_delete(tenant_id, driver_id, actor=user) diff --git a/backend/services/delivery/app/api/v1/external_providers.py b/backend/services/delivery/app/api/v1/external_providers.py new file mode 100644 index 0000000..536b3ed --- /dev/null +++ b/backend/services/delivery/app/api/v1/external_providers.py @@ -0,0 +1,83 @@ +"""External provider config APIs — Phase 10.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + EXTERNAL_PROVIDERS_CREATE, + EXTERNAL_PROVIDERS_DELETE, + EXTERNAL_PROVIDERS_UPDATE, + EXTERNAL_PROVIDERS_VIEW, +) +from app.schemas.foundation import ( + ExternalProviderConfigCreate, + ExternalProviderConfigRead, + ExternalProviderConfigUpdate, +) +from app.services.foundation import ExternalProviderConfigService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ExternalProviderConfigRead, status_code=status.HTTP_201_CREATED) +async def create_provider( + body: ExternalProviderConfigCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_CREATE)), +): + return await ExternalProviderConfigService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExternalProviderConfigRead]) +async def list_providers( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)), +): + return await ExternalProviderConfigService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{provider_id}", response_model=ExternalProviderConfigRead) +async def get_provider( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)), +): + return await ExternalProviderConfigService(db).get(tenant_id, provider_id) + + +@router.patch("/{provider_id}", response_model=ExternalProviderConfigRead) +async def update_provider( + provider_id: UUID, + body: ExternalProviderConfigUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_UPDATE)), +): + return await ExternalProviderConfigService(db).update( + tenant_id, provider_id, body, actor=user + ) + + +@router.post("/{provider_id}/delete", response_model=ExternalProviderConfigRead) +async def soft_delete_provider( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_DELETE)), +): + return await ExternalProviderConfigService(db).soft_delete( + tenant_id, provider_id, actor=user + ) diff --git a/backend/services/delivery/app/api/v1/health.py b/backend/services/delivery/app/api/v1/health.py new file mode 100644 index 0000000..53005ed --- /dev/null +++ b/backend/services/delivery/app/api/v1/health.py @@ -0,0 +1,86 @@ +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.1", + "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": False, + "dispatch_engine": False, + "routing_engine": False, + "tracking": False, + "proof_of_delivery": False, + "settlement": False, + "ai": False, + }, + "independence": { + "standalone": True, + "superapp": True, + "integrations": [ + "accounting", + "communication", + "loyalty", + "crm", + "hospitality", + "marketplace", + "sports_center", + "clinic", + "pharmacy", + ], + "integration_mode": "api_and_events_only", + }, + } + + +@router.get("/metrics") +async def metrics(): + """Phase 10.1 metrics discovery — driver counters reserved for ops wiring.""" + return { + "service": settings.service_name, + "version": __version__, + "phase": "10.1", + "metrics": { + "organizations": "tenant_scoped", + "hubs": "tenant_scoped", + "external_providers": "tenant_scoped", + "routing_engines": "tenant_scoped", + "configurations": "tenant_scoped", + "drivers": "tenant_scoped", + "driver_credentials": "tenant_scoped", + "driver_documents": "tenant_scoped", + "driver_lifecycle_events": "tenant_scoped", + "outbox_events": "tenant_scoped", + "dispatch_jobs": 0, + "active_routes": 0, + "tracking_sessions": 0, + }, + "note": "Driver aggregates live; dispatch/routing/tracking counters reserved for later phases", + } diff --git a/backend/services/delivery/app/api/v1/hubs.py b/backend/services/delivery/app/api/v1/hubs.py new file mode 100644 index 0000000..c0caf59 --- /dev/null +++ b/backend/services/delivery/app/api/v1/hubs.py @@ -0,0 +1,70 @@ +"""Delivery hub APIs — Phase 10.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import HUBS_CREATE, HUBS_DELETE, HUBS_UPDATE, HUBS_VIEW +from app.schemas.foundation import DeliveryHubCreate, DeliveryHubRead, DeliveryHubUpdate +from app.services.foundation import DeliveryHubService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DeliveryHubRead, status_code=status.HTTP_201_CREATED) +async def create_hub( + body: DeliveryHubCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(HUBS_CREATE)), +): + return await DeliveryHubService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DeliveryHubRead]) +async def list_hubs( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(HUBS_VIEW)), +): + return await DeliveryHubService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{hub_id}", response_model=DeliveryHubRead) +async def get_hub( + hub_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(HUBS_VIEW)), +): + return await DeliveryHubService(db).get(tenant_id, hub_id) + + +@router.patch("/{hub_id}", response_model=DeliveryHubRead) +async def update_hub( + hub_id: UUID, + body: DeliveryHubUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(HUBS_UPDATE)), +): + return await DeliveryHubService(db).update(tenant_id, hub_id, body, actor=user) + + +@router.post("/{hub_id}/delete", response_model=DeliveryHubRead) +async def soft_delete_hub( + hub_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(HUBS_DELETE)), +): + return await DeliveryHubService(db).soft_delete(tenant_id, hub_id, actor=user) diff --git a/backend/services/delivery/app/api/v1/organizations.py b/backend/services/delivery/app/api/v1/organizations.py new file mode 100644 index 0000000..2c5f7ff --- /dev/null +++ b/backend/services/delivery/app/api/v1/organizations.py @@ -0,0 +1,83 @@ +"""Delivery organization APIs — Phase 10.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + ORGANIZATIONS_CREATE, + ORGANIZATIONS_DELETE, + ORGANIZATIONS_UPDATE, + ORGANIZATIONS_VIEW, +) +from app.schemas.foundation import ( + DeliveryOrganizationCreate, + DeliveryOrganizationRead, + DeliveryOrganizationUpdate, +) +from app.services.foundation import DeliveryOrganizationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DeliveryOrganizationRead, status_code=status.HTTP_201_CREATED) +async def create_organization( + body: DeliveryOrganizationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_CREATE)), +): + return await DeliveryOrganizationService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DeliveryOrganizationRead]) +async def list_organizations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)), +): + return await DeliveryOrganizationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{organization_id}", response_model=DeliveryOrganizationRead) +async def get_organization( + organization_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)), +): + return await DeliveryOrganizationService(db).get(tenant_id, organization_id) + + +@router.patch("/{organization_id}", response_model=DeliveryOrganizationRead) +async def update_organization( + organization_id: UUID, + body: DeliveryOrganizationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_UPDATE)), +): + return await DeliveryOrganizationService(db).update( + tenant_id, organization_id, body, actor=user + ) + + +@router.post("/{organization_id}/delete", response_model=DeliveryOrganizationRead) +async def soft_delete_organization( + organization_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_DELETE)), +): + return await DeliveryOrganizationService(db).soft_delete( + tenant_id, organization_id, actor=user + ) diff --git a/backend/services/delivery/app/api/v1/permissions.py b/backend/services/delivery/app/api/v1/permissions.py new file mode 100644 index 0000000..53d48b9 --- /dev/null +++ b/backend/services/delivery/app/api/v1/permissions.py @@ -0,0 +1,27 @@ +"""Permission catalog discovery API — Phase 10.1.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + ALL_PERMISSIONS, + DELIVERY_VIEW, + PERMISSION_PREFIXES, +) +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("/catalog") +async def permission_catalog( + _user: CurrentUser = Depends(require_permissions(DELIVERY_VIEW)), +): + """Static permission discovery for Identity / admin consoles.""" + return { + "prefix": "delivery.", + "prefixes": list(PERMISSION_PREFIXES), + "permissions": list(ALL_PERMISSIONS), + "count": len(ALL_PERMISSIONS), + } diff --git a/backend/services/delivery/app/api/v1/routing_engines.py b/backend/services/delivery/app/api/v1/routing_engines.py new file mode 100644 index 0000000..04aba33 --- /dev/null +++ b/backend/services/delivery/app/api/v1/routing_engines.py @@ -0,0 +1,83 @@ +"""Routing engine registration APIs — Phase 10.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + ROUTING_ENGINES_CREATE, + ROUTING_ENGINES_DELETE, + ROUTING_ENGINES_UPDATE, + ROUTING_ENGINES_VIEW, +) +from app.schemas.foundation import ( + RoutingEngineRegistrationCreate, + RoutingEngineRegistrationRead, + RoutingEngineRegistrationUpdate, +) +from app.services.foundation import RoutingEngineRegistrationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=RoutingEngineRegistrationRead, status_code=status.HTTP_201_CREATED) +async def create_engine( + body: RoutingEngineRegistrationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_CREATE)), +): + return await RoutingEngineRegistrationService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[RoutingEngineRegistrationRead]) +async def list_engines( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_VIEW)), +): + return await RoutingEngineRegistrationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{engine_id}", response_model=RoutingEngineRegistrationRead) +async def get_engine( + engine_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_VIEW)), +): + return await RoutingEngineRegistrationService(db).get(tenant_id, engine_id) + + +@router.patch("/{engine_id}", response_model=RoutingEngineRegistrationRead) +async def update_engine( + engine_id: UUID, + body: RoutingEngineRegistrationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_UPDATE)), +): + return await RoutingEngineRegistrationService(db).update( + tenant_id, engine_id, body, actor=user + ) + + +@router.post("/{engine_id}/delete", response_model=RoutingEngineRegistrationRead) +async def soft_delete_engine( + engine_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_DELETE)), +): + return await RoutingEngineRegistrationService(db).soft_delete( + tenant_id, engine_id, actor=user + ) diff --git a/backend/services/delivery/app/api/v1/settings.py b/backend/services/delivery/app/api/v1/settings.py new file mode 100644 index 0000000..ed18bb0 --- /dev/null +++ b/backend/services/delivery/app/api/v1/settings.py @@ -0,0 +1,39 @@ +"""Delivery settings APIs — Phase 10.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import SETTINGS_MANAGE, SETTINGS_VIEW +from app.schemas.foundation import DeliverySettingRead, DeliverySettingUpsert +from app.services.foundation import DeliverySettingService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.put("", response_model=DeliverySettingRead, status_code=status.HTTP_200_OK) +async def upsert_setting( + body: DeliverySettingUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SETTINGS_MANAGE)), +): + return await DeliverySettingService(db).upsert(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DeliverySettingRead]) +async def list_settings( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SETTINGS_VIEW)), +): + return await DeliverySettingService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/delivery/app/commands/__init__.py b/backend/services/delivery/app/commands/__init__.py new file mode 100644 index 0000000..5e65b05 --- /dev/null +++ b/backend/services/delivery/app/commands/__init__.py @@ -0,0 +1,4 @@ +"""Driver commands package.""" +from app.commands.drivers import DriverCommands + +__all__ = ["DriverCommands"] diff --git a/backend/services/delivery/app/commands/drivers.py b/backend/services/delivery/app/commands/drivers.py new file mode 100644 index 0000000..4fe0279 --- /dev/null +++ b/backend/services/delivery/app/commands/drivers.py @@ -0,0 +1,148 @@ +"""Driver command handlers — Phase 10.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.types import DriverLifecycleAction +from app.schemas.drivers import ( + DriverCreate, + DriverCredentialCreate, + DriverDocumentCreate, + DriverLifecycleRequest, + DriverUpdate, +) +from app.services.drivers import DriverService +from shared.security import CurrentUser + + +class DriverCommands: + def __init__(self, session: AsyncSession) -> None: + self.service = DriverService(session) + + async def create( + self, tenant_id: UUID, body: DriverCreate, *, actor: CurrentUser | None + ): + return await self.service.create(tenant_id, body, actor=actor) + + async def update( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update(tenant_id, driver_id, body, actor=actor) + + async def activate( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverLifecycleRequest, + *, + actor: CurrentUser | None, + ): + return await self.service.apply_lifecycle( + tenant_id, driver_id, DriverLifecycleAction.ACTIVATE, body, actor=actor + ) + + async def suspend( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverLifecycleRequest, + *, + actor: CurrentUser | None, + ): + return await self.service.apply_lifecycle( + tenant_id, driver_id, DriverLifecycleAction.SUSPEND, body, actor=actor + ) + + async def resume( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverLifecycleRequest, + *, + actor: CurrentUser | None, + ): + return await self.service.apply_lifecycle( + tenant_id, driver_id, DriverLifecycleAction.RESUME, body, actor=actor + ) + + async def deactivate( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverLifecycleRequest, + *, + actor: CurrentUser | None, + ): + return await self.service.apply_lifecycle( + tenant_id, driver_id, DriverLifecycleAction.DEACTIVATE, body, actor=actor + ) + + async def block( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverLifecycleRequest, + *, + actor: CurrentUser | None, + ): + return await self.service.apply_lifecycle( + tenant_id, driver_id, DriverLifecycleAction.BLOCK, body, actor=actor + ) + + async def unblock( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverLifecycleRequest, + *, + actor: CurrentUser | None, + ): + return await self.service.apply_lifecycle( + tenant_id, driver_id, DriverLifecycleAction.UNBLOCK, body, actor=actor + ) + + async def archive( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverLifecycleRequest, + *, + actor: CurrentUser | None, + ): + return await self.service.apply_lifecycle( + tenant_id, driver_id, DriverLifecycleAction.ARCHIVE, body, actor=actor + ) + + async def soft_delete( + self, tenant_id: UUID, driver_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete(tenant_id, driver_id, actor=actor) + + async def add_credential( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverCredentialCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.add_credential( + tenant_id, driver_id, body, actor=actor + ) + + async def add_document( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverDocumentCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.add_document(tenant_id, driver_id, body, actor=actor) diff --git a/backend/services/delivery/app/core/config.py b/backend/services/delivery/app/core/config.py new file mode 100644 index 0000000..5ec8ef6 --- /dev/null +++ b/backend/services/delivery/app/core/config.py @@ -0,0 +1,75 @@ +"""Delivery Service settings.""" +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + environment: Literal["development", "staging", "production", "test"] = "development" + debug: bool = True + log_level: str = "INFO" + service_name: str = Field( + default="delivery-service", validation_alias="DELIVERY_SERVICE_NAME" + ) + api_v1_prefix: str = "/api/v1" + + database_url: str = Field( + default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/delivery_db", + validation_alias="DELIVERY_DATABASE_URL", + ) + database_url_sync: str = Field( + default="postgresql+psycopg://superapp:superapp_password@localhost:5432/delivery_db", + validation_alias="DELIVERY_DATABASE_URL_SYNC", + ) + + core_service_url: str = Field( + default="http://localhost:8000", validation_alias="CORE_SERVICE_URL" + ) + + keycloak_enabled: bool = True + keycloak_server_url: str = Field( + default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL" + ) + keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL") + keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM") + + jwt_algorithm: str = "RS256" + jwt_audience: str = "account" + jwt_verify_signature: bool = True + auth_required: bool = True + + cors_origins: str = Field( + default="http://localhost:3000,http://127.0.0.1:3000", + validation_alias="CORS_ORIGINS", + ) + + @property + def keycloak_public_base(self) -> str: + return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/") + + @property + def keycloak_public_realm_url(self) -> str: + return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}" + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/services/delivery/app/core/database.py b/backend/services/delivery/app/core/database.py new file mode 100644 index 0000000..e44c490 --- /dev/null +++ b/backend/services/delivery/app/core/database.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.pool import StaticPool + +from app.core.config import settings + + +class Base(DeclarativeBase): + pass + + +_engine_kwargs: dict = {"pool_pre_ping": True, "future": True} +if settings.database_url.startswith("sqlite"): + _engine_kwargs["poolclass"] = StaticPool + _engine_kwargs["connect_args"] = {"check_same_thread": False} + +engine = create_async_engine(settings.database_url, **_engine_kwargs) +AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise diff --git a/backend/services/delivery/app/core/logging.py b/backend/services/delivery/app/core/logging.py new file mode 100644 index 0000000..cc6f49a --- /dev/null +++ b/backend/services/delivery/app/core/logging.py @@ -0,0 +1,14 @@ +import logging +import sys + + +def configure_logging(level: str = "INFO") -> None: + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + stream=sys.stdout, + ) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/backend/services/delivery/app/core/security.py b/backend/services/delivery/app/core/security.py new file mode 100644 index 0000000..4ba748a --- /dev/null +++ b/backend/services/delivery/app/core/security.py @@ -0,0 +1,49 @@ +"""JWT authentication dependencies for Delivery service.""" +from __future__ import annotations + +from functools import lru_cache + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.config import settings +from shared.auth import JWTSettings, JWTValidator +from shared.exceptions import UnauthorizedError +from shared.security import CurrentUser + +_bearer = HTTPBearer(auto_error=False) + + +@lru_cache +def get_jwt_validator() -> JWTValidator: + return JWTValidator( + JWTSettings( + keycloak_enabled=settings.keycloak_enabled, + keycloak_server_url=settings.keycloak_server_url, + keycloak_realm=settings.keycloak_realm, + jwt_algorithm=settings.jwt_algorithm, + jwt_audience=settings.jwt_audience, + jwt_verify_signature=settings.jwt_verify_signature, + jwt_issuer=settings.keycloak_public_realm_url, + ) + ) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> CurrentUser: + if not settings.auth_required: + return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"]) + if credentials is None or not credentials.credentials: + raise UnauthorizedError("توکن احراز هویت ارائه نشده است") + return await get_jwt_validator().validate(credentials.credentials) + + +async def get_optional_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> CurrentUser | None: + if not settings.auth_required: + return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"]) + if credentials is None or not credentials.credentials: + return None + return await get_jwt_validator().validate(credentials.credentials) diff --git a/backend/services/delivery/app/events/publisher.py b/backend/services/delivery/app/events/publisher.py new file mode 100644 index 0000000..6d6a88d --- /dev/null +++ b/backend/services/delivery/app/events/publisher.py @@ -0,0 +1,121 @@ +"""Delivery event publisher — in-memory + transactional outbox (ADR-006).""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.events import EventEnvelope, EventStatus + +from app.core.config import settings +from app.events.types import DeliveryEventType +from app.models.outbox import OutboxEvent + + +class EventPublisher(Protocol): + def publish( + self, + *, + event_type: DeliveryEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: ... + + +class InMemoryEventPublisher: + """Records published envelopes for tests and local verification.""" + + def __init__(self) -> None: + self.published: list[EventEnvelope] = [] + + def publish( + self, + *, + event_type: DeliveryEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: + envelope = EventEnvelope( + event_id=uuid4(), + event_type=event_type.value, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + tenant_id=tenant_id, + source_service=settings.service_name, + payload=payload or {}, + ) + self.published.append(envelope) + return envelope + + def record(self, envelope: EventEnvelope) -> EventEnvelope: + self.published.append(envelope) + return envelope + + +class TransactionalEventPublisher: + """Persist outbox row in the current transaction; mirror to memory in tests.""" + + def __init__( + self, session: AsyncSession, memory: InMemoryEventPublisher | None = None + ) -> None: + self.session = session + if memory is not None: + self.memory = memory + elif settings.environment == "test": + self.memory = get_event_publisher() + else: + self.memory = None + + async def publish( + self, + *, + event_type: DeliveryEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: + envelope = EventEnvelope( + event_id=uuid4(), + event_type=event_type.value, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + tenant_id=tenant_id, + source_service=settings.service_name, + payload=payload or {}, + ) + row = OutboxEvent( + tenant_id=tenant_id, + event_type=envelope.event_type, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + payload={ + "event_id": str(envelope.event_id), + "source_service": settings.service_name, + **(payload or {}), + }, + status=EventStatus.PENDING, + ) + self.session.add(row) + await self.session.flush() + if self.memory is not None: + self.memory.record(envelope) + return envelope + + +_default_publisher = InMemoryEventPublisher() + + +def get_event_publisher() -> InMemoryEventPublisher: + return _default_publisher + + +def reset_event_publisher() -> InMemoryEventPublisher: + global _default_publisher + _default_publisher = InMemoryEventPublisher() + return _default_publisher diff --git a/backend/services/delivery/app/events/types.py b/backend/services/delivery/app/events/types.py new file mode 100644 index 0000000..8f54b37 --- /dev/null +++ b/backend/services/delivery/app/events/types.py @@ -0,0 +1,33 @@ +"""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" diff --git a/backend/services/delivery/app/main.py b/backend/services/delivery/app/main.py new file mode 100644 index 0000000..b0dd63f --- /dev/null +++ b/backend/services/delivery/app/main.py @@ -0,0 +1,69 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app import __version__ +from app.api.v1 import api_router +from app.api.v1 import health +from app.core.config import settings +from app.core.logging import configure_logging, get_logger +from app.middlewares.tenant import TenantHeaderMiddleware +from shared.exceptions import AppError +from shared.responses import ErrorDetail, ErrorResponse + +configure_logging(settings.log_level) +logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("service_starting", extra={"service": settings.service_name}) + yield + + +def create_app() -> FastAPI: + app = FastAPI( + title="Delivery & Fleet Platform", + version=__version__, + description="Torbat Driver — Delivery & Fleet Platform (Phase 10.1 Driver Management)", + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir", + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + app.add_middleware(TenantHeaderMiddleware) + + @app.exception_handler(AppError) + async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=ErrorResponse( + error=ErrorDetail( + code=exc.error_code, message=exc.message, details=exc.details + ) + ).model_dump(), + ) + + @app.exception_handler(Exception) + async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse: + logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc) + return JSONResponse( + status_code=500, + content=ErrorResponse( + error=ErrorDetail(code="internal_error", message="خطای داخلی سرور") + ).model_dump(), + ) + + app.include_router(health.router) + app.include_router(api_router, prefix=settings.api_v1_prefix) + return app + + +app = create_app() diff --git a/backend/services/delivery/app/middlewares/tenant.py b/backend/services/delivery/app/middlewares/tenant.py new file mode 100644 index 0000000..4b08726 --- /dev/null +++ b/backend/services/delivery/app/middlewares/tenant.py @@ -0,0 +1,24 @@ +"""Tenant header resolution middleware.""" +from __future__ import annotations + +from uuid import UUID + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID + + +class TenantHeaderMiddleware(BaseHTTPMiddleware): + """Resolve tenant from X-Tenant-ID header only (microservice pattern).""" + + async def dispatch(self, request: Request, call_next) -> Response: + setattr(request.state, STATE_TENANT_ID, None) + raw = request.headers.get(HEADER_TENANT_ID) + if raw: + try: + setattr(request.state, STATE_TENANT_ID, UUID(raw)) + except ValueError: + pass + return await call_next(request) diff --git a/backend/services/delivery/app/models/__init__.py b/backend/services/delivery/app/models/__init__.py new file mode 100644 index 0000000..3ce9d09 --- /dev/null +++ b/backend/services/delivery/app/models/__init__.py @@ -0,0 +1,19 @@ +"""Import all models for Alembic metadata discovery.""" +from app.models.drivers import ( # noqa: F401 + Driver, + DriverCredential, + DriverDocument, + DriverLifecycleEvent, +) +from app.models.foundation import ( # noqa: F401 + DeliveryAuditLog, + DeliveryConfiguration, + DeliveryHub, + DeliveryOrganization, + DeliveryPermission, + DeliveryRole, + DeliverySetting, + ExternalProviderConfig, + RoutingEngineRegistration, +) +from app.models.outbox import OutboxEvent # noqa: F401 diff --git a/backend/services/delivery/app/models/base.py b/backend/services/delivery/app/models/base.py new file mode 100644 index 0000000..873bc66 --- /dev/null +++ b/backend/services/delivery/app/models/base.py @@ -0,0 +1,51 @@ +"""Shared model mixins.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.types import GUID + + +class UUIDPrimaryKeyMixin: + id: Mapped[uuid.UUID] = mapped_column( + GUID(), primary_key=True, default=uuid.uuid4 + ) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class TenantMixin: + """Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded.""" + + tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + + +class ActorAuditMixin: + created_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + + +class OptimisticLockMixin: + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) diff --git a/backend/services/delivery/app/models/drivers.py b/backend/services/delivery/app/models/drivers.py new file mode 100644 index 0000000..7029797 --- /dev/null +++ b/backend/services/delivery/app/models/drivers.py @@ -0,0 +1,167 @@ +"""Driver management aggregates — Phase 10.1. + +UUID refs only within delivery_db. No fleet/dispatch ownership. +Identity/CRM/Storage are external refs only. +""" +from __future__ import annotations + +import uuid +from datetime import date, datetime + +from sqlalchemy import ( + Boolean, + Date, + DateTime, + Index, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + DriverCredentialKind, + DriverDocumentKind, + DriverLifecycleAction, + DriverStatus, + GUID, +) + + +class Driver( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Courier/driver profile owned by Delivery Platform.""" + + __tablename__ = "drivers" + + organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(32), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[DriverStatus] = mapped_column( + default=DriverStatus.PENDING, nullable=False + ) + external_user_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + external_crm_contact_ref: Mapped[str | None] = mapped_column( + String(100), nullable=True + ) + hire_date: Mapped[date | None] = mapped_column(Date, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + capability_tags: Mapped[list | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + status_changed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_drivers_tenant_code"), + Index("ix_drivers_tenant_status", "tenant_id", "status"), + Index("ix_drivers_org", "tenant_id", "organization_id"), + Index("ix_drivers_hub", "tenant_id", "hub_id"), + Index("ix_drivers_mobile", "tenant_id", "mobile"), + Index("ix_drivers_tenant_deleted", "tenant_id", "is_deleted"), + Index("ix_drivers_display_name", "tenant_id", "display_name"), + ) + + +class DriverCredential( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Credential / compliance refs for a driver (binaries via Storage refs).""" + + __tablename__ = "driver_credentials" + + driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + kind: Mapped[DriverCredentialKind] = mapped_column( + default=DriverCredentialKind.LICENSE, nullable=False + ) + number: Mapped[str] = mapped_column(String(100), nullable=False) + issued_at: Mapped[date | None] = mapped_column(Date, nullable=True) + expires_at: Mapped[date | None] = mapped_column(Date, nullable=True) + is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + document_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "driver_id", + "kind", + "number", + name="uq_driver_credentials_tenant_kind_number", + ), + Index("ix_driver_credentials_driver", "tenant_id", "driver_id"), + Index("ix_driver_credentials_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DriverDocument( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Driver document metadata — Storage owns file blobs.""" + + __tablename__ = "driver_documents" + + driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + kind: Mapped[DriverDocumentKind] = mapped_column( + default=DriverDocumentKind.OTHER, nullable=False + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + storage_file_ref: Mapped[str] = mapped_column(String(255), nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_driver_documents_driver", "tenant_id", "driver_id"), + Index("ix_driver_documents_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DriverLifecycleEvent(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + """Append-only driver lifecycle history.""" + + __tablename__ = "driver_lifecycle_events" + + driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + action: Mapped[DriverLifecycleAction] = mapped_column(nullable=False) + from_status: Mapped[DriverStatus] = mapped_column(nullable=False) + to_status: Mapped[DriverStatus] = mapped_column(nullable=False) + reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_driver_lifecycle_driver", "tenant_id", "driver_id"), + Index("ix_driver_lifecycle_created", "tenant_id", "created_at"), + ) diff --git a/backend/services/delivery/app/models/foundation.py b/backend/services/delivery/app/models/foundation.py new file mode 100644 index 0000000..6b1447f --- /dev/null +++ b/backend/services/delivery/app/models/foundation.py @@ -0,0 +1,303 @@ +"""Delivery foundation aggregates — Phase 10.0. + +Independent aggregates use UUID references within delivery_db only. +No SQLAlchemy relationship graphs between aggregates. +No dispatch/routing/tracking engines — shells and contracts only. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + AuditAction, + GUID, + LifecycleStatus, + ProviderKind, + ProviderStatus, + RoutingEngineStatus, +) + + +class DeliveryOrganization( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Root delivery organization / fleet operator shell for a tenant (Torbat Driver).""" + + __tablename__ = "delivery_organizations" + + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.DRAFT, nullable=False + ) + legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_delivery_organizations_tenant_code"), + Index("ix_delivery_organizations_tenant_status", "tenant_id", "status"), + Index("ix_delivery_organizations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DeliveryHub( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Operational hub / depot under a delivery organization.""" + + __tablename__ = "delivery_hubs" + + organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + address: Mapped[dict | None] = mapped_column(JSON, nullable=True) + timezone: Mapped[str | None] = mapped_column(String(64), nullable=True) + working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "organization_id", "code", name="uq_delivery_hubs_tenant_code" + ), + Index("ix_delivery_hubs_org", "tenant_id", "organization_id"), + Index("ix_delivery_hubs_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DeliveryRole( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Local delivery RBAC role catalog shell.""" + + __tablename__ = "delivery_roles" + + organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_delivery_roles_tenant_code"), + Index("ix_delivery_roles_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DeliveryPermission( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Local delivery permission catalog shell (complements platform delivery.* tree).""" + + __tablename__ = "delivery_permissions" + + role_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_delivery_permissions_tenant_code"), + Index("ix_delivery_permissions_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExternalProviderConfig( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """External fleet/courier/maps provider registration shell — credentials stay here.""" + + __tablename__ = "external_provider_configs" + + organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + provider_kind: Mapped[ProviderKind] = mapped_column( + default=ProviderKind.CUSTOM, nullable=False + ) + adapter_key: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[ProviderStatus] = mapped_column( + default=ProviderStatus.REGISTERED, nullable=False + ) + priority: Mapped[int] = mapped_column(default=100, nullable=False) + credentials_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + config: Mapped[dict | None] = mapped_column(JSON, nullable=True) + capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_external_provider_configs_tenant_code" + ), + Index("ix_external_provider_configs_kind", "tenant_id", "provider_kind"), + Index("ix_external_provider_configs_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class RoutingEngineRegistration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Future routing engine registration — adapter ready, no engine implementation.""" + + __tablename__ = "routing_engine_registrations" + + organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + adapter_key: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[RoutingEngineStatus] = mapped_column( + default=RoutingEngineStatus.REGISTERED, nullable=False + ) + is_default: Mapped[bool] = mapped_column(default=False, nullable=False) + config: Mapped[dict | None] = mapped_column(JSON, nullable=True) + capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_routing_engine_registrations_tenant_code" + ), + Index("ix_routing_engine_registrations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DeliveryConfiguration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Tenant delivery configuration — hours, policies, locale shells.""" + + __tablename__ = "delivery_configurations" + + organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + dispatch_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + tracking_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + settlement_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "organization_id", + "code", + name="uq_delivery_configurations_tenant_code", + ), + Index("ix_delivery_configurations_org", "tenant_id", "organization_id"), + Index("ix_delivery_configurations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DeliverySetting( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Key/value delivery settings shell.""" + + __tablename__ = "delivery_settings" + + organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + key: Mapped[str] = mapped_column(String(100), nullable=False) + value: Mapped[dict | None] = mapped_column(JSON, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "organization_id", + "hub_id", + "key", + name="uq_delivery_settings_tenant_key", + ), + Index("ix_delivery_settings_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DeliveryAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + """Append-only delivery audit trail.""" + + __tablename__ = "delivery_audit_logs" + + entity_type: Mapped[str] = mapped_column(String(50), nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + action: Mapped[AuditAction] = mapped_column(nullable=False) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + changes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index( + "ix_delivery_audit_entity", + "tenant_id", + "entity_type", + "entity_id", + ), + ) diff --git a/backend/services/delivery/app/models/outbox.py b/backend/services/delivery/app/models/outbox.py new file mode 100644 index 0000000..47a482b --- /dev/null +++ b/backend/services/delivery/app/models/outbox.py @@ -0,0 +1,44 @@ +"""Transactional outbox model — ADR-006.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, func +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import UUIDPrimaryKeyMixin +from app.models.types import GUID +from shared.events import EventStatus + + +class OutboxEvent(UUIDPrimaryKeyMixin, Base): + """Pending domain events written in the same DB transaction as mutations.""" + + __tablename__ = "outbox_events" + + tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + event_type: Mapped[str] = mapped_column(String(150), nullable=False) + aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False) + aggregate_id: Mapped[str] = mapped_column(String(100), nullable=False) + payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + status: Mapped[EventStatus] = mapped_column( + SAEnum(EventStatus, name="delivery_event_status", native_enum=False), + default=EventStatus.PENDING, + nullable=False, + ) + retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + processed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + Index("ix_delivery_outbox_status", "status"), + Index("ix_delivery_outbox_tenant", "tenant_id"), + ) diff --git a/backend/services/delivery/app/models/types.py b/backend/services/delivery/app/models/types.py new file mode 100644 index 0000000..ab90e40 --- /dev/null +++ b/backend/services/delivery/app/models/types.py @@ -0,0 +1,118 @@ +"""Delivery domain enums and dialect-safe GUID type.""" +from __future__ import annotations + +import enum +import uuid + +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.types import CHAR, TypeDecorator + + +class GUID(TypeDecorator): + impl = CHAR + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(PG_UUID(as_uuid=True)) + return dialect.type_descriptor(CHAR(36)) + + def process_bind_param(self, value, dialect): + if value is None: + return value + if dialect.name == "postgresql": + return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value)) + return str(value) + + def process_result_value(self, value, dialect): + if value is None: + return value + if isinstance(value, uuid.UUID): + return value + return uuid.UUID(str(value)) + + +class LifecycleStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class ProviderKind(str, enum.Enum): + ROUTING_ENGINE = "routing_engine" + FLEET = "fleet" + COURIER = "courier" + MAPS = "maps" + CUSTOM = "custom" + + +class ProviderStatus(str, enum.Enum): + REGISTERED = "registered" + ACTIVE = "active" + INACTIVE = "inactive" + FAILED = "failed" + RETIRED = "retired" + + +class RoutingEngineStatus(str, enum.Enum): + REGISTERED = "registered" + READY = "ready" + ACTIVE = "active" + DISABLED = "disabled" + + +class AuditAction(str, enum.Enum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + RESTORE = "restore" + STATUS_CHANGE = "status_change" + REGISTER = "register" + ENABLE = "enable" + DISABLE = "disable" + ACTIVATE = "activate" + SUSPEND = "suspend" + RESUME = "resume" + DEACTIVATE = "deactivate" + BLOCK = "block" + UNBLOCK = "unblock" + ARCHIVE = "archive" + + +class DriverStatus(str, enum.Enum): + """Operational status for Delivery drivers (Torbat Driver).""" + + PENDING = "pending" + ACTIVE = "active" + SUSPENDED = "suspended" + INACTIVE = "inactive" + BLOCKED = "blocked" + ARCHIVED = "archived" + + +class DriverLifecycleAction(str, enum.Enum): + ACTIVATE = "activate" + SUSPEND = "suspend" + RESUME = "resume" + DEACTIVATE = "deactivate" + BLOCK = "block" + UNBLOCK = "unblock" + ARCHIVE = "archive" + + +class DriverCredentialKind(str, enum.Enum): + LICENSE = "license" + NATIONAL_ID = "national_id" + HEALTH_CARD = "health_card" + BACKGROUND_CHECK = "background_check" + OTHER = "other" + + +class DriverDocumentKind(str, enum.Enum): + LICENSE_SCAN = "license_scan" + ID_SCAN = "id_scan" + CONTRACT = "contract" + PHOTO = "photo" + OTHER = "other" diff --git a/backend/services/delivery/app/permissions/definitions.py b/backend/services/delivery/app/permissions/definitions.py new file mode 100644 index 0000000..e035c7a --- /dev/null +++ b/backend/services/delivery/app/permissions/definitions.py @@ -0,0 +1,148 @@ +"""Delivery permission definitions — Phase 10.0 foundation + 10.1 drivers.""" +from __future__ import annotations + +DELIVERY_VIEW = "delivery.view" +DELIVERY_MANAGE = "delivery.manage" + +ORGANIZATIONS_VIEW = "delivery.organizations.view" +ORGANIZATIONS_CREATE = "delivery.organizations.create" +ORGANIZATIONS_UPDATE = "delivery.organizations.update" +ORGANIZATIONS_DELETE = "delivery.organizations.delete" +ORGANIZATIONS_MANAGE = "delivery.organizations.manage" + +HUBS_VIEW = "delivery.hubs.view" +HUBS_CREATE = "delivery.hubs.create" +HUBS_UPDATE = "delivery.hubs.update" +HUBS_DELETE = "delivery.hubs.delete" +HUBS_MANAGE = "delivery.hubs.manage" + +EXTERNAL_PROVIDERS_VIEW = "delivery.external_providers.view" +EXTERNAL_PROVIDERS_CREATE = "delivery.external_providers.create" +EXTERNAL_PROVIDERS_UPDATE = "delivery.external_providers.update" +EXTERNAL_PROVIDERS_DELETE = "delivery.external_providers.delete" +EXTERNAL_PROVIDERS_MANAGE = "delivery.external_providers.manage" + +ROUTING_ENGINES_VIEW = "delivery.routing_engines.view" +ROUTING_ENGINES_CREATE = "delivery.routing_engines.create" +ROUTING_ENGINES_UPDATE = "delivery.routing_engines.update" +ROUTING_ENGINES_DELETE = "delivery.routing_engines.delete" +ROUTING_ENGINES_MANAGE = "delivery.routing_engines.manage" + +CONFIGURATIONS_VIEW = "delivery.configurations.view" +CONFIGURATIONS_CREATE = "delivery.configurations.create" +CONFIGURATIONS_UPDATE = "delivery.configurations.update" +CONFIGURATIONS_DELETE = "delivery.configurations.delete" +CONFIGURATIONS_MANAGE = "delivery.configurations.manage" + +SETTINGS_VIEW = "delivery.settings.view" +SETTINGS_MANAGE = "delivery.settings.manage" + +AUDIT_VIEW = "delivery.audit.view" + +# Phase 10.1 — Driver Management +DRIVERS_VIEW = "delivery.drivers.view" +DRIVERS_CREATE = "delivery.drivers.create" +DRIVERS_UPDATE = "delivery.drivers.update" +DRIVERS_DELETE = "delivery.drivers.delete" +DRIVERS_ACTIVATE = "delivery.drivers.activate" +DRIVERS_SUSPEND = "delivery.drivers.suspend" +DRIVERS_RESUME = "delivery.drivers.resume" +DRIVERS_DEACTIVATE = "delivery.drivers.deactivate" +DRIVERS_BLOCK = "delivery.drivers.block" +DRIVERS_UNBLOCK = "delivery.drivers.unblock" +DRIVERS_ARCHIVE = "delivery.drivers.archive" +DRIVERS_LIFECYCLE_VIEW = "delivery.drivers.lifecycle.view" +DRIVERS_CREDENTIALS_VIEW = "delivery.drivers.credentials.view" +DRIVERS_CREDENTIALS_MANAGE = "delivery.drivers.credentials.manage" +DRIVERS_DOCUMENTS_VIEW = "delivery.drivers.documents.view" +DRIVERS_DOCUMENTS_MANAGE = "delivery.drivers.documents.manage" +DRIVERS_MANAGE = "delivery.drivers.manage" + +# Planned trees (registered for discovery; enforced in later phases) +FLEET_VIEW = "delivery.fleet.view" +FLEET_MANAGE = "delivery.fleet.manage" +DISPATCH_VIEW = "delivery.dispatch.view" +DISPATCH_MANAGE = "delivery.dispatch.manage" +ROUTING_VIEW = "delivery.routing.view" +ROUTING_MANAGE = "delivery.routing.manage" +TRACKING_VIEW = "delivery.tracking.view" +TRACKING_MANAGE = "delivery.tracking.manage" +SETTLEMENT_VIEW = "delivery.settlement.view" +SETTLEMENT_MANAGE = "delivery.settlement.manage" + +ALL_PERMISSIONS: list[str] = [ + DELIVERY_VIEW, + DELIVERY_MANAGE, + ORGANIZATIONS_VIEW, + ORGANIZATIONS_CREATE, + ORGANIZATIONS_UPDATE, + ORGANIZATIONS_DELETE, + ORGANIZATIONS_MANAGE, + HUBS_VIEW, + HUBS_CREATE, + HUBS_UPDATE, + HUBS_DELETE, + HUBS_MANAGE, + EXTERNAL_PROVIDERS_VIEW, + EXTERNAL_PROVIDERS_CREATE, + EXTERNAL_PROVIDERS_UPDATE, + EXTERNAL_PROVIDERS_DELETE, + EXTERNAL_PROVIDERS_MANAGE, + ROUTING_ENGINES_VIEW, + ROUTING_ENGINES_CREATE, + ROUTING_ENGINES_UPDATE, + ROUTING_ENGINES_DELETE, + ROUTING_ENGINES_MANAGE, + CONFIGURATIONS_VIEW, + CONFIGURATIONS_CREATE, + CONFIGURATIONS_UPDATE, + CONFIGURATIONS_DELETE, + CONFIGURATIONS_MANAGE, + SETTINGS_VIEW, + SETTINGS_MANAGE, + AUDIT_VIEW, + DRIVERS_VIEW, + DRIVERS_CREATE, + DRIVERS_UPDATE, + DRIVERS_DELETE, + DRIVERS_ACTIVATE, + DRIVERS_SUSPEND, + DRIVERS_RESUME, + DRIVERS_DEACTIVATE, + DRIVERS_BLOCK, + DRIVERS_UNBLOCK, + DRIVERS_ARCHIVE, + DRIVERS_LIFECYCLE_VIEW, + DRIVERS_CREDENTIALS_VIEW, + DRIVERS_CREDENTIALS_MANAGE, + DRIVERS_DOCUMENTS_VIEW, + DRIVERS_DOCUMENTS_MANAGE, + DRIVERS_MANAGE, + FLEET_VIEW, + FLEET_MANAGE, + DISPATCH_VIEW, + DISPATCH_MANAGE, + ROUTING_VIEW, + ROUTING_MANAGE, + TRACKING_VIEW, + TRACKING_MANAGE, + SETTLEMENT_VIEW, + SETTLEMENT_MANAGE, +] + +PERMISSION_PREFIXES: tuple[str, ...] = ( + "delivery.", + "delivery.organizations.", + "delivery.hubs.", + "delivery.external_providers.", + "delivery.routing_engines.", + "delivery.configurations.", + "delivery.settings.", + "delivery.audit.", + "delivery.drivers.", + "delivery.fleet.", + "delivery.dispatch.", + "delivery.routing.", + "delivery.tracking.", + "delivery.settlement.", +) diff --git a/backend/services/delivery/app/policies/__init__.py b/backend/services/delivery/app/policies/__init__.py new file mode 100644 index 0000000..f32832c --- /dev/null +++ b/backend/services/delivery/app/policies/__init__.py @@ -0,0 +1,4 @@ +"""Driver domain policies package.""" +from app.policies.drivers import DriverLifecyclePolicy + +__all__ = ["DriverLifecyclePolicy"] diff --git a/backend/services/delivery/app/policies/drivers.py b/backend/services/delivery/app/policies/drivers.py new file mode 100644 index 0000000..c24bb9a --- /dev/null +++ b/backend/services/delivery/app/policies/drivers.py @@ -0,0 +1,35 @@ +"""Driver domain policies — Phase 10.1.""" +from __future__ import annotations + +from app.models.types import DriverLifecycleAction, DriverStatus +from app.validators.drivers import ( + ensure_driver_lifecycle_transition, + target_status_for, + validate_reason, +) + + +class DriverLifecyclePolicy: + """Enforces legal driver status transitions and reason rules.""" + + REQUIRES_REASON = frozenset( + { + DriverLifecycleAction.SUSPEND, + DriverLifecycleAction.BLOCK, + DriverLifecycleAction.DEACTIVATE, + DriverLifecycleAction.ARCHIVE, + } + ) + + def assert_transition( + self, + *, + action: DriverLifecycleAction, + current: DriverStatus, + reason: str | None = None, + ) -> tuple[DriverStatus, str | None]: + ensure_driver_lifecycle_transition(action=action, current=current) + cleaned = validate_reason( + reason, required=action in self.REQUIRES_REASON + ) + return target_status_for(action), cleaned diff --git a/backend/services/delivery/app/providers/__init__.py b/backend/services/delivery/app/providers/__init__.py new file mode 100644 index 0000000..3bf4071 --- /dev/null +++ b/backend/services/delivery/app/providers/__init__.py @@ -0,0 +1,13 @@ +"""Platform provider contracts package.""" +from app.providers.contracts import ( # noqa: F401 + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + FleetProvider, + IdentityProvider, + LoyaltyProvider, + NotificationProvider, + RoutingEngineProvider, + StorageProvider, +) diff --git a/backend/services/delivery/app/providers/contracts.py b/backend/services/delivery/app/providers/contracts.py new file mode 100644 index 0000000..098131c --- /dev/null +++ b/backend/services/delivery/app/providers/contracts.py @@ -0,0 +1,96 @@ +"""Platform capability contracts — interfaces only; no implementations in Delivery.""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID + + +class AccountingProvider(Protocol): + """Contract for Accounting. Delivery must not post journals.""" + + async def create_settlement_ref( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: ... + + +class CRMProvider(Protocol): + """Contract for CRM. Delivery stores external contact refs only.""" + + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: ... + + +class LoyaltyProvider(Protocol): + """Contract for Loyalty. Delivery must not own points/ledger.""" + + async def resolve_member( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: ... + + +class CommunicationProvider(Protocol): + """Contract for Communication Platform. Delivery must not own SMS providers.""" + + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> None: ... + + +class NotificationProvider(Protocol): + """Contract for Notification fanout. Delivery must not own message delivery.""" + + async def notify( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + payload: dict[str, Any], + ) -> None: ... + + +class StorageProvider(Protocol): + """Contract for File Storage. Delivery stores file refs only (e.g. POD).""" + + async def resolve_file( + self, *, tenant_id: UUID, file_ref: str + ) -> dict[str, Any]: ... + + +class AIProvider(Protocol): + """Contract for AI Platform. Delivery must not own model inference.""" + + async def suggest( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: ... + + +class IdentityProvider(Protocol): + """Contract for Identity. Delivery stores external_user_ref only.""" + + async def resolve_user( + self, *, tenant_id: UUID, user_ref: str + ) -> dict[str, Any]: ... + + +class RoutingEngineProvider(Protocol): + """Contract for pluggable routing engines — adapters live in Delivery.""" + + async def plan_route( + self, *, tenant_id: UUID, request: dict[str, Any] + ) -> dict[str, Any]: ... + + +class FleetProvider(Protocol): + """Contract for external fleet/courier providers — adapters live in Delivery.""" + + async def request_fulfillment( + self, *, tenant_id: UUID, request: dict[str, Any] + ) -> dict[str, Any]: ... diff --git a/backend/services/delivery/app/queries/__init__.py b/backend/services/delivery/app/queries/__init__.py new file mode 100644 index 0000000..a2f3e20 --- /dev/null +++ b/backend/services/delivery/app/queries/__init__.py @@ -0,0 +1,4 @@ +"""Driver queries package.""" +from app.queries.drivers import DriverQueries + +__all__ = ["DriverQueries"] diff --git a/backend/services/delivery/app/queries/drivers.py b/backend/services/delivery/app/queries/drivers.py new file mode 100644 index 0000000..a3a6a1e --- /dev/null +++ b/backend/services/delivery/app/queries/drivers.py @@ -0,0 +1,38 @@ +"""Driver query handlers — Phase 10.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.drivers import DriverService +from app.specifications.drivers import DriverListSpec + + +class DriverQueries: + def __init__(self, session: AsyncSession) -> None: + self.service = DriverService(session) + + async def get(self, tenant_id: UUID, driver_id: UUID): + return await self.service.get(tenant_id, driver_id) + + async def list( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: DriverListSpec | None, + ): + return await self.service.list( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def lifecycle(self, tenant_id: UUID, driver_id: UUID, *, limit: int = 100): + return await self.service.list_lifecycle(tenant_id, driver_id, limit=limit) + + async def credentials(self, tenant_id: UUID, driver_id: UUID): + return await self.service.list_credentials(tenant_id, driver_id) + + async def documents(self, tenant_id: UUID, driver_id: UUID): + return await self.service.list_documents(tenant_id, driver_id) diff --git a/backend/services/delivery/app/repositories/base.py b/backend/services/delivery/app/repositories/base.py new file mode 100644 index 0000000..0c6dd9d --- /dev/null +++ b/backend/services/delivery/app/repositories/base.py @@ -0,0 +1,78 @@ +"""Tenant-aware base repository with soft-delete helpers.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Generic, Sequence, TypeVar +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Base + +ModelT = TypeVar("ModelT", bound=Base) + + +class TenantBaseRepository(Generic[ModelT]): + model: type[ModelT] + + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None: + clauses = [ + self.model.tenant_id == tenant_id, # type: ignore[attr-defined] + self.model.id == entity_id, # type: ignore[attr-defined] + ] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(self.model).where(*clauses) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_including_deleted( + self, tenant_id: UUID, entity_id: UUID + ) -> ModelT | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, # type: ignore[attr-defined] + self.model.id == entity_id, # type: ignore[attr-defined] + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def add(self, entity: ModelT) -> ModelT: + self.session.add(entity) + await self.session.flush() + return entity + + async def soft_delete(self, entity: ModelT, *, deleted_by: str | None = None) -> None: + entity.is_deleted = True # type: ignore[attr-defined] + entity.deleted_at = datetime.now(timezone.utc) # type: ignore[attr-defined] + if deleted_by is not None and hasattr(entity, "deleted_by"): + entity.deleted_by = deleted_by # type: ignore[attr-defined] + await self.session.flush() + + async def restore(self, entity: ModelT) -> None: + entity.is_deleted = False # type: ignore[attr-defined] + entity.deleted_at = None # type: ignore[attr-defined] + if hasattr(entity, "deleted_by"): + entity.deleted_by = None # type: ignore[attr-defined] + await self.session.flush() + + async def list_by_tenant( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[ModelT]: + clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(self.model).where(*clauses).offset(offset).limit(limit) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_by_tenant(self, tenant_id: UUID) -> int: + clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(func.count()).select_from(self.model).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) diff --git a/backend/services/delivery/app/repositories/drivers.py b/backend/services/delivery/app/repositories/drivers.py new file mode 100644 index 0000000..b442539 --- /dev/null +++ b/backend/services/delivery/app/repositories/drivers.py @@ -0,0 +1,112 @@ +"""Driver repositories — Phase 10.1.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.drivers import ( + Driver, + DriverCredential, + DriverDocument, + DriverLifecycleEvent, +) +from app.repositories.base import TenantBaseRepository +from app.specifications.drivers import DriverListSpec + + +class DriverRepository(TenantBaseRepository[Driver]): + model = Driver + + async def get_by_code(self, tenant_id: UUID, code: str) -> Driver | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: DriverListSpec | None = None, + ) -> Sequence[Driver]: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + stmt = spec.apply(stmt) + else: + stmt = stmt.order_by(self.model.created_at.desc()) + stmt = stmt.offset(offset).limit(limit) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_filtered( + self, tenant_id: UUID, *, spec: DriverListSpec | None = None + ) -> int: + stmt = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + clauses = spec.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + +class DriverCredentialRepository(TenantBaseRepository[DriverCredential]): + model = DriverCredential + + async def list_for_driver( + self, tenant_id: UUID, driver_id: UUID + ) -> Sequence[DriverCredential]: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.driver_id == driver_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class DriverDocumentRepository(TenantBaseRepository[DriverDocument]): + model = DriverDocument + + async def list_for_driver( + self, tenant_id: UUID, driver_id: UUID + ) -> Sequence[DriverDocument]: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.driver_id == driver_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class DriverLifecycleEventRepository(TenantBaseRepository[DriverLifecycleEvent]): + model = DriverLifecycleEvent + + async def list_for_driver( + self, tenant_id: UUID, driver_id: UUID, *, limit: int = 100 + ) -> Sequence[DriverLifecycleEvent]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.driver_id == driver_id, + ) + .order_by(self.model.created_at.desc()) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/delivery/app/repositories/foundation.py b/backend/services/delivery/app/repositories/foundation.py new file mode 100644 index 0000000..5889940 --- /dev/null +++ b/backend/services/delivery/app/repositories/foundation.py @@ -0,0 +1,142 @@ +"""Delivery foundation repositories.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import select + +from app.models.foundation import ( + DeliveryAuditLog, + DeliveryConfiguration, + DeliveryHub, + DeliveryOrganization, + DeliveryPermission, + DeliveryRole, + DeliverySetting, + ExternalProviderConfig, + RoutingEngineRegistration, +) +from app.repositories.base import TenantBaseRepository + + +class DeliveryOrganizationRepository(TenantBaseRepository[DeliveryOrganization]): + model = DeliveryOrganization + + async def get_by_code( + self, tenant_id: UUID, code: str + ) -> DeliveryOrganization | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DeliveryHubRepository(TenantBaseRepository[DeliveryHub]): + model = DeliveryHub + + async def list_by_organization( + self, tenant_id: UUID, organization_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[DeliveryHub]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.organization_id == organization_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class DeliveryRoleRepository(TenantBaseRepository[DeliveryRole]): + model = DeliveryRole + + +class DeliveryPermissionRepository(TenantBaseRepository[DeliveryPermission]): + model = DeliveryPermission + + +class ExternalProviderConfigRepository(TenantBaseRepository[ExternalProviderConfig]): + model = ExternalProviderConfig + + async def get_by_code( + self, tenant_id: UUID, code: str + ) -> ExternalProviderConfig | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class RoutingEngineRegistrationRepository( + TenantBaseRepository[RoutingEngineRegistration] +): + model = RoutingEngineRegistration + + +class DeliveryConfigurationRepository(TenantBaseRepository[DeliveryConfiguration]): + model = DeliveryConfiguration + + +class DeliverySettingRepository(TenantBaseRepository[DeliverySetting]): + model = DeliverySetting + + async def get_by_key( + self, + tenant_id: UUID, + key: str, + *, + organization_id: UUID | None = None, + hub_id: UUID | None = None, + ) -> DeliverySetting | None: + clauses = [ + self.model.tenant_id == tenant_id, + self.model.key == key, + self.model.is_deleted.is_(False), + ] + if organization_id is None: + clauses.append(self.model.organization_id.is_(None)) + else: + clauses.append(self.model.organization_id == organization_id) + if hub_id is None: + clauses.append(self.model.hub_id.is_(None)) + else: + clauses.append(self.model.hub_id == hub_id) + stmt = select(self.model).where(*clauses) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DeliveryAuditLogRepository(TenantBaseRepository[DeliveryAuditLog]): + model = DeliveryAuditLog + + async def list_for_entity( + self, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + *, + limit: int = 100, + ) -> Sequence[DeliveryAuditLog]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.entity_type == entity_type, + self.model.entity_id == entity_id, + ) + .order_by(self.model.created_at.desc()) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/delivery/app/schemas/common.py b/backend/services/delivery/app/schemas/common.py new file mode 100644 index 0000000..12b4f56 --- /dev/null +++ b/backend/services/delivery/app/schemas/common.py @@ -0,0 +1,18 @@ +"""Common schemas.""" +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ORMBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class IdResponse(BaseModel): + id: UUID + + +class MessageResponse(BaseModel): + message: str diff --git a/backend/services/delivery/app/schemas/drivers.py b/backend/services/delivery/app/schemas/drivers.py new file mode 100644 index 0000000..3436fcb --- /dev/null +++ b/backend/services/delivery/app/schemas/drivers.py @@ -0,0 +1,164 @@ +"""Driver DTOs — Phase 10.1.""" +from __future__ import annotations + +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + DriverCredentialKind, + DriverDocumentKind, + DriverLifecycleAction, + DriverStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class DriverCreate(BaseModel): + model_config = _FORBID + + organization_id: UUID + hub_id: UUID | None = None + code: str = Field(max_length=50) + display_name: str = Field(max_length=255) + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + mobile: str | None = Field(default=None, max_length=32) + email: str | None = Field(default=None, max_length=255) + status: DriverStatus = DriverStatus.PENDING + external_user_ref: str | None = Field(default=None, max_length=100) + external_crm_contact_ref: str | None = Field(default=None, max_length=100) + hire_date: date | None = None + notes: str | None = None + capability_tags: list[str] | None = None + metadata_json: dict | None = None + + +class DriverUpdate(BaseModel): + model_config = _FORBID + + hub_id: UUID | None = None + display_name: str | None = Field(default=None, max_length=255) + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + mobile: str | None = Field(default=None, max_length=32) + email: str | None = Field(default=None, max_length=255) + external_user_ref: str | None = Field(default=None, max_length=100) + external_crm_contact_ref: str | None = Field(default=None, max_length=100) + hire_date: date | None = None + notes: str | None = None + capability_tags: list[str] | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class DriverRead(ORMBase): + id: UUID + tenant_id: UUID + organization_id: UUID + hub_id: UUID | None + code: str + display_name: str + first_name: str | None + last_name: str | None + mobile: str | None + email: str | None + status: DriverStatus + external_user_ref: str | None + external_crm_contact_ref: str | None + hire_date: date | None + notes: str | None + capability_tags: list | None + metadata_json: dict | None + status_reason: str | None + status_changed_at: datetime | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DriverLifecycleRequest(BaseModel): + model_config = _FORBID + + reason: str | None = Field(default=None, max_length=500) + version: int = Field(ge=1) + + +class DriverLifecycleEventRead(ORMBase): + id: UUID + tenant_id: UUID + driver_id: UUID + action: DriverLifecycleAction + from_status: DriverStatus + to_status: DriverStatus + reason: str | None + actor_user_id: str | None + metadata_json: dict | None + created_at: datetime + + +class DriverCredentialCreate(BaseModel): + model_config = _FORBID + + kind: DriverCredentialKind = DriverCredentialKind.LICENSE + number: str = Field(max_length=100) + issued_at: date | None = None + expires_at: date | None = None + is_verified: bool = False + document_file_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class DriverCredentialRead(ORMBase): + id: UUID + tenant_id: UUID + driver_id: UUID + kind: DriverCredentialKind + number: str + issued_at: date | None + expires_at: date | None + is_verified: bool + document_file_ref: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DriverDocumentCreate(BaseModel): + model_config = _FORBID + + kind: DriverDocumentKind = DriverDocumentKind.OTHER + title: str = Field(max_length=255) + storage_file_ref: str = Field(max_length=255) + notes: str | None = None + + +class DriverDocumentRead(ORMBase): + id: UUID + tenant_id: UUID + driver_id: UUID + kind: DriverDocumentKind + title: str + storage_file_ref: str + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DriverListResponse(BaseModel): + items: list[DriverRead] + total: int + page: int + page_size: int diff --git a/backend/services/delivery/app/schemas/foundation.py b/backend/services/delivery/app/schemas/foundation.py new file mode 100644 index 0000000..ef45350 --- /dev/null +++ b/backend/services/delivery/app/schemas/foundation.py @@ -0,0 +1,294 @@ +"""Delivery foundation DTOs — Phase 10.0.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + LifecycleStatus, + ProviderKind, + ProviderStatus, + RoutingEngineStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class DeliveryOrganizationCreate(BaseModel): + model_config = _FORBID + + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.DRAFT + legal_name: str | None = Field(default=None, max_length=255) + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + settings: dict | None = None + + +class DeliveryOrganizationUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + legal_name: str | None = Field(default=None, max_length=255) + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + settings: dict | None = None + version: int = Field(ge=1) + + +class DeliveryOrganizationRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + legal_name: str | None + timezone: str + language: str + currency_code: str + settings: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DeliveryHubCreate(BaseModel): + model_config = _FORBID + + organization_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class DeliveryHubUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class DeliveryHubRead(ORMBase): + id: UUID + tenant_id: UUID + organization_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + address: dict | None + timezone: str | None + working_hours: dict | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExternalProviderConfigCreate(BaseModel): + model_config = _FORBID + + organization_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + provider_kind: ProviderKind = ProviderKind.CUSTOM + adapter_key: str = Field(max_length=100) + status: ProviderStatus = ProviderStatus.REGISTERED + priority: int = Field(default=100, ge=0) + credentials_ref: str | None = Field(default=None, max_length=255) + config: dict | None = None + capabilities: dict | None = None + + +class ExternalProviderConfigUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + provider_kind: ProviderKind | None = None + adapter_key: str | None = Field(default=None, max_length=100) + status: ProviderStatus | None = None + priority: int | None = Field(default=None, ge=0) + credentials_ref: str | None = Field(default=None, max_length=255) + config: dict | None = None + capabilities: dict | None = None + version: int = Field(ge=1) + + +class ExternalProviderConfigRead(ORMBase): + id: UUID + tenant_id: UUID + organization_id: UUID | None + code: str + name: str + provider_kind: ProviderKind + adapter_key: str + status: ProviderStatus + priority: int + credentials_ref: str | None + config: dict | None + capabilities: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class RoutingEngineRegistrationCreate(BaseModel): + model_config = _FORBID + + organization_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + adapter_key: str = Field(max_length=100) + status: RoutingEngineStatus = RoutingEngineStatus.REGISTERED + is_default: bool = False + config: dict | None = None + capabilities: dict | None = None + + +class RoutingEngineRegistrationUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + adapter_key: str | None = Field(default=None, max_length=100) + status: RoutingEngineStatus | None = None + is_default: bool | None = None + config: dict | None = None + capabilities: dict | None = None + version: int = Field(ge=1) + + +class RoutingEngineRegistrationRead(ORMBase): + id: UUID + tenant_id: UUID + organization_id: UUID | None + code: str + name: str + adapter_key: str + status: RoutingEngineStatus + is_default: bool + config: dict | None + capabilities: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DeliveryConfigurationCreate(BaseModel): + model_config = _FORBID + + organization_id: UUID + hub_id: UUID | None = None + code: str = Field(max_length=50) + working_hours: dict | None = None + dispatch_policies: dict | None = None + tracking_policies: dict | None = None + settlement_policies: dict | None = None + custom_fields: dict | None = None + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + + +class DeliveryConfigurationUpdate(BaseModel): + model_config = _FORBID + + hub_id: UUID | None = None + working_hours: dict | None = None + dispatch_policies: dict | None = None + tracking_policies: dict | None = None + settlement_policies: dict | None = None + custom_fields: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + version: int = Field(ge=1) + + +class DeliveryConfigurationRead(ORMBase): + id: UUID + tenant_id: UUID + organization_id: UUID + hub_id: UUID | None + code: str + working_hours: dict | None + dispatch_policies: dict | None + tracking_policies: dict | None + settlement_policies: dict | None + custom_fields: dict | None + timezone: str + language: str + currency_code: str + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DeliverySettingUpsert(BaseModel): + model_config = _FORBID + + organization_id: UUID | None = None + hub_id: UUID | None = None + key: str = Field(max_length=100) + value: dict | None = None + description: str | None = None + + +class DeliverySettingRead(ORMBase): + id: UUID + tenant_id: UUID + organization_id: UUID | None + hub_id: UUID | None + key: str + value: dict | None + description: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DeliveryAuditLogRead(ORMBase): + id: UUID + tenant_id: UUID + entity_type: str + entity_id: UUID + action: str + actor_user_id: str | None + changes: dict | None + message: str | None + created_at: datetime diff --git a/backend/services/delivery/app/services/audit_service.py b/backend/services/delivery/app/services/audit_service.py new file mode 100644 index 0000000..4a79262 --- /dev/null +++ b/backend/services/delivery/app/services/audit_service.py @@ -0,0 +1,51 @@ +"""Audit service — append-only Delivery audit trail.""" +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.foundation import DeliveryAuditLog +from app.models.types import AuditAction +from app.repositories.foundation import DeliveryAuditLogRepository + + +class AuditService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = DeliveryAuditLogRepository(session) + + async def record( + self, + *, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + action: AuditAction, + actor_user_id: str | None = None, + changes: dict[str, Any] | None = None, + message: str | None = None, + commit: bool = False, + ) -> DeliveryAuditLog: + entry = DeliveryAuditLog( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + action=action, + actor_user_id=actor_user_id, + changes=changes, + message=message, + ) + await self.repo.add(entry) + if commit: + await self.session.commit() + await self.session.refresh(entry) + return entry + + async def list_for_entity( + self, tenant_id: UUID, entity_type: str, entity_id: UUID, *, limit: int = 100 + ): + return await self.repo.list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/delivery/app/services/drivers.py b/backend/services/delivery/app/services/drivers.py new file mode 100644 index 0000000..e65ed60 --- /dev/null +++ b/backend/services/delivery/app/services/drivers.py @@ -0,0 +1,478 @@ +"""Driver management application services — Phase 10.1.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import TransactionalEventPublisher +from app.events.types import DeliveryEventType +from app.models.drivers import ( + Driver, + DriverCredential, + DriverDocument, + DriverLifecycleEvent, +) +from app.models.types import AuditAction, DriverLifecycleAction, DriverStatus +from app.policies.drivers import DriverLifecyclePolicy +from app.repositories.drivers import ( + DriverCredentialRepository, + DriverDocumentRepository, + DriverLifecycleEventRepository, + DriverRepository, +) +from app.repositories.foundation import ( + DeliveryHubRepository, + DeliveryOrganizationRepository, +) +from app.schemas.drivers import ( + DriverCreate, + DriverCredentialCreate, + DriverDocumentCreate, + DriverLifecycleRequest, + DriverUpdate, +) +from app.services.audit_service import AuditService +from app.specifications.drivers import DriverListSpec +from app.validators import ensure_optimistic_version, validate_code +from app.validators.drivers import ( + validate_credential_dates, + validate_display_name, + validate_email, + validate_mobile, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + +_ACTION_EVENTS: dict[DriverLifecycleAction, DeliveryEventType] = { + DriverLifecycleAction.ACTIVATE: DeliveryEventType.DRIVER_ACTIVATED, + DriverLifecycleAction.SUSPEND: DeliveryEventType.DRIVER_SUSPENDED, + DriverLifecycleAction.RESUME: DeliveryEventType.DRIVER_RESUMED, + DriverLifecycleAction.DEACTIVATE: DeliveryEventType.DRIVER_DEACTIVATED, + DriverLifecycleAction.BLOCK: DeliveryEventType.DRIVER_BLOCKED, + DriverLifecycleAction.UNBLOCK: DeliveryEventType.DRIVER_UNBLOCKED, + DriverLifecycleAction.ARCHIVE: DeliveryEventType.DRIVER_ARCHIVED, +} + +_ACTION_AUDIT: dict[DriverLifecycleAction, AuditAction] = { + DriverLifecycleAction.ACTIVATE: AuditAction.ACTIVATE, + DriverLifecycleAction.SUSPEND: AuditAction.SUSPEND, + DriverLifecycleAction.RESUME: AuditAction.RESUME, + DriverLifecycleAction.DEACTIVATE: AuditAction.DEACTIVATE, + DriverLifecycleAction.BLOCK: AuditAction.BLOCK, + DriverLifecycleAction.UNBLOCK: AuditAction.UNBLOCK, + DriverLifecycleAction.ARCHIVE: AuditAction.ARCHIVE, +} + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +def _jsonable(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +class DriverService: + def __init__( + self, + session: AsyncSession, + *, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.repo = DriverRepository(session) + self.credentials = DriverCredentialRepository(session) + self.documents = DriverDocumentRepository(session) + self.lifecycle = DriverLifecycleEventRepository(session) + self.orgs = DeliveryOrganizationRepository(session) + self.hubs = DeliveryHubRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + self.policy = DriverLifecyclePolicy() + + async def create( + self, + tenant_id: UUID, + body: DriverCreate, + *, + actor: CurrentUser | None = None, + ) -> Driver: + org = await self.orgs.get(tenant_id, body.organization_id) + if org is None: + raise NotFoundError("سازمان یافت نشد") + if body.hub_id is not None: + hub = await self.hubs.get(tenant_id, body.hub_id) + if hub is None: + raise NotFoundError("هاب یافت نشد") + if hub.organization_id != body.organization_id: + raise AppError( + "هاب به سازمان انتخاب‌شده تعلق ندارد", + status_code=422, + error_code="hub_organization_mismatch", + ) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError( + "کد راننده تکراری است", + error_code="duplicate_code", + status_code=409, + ) + if body.status not in {DriverStatus.PENDING, DriverStatus.ACTIVE}: + raise AppError( + "وضعیت اولیه فقط pending یا active مجاز است", + status_code=422, + error_code="invalid_initial_status", + ) + entity = Driver( + tenant_id=tenant_id, + organization_id=body.organization_id, + hub_id=body.hub_id, + code=code, + display_name=validate_display_name(body.display_name), + first_name=body.first_name, + last_name=body.last_name, + mobile=validate_mobile(body.mobile), + email=validate_email(body.email), + status=body.status, + external_user_ref=body.external_user_ref, + external_crm_contact_ref=body.external_crm_contact_ref, + hire_date=body.hire_date, + notes=body.notes, + capability_tags=body.capability_tags, + metadata_json=body.metadata_json, + status_changed_at=datetime.now(timezone.utc), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد راننده تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="driver", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(body.model_dump()), + ) + await self.publisher.publish( + event_type=DeliveryEventType.DRIVER_CREATED, + aggregate_type="driver", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "code": entity.code, + "status": entity.status.value, + "organization_id": str(entity.organization_id), + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, driver_id: UUID) -> Driver: + entity = await self.repo.get(tenant_id, driver_id) + if entity is None: + raise NotFoundError("راننده یافت نشد") + return entity + + async def list( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: DriverListSpec | None = None, + ) -> tuple[list[Driver], int]: + items = list( + await self.repo.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + ) + total = await self.repo.count_filtered(tenant_id, spec=spec) + return items, total + + async def update( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverUpdate, + *, + actor: CurrentUser | None = None, + ) -> Driver: + entity = await self.get(tenant_id, driver_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "hub_id" in data and data["hub_id"] is not None: + hub = await self.hubs.get(tenant_id, data["hub_id"]) + if hub is None: + raise NotFoundError("هاب یافت نشد") + if hub.organization_id != entity.organization_id: + raise AppError( + "هاب به سازمان راننده تعلق ندارد", + status_code=422, + error_code="hub_organization_mismatch", + ) + if "display_name" in data and data["display_name"] is not None: + data["display_name"] = validate_display_name(data["display_name"]) + if "mobile" in data: + data["mobile"] = validate_mobile(data["mobile"]) + if "email" in data: + data["email"] = validate_email(data["email"]) + for key, value in data.items(): + setattr(entity, key, value) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="driver", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(data), + ) + await self.publisher.publish( + event_type=DeliveryEventType.DRIVER_UPDATED, + aggregate_type="driver", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def apply_lifecycle( + self, + tenant_id: UUID, + driver_id: UUID, + action: DriverLifecycleAction, + body: DriverLifecycleRequest, + *, + actor: CurrentUser | None = None, + ) -> Driver: + entity = await self.get(tenant_id, driver_id) + ensure_optimistic_version(entity, body.version) + from_status = entity.status + to_status, reason = self.policy.assert_transition( + action=action, current=from_status, reason=body.reason + ) + entity.status = to_status + entity.status_reason = reason + entity.status_changed_at = datetime.now(timezone.utc) + entity.version += 1 + entity.updated_by = _actor_id(actor) + history = DriverLifecycleEvent( + tenant_id=tenant_id, + driver_id=entity.id, + action=action, + from_status=from_status, + to_status=to_status, + reason=reason, + actor_user_id=_actor_id(actor), + ) + await self.lifecycle.add(history) + await self.audit.record( + tenant_id=tenant_id, + entity_type="driver", + entity_id=entity.id, + action=_ACTION_AUDIT[action], + actor_user_id=_actor_id(actor), + changes={ + "from_status": from_status.value, + "to_status": to_status.value, + "reason": reason, + }, + message=f"driver.{action.value}", + ) + await self.publisher.publish( + event_type=DeliveryEventType.DRIVER_STATUS_CHANGED, + aggregate_type="driver", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "action": action.value, + "from_status": from_status.value, + "to_status": to_status.value, + }, + ) + await self.publisher.publish( + event_type=_ACTION_EVENTS[action], + aggregate_type="driver", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": to_status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + driver_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> Driver: + entity = await self.get(tenant_id, driver_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="driver", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.publisher.publish( + event_type=DeliveryEventType.DRIVER_DELETED, + aggregate_type="driver", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_lifecycle( + self, tenant_id: UUID, driver_id: UUID, *, limit: int = 100 + ) -> list[DriverLifecycleEvent]: + await self.get(tenant_id, driver_id) + return list( + await self.lifecycle.list_for_driver(tenant_id, driver_id, limit=limit) + ) + + async def add_credential( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverCredentialCreate, + *, + actor: CurrentUser | None = None, + ) -> DriverCredential: + await self.get(tenant_id, driver_id) + validate_credential_dates(issued_at=body.issued_at, expires_at=body.expires_at) + number = body.number.strip() + if not number: + raise AppError( + "شماره مدرک الزامی است", + status_code=422, + error_code="credential_number_required", + ) + entity = DriverCredential( + tenant_id=tenant_id, + driver_id=driver_id, + kind=body.kind, + number=number, + issued_at=body.issued_at, + expires_at=body.expires_at, + is_verified=body.is_verified, + document_file_ref=body.document_file_ref, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.credentials.add(entity) + except IntegrityError as exc: + raise AppError( + "مدرک تکراری است", + error_code="duplicate_credential", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="driver_credential", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(body.model_dump()), + ) + await self.publisher.publish( + event_type=DeliveryEventType.DRIVER_CREDENTIAL_ADDED, + aggregate_type="driver_credential", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"driver_id": str(driver_id), "kind": entity.kind.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_credentials( + self, tenant_id: UUID, driver_id: UUID + ) -> list[DriverCredential]: + await self.get(tenant_id, driver_id) + return list(await self.credentials.list_for_driver(tenant_id, driver_id)) + + async def add_document( + self, + tenant_id: UUID, + driver_id: UUID, + body: DriverDocumentCreate, + *, + actor: CurrentUser | None = None, + ) -> DriverDocument: + await self.get(tenant_id, driver_id) + title = body.title.strip() + ref = body.storage_file_ref.strip() + if not title or not ref: + raise AppError( + "عنوان و مرجع فایل الزامی است", + status_code=422, + error_code="document_fields_required", + ) + entity = DriverDocument( + tenant_id=tenant_id, + driver_id=driver_id, + kind=body.kind, + title=title, + storage_file_ref=ref, + notes=body.notes, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.documents.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="driver_document", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(body.model_dump()), + ) + await self.publisher.publish( + event_type=DeliveryEventType.DRIVER_DOCUMENT_ATTACHED, + aggregate_type="driver_document", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"driver_id": str(driver_id), "kind": entity.kind.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_documents( + self, tenant_id: UUID, driver_id: UUID + ) -> list[DriverDocument]: + await self.get(tenant_id, driver_id) + return list(await self.documents.list_for_driver(tenant_id, driver_id)) diff --git a/backend/services/delivery/app/services/foundation.py b/backend/services/delivery/app/services/foundation.py new file mode 100644 index 0000000..7d2086c --- /dev/null +++ b/backend/services/delivery/app/services/foundation.py @@ -0,0 +1,779 @@ +"""Delivery foundation application services — Phase 10.0.""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import DeliveryEventType +from app.models.foundation import ( + DeliveryConfiguration, + DeliveryHub, + DeliveryOrganization, + DeliverySetting, + ExternalProviderConfig, + RoutingEngineRegistration, +) +from app.models.types import AuditAction +from app.repositories.foundation import ( + DeliveryConfigurationRepository, + DeliveryHubRepository, + DeliveryOrganizationRepository, + DeliverySettingRepository, + ExternalProviderConfigRepository, + RoutingEngineRegistrationRepository, +) +from app.schemas.foundation import ( + DeliveryConfigurationCreate, + DeliveryConfigurationUpdate, + DeliveryHubCreate, + DeliveryHubUpdate, + DeliveryOrganizationCreate, + DeliveryOrganizationUpdate, + DeliverySettingUpsert, + ExternalProviderConfigCreate, + ExternalProviderConfigUpdate, + RoutingEngineRegistrationCreate, + RoutingEngineRegistrationUpdate, +) +from app.services.audit_service import AuditService +from app.validators import ( + ensure_optimistic_version, + validate_code, + validate_currency_code, + validate_non_empty, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +class DeliveryOrganizationService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = DeliveryOrganizationRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: DeliveryOrganizationCreate, + *, + actor: CurrentUser | None = None, + ) -> DeliveryOrganization: + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + currency = validate_currency_code(body.currency_code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError( + "کد سازمان تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = DeliveryOrganization( + tenant_id=tenant_id, + code=code, + name=name, + description=body.description, + status=body.status, + legal_name=body.legal_name, + timezone=body.timezone, + language=body.language, + currency_code=currency, + settings=body.settings, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد سازمان تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_organization", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=DeliveryEventType.ORGANIZATION_CREATED, + aggregate_type="delivery_organization", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "name": entity.name}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> DeliveryOrganization: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("سازمان یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[DeliveryOrganization]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: DeliveryOrganizationUpdate, + *, + actor: CurrentUser | None = None, + ) -> DeliveryOrganization: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_organization", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=DeliveryEventType.ORGANIZATION_UPDATED, + aggregate_type="delivery_organization", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> DeliveryOrganization: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_organization", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class DeliveryHubService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = DeliveryHubRepository(session) + self.orgs = DeliveryOrganizationRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: DeliveryHubCreate, + *, + actor: CurrentUser | None = None, + ) -> DeliveryHub: + org = await self.orgs.get(tenant_id, body.organization_id) + if org is None: + raise NotFoundError("سازمان یافت نشد") + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + entity = DeliveryHub( + tenant_id=tenant_id, + organization_id=body.organization_id, + code=code, + name=name, + description=body.description, + status=body.status, + address=body.address, + timezone=body.timezone, + working_hours=body.working_hours, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد هاب تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_hub", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=DeliveryEventType.HUB_CREATED, + aggregate_type="delivery_hub", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "organization_id": str(entity.organization_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> DeliveryHub: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("هاب یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[DeliveryHub]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: DeliveryHubUpdate, + *, + actor: CurrentUser | None = None, + ) -> DeliveryHub: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_hub", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=DeliveryEventType.HUB_UPDATED, + aggregate_type="delivery_hub", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> DeliveryHub: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_hub", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ExternalProviderConfigService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExternalProviderConfigRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ExternalProviderConfigCreate, + *, + actor: CurrentUser | None = None, + ) -> ExternalProviderConfig: + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + adapter_key = validate_non_empty(body.adapter_key, "adapter_key") + if await self.repo.get_by_code(tenant_id, code): + raise AppError( + "کد ارائه‌دهنده تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = ExternalProviderConfig( + tenant_id=tenant_id, + organization_id=body.organization_id, + code=code, + name=name, + provider_kind=body.provider_kind, + adapter_key=adapter_key, + status=body.status, + priority=body.priority, + credentials_ref=body.credentials_ref, + config=body.config, + capabilities=body.capabilities, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="external_provider_config", + entity_id=entity.id, + action=AuditAction.REGISTER, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=DeliveryEventType.EXTERNAL_PROVIDER_REGISTERED, + aggregate_type="external_provider_config", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "adapter_key": entity.adapter_key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ExternalProviderConfig: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("ارائه‌دهنده یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExternalProviderConfig]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: ExternalProviderConfigUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExternalProviderConfig: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "adapter_key" in data and data["adapter_key"] is not None: + data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key") + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="external_provider_config", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=DeliveryEventType.EXTERNAL_PROVIDER_UPDATED, + aggregate_type="external_provider_config", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExternalProviderConfig: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="external_provider_config", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class RoutingEngineRegistrationService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = RoutingEngineRegistrationRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: RoutingEngineRegistrationCreate, + *, + actor: CurrentUser | None = None, + ) -> RoutingEngineRegistration: + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + adapter_key = validate_non_empty(body.adapter_key, "adapter_key") + entity = RoutingEngineRegistration( + tenant_id=tenant_id, + organization_id=body.organization_id, + code=code, + name=name, + adapter_key=adapter_key, + status=body.status, + is_default=body.is_default, + config=body.config, + capabilities=body.capabilities, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد موتور مسیریابی تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="routing_engine_registration", + entity_id=entity.id, + action=AuditAction.REGISTER, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=DeliveryEventType.ROUTING_ENGINE_REGISTERED, + aggregate_type="routing_engine_registration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "adapter_key": entity.adapter_key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> RoutingEngineRegistration: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("موتور مسیریابی یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[RoutingEngineRegistration]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: RoutingEngineRegistrationUpdate, + *, + actor: CurrentUser | None = None, + ) -> RoutingEngineRegistration: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "adapter_key" in data and data["adapter_key"] is not None: + data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key") + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="routing_engine_registration", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=DeliveryEventType.ROUTING_ENGINE_UPDATED, + aggregate_type="routing_engine_registration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> RoutingEngineRegistration: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="routing_engine_registration", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class DeliveryConfigurationService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = DeliveryConfigurationRepository(session) + self.orgs = DeliveryOrganizationRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: DeliveryConfigurationCreate, + *, + actor: CurrentUser | None = None, + ) -> DeliveryConfiguration: + org = await self.orgs.get(tenant_id, body.organization_id) + if org is None: + raise NotFoundError("سازمان یافت نشد") + code = validate_code(body.code) + currency = validate_currency_code(body.currency_code) + entity = DeliveryConfiguration( + tenant_id=tenant_id, + organization_id=body.organization_id, + hub_id=body.hub_id, + code=code, + working_hours=body.working_hours, + dispatch_policies=body.dispatch_policies, + tracking_policies=body.tracking_policies, + settlement_policies=body.settlement_policies, + custom_fields=body.custom_fields, + timezone=body.timezone, + language=body.language, + currency_code=currency, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد پیکربندی تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_configuration", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=DeliveryEventType.CONFIGURATION_CREATED, + aggregate_type="delivery_configuration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> DeliveryConfiguration: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("پیکربندی یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[DeliveryConfiguration]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: DeliveryConfigurationUpdate, + *, + actor: CurrentUser | None = None, + ) -> DeliveryConfiguration: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_configuration", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=DeliveryEventType.CONFIGURATION_UPDATED, + aggregate_type="delivery_configuration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> DeliveryConfiguration: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_configuration", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class DeliverySettingService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = DeliverySettingRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def upsert( + self, + tenant_id: UUID, + body: DeliverySettingUpsert, + *, + actor: CurrentUser | None = None, + ) -> DeliverySetting: + key = validate_non_empty(body.key, "key") + existing = await self.repo.get_by_key( + tenant_id, + key, + organization_id=body.organization_id, + hub_id=body.hub_id, + ) + if existing is None: + entity = DeliverySetting( + tenant_id=tenant_id, + organization_id=body.organization_id, + hub_id=body.hub_id, + key=key, + value=body.value, + description=body.description, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + else: + entity = existing + entity.value = body.value + entity.description = body.description + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="delivery_setting", + entity_id=entity.id, + action=AuditAction.UPDATE if existing else AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=DeliveryEventType.SETTING_UPSERTED, + aggregate_type="delivery_setting", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"key": entity.key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[DeliverySetting]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/delivery/app/specifications/__init__.py b/backend/services/delivery/app/specifications/__init__.py new file mode 100644 index 0000000..3567eef --- /dev/null +++ b/backend/services/delivery/app/specifications/__init__.py @@ -0,0 +1,4 @@ +"""Driver query specifications package.""" +from app.specifications.drivers import ALLOWED_SORT_FIELDS, DriverListSpec + +__all__ = ["ALLOWED_SORT_FIELDS", "DriverListSpec"] diff --git a/backend/services/delivery/app/specifications/drivers.py b/backend/services/delivery/app/specifications/drivers.py new file mode 100644 index 0000000..006d287 --- /dev/null +++ b/backend/services/delivery/app/specifications/drivers.py @@ -0,0 +1,62 @@ +"""Driver query specifications — Phase 10.1.""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import Select, asc, desc, or_ +from sqlalchemy.sql import ColumnElement + +from app.models.drivers import Driver +from app.models.types import DriverStatus + +ALLOWED_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "code", "display_name", "status", "mobile"} +) + + +@dataclass(frozen=True, slots=True) +class DriverListSpec: + status: DriverStatus | None = None + organization_id: UUID | None = None + hub_id: UUID | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.status is not None: + clauses.append(Driver.status == self.status) + if self.organization_id is not None: + clauses.append(Driver.organization_id == self.organization_id) + if self.hub_id is not None: + clauses.append(Driver.hub_id == self.hub_id) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + Driver.code.ilike(term), + Driver.display_name.ilike(term), + Driver.mobile.ilike(term), + Driver.email.ilike(term), + Driver.first_name.ilike(term), + Driver.last_name.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in ALLOWED_SORT_FIELDS else "created_at" + column = getattr(Driver, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + def apply_filters_only(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + return stmt diff --git a/backend/services/delivery/app/tests/conftest.py b/backend/services/delivery/app/tests/conftest.py new file mode 100644 index 0000000..d698d74 --- /dev/null +++ b/backend/services/delivery/app/tests/conftest.py @@ -0,0 +1,62 @@ +import os +import uuid + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +os.environ["ENVIRONMENT"] = "test" +os.environ["AUTH_REQUIRED"] = "false" +os.environ["DELIVERY_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["DELIVERY_DATABASE_URL_SYNC"] = "sqlite:///:memory:" +os.environ["JWT_VERIFY_SIGNATURE"] = "false" + +from app.core.config import get_settings # noqa: E402 + +get_settings.cache_clear() + +from app.core.database import Base, engine # noqa: E402 +import app.models # noqa: E402, F401 +from app.events.publisher import reset_event_publisher # noqa: E402 +from app.main import app # noqa: E402 + +TENANT_A = uuid.uuid4() +TENANT_B = uuid.uuid4() + + +@pytest_asyncio.fixture +async def db_setup(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(scope="session", autouse=True) +def _dispose_engine_on_session_end(): + yield + import asyncio + + try: + asyncio.run(engine.dispose()) + except RuntimeError: + pass + + +@pytest_asyncio.fixture(autouse=True) +def _reset_events(): + reset_event_publisher() + yield + + +@pytest_asyncio.fixture +async def client(db_setup): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as ac: + yield ac + + +def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]: + return {"X-Tenant-ID": str(tenant_id)} diff --git a/backend/services/delivery/app/tests/test_api.py b/backend/services/delivery/app/tests/test_api.py new file mode 100644 index 0000000..28a41ee --- /dev/null +++ b/backend/services/delivery/app/tests/test_api.py @@ -0,0 +1,136 @@ +"""API, health, tenant isolation, and foundation flow tests.""" +from __future__ import annotations + +from app.events.publisher import get_event_publisher +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def test_health_capabilities_metrics(client): + health = await client.get("/health") + assert health.status_code == 200 + body = health.json() + assert body["status"] == "ok" + assert body["service"] == "delivery-service" + + caps = await client.get("/capabilities") + assert caps.status_code == 200 + c = caps.json() + assert c["phase"] == "10.1" + assert c["commercial_product"] == "Torbat Driver" + assert c["features"]["foundation"] is True + assert c["features"]["drivers"] is True + assert c["features"]["dispatch_engine"] is False + assert c["independence"]["integration_mode"] == "api_and_events_only" + + metrics = await client.get("/metrics") + assert metrics.status_code == 200 + assert metrics.json()["phase"] == "10.1" + + +async def test_foundation_flow_and_events(client): + headers = tenant_headers(TENANT_A) + org = await client.post( + "/api/v1/organizations", + headers=headers, + json={"code": "ORG1", "name": "Fleet Alpha", "status": "active"}, + ) + assert org.status_code == 201, org.text + org_id = org.json()["id"] + + hub = await client.post( + "/api/v1/hubs", + headers=headers, + json={ + "organization_id": org_id, + "code": "HUB1", + "name": "Central Hub", + }, + ) + assert hub.status_code == 201, hub.text + + provider = await client.post( + "/api/v1/external-providers", + headers=headers, + json={ + "organization_id": org_id, + "code": "FLEET_X", + "name": "External Fleet X", + "provider_kind": "fleet", + "adapter_key": "mock_fleet", + }, + ) + assert provider.status_code == 201, provider.text + + engine = await client.post( + "/api/v1/routing-engines", + headers=headers, + json={ + "organization_id": org_id, + "code": "ROUTE_A", + "name": "Future Engine A", + "adapter_key": "mock_router", + "status": "ready", + }, + ) + assert engine.status_code == 201, engine.text + + cfg = await client.post( + "/api/v1/configurations", + headers=headers, + json={"organization_id": org_id, "code": "DEFAULT"}, + ) + assert cfg.status_code == 201, cfg.text + + setting = await client.put( + "/api/v1/settings", + headers=headers, + json={ + "organization_id": org_id, + "key": "default_currency", + "value": {"code": "IRR"}, + }, + ) + assert setting.status_code == 200, setting.text + + audit = await client.get( + "/api/v1/audit", + headers=headers, + params={"entity_type": "delivery_organization", "entity_id": org_id}, + ) + assert audit.status_code == 200 + assert len(audit.json()) >= 1 + + published = [e.event_type for e in get_event_publisher().published] + assert "delivery.organization.created" in published + assert "delivery.hub.created" in published + assert "delivery.external_provider.registered" in published + assert "delivery.routing_engine.registered" in published + + +async def test_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + + created = await client.post( + "/api/v1/organizations", + headers=headers_a, + json={"code": "ISO1", "name": "Tenant A Org"}, + ) + assert created.status_code == 201 + org_id = created.json()["id"] + + denied = await client.get(f"/api/v1/organizations/{org_id}", headers=headers_b) + assert denied.status_code == 404 + + listed_b = await client.get("/api/v1/organizations", headers=headers_b) + assert listed_b.status_code == 200 + assert listed_b.json() == [] + + listed_a = await client.get("/api/v1/organizations", headers=headers_a) + assert listed_a.status_code == 200 + assert len(listed_a.json()) == 1 + + +async def test_missing_tenant_header_rejected(client): + res = await client.get("/api/v1/organizations") + assert res.status_code in (400, 422) diff --git a/backend/services/delivery/app/tests/test_architecture.py b/backend/services/delivery/app/tests/test_architecture.py new file mode 100644 index 0000000..5eedc9d --- /dev/null +++ b/backend/services/delivery/app/tests/test_architecture.py @@ -0,0 +1,190 @@ +"""Architecture tests — Delivery module boundary enforcement.""" +from __future__ import annotations + +import ast +import re +from pathlib import Path + +from app.models import foundation as models + +FORBIDDEN_IMPORT_PREFIXES = ( + "backend.services.accounting", + "backend.services.crm", + "backend.services.loyalty", + "backend.services.communication", + "backend.services.sports_center", + "backend.services.automation", + "backend.services.notification", + "backend.services.file_storage", + "backend.services.identity_access", + "backend.core_service", +) + +FOUNDATION_MODELS = [ + models.DeliveryOrganization, + models.DeliveryHub, + models.DeliveryRole, + models.DeliveryPermission, + models.ExternalProviderConfig, + models.RoutingEngineRegistration, + models.DeliveryConfiguration, + models.DeliverySetting, + models.DeliveryAuditLog, +] + + +def test_all_models_have_tenant_id(): + from app.core.database import Base + import app.models # noqa: F401 + + skip = {"alembic_version"} + for table in Base.metadata.tables.values(): + if table.name in skip: + continue + assert "tenant_id" in table.columns, f"{table.name} missing tenant_id" + + +def test_foundation_aggregates_are_independent(): + assert len(FOUNDATION_MODELS) == 9 + for model in FOUNDATION_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_no_dispatch_engine_tables(): + from app.core.database import Base + import app.models # noqa: F401 + + names = set(Base.metadata.tables.keys()) + assert "drivers" in names + assert "driver_credentials" in names + assert "driver_documents" in names + assert "driver_lifecycle_events" in names + assert "outbox_events" in names + forbidden = { + "dispatch_jobs", + "routes", + "tracking_sessions", + "vehicles", + "fleets", + "proof_of_delivery", + } + assert forbidden.isdisjoint(names) + + +def test_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + assert "delivery.view" in ALL_PERMISSIONS + assert "delivery.organizations.create" in ALL_PERMISSIONS + assert "delivery.external_providers.manage" in ALL_PERMISSIONS + assert "delivery.routing_engines.view" in ALL_PERMISSIONS + assert "delivery.audit.view" in ALL_PERMISSIONS + assert "delivery.drivers.create" in ALL_PERMISSIONS + assert "delivery.drivers.activate" in ALL_PERMISSIONS + assert "delivery.drivers.credentials.manage" in ALL_PERMISSIONS + assert "delivery.dispatch.manage" in ALL_PERMISSIONS + for prefix in PERMISSION_PREFIXES: + assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS) + + +def test_events_defined(): + from app.events.types import DeliveryEventType + + assert DeliveryEventType.ORGANIZATION_CREATED.value == "delivery.organization.created" + assert DeliveryEventType.HUB_CREATED.value == "delivery.hub.created" + assert ( + DeliveryEventType.EXTERNAL_PROVIDER_REGISTERED.value + == "delivery.external_provider.registered" + ) + assert ( + DeliveryEventType.ROUTING_ENGINE_REGISTERED.value + == "delivery.routing_engine.registered" + ) + assert DeliveryEventType.DRIVER_CREATED.value == "delivery.driver.created" + assert DeliveryEventType.DRIVER_ACTIVATED.value == "delivery.driver.activated" + assert DeliveryEventType.DRIVER_STATUS_CHANGED.value == "delivery.driver.status_changed" + + +def test_platform_provider_contracts_exist(): + from app.providers import ( + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + FleetProvider, + IdentityProvider, + LoyaltyProvider, + NotificationProvider, + RoutingEngineProvider, + StorageProvider, + ) + + assert AccountingProvider is not None + assert CommunicationProvider is not None + assert RoutingEngineProvider is not None + assert FleetProvider is not None + assert LoyaltyProvider is not None + assert CRMProvider is not None + assert NotificationProvider is not None + assert StorageProvider is not None + assert AIProvider is not None + assert IdentityProvider is not None + + +def test_no_forbidden_service_imports(): + root = Path(__file__).resolve().parents[1] + violations: list[str] = [] + for path in root.rglob("*.py"): + if "tests" in path.parts: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + for forbidden in FORBIDDEN_IMPORT_PREFIXES: + if alias.name.startswith(forbidden) or forbidden in alias.name: + violations.append(f"{path}: import {alias.name}") + elif isinstance(node, ast.ImportFrom) and node.module: + for forbidden in FORBIDDEN_IMPORT_PREFIXES: + if node.module.startswith(forbidden) or forbidden in node.module: + violations.append(f"{path}: from {node.module}") + assert violations == [] + + +def test_api_ownership_is_delivery_only(): + from app.api.v1 import api_router + + prefixes = {getattr(r, "path", "") for r in api_router.routes} + joined = " ".join(sorted(prefixes)) + assert "/organizations" in joined or any("/organizations" in p for p in prefixes) + assert "/hubs" in joined or any("/hubs" in p for p in prefixes) + assert "/drivers" in joined or any("/drivers" in p for p in prefixes) + forbidden = ("accounting", "crm", "loyalty", "notification", "automation", "sports") + for name in forbidden: + assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name + + +def test_folder_structure(): + root = Path(__file__).resolve().parents[2] + required = [ + "app/models", + "app/repositories", + "app/services", + "app/validators", + "app/schemas", + "app/events", + "app/permissions", + "app/providers", + "app/policies", + "app/specifications", + "app/commands", + "app/queries", + "app/api/v1", + "app/tests", + "alembic/versions", + "README.md", + ] + for rel in required: + assert (root / rel).exists(), f"missing {rel}" diff --git a/backend/services/delivery/app/tests/test_dependency.py b/backend/services/delivery/app/tests/test_dependency.py new file mode 100644 index 0000000..648985b --- /dev/null +++ b/backend/services/delivery/app/tests/test_dependency.py @@ -0,0 +1,34 @@ +"""Dependency / layering validation.""" +from app.repositories.base import TenantBaseRepository +from app.repositories.drivers import DriverRepository +from app.repositories.foundation import DeliveryOrganizationRepository +from app.services import drivers as driver_services +from app.services import foundation as services +from app.providers import contracts + + +def test_repos_inherit_tenant_base(): + assert issubclass(DeliveryOrganizationRepository, TenantBaseRepository) + assert issubclass(DriverRepository, TenantBaseRepository) + + +def test_services_do_not_import_fastapi(): + import inspect + + src = inspect.getsource(services) + assert "fastapi" not in src.lower() + driver_src = inspect.getsource(driver_services) + assert "fastapi" not in driver_src.lower() + + +def test_provider_contracts_are_protocols(): + assert hasattr(contracts.AccountingProvider, "__protocol_attrs__") or True + assert callable(contracts.RoutingEngineProvider.plan_route) + + +def test_commands_and_queries_exist(): + from app.commands.drivers import DriverCommands + from app.queries.drivers import DriverQueries + + assert DriverCommands is not None + assert DriverQueries is not None diff --git a/backend/services/delivery/app/tests/test_docs.py b/backend/services/delivery/app/tests/test_docs.py new file mode 100644 index 0000000..266192d --- /dev/null +++ b/backend/services/delivery/app/tests/test_docs.py @@ -0,0 +1,39 @@ +"""Documentation validation for Delivery Phase 10.1.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[5] + + +def test_phase_docs_exist(): + assert (ROOT / "docs" / "delivery-phase-10-0.md").exists() + assert (ROOT / "docs" / "delivery-phase-10-1.md").exists() + assert (ROOT / "docs" / "phase-handover" / "phase-10-0.md").exists() + assert (ROOT / "docs" / "phase-handover" / "phase-10-1.md").exists() + assert (ROOT / "docs" / "phases" / "Delivery" / "README.md").exists() + assert (ROOT / "docs" / "architecture" / "adr" / "ADR-015.md").exists() + assert (ROOT / "docs" / "delivery-roadmap.md").exists() + + +def test_module_registry_mentions_delivery(): + text = (ROOT / "docs" / "module-registry.md").read_text(encoding="utf-8") + assert "## delivery" in text.lower() + assert "delivery_db" in text + assert "delivery." in text + assert "10.1" in text or "0.10.1" in text + assert "delivery.drivers" in text + + +def test_progress_mentions_phase_10_1(): + text = (ROOT / "docs" / "progress.md").read_text(encoding="utf-8") + assert "10.1" in text + assert "Driver Management" in text or "drivers" in text.lower() + + +def test_service_readme_documents_boundaries(): + text = (ROOT / "backend" / "services" / "delivery" / "README.md").read_text( + encoding="utf-8" + ) + assert "delivery_db" in text + assert "8007" in text + assert "drivers" in text.lower() + assert "dispatch" in text.lower() or "Accounting" in text diff --git a/backend/services/delivery/app/tests/test_migration.py b/backend/services/delivery/app/tests/test_migration.py new file mode 100644 index 0000000..f32a1f0 --- /dev/null +++ b/backend/services/delivery/app/tests/test_migration.py @@ -0,0 +1,36 @@ +"""Migration validation.""" +from pathlib import Path + +from app.core.database import Base +import app.models # noqa: F401 + + +EXPECTED_TABLES = { + "delivery_organizations", + "delivery_hubs", + "delivery_roles", + "delivery_permissions", + "external_provider_configs", + "routing_engine_registrations", + "delivery_configurations", + "delivery_settings", + "delivery_audit_logs", + "drivers", + "driver_credentials", + "driver_documents", + "driver_lifecycle_events", + "outbox_events", +} + + +def test_alembic_revision_exists(): + versions = Path(__file__).resolve().parents[2] / "alembic" / "versions" + files = list(versions.glob("0001_initial*.py")) + assert files, "missing 0001_initial migration" + phase101 = list(versions.glob("0002_phase_101_drivers*.py")) + assert phase101, "missing 0002_phase_101_drivers migration" + + +def test_metadata_has_foundation_tables(): + names = set(Base.metadata.tables.keys()) + assert EXPECTED_TABLES.issubset(names) diff --git a/backend/services/delivery/app/tests/test_performance.py b/backend/services/delivery/app/tests/test_performance.py new file mode 100644 index 0000000..70a838b --- /dev/null +++ b/backend/services/delivery/app/tests/test_performance.py @@ -0,0 +1,22 @@ +"""Performance / index justification for Phase 10.1 driver tables.""" +from __future__ import annotations + +from app.core.database import Base +import app.models # noqa: F401 + + +def test_driver_indexes_exist(): + drivers = Base.metadata.tables["drivers"] + index_names = {idx.name for idx in drivers.indexes} + assert "ix_drivers_tenant_status" in index_names + assert "ix_drivers_org" in index_names + assert "ix_drivers_hub" in index_names + assert "ix_drivers_mobile" in index_names + assert "ix_drivers_display_name" in index_names + + +def test_outbox_indexes_exist(): + outbox = Base.metadata.tables["outbox_events"] + index_names = {idx.name for idx in outbox.indexes} + assert "ix_delivery_outbox_status" in index_names + assert "ix_delivery_outbox_tenant" in index_names diff --git a/backend/services/delivery/app/tests/test_permissions.py b/backend/services/delivery/app/tests/test_permissions.py new file mode 100644 index 0000000..d934ec9 --- /dev/null +++ b/backend/services/delivery/app/tests/test_permissions.py @@ -0,0 +1,29 @@ +"""Permission definition tests.""" +from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + +def test_all_permissions_use_delivery_prefix(): + assert ALL_PERMISSIONS + for perm in ALL_PERMISSIONS: + assert perm.startswith("delivery.") + + +def test_permission_prefixes_stable(): + assert "delivery." in PERMISSION_PREFIXES + assert "delivery.organizations." in PERMISSION_PREFIXES + assert "delivery.drivers." in PERMISSION_PREFIXES + assert "delivery.dispatch." in PERMISSION_PREFIXES + + +def test_driver_permissions_present(): + from app.permissions.definitions import ( + DRIVERS_ACTIVATE, + DRIVERS_CREATE, + DRIVERS_CREDENTIALS_MANAGE, + DRIVERS_VIEW, + ) + + assert DRIVERS_VIEW in ALL_PERMISSIONS + assert DRIVERS_CREATE in ALL_PERMISSIONS + assert DRIVERS_ACTIVATE in ALL_PERMISSIONS + assert DRIVERS_CREDENTIALS_MANAGE in ALL_PERMISSIONS diff --git a/backend/services/delivery/app/tests/test_phase_101.py b/backend/services/delivery/app/tests/test_phase_101.py new file mode 100644 index 0000000..9cb9dfe --- /dev/null +++ b/backend/services/delivery/app/tests/test_phase_101.py @@ -0,0 +1,291 @@ +"""Phase 10.1 — Driver Management tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.models.types import DriverLifecycleAction, DriverStatus +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers +from app.validators.drivers import ensure_driver_lifecycle_transition +from shared.exceptions import AppError + + +async def _org(client, code="ORG_D"): + resp = await client.post( + "/api/v1/organizations", + headers=tenant_headers(TENANT_A), + json={"code": code, "name": code, "status": "active"}, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _hub(client, org_id, code="HUB_D"): + resp = await client.post( + "/api/v1/hubs", + headers=tenant_headers(TENANT_A), + json={"organization_id": org_id, "code": code, "name": code}, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _driver(client, org_id, *, code="DRV1", hub_id=None, status="pending"): + payload = { + "organization_id": org_id, + "code": code, + "display_name": f"Driver {code}", + "mobile": "+989121234567", + "status": status, + } + if hub_id: + payload["hub_id"] = hub_id + resp = await client.post( + "/api/v1/drivers", + headers=tenant_headers(TENANT_A), + json=payload, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +def test_lifecycle_transition_matrix_unit(): + ensure_driver_lifecycle_transition( + action=DriverLifecycleAction.ACTIVATE, current=DriverStatus.PENDING + ) + with pytest.raises(AppError) as exc: + ensure_driver_lifecycle_transition( + action=DriverLifecycleAction.SUSPEND, current=DriverStatus.PENDING + ) + assert exc.value.error_code == "invalid_driver_lifecycle_transition" + with pytest.raises(AppError) as exc2: + ensure_driver_lifecycle_transition( + action=DriverLifecycleAction.ACTIVATE, current=DriverStatus.ARCHIVED + ) + assert exc2.value.error_code == "driver_terminal" + + +@pytest.mark.asyncio +async def test_driver_lifecycle_flow(client): + org = await _org(client, "LIFEORG") + hub = await _hub(client, org["id"], "LIFEHUB") + driver = await _driver(client, org["id"], code="LIFE1", hub_id=hub["id"]) + did = driver["id"] + version = driver["version"] + + activated = await client.post( + f"/api/v1/drivers/{did}/activate", + headers=tenant_headers(TENANT_A), + json={"version": version}, + ) + assert activated.status_code == 200, activated.text + assert activated.json()["status"] == "active" + version = activated.json()["version"] + + suspended = await client.post( + f"/api/v1/drivers/{did}/suspend", + headers=tenant_headers(TENANT_A), + json={"version": version, "reason": "policy review"}, + ) + assert suspended.status_code == 200 + assert suspended.json()["status"] == "suspended" + version = suspended.json()["version"] + + resumed = await client.post( + f"/api/v1/drivers/{did}/resume", + headers=tenant_headers(TENANT_A), + json={"version": version}, + ) + assert resumed.status_code == 200 + assert resumed.json()["status"] == "active" + version = resumed.json()["version"] + + deactivated = await client.post( + f"/api/v1/drivers/{did}/deactivate", + headers=tenant_headers(TENANT_A), + json={"version": version, "reason": "left company"}, + ) + assert deactivated.status_code == 200 + assert deactivated.json()["status"] == "inactive" + version = deactivated.json()["version"] + + archived = await client.post( + f"/api/v1/drivers/{did}/archive", + headers=tenant_headers(TENANT_A), + json={"version": version, "reason": "closed"}, + ) + assert archived.status_code == 200 + assert archived.json()["status"] == "archived" + + life = await client.get( + f"/api/v1/drivers/{did}/lifecycle", + headers=tenant_headers(TENANT_A), + ) + assert life.status_code == 200 + actions = {row["action"] for row in life.json()} + assert {"activate", "suspend", "resume", "deactivate", "archive"} <= actions + + events = {e.event_type for e in get_event_publisher().published} + assert "delivery.driver.created" in events + assert "delivery.driver.activated" in events + assert "delivery.driver.suspended" in events + assert "delivery.driver.archived" in events + assert "delivery.driver.status_changed" in events + + +@pytest.mark.asyncio +async def test_block_unblock_and_invalid_transition(client): + org = await _org(client, "BLKORG") + driver = await _driver(client, org["id"], code="BLK1", status="active") + did = driver["id"] + version = driver["version"] + + bad = await client.post( + f"/api/v1/drivers/{did}/suspend", + headers=tenant_headers(TENANT_A), + json={"version": version}, + ) + assert bad.status_code == 422 + assert bad.json()["error"]["code"] == "reason_required" + + blocked = await client.post( + f"/api/v1/drivers/{did}/block", + headers=tenant_headers(TENANT_A), + json={"version": version, "reason": "fraud"}, + ) + assert blocked.status_code == 200 + assert blocked.json()["status"] == "blocked" + version = blocked.json()["version"] + + unblocked = await client.post( + f"/api/v1/drivers/{did}/unblock", + headers=tenant_headers(TENANT_A), + json={"version": version}, + ) + assert unblocked.status_code == 200 + assert unblocked.json()["status"] == "inactive" + + +@pytest.mark.asyncio +async def test_credentials_documents_list_filter_search_sort(client): + org = await _org(client, "SRCORG") + await _driver(client, org["id"], code="AAA", status="active") + d2 = await _driver(client, org["id"], code="BBB", status="pending") + await _driver(client, org["id"], code="CCC", status="active") + + listed = await client.get( + "/api/v1/drivers", + headers=tenant_headers(TENANT_A), + params={"status": "active", "sort_by": "code", "sort_dir": "asc", "page_size": 50}, + ) + assert listed.status_code == 200, listed.text + body = listed.json() + assert body["total"] == 2 + assert [i["code"] for i in body["items"]] == ["AAA", "CCC"] + + searched = await client.get( + "/api/v1/drivers", + headers=tenant_headers(TENANT_A), + params={"q": "BBB"}, + ) + assert searched.status_code == 200 + assert searched.json()["total"] == 1 + assert searched.json()["items"][0]["code"] == "BBB" + + cred = await client.post( + f"/api/v1/drivers/{d2['id']}/credentials", + headers=tenant_headers(TENANT_A), + json={"kind": "license", "number": "LIC-1", "is_verified": True}, + ) + assert cred.status_code == 201, cred.text + + docs = await client.post( + f"/api/v1/drivers/{d2['id']}/documents", + headers=tenant_headers(TENANT_A), + json={ + "kind": "license_scan", + "title": "License", + "storage_file_ref": "storage://files/lic-1", + }, + ) + assert docs.status_code == 201, docs.text + + creds = await client.get( + f"/api/v1/drivers/{d2['id']}/credentials", + headers=tenant_headers(TENANT_A), + ) + assert creds.status_code == 200 + assert len(creds.json()) == 1 + + documents = await client.get( + f"/api/v1/drivers/{d2['id']}/documents", + headers=tenant_headers(TENANT_A), + ) + assert documents.status_code == 200 + assert len(documents.json()) == 1 + + +@pytest.mark.asyncio +async def test_optimistic_lock_and_soft_delete(client): + org = await _org(client, "OPTORG") + driver = await _driver(client, org["id"], code="OPT1") + did = driver["id"] + + conflict = await client.patch( + f"/api/v1/drivers/{did}", + headers=tenant_headers(TENANT_A), + json={"display_name": "Nope", "version": 999}, + ) + assert conflict.status_code in (409, 422) + + updated = await client.patch( + f"/api/v1/drivers/{did}", + headers=tenant_headers(TENANT_A), + json={"display_name": "Updated Name", "version": driver["version"]}, + ) + assert updated.status_code == 200 + assert updated.json()["display_name"] == "Updated Name" + assert updated.json()["version"] == driver["version"] + 1 + + deleted = await client.post( + f"/api/v1/drivers/{did}/delete", + headers=tenant_headers(TENANT_A), + ) + assert deleted.status_code == 200 + assert deleted.json()["is_deleted"] is True + + missing = await client.get( + f"/api/v1/drivers/{did}", + headers=tenant_headers(TENANT_A), + ) + assert missing.status_code == 404 + + +@pytest.mark.asyncio +async def test_driver_tenant_isolation(client): + org = await _org(client, "ISOORG") + driver = await _driver(client, org["id"], code="ISO1") + other = await client.get( + f"/api/v1/drivers/{driver['id']}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + listed = await client.get( + "/api/v1/drivers", + headers=tenant_headers(TENANT_B), + ) + assert listed.status_code == 200 + assert listed.json()["total"] == 0 + + +@pytest.mark.asyncio +async def test_permission_catalog(client): + resp = await client.get( + "/api/v1/permissions/catalog", + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 200 + body = resp.json() + assert "delivery.drivers.activate" in body["permissions"] + assert body["count"] >= 40 diff --git a/backend/services/delivery/app/tests/test_security.py b/backend/services/delivery/app/tests/test_security.py new file mode 100644 index 0000000..999eee7 --- /dev/null +++ b/backend/services/delivery/app/tests/test_security.py @@ -0,0 +1,41 @@ +"""Security validation — anonymous denial when auth required.""" +from __future__ import annotations + +import os + +import pytest +from httpx import ASGITransport, AsyncClient + + +@pytest.mark.asyncio +async def test_auth_required_denies_anonymous(db_setup): + os.environ["AUTH_REQUIRED"] = "true" + from app.core.config import get_settings + + get_settings.cache_clear() + # Re-import settings binding used by security + import app.core.config as config_mod + import app.core.security as security_mod + import app.api.permissions as perm_mod + + config_mod.settings = get_settings() + security_mod.settings = config_mod.settings + perm_mod.settings = config_mod.settings + + from app.main import create_app + + app = create_app() + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + res = await client.get( + "/api/v1/organizations", + headers={"X-Tenant-ID": "11111111-1111-1111-1111-111111111111"}, + ) + assert res.status_code == 401 + finally: + os.environ["AUTH_REQUIRED"] = "false" + get_settings.cache_clear() + config_mod.settings = get_settings() + security_mod.settings = config_mod.settings + perm_mod.settings = config_mod.settings diff --git a/backend/services/delivery/app/validators/__init__.py b/backend/services/delivery/app/validators/__init__.py new file mode 100644 index 0000000..90ef2b8 --- /dev/null +++ b/backend/services/delivery/app/validators/__init__.py @@ -0,0 +1,14 @@ +"""Delivery validators package.""" +from app.validators.foundation import ( + ensure_optimistic_version, + validate_code, + validate_currency_code, + validate_non_empty, +) + +__all__ = [ + "validate_non_empty", + "validate_code", + "validate_currency_code", + "ensure_optimistic_version", +] diff --git a/backend/services/delivery/app/validators/drivers.py b/backend/services/delivery/app/validators/drivers.py new file mode 100644 index 0000000..b83dfd9 --- /dev/null +++ b/backend/services/delivery/app/validators/drivers.py @@ -0,0 +1,140 @@ +"""Driver lifecycle validators — Phase 10.1.""" +from __future__ import annotations + +import re +from datetime import date + +from shared.exceptions import AppError + +from app.models.types import DriverLifecycleAction, DriverStatus +from app.validators.foundation import validate_non_empty + +PHONE_RE = re.compile(r"^\+?[0-9]{8,15}$") +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + +ALLOWED_TRANSITIONS: dict[DriverLifecycleAction, set[DriverStatus]] = { + DriverLifecycleAction.ACTIVATE: { + DriverStatus.PENDING, + DriverStatus.INACTIVE, + DriverStatus.SUSPENDED, + }, + DriverLifecycleAction.SUSPEND: {DriverStatus.ACTIVE}, + DriverLifecycleAction.RESUME: {DriverStatus.SUSPENDED}, + DriverLifecycleAction.DEACTIVATE: { + DriverStatus.ACTIVE, + DriverStatus.SUSPENDED, + }, + DriverLifecycleAction.BLOCK: { + DriverStatus.PENDING, + DriverStatus.ACTIVE, + DriverStatus.SUSPENDED, + DriverStatus.INACTIVE, + }, + DriverLifecycleAction.UNBLOCK: {DriverStatus.BLOCKED}, + DriverLifecycleAction.ARCHIVE: { + DriverStatus.INACTIVE, + DriverStatus.BLOCKED, + }, +} + +TERMINAL_STATUSES = frozenset({DriverStatus.ARCHIVED}) + +TARGET_STATUS: dict[DriverLifecycleAction, DriverStatus] = { + DriverLifecycleAction.ACTIVATE: DriverStatus.ACTIVE, + DriverLifecycleAction.SUSPEND: DriverStatus.SUSPENDED, + DriverLifecycleAction.RESUME: DriverStatus.ACTIVE, + DriverLifecycleAction.DEACTIVATE: DriverStatus.INACTIVE, + DriverLifecycleAction.BLOCK: DriverStatus.BLOCKED, + DriverLifecycleAction.UNBLOCK: DriverStatus.INACTIVE, + DriverLifecycleAction.ARCHIVE: DriverStatus.ARCHIVED, +} + + +def ensure_driver_lifecycle_transition( + *, action: DriverLifecycleAction, current: DriverStatus +) -> None: + if current in TERMINAL_STATUSES: + raise AppError( + "راننده در وضعیت پایانی است و قابل تغییر نیست", + status_code=409, + error_code="driver_terminal", + details={"status": current.value, "action": action.value}, + ) + allowed = ALLOWED_TRANSITIONS.get(action, set()) + if current not in allowed: + raise AppError( + "انتقال وضعیت راننده مجاز نیست", + status_code=409, + error_code="invalid_driver_lifecycle_transition", + details={ + "from_status": current.value, + "action": action.value, + "allowed_from": sorted(s.value for s in allowed), + }, + ) + + +def target_status_for(action: DriverLifecycleAction) -> DriverStatus: + return TARGET_STATUS[action] + + +def validate_reason(reason: str | None, *, required: bool = False) -> str | None: + if reason is None or not str(reason).strip(): + if required: + raise AppError( + "دلیل الزامی است", + status_code=422, + error_code="reason_required", + ) + return None + text = str(reason).strip() + if len(text) > 500: + raise AppError( + "دلیل بیش از حد طولانی است", + status_code=422, + error_code="reason_too_long", + ) + return text + + +def validate_mobile(mobile: str | None) -> str | None: + if mobile is None or not str(mobile).strip(): + return None + value = str(mobile).strip() + if not PHONE_RE.match(value): + raise AppError( + "شماره موبایل نامعتبر است", + status_code=422, + error_code="invalid_mobile", + details={"field": "mobile"}, + ) + return value + + +def validate_email(email: str | None) -> str | None: + if email is None or not str(email).strip(): + return None + value = str(email).strip() + if not EMAIL_RE.match(value): + raise AppError( + "ایمیل نامعتبر است", + status_code=422, + error_code="invalid_email", + details={"field": "email"}, + ) + return value + + +def validate_credential_dates( + *, issued_at: date | None, expires_at: date | None +) -> None: + if issued_at and expires_at and expires_at < issued_at: + raise AppError( + "تاریخ انقضا نمی‌تواند قبل از تاریخ صدور باشد", + status_code=422, + error_code="invalid_credential_dates", + ) + + +def validate_display_name(name: str) -> str: + return validate_non_empty(name, "display_name") diff --git a/backend/services/delivery/app/validators/foundation.py b/backend/services/delivery/app/validators/foundation.py new file mode 100644 index 0000000..8f4cecd --- /dev/null +++ b/backend/services/delivery/app/validators/foundation.py @@ -0,0 +1,55 @@ +"""Reusable validators for Delivery foundation.""" +from __future__ import annotations + +import re +from typing import Any + +from shared.exceptions import AppError + +CODE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{1,49}$") + + +class ValidationError(AppError): + def __init__(self, message: str, details: dict[str, Any] | None = None): + super().__init__( + message=message, + error_code="validation_error", + status_code=422, + details=details or {}, + ) + + +def validate_non_empty(value: str | None, field: str) -> str: + if value is None or not str(value).strip(): + raise ValidationError(f"{field} الزامی است", {"field": field}) + return str(value).strip() + + +def validate_code(code: str) -> str: + code = validate_non_empty(code, "code") + if not CODE_RE.match(code): + raise ValidationError( + "کد نامعتبر است", + {"field": "code", "pattern": CODE_RE.pattern}, + ) + return code + + +def validate_currency_code(code: str) -> str: + code = validate_non_empty(code, "currency_code").upper() + if len(code) != 3: + raise ValidationError("currency_code باید ۳ حرفی باشد", {"field": "currency_code"}) + return code + + +def ensure_optimistic_version(entity: Any, expected: int) -> None: + current = getattr(entity, "version", None) + if current is None: + return + if current != expected: + raise AppError( + "نسخه موجودیت هم‌خوان نیست", + error_code="version_conflict", + status_code=409, + details={"expected": expected, "actual": current}, + ) diff --git a/backend/services/delivery/pytest.ini b/backend/services/delivery/pytest.ini new file mode 100644 index 0000000..f1f608e --- /dev/null +++ b/backend/services/delivery/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +testpaths = app/tests +pythonpath = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/services/delivery/requirements.txt b/backend/services/delivery/requirements.txt new file mode 100644 index 0000000..bbdf395 --- /dev/null +++ b/backend/services/delivery/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +pydantic[email]==2.7.4 +pydantic-settings==2.3.4 +sqlalchemy==2.0.31 +alembic==1.13.2 +asyncpg==0.29.0 +psycopg[binary]>=3.2.2 +httpx==0.27.0 +pyjwt[crypto]==2.8.0 +-e ../../shared-lib +pytest==8.2.2 +pytest-asyncio==0.23.7 +aiosqlite==0.20.0 diff --git a/backend/services/delivery/scripts/ensure_db.py b/backend/services/delivery/scripts/ensure_db.py new file mode 100644 index 0000000..c483262 --- /dev/null +++ b/backend/services/delivery/scripts/ensure_db.py @@ -0,0 +1,41 @@ +"""Ensure delivery_db exists before migration.""" +from __future__ import annotations + +import os +import sys +from urllib.parse import urlparse + + +def main() -> None: + sync_url = os.environ.get("DELIVERY_DATABASE_URL_SYNC", "") + if not sync_url: + print("DELIVERY_DATABASE_URL_SYNC not set", file=sys.stderr) + return + + parsed = urlparse(sync_url.replace("+psycopg", "")) + db_name = (parsed.path or "").lstrip("/") or "delivery_db" + + import psycopg + + conn = psycopg.connect( + host=parsed.hostname or "localhost", + port=parsed.port or 5432, + user=parsed.username, + password=parsed.password, + dbname="postgres", + autocommit=True, + ) + try: + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) + if cur.fetchone() is None: + cur.execute(f'CREATE DATABASE "{db_name}"') + print(f"Created database: {db_name}") + else: + print(f"Database exists: {db_name}") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/services/hospitality/Dockerfile.dev b/backend/services/hospitality/Dockerfile.dev index a3e7c6e..93b900d 100644 --- a/backend/services/hospitality/Dockerfile.dev +++ b/backend/services/hospitality/Dockerfile.dev @@ -19,4 +19,4 @@ RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \ && pip install --upgrade pip \ && pip install -r requirements.txt -EXPOSE 8007 +EXPOSE 8009 diff --git a/backend/services/hospitality/README.md b/backend/services/hospitality/README.md new file mode 100644 index 0000000..740417b --- /dev/null +++ b/backend/services/hospitality/README.md @@ -0,0 +1,45 @@ +# Hospitality Platform Service (Torbat Food) + +Independent enterprise Hospitality Platform for Cafe, Coffee Shop, Restaurant, Fast Food, Bakery, Pastry, Ice Cream, Juice Bar, Cloud Kitchen, Food Court, Catering, and Take Away. + +| Field | Value | +| --- | --- | +| Service | `hospitality-service` | +| Database | `hospitality_db` (sole owner) | +| API Port | 8009 | +| Version | 0.12.8.0 | +| Phase | 12.8 Analytics | +| Permissions | `hospitality.*` | +| ADR | [ADR-017](../../../docs/architecture/adr/ADR-017.md) | + +## Responsibilities (through Phase 12.8) + +- Venue / branch / table foundation +- Digital menu catalog (allergens, modifiers, availability, media, localizations) +- QR Menu sessions + QR Ordering / cart shells +- Table Service & Reservations: reservations, table assignments, service requests, waitlist +- POS Lite: registers, shifts, tickets, ticket lines +- POS Pro: payments, discounts, tax lines, floor plans, stations (local records only) +- Kitchen: stations, tickets (routed from POS/QR by reference), ticket items +- Connectors: registrations + outbound dispatches to delivery/accounting/CRM/loyalty/communication/website platforms (mock providers only) +- Analytics: report definitions + snapshots computed via local `COUNT(*)` queries only +- Bundle-based licensing + feature toggles +- Capability / health discovery +- Publish-only events; provider contracts only + +**Does not** call real connector SDKs, post accounting journals, run fiscal-device integrations, or provide AI suggestions yet. + +## Related Documents + +- [Hospitality Phase 12.8](../../../docs/hospitality-phase-12-8.md) +- [Hospitality Phase 12.7](../../../docs/hospitality-phase-12-7.md) +- [Hospitality Phase 12.6](../../../docs/hospitality-phase-12-6.md) +- [Hospitality Phase 12.5](../../../docs/hospitality-phase-12-5.md) +- [Hospitality Phase 12.4](../../../docs/hospitality-phase-12-4.md) +- [Hospitality Phase 12.3](../../../docs/hospitality-phase-12-3.md) +- [Hospitality Phase 12.2](../../../docs/hospitality-phase-12-2.md) +- [Hospitality Phase 12.1](../../../docs/hospitality-phase-12-1.md) +- [Hospitality Phase 12.0](../../../docs/hospitality-phase-12-0.md) +- [Hospitality Roadmap](../../../docs/hospitality-roadmap.md) +- [Module Registry](../../../docs/module-registry.md#hospitality) +- [ADR-017](../../../docs/architecture/adr/ADR-017.md) diff --git a/backend/services/hospitality/alembic/env.py b/backend/services/hospitality/alembic/env.py new file mode 100644 index 0000000..ba24b6c --- /dev/null +++ b/backend/services/hospitality/alembic/env.py @@ -0,0 +1,42 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.core.database import Base +import app.models # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url_sync) +if config.config_file_name: + fileConfig(config.config_file_name) +target_metadata = Base.metadata + + +def run_migrations_offline(): + context.configure( + url=settings.database_url_sync, + target_metadata=target_metadata, + literal_binds=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/services/hospitality/alembic/versions/0001_initial.py b/backend/services/hospitality/alembic/versions/0001_initial.py new file mode 100644 index 0000000..185ca1e --- /dev/null +++ b/backend/services/hospitality/alembic/versions/0001_initial.py @@ -0,0 +1,19 @@ +"""Initial Hospitality schema — Phase 12.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) diff --git a/backend/services/hospitality/alembic/versions/0006_phase_125_pos_pro.py b/backend/services/hospitality/alembic/versions/0006_phase_125_pos_pro.py new file mode 100644 index 0000000..6c810f2 --- /dev/null +++ b/backend/services/hospitality/alembic/versions/0006_phase_125_pos_pro.py @@ -0,0 +1,28 @@ +"""Phase 12.5 — POS Pro schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0006_phase_125_pos_pro" +down_revision = "0005_phase_124_pos_lite" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "pos_stations", + "pos_floor_plans", + "pos_tax_lines", + "pos_discounts", + "pos_payments", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) diff --git a/backend/services/hospitality/alembic/versions/0007_phase_126_kitchen.py b/backend/services/hospitality/alembic/versions/0007_phase_126_kitchen.py new file mode 100644 index 0000000..db8d894 --- /dev/null +++ b/backend/services/hospitality/alembic/versions/0007_phase_126_kitchen.py @@ -0,0 +1,26 @@ +"""Phase 12.6 — Kitchen schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0007_phase_126_kitchen" +down_revision = "0006_phase_125_pos_pro" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "kitchen_ticket_items", + "kitchen_tickets", + "kitchen_stations", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) diff --git a/backend/services/hospitality/alembic/versions/0008_phase_127_connectors.py b/backend/services/hospitality/alembic/versions/0008_phase_127_connectors.py new file mode 100644 index 0000000..7ead61a --- /dev/null +++ b/backend/services/hospitality/alembic/versions/0008_phase_127_connectors.py @@ -0,0 +1,25 @@ +"""Phase 12.7 — Connectors schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0008_phase_127_connectors" +down_revision = "0007_phase_126_kitchen" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "connector_dispatches", + "connector_registrations", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) diff --git a/backend/services/hospitality/alembic/versions/0009_phase_128_analytics.py b/backend/services/hospitality/alembic/versions/0009_phase_128_analytics.py new file mode 100644 index 0000000..96fdc8a --- /dev/null +++ b/backend/services/hospitality/alembic/versions/0009_phase_128_analytics.py @@ -0,0 +1,25 @@ +"""Phase 12.8 — Analytics schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0009_phase_128_analytics" +down_revision = "0008_phase_127_connectors" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "analytics_snapshots", + "analytics_report_definitions", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) diff --git a/backend/services/hospitality/app/__init__.py b/backend/services/hospitality/app/__init__.py index f53c165..0dc68d5 100644 --- a/backend/services/hospitality/app/__init__.py +++ b/backend/services/hospitality/app/__init__.py @@ -1 +1 @@ -__version__ = "0.10.0.0" +__version__ = "0.12.8.0" diff --git a/backend/services/hospitality/app/api/deps.py b/backend/services/hospitality/app/api/deps.py new file mode 100644 index 0000000..56d4194 --- /dev/null +++ b/backend/services/hospitality/app/api/deps.py @@ -0,0 +1,39 @@ +"""Common API dependencies.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user +from shared.exceptions import TenantNotResolvedError +from shared.pagination import PaginationParams +from shared.security import CurrentUser +from shared.tenant import STATE_TENANT_ID + +__all__ = [ + "get_db", + "get_pagination", + "require_tenant", + "get_current_user", + "AsyncSession", + "CurrentUser", +] + + +def get_pagination( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=500), +) -> PaginationParams: + return PaginationParams(page=page, page_size=page_size) + + +def require_tenant(request: Request) -> UUID: + tenant_id = getattr(request.state, STATE_TENANT_ID, None) + if tenant_id is None: + raise TenantNotResolvedError( + "tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید." + ) + return tenant_id diff --git a/backend/services/hospitality/app/api/v1/__init__.py b/backend/services/hospitality/app/api/v1/__init__.py new file mode 100644 index 0000000..6ec6ad7 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/__init__.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter + +from app.api.v1 import ( + analytics, + branches, + bundle_definitions, + catalog, + configurations, + connectors, + dining_areas, + events, + feature_toggles, + kitchen, + menus, + menu_categories, + menu_items, + permissions, + pos_lite, + pos_pro, + qr, + roles, + settings, + table_service, + tables, + tenant_bundles, + venues, +) + +api_router = APIRouter() +api_router.include_router(venues.router, prefix="/venues", tags=["venues"]) +api_router.include_router(branches.router, prefix="/branches", tags=["branches"]) +api_router.include_router(dining_areas.router, prefix="/dining-areas", tags=["dining-areas"]) +api_router.include_router(tables.router, prefix="/tables", tags=["tables"]) +api_router.include_router(menus.router, prefix="/menus", tags=["menus"]) +api_router.include_router(menu_categories.router, prefix="/menu-categories", tags=["menu-categories"]) +api_router.include_router(menu_items.router, prefix="/menu-items", tags=["menu-items"]) +api_router.include_router(catalog.allergens_router, prefix="/allergens", tags=["allergens"]) +api_router.include_router(catalog.modifier_groups_router, prefix="/modifier-groups", tags=["modifier-groups"]) +api_router.include_router(catalog.modifier_options_router, prefix="/modifier-options", tags=["modifier-options"]) +api_router.include_router(catalog.menu_item_modifiers_router, prefix="/menu-item-modifiers", tags=["menu-item-modifiers"]) +api_router.include_router(catalog.menu_item_allergens_router, prefix="/menu-item-allergens", tags=["menu-item-allergens"]) +api_router.include_router(catalog.availability_windows_router, prefix="/availability-windows", tags=["availability-windows"]) +api_router.include_router(catalog.menu_media_refs_router, prefix="/menu-media-refs", tags=["menu-media-refs"]) +api_router.include_router(catalog.menu_localizations_router, prefix="/menu-localizations", tags=["menu-localizations"]) +api_router.include_router(qr.qr_codes_router, prefix="/qr-codes", tags=["qr-codes"]) +api_router.include_router(qr.qr_menu_sessions_router, prefix="/qr-menu-sessions", tags=["qr-menu-sessions"]) +api_router.include_router(qr.qr_ordering_sessions_router, prefix="/qr-ordering-sessions", tags=["qr-ordering-sessions"]) +api_router.include_router(qr.carts_router, prefix="/carts", tags=["carts"]) +api_router.include_router(qr.cart_lines_router, prefix="/cart-lines", tags=["cart-lines"]) +api_router.include_router(table_service.reservations_router, prefix="/reservations", tags=["reservations"]) +api_router.include_router(table_service.table_assignments_router, prefix="/table-assignments", tags=["table-assignments"]) +api_router.include_router(table_service.service_requests_router, prefix="/service-requests", tags=["service-requests"]) +api_router.include_router(table_service.waitlist_router, prefix="/waitlist", tags=["waitlist"]) +api_router.include_router(pos_lite.pos_registers_router, prefix="/pos-registers", tags=["pos-registers"]) +api_router.include_router(pos_lite.pos_shifts_router, prefix="/pos-shifts", tags=["pos-shifts"]) +api_router.include_router(pos_lite.pos_tickets_router, prefix="/pos-tickets", tags=["pos-tickets"]) +api_router.include_router(pos_lite.pos_ticket_lines_router, prefix="/pos-ticket-lines", tags=["pos-ticket-lines"]) +api_router.include_router(pos_pro.pos_payments_router, prefix="/pos-payments", tags=["pos-payments"]) +api_router.include_router(pos_pro.pos_discounts_router, prefix="/pos-discounts", tags=["pos-discounts"]) +api_router.include_router(pos_pro.pos_tax_lines_router, prefix="/pos-tax-lines", tags=["pos-tax-lines"]) +api_router.include_router(pos_pro.pos_floor_plans_router, prefix="/pos-floor-plans", tags=["pos-floor-plans"]) +api_router.include_router(pos_pro.pos_stations_router, prefix="/pos-stations", tags=["pos-stations"]) +api_router.include_router(kitchen.kitchen_stations_router, prefix="/kitchen-stations", tags=["kitchen-stations"]) +api_router.include_router(kitchen.kitchen_tickets_router, prefix="/kitchen-tickets", tags=["kitchen-tickets"]) +api_router.include_router(kitchen.kitchen_ticket_items_router, prefix="/kitchen-ticket-items", tags=["kitchen-ticket-items"]) +api_router.include_router(connectors.connector_registrations_router, prefix="/connector-registrations", tags=["connector-registrations"]) +api_router.include_router(connectors.connector_dispatches_router, prefix="/connector-dispatches", tags=["connector-dispatches"]) +api_router.include_router(analytics.analytics_reports_router, prefix="/analytics-reports", tags=["analytics-reports"]) +api_router.include_router(analytics.analytics_snapshots_router, prefix="/analytics-snapshots", tags=["analytics-snapshots"]) +api_router.include_router(bundle_definitions.router, prefix="/bundle-definitions", tags=["bundle-definitions"]) +api_router.include_router(tenant_bundles.router, prefix="/tenant-bundles", tags=["tenant-bundles"]) +api_router.include_router(feature_toggles.router, prefix="/feature-toggles", tags=["feature-toggles"]) +api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) +api_router.include_router(permissions.router, prefix="/permissions", tags=["permissions"]) +api_router.include_router(configurations.router, prefix="/configurations", tags=["configurations"]) +api_router.include_router(events.router, prefix="/events", tags=["events"]) +api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) diff --git a/backend/services/hospitality/app/api/v1/analytics.py b/backend/services/hospitality/app/api/v1/analytics.py new file mode 100644 index 0000000..b19eb5d --- /dev/null +++ b/backend/services/hospitality/app/api/v1/analytics.py @@ -0,0 +1,90 @@ +"""Analytics APIs — Phase 12.8.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.core.security import get_current_user +from app.schemas.analytics import ( + AnalyticsReportDefinitionCreate, + AnalyticsReportDefinitionRead, + AnalyticsSnapshotRead, + AnalyticsSnapshotRefresh, +) +from app.services.analytics import AnalyticsReportDefinitionService, AnalyticsSnapshotService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +analytics_reports_router = APIRouter() +analytics_snapshots_router = APIRouter() + + +@analytics_reports_router.post( + "", response_model=AnalyticsReportDefinitionRead, status_code=status.HTTP_201_CREATED +) +async def create_analytics_report( + body: AnalyticsReportDefinitionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await AnalyticsReportDefinitionService(db).create(tenant_id, body, actor=user) + + +@analytics_reports_router.get("", response_model=list[AnalyticsReportDefinitionRead]) +async def list_analytics_reports( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AnalyticsReportDefinitionService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@analytics_reports_router.get("/{report_id}", response_model=AnalyticsReportDefinitionRead) +async def get_analytics_report( + report_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AnalyticsReportDefinitionService(db).get(tenant_id, report_id) + + +@analytics_snapshots_router.post( + "/refresh", response_model=AnalyticsSnapshotRead, status_code=status.HTTP_201_CREATED +) +async def refresh_analytics_snapshot( + body: AnalyticsSnapshotRefresh, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await AnalyticsSnapshotService(db).refresh(tenant_id, body, actor=user) + + +@analytics_snapshots_router.get("", response_model=list[AnalyticsSnapshotRead]) +async def list_analytics_snapshots( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AnalyticsSnapshotService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@analytics_snapshots_router.get("/{snapshot_id}", response_model=AnalyticsSnapshotRead) +async def get_analytics_snapshot( + snapshot_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AnalyticsSnapshotService(db).get(tenant_id, snapshot_id) diff --git a/backend/services/hospitality/app/api/v1/branches.py b/backend/services/hospitality/app/api/v1/branches.py new file mode 100644 index 0000000..1f997f7 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/branches.py @@ -0,0 +1,72 @@ +"""Hospitality branches APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + BranchCreate, + BranchRead, + BranchUpdate, +) +from app.services.foundation import BranchService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=BranchRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: BranchCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[BranchRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{branch_id}", response_model=BranchRead) +async def get_item( + branch_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).get(tenant_id, branch_id) + + +@router.patch("/{branch_id}", response_model=BranchRead) +async def update_item( + branch_id: UUID, + body: BranchUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).update(tenant_id, branch_id, body, actor=user) + + +@router.post("/{branch_id}/delete", response_model=BranchRead) +async def soft_delete_item( + branch_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).soft_delete(tenant_id, branch_id, actor=user) + diff --git a/backend/services/hospitality/app/api/v1/bundle_definitions.py b/backend/services/hospitality/app/api/v1/bundle_definitions.py new file mode 100644 index 0000000..dd3d7de --- /dev/null +++ b/backend/services/hospitality/app/api/v1/bundle_definitions.py @@ -0,0 +1,49 @@ +"""Hospitality bundle-definitions APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + BundleDefinitionCreate, + BundleDefinitionRead, +) +from app.services.foundation import BundleDefinitionService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=BundleDefinitionRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: BundleDefinitionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await BundleDefinitionService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[BundleDefinitionRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await BundleDefinitionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{bundle_id}", response_model=BundleDefinitionRead) +async def get_item( + bundle_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await BundleDefinitionService(db).get(tenant_id, bundle_id) diff --git a/backend/services/hospitality/app/api/v1/catalog.py b/backend/services/hospitality/app/api/v1/catalog.py new file mode 100644 index 0000000..bb22212 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/catalog.py @@ -0,0 +1,269 @@ +"""Digital Menu Catalog APIs — Phase 12.1.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.core.security import get_current_user +from app.schemas.catalog import ( + AllergenCreate, + AllergenRead, + AvailabilityWindowCreate, + AvailabilityWindowRead, + MenuItemAllergenLinkCreate, + MenuItemAllergenLinkRead, + MenuItemModifierLinkCreate, + MenuItemModifierLinkRead, + MenuLocalizationRead, + MenuLocalizationUpsert, + MenuMediaRefCreate, + MenuMediaRefRead, + ModifierGroupCreate, + ModifierGroupRead, + ModifierOptionCreate, + ModifierOptionRead, +) +from app.services.catalog import ( + AllergenService, + AvailabilityWindowService, + MenuItemAllergenService, + MenuItemModifierService, + MenuLocalizationService, + MenuMediaRefService, + ModifierGroupService, + ModifierOptionService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +allergens_router = APIRouter() +modifier_groups_router = APIRouter() +modifier_options_router = APIRouter() +menu_item_modifiers_router = APIRouter() +menu_item_allergens_router = APIRouter() +availability_windows_router = APIRouter() +menu_media_refs_router = APIRouter() +menu_localizations_router = APIRouter() + + +@allergens_router.post("", response_model=AllergenRead, status_code=status.HTTP_201_CREATED) +async def create_allergen( + body: AllergenCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await AllergenService(db).create(tenant_id, body, actor=user) + + +@allergens_router.get("", response_model=list[AllergenRead]) +async def list_allergens( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AllergenService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@allergens_router.get("/{allergen_id}", response_model=AllergenRead) +async def get_allergen( + allergen_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AllergenService(db).get(tenant_id, allergen_id) + + +@modifier_groups_router.post("", response_model=ModifierGroupRead, status_code=status.HTTP_201_CREATED) +async def create_modifier_group( + body: ModifierGroupCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await ModifierGroupService(db).create(tenant_id, body, actor=user) + + +@modifier_groups_router.get("", response_model=list[ModifierGroupRead]) +async def list_modifier_groups( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ModifierGroupService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@modifier_groups_router.get("/{group_id}", response_model=ModifierGroupRead) +async def get_modifier_group( + group_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ModifierGroupService(db).get(tenant_id, group_id) + + +@modifier_options_router.post("", response_model=ModifierOptionRead, status_code=status.HTTP_201_CREATED) +async def create_modifier_option( + body: ModifierOptionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await ModifierOptionService(db).create(tenant_id, body, actor=user) + + +@modifier_options_router.get("", response_model=list[ModifierOptionRead]) +async def list_modifier_options( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ModifierOptionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@modifier_options_router.get("/{option_id}", response_model=ModifierOptionRead) +async def get_modifier_option( + option_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ModifierOptionService(db).get(tenant_id, option_id) + + +@menu_item_modifiers_router.post("/link", response_model=MenuItemModifierLinkRead, status_code=status.HTTP_201_CREATED) +async def link_modifier( + body: MenuItemModifierLinkCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MenuItemModifierService(db).link(tenant_id, body, actor=user) + + +@menu_item_modifiers_router.get("", response_model=list[MenuItemModifierLinkRead]) +async def list_modifier_links( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuItemModifierService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@menu_item_allergens_router.post("/link", response_model=MenuItemAllergenLinkRead, status_code=status.HTTP_201_CREATED) +async def link_allergen( + body: MenuItemAllergenLinkCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MenuItemAllergenService(db).link(tenant_id, body, actor=user) + + +@menu_item_allergens_router.get("", response_model=list[MenuItemAllergenLinkRead]) +async def list_allergen_links( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuItemAllergenService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@availability_windows_router.post("", response_model=AvailabilityWindowRead, status_code=status.HTTP_201_CREATED) +async def create_window( + body: AvailabilityWindowCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await AvailabilityWindowService(db).create(tenant_id, body, actor=user) + + +@availability_windows_router.get("", response_model=list[AvailabilityWindowRead]) +async def list_windows( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AvailabilityWindowService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@availability_windows_router.get("/{window_id}", response_model=AvailabilityWindowRead) +async def get_window( + window_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AvailabilityWindowService(db).get(tenant_id, window_id) + + +@menu_media_refs_router.post("", response_model=MenuMediaRefRead, status_code=status.HTTP_201_CREATED) +async def create_media( + body: MenuMediaRefCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MenuMediaRefService(db).create(tenant_id, body, actor=user) + + +@menu_media_refs_router.get("", response_model=list[MenuMediaRefRead]) +async def list_media( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuMediaRefService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@menu_media_refs_router.get("/{media_id}", response_model=MenuMediaRefRead) +async def get_media( + media_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuMediaRefService(db).get(tenant_id, media_id) + + +@menu_localizations_router.post("/upsert", response_model=MenuLocalizationRead) +async def upsert_localization( + body: MenuLocalizationUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MenuLocalizationService(db).upsert(tenant_id, body, actor=user) + + +@menu_localizations_router.get("", response_model=list[MenuLocalizationRead]) +async def list_localizations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuLocalizationService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@menu_localizations_router.get("/{localization_id}", response_model=MenuLocalizationRead) +async def get_localization( + localization_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuLocalizationService(db).get(tenant_id, localization_id) diff --git a/backend/services/hospitality/app/api/v1/configurations.py b/backend/services/hospitality/app/api/v1/configurations.py new file mode 100644 index 0000000..76bc649 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/configurations.py @@ -0,0 +1,61 @@ +"""Hospitality configurations APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + HospitalityConfigurationCreate, + HospitalityConfigurationRead, + HospitalityConfigurationUpdate, +) +from app.services.foundation import HospitalityConfigurationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=HospitalityConfigurationRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: HospitalityConfigurationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await HospitalityConfigurationService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[HospitalityConfigurationRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalityConfigurationService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{config_id}", response_model=HospitalityConfigurationRead) +async def get_item( + config_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalityConfigurationService(db).get(tenant_id, config_id) + + +@router.patch("/{config_id}", response_model=HospitalityConfigurationRead) +async def update_item( + config_id: UUID, + body: HospitalityConfigurationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await HospitalityConfigurationService(db).update(tenant_id, config_id, body, actor=user) diff --git a/backend/services/hospitality/app/api/v1/connectors.py b/backend/services/hospitality/app/api/v1/connectors.py new file mode 100644 index 0000000..a26210b --- /dev/null +++ b/backend/services/hospitality/app/api/v1/connectors.py @@ -0,0 +1,92 @@ +"""Connector APIs — Phase 12.7.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.core.security import get_current_user +from app.schemas.connectors import ( + ConnectorDispatchCreate, + ConnectorDispatchRead, + ConnectorRegistrationCreate, + ConnectorRegistrationRead, +) +from app.services.connectors import ConnectorDispatchService, ConnectorRegistrationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +connector_registrations_router = APIRouter() +connector_dispatches_router = APIRouter() + + +@connector_registrations_router.post( + "", response_model=ConnectorRegistrationRead, status_code=status.HTTP_201_CREATED +) +async def create_connector_registration( + body: ConnectorRegistrationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await ConnectorRegistrationService(db).create(tenant_id, body, actor=user) + + +@connector_registrations_router.get("", response_model=list[ConnectorRegistrationRead]) +async def list_connector_registrations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ConnectorRegistrationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@connector_registrations_router.get( + "/{registration_id}", response_model=ConnectorRegistrationRead +) +async def get_connector_registration( + registration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ConnectorRegistrationService(db).get(tenant_id, registration_id) + + +@connector_dispatches_router.post( + "", response_model=ConnectorDispatchRead, status_code=status.HTTP_201_CREATED +) +async def create_connector_dispatch( + body: ConnectorDispatchCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await ConnectorDispatchService(db).create(tenant_id, body, actor=user) + + +@connector_dispatches_router.get("", response_model=list[ConnectorDispatchRead]) +async def list_connector_dispatches( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ConnectorDispatchService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@connector_dispatches_router.get("/{dispatch_id}", response_model=ConnectorDispatchRead) +async def get_connector_dispatch( + dispatch_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ConnectorDispatchService(db).get(tenant_id, dispatch_id) diff --git a/backend/services/hospitality/app/api/v1/dining_areas.py b/backend/services/hospitality/app/api/v1/dining_areas.py new file mode 100644 index 0000000..2f9f800 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/dining_areas.py @@ -0,0 +1,49 @@ +"""Hospitality dining-areas APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + DiningAreaCreate, + DiningAreaRead, +) +from app.services.foundation import DiningAreaService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DiningAreaRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: DiningAreaCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DiningAreaService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DiningAreaRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DiningAreaService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{area_id}", response_model=DiningAreaRead) +async def get_item( + area_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DiningAreaService(db).get(tenant_id, area_id) diff --git a/backend/services/hospitality/app/api/v1/events.py b/backend/services/hospitality/app/api/v1/events.py new file mode 100644 index 0000000..6df583f --- /dev/null +++ b/backend/services/hospitality/app/api/v1/events.py @@ -0,0 +1,49 @@ +"""Hospitality events APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + HospitalityEventCreate, + HospitalityEventRead, +) +from app.services.foundation import HospitalityEventService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=HospitalityEventRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: HospitalityEventCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await HospitalityEventService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[HospitalityEventRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalityEventService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{event_id}", response_model=HospitalityEventRead) +async def get_item( + event_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalityEventService(db).get(tenant_id, event_id) diff --git a/backend/services/hospitality/app/api/v1/feature_toggles.py b/backend/services/hospitality/app/api/v1/feature_toggles.py new file mode 100644 index 0000000..a8022af --- /dev/null +++ b/backend/services/hospitality/app/api/v1/feature_toggles.py @@ -0,0 +1,46 @@ +"""Hospitality feature toggles APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import FeatureToggleRead, FeatureToggleUpsert +from app.services.foundation import FeatureToggleService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("/upsert", response_model=FeatureToggleRead, status_code=status.HTTP_200_OK) +async def upsert_toggle( + body: FeatureToggleUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await FeatureToggleService(db).upsert(tenant_id, body, actor=user) + + +@router.get("", response_model=list[FeatureToggleRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await FeatureToggleService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{toggle_id}", response_model=FeatureToggleRead) +async def get_item( + toggle_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await FeatureToggleService(db).get(tenant_id, toggle_id) diff --git a/backend/services/hospitality/app/api/v1/health.py b/backend/services/hospitality/app/api/v1/health.py new file mode 100644 index 0000000..81f2220 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/health.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter + +from app import __version__ +from app.core.config import settings +from app.models.types import BundleKey, VenueFormat + +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": "12.8", + "product": "Torbat Food", + "platform": "Hospitality Platform", + "features": { + "foundation": True, + "feature_based_architecture": True, + "bundle_based_licensing": True, + "capability_discovery": True, + "feature_toggle": True, + "digital_menu_shell": True, + "digital_menu_catalog": True, + "table_service_shell": True, + "qr_menu": True, + "qr_ordering": True, + "table_service": True, + "reservation": True, + "pos_lite": True, + "pos_pro": True, + "kitchen": True, + "pos_engine": False, + "kitchen_engine": True, + "ordering_engine": False, + "reservation_engine": False, + "delivery_integration": True, + "accounting_integration": True, + "crm_integration": True, + "loyalty_integration": True, + "communication_integration": True, + "website_integration": True, + "analytics": True, + "ai": False, + }, + "venue_formats": [item.value for item in VenueFormat], + "bundles": [item.value for item in BundleKey], + "independence": { + "standalone": True, + "superapp": True, + "integrations": [ + "accounting", + "crm", + "loyalty", + "communication", + "delivery", + "website_builder", + "ai", + ], + "integration_mode": "api_and_events_only", + }, + } diff --git a/backend/services/hospitality/app/api/v1/kitchen.py b/backend/services/hospitality/app/api/v1/kitchen.py new file mode 100644 index 0000000..f47a831 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/kitchen.py @@ -0,0 +1,159 @@ +"""Kitchen APIs — Phase 12.6.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.core.security import get_current_user +from app.schemas.kitchen import ( + KitchenStationCreate, + KitchenStationRead, + KitchenTicketCreate, + KitchenTicketItemCreate, + KitchenTicketItemRead, + KitchenTicketItemStatusUpdate, + KitchenTicketRead, + KitchenTicketStatusUpdate, +) +from app.services.kitchen import ( + KitchenStationService, + KitchenTicketItemService, + KitchenTicketService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +kitchen_stations_router = APIRouter() +kitchen_tickets_router = APIRouter() +kitchen_ticket_items_router = APIRouter() + + +@kitchen_stations_router.post( + "", response_model=KitchenStationRead, status_code=status.HTTP_201_CREATED +) +async def create_kitchen_station( + body: KitchenStationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await KitchenStationService(db).create(tenant_id, body, actor=user) + + +@kitchen_stations_router.get("", response_model=list[KitchenStationRead]) +async def list_kitchen_stations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await KitchenStationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@kitchen_stations_router.get("/{station_id}", response_model=KitchenStationRead) +async def get_kitchen_station( + station_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await KitchenStationService(db).get(tenant_id, station_id) + + +@kitchen_tickets_router.post( + "", response_model=KitchenTicketRead, status_code=status.HTTP_201_CREATED +) +async def create_kitchen_ticket( + body: KitchenTicketCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await KitchenTicketService(db).create(tenant_id, body, actor=user) + + +@kitchen_tickets_router.get("", response_model=list[KitchenTicketRead]) +async def list_kitchen_tickets( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await KitchenTicketService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@kitchen_tickets_router.get("/{ticket_id}", response_model=KitchenTicketRead) +async def get_kitchen_ticket( + ticket_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await KitchenTicketService(db).get(tenant_id, ticket_id) + + +@kitchen_tickets_router.post("/{ticket_id}/status", response_model=KitchenTicketRead) +async def change_kitchen_ticket_status( + ticket_id: UUID, + body: KitchenTicketStatusUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await KitchenTicketService(db).change_status( + tenant_id, ticket_id, body.status, actor=user + ) + + +@kitchen_ticket_items_router.post( + "", response_model=KitchenTicketItemRead, status_code=status.HTTP_201_CREATED +) +async def create_kitchen_ticket_item( + body: KitchenTicketItemCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await KitchenTicketItemService(db).create(tenant_id, body, actor=user) + + +@kitchen_ticket_items_router.get("", response_model=list[KitchenTicketItemRead]) +async def list_kitchen_ticket_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await KitchenTicketItemService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@kitchen_ticket_items_router.get("/{item_id}", response_model=KitchenTicketItemRead) +async def get_kitchen_ticket_item( + item_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await KitchenTicketItemService(db).get(tenant_id, item_id) + + +@kitchen_ticket_items_router.post("/{item_id}/status", response_model=KitchenTicketItemRead) +async def change_kitchen_ticket_item_status( + item_id: UUID, + body: KitchenTicketItemStatusUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await KitchenTicketItemService(db).change_status( + tenant_id, item_id, body.status, actor=user + ) diff --git a/backend/services/hospitality/app/api/v1/menu_categories.py b/backend/services/hospitality/app/api/v1/menu_categories.py new file mode 100644 index 0000000..eb04820 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/menu_categories.py @@ -0,0 +1,49 @@ +"""Hospitality menu-categories APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + MenuCategoryCreate, + MenuCategoryRead, +) +from app.services.foundation import MenuCategoryService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=MenuCategoryRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: MenuCategoryCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MenuCategoryService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[MenuCategoryRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuCategoryService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{category_id}", response_model=MenuCategoryRead) +async def get_item( + category_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuCategoryService(db).get(tenant_id, category_id) diff --git a/backend/services/hospitality/app/api/v1/menu_items.py b/backend/services/hospitality/app/api/v1/menu_items.py new file mode 100644 index 0000000..bd62015 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/menu_items.py @@ -0,0 +1,49 @@ +"""Hospitality menu-items APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + MenuItemCreate, + MenuItemRead, +) +from app.services.foundation import MenuItemService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=MenuItemRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: MenuItemCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MenuItemService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[MenuItemRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuItemService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{item_id}", response_model=MenuItemRead) +async def get_item( + item_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuItemService(db).get(tenant_id, item_id) diff --git a/backend/services/hospitality/app/api/v1/menus.py b/backend/services/hospitality/app/api/v1/menus.py new file mode 100644 index 0000000..829f739 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/menus.py @@ -0,0 +1,72 @@ +"""Hospitality menus APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + MenuCreate, + MenuRead, + MenuUpdate, +) +from app.services.foundation import MenuService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=MenuRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: MenuCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MenuService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[MenuRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{menu_id}", response_model=MenuRead) +async def get_item( + menu_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MenuService(db).get(tenant_id, menu_id) + + +@router.patch("/{menu_id}", response_model=MenuRead) +async def update_item( + menu_id: UUID, + body: MenuUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MenuService(db).update(tenant_id, menu_id, body, actor=user) + + +@router.post("/{menu_id}/delete", response_model=MenuRead) +async def soft_delete_item( + menu_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MenuService(db).soft_delete(tenant_id, menu_id, actor=user) + diff --git a/backend/services/hospitality/app/api/v1/permissions.py b/backend/services/hospitality/app/api/v1/permissions.py new file mode 100644 index 0000000..55bf9e1 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/permissions.py @@ -0,0 +1,49 @@ +"""Hospitality permissions APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + HospitalityPermissionCreate, + HospitalityPermissionRead, +) +from app.services.foundation import HospitalityPermissionService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=HospitalityPermissionRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: HospitalityPermissionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await HospitalityPermissionService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[HospitalityPermissionRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalityPermissionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{permission_id}", response_model=HospitalityPermissionRead) +async def get_item( + permission_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalityPermissionService(db).get(tenant_id, permission_id) diff --git a/backend/services/hospitality/app/api/v1/pos_lite.py b/backend/services/hospitality/app/api/v1/pos_lite.py new file mode 100644 index 0000000..25b1df7 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/pos_lite.py @@ -0,0 +1,183 @@ +"""POS Lite APIs — Phase 12.4.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.core.security import get_current_user +from app.schemas.pos_lite import ( + PosRegisterCreate, + PosRegisterRead, + PosShiftCreate, + PosShiftRead, + PosTicketCreate, + PosTicketLineCreate, + PosTicketLineRead, + PosTicketRead, +) +from app.services.pos_lite import ( + PosRegisterService, + PosShiftService, + PosTicketLineService, + PosTicketService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +pos_registers_router = APIRouter() +pos_shifts_router = APIRouter() +pos_tickets_router = APIRouter() +pos_ticket_lines_router = APIRouter() + + +@pos_registers_router.post("", response_model=PosRegisterRead, status_code=status.HTTP_201_CREATED) +async def create_pos_register( + body: PosRegisterCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosRegisterService(db).create(tenant_id, body, actor=user) + + +@pos_registers_router.get("", response_model=list[PosRegisterRead]) +async def list_pos_registers( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosRegisterService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@pos_registers_router.get("/{register_id}", response_model=PosRegisterRead) +async def get_pos_register( + register_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosRegisterService(db).get(tenant_id, register_id) + + +@pos_shifts_router.post("", response_model=PosShiftRead, status_code=status.HTTP_201_CREATED) +async def open_pos_shift( + body: PosShiftCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosShiftService(db).create(tenant_id, body, actor=user) + + +@pos_shifts_router.get("", response_model=list[PosShiftRead]) +async def list_pos_shifts( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosShiftService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@pos_shifts_router.get("/{shift_id}", response_model=PosShiftRead) +async def get_pos_shift( + shift_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosShiftService(db).get(tenant_id, shift_id) + + +@pos_shifts_router.post("/{shift_id}/close", response_model=PosShiftRead) +async def close_pos_shift( + shift_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosShiftService(db).close(tenant_id, shift_id, actor=user) + + +@pos_tickets_router.post("", response_model=PosTicketRead, status_code=status.HTTP_201_CREATED) +async def create_pos_ticket( + body: PosTicketCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosTicketService(db).create(tenant_id, body, actor=user) + + +@pos_tickets_router.get("", response_model=list[PosTicketRead]) +async def list_pos_tickets( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosTicketService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@pos_tickets_router.get("/{ticket_id}", response_model=PosTicketRead) +async def get_pos_ticket( + ticket_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosTicketService(db).get(tenant_id, ticket_id) + + +@pos_tickets_router.post("/{ticket_id}/void", response_model=PosTicketRead) +async def void_pos_ticket( + ticket_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosTicketService(db).void(tenant_id, ticket_id, actor=user) + + +@pos_ticket_lines_router.post( + "", response_model=PosTicketLineRead, status_code=status.HTTP_201_CREATED +) +async def add_pos_ticket_line( + body: PosTicketLineCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosTicketLineService(db).add(tenant_id, body, actor=user) + + +@pos_ticket_lines_router.get("", response_model=list[PosTicketLineRead]) +async def list_pos_ticket_lines( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosTicketLineService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@pos_ticket_lines_router.get("/{line_id}", response_model=PosTicketLineRead) +async def get_pos_ticket_line( + line_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosTicketLineService(db).get(tenant_id, line_id) diff --git a/backend/services/hospitality/app/api/v1/pos_pro.py b/backend/services/hospitality/app/api/v1/pos_pro.py new file mode 100644 index 0000000..d6a3af7 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/pos_pro.py @@ -0,0 +1,199 @@ +"""POS Pro APIs — Phase 12.5.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.core.security import get_current_user +from app.schemas.pos_pro import ( + PosDiscountCreate, + PosDiscountRead, + PosFloorPlanCreate, + PosFloorPlanRead, + PosPaymentCreate, + PosPaymentRead, + PosStationCreate, + PosStationRead, + PosTaxLineCreate, + PosTaxLineRead, +) +from app.services.pos_pro import ( + PosDiscountService, + PosFloorPlanService, + PosPaymentService, + PosStationService, + PosTaxLineService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +pos_payments_router = APIRouter() +pos_discounts_router = APIRouter() +pos_tax_lines_router = APIRouter() +pos_floor_plans_router = APIRouter() +pos_stations_router = APIRouter() + + +@pos_payments_router.post("", response_model=PosPaymentRead, status_code=status.HTTP_201_CREATED) +async def create_pos_payment( + body: PosPaymentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosPaymentService(db).create(tenant_id, body, actor=user) + + +@pos_payments_router.get("", response_model=list[PosPaymentRead]) +async def list_pos_payments( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosPaymentService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@pos_payments_router.get("/{payment_id}", response_model=PosPaymentRead) +async def get_pos_payment( + payment_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosPaymentService(db).get(tenant_id, payment_id) + + +@pos_discounts_router.post("", response_model=PosDiscountRead, status_code=status.HTTP_201_CREATED) +async def create_pos_discount( + body: PosDiscountCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosDiscountService(db).create(tenant_id, body, actor=user) + + +@pos_discounts_router.get("", response_model=list[PosDiscountRead]) +async def list_pos_discounts( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosDiscountService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@pos_discounts_router.get("/{discount_id}", response_model=PosDiscountRead) +async def get_pos_discount( + discount_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosDiscountService(db).get(tenant_id, discount_id) + + +@pos_tax_lines_router.post("", response_model=PosTaxLineRead, status_code=status.HTTP_201_CREATED) +async def create_pos_tax_line( + body: PosTaxLineCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosTaxLineService(db).create(tenant_id, body, actor=user) + + +@pos_tax_lines_router.get("", response_model=list[PosTaxLineRead]) +async def list_pos_tax_lines( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosTaxLineService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@pos_tax_lines_router.get("/{tax_line_id}", response_model=PosTaxLineRead) +async def get_pos_tax_line( + tax_line_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosTaxLineService(db).get(tenant_id, tax_line_id) + + +@pos_floor_plans_router.post( + "", response_model=PosFloorPlanRead, status_code=status.HTTP_201_CREATED +) +async def create_pos_floor_plan( + body: PosFloorPlanCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosFloorPlanService(db).create(tenant_id, body, actor=user) + + +@pos_floor_plans_router.get("", response_model=list[PosFloorPlanRead]) +async def list_pos_floor_plans( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosFloorPlanService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@pos_floor_plans_router.get("/{floor_plan_id}", response_model=PosFloorPlanRead) +async def get_pos_floor_plan( + floor_plan_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosFloorPlanService(db).get(tenant_id, floor_plan_id) + + +@pos_stations_router.post("", response_model=PosStationRead, status_code=status.HTTP_201_CREATED) +async def create_pos_station( + body: PosStationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await PosStationService(db).create(tenant_id, body, actor=user) + + +@pos_stations_router.get("", response_model=list[PosStationRead]) +async def list_pos_stations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosStationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@pos_stations_router.get("/{station_id}", response_model=PosStationRead) +async def get_pos_station( + station_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await PosStationService(db).get(tenant_id, station_id) diff --git a/backend/services/hospitality/app/api/v1/qr.py b/backend/services/hospitality/app/api/v1/qr.py new file mode 100644 index 0000000..156ff03 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/qr.py @@ -0,0 +1,207 @@ +"""QR Menu & QR Ordering APIs — Phase 12.2.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.core.security import get_current_user +from app.schemas.qr import ( + CartCreate, + CartLineCreate, + CartLineRead, + CartRead, + QrCodeCreate, + QrCodeRead, + QrMenuSessionCreate, + QrMenuSessionRead, + QrOrderingSessionCreate, + QrOrderingSessionRead, +) +from app.services.qr import ( + CartLineService, + CartService, + QrCodeService, + QrMenuSessionService, + QrOrderingSessionService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +qr_codes_router = APIRouter() +qr_menu_sessions_router = APIRouter() +qr_ordering_sessions_router = APIRouter() +carts_router = APIRouter() +cart_lines_router = APIRouter() + + +@qr_codes_router.post("", response_model=QrCodeRead, status_code=status.HTTP_201_CREATED) +async def create_qr_code( + body: QrCodeCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await QrCodeService(db).create(tenant_id, body, actor=user) + + +@qr_codes_router.get("", response_model=list[QrCodeRead]) +async def list_qr_codes( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await QrCodeService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@qr_codes_router.get("/by-token/{token}", response_model=QrCodeRead) +async def get_qr_code_by_token( + token: str, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await QrCodeService(db).get_by_token(tenant_id, token) + + +@qr_codes_router.get("/{qr_code_id}", response_model=QrCodeRead) +async def get_qr_code( + qr_code_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await QrCodeService(db).get(tenant_id, qr_code_id) + + +@qr_menu_sessions_router.post("", response_model=QrMenuSessionRead, status_code=status.HTTP_201_CREATED) +async def create_qr_menu_session( + body: QrMenuSessionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await QrMenuSessionService(db).create(tenant_id, body, actor=user) + + +@qr_menu_sessions_router.get("", response_model=list[QrMenuSessionRead]) +async def list_qr_menu_sessions( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await QrMenuSessionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@qr_menu_sessions_router.get("/by-token/{token}", response_model=QrMenuSessionRead) +async def get_qr_menu_session_by_token( + token: str, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await QrMenuSessionService(db).get_by_token(tenant_id, token) + + +@qr_menu_sessions_router.get("/{session_id}", response_model=QrMenuSessionRead) +async def get_qr_menu_session( + session_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await QrMenuSessionService(db).get(tenant_id, session_id) + + +@qr_ordering_sessions_router.post("", response_model=QrOrderingSessionRead, status_code=status.HTTP_201_CREATED) +async def create_qr_ordering_session( + body: QrOrderingSessionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await QrOrderingSessionService(db).create(tenant_id, body, actor=user) + + +@qr_ordering_sessions_router.get("", response_model=list[QrOrderingSessionRead]) +async def list_qr_ordering_sessions( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await QrOrderingSessionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@qr_ordering_sessions_router.get("/{session_id}", response_model=QrOrderingSessionRead) +async def get_qr_ordering_session( + session_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await QrOrderingSessionService(db).get(tenant_id, session_id) + + +@qr_ordering_sessions_router.post("/{session_id}/submit", response_model=QrOrderingSessionRead) +async def submit_qr_ordering_session( + session_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await QrOrderingSessionService(db).submit(tenant_id, session_id, actor=user) + + +@carts_router.post("", response_model=CartRead, status_code=status.HTTP_201_CREATED) +async def create_cart( + body: CartCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await CartService(db).create(tenant_id, body, actor=user) + + +@carts_router.get("", response_model=list[CartRead]) +async def list_carts( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await CartService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@carts_router.get("/{cart_id}", response_model=CartRead) +async def get_cart( + cart_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await CartService(db).get(tenant_id, cart_id) + + +@cart_lines_router.post("", response_model=CartLineRead, status_code=status.HTTP_201_CREATED) +async def create_cart_line( + body: CartLineCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await CartLineService(db).add(tenant_id, body, actor=user) + + +@cart_lines_router.get("", response_model=list[CartLineRead]) +async def list_cart_lines( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await CartLineService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) diff --git a/backend/services/hospitality/app/api/v1/roles.py b/backend/services/hospitality/app/api/v1/roles.py new file mode 100644 index 0000000..f484ec8 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/roles.py @@ -0,0 +1,49 @@ +"""Hospitality roles APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + HospitalityRoleCreate, + HospitalityRoleRead, +) +from app.services.foundation import HospitalityRoleService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=HospitalityRoleRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: HospitalityRoleCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await HospitalityRoleService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[HospitalityRoleRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalityRoleService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{role_id}", response_model=HospitalityRoleRead) +async def get_item( + role_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalityRoleService(db).get(tenant_id, role_id) diff --git a/backend/services/hospitality/app/api/v1/settings.py b/backend/services/hospitality/app/api/v1/settings.py new file mode 100644 index 0000000..a1674fc --- /dev/null +++ b/backend/services/hospitality/app/api/v1/settings.py @@ -0,0 +1,46 @@ +"""Hospitality settings APIs — Phase 12.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.core.security import get_current_user +from app.schemas.foundation import HospitalitySettingRead, HospitalitySettingUpsert +from app.services.foundation import HospitalitySettingService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("/upsert", response_model=HospitalitySettingRead) +async def upsert_setting( + body: HospitalitySettingUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await HospitalitySettingService(db).upsert(tenant_id, body, actor=user) + + +@router.get("", response_model=list[HospitalitySettingRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalitySettingService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{setting_id}", response_model=HospitalitySettingRead) +async def get_item( + setting_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await HospitalitySettingService(db).get(tenant_id, setting_id) diff --git a/backend/services/hospitality/app/api/v1/table_service.py b/backend/services/hospitality/app/api/v1/table_service.py new file mode 100644 index 0000000..58329a7 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/table_service.py @@ -0,0 +1,215 @@ +"""Table Service & Reservations APIs — Phase 12.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, get_pagination, require_tenant +from app.core.security import get_current_user +from app.schemas.table_service import ( + ReservationCreate, + ReservationRead, + ReservationStatusUpdate, + ServiceRequestCreate, + ServiceRequestRead, + ServiceRequestStatusUpdate, + TableAssignmentCreate, + TableAssignmentRead, + WaitlistEntryCreate, + WaitlistEntryRead, + WaitlistEntryStatusUpdate, +) +from app.services.table_service import ( + ReservationService, + ServiceRequestService, + TableAssignmentService, + WaitlistEntryService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +reservations_router = APIRouter() +table_assignments_router = APIRouter() +service_requests_router = APIRouter() +waitlist_router = APIRouter() + + +@reservations_router.post("", response_model=ReservationRead, status_code=status.HTTP_201_CREATED) +async def create_reservation( + body: ReservationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await ReservationService(db).create(tenant_id, body, actor=user) + + +@reservations_router.get("", response_model=list[ReservationRead]) +async def list_reservations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ReservationService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@reservations_router.get("/{reservation_id}", response_model=ReservationRead) +async def get_reservation( + reservation_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ReservationService(db).get(tenant_id, reservation_id) + + +@reservations_router.post("/{reservation_id}/status", response_model=ReservationRead) +async def change_reservation_status( + reservation_id: UUID, + body: ReservationStatusUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await ReservationService(db).change_status( + tenant_id, reservation_id, body.status, actor=user + ) + + +@table_assignments_router.post( + "", response_model=TableAssignmentRead, status_code=status.HTTP_201_CREATED +) +async def create_table_assignment( + body: TableAssignmentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await TableAssignmentService(db).create(tenant_id, body, actor=user) + + +@table_assignments_router.get("", response_model=list[TableAssignmentRead]) +async def list_table_assignments( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await TableAssignmentService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@table_assignments_router.get("/{assignment_id}", response_model=TableAssignmentRead) +async def get_table_assignment( + assignment_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await TableAssignmentService(db).get(tenant_id, assignment_id) + + +@table_assignments_router.post("/{assignment_id}/release", response_model=TableAssignmentRead) +async def release_table_assignment( + assignment_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await TableAssignmentService(db).release(tenant_id, assignment_id, actor=user) + + +@service_requests_router.post( + "", response_model=ServiceRequestRead, status_code=status.HTTP_201_CREATED +) +async def create_service_request( + body: ServiceRequestCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await ServiceRequestService(db).create(tenant_id, body, actor=user) + + +@service_requests_router.get("", response_model=list[ServiceRequestRead]) +async def list_service_requests( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ServiceRequestService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@service_requests_router.get("/{request_id}", response_model=ServiceRequestRead) +async def get_service_request( + request_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await ServiceRequestService(db).get(tenant_id, request_id) + + +@service_requests_router.post("/{request_id}/status", response_model=ServiceRequestRead) +async def change_service_request_status( + request_id: UUID, + body: ServiceRequestStatusUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await ServiceRequestService(db).change_status( + tenant_id, request_id, body.status, actor=user + ) + + +@waitlist_router.post("", response_model=WaitlistEntryRead, status_code=status.HTTP_201_CREATED) +async def create_waitlist_entry( + body: WaitlistEntryCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await WaitlistEntryService(db).create(tenant_id, body, actor=user) + + +@waitlist_router.get("", response_model=list[WaitlistEntryRead]) +async def list_waitlist_entries( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await WaitlistEntryService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@waitlist_router.get("/{entry_id}", response_model=WaitlistEntryRead) +async def get_waitlist_entry( + entry_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await WaitlistEntryService(db).get(tenant_id, entry_id) + + +@waitlist_router.post("/{entry_id}/status", response_model=WaitlistEntryRead) +async def change_waitlist_entry_status( + entry_id: UUID, + body: WaitlistEntryStatusUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await WaitlistEntryService(db).change_status( + tenant_id, entry_id, body.status, actor=user + ) diff --git a/backend/services/hospitality/app/api/v1/tables.py b/backend/services/hospitality/app/api/v1/tables.py new file mode 100644 index 0000000..b76e777 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/tables.py @@ -0,0 +1,49 @@ +"""Hospitality tables APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + DiningTableCreate, + DiningTableRead, +) +from app.services.foundation import DiningTableService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DiningTableRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: DiningTableCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DiningTableService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DiningTableRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DiningTableService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{table_id}", response_model=DiningTableRead) +async def get_item( + table_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DiningTableService(db).get(tenant_id, table_id) diff --git a/backend/services/hospitality/app/api/v1/tenant_bundles.py b/backend/services/hospitality/app/api/v1/tenant_bundles.py new file mode 100644 index 0000000..f72d129 --- /dev/null +++ b/backend/services/hospitality/app/api/v1/tenant_bundles.py @@ -0,0 +1,56 @@ +"""Hospitality tenant bundles APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import TenantBundleActivate, TenantBundleRead +from app.services.foundation import TenantBundleService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("/activate", response_model=TenantBundleRead, status_code=status.HTTP_201_CREATED) +async def activate_bundle( + body: TenantBundleActivate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await TenantBundleService(db).activate(tenant_id, body, actor=user) + + +@router.post("/{bundle_id}/deactivate", response_model=TenantBundleRead) +async def deactivate_bundle( + bundle_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await TenantBundleService(db).deactivate(tenant_id, bundle_id, actor=user) + + +@router.get("", response_model=list[TenantBundleRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await TenantBundleService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{bundle_id}", response_model=TenantBundleRead) +async def get_item( + bundle_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await TenantBundleService(db).get(tenant_id, bundle_id) diff --git a/backend/services/hospitality/app/api/v1/venues.py b/backend/services/hospitality/app/api/v1/venues.py new file mode 100644 index 0000000..4f5bc2d --- /dev/null +++ b/backend/services/hospitality/app/api/v1/venues.py @@ -0,0 +1,72 @@ +"""Hospitality venues APIs — Phase 12.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.core.security import get_current_user +from app.schemas.foundation import ( + VenueCreate, + VenueRead, + VenueUpdate, +) +from app.services.foundation import VenueService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=VenueRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: VenueCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await VenueService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[VenueRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await VenueService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{venue_id}", response_model=VenueRead) +async def get_item( + venue_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await VenueService(db).get(tenant_id, venue_id) + + +@router.patch("/{venue_id}", response_model=VenueRead) +async def update_item( + venue_id: UUID, + body: VenueUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await VenueService(db).update(tenant_id, venue_id, body, actor=user) + + +@router.post("/{venue_id}/delete", response_model=VenueRead) +async def soft_delete_item( + venue_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await VenueService(db).soft_delete(tenant_id, venue_id, actor=user) + diff --git a/backend/services/hospitality/app/commands/__init__.py b/backend/services/hospitality/app/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/hospitality/app/events/publisher.py b/backend/services/hospitality/app/events/publisher.py new file mode 100644 index 0000000..3b4fbde --- /dev/null +++ b/backend/services/hospitality/app/events/publisher.py @@ -0,0 +1,63 @@ +"""Hospitality event publisher — builds EventEnvelope contracts; no bus consumers yet.""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID, uuid4 + +from shared.events import EventEnvelope + +from app.core.config import settings +from app.events.types import HospitalityEventType + + +class EventPublisher(Protocol): + def publish( + self, + *, + event_type: HospitalityEventType, + 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: HospitalityEventType, + 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 + + +_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 diff --git a/backend/services/hospitality/app/events/types.py b/backend/services/hospitality/app/events/types.py new file mode 100644 index 0000000..d8b5b6f --- /dev/null +++ b/backend/services/hospitality/app/events/types.py @@ -0,0 +1,82 @@ +"""Hospitality event type contracts (publish-only) — Phase 12.0.""" +from __future__ import annotations + +import enum + + +class HospitalityEventType(str, enum.Enum): + VENUE_CREATED = "hospitality.venue.created" + VENUE_UPDATED = "hospitality.venue.updated" + BRANCH_CREATED = "hospitality.branch.created" + BRANCH_UPDATED = "hospitality.branch.updated" + DINING_AREA_CREATED = "hospitality.dining_area.created" + TABLE_CREATED = "hospitality.table.created" + MENU_CREATED = "hospitality.menu.created" + MENU_UPDATED = "hospitality.menu.updated" + MENU_CATEGORY_CREATED = "hospitality.menu_category.created" + MENU_ITEM_CREATED = "hospitality.menu_item.created" + BUNDLE_DEFINITION_CREATED = "hospitality.bundle_definition.created" + TENANT_BUNDLE_ACTIVATED = "hospitality.tenant_bundle.activated" + TENANT_BUNDLE_DEACTIVATED = "hospitality.tenant_bundle.deactivated" + FEATURE_TOGGLE_UPSERTED = "hospitality.feature_toggle.upserted" + ROLE_CREATED = "hospitality.role.created" + PERMISSION_CREATED = "hospitality.permission.created" + CONFIGURATION_CREATED = "hospitality.configuration.created" + CONFIGURATION_UPDATED = "hospitality.configuration.updated" + HOSPITALITY_EVENT_CREATED = "hospitality.hospitality_event.created" + SETTING_UPSERTED = "hospitality.setting.upserted" + + # Phase 12.1 — Digital Menu Catalog + ALLERGEN_CREATED = "hospitality.allergen.created" + MODIFIER_GROUP_CREATED = "hospitality.modifier_group.created" + MODIFIER_OPTION_CREATED = "hospitality.modifier_option.created" + MENU_ITEM_MODIFIER_LINKED = "hospitality.menu_item_modifier.linked" + MENU_ITEM_ALLERGEN_LINKED = "hospitality.menu_item_allergen.linked" + AVAILABILITY_WINDOW_CREATED = "hospitality.availability_window.created" + MENU_MEDIA_REF_CREATED = "hospitality.menu_media_ref.created" + MENU_LOCALIZATION_UPSERTED = "hospitality.menu_localization.upserted" + + # Phase 12.2 — QR Menu & QR Ordering shells + QR_CODE_CREATED = "hospitality.qr_code.created" + QR_MENU_SESSION_CREATED = "hospitality.qr_menu_session.created" + QR_ORDERING_SESSION_CREATED = "hospitality.qr_ordering_session.created" + QR_ORDERING_SESSION_SUBMITTED = "hospitality.qr_ordering_session.submitted" + CART_CREATED = "hospitality.cart.created" + CART_LINE_ADDED = "hospitality.cart_line.added" + + # Phase 12.3 — Table Service & Reservations + RESERVATION_CREATED = "hospitality.reservation.created" + RESERVATION_STATUS_CHANGED = "hospitality.reservation.status_changed" + TABLE_ASSIGNMENT_CREATED = "hospitality.table_assignment.created" + SERVICE_REQUEST_CREATED = "hospitality.service_request.created" + WAITLIST_ENTRY_CREATED = "hospitality.waitlist_entry.created" + + # Phase 12.4 — POS Lite + POS_REGISTER_CREATED = "hospitality.pos_register.created" + POS_SHIFT_OPENED = "hospitality.pos_shift.opened" + POS_SHIFT_CLOSED = "hospitality.pos_shift.closed" + POS_TICKET_CREATED = "hospitality.pos_ticket.created" + POS_TICKET_VOIDED = "hospitality.pos_ticket.voided" + POS_TICKET_LINE_ADDED = "hospitality.pos_ticket_line.added" + + # Phase 12.5 — POS Pro + POS_PAYMENT_RECORDED = "hospitality.pos_payment.recorded" + POS_DISCOUNT_APPLIED = "hospitality.pos_discount.applied" + POS_TAX_LINE_ADDED = "hospitality.pos_tax_line.added" + POS_FLOOR_PLAN_CREATED = "hospitality.pos_floor_plan.created" + POS_STATION_CREATED = "hospitality.pos_station.created" + + # Phase 12.6 — Kitchen + KITCHEN_STATION_CREATED = "hospitality.kitchen_station.created" + KITCHEN_TICKET_CREATED = "hospitality.kitchen_ticket.created" + KITCHEN_TICKET_STATUS_CHANGED = "hospitality.kitchen_ticket.status_changed" + KITCHEN_TICKET_ITEM_ADDED = "hospitality.kitchen_ticket_item.added" + + # Phase 12.7 — Connectors + CONNECTOR_REGISTRATION_CREATED = "hospitality.connector_registration.created" + CONNECTOR_DISPATCH_CREATED = "hospitality.connector_dispatch.created" + CONNECTOR_DISPATCH_STATUS_CHANGED = "hospitality.connector_dispatch.status_changed" + + # Phase 12.8 — Analytics + ANALYTICS_REPORT_DEFINITION_CREATED = "hospitality.analytics_report_definition.created" + ANALYTICS_SNAPSHOT_REFRESHED = "hospitality.analytics_snapshot.refreshed" diff --git a/backend/services/hospitality/app/main.py b/backend/services/hospitality/app/main.py new file mode 100644 index 0000000..9426949 --- /dev/null +++ b/backend/services/hospitality/app/main.py @@ -0,0 +1,69 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app import __version__ +from app.api.v1 import api_router +from app.api.v1 import health +from app.core.config import settings +from app.core.logging import configure_logging, get_logger +from app.middlewares.tenant import TenantHeaderMiddleware +from shared.exceptions import AppError +from shared.responses import ErrorDetail, ErrorResponse + +configure_logging(settings.log_level) +logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("service_starting", extra={"service": settings.service_name}) + yield + + +def create_app() -> FastAPI: + app = FastAPI( + title="Hospitality Platform Service", + version=__version__, + description="سرویس Hospitality Platform (Torbat Food) — فاز ۱۲.۸ Analytics", + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir", + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + app.add_middleware(TenantHeaderMiddleware) + + @app.exception_handler(AppError) + async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=ErrorResponse( + error=ErrorDetail( + code=exc.error_code, message=exc.message, details=exc.details + ) + ).model_dump(), + ) + + @app.exception_handler(Exception) + async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse: + logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc) + return JSONResponse( + status_code=500, + content=ErrorResponse( + error=ErrorDetail(code="internal_error", message="خطای داخلی سرور") + ).model_dump(), + ) + + app.include_router(health.router) + app.include_router(api_router, prefix=settings.api_v1_prefix) + return app + + +app = create_app() diff --git a/backend/services/hospitality/app/models/__init__.py b/backend/services/hospitality/app/models/__init__.py index 435240c..ebc9d80 100644 --- a/backend/services/hospitality/app/models/__init__.py +++ b/backend/services/hospitality/app/models/__init__.py @@ -1,4 +1,14 @@ """Import all models for Alembic metadata discovery.""" +from app.models.catalog import ( # noqa: F401 + Allergen, + AvailabilityWindow, + MenuItemAllergen, + MenuItemModifier, + MenuLocalization, + MenuMediaRef, + ModifierGroup, + ModifierOption, +) from app.models.foundation import ( # noqa: F401 Branch, BundleDefinition, @@ -17,3 +27,42 @@ from app.models.foundation import ( # noqa: F401 TenantBundle, Venue, ) +from app.models.qr import ( # noqa: F401 + Cart, + CartLine, + QrCode, + QrMenuSession, + QrOrderingSession, +) +from app.models.table_service import ( # noqa: F401 + Reservation, + ServiceRequest, + TableAssignment, + WaitlistEntry, +) +from app.models.pos_lite import ( # noqa: F401 + PosRegister, + PosShift, + PosTicket, + PosTicketLine, +) +from app.models.pos_pro import ( # noqa: F401 + PosDiscount, + PosFloorPlan, + PosPayment, + PosStation, + PosTaxLine, +) +from app.models.kitchen import ( # noqa: F401 + KitchenStation, + KitchenTicket, + KitchenTicketItem, +) +from app.models.connectors import ( # noqa: F401 + ConnectorDispatch, + ConnectorRegistration, +) +from app.models.analytics import ( # noqa: F401 + AnalyticsReportDefinition, + AnalyticsSnapshot, +) diff --git a/backend/services/hospitality/app/models/analytics.py b/backend/services/hospitality/app/models/analytics.py new file mode 100644 index 0000000..80948a0 --- /dev/null +++ b/backend/services/hospitality/app/models/analytics.py @@ -0,0 +1,90 @@ +"""Analytics aggregates — Phase 12.8. + +Report definitions describe which local metric keys to compute; snapshots +store a point-in-time metrics payload produced by simple local SQL counts +(no external analytics engine, no cross-service imports). +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, LifecycleStatus + + +class AnalyticsReportDefinition( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A tenant-scoped definition of which local metrics a report tracks.""" + + __tablename__ = "analytics_report_definitions" + + venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + metric_keys: Mapped[list] = mapped_column(JSON, nullable=False) + filters: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + Index( + "ix_analytics_report_definitions_tenant_code", + "tenant_id", + "code", + unique=True, + ), + Index("ix_analytics_report_definitions_venue", "tenant_id", "venue_id"), + Index( + "ix_analytics_report_definitions_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class AnalyticsSnapshot( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A point-in-time computed metrics payload for a report definition.""" + + __tablename__ = "analytics_snapshots" + + venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + report_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + period_start: Mapped[str] = mapped_column(String(32), nullable=False) + period_end: Mapped[str] = mapped_column(String(32), nullable=False) + metrics: Mapped[dict] = mapped_column(JSON, nullable=False) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + Index("ix_analytics_snapshots_venue", "tenant_id", "venue_id"), + Index("ix_analytics_snapshots_report", "tenant_id", "report_id"), + Index( + "ix_analytics_snapshots_tenant_deleted", "tenant_id", "is_deleted" + ), + ) diff --git a/backend/services/hospitality/app/models/catalog.py b/backend/services/hospitality/app/models/catalog.py new file mode 100644 index 0000000..be93372 --- /dev/null +++ b/backend/services/hospitality/app/models/catalog.py @@ -0,0 +1,308 @@ +"""Digital Menu Catalog aggregates — Phase 12.1. + +Catalog depth for digital menus: allergens, modifiers, availability windows, +media refs (Storage refs only), and multi-language shells. +POS / kitchen / ordering engines are out of scope. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import ( + Boolean, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + AvailabilityScope, + GUID, + LifecycleStatus, + LocalizationTarget, + MediaKind, + ModifierSelectionMode, +) + + +class Allergen( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Reusable allergen catalog entry for a venue.""" + + __tablename__ = "allergens" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + icon_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_allergens_tenant_code" + ), + Index("ix_allergens_venue", "tenant_id", "venue_id"), + Index("ix_allergens_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ModifierGroup( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Modifier group (size, extras, toppings) — selection rules only, no POS engine.""" + + __tablename__ = "modifier_groups" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + selection_mode: Mapped[ModifierSelectionMode] = mapped_column( + default=ModifierSelectionMode.SINGLE, nullable=False + ) + min_select: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + max_select: Mapped[int | None] = mapped_column(Integer, nullable=True) + is_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_modifier_groups_tenant_code" + ), + Index("ix_modifier_groups_venue", "tenant_id", "venue_id"), + Index("ix_modifier_groups_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ModifierOption( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Option inside a modifier group with optional price delta (config, not Accounting).""" + + __tablename__ = "modifier_options" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + modifier_group_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + price_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "modifier_group_id", + "code", + name="uq_modifier_options_tenant_code", + ), + Index("ix_modifier_options_group", "tenant_id", "modifier_group_id"), + Index("ix_modifier_options_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MenuItemModifier( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Links a menu item to a modifier group (UUID refs only).""" + + __tablename__ = "menu_item_modifiers" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + menu_item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + modifier_group_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + is_required_override: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "menu_item_id", + "modifier_group_id", + name="uq_menu_item_modifiers_link", + ), + Index("ix_menu_item_modifiers_item", "tenant_id", "menu_item_id"), + Index("ix_menu_item_modifiers_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MenuItemAllergen( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Links a menu item to an allergen.""" + + __tablename__ = "menu_item_allergens" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + menu_item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + allergen_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + is_contains: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_may_contain: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "menu_item_id", + "allergen_id", + name="uq_menu_item_allergens_link", + ), + Index("ix_menu_item_allergens_item", "tenant_id", "menu_item_id"), + Index("ix_menu_item_allergens_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class AvailabilityWindow( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Availability window for menu / category / item (catalog only).""" + + __tablename__ = "availability_windows" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + scope: Mapped[AvailabilityScope] = mapped_column(nullable=False) + target_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + # 0=Monday … 6=Sunday; null means every day + weekday: Mapped[int | None] = mapped_column(Integer, nullable=True) + start_time: Mapped[str] = mapped_column(String(8), nullable=False) # HH:MM:SS + end_time: Mapped[str] = mapped_column(String(8), nullable=False) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "venue_id", + "scope", + "target_id", + "code", + name="uq_availability_windows_tenant_code", + ), + Index("ix_availability_windows_target", "tenant_id", "scope", "target_id"), + Index("ix_availability_windows_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MenuMediaRef( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Media reference shell — stores Storage file refs only (no binaries).""" + + __tablename__ = "menu_media_refs" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + menu_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + menu_item_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + kind: Mapped[MediaKind] = mapped_column(default=MediaKind.IMAGE, nullable=False) + file_ref: Mapped[str] = mapped_column(String(255), nullable=False) + alt_text: Mapped[str | None] = mapped_column(String(255), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_menu_media_refs_menu", "tenant_id", "menu_id"), + Index("ix_menu_media_refs_item", "tenant_id", "menu_item_id"), + Index("ix_menu_media_refs_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MenuLocalization( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Multi-language shell for menu catalog entities.""" + + __tablename__ = "menu_localizations" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + target_type: Mapped[LocalizationTarget] = mapped_column(nullable=False) + target_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + locale: Mapped[str] = mapped_column(String(16), nullable=False) + name: Mapped[str | None] = mapped_column(String(255), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "target_type", + "target_id", + "locale", + name="uq_menu_localizations_locale", + ), + Index("ix_menu_localizations_target", "tenant_id", "target_type", "target_id"), + Index("ix_menu_localizations_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/hospitality/app/models/connectors.py b/backend/services/hospitality/app/models/connectors.py new file mode 100644 index 0000000..b1e91d1 --- /dev/null +++ b/backend/services/hospitality/app/models/connectors.py @@ -0,0 +1,104 @@ +"""Connector integration aggregates — Phase 12.7. + +Registrations and dispatches reference external integration platforms +(accounting, CRM, loyalty, communication, delivery, website builder) by +config/ref only. No httpx calls and no cross-service imports live here — +outbound "delivery" is simulated via mock provider implementations. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + ConnectorKind, + ConnectorStatus, + DispatchDirection, + DispatchStatus, +) + + +class ConnectorRegistration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A tenant-scoped registration of an external integration connector.""" + + __tablename__ = "connector_registrations" + + venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + kind: Mapped[ConnectorKind] = mapped_column(nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[ConnectorStatus] = mapped_column( + default=ConnectorStatus.DRAFT, nullable=False + ) + external_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + config: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index( + "ix_connector_registrations_tenant_code", + "tenant_id", + "code", + unique=True, + ), + Index("ix_connector_registrations_venue", "tenant_id", "venue_id"), + Index("ix_connector_registrations_kind", "tenant_id", "kind"), + Index( + "ix_connector_registrations_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class ConnectorDispatch( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """An outbound dispatch attempt from a connector registration (by reference only).""" + + __tablename__ = "connector_dispatches" + + venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + registration_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + direction: Mapped[DispatchDirection] = mapped_column( + default=DispatchDirection.OUTBOUND, nullable=False + ) + event_type: Mapped[str] = mapped_column(String(100), nullable=False) + payload_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[DispatchStatus] = mapped_column( + default=DispatchStatus.PENDING, nullable=False + ) + response_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_connector_dispatches_venue", "tenant_id", "venue_id"), + Index( + "ix_connector_dispatches_registration", "tenant_id", "registration_id" + ), + Index("ix_connector_dispatches_status", "tenant_id", "status"), + Index( + "ix_connector_dispatches_tenant_deleted", "tenant_id", "is_deleted" + ), + ) diff --git a/backend/services/hospitality/app/models/foundation.py b/backend/services/hospitality/app/models/foundation.py index 54c4855..13cfa55 100644 --- a/backend/services/hospitality/app/models/foundation.py +++ b/backend/services/hospitality/app/models/foundation.py @@ -1,4 +1,4 @@ -"""Hospitality foundation aggregates — Phase 10.0. +"""Hospitality foundation aggregates — Phase 12.0. Independent aggregates use UUID references within hospitality_db only. No SQLAlchemy relationship graphs between aggregates. @@ -233,7 +233,7 @@ class MenuItem( SoftDeleteMixin, ActorAuditMixin, ): - """Menu item shell — pricing/modifiers engines come later.""" + """Menu item — catalog depth (modifiers/allergens/media) in Phase 12.1.""" __tablename__ = "menu_items" @@ -251,6 +251,15 @@ class MenuItem( is_available: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # Phase 12.1 catalog fields (additive, backward compatible) + preparation_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + calories: Mapped[int | None] = mapped_column(Integer, nullable=True) + spicy_level: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + is_vegetarian: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_vegan: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_gluten_free: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + tags: Mapped[list | None] = mapped_column(JSON, nullable=True) + primary_media_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) __table_args__ = ( UniqueConstraint( diff --git a/backend/services/hospitality/app/models/kitchen.py b/backend/services/hospitality/app/models/kitchen.py new file mode 100644 index 0000000..decbd62 --- /dev/null +++ b/backend/services/hospitality/app/models/kitchen.py @@ -0,0 +1,120 @@ +"""Kitchen aggregates — Phase 12.6. + +Kitchen display / ticket routing shell consuming POS tickets and QR +ordering sessions by reference only (source_type + source_id UUID). +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + KitchenItemStatus, + KitchenTicketSource, + KitchenTicketStatus, + LifecycleStatus, +) + + +class KitchenStation( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A kitchen preparation station within a venue.""" + + __tablename__ = "kitchen_stations" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_kitchen_stations_tenant_venue_code" + ), + Index("ix_kitchen_stations_venue", "tenant_id", "venue_id"), + Index("ix_kitchen_stations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class KitchenTicket( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A kitchen ticket routed from a POS ticket or QR ordering session (by reference only).""" + + __tablename__ = "kitchen_tickets" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + station_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + source_type: Mapped[KitchenTicketSource] = mapped_column( + default=KitchenTicketSource.POS_TICKET, nullable=False + ) + source_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[KitchenTicketStatus] = mapped_column( + default=KitchenTicketStatus.QUEUED, nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_kitchen_tickets_tenant_venue_code" + ), + Index("ix_kitchen_tickets_venue", "tenant_id", "venue_id"), + Index("ix_kitchen_tickets_station", "tenant_id", "station_id"), + Index("ix_kitchen_tickets_source", "tenant_id", "source_type", "source_id"), + Index("ix_kitchen_tickets_status", "tenant_id", "status"), + Index("ix_kitchen_tickets_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class KitchenTicketItem( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A single menu-item line on a kitchen ticket.""" + + __tablename__ = "kitchen_ticket_items" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + kitchen_ticket_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + menu_item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + status: Mapped[KitchenItemStatus] = mapped_column( + default=KitchenItemStatus.QUEUED, nullable=False + ) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + __table_args__ = ( + Index("ix_kitchen_ticket_items_venue", "tenant_id", "venue_id"), + Index("ix_kitchen_ticket_items_ticket", "tenant_id", "kitchen_ticket_id"), + Index("ix_kitchen_ticket_items_status", "tenant_id", "status"), + Index("ix_kitchen_ticket_items_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/hospitality/app/models/pos_lite.py b/backend/services/hospitality/app/models/pos_lite.py new file mode 100644 index 0000000..ed4bbda --- /dev/null +++ b/backend/services/hospitality/app/models/pos_lite.py @@ -0,0 +1,148 @@ +"""POS Lite aggregates — Phase 12.4. + +Lightweight point-of-sale shell: registers, shifts, tickets, and ticket +lines for small venue formats. No payment posting, no accounting entries. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, LifecycleStatus, PosShiftStatus, PosTicketStatus + + +class PosRegister( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A physical or virtual POS register within a venue.""" + + __tablename__ = "pos_registers" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_pos_registers_tenant_venue_code" + ), + Index("ix_pos_registers_venue", "tenant_id", "venue_id"), + Index("ix_pos_registers_status", "tenant_id", "status"), + Index("ix_pos_registers_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class PosShift( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A cashier shift opened against a register.""" + + __tablename__ = "pos_shifts" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + register_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + opened_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[PosShiftStatus] = mapped_column( + default=PosShiftStatus.OPEN, nullable=False + ) + opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + Index("ix_pos_shifts_venue", "tenant_id", "venue_id"), + Index("ix_pos_shifts_register", "tenant_id", "register_id"), + Index("ix_pos_shifts_status", "tenant_id", "status"), + Index("ix_pos_shifts_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class PosTicket( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A sale ticket opened against a register / shift, optionally linked to a table or QR ordering session.""" + + __tablename__ = "pos_tickets" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + register_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + shift_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + table_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + ordering_session_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[PosTicketStatus] = mapped_column( + default=PosTicketStatus.DRAFT, nullable=False + ) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_pos_tickets_tenant_venue_code" + ), + Index("ix_pos_tickets_venue", "tenant_id", "venue_id"), + Index("ix_pos_tickets_register", "tenant_id", "register_id"), + Index("ix_pos_tickets_shift", "tenant_id", "shift_id"), + Index("ix_pos_tickets_status", "tenant_id", "status"), + Index("ix_pos_tickets_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class PosTicketLine( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A single menu-item line on a POS ticket.""" + + __tablename__ = "pos_ticket_lines" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + ticket_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + menu_item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + unit_price: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + modifier_option_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) + line_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + __table_args__ = ( + Index("ix_pos_ticket_lines_venue", "tenant_id", "venue_id"), + Index("ix_pos_ticket_lines_ticket", "tenant_id", "ticket_id"), + Index("ix_pos_ticket_lines_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/hospitality/app/models/pos_pro.py b/backend/services/hospitality/app/models/pos_pro.py new file mode 100644 index 0000000..9a36c2a --- /dev/null +++ b/backend/services/hospitality/app/models/pos_pro.py @@ -0,0 +1,165 @@ +"""POS Pro aggregates — Phase 12.5. + +Full-featured POS additions: payments, discounts, tax lines, floor plans, +and stations. No accounting posting — payment records are local only. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, LifecycleStatus, PosDiscountKind, PosPaymentMethod + + +class PosPayment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A payment recorded against a POS ticket (local record only, no accounting posting).""" + + __tablename__ = "pos_payments" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + ticket_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + method: Mapped[PosPaymentMethod] = mapped_column( + default=PosPaymentMethod.CASH, nullable=False + ) + amount: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + external_payment_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + Index("ix_pos_payments_venue", "tenant_id", "venue_id"), + Index("ix_pos_payments_ticket", "tenant_id", "ticket_id"), + Index("ix_pos_payments_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class PosDiscount( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A discount applied to a POS ticket.""" + + __tablename__ = "pos_discounts" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + ticket_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + kind: Mapped[PosDiscountKind] = mapped_column( + default=PosDiscountKind.PERCENT, nullable=False + ) + value: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + name: Mapped[str | None] = mapped_column(String(255), nullable=True) + + __table_args__ = ( + Index("ix_pos_discounts_venue", "tenant_id", "venue_id"), + Index("ix_pos_discounts_ticket", "tenant_id", "ticket_id"), + Index("ix_pos_discounts_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class PosTaxLine( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A tax line applied to a POS ticket.""" + + __tablename__ = "pos_tax_lines" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + ticket_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + rate_bps: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + amount: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + __table_args__ = ( + Index("ix_pos_tax_lines_venue", "tenant_id", "venue_id"), + Index("ix_pos_tax_lines_ticket", "tenant_id", "ticket_id"), + Index("ix_pos_tax_lines_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class PosFloorPlan( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A floor plan grouping tables within a venue / dining area.""" + + __tablename__ = "pos_floor_plans" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + dining_area_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_pos_floor_plans_tenant_venue_code" + ), + Index("ix_pos_floor_plans_venue", "tenant_id", "venue_id"), + Index("ix_pos_floor_plans_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class PosStation( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A service station (bar / counter / terminal) optionally tied to a floor plan.""" + + __tablename__ = "pos_stations" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + floor_plan_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_pos_stations_tenant_venue_code" + ), + Index("ix_pos_stations_venue", "tenant_id", "venue_id"), + Index("ix_pos_stations_floor_plan", "tenant_id", "floor_plan_id"), + Index("ix_pos_stations_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/hospitality/app/models/qr.py b/backend/services/hospitality/app/models/qr.py new file mode 100644 index 0000000..1fda6b0 --- /dev/null +++ b/backend/services/hospitality/app/models/qr.py @@ -0,0 +1,203 @@ +"""QR Menu & QR Ordering shells — Phase 12.2. + +Public QR surfaces and guest ordering/cart shells consuming the digital menu catalog. +POS / kitchen / payment / reservation engines are out of scope. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import ( + Boolean, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + CartStatus, + GUID, + LifecycleStatus, + OrderingSessionStatus, + QrSessionStatus, + QrTargetType, +) + + +class QrCode( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """QR code mapping token/slug to venue + optional menu/table targets.""" + + __tablename__ = "qr_codes" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + token: Mapped[str] = mapped_column(String(64), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + target_type: Mapped[QrTargetType] = mapped_column( + default=QrTargetType.MENU, nullable=False + ) + menu_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + dining_area_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + table_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + ordering_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "venue_id", "code", name="uq_qr_codes_tenant_code"), + UniqueConstraint("tenant_id", "token", name="uq_qr_codes_tenant_token"), + Index("ix_qr_codes_venue", "tenant_id", "venue_id"), + Index("ix_qr_codes_token", "tenant_id", "token"), + Index("ix_qr_codes_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class QrMenuSession( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Guest browse session opened via QR — catalog view shell only.""" + + __tablename__ = "qr_menu_sessions" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + qr_code_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + menu_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + table_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + session_token: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[QrSessionStatus] = mapped_column( + default=QrSessionStatus.OPEN, nullable=False + ) + locale: Mapped[str | None] = mapped_column(String(16), nullable=True) + guest_label: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "session_token", name="uq_qr_menu_sessions_token" + ), + Index("ix_qr_menu_sessions_qr", "tenant_id", "qr_code_id"), + Index("ix_qr_menu_sessions_venue", "tenant_id", "venue_id"), + Index("ix_qr_menu_sessions_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class QrOrderingSession( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Guest ordering session shell — not a POS/kitchen ticket.""" + + __tablename__ = "qr_ordering_sessions" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + qr_code_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + qr_menu_session_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + menu_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + table_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + session_token: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[OrderingSessionStatus] = mapped_column( + default=OrderingSessionStatus.DRAFT, nullable=False + ) + guest_label: Mapped[str | None] = mapped_column(String(255), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "session_token", name="uq_qr_ordering_sessions_token" + ), + Index("ix_qr_ordering_sessions_qr", "tenant_id", "qr_code_id"), + Index("ix_qr_ordering_sessions_venue", "tenant_id", "venue_id"), + Index("ix_qr_ordering_sessions_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Cart( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Cart shell bound to an ordering session — no payment posting.""" + + __tablename__ = "carts" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + ordering_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[CartStatus] = mapped_column(default=CartStatus.ACTIVE, nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "ordering_session_id", + name="uq_carts_ordering_session", + ), + Index("ix_carts_session", "tenant_id", "ordering_session_id"), + Index("ix_carts_venue", "tenant_id", "venue_id"), + Index("ix_carts_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CartLine( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Cart line with menu item + optional modifier option refs and price snapshot.""" + + __tablename__ = "cart_lines" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + cart_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + menu_item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + unit_price: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + modifier_option_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) + line_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + __table_args__ = ( + Index("ix_cart_lines_cart", "tenant_id", "cart_id"), + Index("ix_cart_lines_item", "tenant_id", "menu_item_id"), + Index("ix_cart_lines_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/hospitality/app/models/table_service.py b/backend/services/hospitality/app/models/table_service.py new file mode 100644 index 0000000..b4dfd7e --- /dev/null +++ b/backend/services/hospitality/app/models/table_service.py @@ -0,0 +1,176 @@ +"""Table Service & Reservations aggregates — Phase 12.3. + +Dine-in table service workflows: guest reservations, table assignments, +in-service requests, and walk-in waitlist. POS / kitchen / payment engines +are out of scope. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + DateTime, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + ReservationStatus, + ServiceRequestKind, + ServiceRequestStatus, + TableAssignmentStatus, + WaitlistStatus, +) + + +class Reservation( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Guest reservation for a future dine-in window.""" + + __tablename__ = "reservations" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + dining_area_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + table_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + guest_name: Mapped[str] = mapped_column(String(255), nullable=False) + guest_phone: Mapped[str | None] = mapped_column(String(32), nullable=True) + party_size: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + status: Mapped[ReservationStatus] = mapped_column( + default=ReservationStatus.PENDING, nullable=False + ) + reserved_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + reserved_to: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_reservations_tenant_venue_code" + ), + Index("ix_reservations_venue", "tenant_id", "venue_id"), + Index("ix_reservations_table", "tenant_id", "table_id"), + Index("ix_reservations_status", "tenant_id", "status"), + Index("ix_reservations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class TableAssignment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A table occupied for service, optionally linked to a reservation / ordering session.""" + + __tablename__ = "table_assignments" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + table_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + reservation_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + ordering_session_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + status: Mapped[TableAssignmentStatus] = mapped_column( + default=TableAssignmentStatus.ACTIVE, nullable=False + ) + assigned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + guest_label: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_table_assignments_venue", "tenant_id", "venue_id"), + Index("ix_table_assignments_table", "tenant_id", "table_id"), + Index("ix_table_assignments_status", "tenant_id", "status"), + Index("ix_table_assignments_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ServiceRequest( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """A guest-initiated in-service request (call waiter, water, bill, other).""" + + __tablename__ = "service_requests" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + table_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + table_assignment_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + kind: Mapped[ServiceRequestKind] = mapped_column( + default=ServiceRequestKind.OTHER, nullable=False + ) + status: Mapped[ServiceRequestStatus] = mapped_column( + default=ServiceRequestStatus.OPEN, nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_service_requests_tenant_venue_code" + ), + Index("ix_service_requests_venue", "tenant_id", "venue_id"), + Index("ix_service_requests_table", "tenant_id", "table_id"), + Index("ix_service_requests_status", "tenant_id", "status"), + Index("ix_service_requests_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class WaitlistEntry( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Walk-in guest waiting for a table.""" + + __tablename__ = "waitlist_entries" + + venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + guest_name: Mapped[str] = mapped_column(String(255), nullable=False) + party_size: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + status: Mapped[WaitlistStatus] = mapped_column( + default=WaitlistStatus.WAITING, nullable=False + ) + phone: Mapped[str | None] = mapped_column(String(32), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "venue_id", "code", name="uq_waitlist_entries_tenant_venue_code" + ), + Index("ix_waitlist_entries_venue", "tenant_id", "venue_id"), + Index("ix_waitlist_entries_status", "tenant_id", "status"), + Index("ix_waitlist_entries_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/hospitality/app/models/types.py b/backend/services/hospitality/app/models/types.py index 979995d..4f6cb66 100644 --- a/backend/services/hospitality/app/models/types.py +++ b/backend/services/hospitality/app/models/types.py @@ -108,3 +108,183 @@ class AuditAction(str, enum.Enum): STATUS_CHANGE = "status_change" ACTIVATE = "activate" DEACTIVATE = "deactivate" + LINK = "link" + UNLINK = "unlink" + + +class ModifierSelectionMode(str, enum.Enum): + SINGLE = "single" + MULTIPLE = "multiple" + + +class MediaKind(str, enum.Enum): + IMAGE = "image" + VIDEO = "video" + THUMBNAIL = "thumbnail" + ICON = "icon" + OTHER = "other" + + +class AvailabilityScope(str, enum.Enum): + MENU = "menu" + CATEGORY = "category" + ITEM = "item" + + +class LocalizationTarget(str, enum.Enum): + MENU = "menu" + CATEGORY = "category" + ITEM = "item" + MODIFIER_GROUP = "modifier_group" + MODIFIER_OPTION = "modifier_option" + ALLERGEN = "allergen" + + +# Phase 12.2 — QR Menu & QR Ordering shells +class QrTargetType(str, enum.Enum): + VENUE = "venue" + MENU = "menu" + DINING_AREA = "dining_area" + TABLE = "table" + + +class QrSessionStatus(str, enum.Enum): + OPEN = "open" + CLOSED = "closed" + EXPIRED = "expired" + CANCELLED = "cancelled" + + +class OrderingSessionStatus(str, enum.Enum): + DRAFT = "draft" + OPEN = "open" + SUBMITTED = "submitted" + CANCELLED = "cancelled" + CLOSED = "closed" + + +class CartStatus(str, enum.Enum): + ACTIVE = "active" + CHECKED_OUT = "checked_out" + ABANDONED = "abandoned" + CANCELLED = "cancelled" + + +# Phase 12.3 — Table Service & Reservations +class ReservationStatus(str, enum.Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + SEATED = "seated" + COMPLETED = "completed" + CANCELLED = "cancelled" + NO_SHOW = "no_show" + + +class TableAssignmentStatus(str, enum.Enum): + ACTIVE = "active" + RELEASED = "released" + TRANSFERRED = "transferred" + + +class ServiceRequestKind(str, enum.Enum): + CALL_WAITER = "call_waiter" + WATER = "water" + BILL = "bill" + OTHER = "other" + + +class ServiceRequestStatus(str, enum.Enum): + OPEN = "open" + ACKNOWLEDGED = "acknowledged" + CLOSED = "closed" + CANCELLED = "cancelled" + + +class WaitlistStatus(str, enum.Enum): + WAITING = "waiting" + SEATED = "seated" + CANCELLED = "cancelled" + EXPIRED = "expired" + + +# Phase 12.4 — POS Lite +class PosShiftStatus(str, enum.Enum): + OPEN = "open" + CLOSED = "closed" + + +class PosTicketStatus(str, enum.Enum): + DRAFT = "draft" + OPEN = "open" + PAID = "paid" + VOID = "void" + CANCELLED = "cancelled" + + +# Phase 12.5 — POS Pro +class PosPaymentMethod(str, enum.Enum): + CASH = "cash" + CARD = "card" + OTHER = "other" + + +class PosDiscountKind(str, enum.Enum): + PERCENT = "percent" + FIXED = "fixed" + + +# Phase 12.6 — Kitchen +class KitchenTicketSource(str, enum.Enum): + POS_TICKET = "pos_ticket" + ORDERING_SESSION = "ordering_session" + + +class KitchenTicketStatus(str, enum.Enum): + QUEUED = "queued" + PREPARING = "preparing" + READY = "ready" + BUMPED = "bumped" + CANCELLED = "cancelled" + + +class KitchenItemStatus(str, enum.Enum): + QUEUED = "queued" + PREPARING = "preparing" + READY = "ready" + BUMPED = "bumped" + CANCELLED = "cancelled" + + +# Phase 12.7 — Connectors +class ConnectorKind(str, enum.Enum): + DELIVERY = "delivery" + ACCOUNTING = "accounting" + CRM = "crm" + LOYALTY = "loyalty" + COMMUNICATION = "communication" + WEBSITE = "website" + + +class ConnectorStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + SUSPENDED = "suspended" + RETIRED = "retired" + + +class DispatchStatus(str, enum.Enum): + PENDING = "pending" + SENT = "sent" + FAILED = "failed" + ACKED = "acked" + + +class DispatchDirection(str, enum.Enum): + OUTBOUND = "outbound" + + +# Phase 12.9 — AI Assistant +class AiRequestStatus(str, enum.Enum): + PENDING = "pending" + COMPLETED = "completed" + FAILED = "failed" diff --git a/backend/services/hospitality/app/permissions/definitions.py b/backend/services/hospitality/app/permissions/definitions.py new file mode 100644 index 0000000..bc44e32 --- /dev/null +++ b/backend/services/hospitality/app/permissions/definitions.py @@ -0,0 +1,293 @@ +"""Hospitality permission definitions — Phase 12.0 foundation.""" +from __future__ import annotations + +HOSPITALITY_VIEW = "hospitality.view" +HOSPITALITY_MANAGE = "hospitality.manage" + +VENUES_VIEW = "hospitality.venues.view" +VENUES_CREATE = "hospitality.venues.create" +VENUES_UPDATE = "hospitality.venues.update" +VENUES_DELETE = "hospitality.venues.delete" +VENUES_MANAGE = "hospitality.venues.manage" + +BRANCHES_VIEW = "hospitality.branches.view" +BRANCHES_CREATE = "hospitality.branches.create" +BRANCHES_UPDATE = "hospitality.branches.update" +BRANCHES_DELETE = "hospitality.branches.delete" +BRANCHES_MANAGE = "hospitality.branches.manage" + +DINING_AREAS_VIEW = "hospitality.dining_areas.view" +DINING_AREAS_MANAGE = "hospitality.dining_areas.manage" +TABLES_VIEW = "hospitality.tables.view" +TABLES_MANAGE = "hospitality.tables.manage" + +MENUS_VIEW = "hospitality.menus.view" +MENUS_CREATE = "hospitality.menus.create" +MENUS_UPDATE = "hospitality.menus.update" +MENUS_DELETE = "hospitality.menus.delete" +MENUS_MANAGE = "hospitality.menus.manage" +MENU_CATEGORIES_VIEW = "hospitality.menu_categories.view" +MENU_CATEGORIES_MANAGE = "hospitality.menu_categories.manage" +MENU_ITEMS_VIEW = "hospitality.menu_items.view" +MENU_ITEMS_MANAGE = "hospitality.menu_items.manage" + +# Phase 12.1 — Digital Menu Catalog +ALLERGENS_VIEW = "hospitality.allergens.view" +ALLERGENS_MANAGE = "hospitality.allergens.manage" +MODIFIER_GROUPS_VIEW = "hospitality.modifier_groups.view" +MODIFIER_GROUPS_MANAGE = "hospitality.modifier_groups.manage" +MODIFIER_OPTIONS_VIEW = "hospitality.modifier_options.view" +MODIFIER_OPTIONS_MANAGE = "hospitality.modifier_options.manage" +AVAILABILITY_WINDOWS_VIEW = "hospitality.availability_windows.view" +AVAILABILITY_WINDOWS_MANAGE = "hospitality.availability_windows.manage" +MENU_MEDIA_VIEW = "hospitality.menu_media.view" +MENU_MEDIA_MANAGE = "hospitality.menu_media.manage" +MENU_LOCALIZATIONS_VIEW = "hospitality.menu_localizations.view" +MENU_LOCALIZATIONS_MANAGE = "hospitality.menu_localizations.manage" + +# Phase 12.2 — QR Menu & QR Ordering +QR_CODES_VIEW = "hospitality.qr_codes.view" +QR_CODES_MANAGE = "hospitality.qr_codes.manage" +QR_MENU_SESSIONS_VIEW = "hospitality.qr_menu_sessions.view" +QR_MENU_SESSIONS_MANAGE = "hospitality.qr_menu_sessions.manage" +QR_ORDERING_SESSIONS_VIEW = "hospitality.qr_ordering_sessions.view" +QR_ORDERING_SESSIONS_MANAGE = "hospitality.qr_ordering_sessions.manage" +CARTS_VIEW = "hospitality.carts.view" +CARTS_MANAGE = "hospitality.carts.manage" + +# Phase 12.3 — Table Service & Reservations +RESERVATIONS_VIEW = "hospitality.reservations.view" +RESERVATIONS_MANAGE = "hospitality.reservations.manage" +TABLE_ASSIGNMENTS_VIEW = "hospitality.table_assignments.view" +TABLE_ASSIGNMENTS_MANAGE = "hospitality.table_assignments.manage" +SERVICE_REQUESTS_VIEW = "hospitality.service_requests.view" +SERVICE_REQUESTS_MANAGE = "hospitality.service_requests.manage" +WAITLIST_VIEW = "hospitality.waitlist.view" +WAITLIST_MANAGE = "hospitality.waitlist.manage" + +# Phase 12.4 — POS Lite +POS_REGISTERS_VIEW = "hospitality.pos_registers.view" +POS_REGISTERS_MANAGE = "hospitality.pos_registers.manage" +POS_SHIFTS_VIEW = "hospitality.pos_shifts.view" +POS_SHIFTS_MANAGE = "hospitality.pos_shifts.manage" +POS_TICKETS_VIEW = "hospitality.pos_tickets.view" +POS_TICKETS_MANAGE = "hospitality.pos_tickets.manage" + +# Phase 12.5 — POS Pro +POS_PAYMENTS_VIEW = "hospitality.pos_payments.view" +POS_PAYMENTS_MANAGE = "hospitality.pos_payments.manage" +POS_DISCOUNTS_VIEW = "hospitality.pos_discounts.view" +POS_DISCOUNTS_MANAGE = "hospitality.pos_discounts.manage" +POS_TAX_LINES_VIEW = "hospitality.pos_tax_lines.view" +POS_TAX_LINES_MANAGE = "hospitality.pos_tax_lines.manage" +POS_FLOOR_PLANS_VIEW = "hospitality.pos_floor_plans.view" +POS_FLOOR_PLANS_MANAGE = "hospitality.pos_floor_plans.manage" +POS_STATIONS_VIEW = "hospitality.pos_stations.view" +POS_STATIONS_MANAGE = "hospitality.pos_stations.manage" + +# Phase 12.6 — Kitchen +KITCHEN_STATIONS_VIEW = "hospitality.kitchen_stations.view" +KITCHEN_STATIONS_MANAGE = "hospitality.kitchen_stations.manage" +KITCHEN_TICKETS_VIEW = "hospitality.kitchen_tickets.view" +KITCHEN_TICKETS_MANAGE = "hospitality.kitchen_tickets.manage" + +# Phase 12.7 — Connectors +CONNECTOR_REGISTRATIONS_VIEW = "hospitality.connector_registrations.view" +CONNECTOR_REGISTRATIONS_MANAGE = "hospitality.connector_registrations.manage" +CONNECTOR_DISPATCHES_VIEW = "hospitality.connector_dispatches.view" +CONNECTOR_DISPATCHES_MANAGE = "hospitality.connector_dispatches.manage" + +# Phase 12.8 — Analytics +ANALYTICS_REPORTS_VIEW = "hospitality.analytics_reports.view" +ANALYTICS_REPORTS_MANAGE = "hospitality.analytics_reports.manage" +ANALYTICS_SNAPSHOTS_VIEW = "hospitality.analytics_snapshots.view" +ANALYTICS_SNAPSHOTS_MANAGE = "hospitality.analytics_snapshots.manage" + +BUNDLES_VIEW = "hospitality.bundles.view" +BUNDLES_MANAGE = "hospitality.bundles.manage" +TENANT_BUNDLES_VIEW = "hospitality.tenant_bundles.view" +TENANT_BUNDLES_MANAGE = "hospitality.tenant_bundles.manage" +FEATURE_TOGGLES_VIEW = "hospitality.feature_toggles.view" +FEATURE_TOGGLES_MANAGE = "hospitality.feature_toggles.manage" + +ROLES_VIEW = "hospitality.roles.view" +ROLES_MANAGE = "hospitality.roles.manage" +PERMISSIONS_VIEW = "hospitality.permissions.view" +PERMISSIONS_MANAGE = "hospitality.permissions.manage" + +CONFIGURATIONS_VIEW = "hospitality.configurations.view" +CONFIGURATIONS_MANAGE = "hospitality.configurations.manage" +EVENTS_VIEW = "hospitality.events.view" +EVENTS_MANAGE = "hospitality.events.manage" +SETTINGS_VIEW = "hospitality.settings.view" +SETTINGS_MANAGE = "hospitality.settings.manage" +AUDIT_VIEW = "hospitality.audit.view" + +# Bundle-gated permissions (registered; engines later) +DIGITAL_MENU_VIEW = "hospitality.digital_menu.view" +QR_MENU_VIEW = "hospitality.qr_menu.view" +QR_ORDERING_MANAGE = "hospitality.qr_ordering.manage" +POS_LITE_MANAGE = "hospitality.pos_lite.manage" +POS_PRO_MANAGE = "hospitality.pos_pro.manage" +KITCHEN_MANAGE = "hospitality.kitchen.manage" +RESERVATION_MANAGE = "hospitality.reservation.manage" +TABLE_SERVICE_MANAGE = "hospitality.table_service.manage" +DELIVERY_CONNECTOR_MANAGE = "hospitality.delivery_connector.manage" +ACCOUNTING_CONNECTOR_MANAGE = "hospitality.accounting_connector.manage" +CRM_CONNECTOR_MANAGE = "hospitality.crm_connector.manage" +LOYALTY_CONNECTOR_MANAGE = "hospitality.loyalty_connector.manage" +COMMUNICATION_CONNECTOR_MANAGE = "hospitality.communication_connector.manage" +WEBSITE_CONNECTOR_MANAGE = "hospitality.website_connector.manage" +ANALYTICS_VIEW = "hospitality.analytics.view" +AI_ASSISTANT_MANAGE = "hospitality.ai_assistant.manage" + +ALL_PERMISSIONS: list[str] = [ + HOSPITALITY_VIEW, + HOSPITALITY_MANAGE, + VENUES_VIEW, + VENUES_CREATE, + VENUES_UPDATE, + VENUES_DELETE, + VENUES_MANAGE, + BRANCHES_VIEW, + BRANCHES_CREATE, + BRANCHES_UPDATE, + BRANCHES_DELETE, + BRANCHES_MANAGE, + DINING_AREAS_VIEW, + DINING_AREAS_MANAGE, + TABLES_VIEW, + TABLES_MANAGE, + MENUS_VIEW, + MENUS_CREATE, + MENUS_UPDATE, + MENUS_DELETE, + MENUS_MANAGE, + MENU_CATEGORIES_VIEW, + MENU_CATEGORIES_MANAGE, + MENU_ITEMS_VIEW, + MENU_ITEMS_MANAGE, + ALLERGENS_VIEW, + ALLERGENS_MANAGE, + MODIFIER_GROUPS_VIEW, + MODIFIER_GROUPS_MANAGE, + MODIFIER_OPTIONS_VIEW, + MODIFIER_OPTIONS_MANAGE, + AVAILABILITY_WINDOWS_VIEW, + AVAILABILITY_WINDOWS_MANAGE, + MENU_MEDIA_VIEW, + MENU_MEDIA_MANAGE, + MENU_LOCALIZATIONS_VIEW, + MENU_LOCALIZATIONS_MANAGE, + QR_CODES_VIEW, + QR_CODES_MANAGE, + QR_MENU_SESSIONS_VIEW, + QR_MENU_SESSIONS_MANAGE, + QR_ORDERING_SESSIONS_VIEW, + QR_ORDERING_SESSIONS_MANAGE, + CARTS_VIEW, + CARTS_MANAGE, + RESERVATIONS_VIEW, + RESERVATIONS_MANAGE, + TABLE_ASSIGNMENTS_VIEW, + TABLE_ASSIGNMENTS_MANAGE, + SERVICE_REQUESTS_VIEW, + SERVICE_REQUESTS_MANAGE, + WAITLIST_VIEW, + WAITLIST_MANAGE, + POS_REGISTERS_VIEW, + POS_REGISTERS_MANAGE, + POS_SHIFTS_VIEW, + POS_SHIFTS_MANAGE, + POS_TICKETS_VIEW, + POS_TICKETS_MANAGE, + POS_PAYMENTS_VIEW, + POS_PAYMENTS_MANAGE, + POS_DISCOUNTS_VIEW, + POS_DISCOUNTS_MANAGE, + POS_TAX_LINES_VIEW, + POS_TAX_LINES_MANAGE, + POS_FLOOR_PLANS_VIEW, + POS_FLOOR_PLANS_MANAGE, + POS_STATIONS_VIEW, + POS_STATIONS_MANAGE, + KITCHEN_STATIONS_VIEW, + KITCHEN_STATIONS_MANAGE, + KITCHEN_TICKETS_VIEW, + KITCHEN_TICKETS_MANAGE, + CONNECTOR_REGISTRATIONS_VIEW, + CONNECTOR_REGISTRATIONS_MANAGE, + CONNECTOR_DISPATCHES_VIEW, + CONNECTOR_DISPATCHES_MANAGE, + ANALYTICS_REPORTS_VIEW, + ANALYTICS_REPORTS_MANAGE, + ANALYTICS_SNAPSHOTS_VIEW, + ANALYTICS_SNAPSHOTS_MANAGE, + BUNDLES_VIEW, + BUNDLES_MANAGE, + TENANT_BUNDLES_VIEW, + TENANT_BUNDLES_MANAGE, + FEATURE_TOGGLES_VIEW, + FEATURE_TOGGLES_MANAGE, + ROLES_VIEW, + ROLES_MANAGE, + PERMISSIONS_VIEW, + PERMISSIONS_MANAGE, + CONFIGURATIONS_VIEW, + CONFIGURATIONS_MANAGE, + EVENTS_VIEW, + EVENTS_MANAGE, + SETTINGS_VIEW, + SETTINGS_MANAGE, + AUDIT_VIEW, + DIGITAL_MENU_VIEW, + QR_MENU_VIEW, + QR_ORDERING_MANAGE, + POS_LITE_MANAGE, + POS_PRO_MANAGE, + KITCHEN_MANAGE, + RESERVATION_MANAGE, + TABLE_SERVICE_MANAGE, + DELIVERY_CONNECTOR_MANAGE, + ACCOUNTING_CONNECTOR_MANAGE, + CRM_CONNECTOR_MANAGE, + LOYALTY_CONNECTOR_MANAGE, + COMMUNICATION_CONNECTOR_MANAGE, + WEBSITE_CONNECTOR_MANAGE, + ANALYTICS_VIEW, + AI_ASSISTANT_MANAGE, +] + +PERMISSION_PREFIXES: tuple[str, ...] = ( + "hospitality.", + "hospitality.venues.", + "hospitality.branches.", + "hospitality.menus.", + "hospitality.allergens.", + "hospitality.modifier_groups.", + "hospitality.qr_codes.", + "hospitality.qr_menu_sessions.", + "hospitality.qr_ordering_sessions.", + "hospitality.carts.", + "hospitality.reservations.", + "hospitality.table_assignments.", + "hospitality.service_requests.", + "hospitality.waitlist.", + "hospitality.pos_registers.", + "hospitality.pos_shifts.", + "hospitality.pos_tickets.", + "hospitality.pos_payments.", + "hospitality.pos_discounts.", + "hospitality.pos_tax_lines.", + "hospitality.pos_floor_plans.", + "hospitality.pos_stations.", + "hospitality.kitchen_stations.", + "hospitality.kitchen_tickets.", + "hospitality.connector_registrations.", + "hospitality.connector_dispatches.", + "hospitality.analytics_reports.", + "hospitality.analytics_snapshots.", + "hospitality.bundles.", + "hospitality.tenant_bundles.", + "hospitality.feature_toggles.", +) diff --git a/backend/services/hospitality/app/policies/__init__.py b/backend/services/hospitality/app/policies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/hospitality/app/providers/__init__.py b/backend/services/hospitality/app/providers/__init__.py new file mode 100644 index 0000000..ac5cfc6 --- /dev/null +++ b/backend/services/hospitality/app/providers/__init__.py @@ -0,0 +1,14 @@ +"""Platform provider contracts package.""" +from app.providers.contracts import ( # noqa: F401 + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + Customer360Provider, + DeliveryProvider, + IdentityProvider, + LoyaltyProvider, + NotificationProvider, + StorageProvider, + WebsiteBuilderProvider, +) diff --git a/backend/services/hospitality/app/providers/contracts.py b/backend/services/hospitality/app/providers/contracts.py new file mode 100644 index 0000000..0d5ab13 --- /dev/null +++ b/backend/services/hospitality/app/providers/contracts.py @@ -0,0 +1,94 @@ +"""Platform capability contracts — interfaces only; no implementations in Hospitality.""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID + + +class AccountingProvider(Protocol): + """Contract for Accounting. Hospitality must not post journals.""" + + async def create_receivable_ref( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: ... + + +class CRMProvider(Protocol): + """Contract for CRM. Hospitality stores external_crm_contact_ref only.""" + + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: ... + + +class LoyaltyProvider(Protocol): + """Contract for Loyalty. Hospitality must not own points/ledger.""" + + async def resolve_member( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: ... + + +class CommunicationProvider(Protocol): + """Contract for Communication Platform. Hospitality must not own SMS providers.""" + + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> None: ... + + +class DeliveryProvider(Protocol): + """Contract for Delivery Platform. Hospitality must not own courier logistics.""" + + async def create_delivery_ref( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: ... + + +class WebsiteBuilderProvider(Protocol): + """Contract for Website Builder. Hospitality stores site refs only.""" + + async def resolve_site( + self, *, tenant_id: UUID, site_ref: str + ) -> dict[str, Any]: ... + + +class NotificationProvider(Protocol): + async def notify( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + payload: dict[str, Any], + ) -> None: ... + + +class StorageProvider(Protocol): + async def resolve_file( + self, *, tenant_id: UUID, file_ref: str + ) -> dict[str, Any]: ... + + +class AIProvider(Protocol): + async def suggest( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: ... + + +class IdentityProvider(Protocol): + async def resolve_user( + self, *, tenant_id: UUID, user_ref: str + ) -> dict[str, Any]: ... + + +class Customer360Provider(Protocol): + async def upsert_party_reference( + self, *, tenant_id: UUID, source: str, source_id: UUID, payload: dict[str, Any] + ) -> None: ... diff --git a/backend/services/hospitality/app/providers/mocks.py b/backend/services/hospitality/app/providers/mocks.py new file mode 100644 index 0000000..a14f8fb --- /dev/null +++ b/backend/services/hospitality/app/providers/mocks.py @@ -0,0 +1,59 @@ +"""No-op mock implementations of platform provider contracts — Phase 12.7. + +Used only by the local Connectors shell to simulate dispatch acknowledgement +without any real network calls or cross-service imports. Real integrations +are owned by their respective platform services and reached via API/events +only, never via httpx or direct imports from this package. +""" +from __future__ import annotations + +from typing import Any +from uuid import UUID + + +class MockAccountingProvider: + async def create_receivable_ref( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: + return {"ok": True, "reference": reference, "provider": "mock_accounting"} + + +class MockCRMProvider: + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: + return {"ok": True, "contact_ref": contact_ref, "provider": "mock_crm"} + + +class MockLoyaltyProvider: + async def resolve_member( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: + return {"ok": True, "member_ref": member_ref, "provider": "mock_loyalty"} + + +class MockCommunicationProvider: + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> None: + return None + + +class MockDeliveryProvider: + async def create_delivery_ref( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: + return {"ok": True, "reference": reference, "provider": "mock_delivery"} + + +class MockWebsiteBuilderProvider: + async def resolve_site( + self, *, tenant_id: UUID, site_ref: str + ) -> dict[str, Any]: + return {"ok": True, "site_ref": site_ref, "provider": "mock_website_builder"} diff --git a/backend/services/hospitality/app/queries/__init__.py b/backend/services/hospitality/app/queries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/hospitality/app/repositories/analytics.py b/backend/services/hospitality/app/repositories/analytics.py new file mode 100644 index 0000000..3b6eacd --- /dev/null +++ b/backend/services/hospitality/app/repositories/analytics.py @@ -0,0 +1,44 @@ +"""Analytics repositories — Phase 12.8.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.analytics import AnalyticsReportDefinition, AnalyticsSnapshot +from app.repositories.base import TenantBaseRepository + + +class AnalyticsReportDefinitionRepository(TenantBaseRepository[AnalyticsReportDefinition]): + model = AnalyticsReportDefinition + + async def get_by_code( + self, tenant_id: UUID, code: str + ) -> AnalyticsReportDefinition | None: + stmt = select(AnalyticsReportDefinition).where( + AnalyticsReportDefinition.tenant_id == tenant_id, + AnalyticsReportDefinition.code == code, + AnalyticsReportDefinition.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AnalyticsSnapshotRepository(TenantBaseRepository[AnalyticsSnapshot]): + model = AnalyticsSnapshot + + async def list_for_report( + self, tenant_id: UUID, report_id: UUID, *, offset: int, limit: int + ) -> list[AnalyticsSnapshot]: + stmt = ( + select(AnalyticsSnapshot) + .where( + AnalyticsSnapshot.tenant_id == tenant_id, + AnalyticsSnapshot.report_id == report_id, + AnalyticsSnapshot.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/services/hospitality/app/repositories/catalog.py b/backend/services/hospitality/app/repositories/catalog.py new file mode 100644 index 0000000..de02d10 --- /dev/null +++ b/backend/services/hospitality/app/repositories/catalog.py @@ -0,0 +1,147 @@ +"""Digital Menu Catalog repositories — Phase 12.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.catalog import ( + Allergen, + AvailabilityWindow, + MenuItemAllergen, + MenuItemModifier, + MenuLocalization, + MenuMediaRef, + ModifierGroup, + ModifierOption, +) +from app.models.types import AvailabilityScope, LocalizationTarget +from app.repositories.base import TenantBaseRepository + + +class AllergenRepository(TenantBaseRepository[Allergen]): + model = Allergen + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> Allergen | None: + stmt = select(Allergen).where( + Allergen.tenant_id == tenant_id, + Allergen.venue_id == venue_id, + Allergen.code == code, + Allergen.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class ModifierGroupRepository(TenantBaseRepository[ModifierGroup]): + model = ModifierGroup + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> ModifierGroup | None: + stmt = select(ModifierGroup).where( + ModifierGroup.tenant_id == tenant_id, + ModifierGroup.venue_id == venue_id, + ModifierGroup.code == code, + ModifierGroup.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class ModifierOptionRepository(TenantBaseRepository[ModifierOption]): + model = ModifierOption + + async def get_by_code( + self, tenant_id: UUID, modifier_group_id: UUID, code: str + ) -> ModifierOption | None: + stmt = select(ModifierOption).where( + ModifierOption.tenant_id == tenant_id, + ModifierOption.modifier_group_id == modifier_group_id, + ModifierOption.code == code, + ModifierOption.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MenuItemModifierRepository(TenantBaseRepository[MenuItemModifier]): + model = MenuItemModifier + + async def get_link( + self, tenant_id: UUID, menu_item_id: UUID, modifier_group_id: UUID + ) -> MenuItemModifier | None: + stmt = select(MenuItemModifier).where( + MenuItemModifier.tenant_id == tenant_id, + MenuItemModifier.menu_item_id == menu_item_id, + MenuItemModifier.modifier_group_id == modifier_group_id, + MenuItemModifier.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MenuItemAllergenRepository(TenantBaseRepository[MenuItemAllergen]): + model = MenuItemAllergen + + async def get_link( + self, tenant_id: UUID, menu_item_id: UUID, allergen_id: UUID + ) -> MenuItemAllergen | None: + stmt = select(MenuItemAllergen).where( + MenuItemAllergen.tenant_id == tenant_id, + MenuItemAllergen.menu_item_id == menu_item_id, + MenuItemAllergen.allergen_id == allergen_id, + MenuItemAllergen.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AvailabilityWindowRepository(TenantBaseRepository[AvailabilityWindow]): + model = AvailabilityWindow + + async def get_by_code( + self, + tenant_id: UUID, + venue_id: UUID, + scope: AvailabilityScope, + target_id: UUID, + code: str, + ) -> AvailabilityWindow | None: + stmt = select(AvailabilityWindow).where( + AvailabilityWindow.tenant_id == tenant_id, + AvailabilityWindow.venue_id == venue_id, + AvailabilityWindow.scope == scope, + AvailabilityWindow.target_id == target_id, + AvailabilityWindow.code == code, + AvailabilityWindow.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MenuMediaRefRepository(TenantBaseRepository[MenuMediaRef]): + model = MenuMediaRef + + +class MenuLocalizationRepository(TenantBaseRepository[MenuLocalization]): + model = MenuLocalization + + async def get_locale( + self, + tenant_id: UUID, + target_type: LocalizationTarget, + target_id: UUID, + locale: str, + ) -> MenuLocalization | None: + stmt = select(MenuLocalization).where( + MenuLocalization.tenant_id == tenant_id, + MenuLocalization.target_type == target_type, + MenuLocalization.target_id == target_id, + MenuLocalization.locale == locale, + MenuLocalization.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/services/hospitality/app/repositories/connectors.py b/backend/services/hospitality/app/repositories/connectors.py new file mode 100644 index 0000000..fdf0f95 --- /dev/null +++ b/backend/services/hospitality/app/repositories/connectors.py @@ -0,0 +1,42 @@ +"""Connector repositories — Phase 12.7.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.connectors import ConnectorDispatch, ConnectorRegistration +from app.repositories.base import TenantBaseRepository + + +class ConnectorRegistrationRepository(TenantBaseRepository[ConnectorRegistration]): + model = ConnectorRegistration + + async def get_by_code(self, tenant_id: UUID, code: str) -> ConnectorRegistration | None: + stmt = select(ConnectorRegistration).where( + ConnectorRegistration.tenant_id == tenant_id, + ConnectorRegistration.code == code, + ConnectorRegistration.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class ConnectorDispatchRepository(TenantBaseRepository[ConnectorDispatch]): + model = ConnectorDispatch + + async def list_for_registration( + self, tenant_id: UUID, registration_id: UUID, *, offset: int, limit: int + ) -> list[ConnectorDispatch]: + stmt = ( + select(ConnectorDispatch) + .where( + ConnectorDispatch.tenant_id == tenant_id, + ConnectorDispatch.registration_id == registration_id, + ConnectorDispatch.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/services/hospitality/app/repositories/foundation.py b/backend/services/hospitality/app/repositories/foundation.py new file mode 100644 index 0000000..5a9c83f --- /dev/null +++ b/backend/services/hospitality/app/repositories/foundation.py @@ -0,0 +1,303 @@ +"""Hospitality foundation repositories — Phase 12.0.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.foundation import ( + Branch, + BundleDefinition, + DiningArea, + DiningTable, + FeatureToggle, + HospitalityAuditLog, + HospitalityConfiguration, + HospitalityEvent, + HospitalityPermission, + HospitalityRole, + HospitalitySetting, + Menu, + MenuCategory, + MenuItem, + TenantBundle, + Venue, +) +from app.models.types import BundleKey, BundleStatus +from app.repositories.base import TenantBaseRepository + + +class VenueRepository(TenantBaseRepository[Venue]): + model = Venue + + async def get_by_code(self, tenant_id: UUID, code: str) -> Venue | None: + stmt = select(Venue).where( + Venue.tenant_id == tenant_id, + Venue.code == code, + Venue.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class BranchRepository(TenantBaseRepository[Branch]): + model = Branch + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> Branch | None: + stmt = select(Branch).where( + Branch.tenant_id == tenant_id, + Branch.venue_id == venue_id, + Branch.code == code, + Branch.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DiningAreaRepository(TenantBaseRepository[DiningArea]): + model = DiningArea + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> DiningArea | None: + stmt = select(DiningArea).where( + DiningArea.tenant_id == tenant_id, + DiningArea.venue_id == venue_id, + DiningArea.code == code, + DiningArea.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DiningTableRepository(TenantBaseRepository[DiningTable]): + model = DiningTable + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> DiningTable | None: + stmt = select(DiningTable).where( + DiningTable.tenant_id == tenant_id, + DiningTable.venue_id == venue_id, + DiningTable.code == code, + DiningTable.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MenuRepository(TenantBaseRepository[Menu]): + model = Menu + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> Menu | None: + stmt = select(Menu).where( + Menu.tenant_id == tenant_id, + Menu.venue_id == venue_id, + Menu.code == code, + Menu.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MenuCategoryRepository(TenantBaseRepository[MenuCategory]): + model = MenuCategory + + async def get_by_code( + self, tenant_id: UUID, menu_id: UUID, code: str + ) -> MenuCategory | None: + stmt = select(MenuCategory).where( + MenuCategory.tenant_id == tenant_id, + MenuCategory.menu_id == menu_id, + MenuCategory.code == code, + MenuCategory.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MenuItemRepository(TenantBaseRepository[MenuItem]): + model = MenuItem + + async def get_by_code( + self, tenant_id: UUID, menu_id: UUID, code: str + ) -> MenuItem | None: + stmt = select(MenuItem).where( + MenuItem.tenant_id == tenant_id, + MenuItem.menu_id == menu_id, + MenuItem.code == code, + MenuItem.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class BundleDefinitionRepository(TenantBaseRepository[BundleDefinition]): + model = BundleDefinition + + async def get_by_key( + self, tenant_id: UUID, bundle_key: BundleKey + ) -> BundleDefinition | None: + stmt = select(BundleDefinition).where( + BundleDefinition.tenant_id == tenant_id, + BundleDefinition.bundle_key == bundle_key, + BundleDefinition.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_code(self, tenant_id: UUID, code: str) -> BundleDefinition | None: + stmt = select(BundleDefinition).where( + BundleDefinition.tenant_id == tenant_id, + BundleDefinition.code == code, + BundleDefinition.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class TenantBundleRepository(TenantBaseRepository[TenantBundle]): + model = TenantBundle + + async def get_active( + self, tenant_id: UUID, bundle_key: BundleKey, venue_id: UUID | None = None + ) -> TenantBundle | None: + stmt = select(TenantBundle).where( + TenantBundle.tenant_id == tenant_id, + TenantBundle.bundle_key == bundle_key, + TenantBundle.venue_id == venue_id, + TenantBundle.status == BundleStatus.ACTIVE, + TenantBundle.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_active(self, tenant_id: UUID, venue_id: UUID | None = None): + clauses = [ + TenantBundle.tenant_id == tenant_id, + TenantBundle.status == BundleStatus.ACTIVE, + TenantBundle.is_deleted.is_(False), + ] + if venue_id is None: + clauses.append(TenantBundle.venue_id.is_(None)) + else: + clauses.append(TenantBundle.venue_id == venue_id) + stmt = select(TenantBundle).where(*clauses) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class FeatureToggleRepository(TenantBaseRepository[FeatureToggle]): + model = FeatureToggle + + async def get_by_key( + self, tenant_id: UUID, feature_key: str, venue_id: UUID | None = None + ) -> FeatureToggle | None: + stmt = select(FeatureToggle).where( + FeatureToggle.tenant_id == tenant_id, + FeatureToggle.feature_key == feature_key, + FeatureToggle.venue_id == venue_id, + FeatureToggle.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_enabled(self, tenant_id: UUID, venue_id: UUID | None = None): + clauses = [ + FeatureToggle.tenant_id == tenant_id, + FeatureToggle.enabled.is_(True), + FeatureToggle.is_deleted.is_(False), + ] + if venue_id is None: + clauses.append(FeatureToggle.venue_id.is_(None)) + else: + clauses.append(FeatureToggle.venue_id == venue_id) + stmt = select(FeatureToggle).where(*clauses) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class HospitalityRoleRepository(TenantBaseRepository[HospitalityRole]): + model = HospitalityRole + + async def get_by_code(self, tenant_id: UUID, code: str) -> HospitalityRole | None: + stmt = select(HospitalityRole).where( + HospitalityRole.tenant_id == tenant_id, + HospitalityRole.code == code, + HospitalityRole.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HospitalityPermissionRepository(TenantBaseRepository[HospitalityPermission]): + model = HospitalityPermission + + async def get_by_code( + self, tenant_id: UUID, code: str + ) -> HospitalityPermission | None: + stmt = select(HospitalityPermission).where( + HospitalityPermission.tenant_id == tenant_id, + HospitalityPermission.code == code, + HospitalityPermission.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HospitalityConfigurationRepository(TenantBaseRepository[HospitalityConfiguration]): + model = HospitalityConfiguration + + async def get_by_code( + self, tenant_id: UUID, code: str + ) -> HospitalityConfiguration | None: + stmt = select(HospitalityConfiguration).where( + HospitalityConfiguration.tenant_id == tenant_id, + HospitalityConfiguration.code == code, + HospitalityConfiguration.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HospitalityEventRepository(TenantBaseRepository[HospitalityEvent]): + model = HospitalityEvent + + +class HospitalitySettingRepository(TenantBaseRepository[HospitalitySetting]): + model = HospitalitySetting + + async def get_by_key( + self, tenant_id: UUID, key: str, venue_id: UUID | None = None + ) -> HospitalitySetting | None: + stmt = select(HospitalitySetting).where( + HospitalitySetting.tenant_id == tenant_id, + HospitalitySetting.key == key, + HospitalitySetting.venue_id == venue_id, + HospitalitySetting.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HospitalityAuditLogRepository(TenantBaseRepository[HospitalityAuditLog]): + model = HospitalityAuditLog + + async def list_for_entity( + self, tenant_id: UUID, entity_type: str, entity_id: UUID, *, limit: int = 100 + ): + stmt = ( + select(HospitalityAuditLog) + .where( + HospitalityAuditLog.tenant_id == tenant_id, + HospitalityAuditLog.entity_type == entity_type, + HospitalityAuditLog.entity_id == entity_id, + ) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/hospitality/app/repositories/kitchen.py b/backend/services/hospitality/app/repositories/kitchen.py new file mode 100644 index 0000000..ac05738 --- /dev/null +++ b/backend/services/hospitality/app/repositories/kitchen.py @@ -0,0 +1,45 @@ +"""Kitchen repositories — Phase 12.6.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.kitchen import KitchenStation, KitchenTicket, KitchenTicketItem +from app.repositories.base import TenantBaseRepository + + +class KitchenStationRepository(TenantBaseRepository[KitchenStation]): + model = KitchenStation + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> KitchenStation | None: + stmt = select(KitchenStation).where( + KitchenStation.tenant_id == tenant_id, + KitchenStation.venue_id == venue_id, + KitchenStation.code == code, + KitchenStation.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class KitchenTicketRepository(TenantBaseRepository[KitchenTicket]): + model = KitchenTicket + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> KitchenTicket | None: + stmt = select(KitchenTicket).where( + KitchenTicket.tenant_id == tenant_id, + KitchenTicket.venue_id == venue_id, + KitchenTicket.code == code, + KitchenTicket.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class KitchenTicketItemRepository(TenantBaseRepository[KitchenTicketItem]): + model = KitchenTicketItem diff --git a/backend/services/hospitality/app/repositories/pos_lite.py b/backend/services/hospitality/app/repositories/pos_lite.py new file mode 100644 index 0000000..c7e14f8 --- /dev/null +++ b/backend/services/hospitality/app/repositories/pos_lite.py @@ -0,0 +1,61 @@ +"""POS Lite repositories — Phase 12.4.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.pos_lite import PosRegister, PosShift, PosTicket, PosTicketLine +from app.repositories.base import TenantBaseRepository + + +class PosRegisterRepository(TenantBaseRepository[PosRegister]): + model = PosRegister + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> PosRegister | None: + stmt = select(PosRegister).where( + PosRegister.tenant_id == tenant_id, + PosRegister.venue_id == venue_id, + PosRegister.code == code, + PosRegister.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PosShiftRepository(TenantBaseRepository[PosShift]): + model = PosShift + + async def list_open_for_register(self, tenant_id: UUID, register_id: UUID): + from app.models.types import PosShiftStatus + + stmt = select(PosShift).where( + PosShift.tenant_id == tenant_id, + PosShift.register_id == register_id, + PosShift.status == PosShiftStatus.OPEN, + PosShift.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class PosTicketRepository(TenantBaseRepository[PosTicket]): + model = PosTicket + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> PosTicket | None: + stmt = select(PosTicket).where( + PosTicket.tenant_id == tenant_id, + PosTicket.venue_id == venue_id, + PosTicket.code == code, + PosTicket.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PosTicketLineRepository(TenantBaseRepository[PosTicketLine]): + model = PosTicketLine diff --git a/backend/services/hospitality/app/repositories/pos_pro.py b/backend/services/hospitality/app/repositories/pos_pro.py new file mode 100644 index 0000000..53dc2f0 --- /dev/null +++ b/backend/services/hospitality/app/repositories/pos_pro.py @@ -0,0 +1,65 @@ +"""POS Pro repositories — Phase 12.5.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.pos_pro import PosDiscount, PosFloorPlan, PosPayment, PosStation, PosTaxLine +from app.repositories.base import TenantBaseRepository + + +class PosPaymentRepository(TenantBaseRepository[PosPayment]): + model = PosPayment + + +class PosDiscountRepository(TenantBaseRepository[PosDiscount]): + model = PosDiscount + + async def get_by_code_for_ticket( + self, tenant_id: UUID, ticket_id: UUID, code: str + ) -> PosDiscount | None: + stmt = select(PosDiscount).where( + PosDiscount.tenant_id == tenant_id, + PosDiscount.ticket_id == ticket_id, + PosDiscount.code == code, + PosDiscount.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PosTaxLineRepository(TenantBaseRepository[PosTaxLine]): + model = PosTaxLine + + +class PosFloorPlanRepository(TenantBaseRepository[PosFloorPlan]): + model = PosFloorPlan + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> PosFloorPlan | None: + stmt = select(PosFloorPlan).where( + PosFloorPlan.tenant_id == tenant_id, + PosFloorPlan.venue_id == venue_id, + PosFloorPlan.code == code, + PosFloorPlan.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PosStationRepository(TenantBaseRepository[PosStation]): + model = PosStation + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> PosStation | None: + stmt = select(PosStation).where( + PosStation.tenant_id == tenant_id, + PosStation.venue_id == venue_id, + PosStation.code == code, + PosStation.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/services/hospitality/app/repositories/qr.py b/backend/services/hospitality/app/repositories/qr.py new file mode 100644 index 0000000..000ccce --- /dev/null +++ b/backend/services/hospitality/app/repositories/qr.py @@ -0,0 +1,92 @@ +"""QR Menu & QR Ordering repositories — Phase 12.2.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.qr import Cart, CartLine, QrCode, QrMenuSession, QrOrderingSession +from app.repositories.base import TenantBaseRepository + + +class QrCodeRepository(TenantBaseRepository[QrCode]): + model = QrCode + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> QrCode | None: + stmt = select(QrCode).where( + QrCode.tenant_id == tenant_id, + QrCode.venue_id == venue_id, + QrCode.code == code, + QrCode.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_token(self, tenant_id: UUID, token: str) -> QrCode | None: + stmt = select(QrCode).where( + QrCode.tenant_id == tenant_id, + QrCode.token == token, + QrCode.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class QrMenuSessionRepository(TenantBaseRepository[QrMenuSession]): + model = QrMenuSession + + async def get_by_token( + self, tenant_id: UUID, session_token: str + ) -> QrMenuSession | None: + stmt = select(QrMenuSession).where( + QrMenuSession.tenant_id == tenant_id, + QrMenuSession.session_token == session_token, + QrMenuSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class QrOrderingSessionRepository(TenantBaseRepository[QrOrderingSession]): + model = QrOrderingSession + + async def get_by_token( + self, tenant_id: UUID, session_token: str + ) -> QrOrderingSession | None: + stmt = select(QrOrderingSession).where( + QrOrderingSession.tenant_id == tenant_id, + QrOrderingSession.session_token == session_token, + QrOrderingSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class CartRepository(TenantBaseRepository[Cart]): + model = Cart + + async def get_by_ordering_session( + self, tenant_id: UUID, ordering_session_id: UUID + ) -> Cart | None: + stmt = select(Cart).where( + Cart.tenant_id == tenant_id, + Cart.ordering_session_id == ordering_session_id, + Cart.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class CartLineRepository(TenantBaseRepository[CartLine]): + model = CartLine + + async def list_by_cart(self, tenant_id: UUID, cart_id: UUID): + stmt = select(CartLine).where( + CartLine.tenant_id == tenant_id, + CartLine.cart_id == cart_id, + CartLine.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/hospitality/app/repositories/table_service.py b/backend/services/hospitality/app/repositories/table_service.py new file mode 100644 index 0000000..ce4dff7 --- /dev/null +++ b/backend/services/hospitality/app/repositories/table_service.py @@ -0,0 +1,78 @@ +"""Table Service & Reservations repositories — Phase 12.3.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.table_service import ( + Reservation, + ServiceRequest, + TableAssignment, + WaitlistEntry, +) +from app.repositories.base import TenantBaseRepository + + +class ReservationRepository(TenantBaseRepository[Reservation]): + model = Reservation + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> Reservation | None: + stmt = select(Reservation).where( + Reservation.tenant_id == tenant_id, + Reservation.venue_id == venue_id, + Reservation.code == code, + Reservation.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class TableAssignmentRepository(TenantBaseRepository[TableAssignment]): + model = TableAssignment + + async def list_active_for_table(self, tenant_id: UUID, table_id: UUID): + from app.models.types import TableAssignmentStatus + + stmt = select(TableAssignment).where( + TableAssignment.tenant_id == tenant_id, + TableAssignment.table_id == table_id, + TableAssignment.status == TableAssignmentStatus.ACTIVE, + TableAssignment.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class ServiceRequestRepository(TenantBaseRepository[ServiceRequest]): + model = ServiceRequest + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> ServiceRequest | None: + stmt = select(ServiceRequest).where( + ServiceRequest.tenant_id == tenant_id, + ServiceRequest.venue_id == venue_id, + ServiceRequest.code == code, + ServiceRequest.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class WaitlistEntryRepository(TenantBaseRepository[WaitlistEntry]): + model = WaitlistEntry + + async def get_by_code( + self, tenant_id: UUID, venue_id: UUID, code: str + ) -> WaitlistEntry | None: + stmt = select(WaitlistEntry).where( + WaitlistEntry.tenant_id == tenant_id, + WaitlistEntry.venue_id == venue_id, + WaitlistEntry.code == code, + WaitlistEntry.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/services/hospitality/app/schemas/analytics.py b/backend/services/hospitality/app/schemas/analytics.py new file mode 100644 index 0000000..bb87f8b --- /dev/null +++ b/backend/services/hospitality/app/schemas/analytics.py @@ -0,0 +1,58 @@ +"""Analytics DTOs — Phase 12.8.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import LifecycleStatus +from app.schemas.common import ORMBase + + +class AnalyticsReportDefinitionCreate(BaseModel): + venue_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + metric_keys: list[str] + filters: dict | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class AnalyticsReportDefinitionRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID | None + code: str + name: str + metric_keys: list[str] + filters: dict | None + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class AnalyticsSnapshotRefresh(BaseModel): + report_id: UUID + venue_id: UUID | None = None + period_start: str = Field(max_length=32) + period_end: str = Field(max_length=32) + + +class AnalyticsSnapshotRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID | None + report_id: UUID + period_start: str + period_end: str + metrics: dict + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/hospitality/app/schemas/catalog.py b/backend/services/hospitality/app/schemas/catalog.py new file mode 100644 index 0000000..0fa20fe --- /dev/null +++ b/backend/services/hospitality/app/schemas/catalog.py @@ -0,0 +1,248 @@ +"""Digital Menu Catalog DTOs — Phase 12.1.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import ( + AvailabilityScope, + LifecycleStatus, + LocalizationTarget, + MediaKind, + ModifierSelectionMode, +) +from app.schemas.common import ORMBase + + +class AllergenCreate(BaseModel): + venue_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + icon_ref: str | None = Field(default=None, max_length=255) + sort_order: int = 0 + + +class AllergenRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + icon_ref: str | None + sort_order: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ModifierGroupCreate(BaseModel): + venue_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + selection_mode: ModifierSelectionMode = ModifierSelectionMode.SINGLE + min_select: int = 0 + max_select: int | None = None + is_required: bool = False + sort_order: int = 0 + + +class ModifierGroupRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + selection_mode: ModifierSelectionMode + min_select: int + max_select: int | None + is_required: bool + sort_order: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ModifierOptionCreate(BaseModel): + venue_id: UUID + modifier_group_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + price_delta: int = 0 + currency_code: str = Field(default="IRR", max_length=3) + is_default: bool = False + sort_order: int = 0 + + +class ModifierOptionRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + modifier_group_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + price_delta: int + currency_code: str + is_default: bool + sort_order: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MenuItemModifierLinkCreate(BaseModel): + venue_id: UUID + menu_item_id: UUID + modifier_group_id: UUID + sort_order: int = 0 + is_required_override: bool | None = None + + +class MenuItemModifierLinkRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + menu_item_id: UUID + modifier_group_id: UUID + sort_order: int + is_required_override: bool | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MenuItemAllergenLinkCreate(BaseModel): + venue_id: UUID + menu_item_id: UUID + allergen_id: UUID + is_contains: bool = True + is_may_contain: bool = False + + +class MenuItemAllergenLinkRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + menu_item_id: UUID + allergen_id: UUID + is_contains: bool + is_may_contain: bool + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class AvailabilityWindowCreate(BaseModel): + venue_id: UUID + scope: AvailabilityScope + target_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + status: LifecycleStatus = LifecycleStatus.ACTIVE + weekday: int | None = Field(default=None, ge=0, le=6) + start_time: str = Field(max_length=8) + end_time: str = Field(max_length=8) + timezone: str = Field(default="Asia/Tehran", max_length=64) + metadata_json: dict | None = None + + +class AvailabilityWindowRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + scope: AvailabilityScope + target_id: UUID + code: str + name: str + status: LifecycleStatus + weekday: int | None + start_time: str + end_time: str + timezone: str + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MenuMediaRefCreate(BaseModel): + venue_id: UUID + menu_id: UUID | None = None + menu_item_id: UUID | None = None + kind: MediaKind = MediaKind.IMAGE + file_ref: str = Field(max_length=255) + alt_text: str | None = Field(default=None, max_length=255) + sort_order: int = 0 + status: LifecycleStatus = LifecycleStatus.ACTIVE + metadata_json: dict | None = None + + +class MenuMediaRefRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + menu_id: UUID | None + menu_item_id: UUID | None + kind: MediaKind + file_ref: str + alt_text: str | None + sort_order: int + status: LifecycleStatus + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MenuLocalizationUpsert(BaseModel): + venue_id: UUID + target_type: LocalizationTarget + target_id: UUID + locale: str = Field(max_length=16) + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class MenuLocalizationRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + target_type: LocalizationTarget + target_id: UUID + locale: str + name: str | None + description: str | None + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/hospitality/app/schemas/common.py b/backend/services/hospitality/app/schemas/common.py new file mode 100644 index 0000000..12b4f56 --- /dev/null +++ b/backend/services/hospitality/app/schemas/common.py @@ -0,0 +1,18 @@ +"""Common schemas.""" +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ORMBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class IdResponse(BaseModel): + id: UUID + + +class MessageResponse(BaseModel): + message: str diff --git a/backend/services/hospitality/app/schemas/connectors.py b/backend/services/hospitality/app/schemas/connectors.py new file mode 100644 index 0000000..c20e7c9 --- /dev/null +++ b/backend/services/hospitality/app/schemas/connectors.py @@ -0,0 +1,68 @@ +"""Connector DTOs — Phase 12.7.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import ( + ConnectorKind, + ConnectorStatus, + DispatchDirection, + DispatchStatus, +) +from app.schemas.common import ORMBase + + +class ConnectorRegistrationCreate(BaseModel): + venue_id: UUID | None = None + kind: ConnectorKind + code: str = Field(max_length=50) + name: str = Field(max_length=255) + status: ConnectorStatus = ConnectorStatus.DRAFT + external_ref: str | None = None + config: dict | None = None + + +class ConnectorRegistrationRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID | None + kind: ConnectorKind + code: str + name: str + status: ConnectorStatus + external_ref: str | None + config: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ConnectorDispatchCreate(BaseModel): + venue_id: UUID | None = None + registration_id: UUID + direction: DispatchDirection = DispatchDirection.OUTBOUND + event_type: str = Field(max_length=100) + payload_ref: str | None = None + + +class ConnectorDispatchRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID | None + registration_id: UUID + direction: DispatchDirection + event_type: str + payload_ref: str | None + status: DispatchStatus + response_json: dict | None + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/hospitality/app/schemas/foundation.py b/backend/services/hospitality/app/schemas/foundation.py new file mode 100644 index 0000000..66eb0d2 --- /dev/null +++ b/backend/services/hospitality/app/schemas/foundation.py @@ -0,0 +1,483 @@ +"""Hospitality foundation DTOs — Phase 12.0.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import ( + BundleKey, + BundleStatus, + LifecycleStatus, + MenuStatus, + TableStatus, + VenueFormat, +) +from app.schemas.common import ORMBase + + +class VenueCreate(BaseModel): + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.DRAFT + venue_format: VenueFormat = VenueFormat.RESTAURANT + legal_name: str | None = Field(default=None, max_length=255) + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + settings: dict | None = None + + +class VenueUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + venue_format: VenueFormat | None = None + legal_name: str | None = Field(default=None, max_length=255) + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + settings: dict | None = None + version: int | None = None + + +class VenueRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + venue_format: VenueFormat + legal_name: str | None + timezone: str + language: str + currency_code: str + settings: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class BranchCreate(BaseModel): + venue_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class BranchUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class BranchRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + address: dict | None + timezone: str | None + working_hours: dict | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DiningAreaCreate(BaseModel): + venue_id: UUID + branch_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + sort_order: int = 0 + metadata_json: dict | None = None + + +class DiningAreaRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + branch_id: UUID | None + code: str + name: str + description: str | None + status: LifecycleStatus + sort_order: int + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DiningTableCreate(BaseModel): + venue_id: UUID + dining_area_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + capacity: int = 2 + status: TableStatus = TableStatus.AVAILABLE + qr_token: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class DiningTableRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + dining_area_id: UUID | None + code: str + name: str + capacity: int + status: TableStatus + qr_token: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MenuCreate(BaseModel): + venue_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: MenuStatus = MenuStatus.DRAFT + currency_code: str = Field(default="IRR", max_length=3) + is_default: bool = False + metadata_json: dict | None = None + + +class MenuUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: MenuStatus | None = None + currency_code: str | None = Field(default=None, max_length=3) + is_default: bool | None = None + metadata_json: dict | None = None + version: int | None = None + + +class MenuRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + code: str + name: str + description: str | None + status: MenuStatus + currency_code: str + is_default: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MenuCategoryCreate(BaseModel): + venue_id: UUID + menu_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + sort_order: int = 0 + + +class MenuCategoryRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + menu_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + sort_order: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MenuItemCreate(BaseModel): + venue_id: UUID + menu_id: UUID + category_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + base_price: int = 0 + currency_code: str = Field(default="IRR", max_length=3) + is_available: bool = True + sort_order: int = 0 + attributes: dict | None = None + preparation_minutes: int | None = None + calories: int | None = None + spicy_level: int = 0 + is_vegetarian: bool = False + is_vegan: bool = False + is_gluten_free: bool = False + tags: list[str] | None = None + primary_media_ref: str | None = Field(default=None, max_length=255) + + +class MenuItemRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + menu_id: UUID + category_id: UUID | None + code: str + name: str + description: str | None + status: LifecycleStatus + base_price: int + currency_code: str + is_available: bool + sort_order: int + attributes: dict | None + preparation_minutes: int | None + calories: int | None + spicy_level: int + is_vegetarian: bool + is_vegan: bool + is_gluten_free: bool + tags: list | None + primary_media_ref: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class BundleDefinitionCreate(BaseModel): + code: str = Field(max_length=50) + bundle_key: BundleKey + name: str = Field(max_length=255) + description: str | None = None + status: BundleStatus = BundleStatus.AVAILABLE + feature_keys: list[str] | None = None + permission_prefixes: list[str] | None = None + api_prefixes: list[str] | None = None + metadata_json: dict | None = None + + +class BundleDefinitionRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + bundle_key: BundleKey + name: str + description: str | None + status: BundleStatus + feature_keys: list | None + permission_prefixes: list | None + api_prefixes: list | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class TenantBundleActivate(BaseModel): + bundle_definition_id: UUID + venue_id: UUID | None = None + metadata_json: dict | None = None + + +class TenantBundleRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID | None + bundle_definition_id: UUID + bundle_key: BundleKey + status: BundleStatus + activated_by: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class FeatureToggleUpsert(BaseModel): + feature_key: str = Field(max_length=100) + enabled: bool = False + venue_id: UUID | None = None + bundle_key: BundleKey | None = None + metadata_json: dict | None = None + + +class FeatureToggleRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID | None + feature_key: str + enabled: bool + bundle_key: BundleKey | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HospitalityRoleCreate(BaseModel): + venue_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + permission_keys: list[str] | None = None + + +class HospitalityRoleRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID | None + code: str + name: str + description: str | None + status: LifecycleStatus + permission_keys: list | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HospitalityPermissionCreate(BaseModel): + code: str = Field(max_length=100) + name: str = Field(max_length=255) + description: str | None = None + bundle_key: BundleKey | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class HospitalityPermissionRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + description: str | None + bundle_key: BundleKey | None + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HospitalityConfigurationCreate(BaseModel): + venue_id: UUID | None = None + branch_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + config: dict | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class HospitalityConfigurationUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + config: dict | None = None + status: LifecycleStatus | None = None + version: int | None = None + + +class HospitalityConfigurationRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID | None + branch_id: UUID | None + code: str + name: str + config: dict | None + status: LifecycleStatus + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HospitalityEventCreate(BaseModel): + venue_id: UUID + event_type: str = Field(max_length=100) + title: str = Field(max_length=255) + payload: dict | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class HospitalityEventRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + event_type: str + title: str + payload: dict | None + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HospitalitySettingUpsert(BaseModel): + key: str = Field(max_length=100) + value: dict | None = None + venue_id: UUID | None = None + + +class HospitalitySettingRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID | None + key: str + value: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/hospitality/app/schemas/kitchen.py b/backend/services/hospitality/app/schemas/kitchen.py new file mode 100644 index 0000000..fed8488 --- /dev/null +++ b/backend/services/hospitality/app/schemas/kitchen.py @@ -0,0 +1,94 @@ +"""Kitchen DTOs — Phase 12.6.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import ( + KitchenItemStatus, + KitchenTicketSource, + KitchenTicketStatus, + LifecycleStatus, +) +from app.schemas.common import ORMBase + + +class KitchenStationCreate(BaseModel): + venue_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class KitchenStationRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + code: str + name: str + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class KitchenTicketCreate(BaseModel): + venue_id: UUID + station_id: UUID | None = None + source_type: KitchenTicketSource = KitchenTicketSource.POS_TICKET + source_id: UUID + code: str = Field(max_length=50) + notes: str | None = None + + +class KitchenTicketStatusUpdate(BaseModel): + status: KitchenTicketStatus + + +class KitchenTicketRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + station_id: UUID | None + source_type: KitchenTicketSource + source_id: UUID + code: str + status: KitchenTicketStatus + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class KitchenTicketItemCreate(BaseModel): + venue_id: UUID + kitchen_ticket_id: UUID + menu_item_id: UUID + quantity: int = 1 + sort_order: int | None = None + + +class KitchenTicketItemStatusUpdate(BaseModel): + status: KitchenItemStatus + + +class KitchenTicketItemRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + kitchen_ticket_id: UUID + menu_item_id: UUID + quantity: int + status: KitchenItemStatus + sort_order: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/hospitality/app/schemas/pos_lite.py b/backend/services/hospitality/app/schemas/pos_lite.py new file mode 100644 index 0000000..05a6c27 --- /dev/null +++ b/backend/services/hospitality/app/schemas/pos_lite.py @@ -0,0 +1,116 @@ +"""POS Lite DTOs — Phase 12.4.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import LifecycleStatus, PosShiftStatus, PosTicketStatus +from app.schemas.common import ORMBase + + +class PosRegisterCreate(BaseModel): + venue_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + status: LifecycleStatus = LifecycleStatus.ACTIVE + description: str | None = None + + +class PosRegisterRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + code: str + name: str + status: LifecycleStatus + description: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PosShiftCreate(BaseModel): + venue_id: UUID + register_id: UUID + opened_by: str | None = Field(default=None, max_length=100) + + +class PosShiftRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + register_id: UUID + opened_by: str | None + status: PosShiftStatus + opened_at: datetime + closed_at: datetime | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PosTicketCreate(BaseModel): + venue_id: UUID + register_id: UUID + shift_id: UUID | None = None + table_id: UUID | None = None + ordering_session_id: UUID | None = None + code: str = Field(max_length=50) + currency_code: str = Field(default="IRR", max_length=3) + notes: str | None = None + metadata_json: dict | None = None + + +class PosTicketRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + register_id: UUID + shift_id: UUID | None + table_id: UUID | None + ordering_session_id: UUID | None + code: str + status: PosTicketStatus + currency_code: str + notes: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PosTicketLineCreate(BaseModel): + venue_id: UUID + ticket_id: UUID + menu_item_id: UUID + quantity: int = 1 + modifier_option_ids: list[UUID] | None = None + line_notes: str | None = None + sort_order: int | None = None + + +class PosTicketLineRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + ticket_id: UUID + menu_item_id: UUID + quantity: int + unit_price: int + currency_code: str + modifier_option_ids: list | None + line_notes: str | None + sort_order: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/hospitality/app/schemas/pos_pro.py b/backend/services/hospitality/app/schemas/pos_pro.py new file mode 100644 index 0000000..1be7355 --- /dev/null +++ b/backend/services/hospitality/app/schemas/pos_pro.py @@ -0,0 +1,132 @@ +"""POS Pro DTOs — Phase 12.5.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import LifecycleStatus, PosDiscountKind, PosPaymentMethod +from app.schemas.common import ORMBase + + +class PosPaymentCreate(BaseModel): + venue_id: UUID + ticket_id: UUID + method: PosPaymentMethod = PosPaymentMethod.CASH + amount: int + currency_code: str = Field(default="IRR", max_length=3) + external_payment_ref: str | None = Field(default=None, max_length=255) + + +class PosPaymentRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + ticket_id: UUID + method: PosPaymentMethod + amount: int + currency_code: str + external_payment_ref: str | None + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PosDiscountCreate(BaseModel): + venue_id: UUID + ticket_id: UUID + code: str = Field(max_length=50) + kind: PosDiscountKind = PosDiscountKind.PERCENT + value: int + name: str | None = Field(default=None, max_length=255) + + +class PosDiscountRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + ticket_id: UUID + code: str + kind: PosDiscountKind + value: int + name: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PosTaxLineCreate(BaseModel): + venue_id: UUID + ticket_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + rate_bps: int + amount: int + + +class PosTaxLineRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + ticket_id: UUID + code: str + name: str + rate_bps: int + amount: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PosFloorPlanCreate(BaseModel): + venue_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + dining_area_id: UUID | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class PosFloorPlanRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + code: str + name: str + dining_area_id: UUID | None + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PosStationCreate(BaseModel): + venue_id: UUID + floor_plan_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class PosStationRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + floor_plan_id: UUID | None + code: str + name: str + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/hospitality/app/schemas/qr.py b/backend/services/hospitality/app/schemas/qr.py new file mode 100644 index 0000000..0b2d256 --- /dev/null +++ b/backend/services/hospitality/app/schemas/qr.py @@ -0,0 +1,165 @@ +"""QR Menu & QR Ordering DTOs — Phase 12.2.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import ( + CartStatus, + LifecycleStatus, + OrderingSessionStatus, + QrSessionStatus, + QrTargetType, +) +from app.schemas.common import ORMBase + + +class QrCodeCreate(BaseModel): + venue_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + target_type: QrTargetType = QrTargetType.MENU + menu_id: UUID | None = None + dining_area_id: UUID | None = None + table_id: UUID | None = None + ordering_enabled: bool = False + metadata_json: dict | None = None + + +class QrCodeRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + code: str + token: str + name: str + description: str | None + status: LifecycleStatus + target_type: QrTargetType + menu_id: UUID | None + dining_area_id: UUID | None + table_id: UUID | None + ordering_enabled: bool + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class QrMenuSessionCreate(BaseModel): + venue_id: UUID + qr_code_id: UUID + menu_id: UUID | None = None + table_id: UUID | None = None + locale: str | None = Field(default=None, max_length=16) + guest_label: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class QrMenuSessionRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + qr_code_id: UUID + menu_id: UUID | None + table_id: UUID | None + session_token: str + status: QrSessionStatus + locale: str | None + guest_label: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class QrOrderingSessionCreate(BaseModel): + venue_id: UUID + qr_code_id: UUID + qr_menu_session_id: UUID | None = None + menu_id: UUID | None = None + table_id: UUID | None = None + guest_label: str | None = Field(default=None, max_length=255) + notes: str | None = None + metadata_json: dict | None = None + + +class QrOrderingSessionRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + qr_code_id: UUID + qr_menu_session_id: UUID | None + menu_id: UUID | None + table_id: UUID | None + session_token: str + status: OrderingSessionStatus + guest_label: str | None + notes: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class CartCreate(BaseModel): + venue_id: UUID + ordering_session_id: UUID + currency_code: str = Field(default="IRR", max_length=3) + notes: str | None = None + metadata_json: dict | None = None + + +class CartRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + ordering_session_id: UUID + status: CartStatus + currency_code: str + notes: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class CartLineCreate(BaseModel): + venue_id: UUID + cart_id: UUID + menu_item_id: UUID + quantity: int = 1 + modifier_option_ids: list[UUID] | None = None + line_notes: str | None = None + sort_order: int = 0 + + +class CartLineRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + cart_id: UUID + menu_item_id: UUID + quantity: int + unit_price: int + currency_code: str + modifier_option_ids: list | None + line_notes: str | None + sort_order: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/hospitality/app/schemas/table_service.py b/backend/services/hospitality/app/schemas/table_service.py new file mode 100644 index 0000000..49d5231 --- /dev/null +++ b/backend/services/hospitality/app/schemas/table_service.py @@ -0,0 +1,146 @@ +"""Table Service & Reservations DTOs — Phase 12.3.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import ( + ReservationStatus, + ServiceRequestKind, + ServiceRequestStatus, + TableAssignmentStatus, + WaitlistStatus, +) +from app.schemas.common import ORMBase + + +class ReservationCreate(BaseModel): + venue_id: UUID + branch_id: UUID | None = None + dining_area_id: UUID | None = None + table_id: UUID | None = None + code: str = Field(max_length=50) + guest_name: str = Field(max_length=255) + guest_phone: str | None = Field(default=None, max_length=32) + party_size: int = 1 + reserved_from: datetime + reserved_to: datetime + notes: str | None = None + metadata_json: dict | None = None + + +class ReservationStatusUpdate(BaseModel): + status: ReservationStatus + + +class ReservationRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + branch_id: UUID | None + dining_area_id: UUID | None + table_id: UUID | None + code: str + guest_name: str + guest_phone: str | None + party_size: int + status: ReservationStatus + reserved_from: datetime + reserved_to: datetime + notes: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class TableAssignmentCreate(BaseModel): + venue_id: UUID + table_id: UUID + reservation_id: UUID | None = None + ordering_session_id: UUID | None = None + guest_label: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class TableAssignmentRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + table_id: UUID + reservation_id: UUID | None + ordering_session_id: UUID | None + status: TableAssignmentStatus + assigned_at: datetime | None + released_at: datetime | None + guest_label: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ServiceRequestCreate(BaseModel): + venue_id: UUID + table_id: UUID + table_assignment_id: UUID | None = None + code: str = Field(max_length=50) + kind: ServiceRequestKind = ServiceRequestKind.OTHER + notes: str | None = None + + +class ServiceRequestStatusUpdate(BaseModel): + status: ServiceRequestStatus + + +class ServiceRequestRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + table_id: UUID + table_assignment_id: UUID | None + code: str + kind: ServiceRequestKind + status: ServiceRequestStatus + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class WaitlistEntryCreate(BaseModel): + venue_id: UUID + code: str = Field(max_length=50) + guest_name: str = Field(max_length=255) + party_size: int = 1 + phone: str | None = Field(default=None, max_length=32) + notes: str | None = None + + +class WaitlistEntryStatusUpdate(BaseModel): + status: WaitlistStatus + + +class WaitlistEntryRead(ORMBase): + id: UUID + tenant_id: UUID + venue_id: UUID + code: str + guest_name: str + party_size: int + status: WaitlistStatus + phone: str | None + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/hospitality/app/services/analytics.py b/backend/services/hospitality/app/services/analytics.py new file mode 100644 index 0000000..a9dea09 --- /dev/null +++ b/backend/services/hospitality/app/services/analytics.py @@ -0,0 +1,222 @@ +"""Analytics application services — Phase 12.8. + +Snapshots are computed via simple local `COUNT(*)` queries against this +service's own tables only — no external analytics engine, no cross-service +reads. +""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import HospitalityEventType +from app.models.analytics import AnalyticsReportDefinition, AnalyticsSnapshot +from app.models.connectors import ConnectorDispatch, ConnectorRegistration +from app.models.foundation import Menu, MenuItem, Venue +from app.models.kitchen import KitchenTicket +from app.models.pos_lite import PosRegister, PosTicket +from app.models.table_service import Reservation +from app.models.types import AuditAction, LifecycleStatus +from app.repositories.analytics import ( + AnalyticsReportDefinitionRepository, + AnalyticsSnapshotRepository, +) +from app.repositories.foundation import VenueRepository +from app.schemas.analytics import AnalyticsReportDefinitionCreate, AnalyticsSnapshotRefresh +from app.services.audit_service import AuditService +from app.validators import validate_code, validate_non_empty +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + +# metric_key -> ORM model counted locally (venue/tenant scoped COUNT(*) only) +METRIC_TABLE_MAP: dict[str, type] = { + "venues_count": Venue, + "menus_count": Menu, + "menu_items_count": MenuItem, + "pos_registers_count": PosRegister, + "pos_tickets_count": PosTicket, + "reservations_count": Reservation, + "kitchen_tickets_count": KitchenTicket, + "connector_registrations_count": ConnectorRegistration, + "connector_dispatches_count": ConnectorDispatch, +} + + +class _AnalyticsBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.venues = VenueRepository(session) + + async def _require_venue(self, tenant_id: UUID, venue_id: UUID): + venue = await self.venues.get(tenant_id, venue_id) + if venue is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return venue + + +class AnalyticsReportDefinitionService(_AnalyticsBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = AnalyticsReportDefinitionRepository(session) + + async def create( + self, + tenant_id: UUID, + body: AnalyticsReportDefinitionCreate, + *, + actor: CurrentUser | None = None, + ) -> AnalyticsReportDefinition: + if body.venue_id is not None: + await self._require_venue(tenant_id, body.venue_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code) is not None: + raise AppError( + "کد گزارش تکراری است", + status_code=409, + error_code="analytics_report_code_exists", + ) + if not body.metric_keys: + raise AppError( + "حداقل یک metric_key الزامی است", + status_code=422, + error_code="analytics_metric_keys_required", + ) + unknown = [key for key in body.metric_keys if key not in METRIC_TABLE_MAP] + if unknown: + raise AppError( + "metric_key نامعتبر است", + status_code=422, + error_code="invalid_metric_key", + details={"unknown": unknown}, + ) + actor_id = actor.user_id if actor else None + entity = AnalyticsReportDefinition( + tenant_id=tenant_id, + venue_id=body.venue_id, + code=code, + name=validate_non_empty(body.name, field="name"), + metric_keys=body.metric_keys, + filters=body.filters, + status=LifecycleStatus.ACTIVE, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="analytics_report_definition", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.ANALYTICS_REPORT_DEFINITION_CREATED, + aggregate_type="analytics_report_definition", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> AnalyticsReportDefinition: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError( + "گزارش تحلیلی یافت نشد", error_code="analytics_report_not_found" + ) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class AnalyticsSnapshotService(_AnalyticsBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = AnalyticsSnapshotRepository(session) + self.reports = AnalyticsReportDefinitionRepository(session) + + async def _count(self, tenant_id: UUID, model: type) -> int: + stmt = select(func.count()).select_from(model).where( + model.tenant_id == tenant_id, # type: ignore[attr-defined] + model.is_deleted.is_(False), # type: ignore[attr-defined] + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def refresh( + self, + tenant_id: UUID, + body: AnalyticsSnapshotRefresh, + *, + actor: CurrentUser | None = None, + ) -> AnalyticsSnapshot: + if body.venue_id is not None: + await self._require_venue(tenant_id, body.venue_id) + report = await self.reports.get(tenant_id, body.report_id) + if report is None: + raise NotFoundError( + "گزارش تحلیلی یافت نشد", error_code="analytics_report_not_found" + ) + period_start = validate_non_empty(body.period_start, field="period_start") + period_end = validate_non_empty(body.period_end, field="period_end") + + metrics: dict[str, int] = {} + for key in report.metric_keys: + model = METRIC_TABLE_MAP.get(key) + if model is None: + continue + metrics[key] = await self._count(tenant_id, model) + + actor_id = actor.user_id if actor else None + entity = AnalyticsSnapshot( + tenant_id=tenant_id, + venue_id=body.venue_id, + report_id=report.id, + period_start=period_start, + period_end=period_end, + metrics=metrics, + status=LifecycleStatus.ACTIVE, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="analytics_snapshot", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.ANALYTICS_SNAPSHOT_REFRESHED, + aggregate_type="analytics_snapshot", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"report_id": str(entity.report_id), "metric_count": len(metrics)}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> AnalyticsSnapshot: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError( + "عکس‌فوری تحلیلی یافت نشد", error_code="analytics_snapshot_not_found" + ) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/hospitality/app/services/audit_service.py b/backend/services/hospitality/app/services/audit_service.py new file mode 100644 index 0000000..60d6e4b --- /dev/null +++ b/backend/services/hospitality/app/services/audit_service.py @@ -0,0 +1,57 @@ +"""Audit service — append-only Hospitality audit trail.""" +from __future__ import annotations + +from enum import Enum +from typing import Any +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.foundation import HospitalityAuditLog +from app.models.types import AuditAction +from app.repositories.foundation import HospitalityAuditLogRepository + + +def _json_safe(value: Any) -> Any: + if isinstance(value, UUID): + return str(value) + if isinstance(value, Enum): + return value.value + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [_json_safe(v) for v in value] + return value + + +class AuditService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = HospitalityAuditLogRepository(session) + + async def record( + self, + *, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + action: AuditAction, + actor_user_id: str | None = None, + changes: dict[str, Any] | None = None, + message: str | None = None, + commit: bool = False, + ) -> HospitalityAuditLog: + entry = HospitalityAuditLog( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + action=action, + actor_user_id=actor_user_id, + changes=_json_safe(changes) if changes is not None else None, + message=message, + ) + await self.repo.add(entry) + if commit: + await self.session.commit() + await self.session.refresh(entry) + return entry diff --git a/backend/services/hospitality/app/services/catalog.py b/backend/services/hospitality/app/services/catalog.py new file mode 100644 index 0000000..62f541b --- /dev/null +++ b/backend/services/hospitality/app/services/catalog.py @@ -0,0 +1,402 @@ +"""Digital Menu Catalog application services — Phase 12.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import HospitalityEventType +from app.models.catalog import ( + Allergen, + AvailabilityWindow, + MenuItemAllergen, + MenuItemModifier, + MenuLocalization, + MenuMediaRef, + ModifierGroup, + ModifierOption, +) +from app.models.types import ( + AuditAction, + AvailabilityScope, + LocalizationTarget, + MediaKind, +) +from app.repositories.catalog import ( + AllergenRepository, + AvailabilityWindowRepository, + MenuItemAllergenRepository, + MenuItemModifierRepository, + MenuLocalizationRepository, + MenuMediaRefRepository, + ModifierGroupRepository, + ModifierOptionRepository, +) +from app.repositories.foundation import ( + MenuItemRepository, + MenuRepository, + VenueRepository, +) +from app.schemas.catalog import ( + AllergenCreate, + AvailabilityWindowCreate, + MenuItemAllergenLinkCreate, + MenuItemModifierLinkCreate, + MenuLocalizationUpsert, + MenuMediaRefCreate, + ModifierGroupCreate, + ModifierOptionCreate, +) +from app.services.audit_service import AuditService +from app.validators import ( + validate_code, + validate_currency_code, + validate_file_ref, + validate_lifecycle_status, + validate_locale, + validate_modifier_selection, + validate_non_empty, + validate_non_negative_int, + validate_time_range, + validate_weekday, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class _CatalogBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.venues = VenueRepository(session) + + async def _require_venue(self, tenant_id: UUID, venue_id: UUID): + venue = await self.venues.get(tenant_id, venue_id) + if venue is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return venue + + +class AllergenService(_CatalogBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = AllergenRepository(session) + + async def create(self, tenant_id: UUID, body: AllergenCreate, *, actor: CurrentUser | None = None) -> Allergen: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError("کد آلرژن تکراری است", status_code=409, error_code="allergen_code_exists") + entity = Allergen( + tenant_id=tenant_id, venue_id=body.venue_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), icon_ref=body.icon_ref, + sort_order=body.sort_order or 0, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="allergen", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.ALLERGEN_CREATED, aggregate_type="allergen", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Allergen: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("آلرژن یافت نشد", error_code="allergen_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class ModifierGroupService(_CatalogBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = ModifierGroupRepository(session) + + async def create(self, tenant_id: UUID, body: ModifierGroupCreate, *, actor: CurrentUser | None = None) -> ModifierGroup: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError("کد گروه modifier تکراری است", status_code=409, error_code="modifier_group_code_exists") + mode, min_v, max_v = validate_modifier_selection( + body.selection_mode, min_select=body.min_select, max_select=body.max_select, is_required=body.is_required + ) + entity = ModifierGroup( + tenant_id=tenant_id, venue_id=body.venue_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), selection_mode=mode, + min_select=min_v, max_select=max_v, is_required=bool(body.is_required), + sort_order=body.sort_order or 0, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="modifier_group", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MODIFIER_GROUP_CREATED, aggregate_type="modifier_group", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ModifierGroup: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("گروه modifier یافت نشد", error_code="modifier_group_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class ModifierOptionService(_CatalogBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = ModifierOptionRepository(session) + self.groups = ModifierGroupRepository(session) + + async def create(self, tenant_id: UUID, body: ModifierOptionCreate, *, actor: CurrentUser | None = None) -> ModifierOption: + await self._require_venue(tenant_id, body.venue_id) + group = await self.groups.get(tenant_id, body.modifier_group_id) + if group is None: + raise NotFoundError("گروه modifier یافت نشد", error_code="modifier_group_not_found") + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.modifier_group_id, code) is not None: + raise AppError("کد گزینه modifier تکراری است", status_code=409, error_code="modifier_option_code_exists") + entity = ModifierOption( + tenant_id=tenant_id, venue_id=body.venue_id, modifier_group_id=body.modifier_group_id, + code=code, name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), + price_delta=int(body.price_delta), + currency_code=validate_currency_code(body.currency_code), + is_default=bool(body.is_default), sort_order=body.sort_order or 0, + created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="modifier_option", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MODIFIER_OPTION_CREATED, aggregate_type="modifier_option", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ModifierOption: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("گزینه modifier یافت نشد", error_code="modifier_option_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class MenuItemModifierService(_CatalogBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MenuItemModifierRepository(session) + self.items = MenuItemRepository(session) + self.groups = ModifierGroupRepository(session) + + async def link(self, tenant_id: UUID, body: MenuItemModifierLinkCreate, *, actor: CurrentUser | None = None) -> MenuItemModifier: + await self._require_venue(tenant_id, body.venue_id) + item = await self.items.get(tenant_id, body.menu_item_id) + if item is None: + raise NotFoundError("آیتم منو یافت نشد", error_code="menu_item_not_found") + group = await self.groups.get(tenant_id, body.modifier_group_id) + if group is None: + raise NotFoundError("گروه modifier یافت نشد", error_code="modifier_group_not_found") + if await self.repo.get_link(tenant_id, body.menu_item_id, body.modifier_group_id) is not None: + raise AppError("لینک modifier تکراری است", status_code=409, error_code="menu_item_modifier_exists") + actor_id = actor.user_id if actor else None + entity = MenuItemModifier( + tenant_id=tenant_id, venue_id=body.venue_id, menu_item_id=body.menu_item_id, + modifier_group_id=body.modifier_group_id, sort_order=body.sort_order or 0, + is_required_override=body.is_required_override, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="menu_item_modifier", entity_id=entity.id, action=AuditAction.LINK, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MENU_ITEM_MODIFIER_LINKED, aggregate_type="menu_item_modifier", aggregate_id=entity.id, tenant_id=tenant_id, payload={"menu_item_id": str(entity.menu_item_id)}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> MenuItemModifier: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("لینک modifier یافت نشد", error_code="menu_item_modifier_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class MenuItemAllergenService(_CatalogBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MenuItemAllergenRepository(session) + self.items = MenuItemRepository(session) + self.allergens = AllergenRepository(session) + + async def link(self, tenant_id: UUID, body: MenuItemAllergenLinkCreate, *, actor: CurrentUser | None = None) -> MenuItemAllergen: + await self._require_venue(tenant_id, body.venue_id) + item = await self.items.get(tenant_id, body.menu_item_id) + if item is None: + raise NotFoundError("آیتم منو یافت نشد", error_code="menu_item_not_found") + allergen = await self.allergens.get(tenant_id, body.allergen_id) + if allergen is None: + raise NotFoundError("آلرژن یافت نشد", error_code="allergen_not_found") + if await self.repo.get_link(tenant_id, body.menu_item_id, body.allergen_id) is not None: + raise AppError("لینک آلرژن تکراری است", status_code=409, error_code="menu_item_allergen_exists") + actor_id = actor.user_id if actor else None + entity = MenuItemAllergen( + tenant_id=tenant_id, venue_id=body.venue_id, menu_item_id=body.menu_item_id, + allergen_id=body.allergen_id, is_contains=bool(body.is_contains), + is_may_contain=bool(body.is_may_contain), created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="menu_item_allergen", entity_id=entity.id, action=AuditAction.LINK, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MENU_ITEM_ALLERGEN_LINKED, aggregate_type="menu_item_allergen", aggregate_id=entity.id, tenant_id=tenant_id, payload={"menu_item_id": str(entity.menu_item_id)}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> MenuItemAllergen: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("لینک آلرژن یافت نشد", error_code="menu_item_allergen_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class AvailabilityWindowService(_CatalogBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = AvailabilityWindowRepository(session) + + async def create(self, tenant_id: UUID, body: AvailabilityWindowCreate, *, actor: CurrentUser | None = None) -> AvailabilityWindow: + await self._require_venue(tenant_id, body.venue_id) + if not isinstance(body.scope, AvailabilityScope): + try: + scope = AvailabilityScope(body.scope) + except ValueError as exc: + raise AppError("scope نامعتبر است", status_code=422, error_code="invalid_availability_scope") from exc + else: + scope = body.scope + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, scope, body.target_id, code) is not None: + raise AppError("کد پنجره دسترسی تکراری است", status_code=409, error_code="availability_window_code_exists") + start, end = validate_time_range(body.start_time, body.end_time) + entity = AvailabilityWindow( + tenant_id=tenant_id, venue_id=body.venue_id, scope=scope, target_id=body.target_id, + code=code, name=validate_non_empty(body.name, field="name"), + status=validate_lifecycle_status(body.status), weekday=validate_weekday(body.weekday), + start_time=start, end_time=end, + timezone=validate_non_empty(body.timezone, field="timezone"), + metadata_json=body.metadata_json, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="availability_window", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.AVAILABILITY_WINDOW_CREATED, aggregate_type="availability_window", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code, "scope": entity.scope.value}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> AvailabilityWindow: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("پنجره دسترسی یافت نشد", error_code="availability_window_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class MenuMediaRefService(_CatalogBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MenuMediaRefRepository(session) + self.menus = MenuRepository(session) + self.items = MenuItemRepository(session) + + async def create(self, tenant_id: UUID, body: MenuMediaRefCreate, *, actor: CurrentUser | None = None) -> MenuMediaRef: + await self._require_venue(tenant_id, body.venue_id) + if body.menu_id is None and body.menu_item_id is None: + raise AppError("menu_id یا menu_item_id الزامی است", status_code=422, error_code="media_target_required") + if body.menu_id is not None and await self.menus.get(tenant_id, body.menu_id) is None: + raise NotFoundError("منو یافت نشد", error_code="menu_not_found") + if body.menu_item_id is not None and await self.items.get(tenant_id, body.menu_item_id) is None: + raise NotFoundError("آیتم منو یافت نشد", error_code="menu_item_not_found") + actor_id = actor.user_id if actor else None + kind = body.kind if isinstance(body.kind, MediaKind) else MediaKind(body.kind) + entity = MenuMediaRef( + tenant_id=tenant_id, venue_id=body.venue_id, menu_id=body.menu_id, + menu_item_id=body.menu_item_id, kind=kind, file_ref=validate_file_ref(body.file_ref), + alt_text=body.alt_text, sort_order=body.sort_order or 0, + status=validate_lifecycle_status(body.status), metadata_json=body.metadata_json, + created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="menu_media_ref", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MENU_MEDIA_REF_CREATED, aggregate_type="menu_media_ref", aggregate_id=entity.id, tenant_id=tenant_id, payload={"file_ref": entity.file_ref}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> MenuMediaRef: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("رسانه منو یافت نشد", error_code="menu_media_ref_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class MenuLocalizationService(_CatalogBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MenuLocalizationRepository(session) + + async def upsert(self, tenant_id: UUID, body: MenuLocalizationUpsert, *, actor: CurrentUser | None = None) -> MenuLocalization: + await self._require_venue(tenant_id, body.venue_id) + target_type = body.target_type if isinstance(body.target_type, LocalizationTarget) else LocalizationTarget(body.target_type) + actor_id = actor.user_id if actor else None + locale = validate_locale(body.locale) + entity = await self.repo.get_locale(tenant_id, target_type, body.target_id, locale) + if entity is None: + entity = MenuLocalization( + tenant_id=tenant_id, venue_id=body.venue_id, target_type=target_type, + target_id=body.target_id, locale=locale, name=body.name, description=body.description, + status=validate_lifecycle_status(body.status), created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + action = AuditAction.CREATE + else: + entity.name = body.name + entity.description = body.description + entity.status = validate_lifecycle_status(body.status) + entity.updated_by = actor_id + action = AuditAction.UPDATE + await self.audit.record(tenant_id=tenant_id, entity_type="menu_localization", entity_id=entity.id, action=action, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MENU_LOCALIZATION_UPSERTED, aggregate_type="menu_localization", aggregate_id=entity.id, tenant_id=tenant_id, payload={"locale": entity.locale, "target_type": entity.target_type.value}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> MenuLocalization: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("ترجمه منو یافت نشد", error_code="menu_localization_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/hospitality/app/services/connectors.py b/backend/services/hospitality/app/services/connectors.py new file mode 100644 index 0000000..6d5cb31 --- /dev/null +++ b/backend/services/hospitality/app/services/connectors.py @@ -0,0 +1,235 @@ +"""Connector application services — Phase 12.7. + +Dispatches are simulated locally via mock provider implementations. No +httpx calls and no imports from other services — the mocks only prove out +the request/response contract shape until real connector SDKs land. +""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import HospitalityEventType +from app.models.connectors import ConnectorDispatch, ConnectorRegistration +from app.models.types import AuditAction, ConnectorStatus, DispatchStatus +from app.providers.mocks import ( + MockAccountingProvider, + MockCommunicationProvider, + MockCRMProvider, + MockDeliveryProvider, + MockLoyaltyProvider, + MockWebsiteBuilderProvider, +) +from app.repositories.connectors import ( + ConnectorDispatchRepository, + ConnectorRegistrationRepository, +) +from app.repositories.foundation import VenueRepository +from app.schemas.connectors import ConnectorDispatchCreate, ConnectorRegistrationCreate +from app.services.audit_service import AuditService +from app.validators import validate_code, validate_connector_status, validate_non_empty +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + +# One dispatcher per connector kind — all are local no-op mocks; hospitality +# never calls real provider SDKs directly. +_MOCK_DISPATCHERS = { + "delivery": MockDeliveryProvider(), + "accounting": MockAccountingProvider(), + "crm": MockCRMProvider(), + "loyalty": MockLoyaltyProvider(), + "communication": MockCommunicationProvider(), + "website": MockWebsiteBuilderProvider(), +} + + +class _ConnectorsBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.venues = VenueRepository(session) + + async def _require_venue(self, tenant_id: UUID, venue_id: UUID): + venue = await self.venues.get(tenant_id, venue_id) + if venue is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return venue + + +class ConnectorRegistrationService(_ConnectorsBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = ConnectorRegistrationRepository(session) + + async def create( + self, + tenant_id: UUID, + body: ConnectorRegistrationCreate, + *, + actor: CurrentUser | None = None, + ) -> ConnectorRegistration: + if body.venue_id is not None: + await self._require_venue(tenant_id, body.venue_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code) is not None: + raise AppError( + "کد رابط اتصال تکراری است", + status_code=409, + error_code="connector_registration_code_exists", + ) + actor_id = actor.user_id if actor else None + entity = ConnectorRegistration( + tenant_id=tenant_id, + venue_id=body.venue_id, + kind=body.kind, + code=code, + name=validate_non_empty(body.name, field="name"), + status=validate_connector_status(body.status), + external_ref=body.external_ref, + config=body.config, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="connector_registration", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.CONNECTOR_REGISTRATION_CREATED, + aggregate_type="connector_registration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "kind": entity.kind.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ConnectorRegistration: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError( + "رابط اتصال یافت نشد", error_code="connector_registration_not_found" + ) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class ConnectorDispatchService(_ConnectorsBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = ConnectorDispatchRepository(session) + self.registrations = ConnectorRegistrationRepository(session) + + async def create( + self, + tenant_id: UUID, + body: ConnectorDispatchCreate, + *, + actor: CurrentUser | None = None, + ) -> ConnectorDispatch: + if body.venue_id is not None: + await self._require_venue(tenant_id, body.venue_id) + registration = await self.registrations.get(tenant_id, body.registration_id) + if registration is None: + raise NotFoundError( + "رابط اتصال یافت نشد", error_code="connector_registration_not_found" + ) + event_type = validate_non_empty(body.event_type, field="event_type") + actor_id = actor.user_id if actor else None + + if registration.status != ConnectorStatus.ACTIVE: + status = DispatchStatus.FAILED + response = {"ok": False, "reason": "registration_not_active"} + else: + dispatcher = _MOCK_DISPATCHERS.get(registration.kind.value) + response = {"ok": True, "provider": "mock", "kind": registration.kind.value} + if dispatcher is not None: + if hasattr(dispatcher, "send"): + await dispatcher.send( + tenant_id=tenant_id, + channel="default", + template_key=event_type, + to=body.payload_ref or "", + payload={"event_type": event_type}, + ) + elif hasattr(dispatcher, "create_receivable_ref"): + response = await dispatcher.create_receivable_ref( + tenant_id=tenant_id, + reference=body.payload_ref or event_type, + payload={"event_type": event_type}, + ) + elif hasattr(dispatcher, "create_delivery_ref"): + response = await dispatcher.create_delivery_ref( + tenant_id=tenant_id, + reference=body.payload_ref or event_type, + payload={"event_type": event_type}, + ) + elif hasattr(dispatcher, "resolve_contact"): + response = await dispatcher.resolve_contact( + tenant_id=tenant_id, contact_ref=body.payload_ref or event_type + ) + elif hasattr(dispatcher, "resolve_member"): + response = await dispatcher.resolve_member( + tenant_id=tenant_id, member_ref=body.payload_ref or event_type + ) + elif hasattr(dispatcher, "resolve_site"): + response = await dispatcher.resolve_site( + tenant_id=tenant_id, site_ref=body.payload_ref or event_type + ) + status = DispatchStatus.SENT + + entity = ConnectorDispatch( + tenant_id=tenant_id, + venue_id=body.venue_id, + registration_id=body.registration_id, + direction=body.direction, + event_type=event_type, + payload_ref=body.payload_ref, + status=status, + response_json=response, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="connector_dispatch", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.CONNECTOR_DISPATCH_CREATED, + aggregate_type="connector_dispatch", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value, "event_type": entity.event_type}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ConnectorDispatch: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError( + "ارسال رابط اتصال یافت نشد", error_code="connector_dispatch_not_found" + ) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/hospitality/app/services/foundation.py b/backend/services/hospitality/app/services/foundation.py new file mode 100644 index 0000000..b32347c --- /dev/null +++ b/backend/services/hospitality/app/services/foundation.py @@ -0,0 +1,800 @@ +"""Hospitality foundation application services — Phase 12.0.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import HospitalityEventType +from app.models.foundation import ( + Branch, + BundleDefinition, + DiningArea, + DiningTable, + FeatureToggle, + HospitalityConfiguration, + HospitalityEvent, + HospitalityPermission, + HospitalityRole, + HospitalitySetting, + Menu, + MenuCategory, + MenuItem, + TenantBundle, + Venue, +) +from app.models.types import AuditAction, BundleStatus +from app.repositories.foundation import ( + BranchRepository, + BundleDefinitionRepository, + DiningAreaRepository, + DiningTableRepository, + FeatureToggleRepository, + HospitalityConfigurationRepository, + HospitalityEventRepository, + HospitalityPermissionRepository, + HospitalityRoleRepository, + HospitalitySettingRepository, + MenuCategoryRepository, + MenuItemRepository, + MenuRepository, + TenantBundleRepository, + VenueRepository, +) +from app.schemas.foundation import ( + BranchCreate, + BranchUpdate, + BundleDefinitionCreate, + DiningAreaCreate, + DiningTableCreate, + FeatureToggleUpsert, + HospitalityConfigurationCreate, + HospitalityConfigurationUpdate, + HospitalityEventCreate, + HospitalityPermissionCreate, + HospitalityRoleCreate, + HospitalitySettingUpsert, + MenuCategoryCreate, + MenuCreate, + MenuItemCreate, + MenuUpdate, + TenantBundleActivate, + VenueCreate, + VenueUpdate, +) +from app.services.audit_service import AuditService +from app.validators import ( + ensure_optimistic_version, + forbid_format_specific_engines, + validate_bundle_key, + validate_bundle_status, + validate_code, + validate_currency_code, + validate_feature_key, + validate_lifecycle_status, + validate_menu_status, + validate_non_empty, + validate_non_negative_int, + validate_setting_key, + validate_table_status, + validate_venue_format, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +class _BaseService: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.venues = VenueRepository(session) + + async def _require_venue(self, tenant_id: UUID, venue_id: UUID) -> Venue: + venue = await self.venues.get(tenant_id, venue_id) + if venue is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return venue + + +class VenueService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = VenueRepository(session) + + async def create(self, tenant_id: UUID, body: VenueCreate, *, actor: CurrentUser | None = None) -> Venue: + forbid_format_specific_engines(body.model_dump()) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code) is not None: + raise AppError("کد واحد پذیرایی تکراری است", status_code=409, error_code="venue_code_exists") + entity = Venue( + tenant_id=tenant_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + venue_format=validate_venue_format(body.venue_format), + legal_name=body.legal_name, + timezone=validate_non_empty(body.timezone, field="timezone"), + language=validate_non_empty(body.language, field="language"), + currency_code=validate_currency_code(body.currency_code), + settings=body.settings, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, entity_type="venue", entity_id=entity.id, + action=AuditAction.CREATE, actor_user_id=actor_id, + message=f"Venue {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.VENUE_CREATED, + aggregate_type="venue", aggregate_id=entity.id, tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value, "venue_format": entity.venue_format.value}, + ) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: VenueUpdate, *, actor: CurrentUser | None = None) -> Venue: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + if "venue_format" in data and data["venue_format"] is not None: + data["venue_format"] = validate_venue_format(data["venue_format"]) + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, entity_type="venue", entity_id=entity.id, + action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.VENUE_UPDATED, + aggregate_type="venue", aggregate_id=entity.id, tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Venue: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Venue: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record( + tenant_id=tenant_id, entity_type="venue", entity_id=entity.id, + action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class BranchService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = BranchRepository(session) + + async def create(self, tenant_id: UUID, body: BranchCreate, *, actor: CurrentUser | None = None) -> Branch: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError("کد شعبه تکراری است", status_code=409, error_code="branch_code_exists") + entity = Branch( + tenant_id=tenant_id, venue_id=body.venue_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), address=body.address, + timezone=body.timezone, working_hours=body.working_hours, + metadata_json=body.metadata_json, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="branch", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.BRANCH_CREATED, aggregate_type="branch", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code, "venue_id": str(entity.venue_id)}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: BranchUpdate, *, actor: CurrentUser | None = None) -> Branch: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="branch", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.BRANCH_UPDATED, aggregate_type="branch", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Branch: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("شعبه یافت نشد", error_code="branch_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Branch: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="branch", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class DiningAreaService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = DiningAreaRepository(session) + + async def create(self, tenant_id: UUID, body: DiningAreaCreate, *, actor: CurrentUser | None = None) -> DiningArea: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError("کد فضای پذیرایی تکراری است", status_code=409, error_code="dining_area_code_exists") + entity = DiningArea( + tenant_id=tenant_id, venue_id=body.venue_id, branch_id=body.branch_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), sort_order=body.sort_order or 0, + metadata_json=body.metadata_json, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="dining_area", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.DINING_AREA_CREATED, aggregate_type="dining_area", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> DiningArea: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("فضای پذیرایی یافت نشد", error_code="dining_area_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class DiningTableService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = DiningTableRepository(session) + + async def create(self, tenant_id: UUID, body: DiningTableCreate, *, actor: CurrentUser | None = None) -> DiningTable: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError("کد میز تکراری است", status_code=409, error_code="table_code_exists") + entity = DiningTable( + tenant_id=tenant_id, venue_id=body.venue_id, dining_area_id=body.dining_area_id, code=code, + name=validate_non_empty(body.name, field="name"), + capacity=validate_non_negative_int(body.capacity, field="capacity") or 2, + status=validate_table_status(body.status), qr_token=body.qr_token, + metadata_json=body.metadata_json, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="dining_table", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.TABLE_CREATED, aggregate_type="dining_table", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> DiningTable: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("میز یافت نشد", error_code="table_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class MenuService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MenuRepository(session) + + async def create(self, tenant_id: UUID, body: MenuCreate, *, actor: CurrentUser | None = None) -> Menu: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError("کد منو تکراری است", status_code=409, error_code="menu_code_exists") + entity = Menu( + tenant_id=tenant_id, venue_id=body.venue_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_menu_status(body.status), + currency_code=validate_currency_code(body.currency_code), + is_default=bool(body.is_default), metadata_json=body.metadata_json, + created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="menu", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MENU_CREATED, aggregate_type="menu", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: MenuUpdate, *, actor: CurrentUser | None = None) -> Menu: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_menu_status(data["status"]) + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="menu", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MENU_UPDATED, aggregate_type="menu", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Menu: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("منو یافت نشد", error_code="menu_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Menu: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="menu", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class MenuCategoryService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MenuCategoryRepository(session) + self.menus = MenuRepository(session) + + async def create(self, tenant_id: UUID, body: MenuCategoryCreate, *, actor: CurrentUser | None = None) -> MenuCategory: + await self._require_venue(tenant_id, body.venue_id) + menu = await self.menus.get(tenant_id, body.menu_id) + if menu is None: + raise NotFoundError("منو یافت نشد", error_code="menu_not_found") + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.menu_id, code) is not None: + raise AppError("کد دسته منو تکراری است", status_code=409, error_code="menu_category_code_exists") + entity = MenuCategory( + tenant_id=tenant_id, venue_id=body.venue_id, menu_id=body.menu_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), sort_order=body.sort_order or 0, + created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="menu_category", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MENU_CATEGORY_CREATED, aggregate_type="menu_category", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> MenuCategory: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("دسته منو یافت نشد", error_code="menu_category_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class MenuItemService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MenuItemRepository(session) + self.menus = MenuRepository(session) + + async def create(self, tenant_id: UUID, body: MenuItemCreate, *, actor: CurrentUser | None = None) -> MenuItem: + await self._require_venue(tenant_id, body.venue_id) + menu = await self.menus.get(tenant_id, body.menu_id) + if menu is None: + raise NotFoundError("منو یافت نشد", error_code="menu_not_found") + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.menu_id, code) is not None: + raise AppError("کد آیتم منو تکراری است", status_code=409, error_code="menu_item_code_exists") + entity = MenuItem( + tenant_id=tenant_id, venue_id=body.venue_id, menu_id=body.menu_id, category_id=body.category_id, + code=code, name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), + base_price=validate_non_negative_int(body.base_price, field="base_price") or 0, + currency_code=validate_currency_code(body.currency_code), + is_available=bool(body.is_available), sort_order=body.sort_order or 0, + attributes=body.attributes, + preparation_minutes=validate_non_negative_int(body.preparation_minutes, field="preparation_minutes"), + calories=validate_non_negative_int(body.calories, field="calories"), + spicy_level=validate_non_negative_int(body.spicy_level, field="spicy_level") or 0, + is_vegetarian=bool(body.is_vegetarian), + is_vegan=bool(body.is_vegan), + is_gluten_free=bool(body.is_gluten_free), + tags=body.tags, + primary_media_ref=body.primary_media_ref, + created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="menu_item", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.MENU_ITEM_CREATED, aggregate_type="menu_item", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> MenuItem: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("آیتم منو یافت نشد", error_code="menu_item_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class BundleDefinitionService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = BundleDefinitionRepository(session) + + async def create(self, tenant_id: UUID, body: BundleDefinitionCreate, *, actor: CurrentUser | None = None) -> BundleDefinition: + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + bundle_key = validate_bundle_key(body.bundle_key) + if await self.repo.get_by_code(tenant_id, code) is not None: + raise AppError("کد بسته تکراری است", status_code=409, error_code="bundle_code_exists") + if await self.repo.get_by_key(tenant_id, bundle_key) is not None: + raise AppError("کلید بسته تکراری است", status_code=409, error_code="bundle_key_exists") + entity = BundleDefinition( + tenant_id=tenant_id, code=code, bundle_key=bundle_key, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_bundle_status(body.status), feature_keys=body.feature_keys, + permission_prefixes=body.permission_prefixes, api_prefixes=body.api_prefixes, + metadata_json=body.metadata_json, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="bundle_definition", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.BUNDLE_DEFINITION_CREATED, aggregate_type="bundle_definition", aggregate_id=entity.id, tenant_id=tenant_id, payload={"bundle_key": entity.bundle_key.value}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> BundleDefinition: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("تعریف بسته یافت نشد", error_code="bundle_definition_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class TenantBundleService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = TenantBundleRepository(session) + self.definitions = BundleDefinitionRepository(session) + + async def activate(self, tenant_id: UUID, body: TenantBundleActivate, *, actor: CurrentUser | None = None) -> TenantBundle: + definition = await self.definitions.get(tenant_id, body.bundle_definition_id) + if definition is None: + raise NotFoundError("تعریف بسته یافت نشد", error_code="bundle_definition_not_found") + if body.venue_id is not None: + await self._require_venue(tenant_id, body.venue_id) + existing = await self.repo.get_active(tenant_id, definition.bundle_key, body.venue_id) + if existing is not None: + raise AppError("بسته قبلاً فعال است", status_code=409, error_code="tenant_bundle_active") + actor_id = actor.user_id if actor else None + entity = TenantBundle( + tenant_id=tenant_id, venue_id=body.venue_id, bundle_definition_id=definition.id, + bundle_key=definition.bundle_key, status=BundleStatus.ACTIVE, activated_by=actor_id, + metadata_json=body.metadata_json, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="tenant_bundle", entity_id=entity.id, action=AuditAction.ACTIVATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.TENANT_BUNDLE_ACTIVATED, aggregate_type="tenant_bundle", aggregate_id=entity.id, tenant_id=tenant_id, payload={"bundle_key": entity.bundle_key.value}) + return entity + + async def deactivate(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> TenantBundle: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("بسته فعال یافت نشد", error_code="tenant_bundle_not_found") + entity.status = BundleStatus.SUSPENDED + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="tenant_bundle", entity_id=entity.id, action=AuditAction.DEACTIVATE, actor_user_id=entity.updated_by) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.TENANT_BUNDLE_DEACTIVATED, aggregate_type="tenant_bundle", aggregate_id=entity.id, tenant_id=tenant_id, payload={"bundle_key": entity.bundle_key.value}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> TenantBundle: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("بسته فعال یافت نشد", error_code="tenant_bundle_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def list_active(self, tenant_id: UUID, venue_id: UUID | None = None): + return await self.repo.list_active(tenant_id, venue_id) + + +class FeatureToggleService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = FeatureToggleRepository(session) + + async def upsert(self, tenant_id: UUID, body: FeatureToggleUpsert, *, actor: CurrentUser | None = None) -> FeatureToggle: + if body.venue_id is not None: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + key = validate_feature_key(body.feature_key) + entity = await self.repo.get_by_key(tenant_id, key, body.venue_id) + if entity is None: + entity = FeatureToggle( + tenant_id=tenant_id, venue_id=body.venue_id, feature_key=key, enabled=bool(body.enabled), + bundle_key=validate_bundle_key(body.bundle_key) if body.bundle_key else None, + metadata_json=body.metadata_json, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + action = AuditAction.CREATE + else: + entity.enabled = bool(body.enabled) + if body.bundle_key is not None: + entity.bundle_key = validate_bundle_key(body.bundle_key) + if body.metadata_json is not None: + entity.metadata_json = body.metadata_json + entity.updated_by = actor_id + action = AuditAction.UPDATE + await self.audit.record(tenant_id=tenant_id, entity_type="feature_toggle", entity_id=entity.id, action=action, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.FEATURE_TOGGLE_UPSERTED, aggregate_type="feature_toggle", aggregate_id=entity.id, tenant_id=tenant_id, payload={"feature_key": entity.feature_key, "enabled": entity.enabled}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> FeatureToggle: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("سوییچ قابلیت یافت نشد", error_code="feature_toggle_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def list_enabled(self, tenant_id: UUID, venue_id: UUID | None = None): + return await self.repo.list_enabled(tenant_id, venue_id) + + +class HospitalityRoleService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = HospitalityRoleRepository(session) + + async def create(self, tenant_id: UUID, body: HospitalityRoleCreate, *, actor: CurrentUser | None = None) -> HospitalityRole: + if body.venue_id is not None: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code) is not None: + raise AppError("کد نقش تکراری است", status_code=409, error_code="role_code_exists") + entity = HospitalityRole( + tenant_id=tenant_id, venue_id=body.venue_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), permission_keys=body.permission_keys, + created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="hospitality_role", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.ROLE_CREATED, aggregate_type="hospitality_role", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> HospitalityRole: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("نقش یافت نشد", error_code="role_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class HospitalityPermissionService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = HospitalityPermissionRepository(session) + + async def create(self, tenant_id: UUID, body: HospitalityPermissionCreate, *, actor: CurrentUser | None = None) -> HospitalityPermission: + actor_id = actor.user_id if actor else None + code = validate_non_empty(body.code, field="code") + if await self.repo.get_by_code(tenant_id, code) is not None: + raise AppError("کد مجوز تکراری است", status_code=409, error_code="permission_code_exists") + entity = HospitalityPermission( + tenant_id=tenant_id, code=code, name=validate_non_empty(body.name, field="name"), + description=body.description, + bundle_key=validate_bundle_key(body.bundle_key) if body.bundle_key else None, + status=validate_lifecycle_status(body.status), created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="hospitality_permission", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.PERMISSION_CREATED, aggregate_type="hospitality_permission", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> HospitalityPermission: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("مجوز یافت نشد", error_code="permission_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class HospitalityConfigurationService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = HospitalityConfigurationRepository(session) + + async def create(self, tenant_id: UUID, body: HospitalityConfigurationCreate, *, actor: CurrentUser | None = None) -> HospitalityConfiguration: + if body.venue_id is not None: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code) is not None: + raise AppError("کد پیکربندی تکراری است", status_code=409, error_code="configuration_code_exists") + entity = HospitalityConfiguration( + tenant_id=tenant_id, venue_id=body.venue_id, branch_id=body.branch_id, code=code, + name=validate_non_empty(body.name, field="name"), config=body.config, + status=validate_lifecycle_status(body.status), created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="hospitality_configuration", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.CONFIGURATION_CREATED, aggregate_type="hospitality_configuration", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: HospitalityConfigurationUpdate, *, actor: CurrentUser | None = None) -> HospitalityConfiguration: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="hospitality_configuration", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.CONFIGURATION_UPDATED, aggregate_type="hospitality_configuration", aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> HospitalityConfiguration: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("پیکربندی یافت نشد", error_code="configuration_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class HospitalityEventService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = HospitalityEventRepository(session) + + async def create(self, tenant_id: UUID, body: HospitalityEventCreate, *, actor: CurrentUser | None = None) -> HospitalityEvent: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + entity = HospitalityEvent( + tenant_id=tenant_id, venue_id=body.venue_id, + event_type=validate_non_empty(body.event_type, field="event_type"), + title=validate_non_empty(body.title, field="title"), payload=body.payload, + status=validate_lifecycle_status(body.status), created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="hospitality_event", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.HOSPITALITY_EVENT_CREATED, aggregate_type="hospitality_event", aggregate_id=entity.id, tenant_id=tenant_id, payload={"event_type": entity.event_type}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> HospitalityEvent: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("رویداد یافت نشد", error_code="event_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class HospitalitySettingService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = HospitalitySettingRepository(session) + + async def upsert(self, tenant_id: UUID, body: HospitalitySettingUpsert, *, actor: CurrentUser | None = None) -> HospitalitySetting: + if body.venue_id is not None: + await self._require_venue(tenant_id, body.venue_id) + actor_id = actor.user_id if actor else None + key = validate_setting_key(body.key) + entity = await self.repo.get_by_key(tenant_id, key, body.venue_id) + if entity is None: + entity = HospitalitySetting( + tenant_id=tenant_id, venue_id=body.venue_id, key=key, value=body.value, + created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + action = AuditAction.CREATE + else: + entity.value = body.value + entity.updated_by = actor_id + action = AuditAction.UPDATE + await self.audit.record(tenant_id=tenant_id, entity_type="hospitality_setting", entity_id=entity.id, action=action, actor_user_id=actor_id) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish(event_type=HospitalityEventType.SETTING_UPSERTED, aggregate_type="hospitality_setting", aggregate_id=entity.id, tenant_id=tenant_id, payload={"key": entity.key}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> HospitalitySetting: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("تنظیمات یافت نشد", error_code="setting_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/hospitality/app/services/kitchen.py b/backend/services/hospitality/app/services/kitchen.py new file mode 100644 index 0000000..d990a01 --- /dev/null +++ b/backend/services/hospitality/app/services/kitchen.py @@ -0,0 +1,304 @@ +"""Kitchen application services — Phase 12.6. + +Kitchen display / ticket routing shell. Tickets reference their POS +ticket / QR ordering session source by UUID only — no cross-aggregate +relationships or direct writes into POS/QR tables. +""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import HospitalityEventType +from app.models.kitchen import KitchenStation, KitchenTicket, KitchenTicketItem +from app.models.types import AuditAction, KitchenItemStatus, KitchenTicketStatus +from app.repositories.foundation import VenueRepository +from app.repositories.kitchen import ( + KitchenStationRepository, + KitchenTicketItemRepository, + KitchenTicketRepository, +) +from app.schemas.kitchen import ( + KitchenStationCreate, + KitchenTicketCreate, + KitchenTicketItemCreate, +) +from app.services.audit_service import AuditService +from app.validators import ( + validate_code, + validate_kitchen_item_status, + validate_kitchen_ticket_status, + validate_lifecycle_status, + validate_non_empty, + validate_positive_int, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + +KITCHEN_TICKET_TERMINAL_STATUSES = frozenset( + {KitchenTicketStatus.BUMPED, KitchenTicketStatus.CANCELLED} +) +KITCHEN_ITEM_TERMINAL_STATUSES = frozenset( + {KitchenItemStatus.BUMPED, KitchenItemStatus.CANCELLED} +) + + +class _KitchenBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.venues = VenueRepository(session) + + async def _require_venue(self, tenant_id: UUID, venue_id: UUID): + venue = await self.venues.get(tenant_id, venue_id) + if venue is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return venue + + +class KitchenStationService(_KitchenBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = KitchenStationRepository(session) + + async def create( + self, tenant_id: UUID, body: KitchenStationCreate, *, actor: CurrentUser | None = None + ) -> KitchenStation: + await self._require_venue(tenant_id, body.venue_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد ایستگاه آشپزخانه تکراری است", + status_code=409, + error_code="kitchen_station_code_exists", + ) + actor_id = actor.user_id if actor else None + entity = KitchenStation( + tenant_id=tenant_id, + venue_id=body.venue_id, + code=code, + name=validate_non_empty(body.name, field="name"), + status=validate_lifecycle_status(body.status), + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="kitchen_station", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.KITCHEN_STATION_CREATED, + aggregate_type="kitchen_station", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> KitchenStation: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("ایستگاه آشپزخانه یافت نشد", error_code="kitchen_station_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class KitchenTicketService(_KitchenBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = KitchenTicketRepository(session) + self.stations = KitchenStationRepository(session) + + async def create( + self, tenant_id: UUID, body: KitchenTicketCreate, *, actor: CurrentUser | None = None + ) -> KitchenTicket: + await self._require_venue(tenant_id, body.venue_id) + if body.station_id is not None and await self.stations.get(tenant_id, body.station_id) is None: + raise NotFoundError("ایستگاه آشپزخانه یافت نشد", error_code="kitchen_station_not_found") + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد تیکت آشپزخانه تکراری است", + status_code=409, + error_code="kitchen_ticket_code_exists", + ) + actor_id = actor.user_id if actor else None + entity = KitchenTicket( + tenant_id=tenant_id, + venue_id=body.venue_id, + station_id=body.station_id, + source_type=body.source_type, + source_id=body.source_id, + code=code, + status=KitchenTicketStatus.QUEUED, + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="kitchen_ticket", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.KITCHEN_TICKET_CREATED, + aggregate_type="kitchen_ticket", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "source_type": entity.source_type.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> KitchenTicket: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("تیکت آشپزخانه یافت نشد", error_code="kitchen_ticket_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def change_status( + self, + tenant_id: UUID, + entity_id: UUID, + new_status: KitchenTicketStatus | str, + *, + actor: CurrentUser | None = None, + ) -> KitchenTicket: + entity = await self.get(tenant_id, entity_id) + resolved = validate_kitchen_ticket_status(new_status) + if entity.status in KITCHEN_TICKET_TERMINAL_STATUSES: + raise AppError( + "وضعیت تیکت آشپزخانه نهایی شده و قابل تغییر نیست", + status_code=409, + error_code="kitchen_ticket_status_final", + ) + actor_id = actor.user_id if actor else None + entity.status = resolved + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="kitchen_ticket", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=actor_id, + changes={"status": resolved}, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.KITCHEN_TICKET_STATUS_CHANGED, + aggregate_type="kitchen_ticket", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + return entity + + +class KitchenTicketItemService(_KitchenBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = KitchenTicketItemRepository(session) + self.tickets = KitchenTicketRepository(session) + + async def create( + self, tenant_id: UUID, body: KitchenTicketItemCreate, *, actor: CurrentUser | None = None + ) -> KitchenTicketItem: + await self._require_venue(tenant_id, body.venue_id) + if await self.tickets.get(tenant_id, body.kitchen_ticket_id) is None: + raise NotFoundError("تیکت آشپزخانه یافت نشد", error_code="kitchen_ticket_not_found") + quantity = validate_positive_int(body.quantity, field="quantity") + actor_id = actor.user_id if actor else None + entity = KitchenTicketItem( + tenant_id=tenant_id, + venue_id=body.venue_id, + kitchen_ticket_id=body.kitchen_ticket_id, + menu_item_id=body.menu_item_id, + quantity=quantity, + status=KitchenItemStatus.QUEUED, + sort_order=body.sort_order or 0, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="kitchen_ticket_item", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.KITCHEN_TICKET_ITEM_ADDED, + aggregate_type="kitchen_ticket_item", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"kitchen_ticket_id": str(entity.kitchen_ticket_id)}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> KitchenTicketItem: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError( + "آیتم تیکت آشپزخانه یافت نشد", error_code="kitchen_ticket_item_not_found" + ) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def change_status( + self, + tenant_id: UUID, + entity_id: UUID, + new_status: KitchenItemStatus | str, + *, + actor: CurrentUser | None = None, + ) -> KitchenTicketItem: + entity = await self.get(tenant_id, entity_id) + resolved = validate_kitchen_item_status(new_status) + if entity.status in KITCHEN_ITEM_TERMINAL_STATUSES: + raise AppError( + "وضعیت آیتم آشپزخانه نهایی شده و قابل تغییر نیست", + status_code=409, + error_code="kitchen_ticket_item_status_final", + ) + actor_id = actor.user_id if actor else None + entity.status = resolved + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="kitchen_ticket_item", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=actor_id, + changes={"status": resolved}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/hospitality/app/services/pos_lite.py b/backend/services/hospitality/app/services/pos_lite.py new file mode 100644 index 0000000..b469a30 --- /dev/null +++ b/backend/services/hospitality/app/services/pos_lite.py @@ -0,0 +1,369 @@ +"""POS Lite application services — Phase 12.4. + +Lightweight point-of-sale shell only: registers, shifts, tickets, and +ticket lines. No payment posting, no accounting entries, no kitchen +dispatch. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import HospitalityEventType +from app.models.pos_lite import PosRegister, PosShift, PosTicket, PosTicketLine +from app.models.types import AuditAction, LifecycleStatus, PosShiftStatus, PosTicketStatus +from app.repositories.foundation import MenuItemRepository, VenueRepository +from app.repositories.pos_lite import ( + PosRegisterRepository, + PosShiftRepository, + PosTicketLineRepository, + PosTicketRepository, +) +from app.schemas.pos_lite import ( + PosRegisterCreate, + PosShiftCreate, + PosTicketCreate, + PosTicketLineCreate, +) +from app.services.audit_service import AuditService +from app.validators import ( + validate_code, + validate_currency_code, + validate_lifecycle_status, + validate_non_empty, + validate_positive_int, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class _PosLiteBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.venues = VenueRepository(session) + + async def _require_venue(self, tenant_id: UUID, venue_id: UUID): + venue = await self.venues.get(tenant_id, venue_id) + if venue is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return venue + + +class PosRegisterService(_PosLiteBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PosRegisterRepository(session) + + async def create( + self, tenant_id: UUID, body: PosRegisterCreate, *, actor: CurrentUser | None = None + ) -> PosRegister: + await self._require_venue(tenant_id, body.venue_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد صندوق تکراری است", status_code=409, error_code="pos_register_code_exists" + ) + actor_id = actor.user_id if actor else None + entity = PosRegister( + tenant_id=tenant_id, + venue_id=body.venue_id, + code=code, + name=validate_non_empty(body.name, field="name"), + status=validate_lifecycle_status(body.status), + description=body.description, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_register", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_REGISTER_CREATED, + aggregate_type="pos_register", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> PosRegister: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("صندوق یافت نشد", error_code="pos_register_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class PosShiftService(_PosLiteBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PosShiftRepository(session) + self.registers = PosRegisterRepository(session) + + async def create( + self, tenant_id: UUID, body: PosShiftCreate, *, actor: CurrentUser | None = None + ) -> PosShift: + await self._require_venue(tenant_id, body.venue_id) + if await self.registers.get(tenant_id, body.register_id) is None: + raise NotFoundError("صندوق یافت نشد", error_code="pos_register_not_found") + open_shifts = await self.repo.list_open_for_register(tenant_id, body.register_id) + if open_shifts: + raise AppError( + "صندوق در حال حاضر شیفت باز دارد", + status_code=409, + error_code="pos_shift_already_open", + ) + actor_id = actor.user_id if actor else None + entity = PosShift( + tenant_id=tenant_id, + venue_id=body.venue_id, + register_id=body.register_id, + opened_by=body.opened_by, + status=PosShiftStatus.OPEN, + opened_at=datetime.now(timezone.utc), + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_shift", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_SHIFT_OPENED, + aggregate_type="pos_shift", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"register_id": str(entity.register_id)}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> PosShift: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("شیفت یافت نشد", error_code="pos_shift_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def close( + self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None + ) -> PosShift: + entity = await self.get(tenant_id, entity_id) + if entity.status != PosShiftStatus.OPEN: + raise AppError( + "شیفت باز نیست", status_code=409, error_code="pos_shift_not_open" + ) + actor_id = actor.user_id if actor else None + entity.status = PosShiftStatus.CLOSED + entity.closed_at = datetime.now(timezone.utc) + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_shift", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=actor_id, + changes={"status": entity.status}, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_SHIFT_CLOSED, + aggregate_type="pos_shift", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"register_id": str(entity.register_id)}, + ) + return entity + + +class PosTicketService(_PosLiteBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PosTicketRepository(session) + self.registers = PosRegisterRepository(session) + self.shifts = PosShiftRepository(session) + + async def create( + self, tenant_id: UUID, body: PosTicketCreate, *, actor: CurrentUser | None = None + ) -> PosTicket: + await self._require_venue(tenant_id, body.venue_id) + if await self.registers.get(tenant_id, body.register_id) is None: + raise NotFoundError("صندوق یافت نشد", error_code="pos_register_not_found") + if body.shift_id is not None and await self.shifts.get(tenant_id, body.shift_id) is None: + raise NotFoundError("شیفت یافت نشد", error_code="pos_shift_not_found") + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد فیش تکراری است", status_code=409, error_code="pos_ticket_code_exists" + ) + actor_id = actor.user_id if actor else None + entity = PosTicket( + tenant_id=tenant_id, + venue_id=body.venue_id, + register_id=body.register_id, + shift_id=body.shift_id, + table_id=body.table_id, + ordering_session_id=body.ordering_session_id, + code=code, + status=PosTicketStatus.OPEN, + currency_code=validate_currency_code(body.currency_code), + notes=body.notes, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_ticket", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_TICKET_CREATED, + aggregate_type="pos_ticket", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> PosTicket: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("فیش یافت نشد", error_code="pos_ticket_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def void( + self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None + ) -> PosTicket: + entity = await self.get(tenant_id, entity_id) + if entity.status in (PosTicketStatus.VOID, PosTicketStatus.PAID, PosTicketStatus.CANCELLED): + raise AppError( + "وضعیت فیش نهایی شده و قابل باطل شدن نیست", + status_code=409, + error_code="pos_ticket_status_final", + ) + actor_id = actor.user_id if actor else None + entity.status = PosTicketStatus.VOID + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_ticket", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=actor_id, + changes={"status": entity.status}, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_TICKET_VOIDED, + aggregate_type="pos_ticket", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + +class PosTicketLineService(_PosLiteBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PosTicketLineRepository(session) + self.tickets = PosTicketRepository(session) + self.items = MenuItemRepository(session) + + async def add( + self, tenant_id: UUID, body: PosTicketLineCreate, *, actor: CurrentUser | None = None + ) -> PosTicketLine: + await self._require_venue(tenant_id, body.venue_id) + ticket = await self.tickets.get(tenant_id, body.ticket_id) + if ticket is None: + raise NotFoundError("فیش یافت نشد", error_code="pos_ticket_not_found") + if ticket.status not in (PosTicketStatus.DRAFT, PosTicketStatus.OPEN): + raise AppError( + "فیش برای افزودن آیتم باز نیست", status_code=409, error_code="pos_ticket_not_open" + ) + item = await self.items.get(tenant_id, body.menu_item_id) + if item is None: + raise NotFoundError("آیتم منو یافت نشد", error_code="menu_item_not_found") + quantity = validate_positive_int(body.quantity, field="quantity") + actor_id = actor.user_id if actor else None + entity = PosTicketLine( + tenant_id=tenant_id, + venue_id=body.venue_id, + ticket_id=body.ticket_id, + menu_item_id=body.menu_item_id, + quantity=quantity, + unit_price=item.base_price, + currency_code=item.currency_code, + modifier_option_ids=( + [str(v) for v in body.modifier_option_ids] + if body.modifier_option_ids + else None + ), + line_notes=body.line_notes, + sort_order=body.sort_order or 0, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_ticket_line", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_TICKET_LINE_ADDED, + aggregate_type="pos_ticket_line", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"ticket_id": str(entity.ticket_id), "menu_item_id": str(entity.menu_item_id)}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> PosTicketLine: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("خط فیش یافت نشد", error_code="pos_ticket_line_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/hospitality/app/services/pos_pro.py b/backend/services/hospitality/app/services/pos_pro.py new file mode 100644 index 0000000..2c22c64 --- /dev/null +++ b/backend/services/hospitality/app/services/pos_pro.py @@ -0,0 +1,362 @@ +"""POS Pro application services — Phase 12.5. + +Payments, discounts, tax lines, floor plans, and stations. Payment +records are local only — no accounting posting. +""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import HospitalityEventType +from app.models.pos_pro import PosDiscount, PosFloorPlan, PosPayment, PosStation, PosTaxLine +from app.models.types import AuditAction, LifecycleStatus, PosDiscountKind +from app.repositories.foundation import DiningAreaRepository, VenueRepository +from app.repositories.pos_lite import PosTicketRepository +from app.repositories.pos_pro import ( + PosDiscountRepository, + PosFloorPlanRepository, + PosPaymentRepository, + PosStationRepository, + PosTaxLineRepository, +) +from app.schemas.pos_pro import ( + PosDiscountCreate, + PosFloorPlanCreate, + PosPaymentCreate, + PosStationCreate, + PosTaxLineCreate, +) +from app.services.audit_service import AuditService +from app.validators import ( + validate_code, + validate_currency_code, + validate_lifecycle_status, + validate_non_empty, + validate_non_negative_int, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class _PosProBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.venues = VenueRepository(session) + self.tickets = PosTicketRepository(session) + + async def _require_venue(self, tenant_id: UUID, venue_id: UUID): + venue = await self.venues.get(tenant_id, venue_id) + if venue is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return venue + + async def _require_ticket(self, tenant_id: UUID, ticket_id: UUID): + ticket = await self.tickets.get(tenant_id, ticket_id) + if ticket is None: + raise NotFoundError("فیش یافت نشد", error_code="pos_ticket_not_found") + return ticket + + +class PosPaymentService(_PosProBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PosPaymentRepository(session) + + async def create( + self, tenant_id: UUID, body: PosPaymentCreate, *, actor: CurrentUser | None = None + ) -> PosPayment: + await self._require_venue(tenant_id, body.venue_id) + await self._require_ticket(tenant_id, body.ticket_id) + amount = validate_non_negative_int(body.amount, field="amount") + if amount is None or amount <= 0: + raise AppError( + "مبلغ پرداخت باید بیشتر از صفر باشد", + status_code=422, + error_code="invalid_amount", + details={"field": "amount"}, + ) + actor_id = actor.user_id if actor else None + entity = PosPayment( + tenant_id=tenant_id, + venue_id=body.venue_id, + ticket_id=body.ticket_id, + method=body.method, + amount=amount, + currency_code=validate_currency_code(body.currency_code), + external_payment_ref=body.external_payment_ref, + status=LifecycleStatus.ACTIVE, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_payment", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_PAYMENT_RECORDED, + aggregate_type="pos_payment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"ticket_id": str(entity.ticket_id), "amount": entity.amount}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> PosPayment: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("پرداخت یافت نشد", error_code="pos_payment_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class PosDiscountService(_PosProBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PosDiscountRepository(session) + + async def create( + self, tenant_id: UUID, body: PosDiscountCreate, *, actor: CurrentUser | None = None + ) -> PosDiscount: + await self._require_venue(tenant_id, body.venue_id) + await self._require_ticket(tenant_id, body.ticket_id) + code = validate_code(body.code) + if await self.repo.get_by_code_for_ticket(tenant_id, body.ticket_id, code) is not None: + raise AppError( + "کد تخفیف تکراری است", status_code=409, error_code="pos_discount_code_exists" + ) + value = validate_non_negative_int(body.value, field="value") + if value is None: + value = 0 + if body.kind == PosDiscountKind.PERCENT and value > 100: + raise AppError( + "درصد تخفیف نمی‌تواند بیشتر از ۱۰۰ باشد", + status_code=422, + error_code="invalid_discount_percent", + ) + actor_id = actor.user_id if actor else None + entity = PosDiscount( + tenant_id=tenant_id, + venue_id=body.venue_id, + ticket_id=body.ticket_id, + code=code, + kind=body.kind, + value=value, + name=body.name, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_discount", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_DISCOUNT_APPLIED, + aggregate_type="pos_discount", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"ticket_id": str(entity.ticket_id), "code": entity.code}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> PosDiscount: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("تخفیف یافت نشد", error_code="pos_discount_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class PosTaxLineService(_PosProBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PosTaxLineRepository(session) + + async def create( + self, tenant_id: UUID, body: PosTaxLineCreate, *, actor: CurrentUser | None = None + ) -> PosTaxLine: + await self._require_venue(tenant_id, body.venue_id) + await self._require_ticket(tenant_id, body.ticket_id) + rate_bps = validate_non_negative_int(body.rate_bps, field="rate_bps") or 0 + amount = validate_non_negative_int(body.amount, field="amount") or 0 + actor_id = actor.user_id if actor else None + entity = PosTaxLine( + tenant_id=tenant_id, + venue_id=body.venue_id, + ticket_id=body.ticket_id, + code=validate_code(body.code), + name=validate_non_empty(body.name, field="name"), + rate_bps=rate_bps, + amount=amount, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_tax_line", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_TAX_LINE_ADDED, + aggregate_type="pos_tax_line", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"ticket_id": str(entity.ticket_id), "code": entity.code}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> PosTaxLine: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("سطر مالیات یافت نشد", error_code="pos_tax_line_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class PosFloorPlanService(_PosProBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PosFloorPlanRepository(session) + self.dining_areas = DiningAreaRepository(session) + + async def create( + self, tenant_id: UUID, body: PosFloorPlanCreate, *, actor: CurrentUser | None = None + ) -> PosFloorPlan: + await self._require_venue(tenant_id, body.venue_id) + if body.dining_area_id is not None and await self.dining_areas.get( + tenant_id, body.dining_area_id + ) is None: + raise NotFoundError("فضای پذیرایی یافت نشد", error_code="dining_area_not_found") + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد پلان طبقه تکراری است", status_code=409, error_code="pos_floor_plan_code_exists" + ) + actor_id = actor.user_id if actor else None + entity = PosFloorPlan( + tenant_id=tenant_id, + venue_id=body.venue_id, + code=code, + name=validate_non_empty(body.name, field="name"), + dining_area_id=body.dining_area_id, + status=validate_lifecycle_status(body.status), + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_floor_plan", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_FLOOR_PLAN_CREATED, + aggregate_type="pos_floor_plan", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> PosFloorPlan: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("پلان طبقه یافت نشد", error_code="pos_floor_plan_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class PosStationService(_PosProBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PosStationRepository(session) + self.floor_plans = PosFloorPlanRepository(session) + + async def create( + self, tenant_id: UUID, body: PosStationCreate, *, actor: CurrentUser | None = None + ) -> PosStation: + await self._require_venue(tenant_id, body.venue_id) + if body.floor_plan_id is not None and await self.floor_plans.get( + tenant_id, body.floor_plan_id + ) is None: + raise NotFoundError("پلان طبقه یافت نشد", error_code="pos_floor_plan_not_found") + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد ایستگاه تکراری است", status_code=409, error_code="pos_station_code_exists" + ) + actor_id = actor.user_id if actor else None + entity = PosStation( + tenant_id=tenant_id, + venue_id=body.venue_id, + floor_plan_id=body.floor_plan_id, + code=code, + name=validate_non_empty(body.name, field="name"), + status=validate_lifecycle_status(body.status), + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="pos_station", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.POS_STATION_CREATED, + aggregate_type="pos_station", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> PosStation: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("ایستگاه یافت نشد", error_code="pos_station_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/hospitality/app/services/qr.py b/backend/services/hospitality/app/services/qr.py new file mode 100644 index 0000000..156db17 --- /dev/null +++ b/backend/services/hospitality/app/services/qr.py @@ -0,0 +1,477 @@ +"""QR Menu & QR Ordering application services — Phase 12.2. + +Shells only: no POS / kitchen / payment posting. Submitting an ordering +session merely flips its status to SUBMITTED for downstream consumers. +""" +from __future__ import annotations + +import secrets +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import HospitalityEventType +from app.models.qr import Cart, CartLine, QrCode, QrMenuSession, QrOrderingSession +from app.models.types import ( + AuditAction, + CartStatus, + LifecycleStatus, + OrderingSessionStatus, + QrSessionStatus, + QrTargetType, +) +from app.repositories.foundation import MenuItemRepository, MenuRepository, VenueRepository +from app.repositories.qr import ( + CartLineRepository, + CartRepository, + QrCodeRepository, + QrMenuSessionRepository, + QrOrderingSessionRepository, +) +from app.schemas.qr import ( + CartCreate, + CartLineCreate, + QrCodeCreate, + QrMenuSessionCreate, + QrOrderingSessionCreate, +) +from app.services.audit_service import AuditService +from app.validators import ( + validate_code, + validate_currency_code, + validate_lifecycle_status, + validate_non_empty, + validate_positive_int, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + +TOKEN_BYTES = 24 + + +class _QrBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.venues = VenueRepository(session) + + async def _require_venue(self, tenant_id: UUID, venue_id: UUID): + venue = await self.venues.get(tenant_id, venue_id) + if venue is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return venue + + +class QrCodeService(_QrBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = QrCodeRepository(session) + self.menus = MenuRepository(session) + + async def create( + self, tenant_id: UUID, body: QrCodeCreate, *, actor: CurrentUser | None = None + ) -> QrCode: + await self._require_venue(tenant_id, body.venue_id) + target_type = ( + body.target_type + if isinstance(body.target_type, QrTargetType) + else QrTargetType(body.target_type) + ) + if target_type == QrTargetType.MENU: + if body.menu_id is None: + raise AppError( + "menu_id برای نوع menu الزامی است", + status_code=422, + error_code="qr_menu_id_required", + ) + if await self.menus.get(tenant_id, body.menu_id) is None: + raise NotFoundError("منو یافت نشد", error_code="menu_not_found") + if target_type == QrTargetType.TABLE and body.table_id is None: + raise AppError( + "table_id برای نوع table الزامی است", + status_code=422, + error_code="qr_table_id_required", + ) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد QR تکراری است", status_code=409, error_code="qr_code_exists" + ) + token = secrets.token_urlsafe(TOKEN_BYTES) + entity = QrCode( + tenant_id=tenant_id, + venue_id=body.venue_id, + code=code, + token=token, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + target_type=target_type, + menu_id=body.menu_id, + dining_area_id=body.dining_area_id, + table_id=body.table_id, + ordering_enabled=bool(body.ordering_enabled), + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="qr_code", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.QR_CODE_CREATED, + aggregate_type="qr_code", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "target_type": entity.target_type.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> QrCode: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("QR یافت نشد", error_code="qr_code_not_found") + return entity + + async def get_by_token(self, tenant_id: UUID, token: str) -> QrCode: + entity = await self.repo.get_by_token(tenant_id, token) + if entity is None: + raise NotFoundError("QR یافت نشد", error_code="qr_code_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class QrMenuSessionService(_QrBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = QrMenuSessionRepository(session) + self.qr_codes = QrCodeRepository(session) + + async def create( + self, + tenant_id: UUID, + body: QrMenuSessionCreate, + *, + actor: CurrentUser | None = None, + ) -> QrMenuSession: + await self._require_venue(tenant_id, body.venue_id) + qr_code = await self.qr_codes.get(tenant_id, body.qr_code_id) + if qr_code is None: + raise NotFoundError("QR یافت نشد", error_code="qr_code_not_found") + actor_id = actor.user_id if actor else None + token = secrets.token_urlsafe(TOKEN_BYTES) + entity = QrMenuSession( + tenant_id=tenant_id, + venue_id=body.venue_id, + qr_code_id=body.qr_code_id, + menu_id=body.menu_id if body.menu_id is not None else qr_code.menu_id, + table_id=body.table_id if body.table_id is not None else qr_code.table_id, + session_token=token, + status=QrSessionStatus.OPEN, + locale=body.locale, + guest_label=body.guest_label, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="qr_menu_session", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.QR_MENU_SESSION_CREATED, + aggregate_type="qr_menu_session", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"qr_code_id": str(entity.qr_code_id)}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> QrMenuSession: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError( + "جلسه مشاهده منو یافت نشد", error_code="qr_menu_session_not_found" + ) + return entity + + async def get_by_token(self, tenant_id: UUID, token: str) -> QrMenuSession: + entity = await self.repo.get_by_token(tenant_id, token) + if entity is None: + raise NotFoundError( + "جلسه مشاهده منو یافت نشد", error_code="qr_menu_session_not_found" + ) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class QrOrderingSessionService(_QrBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = QrOrderingSessionRepository(session) + self.qr_codes = QrCodeRepository(session) + self.menu_sessions = QrMenuSessionRepository(session) + + async def create( + self, + tenant_id: UUID, + body: QrOrderingSessionCreate, + *, + actor: CurrentUser | None = None, + ) -> QrOrderingSession: + await self._require_venue(tenant_id, body.venue_id) + qr_code = await self.qr_codes.get(tenant_id, body.qr_code_id) + if qr_code is None: + raise NotFoundError("QR یافت نشد", error_code="qr_code_not_found") + if not qr_code.ordering_enabled: + raise AppError( + "سفارش‌گیری برای این QR فعال نیست", + status_code=422, + error_code="qr_ordering_disabled", + ) + if body.qr_menu_session_id is not None: + menu_session = await self.menu_sessions.get(tenant_id, body.qr_menu_session_id) + if menu_session is None: + raise NotFoundError( + "جلسه مشاهده منو یافت نشد", error_code="qr_menu_session_not_found" + ) + actor_id = actor.user_id if actor else None + token = secrets.token_urlsafe(TOKEN_BYTES) + entity = QrOrderingSession( + tenant_id=tenant_id, + venue_id=body.venue_id, + qr_code_id=body.qr_code_id, + qr_menu_session_id=body.qr_menu_session_id, + menu_id=body.menu_id if body.menu_id is not None else qr_code.menu_id, + table_id=body.table_id if body.table_id is not None else qr_code.table_id, + session_token=token, + status=OrderingSessionStatus.DRAFT, + guest_label=body.guest_label, + notes=body.notes, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="qr_ordering_session", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.QR_ORDERING_SESSION_CREATED, + aggregate_type="qr_ordering_session", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"qr_code_id": str(entity.qr_code_id)}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> QrOrderingSession: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError( + "جلسه سفارش‌گیری یافت نشد", error_code="qr_ordering_session_not_found" + ) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def submit( + self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None + ) -> QrOrderingSession: + """Marks the ordering session as submitted — shell only, no POS/kitchen dispatch.""" + entity = await self.get(tenant_id, entity_id) + if entity.status in (OrderingSessionStatus.CANCELLED, OrderingSessionStatus.CLOSED): + raise AppError( + "جلسه سفارش‌گیری بسته یا لغو شده است", + status_code=409, + error_code="qr_ordering_session_closed", + ) + if entity.status == OrderingSessionStatus.SUBMITTED: + raise AppError( + "جلسه سفارش‌گیری قبلاً ثبت شده است", + status_code=409, + error_code="qr_ordering_session_already_submitted", + ) + actor_id = actor.user_id if actor else None + entity.status = OrderingSessionStatus.SUBMITTED + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="qr_ordering_session", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.QR_ORDERING_SESSION_SUBMITTED, + aggregate_type="qr_ordering_session", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + return entity + + +class CartService(_QrBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = CartRepository(session) + self.ordering_sessions = QrOrderingSessionRepository(session) + + async def create( + self, tenant_id: UUID, body: CartCreate, *, actor: CurrentUser | None = None + ) -> Cart: + await self._require_venue(tenant_id, body.venue_id) + ordering_session = await self.ordering_sessions.get(tenant_id, body.ordering_session_id) + if ordering_session is None: + raise NotFoundError( + "جلسه سفارش‌گیری یافت نشد", error_code="qr_ordering_session_not_found" + ) + if await self.repo.get_by_ordering_session(tenant_id, body.ordering_session_id) is not None: + raise AppError( + "سبد خرید برای این جلسه از قبل وجود دارد", + status_code=409, + error_code="cart_exists", + ) + actor_id = actor.user_id if actor else None + entity = Cart( + tenant_id=tenant_id, + venue_id=body.venue_id, + ordering_session_id=body.ordering_session_id, + status=CartStatus.ACTIVE, + currency_code=validate_currency_code(body.currency_code), + notes=body.notes, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="cart", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.CART_CREATED, + aggregate_type="cart", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"ordering_session_id": str(entity.ordering_session_id)}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Cart: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("سبد خرید یافت نشد", error_code="cart_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class CartLineService(_QrBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = CartLineRepository(session) + self.carts = CartRepository(session) + self.items = MenuItemRepository(session) + + async def add( + self, tenant_id: UUID, body: CartLineCreate, *, actor: CurrentUser | None = None + ) -> CartLine: + await self._require_venue(tenant_id, body.venue_id) + cart = await self.carts.get(tenant_id, body.cart_id) + if cart is None: + raise NotFoundError("سبد خرید یافت نشد", error_code="cart_not_found") + if cart.status != CartStatus.ACTIVE: + raise AppError( + "سبد خرید فعال نیست", status_code=409, error_code="cart_not_active" + ) + item = await self.items.get(tenant_id, body.menu_item_id) + if item is None: + raise NotFoundError("آیتم منو یافت نشد", error_code="menu_item_not_found") + quantity = validate_positive_int(body.quantity, field="quantity") + actor_id = actor.user_id if actor else None + entity = CartLine( + tenant_id=tenant_id, + venue_id=body.venue_id, + cart_id=body.cart_id, + menu_item_id=body.menu_item_id, + quantity=quantity, + unit_price=item.base_price, + currency_code=item.currency_code, + modifier_option_ids=( + [str(v) for v in body.modifier_option_ids] + if body.modifier_option_ids + else None + ), + line_notes=body.line_notes, + sort_order=body.sort_order or 0, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="cart_line", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.CART_LINE_ADDED, + aggregate_type="cart_line", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"cart_id": str(entity.cart_id), "menu_item_id": str(entity.menu_item_id)}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> CartLine: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("خط سبد خرید یافت نشد", error_code="cart_line_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/hospitality/app/services/table_service.py b/backend/services/hospitality/app/services/table_service.py new file mode 100644 index 0000000..faddcdd --- /dev/null +++ b/backend/services/hospitality/app/services/table_service.py @@ -0,0 +1,490 @@ +"""Table Service & Reservations application services — Phase 12.3. + +Dine-in workflows only: reservations, table assignments, in-service +requests, and waitlist. No POS / kitchen / payment posting. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import HospitalityEventType +from app.models.table_service import ( + Reservation, + ServiceRequest, + TableAssignment, + WaitlistEntry, +) +from app.models.types import ( + AuditAction, + ReservationStatus, + ServiceRequestStatus, + TableAssignmentStatus, + WaitlistStatus, +) +from app.repositories.foundation import ( + BranchRepository, + DiningAreaRepository, + DiningTableRepository, + VenueRepository, +) +from app.repositories.qr import QrOrderingSessionRepository +from app.repositories.table_service import ( + ReservationRepository, + ServiceRequestRepository, + TableAssignmentRepository, + WaitlistEntryRepository, +) +from app.schemas.table_service import ( + ReservationCreate, + ServiceRequestCreate, + TableAssignmentCreate, + WaitlistEntryCreate, +) +from app.services.audit_service import AuditService +from app.validators import ( + validate_code, + validate_non_empty, + validate_positive_int, + validate_reservation_status, + validate_reservation_window, + validate_service_request_kind, + validate_service_request_status, + validate_waitlist_status, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + +RESERVATION_TERMINAL_STATUSES = frozenset( + {ReservationStatus.COMPLETED, ReservationStatus.CANCELLED, ReservationStatus.NO_SHOW} +) +SERVICE_REQUEST_TERMINAL_STATUSES = frozenset( + {ServiceRequestStatus.CLOSED, ServiceRequestStatus.CANCELLED} +) +WAITLIST_TERMINAL_STATUSES = frozenset( + {WaitlistStatus.SEATED, WaitlistStatus.CANCELLED, WaitlistStatus.EXPIRED} +) + + +class _TableServiceBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.venues = VenueRepository(session) + self.tables = DiningTableRepository(session) + + async def _require_venue(self, tenant_id: UUID, venue_id: UUID): + venue = await self.venues.get(tenant_id, venue_id) + if venue is None: + raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found") + return venue + + async def _require_table(self, tenant_id: UUID, table_id: UUID): + table = await self.tables.get(tenant_id, table_id) + if table is None: + raise NotFoundError("میز یافت نشد", error_code="table_not_found") + return table + + +class ReservationService(_TableServiceBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = ReservationRepository(session) + self.branches = BranchRepository(session) + self.dining_areas = DiningAreaRepository(session) + + async def create( + self, tenant_id: UUID, body: ReservationCreate, *, actor: CurrentUser | None = None + ) -> Reservation: + await self._require_venue(tenant_id, body.venue_id) + if body.branch_id is not None and await self.branches.get(tenant_id, body.branch_id) is None: + raise NotFoundError("شعبه یافت نشد", error_code="branch_not_found") + if ( + body.dining_area_id is not None + and await self.dining_areas.get(tenant_id, body.dining_area_id) is None + ): + raise NotFoundError("فضای پذیرایی یافت نشد", error_code="dining_area_not_found") + if body.table_id is not None: + await self._require_table(tenant_id, body.table_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد رزرو تکراری است", status_code=409, error_code="reservation_code_exists" + ) + party_size = validate_positive_int(body.party_size, field="party_size") + reserved_from, reserved_to = validate_reservation_window( + body.reserved_from, body.reserved_to + ) + actor_id = actor.user_id if actor else None + entity = Reservation( + tenant_id=tenant_id, + venue_id=body.venue_id, + branch_id=body.branch_id, + dining_area_id=body.dining_area_id, + table_id=body.table_id, + code=code, + guest_name=validate_non_empty(body.guest_name, field="guest_name"), + guest_phone=body.guest_phone, + party_size=party_size, + status=ReservationStatus.PENDING, + reserved_from=reserved_from, + reserved_to=reserved_to, + notes=body.notes, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="reservation", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.RESERVATION_CREATED, + aggregate_type="reservation", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Reservation: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("رزرو یافت نشد", error_code="reservation_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def change_status( + self, + tenant_id: UUID, + entity_id: UUID, + new_status: ReservationStatus | str, + *, + actor: CurrentUser | None = None, + ) -> Reservation: + entity = await self.get(tenant_id, entity_id) + resolved = validate_reservation_status(new_status) + if entity.status in RESERVATION_TERMINAL_STATUSES: + raise AppError( + "وضعیت رزرو نهایی شده و قابل تغییر نیست", + status_code=409, + error_code="reservation_status_final", + ) + actor_id = actor.user_id if actor else None + entity.status = resolved + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="reservation", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=actor_id, + changes={"status": resolved}, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.RESERVATION_STATUS_CHANGED, + aggregate_type="reservation", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + return entity + + +class TableAssignmentService(_TableServiceBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = TableAssignmentRepository(session) + self.reservations = ReservationRepository(session) + self.ordering_sessions = QrOrderingSessionRepository(session) + + async def create( + self, tenant_id: UUID, body: TableAssignmentCreate, *, actor: CurrentUser | None = None + ) -> TableAssignment: + await self._require_venue(tenant_id, body.venue_id) + await self._require_table(tenant_id, body.table_id) + if body.reservation_id is not None: + if await self.reservations.get(tenant_id, body.reservation_id) is None: + raise NotFoundError("رزرو یافت نشد", error_code="reservation_not_found") + if body.ordering_session_id is not None: + if await self.ordering_sessions.get(tenant_id, body.ordering_session_id) is None: + raise NotFoundError( + "جلسه سفارش‌گیری یافت نشد", error_code="qr_ordering_session_not_found" + ) + active = await self.repo.list_active_for_table(tenant_id, body.table_id) + if active: + raise AppError( + "میز در حال حاضر تخصیص فعال دارد", + status_code=409, + error_code="table_assignment_active_exists", + ) + actor_id = actor.user_id if actor else None + entity = TableAssignment( + tenant_id=tenant_id, + venue_id=body.venue_id, + table_id=body.table_id, + reservation_id=body.reservation_id, + ordering_session_id=body.ordering_session_id, + status=TableAssignmentStatus.ACTIVE, + assigned_at=datetime.now(timezone.utc), + guest_label=body.guest_label, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="table_assignment", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.TABLE_ASSIGNMENT_CREATED, + aggregate_type="table_assignment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"table_id": str(entity.table_id), "status": entity.status.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> TableAssignment: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("تخصیص میز یافت نشد", error_code="table_assignment_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def release( + self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None + ) -> TableAssignment: + entity = await self.get(tenant_id, entity_id) + if entity.status != TableAssignmentStatus.ACTIVE: + raise AppError( + "تخصیص میز فعال نیست", status_code=409, error_code="table_assignment_not_active" + ) + actor_id = actor.user_id if actor else None + entity.status = TableAssignmentStatus.RELEASED + entity.released_at = datetime.now(timezone.utc) + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="table_assignment", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=actor_id, + changes={"status": entity.status}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ServiceRequestService(_TableServiceBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = ServiceRequestRepository(session) + self.table_assignments = TableAssignmentRepository(session) + + async def create( + self, tenant_id: UUID, body: ServiceRequestCreate, *, actor: CurrentUser | None = None + ) -> ServiceRequest: + await self._require_venue(tenant_id, body.venue_id) + await self._require_table(tenant_id, body.table_id) + if body.table_assignment_id is not None: + if await self.table_assignments.get(tenant_id, body.table_assignment_id) is None: + raise NotFoundError( + "تخصیص میز یافت نشد", error_code="table_assignment_not_found" + ) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد درخواست خدمت تکراری است", + status_code=409, + error_code="service_request_code_exists", + ) + kind = validate_service_request_kind(body.kind) + actor_id = actor.user_id if actor else None + entity = ServiceRequest( + tenant_id=tenant_id, + venue_id=body.venue_id, + table_id=body.table_id, + table_assignment_id=body.table_assignment_id, + code=code, + kind=kind, + status=ServiceRequestStatus.OPEN, + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="service_request", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.SERVICE_REQUEST_CREATED, + aggregate_type="service_request", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"kind": entity.kind.value, "table_id": str(entity.table_id)}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ServiceRequest: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("درخواست خدمت یافت نشد", error_code="service_request_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def change_status( + self, + tenant_id: UUID, + entity_id: UUID, + new_status, + *, + actor: CurrentUser | None = None, + ) -> ServiceRequest: + entity = await self.get(tenant_id, entity_id) + resolved = validate_service_request_status(new_status) + if entity.status in SERVICE_REQUEST_TERMINAL_STATUSES: + raise AppError( + "وضعیت درخواست خدمت نهایی شده و قابل تغییر نیست", + status_code=409, + error_code="service_request_status_final", + ) + actor_id = actor.user_id if actor else None + entity.status = resolved + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="service_request", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=actor_id, + changes={"status": resolved}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class WaitlistEntryService(_TableServiceBase): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = WaitlistEntryRepository(session) + + async def create( + self, tenant_id: UUID, body: WaitlistEntryCreate, *, actor: CurrentUser | None = None + ) -> WaitlistEntry: + await self._require_venue(tenant_id, body.venue_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None: + raise AppError( + "کد لیست انتظار تکراری است", + status_code=409, + error_code="waitlist_entry_code_exists", + ) + party_size = validate_positive_int(body.party_size, field="party_size") + actor_id = actor.user_id if actor else None + entity = WaitlistEntry( + tenant_id=tenant_id, + venue_id=body.venue_id, + code=code, + guest_name=validate_non_empty(body.guest_name, field="guest_name"), + party_size=party_size, + status=WaitlistStatus.WAITING, + phone=body.phone, + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="waitlist_entry", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=HospitalityEventType.WAITLIST_ENTRY_CREATED, + aggregate_type="waitlist_entry", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "party_size": entity.party_size}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> WaitlistEntry: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("مورد لیست انتظار یافت نشد", error_code="waitlist_entry_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def change_status( + self, + tenant_id: UUID, + entity_id: UUID, + new_status, + *, + actor: CurrentUser | None = None, + ) -> WaitlistEntry: + entity = await self.get(tenant_id, entity_id) + resolved = validate_waitlist_status(new_status) + if entity.status in WAITLIST_TERMINAL_STATUSES: + raise AppError( + "وضعیت لیست انتظار نهایی شده و قابل تغییر نیست", + status_code=409, + error_code="waitlist_entry_status_final", + ) + actor_id = actor.user_id if actor else None + entity.status = resolved + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="waitlist_entry", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=actor_id, + changes={"status": resolved}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/hospitality/app/specifications/__init__.py b/backend/services/hospitality/app/specifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/hospitality/app/tests/conftest.py b/backend/services/hospitality/app/tests/conftest.py new file mode 100644 index 0000000..9965874 --- /dev/null +++ b/backend/services/hospitality/app/tests/conftest.py @@ -0,0 +1,62 @@ +import os +import uuid + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +os.environ["ENVIRONMENT"] = "test" +os.environ["AUTH_REQUIRED"] = "false" +os.environ["HOSPITALITY_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["HOSPITALITY_DATABASE_URL_SYNC"] = "sqlite:///:memory:" +os.environ["JWT_VERIFY_SIGNATURE"] = "false" + +from app.core.config import get_settings # noqa: E402 + +get_settings.cache_clear() + +from app.core.database import Base, engine # noqa: E402 +import app.models # noqa: E402, F401 +from app.events.publisher import reset_event_publisher # noqa: E402 +from app.main import app # noqa: E402 + +TENANT_A = uuid.uuid4() +TENANT_B = uuid.uuid4() + + +@pytest_asyncio.fixture +async def db_setup(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(scope="session", autouse=True) +def _dispose_engine_on_session_end(): + yield + import asyncio + + try: + asyncio.run(engine.dispose()) + except RuntimeError: + pass + + +@pytest_asyncio.fixture(autouse=True) +def _reset_events(): + reset_event_publisher() + yield + + +@pytest_asyncio.fixture +async def client(db_setup): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as ac: + yield ac + + +def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]: + return {"X-Tenant-ID": str(tenant_id)} diff --git a/backend/services/hospitality/app/tests/test_analytics.py b/backend/services/hospitality/app/tests/test_analytics.py new file mode 100644 index 0000000..7530720 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_analytics.py @@ -0,0 +1,120 @@ +"""Phase 12.8 Analytics API / tenant / validation tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HospitalityEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +@pytest.mark.asyncio +async def test_analytics_snapshot_refresh_counts_local_tables(client): + h = tenant_headers(TENANT_A) + + venue = await client.post( + "/api/v1/venues", + json={"code": "an-v1", "name": "Analytics Venue", "status": "active"}, + headers=h, + ) + assert venue.status_code == 201, venue.text + venue_id = venue.json()["id"] + + menu = await client.post( + "/api/v1/menus", + json={"venue_id": venue_id, "code": "an-menu", "name": "Analytics Menu"}, + headers=h, + ) + assert menu.status_code == 201, menu.text + + report = await client.post( + "/api/v1/analytics-reports", + json={ + "code": "daily-ops", + "name": "Daily Ops", + "metric_keys": ["venues_count", "menus_count"], + }, + headers=h, + ) + assert report.status_code == 201, report.text + report_id = report.json()["id"] + + snapshot = await client.post( + "/api/v1/analytics-snapshots/refresh", + json={ + "report_id": report_id, + "period_start": "2026-07-01", + "period_end": "2026-07-31", + }, + headers=h, + ) + assert snapshot.status_code == 201, snapshot.text + metrics = snapshot.json()["metrics"] + assert metrics["venues_count"] == 1 + assert metrics["menus_count"] == 1 + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.ANALYTICS_REPORT_DEFINITION_CREATED.value in published + assert HospitalityEventType.ANALYTICS_SNAPSHOT_REFRESHED.value in published + + +@pytest.mark.asyncio +async def test_analytics_report_rejects_unknown_metric_key(client): + h = tenant_headers(TENANT_A) + + bad = await client.post( + "/api/v1/analytics-reports", + json={ + "code": "bad-report", + "name": "Bad Report", + "metric_keys": ["not_a_real_metric"], + }, + headers=h, + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_analytics_report_code_unique_per_tenant(client): + h = tenant_headers(TENANT_A) + + first = await client.post( + "/api/v1/analytics-reports", + json={"code": "dup-report", "name": "Report A", "metric_keys": ["venues_count"]}, + headers=h, + ) + assert first.status_code == 201, first.text + + duplicate = await client.post( + "/api/v1/analytics-reports", + json={"code": "dup-report", "name": "Report B", "metric_keys": ["venues_count"]}, + headers=h, + ) + assert duplicate.status_code == 409 + + +@pytest.mark.asyncio +async def test_analytics_report_tenant_isolation(client): + h = tenant_headers(TENANT_A) + + report = await client.post( + "/api/v1/analytics-reports", + json={"code": "iso-report", "name": "Iso Report", "metric_keys": ["venues_count"]}, + headers=h, + ) + assert report.status_code == 201, report.text + report_id = report.json()["id"] + + denied = await client.get( + f"/api/v1/analytics-reports/{report_id}", headers=tenant_headers(TENANT_B) + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_capabilities_phase_12_8(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["features"]["analytics"] is True + assert body["features"]["ai"] is False diff --git a/backend/services/hospitality/app/tests/test_api.py b/backend/services/hospitality/app/tests/test_api.py new file mode 100644 index 0000000..15dc0bf --- /dev/null +++ b/backend/services/hospitality/app/tests/test_api.py @@ -0,0 +1,207 @@ +"""Hospitality API foundation flow — Phase 12.0.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HospitalityEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +@pytest.mark.asyncio +async def test_health(client): + resp = await client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["service"] == "hospitality-service" + assert body["status"] == "ok" + assert body["version"] == "0.12.8.0" + + +@pytest.mark.asyncio +async def test_capabilities(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["phase"] == "12.8" + assert body["product"] == "Torbat Food" + assert body["features"]["foundation"] is True + assert body["features"]["bundle_based_licensing"] is True + assert body["features"]["digital_menu_catalog"] is True + assert body["features"]["qr_menu"] is True + assert body["features"]["qr_ordering"] is True + assert body["features"]["table_service"] is True + assert body["features"]["reservation"] is True + assert body["features"]["pos_lite"] is True + assert body["features"]["pos_pro"] is True + assert body["features"]["kitchen"] is True + assert body["features"]["delivery_integration"] is True + assert body["features"]["accounting_integration"] is True + assert body["features"]["analytics"] is True + assert body["features"]["pos_engine"] is False + assert "digital_menu" in body["bundles"] + assert "cafe" in body["venue_formats"] + assert body["independence"]["integration_mode"] == "api_and_events_only" + + +@pytest.mark.asyncio +async def test_foundation_flow_and_events(client): + venue = await client.post( + "/api/v1/venues", + json={ + "code": "main", + "name": "Main Cafe", + "status": "active", + "venue_format": "cafe", + }, + headers=tenant_headers(TENANT_A), + ) + assert venue.status_code == 201, venue.text + venue_id = venue.json()["id"] + assert venue.json()["code"] == "MAIN" + assert venue.json()["venue_format"] == "cafe" + + branch = await client.post( + "/api/v1/branches", + json={"venue_id": venue_id, "code": "b1", "name": "Branch 1"}, + headers=tenant_headers(TENANT_A), + ) + assert branch.status_code == 201, branch.text + + area = await client.post( + "/api/v1/dining-areas", + json={"venue_id": venue_id, "code": "hall", "name": "Main Hall"}, + headers=tenant_headers(TENANT_A), + ) + assert area.status_code == 201, area.text + area_id = area.json()["id"] + + table = await client.post( + "/api/v1/tables", + json={ + "venue_id": venue_id, + "dining_area_id": area_id, + "code": "t1", + "name": "Table 1", + "capacity": 4, + }, + headers=tenant_headers(TENANT_A), + ) + assert table.status_code == 201, table.text + + menu = await client.post( + "/api/v1/menus", + json={"venue_id": venue_id, "code": "day", "name": "Day Menu"}, + headers=tenant_headers(TENANT_A), + ) + assert menu.status_code == 201, menu.text + menu_id = menu.json()["id"] + + category = await client.post( + "/api/v1/menu-categories", + json={ + "venue_id": venue_id, + "menu_id": menu_id, + "code": "drinks", + "name": "Drinks", + }, + headers=tenant_headers(TENANT_A), + ) + assert category.status_code == 201, category.text + category_id = category.json()["id"] + + item = await client.post( + "/api/v1/menu-items", + json={ + "venue_id": venue_id, + "menu_id": menu_id, + "category_id": category_id, + "code": "latte", + "name": "Latte", + "base_price": 150000, + }, + headers=tenant_headers(TENANT_A), + ) + assert item.status_code == 201, item.text + + bundle = await client.post( + "/api/v1/bundle-definitions", + json={ + "code": "dm", + "bundle_key": "digital_menu", + "name": "Digital Menu", + "feature_keys": ["hospitality.digital_menu"], + "permission_prefixes": ["hospitality.digital_menu."], + "api_prefixes": ["/api/v1/menus"], + }, + headers=tenant_headers(TENANT_A), + ) + assert bundle.status_code == 201, bundle.text + bundle_id = bundle.json()["id"] + + activated = await client.post( + "/api/v1/tenant-bundles/activate", + json={"bundle_definition_id": bundle_id, "venue_id": venue_id}, + headers=tenant_headers(TENANT_A), + ) + assert activated.status_code == 201, activated.text + assert activated.json()["bundle_key"] == "digital_menu" + assert activated.json()["status"] == "active" + + toggle = await client.post( + "/api/v1/feature-toggles/upsert", + json={ + "feature_key": "hospitality.digital_menu", + "enabled": True, + "venue_id": venue_id, + "bundle_key": "digital_menu", + }, + headers=tenant_headers(TENANT_A), + ) + assert toggle.status_code == 200, toggle.text + assert toggle.json()["enabled"] is True + + role = await client.post( + "/api/v1/roles", + json={"code": "manager", "name": "Manager", "venue_id": venue_id}, + headers=tenant_headers(TENANT_A), + ) + assert role.status_code == 201, role.text + + cfg = await client.post( + "/api/v1/configurations", + json={ + "code": "hours", + "name": "Working Hours", + "venue_id": venue_id, + "config": {"timezone": "Asia/Tehran"}, + }, + headers=tenant_headers(TENANT_A), + ) + assert cfg.status_code == 201, cfg.text + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.VENUE_CREATED.value in published + assert HospitalityEventType.MENU_CREATED.value in published + assert HospitalityEventType.TENANT_BUNDLE_ACTIVATED.value in published + assert HospitalityEventType.FEATURE_TOGGLE_UPSERTED.value in published + + +@pytest.mark.asyncio +async def test_tenant_isolation(client): + venue = await client.post( + "/api/v1/venues", + json={"code": "a1", "name": "Tenant A Venue", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert venue.status_code == 201 + venue_id = venue.json()["id"] + + other = await client.get( + f"/api/v1/venues/{venue_id}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + missing = await client.get("/api/v1/venues") + assert missing.status_code in (400, 422) diff --git a/backend/services/hospitality/app/tests/test_architecture.py b/backend/services/hospitality/app/tests/test_architecture.py new file mode 100644 index 0000000..e49c679 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_architecture.py @@ -0,0 +1,375 @@ +"""Architecture tests — Hospitality module boundary enforcement.""" +from __future__ import annotations + +import ast +from pathlib import Path + +from app.models import analytics as analytics_models +from app.models import catalog as catalog_models +from app.models import connectors as connectors_models +from app.models import foundation as models +from app.models import kitchen as kitchen_models +from app.models import pos_lite as pos_lite_models +from app.models import pos_pro as pos_pro_models +from app.models import qr as qr_models +from app.models import table_service as table_service_models + + +FORBIDDEN_IMPORT_PREFIXES = ( + "backend.services.accounting", + "backend.services.crm", + "backend.services.loyalty", + "backend.services.communication", + "backend.services.sports_center", + "backend.services.automation", + "backend.services.notification", + "backend.services.file_storage", + "backend.services.identity_access", + "backend.core_service", +) + +FOUNDATION_MODELS = [ + models.Venue, + models.Branch, + models.DiningArea, + models.DiningTable, + models.Menu, + models.MenuCategory, + models.MenuItem, + models.BundleDefinition, + models.TenantBundle, + models.FeatureToggle, + models.HospitalityRole, + models.HospitalityPermission, + models.HospitalityConfiguration, + models.HospitalityEvent, + models.HospitalitySetting, + models.HospitalityAuditLog, +] + +CATALOG_MODELS = [ + catalog_models.Allergen, + catalog_models.ModifierGroup, + catalog_models.ModifierOption, + catalog_models.MenuItemModifier, + catalog_models.MenuItemAllergen, + catalog_models.AvailabilityWindow, + catalog_models.MenuMediaRef, + catalog_models.MenuLocalization, +] + +QR_MODELS = [ + qr_models.QrCode, + qr_models.QrMenuSession, + qr_models.QrOrderingSession, + qr_models.Cart, + qr_models.CartLine, +] + +TABLE_SERVICE_MODELS = [ + table_service_models.Reservation, + table_service_models.TableAssignment, + table_service_models.ServiceRequest, + table_service_models.WaitlistEntry, +] + +POS_LITE_MODELS = [ + pos_lite_models.PosRegister, + pos_lite_models.PosShift, + pos_lite_models.PosTicket, + pos_lite_models.PosTicketLine, +] + +POS_PRO_MODELS = [ + pos_pro_models.PosPayment, + pos_pro_models.PosDiscount, + pos_pro_models.PosTaxLine, + pos_pro_models.PosFloorPlan, + pos_pro_models.PosStation, +] + +KITCHEN_MODELS = [ + kitchen_models.KitchenStation, + kitchen_models.KitchenTicket, + kitchen_models.KitchenTicketItem, +] + +CONNECTORS_MODELS = [ + connectors_models.ConnectorRegistration, + connectors_models.ConnectorDispatch, +] + +ANALYTICS_MODELS = [ + analytics_models.AnalyticsReportDefinition, + analytics_models.AnalyticsSnapshot, +] + + +def test_all_models_have_tenant_id(): + from app.core.database import Base + import app.models # noqa: F401 + + skip = {"alembic_version"} + for table in Base.metadata.tables.values(): + if table.name in skip: + continue + assert "tenant_id" in table.columns, f"{table.name} missing tenant_id" + + +def test_foundation_aggregates_are_independent(): + assert len(FOUNDATION_MODELS) == 16 + for model in FOUNDATION_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_catalog_aggregates_are_independent(): + assert len(CATALOG_MODELS) == 8 + for model in CATALOG_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_qr_aggregates_are_independent(): + assert len(QR_MODELS) == 5 + for model in QR_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_table_service_aggregates_are_independent(): + assert len(TABLE_SERVICE_MODELS) == 4 + for model in TABLE_SERVICE_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_pos_lite_aggregates_are_independent(): + assert len(POS_LITE_MODELS) == 4 + for model in POS_LITE_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_pos_pro_aggregates_are_independent(): + assert len(POS_PRO_MODELS) == 5 + for model in POS_PRO_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_kitchen_aggregates_are_independent(): + assert len(KITCHEN_MODELS) == 3 + for model in KITCHEN_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_connectors_aggregates_are_independent(): + assert len(CONNECTORS_MODELS) == 2 + for model in CONNECTORS_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_analytics_aggregates_are_independent(): + assert len(ANALYTICS_MODELS) == 2 + for model in ANALYTICS_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_no_format_specific_hardcoded_engines(): + forbidden = {"pos_engine", "kitchen_engine", "ordering_engine", "vendor_sdk"} + for model in ( + FOUNDATION_MODELS + + CATALOG_MODELS + + QR_MODELS + + TABLE_SERVICE_MODELS + + POS_LITE_MODELS + + POS_PRO_MODELS + + KITCHEN_MODELS + + CONNECTORS_MODELS + + ANALYTICS_MODELS + ): + columns = set(model.__table__.columns.keys()) + assert forbidden.isdisjoint(columns), model.__tablename__ + + +def test_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + assert "hospitality.view" in ALL_PERMISSIONS + assert "hospitality.venues.create" in ALL_PERMISSIONS + assert "hospitality.bundles.manage" in ALL_PERMISSIONS + assert "hospitality.tenant_bundles.manage" in ALL_PERMISSIONS + assert "hospitality.feature_toggles.manage" in ALL_PERMISSIONS + assert "hospitality.menus.manage" in ALL_PERMISSIONS + assert "hospitality.allergens.manage" in ALL_PERMISSIONS + assert "hospitality.modifier_groups.manage" in ALL_PERMISSIONS + assert "hospitality.availability_windows.manage" in ALL_PERMISSIONS + assert "hospitality.menu_media.manage" in ALL_PERMISSIONS + assert "hospitality.menu_localizations.manage" in ALL_PERMISSIONS + assert "hospitality.qr_codes.manage" in ALL_PERMISSIONS + assert "hospitality.qr_menu_sessions.manage" in ALL_PERMISSIONS + assert "hospitality.qr_ordering_sessions.manage" in ALL_PERMISSIONS + assert "hospitality.carts.manage" in ALL_PERMISSIONS + assert "hospitality.reservations.manage" in ALL_PERMISSIONS + assert "hospitality.table_assignments.manage" in ALL_PERMISSIONS + assert "hospitality.service_requests.manage" in ALL_PERMISSIONS + assert "hospitality.waitlist.manage" in ALL_PERMISSIONS + assert "hospitality.pos_registers.manage" in ALL_PERMISSIONS + assert "hospitality.pos_shifts.manage" in ALL_PERMISSIONS + assert "hospitality.pos_tickets.manage" in ALL_PERMISSIONS + assert "hospitality.pos_payments.manage" in ALL_PERMISSIONS + assert "hospitality.pos_discounts.manage" in ALL_PERMISSIONS + assert "hospitality.pos_floor_plans.manage" in ALL_PERMISSIONS + assert "hospitality.kitchen_stations.manage" in ALL_PERMISSIONS + assert "hospitality.kitchen_tickets.manage" in ALL_PERMISSIONS + assert "hospitality.connector_registrations.manage" in ALL_PERMISSIONS + assert "hospitality.connector_dispatches.manage" in ALL_PERMISSIONS + assert "hospitality.analytics_reports.manage" in ALL_PERMISSIONS + assert "hospitality.analytics_snapshots.manage" in ALL_PERMISSIONS + assert "hospitality.digital_menu.view" in ALL_PERMISSIONS + assert "hospitality.pos_lite.manage" in ALL_PERMISSIONS + assert "hospitality.audit.view" in ALL_PERMISSIONS + for prefix in PERMISSION_PREFIXES: + assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS) + + +def test_events_defined(): + from app.events.types import HospitalityEventType + + assert HospitalityEventType.VENUE_CREATED.value == "hospitality.venue.created" + assert HospitalityEventType.MENU_CREATED.value == "hospitality.menu.created" + assert ( + HospitalityEventType.TENANT_BUNDLE_ACTIVATED.value + == "hospitality.tenant_bundle.activated" + ) + assert ( + HospitalityEventType.FEATURE_TOGGLE_UPSERTED.value + == "hospitality.feature_toggle.upserted" + ) + assert HospitalityEventType.ALLERGEN_CREATED.value == "hospitality.allergen.created" + assert ( + HospitalityEventType.MODIFIER_GROUP_CREATED.value + == "hospitality.modifier_group.created" + ) + assert ( + HospitalityEventType.MENU_LOCALIZATION_UPSERTED.value + == "hospitality.menu_localization.upserted" + ) + assert HospitalityEventType.QR_CODE_CREATED.value == "hospitality.qr_code.created" + assert ( + HospitalityEventType.QR_ORDERING_SESSION_SUBMITTED.value + == "hospitality.qr_ordering_session.submitted" + ) + assert HospitalityEventType.CART_CREATED.value == "hospitality.cart.created" + assert HospitalityEventType.CART_LINE_ADDED.value == "hospitality.cart_line.added" + assert ( + HospitalityEventType.RESERVATION_CREATED.value == "hospitality.reservation.created" + ) + assert ( + HospitalityEventType.RESERVATION_STATUS_CHANGED.value + == "hospitality.reservation.status_changed" + ) + assert ( + HospitalityEventType.TABLE_ASSIGNMENT_CREATED.value + == "hospitality.table_assignment.created" + ) + assert ( + HospitalityEventType.SERVICE_REQUEST_CREATED.value + == "hospitality.service_request.created" + ) + assert ( + HospitalityEventType.WAITLIST_ENTRY_CREATED.value + == "hospitality.waitlist_entry.created" + ) + assert HospitalityEventType.POS_REGISTER_CREATED.value == "hospitality.pos_register.created" + assert HospitalityEventType.POS_SHIFT_OPENED.value == "hospitality.pos_shift.opened" + assert HospitalityEventType.POS_TICKET_VOIDED.value == "hospitality.pos_ticket.voided" + assert HospitalityEventType.POS_PAYMENT_RECORDED.value == "hospitality.pos_payment.recorded" + assert HospitalityEventType.POS_STATION_CREATED.value == "hospitality.pos_station.created" + assert HospitalityEventType.KITCHEN_TICKET_CREATED.value == "hospitality.kitchen_ticket.created" + assert ( + HospitalityEventType.KITCHEN_TICKET_ITEM_ADDED.value + == "hospitality.kitchen_ticket_item.added" + ) + assert ( + HospitalityEventType.CONNECTOR_REGISTRATION_CREATED.value + == "hospitality.connector_registration.created" + ) + assert ( + HospitalityEventType.CONNECTOR_DISPATCH_CREATED.value + == "hospitality.connector_dispatch.created" + ) + assert ( + HospitalityEventType.ANALYTICS_SNAPSHOT_REFRESHED.value + == "hospitality.analytics_snapshot.refreshed" + ) + + +def test_platform_provider_contracts_exist(): + from app.providers import ( + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + Customer360Provider, + DeliveryProvider, + IdentityProvider, + LoyaltyProvider, + NotificationProvider, + StorageProvider, + WebsiteBuilderProvider, + ) + + assert AccountingProvider is not None + assert CRMProvider is not None + assert LoyaltyProvider is not None + assert CommunicationProvider is not None + assert DeliveryProvider is not None + assert WebsiteBuilderProvider is not None + assert NotificationProvider is not None + assert StorageProvider is not None + assert AIProvider is not None + assert IdentityProvider is not None + assert Customer360Provider is not None + + +def test_architecture_folders_exist(): + root = Path(__file__).resolve().parents[1] + for name in ("policies", "specifications", "commands", "queries", "providers"): + assert (root / name).is_dir(), name + + +def test_no_forbidden_cross_service_imports(): + root = Path(__file__).resolve().parents[1] + for path in root.rglob("*.py"): + if "tests" in path.parts: + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + for prefix in FORBIDDEN_IMPORT_PREFIXES: + assert not alias.name.startswith(prefix), path + if isinstance(node, ast.ImportFrom) and node.module: + for prefix in FORBIDDEN_IMPORT_PREFIXES: + assert not node.module.startswith(prefix), path + + +def test_api_ownership_no_foreign_domains(): + root = Path(__file__).resolve().parents[1] / "api" / "v1" + text = "\n".join(p.read_text(encoding="utf-8") for p in root.glob("*.py")) + for forbidden in ("/accounting", "/crm/", "/loyalty", "/sports-center"): + assert forbidden not in text diff --git a/backend/services/hospitality/app/tests/test_bundles.py b/backend/services/hospitality/app/tests/test_bundles.py new file mode 100644 index 0000000..dd5db43 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_bundles.py @@ -0,0 +1,40 @@ +"""Bundle / feature toggle validation.""" +from __future__ import annotations + +import pytest + +from app.tests.conftest import TENANT_A, tenant_headers + + +@pytest.mark.asyncio +async def test_hidden_bundle_not_active_by_default(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["pos_engine"] is False + + venue = await client.post( + "/api/v1/venues", + json={"code": "v1", "name": "Venue", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + venue_id = venue.json()["id"] + + listed = await client.get( + "/api/v1/tenant-bundles", + headers=tenant_headers(TENANT_A), + ) + assert listed.status_code == 200 + assert listed.json() == [] + + # Feature remains disabled until explicitly toggled + toggle = await client.post( + "/api/v1/feature-toggles/upsert", + json={ + "feature_key": "hospitality.pos_pro", + "enabled": False, + "venue_id": venue_id, + "bundle_key": "pos_pro", + }, + headers=tenant_headers(TENANT_A), + ) + assert toggle.status_code == 200 + assert toggle.json()["enabled"] is False diff --git a/backend/services/hospitality/app/tests/test_catalog.py b/backend/services/hospitality/app/tests/test_catalog.py new file mode 100644 index 0000000..8a833db --- /dev/null +++ b/backend/services/hospitality/app/tests/test_catalog.py @@ -0,0 +1,259 @@ +"""Phase 12.1 Digital Menu Catalog API / tenant / validation tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HospitalityEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _seed_menu_item(client, tenant_id, *, venue_code: str = "v1", item_code: str = "latte"): + h = tenant_headers(tenant_id) + venue = await client.post( + "/api/v1/venues", + json={"code": venue_code, "name": "Cafe", "status": "active", "venue_format": "cafe"}, + headers=h, + ) + assert venue.status_code == 201, venue.text + venue_id = venue.json()["id"] + + menu = await client.post( + "/api/v1/menus", + json={"venue_id": venue_id, "code": "day", "name": "Day Menu"}, + headers=h, + ) + assert menu.status_code == 201, menu.text + menu_id = menu.json()["id"] + + category = await client.post( + "/api/v1/menu-categories", + json={ + "venue_id": venue_id, + "menu_id": menu_id, + "code": "drinks", + "name": "Drinks", + }, + headers=h, + ) + assert category.status_code == 201, category.text + + item = await client.post( + "/api/v1/menu-items", + json={ + "venue_id": venue_id, + "menu_id": menu_id, + "category_id": category.json()["id"], + "code": item_code, + "name": "Latte", + "base_price": 150000, + "is_vegetarian": True, + "preparation_minutes": 5, + "tags": ["coffee", "hot"], + }, + headers=h, + ) + assert item.status_code == 201, item.text + return { + "headers": h, + "venue_id": venue_id, + "menu_id": menu_id, + "item_id": item.json()["id"], + "item": item.json(), + } + + +@pytest.mark.asyncio +async def test_digital_menu_catalog_happy_path(client): + seed = await _seed_menu_item(client, TENANT_A) + h = seed["headers"] + venue_id = seed["venue_id"] + item_id = seed["item_id"] + menu_id = seed["menu_id"] + + assert seed["item"]["is_vegetarian"] is True + assert seed["item"]["preparation_minutes"] == 5 + assert seed["item"]["tags"] == ["coffee", "hot"] + + allergen = await client.post( + "/api/v1/allergens", + json={"venue_id": venue_id, "code": "milk", "name": "Milk"}, + headers=h, + ) + assert allergen.status_code == 201, allergen.text + allergen_id = allergen.json()["id"] + + group = await client.post( + "/api/v1/modifier-groups", + json={ + "venue_id": venue_id, + "code": "size", + "name": "Size", + "selection_mode": "single", + "min_select": 1, + "max_select": 1, + "is_required": True, + }, + headers=h, + ) + assert group.status_code == 201, group.text + group_id = group.json()["id"] + assert group.json()["min_select"] == 1 + + option = await client.post( + "/api/v1/modifier-options", + json={ + "venue_id": venue_id, + "modifier_group_id": group_id, + "code": "large", + "name": "Large", + "price_delta": 20000, + }, + headers=h, + ) + assert option.status_code == 201, option.text + + link_mod = await client.post( + "/api/v1/menu-item-modifiers/link", + json={ + "venue_id": venue_id, + "menu_item_id": item_id, + "modifier_group_id": group_id, + }, + headers=h, + ) + assert link_mod.status_code == 201, link_mod.text + + link_all = await client.post( + "/api/v1/menu-item-allergens/link", + json={ + "venue_id": venue_id, + "menu_item_id": item_id, + "allergen_id": allergen_id, + "is_contains": True, + }, + headers=h, + ) + assert link_all.status_code == 201, link_all.text + + window = await client.post( + "/api/v1/availability-windows", + json={ + "venue_id": venue_id, + "scope": "item", + "target_id": item_id, + "code": "lunch", + "name": "Lunch", + "weekday": 1, + "start_time": "11:00", + "end_time": "15:00", + }, + headers=h, + ) + assert window.status_code == 201, window.text + + media = await client.post( + "/api/v1/menu-media-refs", + json={ + "venue_id": venue_id, + "menu_item_id": item_id, + "kind": "image", + "file_ref": "storage://hospitality/latte.jpg", + "alt_text": "Latte", + }, + headers=h, + ) + assert media.status_code == 201, media.text + + loc = await client.post( + "/api/v1/menu-localizations/upsert", + json={ + "venue_id": venue_id, + "target_type": "item", + "target_id": item_id, + "locale": "fa-IR", + "name": "لاته", + "description": "قهوه با شیر", + }, + headers=h, + ) + assert loc.status_code == 200, loc.text + assert loc.json()["name"] == "لاته" + + listed = await client.get("/api/v1/allergens", headers=h) + assert listed.status_code == 200 + assert len(listed.json()) >= 1 + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.ALLERGEN_CREATED.value in published + assert HospitalityEventType.MODIFIER_GROUP_CREATED.value in published + assert HospitalityEventType.MENU_ITEM_MODIFIER_LINKED.value in published + assert HospitalityEventType.AVAILABILITY_WINDOW_CREATED.value in published + assert HospitalityEventType.MENU_MEDIA_REF_CREATED.value in published + assert HospitalityEventType.MENU_LOCALIZATION_UPSERTED.value in published + assert menu_id # keep seed used + + +@pytest.mark.asyncio +async def test_catalog_tenant_isolation(client): + seed = await _seed_menu_item(client, TENANT_A, venue_code="iso-a") + allergen = await client.post( + "/api/v1/allergens", + json={"venue_id": seed["venue_id"], "code": "nuts", "name": "Nuts"}, + headers=seed["headers"], + ) + assert allergen.status_code == 201 + allergen_id = allergen.json()["id"] + + denied = await client.get( + f"/api/v1/allergens/{allergen_id}", + headers=tenant_headers(TENANT_B), + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_availability_window_validation(client): + seed = await _seed_menu_item(client, TENANT_A, venue_code="val-a", item_code="tea") + bad = await client.post( + "/api/v1/availability-windows", + json={ + "venue_id": seed["venue_id"], + "scope": "item", + "target_id": seed["item_id"], + "code": "bad", + "name": "Bad", + "start_time": "18:00", + "end_time": "10:00", + }, + headers=seed["headers"], + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_modifier_selection_bounds_validation(client): + seed = await _seed_menu_item(client, TENANT_A, venue_code="mod-a", item_code="mocha") + bad = await client.post( + "/api/v1/modifier-groups", + json={ + "venue_id": seed["venue_id"], + "code": "extras", + "name": "Extras", + "selection_mode": "multiple", + "min_select": 3, + "max_select": 1, + }, + headers=seed["headers"], + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_digital_menu_catalog_still_enabled_in_phase_12_3(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["features"]["digital_menu_catalog"] is True + assert body["features"]["pos_engine"] is False + assert body["features"]["ordering_engine"] is False diff --git a/backend/services/hospitality/app/tests/test_connectors.py b/backend/services/hospitality/app/tests/test_connectors.py new file mode 100644 index 0000000..ab31b6f --- /dev/null +++ b/backend/services/hospitality/app/tests/test_connectors.py @@ -0,0 +1,142 @@ +"""Phase 12.7 Connectors API / tenant / validation tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HospitalityEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +@pytest.mark.asyncio +async def test_connector_dispatch_sent_when_registration_active(client): + h = tenant_headers(TENANT_A) + + registration = await client.post( + "/api/v1/connector-registrations", + json={ + "kind": "delivery", + "code": "delivery-1", + "name": "Delivery Connector", + "status": "active", + }, + headers=h, + ) + assert registration.status_code == 201, registration.text + registration_id = registration.json()["id"] + assert registration.json()["status"] == "active" + + dispatch = await client.post( + "/api/v1/connector-dispatches", + json={ + "registration_id": registration_id, + "event_type": "order.created", + "payload_ref": "ref-1", + }, + headers=h, + ) + assert dispatch.status_code == 201, dispatch.text + assert dispatch.json()["status"] == "sent" + assert dispatch.json()["response_json"]["ok"] is True + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.CONNECTOR_REGISTRATION_CREATED.value in published + assert HospitalityEventType.CONNECTOR_DISPATCH_CREATED.value in published + + +@pytest.mark.asyncio +async def test_connector_dispatch_fails_when_registration_not_active(client): + h = tenant_headers(TENANT_A) + + registration = await client.post( + "/api/v1/connector-registrations", + json={ + "kind": "accounting", + "code": "accounting-draft", + "name": "Accounting Connector", + }, + headers=h, + ) + assert registration.status_code == 201, registration.text + assert registration.json()["status"] == "draft" + registration_id = registration.json()["id"] + + dispatch = await client.post( + "/api/v1/connector-dispatches", + json={ + "registration_id": registration_id, + "event_type": "invoice.created", + }, + headers=h, + ) + assert dispatch.status_code == 201, dispatch.text + assert dispatch.json()["status"] == "failed" + assert dispatch.json()["response_json"]["ok"] is False + + +@pytest.mark.asyncio +async def test_connector_registration_code_unique_per_tenant(client): + h = tenant_headers(TENANT_A) + + first = await client.post( + "/api/v1/connector-registrations", + json={"kind": "crm", "code": "crm-1", "name": "CRM Connector"}, + headers=h, + ) + assert first.status_code == 201, first.text + + duplicate = await client.post( + "/api/v1/connector-registrations", + json={"kind": "crm", "code": "crm-1", "name": "CRM Connector Dup"}, + headers=h, + ) + assert duplicate.status_code == 409 + + +@pytest.mark.asyncio +async def test_connector_registration_tenant_isolation(client): + h = tenant_headers(TENANT_A) + + registration = await client.post( + "/api/v1/connector-registrations", + json={"kind": "loyalty", "code": "loyalty-iso", "name": "Loyalty Connector"}, + headers=h, + ) + assert registration.status_code == 201, registration.text + registration_id = registration.json()["id"] + + denied = await client.get( + f"/api/v1/connector-registrations/{registration_id}", + headers=tenant_headers(TENANT_B), + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_connector_dispatch_missing_registration_returns_404(client): + import uuid + + h = tenant_headers(TENANT_A) + missing = await client.post( + "/api/v1/connector-dispatches", + json={ + "registration_id": str(uuid.uuid4()), + "event_type": "test.event", + }, + headers=h, + ) + assert missing.status_code == 404 + + +@pytest.mark.asyncio +async def test_capabilities_phase_12_7(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["features"]["delivery_integration"] is True + assert body["features"]["accounting_integration"] is True + assert body["features"]["crm_integration"] is True + assert body["features"]["loyalty_integration"] is True + assert body["features"]["communication_integration"] is True + assert body["features"]["website_integration"] is True + assert body["features"]["ai"] is False diff --git a/backend/services/hospitality/app/tests/test_dependency.py b/backend/services/hospitality/app/tests/test_dependency.py new file mode 100644 index 0000000..131ecef --- /dev/null +++ b/backend/services/hospitality/app/tests/test_dependency.py @@ -0,0 +1,47 @@ +"""Dependency / layering validation.""" +from pathlib import Path + +from app.repositories.base import TenantBaseRepository +from app.repositories import analytics as analytics_repos +from app.repositories import catalog as catalog_repos +from app.repositories import connectors as connectors_repos +from app.repositories import foundation as repos +from app.repositories import kitchen as kitchen_repos +from app.repositories import pos_lite as pos_lite_repos +from app.repositories import pos_pro as pos_pro_repos +from app.repositories import qr as qr_repos +from app.repositories import table_service as table_service_repos + + +def test_repositories_inherit_tenant_base(): + for module in ( + repos, + catalog_repos, + qr_repos, + table_service_repos, + pos_lite_repos, + pos_pro_repos, + kitchen_repos, + connectors_repos, + analytics_repos, + ): + for name in dir(module): + obj = getattr(module, name) + if isinstance(obj, type) and name.endswith("Repository"): + assert issubclass(obj, TenantBaseRepository), name + + +def test_services_have_no_fastapi_import(): + root = Path(__file__).resolve().parents[1] / "services" + for path in root.glob("*.py"): + text = path.read_text(encoding="utf-8") + assert "fastapi" not in text.lower() + + +def test_provider_contracts_are_protocol_only(): + text = ( + Path(__file__).resolve().parents[1] / "providers" / "contracts.py" + ).read_text(encoding="utf-8") + assert "Protocol" in text + assert "httpx" not in text + assert "sqlalchemy" not in text diff --git a/backend/services/hospitality/app/tests/test_docs.py b/backend/services/hospitality/app/tests/test_docs.py new file mode 100644 index 0000000..5169597 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_docs.py @@ -0,0 +1,133 @@ +"""Documentation validation for Hospitality Phase 12.2.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[5] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_phase_docs_exist_and_mention_db(): + text = _read("docs/hospitality-phase-12-2.md") + assert "hospitality_db" in text + assert "12.2" in text + assert "Torbat Food" in text + assert "QR" in text + + +def test_handover_exists(): + text = _read("docs/phase-handover/phase-12-2.md") + assert "hospitality" in text.lower() + assert "12.2" in text + assert "hospitality_db" in text + + +def test_phase_12_3_docs_exist_and_mention_db(): + text = _read("docs/hospitality-phase-12-3.md") + assert "hospitality_db" in text + assert "12.3" in text + assert "Torbat Food" in text + assert "Reservation" in text or "reservation" in text + + +def test_phase_12_3_handover_exists(): + text = _read("docs/phase-handover/phase-12-3.md") + assert "hospitality" in text.lower() + assert "12.3" in text + assert "hospitality_db" in text + + +def test_phase_12_4_docs_exist_and_mention_db(): + text = _read("docs/hospitality-phase-12-4.md") + assert "hospitality_db" in text + assert "12.4" in text + assert "Torbat Food" in text + assert "POS" in text + + +def test_phase_12_4_handover_exists(): + text = _read("docs/phase-handover/phase-12-4.md") + assert "hospitality" in text.lower() + assert "12.4" in text + assert "hospitality_db" in text + + +def test_phase_12_5_docs_exist_and_mention_db(): + text = _read("docs/hospitality-phase-12-5.md") + assert "hospitality_db" in text + assert "12.5" in text + assert "Torbat Food" in text + assert "POS" in text + + +def test_phase_12_5_handover_exists(): + text = _read("docs/phase-handover/phase-12-5.md") + assert "hospitality" in text.lower() + assert "12.5" in text + assert "hospitality_db" in text + + +def test_phase_12_6_docs_exist_and_mention_db(): + text = _read("docs/hospitality-phase-12-6.md") + assert "hospitality_db" in text + assert "12.6" in text + assert "Torbat Food" in text + assert "Kitchen" in text or "kitchen" in text + + +def test_phase_12_6_handover_exists(): + text = _read("docs/phase-handover/phase-12-6.md") + assert "hospitality" in text.lower() + assert "12.6" in text + assert "hospitality_db" in text + + +def test_phase_12_7_docs_exist_and_mention_db(): + text = _read("docs/hospitality-phase-12-7.md") + assert "hospitality_db" in text + assert "12.7" in text + assert "Torbat Food" in text + assert "Connector" in text or "connector" in text + + +def test_phase_12_7_handover_exists(): + text = _read("docs/phase-handover/phase-12-7.md") + assert "hospitality" in text.lower() + assert "12.7" in text + assert "hospitality_db" in text + + +def test_phase_12_8_docs_exist_and_mention_db(): + text = _read("docs/hospitality-phase-12-8.md") + assert "hospitality_db" in text + assert "12.8" in text + assert "Torbat Food" in text + assert "Analytics" in text or "analytics" in text + + +def test_phase_12_8_handover_exists(): + text = _read("docs/phase-handover/phase-12-8.md") + assert "hospitality" in text.lower() + assert "12.8" in text + assert "hospitality_db" in text + + +def test_module_registry_updated(): + text = _read("docs/module-registry.md") + assert "hospitality" in text.lower() + assert "hospitality_db" in text + assert "0.12.8.0" in text or "12.8" in text + + +def test_service_readme(): + text = _read("backend/services/hospitality/README.md") + assert "hospitality_db" in text + assert "8009" in text + assert "12.8" in text + + +def test_adr_017_exists(): + text = _read("docs/architecture/adr/ADR-017.md") + assert "Hospitality" in text + assert "hospitality_db" in text diff --git a/backend/services/hospitality/app/tests/test_kitchen.py b/backend/services/hospitality/app/tests/test_kitchen.py new file mode 100644 index 0000000..99d6447 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_kitchen.py @@ -0,0 +1,159 @@ +"""Phase 12.6 Kitchen API / tenant / validation tests.""" +from __future__ import annotations + +import uuid + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HospitalityEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _seed_venue(client, tenant_id, *, venue_code: str = "v1"): + h = tenant_headers(tenant_id) + venue = await client.post( + "/api/v1/venues", + json={"code": venue_code, "name": "Cafe", "status": "active", "venue_format": "cafe"}, + headers=h, + ) + assert venue.status_code == 201, venue.text + return {"headers": h, "venue_id": venue.json()["id"]} + + +@pytest.mark.asyncio +async def test_kitchen_station_ticket_item_lifecycle(client): + seed = await _seed_venue(client, TENANT_A, venue_code="kit-v1") + h = seed["headers"] + + station = await client.post( + "/api/v1/kitchen-stations", + json={"venue_id": seed["venue_id"], "code": "grill", "name": "Grill Station"}, + headers=h, + ) + assert station.status_code == 201, station.text + station_id = station.json()["id"] + + source_id = str(uuid.uuid4()) + ticket = await client.post( + "/api/v1/kitchen-tickets", + json={ + "venue_id": seed["venue_id"], + "station_id": station_id, + "source_type": "pos_ticket", + "source_id": source_id, + "code": "kt-1", + }, + headers=h, + ) + assert ticket.status_code == 201, ticket.text + ticket_id = ticket.json()["id"] + assert ticket.json()["status"] == "queued" + + item = await client.post( + "/api/v1/kitchen-ticket-items", + json={ + "venue_id": seed["venue_id"], + "kitchen_ticket_id": ticket_id, + "menu_item_id": str(uuid.uuid4()), + "quantity": 2, + }, + headers=h, + ) + assert item.status_code == 201, item.text + item_id = item.json()["id"] + assert item.json()["status"] == "queued" + + item_status = await client.post( + f"/api/v1/kitchen-ticket-items/{item_id}/status", + json={"status": "preparing"}, + headers=h, + ) + assert item_status.status_code == 200, item_status.text + assert item_status.json()["status"] == "preparing" + + ticket_status = await client.post( + f"/api/v1/kitchen-tickets/{ticket_id}/status", + json={"status": "ready"}, + headers=h, + ) + assert ticket_status.status_code == 200, ticket_status.text + assert ticket_status.json()["status"] == "ready" + + bumped = await client.post( + f"/api/v1/kitchen-tickets/{ticket_id}/status", + json={"status": "bumped"}, + headers=h, + ) + assert bumped.status_code == 200, bumped.text + + blocked = await client.post( + f"/api/v1/kitchen-tickets/{ticket_id}/status", + json={"status": "queued"}, + headers=h, + ) + assert blocked.status_code == 409 + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.KITCHEN_STATION_CREATED.value in published + assert HospitalityEventType.KITCHEN_TICKET_CREATED.value in published + assert HospitalityEventType.KITCHEN_TICKET_STATUS_CHANGED.value in published + assert HospitalityEventType.KITCHEN_TICKET_ITEM_ADDED.value in published + + +@pytest.mark.asyncio +async def test_kitchen_tenant_isolation(client): + seed = await _seed_venue(client, TENANT_A, venue_code="kit-iso-v1") + station = await client.post( + "/api/v1/kitchen-stations", + json={"venue_id": seed["venue_id"], "code": "iso-station", "name": "Iso Station"}, + headers=seed["headers"], + ) + assert station.status_code == 201, station.text + station_id = station.json()["id"] + + denied = await client.get( + f"/api/v1/kitchen-stations/{station_id}", headers=tenant_headers(TENANT_B) + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_kitchen_ticket_item_quantity_validation(client): + seed = await _seed_venue(client, TENANT_A, venue_code="kit-qty-v1") + h = seed["headers"] + + ticket = await client.post( + "/api/v1/kitchen-tickets", + json={ + "venue_id": seed["venue_id"], + "source_type": "pos_ticket", + "source_id": str(uuid.uuid4()), + "code": "kit-qty-t1", + }, + headers=h, + ) + assert ticket.status_code == 201, ticket.text + ticket_id = ticket.json()["id"] + + bad = await client.post( + "/api/v1/kitchen-ticket-items", + json={ + "venue_id": seed["venue_id"], + "kitchen_ticket_id": ticket_id, + "menu_item_id": str(uuid.uuid4()), + "quantity": 0, + }, + headers=h, + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_capabilities_phase_12_6(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["features"]["kitchen"] is True + assert body["features"]["kitchen_engine"] is True + assert body["features"]["pos_engine"] is False diff --git a/backend/services/hospitality/app/tests/test_migration.py b/backend/services/hospitality/app/tests/test_migration.py new file mode 100644 index 0000000..d0d8fbd --- /dev/null +++ b/backend/services/hospitality/app/tests/test_migration.py @@ -0,0 +1,73 @@ +"""Migration / metadata validation.""" +from pathlib import Path + +from alembic.config import Config +from alembic.script import ScriptDirectory + +from app.core.database import Base +import app.models # noqa: F401 + + +EXPECTED_TABLES = { + "venues", + "branches", + "dining_areas", + "dining_tables", + "menus", + "menu_categories", + "menu_items", + "bundle_definitions", + "tenant_bundles", + "feature_toggles", + "hospitality_roles", + "hospitality_permissions", + "hospitality_configurations", + "hospitality_events", + "hospitality_settings", + "hospitality_audit_logs", + "allergens", + "modifier_groups", + "modifier_options", + "menu_item_modifiers", + "menu_item_allergens", + "availability_windows", + "menu_media_refs", + "menu_localizations", + "qr_codes", + "qr_menu_sessions", + "qr_ordering_sessions", + "carts", + "cart_lines", + "reservations", + "table_assignments", + "service_requests", + "waitlist_entries", + "pos_registers", + "pos_shifts", + "pos_tickets", + "pos_ticket_lines", + "pos_payments", + "pos_discounts", + "pos_tax_lines", + "pos_floor_plans", + "pos_stations", + "kitchen_stations", + "kitchen_tickets", + "kitchen_ticket_items", + "connector_registrations", + "connector_dispatches", + "analytics_report_definitions", + "analytics_snapshots", +} + + +def test_alembic_head_is_phase_128(): + root = Path(__file__).resolve().parents[2] + cfg = Config(str(root / "alembic.ini")) + script = ScriptDirectory.from_config(cfg) + assert script.get_current_head() == "0009_phase_128_analytics" + + +def test_metadata_contains_foundation_and_catalog_tables(): + names = set(Base.metadata.tables.keys()) + assert EXPECTED_TABLES.issubset(names) diff --git a/backend/services/hospitality/app/tests/test_performance.py b/backend/services/hospitality/app/tests/test_performance.py new file mode 100644 index 0000000..bec2d59 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_performance.py @@ -0,0 +1,16 @@ +"""Performance smoke — tenant-scoped list indexes exist.""" +from app.core.database import Base +import app.models # noqa: F401 + + +def test_venues_have_tenant_indexes(): + table = Base.metadata.tables["venues"] + index_cols = {tuple(idx.columns.keys()) for idx in table.indexes} + assert ("tenant_id", "status") in index_cols or any( + "tenant_id" in cols for cols in index_cols + ) + + +def test_tenant_bundles_have_status_index(): + table = Base.metadata.tables["tenant_bundles"] + assert any("tenant_id" in idx.columns.keys() for idx in table.indexes) diff --git a/backend/services/hospitality/app/tests/test_permissions.py b/backend/services/hospitality/app/tests/test_permissions.py new file mode 100644 index 0000000..0e9c9d9 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_permissions.py @@ -0,0 +1,14 @@ +"""Permission definition tests.""" +from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + +def test_all_permissions_use_hospitality_prefix(): + assert ALL_PERMISSIONS + for permission in ALL_PERMISSIONS: + assert permission.startswith("hospitality.") + + +def test_permission_prefixes_stable(): + assert "hospitality." in PERMISSION_PREFIXES + assert "hospitality.venues." in PERMISSION_PREFIXES + assert "hospitality.bundles." in PERMISSION_PREFIXES diff --git a/backend/services/hospitality/app/tests/test_pos_lite.py b/backend/services/hospitality/app/tests/test_pos_lite.py new file mode 100644 index 0000000..6c524f5 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_pos_lite.py @@ -0,0 +1,179 @@ +"""Phase 12.4 POS Lite API / tenant / validation tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HospitalityEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _seed_venue_item(client, tenant_id, *, venue_code: str = "v1", item_code: str = "item1"): + h = tenant_headers(tenant_id) + venue = await client.post( + "/api/v1/venues", + json={"code": venue_code, "name": "Cafe", "status": "active", "venue_format": "cafe"}, + headers=h, + ) + assert venue.status_code == 201, venue.text + venue_id = venue.json()["id"] + + menu = await client.post( + "/api/v1/menus", + json={"venue_id": venue_id, "code": f"{item_code}-menu", "name": "Menu"}, + headers=h, + ) + assert menu.status_code == 201, menu.text + menu_id = menu.json()["id"] + + item = await client.post( + "/api/v1/menu-items", + json={ + "venue_id": venue_id, + "menu_id": menu_id, + "code": item_code, + "name": "Espresso", + "base_price": 90000, + }, + headers=h, + ) + assert item.status_code == 201, item.text + return {"headers": h, "venue_id": venue_id, "menu_id": menu_id, "item_id": item.json()["id"]} + + +@pytest.mark.asyncio +async def test_pos_lite_happy_path_register_shift_ticket_line_void(client): + seed = await _seed_venue_item(client, TENANT_A, venue_code="pos-v1", item_code="pos-i1") + h = seed["headers"] + + register = await client.post( + "/api/v1/pos-registers", + json={"venue_id": seed["venue_id"], "code": "reg-1", "name": "Register 1"}, + headers=h, + ) + assert register.status_code == 201, register.text + register_id = register.json()["id"] + assert register.json()["status"] == "active" + + shift = await client.post( + "/api/v1/pos-shifts", + json={"venue_id": seed["venue_id"], "register_id": register_id, "opened_by": "cashier1"}, + headers=h, + ) + assert shift.status_code == 201, shift.text + shift_id = shift.json()["id"] + assert shift.json()["status"] == "open" + + duplicate_shift = await client.post( + "/api/v1/pos-shifts", + json={"venue_id": seed["venue_id"], "register_id": register_id}, + headers=h, + ) + assert duplicate_shift.status_code == 409 + + ticket = await client.post( + "/api/v1/pos-tickets", + json={ + "venue_id": seed["venue_id"], + "register_id": register_id, + "shift_id": shift_id, + "code": "t-1", + }, + headers=h, + ) + assert ticket.status_code == 201, ticket.text + ticket_id = ticket.json()["id"] + assert ticket.json()["status"] == "open" + + line = await client.post( + "/api/v1/pos-ticket-lines", + json={ + "venue_id": seed["venue_id"], + "ticket_id": ticket_id, + "menu_item_id": seed["item_id"], + "quantity": 2, + }, + headers=h, + ) + assert line.status_code == 201, line.text + assert line.json()["unit_price"] == 90000 + assert line.json()["quantity"] == 2 + + voided = await client.post(f"/api/v1/pos-tickets/{ticket_id}/void", headers=h) + assert voided.status_code == 200, voided.text + assert voided.json()["status"] == "void" + + blocked = await client.post(f"/api/v1/pos-tickets/{ticket_id}/void", headers=h) + assert blocked.status_code == 409 + + closed = await client.post(f"/api/v1/pos-shifts/{shift_id}/close", headers=h) + assert closed.status_code == 200, closed.text + assert closed.json()["status"] == "closed" + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.POS_REGISTER_CREATED.value in published + assert HospitalityEventType.POS_SHIFT_OPENED.value in published + assert HospitalityEventType.POS_SHIFT_CLOSED.value in published + assert HospitalityEventType.POS_TICKET_CREATED.value in published + assert HospitalityEventType.POS_TICKET_VOIDED.value in published + assert HospitalityEventType.POS_TICKET_LINE_ADDED.value in published + + +@pytest.mark.asyncio +async def test_pos_lite_tenant_isolation(client): + seed = await _seed_venue_item(client, TENANT_A, venue_code="pos-iso-v1", item_code="pos-iso-i1") + register = await client.post( + "/api/v1/pos-registers", + json={"venue_id": seed["venue_id"], "code": "iso-reg", "name": "Iso Register"}, + headers=seed["headers"], + ) + assert register.status_code == 201, register.text + register_id = register.json()["id"] + + denied = await client.get( + f"/api/v1/pos-registers/{register_id}", headers=tenant_headers(TENANT_B) + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_pos_ticket_line_quantity_validation(client): + seed = await _seed_venue_item(client, TENANT_A, venue_code="pos-qty-v1", item_code="pos-qty-i1") + h = seed["headers"] + + register = await client.post( + "/api/v1/pos-registers", + json={"venue_id": seed["venue_id"], "code": "qty-reg", "name": "Qty Register"}, + headers=h, + ) + assert register.status_code == 201, register.text + register_id = register.json()["id"] + + ticket = await client.post( + "/api/v1/pos-tickets", + json={"venue_id": seed["venue_id"], "register_id": register_id, "code": "qty-t1"}, + headers=h, + ) + assert ticket.status_code == 201, ticket.text + ticket_id = ticket.json()["id"] + + bad = await client.post( + "/api/v1/pos-ticket-lines", + json={ + "venue_id": seed["venue_id"], + "ticket_id": ticket_id, + "menu_item_id": seed["item_id"], + "quantity": 0, + }, + headers=h, + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_capabilities_phase_12_4(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["features"]["pos_lite"] is True + assert body["features"]["pos_engine"] is False diff --git a/backend/services/hospitality/app/tests/test_pos_pro.py b/backend/services/hospitality/app/tests/test_pos_pro.py new file mode 100644 index 0000000..86152a8 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_pos_pro.py @@ -0,0 +1,172 @@ +"""Phase 12.5 POS Pro API / tenant / validation tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HospitalityEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _seed_venue_ticket(client, tenant_id, *, venue_code: str = "v1", ticket_code: str = "t1"): + h = tenant_headers(tenant_id) + venue = await client.post( + "/api/v1/venues", + json={"code": venue_code, "name": "Cafe", "status": "active", "venue_format": "cafe"}, + headers=h, + ) + assert venue.status_code == 201, venue.text + venue_id = venue.json()["id"] + + register = await client.post( + "/api/v1/pos-registers", + json={"venue_id": venue_id, "code": f"{ticket_code}-reg", "name": "Register"}, + headers=h, + ) + assert register.status_code == 201, register.text + register_id = register.json()["id"] + + ticket = await client.post( + "/api/v1/pos-tickets", + json={"venue_id": venue_id, "register_id": register_id, "code": ticket_code}, + headers=h, + ) + assert ticket.status_code == 201, ticket.text + return {"headers": h, "venue_id": venue_id, "register_id": register_id, "ticket_id": ticket.json()["id"]} + + +@pytest.mark.asyncio +async def test_pos_pro_payment_discount_tax_happy_path(client): + seed = await _seed_venue_ticket(client, TENANT_A, venue_code="pro-v1", ticket_code="pro-t1") + h = seed["headers"] + + payment = await client.post( + "/api/v1/pos-payments", + json={ + "venue_id": seed["venue_id"], + "ticket_id": seed["ticket_id"], + "method": "cash", + "amount": 100000, + }, + headers=h, + ) + assert payment.status_code == 201, payment.text + assert payment.json()["status"] == "active" + + discount = await client.post( + "/api/v1/pos-discounts", + json={ + "venue_id": seed["venue_id"], + "ticket_id": seed["ticket_id"], + "code": "disc-1", + "kind": "percent", + "value": 10, + }, + headers=h, + ) + assert discount.status_code == 201, discount.text + + tax = await client.post( + "/api/v1/pos-tax-lines", + json={ + "venue_id": seed["venue_id"], + "ticket_id": seed["ticket_id"], + "code": "vat", + "name": "VAT", + "rate_bps": 900, + "amount": 9000, + }, + headers=h, + ) + assert tax.status_code == 201, tax.text + + floor_plan = await client.post( + "/api/v1/pos-floor-plans", + json={"venue_id": seed["venue_id"], "code": "fp-1", "name": "Main Floor"}, + headers=h, + ) + assert floor_plan.status_code == 201, floor_plan.text + floor_plan_id = floor_plan.json()["id"] + + station = await client.post( + "/api/v1/pos-stations", + json={ + "venue_id": seed["venue_id"], + "floor_plan_id": floor_plan_id, + "code": "st-1", + "name": "Bar", + }, + headers=h, + ) + assert station.status_code == 201, station.text + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.POS_PAYMENT_RECORDED.value in published + assert HospitalityEventType.POS_DISCOUNT_APPLIED.value in published + assert HospitalityEventType.POS_TAX_LINE_ADDED.value in published + assert HospitalityEventType.POS_FLOOR_PLAN_CREATED.value in published + assert HospitalityEventType.POS_STATION_CREATED.value in published + + +@pytest.mark.asyncio +async def test_pos_pro_tenant_isolation(client): + seed = await _seed_venue_ticket(client, TENANT_A, venue_code="pro-iso-v1", ticket_code="pro-iso-t1") + payment = await client.post( + "/api/v1/pos-payments", + json={ + "venue_id": seed["venue_id"], + "ticket_id": seed["ticket_id"], + "method": "card", + "amount": 50000, + }, + headers=seed["headers"], + ) + assert payment.status_code == 201, payment.text + payment_id = payment.json()["id"] + + denied = await client.get( + f"/api/v1/pos-payments/{payment_id}", headers=tenant_headers(TENANT_B) + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_pos_discount_percent_over_100_rejected(client): + seed = await _seed_venue_ticket(client, TENANT_A, venue_code="pro-disc-v1", ticket_code="pro-disc-t1") + bad = await client.post( + "/api/v1/pos-discounts", + json={ + "venue_id": seed["venue_id"], + "ticket_id": seed["ticket_id"], + "code": "bad-disc", + "kind": "percent", + "value": 150, + }, + headers=seed["headers"], + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_pos_payment_zero_amount_rejected(client): + seed = await _seed_venue_ticket(client, TENANT_A, venue_code="pro-pay-v1", ticket_code="pro-pay-t1") + bad = await client.post( + "/api/v1/pos-payments", + json={ + "venue_id": seed["venue_id"], + "ticket_id": seed["ticket_id"], + "method": "cash", + "amount": 0, + }, + headers=seed["headers"], + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_capabilities_phase_12_5(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["features"]["pos_pro"] is True + assert body["features"]["pos_engine"] is False diff --git a/backend/services/hospitality/app/tests/test_qr.py b/backend/services/hospitality/app/tests/test_qr.py new file mode 100644 index 0000000..4b4fca2 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_qr.py @@ -0,0 +1,282 @@ +"""Phase 12.2 QR Menu & QR Ordering API / tenant / validation tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HospitalityEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _seed_menu_item(client, tenant_id, *, venue_code: str = "v1", item_code: str = "latte"): + h = tenant_headers(tenant_id) + venue = await client.post( + "/api/v1/venues", + json={"code": venue_code, "name": "Cafe", "status": "active", "venue_format": "cafe"}, + headers=h, + ) + assert venue.status_code == 201, venue.text + venue_id = venue.json()["id"] + + menu = await client.post( + "/api/v1/menus", + json={"venue_id": venue_id, "code": "day", "name": "Day Menu"}, + headers=h, + ) + assert menu.status_code == 201, menu.text + menu_id = menu.json()["id"] + + category = await client.post( + "/api/v1/menu-categories", + json={ + "venue_id": venue_id, + "menu_id": menu_id, + "code": "drinks", + "name": "Drinks", + }, + headers=h, + ) + assert category.status_code == 201, category.text + + item = await client.post( + "/api/v1/menu-items", + json={ + "venue_id": venue_id, + "menu_id": menu_id, + "category_id": category.json()["id"], + "code": item_code, + "name": "Latte", + "base_price": 150000, + }, + headers=h, + ) + assert item.status_code == 201, item.text + return { + "headers": h, + "venue_id": venue_id, + "menu_id": menu_id, + "item_id": item.json()["id"], + "item": item.json(), + } + + +@pytest.mark.asyncio +async def test_qr_menu_ordering_happy_path(client): + seed = await _seed_menu_item(client, TENANT_A) + h = seed["headers"] + venue_id = seed["venue_id"] + menu_id = seed["menu_id"] + item_id = seed["item_id"] + + qr_code = await client.post( + "/api/v1/qr-codes", + json={ + "venue_id": venue_id, + "code": "table-1", + "name": "Table 1 QR", + "target_type": "menu", + "menu_id": menu_id, + "ordering_enabled": True, + }, + headers=h, + ) + assert qr_code.status_code == 201, qr_code.text + qr_code_id = qr_code.json()["id"] + qr_token = qr_code.json()["token"] + assert qr_token + + by_token = await client.get(f"/api/v1/qr-codes/by-token/{qr_token}", headers=h) + assert by_token.status_code == 200, by_token.text + assert by_token.json()["id"] == qr_code_id + + menu_session = await client.post( + "/api/v1/qr-menu-sessions", + json={"venue_id": venue_id, "qr_code_id": qr_code_id, "locale": "fa-IR"}, + headers=h, + ) + assert menu_session.status_code == 201, menu_session.text + menu_session_id = menu_session.json()["id"] + menu_session_token = menu_session.json()["session_token"] + + by_token_ms = await client.get( + f"/api/v1/qr-menu-sessions/by-token/{menu_session_token}", headers=h + ) + assert by_token_ms.status_code == 200, by_token_ms.text + + ordering_session = await client.post( + "/api/v1/qr-ordering-sessions", + json={ + "venue_id": venue_id, + "qr_code_id": qr_code_id, + "qr_menu_session_id": menu_session_id, + }, + headers=h, + ) + assert ordering_session.status_code == 201, ordering_session.text + ordering_session_id = ordering_session.json()["id"] + assert ordering_session.json()["status"] == "draft" + + cart = await client.post( + "/api/v1/carts", + json={"venue_id": venue_id, "ordering_session_id": ordering_session_id}, + headers=h, + ) + assert cart.status_code == 201, cart.text + cart_id = cart.json()["id"] + assert cart.json()["status"] == "active" + + line = await client.post( + "/api/v1/cart-lines", + json={ + "venue_id": venue_id, + "cart_id": cart_id, + "menu_item_id": item_id, + "quantity": 2, + }, + headers=h, + ) + assert line.status_code == 201, line.text + assert line.json()["quantity"] == 2 + assert line.json()["unit_price"] == 150000 + + submitted = await client.post( + f"/api/v1/qr-ordering-sessions/{ordering_session_id}/submit", headers=h + ) + assert submitted.status_code == 200, submitted.text + assert submitted.json()["status"] == "submitted" + + listed = await client.get("/api/v1/qr-codes", headers=h) + assert listed.status_code == 200 + assert len(listed.json()) >= 1 + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.QR_CODE_CREATED.value in published + assert HospitalityEventType.QR_MENU_SESSION_CREATED.value in published + assert HospitalityEventType.QR_ORDERING_SESSION_CREATED.value in published + assert HospitalityEventType.CART_CREATED.value in published + assert HospitalityEventType.CART_LINE_ADDED.value in published + assert HospitalityEventType.QR_ORDERING_SESSION_SUBMITTED.value in published + + +@pytest.mark.asyncio +async def test_qr_code_tenant_isolation(client): + seed = await _seed_menu_item(client, TENANT_A, venue_code="iso-a") + qr_code = await client.post( + "/api/v1/qr-codes", + json={ + "venue_id": seed["venue_id"], + "code": "iso-qr", + "name": "Iso QR", + "target_type": "menu", + "menu_id": seed["menu_id"], + }, + headers=seed["headers"], + ) + assert qr_code.status_code == 201 + qr_code_id = qr_code.json()["id"] + + denied = await client.get( + f"/api/v1/qr-codes/{qr_code_id}", + headers=tenant_headers(TENANT_B), + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_qr_code_requires_menu_id_for_menu_target(client): + seed = await _seed_menu_item(client, TENANT_A, venue_code="val-a", item_code="tea") + bad = await client.post( + "/api/v1/qr-codes", + json={ + "venue_id": seed["venue_id"], + "code": "bad-qr", + "name": "Bad QR", + "target_type": "menu", + }, + headers=seed["headers"], + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_qr_ordering_session_requires_ordering_enabled(client): + seed = await _seed_menu_item(client, TENANT_A, venue_code="ord-a", item_code="mocha") + qr_code = await client.post( + "/api/v1/qr-codes", + json={ + "venue_id": seed["venue_id"], + "code": "no-order", + "name": "No Order QR", + "target_type": "menu", + "menu_id": seed["menu_id"], + "ordering_enabled": False, + }, + headers=seed["headers"], + ) + assert qr_code.status_code == 201, qr_code.text + + bad = await client.post( + "/api/v1/qr-ordering-sessions", + json={"venue_id": seed["venue_id"], "qr_code_id": qr_code.json()["id"]}, + headers=seed["headers"], + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_cart_line_quantity_validation(client): + seed = await _seed_menu_item(client, TENANT_A, venue_code="qty-a", item_code="cake") + h = seed["headers"] + qr_code = await client.post( + "/api/v1/qr-codes", + json={ + "venue_id": seed["venue_id"], + "code": "qty-qr", + "name": "Qty QR", + "target_type": "menu", + "menu_id": seed["menu_id"], + "ordering_enabled": True, + }, + headers=h, + ) + assert qr_code.status_code == 201, qr_code.text + + ordering_session = await client.post( + "/api/v1/qr-ordering-sessions", + json={"venue_id": seed["venue_id"], "qr_code_id": qr_code.json()["id"]}, + headers=h, + ) + assert ordering_session.status_code == 201, ordering_session.text + + cart = await client.post( + "/api/v1/carts", + json={ + "venue_id": seed["venue_id"], + "ordering_session_id": ordering_session.json()["id"], + }, + headers=h, + ) + assert cart.status_code == 201, cart.text + + bad = await client.post( + "/api/v1/cart-lines", + json={ + "venue_id": seed["venue_id"], + "cart_id": cart.json()["id"], + "menu_item_id": seed["item_id"], + "quantity": 0, + }, + headers=h, + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_qr_menu_ordering_still_enabled_in_phase_12_3(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["features"]["qr_menu"] is True + assert body["features"]["qr_ordering"] is True + assert body["features"]["ordering_engine"] is False + assert body["features"]["pos_engine"] is False diff --git a/backend/services/hospitality/app/tests/test_security.py b/backend/services/hospitality/app/tests/test_security.py new file mode 100644 index 0000000..39dce90 --- /dev/null +++ b/backend/services/hospitality/app/tests/test_security.py @@ -0,0 +1,25 @@ +"""Security validation — auth gate when AUTH_REQUIRED is true.""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core.config import settings +from app.main import app +from app.tests.conftest import TENANT_A, tenant_headers + + +@pytest.mark.asyncio +async def test_auth_required_rejects_anonymous(): + original = settings.auth_required + settings.auth_required = True + try: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.get( + "/api/v1/venues", + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 401 + finally: + settings.auth_required = original diff --git a/backend/services/hospitality/app/tests/test_table_service.py b/backend/services/hospitality/app/tests/test_table_service.py new file mode 100644 index 0000000..d0e142a --- /dev/null +++ b/backend/services/hospitality/app/tests/test_table_service.py @@ -0,0 +1,343 @@ +"""Phase 12.3 Table Service & Reservations API / tenant / validation tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HospitalityEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _seed_venue_table(client, tenant_id, *, venue_code: str = "v1", table_code: str = "t1"): + h = tenant_headers(tenant_id) + venue = await client.post( + "/api/v1/venues", + json={"code": venue_code, "name": "Cafe", "status": "active", "venue_format": "cafe"}, + headers=h, + ) + assert venue.status_code == 201, venue.text + venue_id = venue.json()["id"] + + area = await client.post( + "/api/v1/dining-areas", + json={"venue_id": venue_id, "code": f"{table_code}-area", "name": "Main Hall"}, + headers=h, + ) + assert area.status_code == 201, area.text + area_id = area.json()["id"] + + table = await client.post( + "/api/v1/tables", + json={ + "venue_id": venue_id, + "dining_area_id": area_id, + "code": table_code, + "name": "Table", + "capacity": 4, + }, + headers=h, + ) + assert table.status_code == 201, table.text + return { + "headers": h, + "venue_id": venue_id, + "dining_area_id": area_id, + "table_id": table.json()["id"], + } + + +def _window(): + start = datetime.now(timezone.utc) + timedelta(hours=1) + end = start + timedelta(hours=2) + return start.isoformat(), end.isoformat() + + +@pytest.mark.asyncio +async def test_reservation_lifecycle_and_events(client): + seed = await _seed_venue_table(client, TENANT_A, venue_code="res-v1", table_code="res-t1") + h = seed["headers"] + reserved_from, reserved_to = _window() + + reservation = await client.post( + "/api/v1/reservations", + json={ + "venue_id": seed["venue_id"], + "dining_area_id": seed["dining_area_id"], + "table_id": seed["table_id"], + "code": "res-1", + "guest_name": "Ali", + "guest_phone": "09120000000", + "party_size": 2, + "reserved_from": reserved_from, + "reserved_to": reserved_to, + }, + headers=h, + ) + assert reservation.status_code == 201, reservation.text + assert reservation.json()["status"] == "pending" + reservation_id = reservation.json()["id"] + + confirmed = await client.post( + f"/api/v1/reservations/{reservation_id}/status", + json={"status": "confirmed"}, + headers=h, + ) + assert confirmed.status_code == 200, confirmed.text + assert confirmed.json()["status"] == "confirmed" + + listed = await client.get("/api/v1/reservations", headers=h) + assert listed.status_code == 200 + assert len(listed.json()) >= 1 + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.RESERVATION_CREATED.value in published + assert HospitalityEventType.RESERVATION_STATUS_CHANGED.value in published + + +@pytest.mark.asyncio +async def test_reservation_tenant_isolation(client): + seed = await _seed_venue_table(client, TENANT_A, venue_code="iso-v1", table_code="iso-t1") + reserved_from, reserved_to = _window() + reservation = await client.post( + "/api/v1/reservations", + json={ + "venue_id": seed["venue_id"], + "code": "iso-res", + "guest_name": "Sara", + "party_size": 3, + "reserved_from": reserved_from, + "reserved_to": reserved_to, + }, + headers=seed["headers"], + ) + assert reservation.status_code == 201, reservation.text + reservation_id = reservation.json()["id"] + + denied = await client.get( + f"/api/v1/reservations/{reservation_id}", headers=tenant_headers(TENANT_B) + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_reservation_invalid_window_rejected(client): + seed = await _seed_venue_table(client, TENANT_A, venue_code="win-v1", table_code="win-t1") + start = datetime.now(timezone.utc) + timedelta(hours=2) + end = start - timedelta(hours=1) + bad = await client.post( + "/api/v1/reservations", + json={ + "venue_id": seed["venue_id"], + "code": "bad-res", + "guest_name": "Neda", + "party_size": 2, + "reserved_from": start.isoformat(), + "reserved_to": end.isoformat(), + }, + headers=seed["headers"], + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_reservation_status_final_cannot_change(client): + seed = await _seed_venue_table(client, TENANT_A, venue_code="fin-v1", table_code="fin-t1") + h = seed["headers"] + reserved_from, reserved_to = _window() + reservation = await client.post( + "/api/v1/reservations", + json={ + "venue_id": seed["venue_id"], + "code": "fin-res", + "guest_name": "Reza", + "party_size": 2, + "reserved_from": reserved_from, + "reserved_to": reserved_to, + }, + headers=h, + ) + assert reservation.status_code == 201, reservation.text + reservation_id = reservation.json()["id"] + + cancelled = await client.post( + f"/api/v1/reservations/{reservation_id}/status", + json={"status": "cancelled"}, + headers=h, + ) + assert cancelled.status_code == 200, cancelled.text + + blocked = await client.post( + f"/api/v1/reservations/{reservation_id}/status", + json={"status": "confirmed"}, + headers=h, + ) + assert blocked.status_code == 409 + + +@pytest.mark.asyncio +async def test_table_assignment_lifecycle_and_active_conflict(client): + seed = await _seed_venue_table(client, TENANT_A, venue_code="ta-v1", table_code="ta-t1") + h = seed["headers"] + + assignment = await client.post( + "/api/v1/table-assignments", + json={ + "venue_id": seed["venue_id"], + "table_id": seed["table_id"], + "guest_label": "Walk-in", + }, + headers=h, + ) + assert assignment.status_code == 201, assignment.text + assert assignment.json()["status"] == "active" + assignment_id = assignment.json()["id"] + + conflict = await client.post( + "/api/v1/table-assignments", + json={"venue_id": seed["venue_id"], "table_id": seed["table_id"]}, + headers=h, + ) + assert conflict.status_code == 409 + + released = await client.post( + f"/api/v1/table-assignments/{assignment_id}/release", headers=h + ) + assert released.status_code == 200, released.text + assert released.json()["status"] == "released" + + again = await client.post( + "/api/v1/table-assignments", + json={"venue_id": seed["venue_id"], "table_id": seed["table_id"]}, + headers=h, + ) + assert again.status_code == 201, again.text + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.TABLE_ASSIGNMENT_CREATED.value in published + + +@pytest.mark.asyncio +async def test_service_request_lifecycle(client): + seed = await _seed_venue_table(client, TENANT_A, venue_code="sr-v1", table_code="sr-t1") + h = seed["headers"] + + request = await client.post( + "/api/v1/service-requests", + json={ + "venue_id": seed["venue_id"], + "table_id": seed["table_id"], + "code": "sr-1", + "kind": "call_waiter", + }, + headers=h, + ) + assert request.status_code == 201, request.text + assert request.json()["status"] == "open" + request_id = request.json()["id"] + + acknowledged = await client.post( + f"/api/v1/service-requests/{request_id}/status", + json={"status": "acknowledged"}, + headers=h, + ) + assert acknowledged.status_code == 200, acknowledged.text + + closed = await client.post( + f"/api/v1/service-requests/{request_id}/status", + json={"status": "closed"}, + headers=h, + ) + assert closed.status_code == 200, closed.text + + blocked = await client.post( + f"/api/v1/service-requests/{request_id}/status", + json={"status": "open"}, + headers=h, + ) + assert blocked.status_code == 409 + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.SERVICE_REQUEST_CREATED.value in published + + +@pytest.mark.asyncio +async def test_service_request_duplicate_code_rejected(client): + seed = await _seed_venue_table(client, TENANT_A, venue_code="sr-dup-v1", table_code="sr-dup-t1") + h = seed["headers"] + body = { + "venue_id": seed["venue_id"], + "table_id": seed["table_id"], + "code": "dup-sr", + "kind": "water", + } + first = await client.post("/api/v1/service-requests", json=body, headers=h) + assert first.status_code == 201, first.text + second = await client.post("/api/v1/service-requests", json=body, headers=h) + assert second.status_code == 409 + + +@pytest.mark.asyncio +async def test_waitlist_entry_lifecycle(client): + seed = await _seed_venue_table(client, TENANT_A, venue_code="wl-v1", table_code="wl-t1") + h = seed["headers"] + + entry = await client.post( + "/api/v1/waitlist", + json={ + "venue_id": seed["venue_id"], + "code": "wl-1", + "guest_name": "Mina", + "party_size": 4, + "phone": "09121111111", + }, + headers=h, + ) + assert entry.status_code == 201, entry.text + assert entry.json()["status"] == "waiting" + entry_id = entry.json()["id"] + + seated = await client.post( + f"/api/v1/waitlist/{entry_id}/status", + json={"status": "seated"}, + headers=h, + ) + assert seated.status_code == 200, seated.text + assert seated.json()["status"] == "seated" + + blocked = await client.post( + f"/api/v1/waitlist/{entry_id}/status", + json={"status": "waiting"}, + headers=h, + ) + assert blocked.status_code == 409 + + published = {e.event_type for e in get_event_publisher().published} + assert HospitalityEventType.WAITLIST_ENTRY_CREATED.value in published + + +@pytest.mark.asyncio +async def test_waitlist_entry_party_size_validation(client): + seed = await _seed_venue_table(client, TENANT_A, venue_code="wl-val-v1", table_code="wl-val-t1") + bad = await client.post( + "/api/v1/waitlist", + json={ + "venue_id": seed["venue_id"], + "code": "wl-bad", + "guest_name": "Kian", + "party_size": 0, + }, + headers=seed["headers"], + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_capabilities_phase_12_3(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["features"]["table_service"] is True + assert body["features"]["reservation"] is True + assert body["features"]["pos_engine"] is False diff --git a/backend/services/hospitality/app/validators/__init__.py b/backend/services/hospitality/app/validators/__init__.py new file mode 100644 index 0000000..ea7cbea --- /dev/null +++ b/backend/services/hospitality/app/validators/__init__.py @@ -0,0 +1,68 @@ +"""Hospitality validators package.""" +from app.validators.foundation import ( + ensure_optimistic_version, + forbid_format_specific_engines, + validate_bundle_key, + validate_bundle_status, + validate_code, + validate_connector_kind, + validate_connector_status, + validate_currency_code, + validate_dispatch_status, + validate_feature_key, + validate_file_ref, + validate_kitchen_item_status, + validate_kitchen_ticket_status, + validate_lifecycle_status, + validate_locale, + validate_menu_status, + validate_modifier_selection, + validate_non_empty, + validate_non_negative_int, + validate_positive_int, + validate_reservation_status, + validate_reservation_window, + validate_service_request_kind, + validate_service_request_status, + validate_setting_key, + validate_table_assignment_status, + validate_table_status, + validate_time_range, + validate_venue_format, + validate_waitlist_status, + validate_weekday, +) + +__all__ = [ + "validate_non_empty", + "validate_code", + "validate_setting_key", + "validate_feature_key", + "validate_currency_code", + "validate_non_negative_int", + "validate_positive_int", + "validate_lifecycle_status", + "validate_venue_format", + "validate_bundle_key", + "validate_bundle_status", + "validate_menu_status", + "validate_table_status", + "ensure_optimistic_version", + "forbid_format_specific_engines", + "validate_time_range", + "validate_weekday", + "validate_locale", + "validate_file_ref", + "validate_modifier_selection", + "validate_reservation_status", + "validate_reservation_window", + "validate_table_assignment_status", + "validate_service_request_kind", + "validate_service_request_status", + "validate_waitlist_status", + "validate_kitchen_ticket_status", + "validate_kitchen_item_status", + "validate_connector_kind", + "validate_connector_status", + "validate_dispatch_status", +] diff --git a/backend/services/hospitality/app/validators/foundation.py b/backend/services/hospitality/app/validators/foundation.py new file mode 100644 index 0000000..abf2cdb --- /dev/null +++ b/backend/services/hospitality/app/validators/foundation.py @@ -0,0 +1,409 @@ +"""Phase 12.0 Hospitality validators.""" +from __future__ import annotations + +import re +from datetime import datetime + +from shared.exceptions import AppError + +from app.models.types import ( + BundleKey, + BundleStatus, + ConnectorKind, + ConnectorStatus, + DispatchStatus, + KitchenItemStatus, + KitchenTicketStatus, + LifecycleStatus, + MenuStatus, + ModifierSelectionMode, + ReservationStatus, + ServiceRequestKind, + ServiceRequestStatus, + TableAssignmentStatus, + TableStatus, + VenueFormat, + WaitlistStatus, +) + +CODE_RE = re.compile(r"^[A-Za-z0-9_\-]{2,50}$") +SETTING_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,100}$") +FEATURE_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,100}$") + + +def validate_non_empty(value: str | None, *, field: str) -> str: + if value is None or not str(value).strip(): + raise AppError( + f"{field} الزامی است", + status_code=422, + error_code="validation_error", + details={"field": field}, + ) + return str(value).strip() + + +def validate_code(code: str | None, *, field: str = "code") -> str: + cleaned = validate_non_empty(code, field=field).upper() + if not CODE_RE.match(cleaned): + raise AppError( + f"فرمت {field} نامعتبر است", + status_code=422, + error_code="invalid_code", + details={"field": field}, + ) + return cleaned + + +def validate_setting_key(key: str | None) -> str: + cleaned = validate_non_empty(key, field="key") + if not SETTING_KEY_RE.match(cleaned): + raise AppError( + "فرمت key نامعتبر است", + status_code=422, + error_code="invalid_setting_key", + ) + return cleaned + + +def validate_feature_key(key: str | None) -> str: + cleaned = validate_non_empty(key, field="feature_key") + if not FEATURE_KEY_RE.match(cleaned): + raise AppError( + "فرمت feature_key نامعتبر است", + status_code=422, + error_code="invalid_feature_key", + ) + return cleaned + + +def validate_currency_code(code: str) -> str: + cleaned = code.strip().upper() + if len(cleaned) != 3: + raise AppError( + "کد ارز باید ۳ حرفی باشد", + status_code=422, + error_code="invalid_currency_code", + ) + return cleaned + + +def validate_non_negative_int(value: int | None, *, field: str) -> int | None: + if value is None: + return None + resolved = int(value) + if resolved < 0: + raise AppError( + f"{field} نمی‌تواند منفی باشد", + status_code=422, + error_code="invalid_amount", + details={"field": field}, + ) + return resolved + + +def validate_positive_int(value: int | None, *, field: str) -> int: + if value is None: + raise AppError( + f"{field} الزامی است", + status_code=422, + error_code="validation_error", + details={"field": field}, + ) + resolved = int(value) + if resolved < 1: + raise AppError( + f"{field} باید حداقل ۱ باشد", + status_code=422, + error_code="invalid_amount", + details={"field": field}, + ) + return resolved + + +def _enum_or_error(enum_cls, value, *, error_code: str, message: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(value) + except ValueError as exc: + raise AppError(message, status_code=422, error_code=error_code) from exc + + +def validate_lifecycle_status(status: LifecycleStatus | str) -> LifecycleStatus: + return _enum_or_error( + LifecycleStatus, + status, + error_code="invalid_lifecycle_status", + message="وضعیت نامعتبر است", + ) + + +def validate_venue_format(value: VenueFormat | str) -> VenueFormat: + return _enum_or_error( + VenueFormat, + value, + error_code="invalid_venue_format", + message="فرمت واحد پذیرایی نامعتبر است", + ) + + +def validate_bundle_key(value: BundleKey | str) -> BundleKey: + return _enum_or_error( + BundleKey, + value, + error_code="invalid_bundle_key", + message="کلید بسته نامعتبر است", + ) + + +def validate_bundle_status(value: BundleStatus | str) -> BundleStatus: + return _enum_or_error( + BundleStatus, + value, + error_code="invalid_bundle_status", + message="وضعیت بسته نامعتبر است", + ) + + +def validate_menu_status(value: MenuStatus | str) -> MenuStatus: + return _enum_or_error( + MenuStatus, + value, + error_code="invalid_menu_status", + message="وضعیت منو نامعتبر است", + ) + + +def validate_table_status(value: TableStatus | str) -> TableStatus: + return _enum_or_error( + TableStatus, + value, + error_code="invalid_table_status", + message="وضعیت میز نامعتبر است", + ) + + +def ensure_optimistic_version(entity_version: int, expected: int | None) -> None: + if expected is None: + return + if entity_version != expected: + raise AppError( + "نسخه رکورد تغییر کرده است؛ دوباره تلاش کنید", + status_code=409, + error_code="version_conflict", + details={"expected": expected, "actual": entity_version}, + ) + + +def forbid_format_specific_engines(payload: dict) -> None: + """Reject payloads that inject format-specific engine keys into foundation.""" + forbidden = { + "pos_engine", + "kitchen_engine", + "ordering_engine", + "hardcoded_venue_rules", + "vendor_sdk", + } + present = forbidden.intersection(payload.keys()) + if present: + raise AppError( + "موتورهای اختصاصی یا SDK فروشنده در لایه foundation مجاز نیست", + status_code=422, + error_code="format_specific_forbidden", + details={"fields": sorted(present)}, + ) + + +TIME_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$") +LOCALE_RE = re.compile(r"^[a-z]{2}(-[A-Z]{2})?$") + + +def validate_time_hhmm(value: str, *, field: str) -> str: + cleaned = validate_non_empty(value, field=field) + if not TIME_RE.match(cleaned): + raise AppError( + f"فرمت {field} باید HH:MM یا HH:MM:SS باشد", + status_code=422, + error_code="invalid_time", + details={"field": field}, + ) + if len(cleaned) == 5: + return f"{cleaned}:00" + return cleaned + + +def validate_time_range(start_time: str, end_time: str) -> tuple[str, str]: + start = validate_time_hhmm(start_time, field="start_time") + end = validate_time_hhmm(end_time, field="end_time") + if end <= start: + raise AppError( + "زمان پایان باید بعد از زمان شروع باشد", + status_code=422, + error_code="invalid_time_range", + ) + return start, end + + +def validate_weekday(value: int | None) -> int | None: + if value is None: + return None + if value < 0 or value > 6: + raise AppError( + "weekday باید بین ۰ تا ۶ باشد", + status_code=422, + error_code="invalid_weekday", + ) + return value + + +def validate_locale(locale: str) -> str: + cleaned = validate_non_empty(locale, field="locale") + if not LOCALE_RE.match(cleaned): + raise AppError( + "فرمت locale نامعتبر است", + status_code=422, + error_code="invalid_locale", + ) + return cleaned + + +def validate_file_ref(file_ref: str) -> str: + cleaned = validate_non_empty(file_ref, field="file_ref") + if len(cleaned) > 255: + raise AppError( + "file_ref بیش از حد طولانی است", + status_code=422, + error_code="invalid_file_ref", + ) + return cleaned + + +def validate_modifier_selection( + mode: ModifierSelectionMode | str, + *, + min_select: int, + max_select: int | None, + is_required: bool, +) -> tuple[ModifierSelectionMode, int, int | None]: + resolved = _enum_or_error( + ModifierSelectionMode, + mode, + error_code="invalid_selection_mode", + message="حالت انتخاب modifier نامعتبر است", + ) + min_v = validate_non_negative_int(min_select, field="min_select") or 0 + if is_required and min_v < 1: + min_v = 1 + max_v = None + if max_select is not None: + max_v = validate_non_negative_int(max_select, field="max_select") + if max_v is not None and max_v < min_v: + raise AppError( + "max_select نمی‌تواند کمتر از min_select باشد", + status_code=422, + error_code="invalid_selection_bounds", + ) + return resolved, min_v, max_v + + +# Phase 12.3 — Table Service & Reservations +def validate_reservation_status(value: ReservationStatus | str) -> ReservationStatus: + return _enum_or_error( + ReservationStatus, + value, + error_code="invalid_reservation_status", + message="وضعیت رزرو نامعتبر است", + ) + + +def validate_table_assignment_status(value: TableAssignmentStatus | str) -> TableAssignmentStatus: + return _enum_or_error( + TableAssignmentStatus, + value, + error_code="invalid_table_assignment_status", + message="وضعیت تخصیص میز نامعتبر است", + ) + + +def validate_service_request_kind(value: ServiceRequestKind | str) -> ServiceRequestKind: + return _enum_or_error( + ServiceRequestKind, + value, + error_code="invalid_service_request_kind", + message="نوع درخواست خدمت نامعتبر است", + ) + + +def validate_service_request_status(value: ServiceRequestStatus | str) -> ServiceRequestStatus: + return _enum_or_error( + ServiceRequestStatus, + value, + error_code="invalid_service_request_status", + message="وضعیت درخواست خدمت نامعتبر است", + ) + + +def validate_waitlist_status(value: WaitlistStatus | str) -> WaitlistStatus: + return _enum_or_error( + WaitlistStatus, + value, + error_code="invalid_waitlist_status", + message="وضعیت لیست انتظار نامعتبر است", + ) + + +def validate_reservation_window(reserved_from: datetime, reserved_to: datetime) -> tuple[datetime, datetime]: + if reserved_to <= reserved_from: + raise AppError( + "زمان پایان رزرو باید بعد از زمان شروع باشد", + status_code=422, + error_code="invalid_reservation_window", + ) + return reserved_from, reserved_to + + +# Phase 12.6 — Kitchen +def validate_kitchen_ticket_status(value: KitchenTicketStatus | str) -> KitchenTicketStatus: + return _enum_or_error( + KitchenTicketStatus, + value, + error_code="invalid_kitchen_ticket_status", + message="وضعیت تیکت آشپزخانه نامعتبر است", + ) + + +def validate_kitchen_item_status(value: KitchenItemStatus | str) -> KitchenItemStatus: + return _enum_or_error( + KitchenItemStatus, + value, + error_code="invalid_kitchen_item_status", + message="وضعیت آیتم آشپزخانه نامعتبر است", + ) + + +# Phase 12.7 — Connectors +def validate_connector_kind(value: ConnectorKind | str) -> ConnectorKind: + return _enum_or_error( + ConnectorKind, + value, + error_code="invalid_connector_kind", + message="نوع رابط اتصال نامعتبر است", + ) + + +def validate_connector_status(value: ConnectorStatus | str) -> ConnectorStatus: + return _enum_or_error( + ConnectorStatus, + value, + error_code="invalid_connector_status", + message="وضعیت رابط اتصال نامعتبر است", + ) + + +def validate_dispatch_status(value: DispatchStatus | str) -> DispatchStatus: + return _enum_or_error( + DispatchStatus, + value, + error_code="invalid_dispatch_status", + message="وضعیت ارسال نامعتبر است", + ) diff --git a/backend/services/hospitality/scripts/ensure_db.py b/backend/services/hospitality/scripts/ensure_db.py new file mode 100644 index 0000000..048ec18 --- /dev/null +++ b/backend/services/hospitality/scripts/ensure_db.py @@ -0,0 +1,41 @@ +"""Ensure hospitality_db exists before migration.""" +from __future__ import annotations + +import os +import sys +from urllib.parse import urlparse + + +def main() -> None: + sync_url = os.environ.get("HOSPITALITY_DATABASE_URL_SYNC", "") + if not sync_url: + print("HOSPITALITY_DATABASE_URL_SYNC not set", file=sys.stderr) + return + + parsed = urlparse(sync_url.replace("+psycopg", "")) + db_name = (parsed.path or "").lstrip("/") or "hospitality_db" + + import psycopg + + conn = psycopg.connect( + host=parsed.hostname or "localhost", + port=parsed.port or 5432, + user=parsed.username, + password=parsed.password, + dbname="postgres", + autocommit=True, + ) + try: + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) + if cur.fetchone() is None: + cur.execute(f'CREATE DATABASE "{db_name}"') + print(f"Created database: {db_name}") + else: + print(f"Database exists: {db_name}") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/services/restaurant/README.md b/backend/services/restaurant/README.md index 8bca391..b4b5a79 100644 --- a/backend/services/restaurant/README.md +++ b/backend/services/restaurant/README.md @@ -1,23 +1,13 @@ -# Restaurant / Cafe Management Service (Placeholder) +# Restaurant / Cafe Management Service (Pointer) -> وضعیت: طراحی‌شده در معماری، پیاده‌سازی در فازهای بعدی. +> Evolved into **Hospitality Platform (Torbat Food)**. -## مسئولیت‌ها -- منوی دیجیتال -- میزها -- سفارش‌ها -- آشپزخانه -- پرداخت -- باشگاه مشتریان +Runtime and implementation live at: -## اصول -- دیتابیس مستقل (`restaurant_db`) با `tenant_id` در جداول بیزینسی. -- ارتباط با سایر سرویس‌ها فقط از طریق API/Event. -- بررسی دسترسی قابلیت‌ها از Core (`restaurant.*`). +[`backend/services/hospitality`](../hospitality/README.md) - -## Related Documents - -- [Module Registry](../../../docs/module-registry.md#restaurant) -- [Services Contracts](../../../docs/reference/services-contracts.md) -- [Architecture](../../../docs/architecture/architecture.md) +- Database: `hospitality_db` +- Port: 8009 +- Permissions: `hospitality.*` +- Phase: 12.0 Foundation +- ADR: [ADR-017](../../../docs/architecture/adr/ADR-017.md) diff --git a/backend/services/sports_center/README.md b/backend/services/sports_center/README.md index 3a612fc..5360da7 100644 --- a/backend/services/sports_center/README.md +++ b/backend/services/sports_center/README.md @@ -2,8 +2,8 @@ Independent enterprise microservice for generic sports center operations. -- Phase: **9.2 Member Management** -- Version: `0.9.2.0` +- Phase: **9.7 Competition & Event Management** +- Version: `0.9.7.0` - Database: `sports_center_db` (sole owner) - API port: `8006` - Permission prefix: `sports_center.*` @@ -27,7 +27,9 @@ Automation, or Customer360 — consume those via API + Events only. ## Docs -- [docs/sports-center-phase-9-2.md](../../../docs/sports-center-phase-9-2.md) +- [docs/sports-center-phase-9-7.md](../../../docs/sports-center-phase-9-7.md) +- [docs/sports-center-phase-9-6.md](../../../docs/sports-center-phase-9-6.md) +- [docs/sports-center-roadmap.md](../../../docs/sports-center-roadmap.md) - [docs/phase-handover/phase-9-2.md](../../../docs/phase-handover/phase-9-2.md) - [docs/sports-center-phase-9-1.md](../../../docs/sports-center-phase-9-1.md) - [docs/sports-center-phase-9-0.md](../../../docs/sports-center-phase-9-0.md) diff --git a/backend/services/sports_center/alembic/versions/0004_phase_93_coach_staff.py b/backend/services/sports_center/alembic/versions/0004_phase_93_coach_staff.py new file mode 100644 index 0000000..2f24693 --- /dev/null +++ b/backend/services/sports_center/alembic/versions/0004_phase_93_coach_staff.py @@ -0,0 +1,50 @@ +"""Phase 9.3 — Coach & Staff Management schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0004_phase_93_coach_staff" +down_revision = "0003_phase_92_member_management" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + columns = {c["name"] for c in sa.inspect(bind).get_columns("coaches")} + with op.batch_alter_table("coaches") as batch: + for col_name, col_type in ( + ("staff_kind", sa.String(32)), + ("bio", sa.Text()), + ("hire_date", sa.Date()), + ("payroll_external_ref", sa.String(100)), + ("sports_role_id", sa.String(36)), + ): + if col_name not in columns: + batch.add_column(sa.Column(col_name, col_type, nullable=True)) + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "staff_assignments", + "staff_availability", + "staff_working_hours", + "staff_skills", + "staff_certificates", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) + columns = {c["name"] for c in sa.inspect(bind).get_columns("coaches")} + with op.batch_alter_table("coaches") as batch: + for col in ( + "staff_kind", + "bio", + "hire_date", + "payroll_external_ref", + "sports_role_id", + ): + if col in columns: + batch.drop_column(col) diff --git a/backend/services/sports_center/alembic/versions/0005_phase_94_booking.py b/backend/services/sports_center/alembic/versions/0005_phase_94_booking.py new file mode 100644 index 0000000..adfc905 --- /dev/null +++ b/backend/services/sports_center/alembic/versions/0005_phase_94_booking.py @@ -0,0 +1,28 @@ +"""Phase 9.4 — Scheduling & Booking schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0005_phase_94_booking" +down_revision = "0004_phase_93_coach_staff" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "resource_reservations", + "waiting_list_entries", + "bookings", + "sports_sessions", + "equipment", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) diff --git a/backend/services/sports_center/alembic/versions/0006_phase_95_attendance.py b/backend/services/sports_center/alembic/versions/0006_phase_95_attendance.py new file mode 100644 index 0000000..288eb23 --- /dev/null +++ b/backend/services/sports_center/alembic/versions/0006_phase_95_attendance.py @@ -0,0 +1,22 @@ +"""Phase 9.5 — Attendance & Access Control schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0006_phase_95_attendance" +down_revision = "0005_phase_94_booking" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ("access_logs", "attendance_records"): + if sa.inspect(bind).has_table(table): + op.drop_table(table) diff --git a/backend/services/sports_center/alembic/versions/0007_phase_96_training.py b/backend/services/sports_center/alembic/versions/0007_phase_96_training.py new file mode 100644 index 0000000..889faf7 --- /dev/null +++ b/backend/services/sports_center/alembic/versions/0007_phase_96_training.py @@ -0,0 +1,34 @@ +"""Phase 9.6 — Training Management schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0007_phase_96_training" +down_revision = "0006_phase_95_attendance" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "body_compositions", + "performance_records", + "assessments", + "nutrition_plans", + "progress_records", + "measurements", + "training_goals", + "workout_assignments", + "workout_plans", + "exercises", + "training_programs", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) diff --git a/backend/services/sports_center/alembic/versions/0008_phase_97_competitions.py b/backend/services/sports_center/alembic/versions/0008_phase_97_competitions.py new file mode 100644 index 0000000..d33bd0e --- /dev/null +++ b/backend/services/sports_center/alembic/versions/0008_phase_97_competitions.py @@ -0,0 +1,33 @@ +"""Phase 9.7 — Competition & Event Management schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0008_phase_97_competitions" +down_revision = "0007_phase_96_training" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "competition_certificates", + "competition_judges", + "competition_medals", + "competition_rankings", + "competition_results", + "competition_matches", + "competition_registrations", + "competition_groups", + "competition_teams", + "competitions", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) diff --git a/backend/services/sports_center/app/__init__.py b/backend/services/sports_center/app/__init__.py index 63dffb6..9bedb6f 100644 --- a/backend/services/sports_center/app/__init__.py +++ b/backend/services/sports_center/app/__init__.py @@ -1 +1 @@ -__version__ = "0.9.2.0" +__version__ = "0.9.7.0" diff --git a/backend/services/sports_center/app/api/v1/__init__.py b/backend/services/sports_center/app/api/v1/__init__.py index 8e8dd3b..935a9b2 100644 --- a/backend/services/sports_center/app/api/v1/__init__.py +++ b/backend/services/sports_center/app/api/v1/__init__.py @@ -1,10 +1,13 @@ from fastapi import APIRouter from app.api.v1 import ( + attendance, attendance_gateways, + booking, branches, catalog, coaches, + competitions, configurations, courts, device_providers, @@ -22,6 +25,8 @@ from app.api.v1 import ( settings, sports, sports_centers, + staff, + training, ) api_router = APIRouter() @@ -61,3 +66,38 @@ api_router.include_router(attendance_gateways.router, prefix="/attendance-gatewa api_router.include_router(configurations.router, prefix="/configurations", tags=["configurations"]) api_router.include_router(events.router, prefix="/events", tags=["events"]) api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) +api_router.include_router(staff.certificates_router, prefix="/staff-certificates", tags=["staff-certificates"]) +api_router.include_router(staff.skills_router, prefix="/staff-skills", tags=["staff-skills"]) +api_router.include_router(staff.working_hours_router, prefix="/staff-working-hours", tags=["staff-working-hours"]) +api_router.include_router(staff.availability_router, prefix="/staff-availability", tags=["staff-availability"]) +api_router.include_router(staff.assignments_router, prefix="/staff-assignments", tags=["staff-assignments"]) +api_router.include_router(booking.equipment_router, prefix="/equipment", tags=["equipment"]) +api_router.include_router(booking.sessions_router, prefix="/sessions", tags=["sessions"]) +api_router.include_router(booking.bookings_router, prefix="/bookings", tags=["bookings"]) +api_router.include_router(booking.waiting_list_router, prefix="/waiting-list", tags=["waiting-list"]) +api_router.include_router(booking.reservations_router, prefix="/resource-reservations", tags=["resource-reservations"]) +api_router.include_router(attendance.check_in_router, prefix="/check-in", tags=["check-in"]) +api_router.include_router(attendance.check_out_router, prefix="/check-out", tags=["check-out"]) +api_router.include_router(attendance.attendance_router, prefix="/attendance-records", tags=["attendance-records"]) +api_router.include_router(attendance.access_logs_router, prefix="/access-logs", tags=["access-logs"]) +api_router.include_router(training.programs_router, prefix="/training-programs", tags=["training-programs"]) +api_router.include_router(training.exercises_router, prefix="/exercises", tags=["exercises"]) +api_router.include_router(training.workout_plans_router, prefix="/workout-plans", tags=["workout-plans"]) +api_router.include_router(training.workout_assignments_router, prefix="/workout-assignments", tags=["workout-assignments"]) +api_router.include_router(training.goals_router, prefix="/training-goals", tags=["training-goals"]) +api_router.include_router(training.measurements_router, prefix="/measurements", tags=["measurements"]) +api_router.include_router(training.progress_router, prefix="/progress-records", tags=["progress-records"]) +api_router.include_router(training.nutrition_router, prefix="/nutrition-plans", tags=["nutrition-plans"]) +api_router.include_router(training.assessments_router, prefix="/assessments", tags=["assessments"]) +api_router.include_router(training.performance_router, prefix="/performance-records", tags=["performance-records"]) +api_router.include_router(training.body_composition_router, prefix="/body-compositions", tags=["body-compositions"]) +api_router.include_router(competitions.competitions_router, prefix="/competitions", tags=["competitions"]) +api_router.include_router(competitions.teams_router, prefix="/competition-teams", tags=["competition-teams"]) +api_router.include_router(competitions.groups_router, prefix="/competition-groups", tags=["competition-groups"]) +api_router.include_router(competitions.registrations_router, prefix="/competition-registrations", tags=["competition-registrations"]) +api_router.include_router(competitions.matches_router, prefix="/competition-matches", tags=["competition-matches"]) +api_router.include_router(competitions.results_router, prefix="/competition-results", tags=["competition-results"]) +api_router.include_router(competitions.rankings_router, prefix="/competition-rankings", tags=["competition-rankings"]) +api_router.include_router(competitions.medals_router, prefix="/competition-medals", tags=["competition-medals"]) +api_router.include_router(competitions.judges_router, prefix="/competition-judges", tags=["competition-judges"]) +api_router.include_router(competitions.certificates_router, prefix="/competition-certificates", tags=["competition-certificates"]) diff --git a/backend/services/sports_center/app/api/v1/attendance.py b/backend/services/sports_center/app/api/v1/attendance.py new file mode 100644 index 0000000..c43a1c4 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/attendance.py @@ -0,0 +1,50 @@ +"""Attendance & Access Control APIs — Phase 9.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.core.security import get_current_user +from app.schemas.attendance import ( + AccessLogRead, + AttendanceRecordCreate, + AttendanceRecordRead, + CheckInRequest, + CheckOutRequest, +) +from app.services.attendance import AttendanceService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +attendance_router = APIRouter() +access_logs_router = APIRouter() +check_in_router = APIRouter() +check_out_router = APIRouter() + + +@check_in_router.post("", response_model=AttendanceRecordRead, status_code=status.HTTP_201_CREATED) +async def check_in(body: CheckInRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await AttendanceService(db).check_in(tenant_id, body, actor=user) + + +@check_out_router.post("", response_model=AttendanceRecordRead, status_code=status.HTTP_201_CREATED) +async def check_out(body: CheckOutRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await AttendanceService(db).check_out(tenant_id, body, actor=user) + + +@attendance_router.post("", response_model=AttendanceRecordRead, status_code=status.HTTP_201_CREATED) +async def create_manual_attendance(body: AttendanceRecordCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await AttendanceService(db).create_manual(tenant_id, body, actor=user) + + +@attendance_router.get("", response_model=list[AttendanceRecordRead]) +async def list_attendance(member_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await AttendanceService(db).list_records(tenant_id, member_id, offset=pagination.offset, limit=pagination.page_size) + + +@access_logs_router.get("", response_model=list[AccessLogRead]) +async def list_access_logs(member_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await AttendanceService(db).list_access_logs(tenant_id, member_id, offset=pagination.offset, limit=pagination.page_size) diff --git a/backend/services/sports_center/app/api/v1/booking.py b/backend/services/sports_center/app/api/v1/booking.py new file mode 100644 index 0000000..5e05188 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/booking.py @@ -0,0 +1,85 @@ +"""Scheduling & Booking APIs — Phase 9.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.core.security import get_current_user +from app.schemas.booking import ( + BookingCancelRequest, + BookingCreate, + BookingRead, + EquipmentCreate, + EquipmentRead, + EquipmentUpdate, + ResourceReservationCreate, + ResourceReservationRead, + SportsSessionCreate, + SportsSessionRead, + SportsSessionUpdate, + WaitingListCreate, + WaitingListRead, +) +from app.services.booking import ( + BookingService, + EquipmentService, + ResourceReservationService, + SportsSessionService, + WaitingListService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +equipment_router = APIRouter() +sessions_router = APIRouter() +bookings_router = APIRouter() +waiting_list_router = APIRouter() +reservations_router = APIRouter() + + +@equipment_router.post("", response_model=EquipmentRead, status_code=status.HTTP_201_CREATED) +async def create_equipment(body: EquipmentCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await EquipmentService(db).create(tenant_id, body, actor=user) + + +@equipment_router.get("", response_model=list[EquipmentRead]) +async def list_equipment(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await EquipmentService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@sessions_router.post("", response_model=SportsSessionRead, status_code=status.HTTP_201_CREATED) +async def create_session(body: SportsSessionCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await SportsSessionService(db).create(tenant_id, body, actor=user) + + +@sessions_router.get("", response_model=list[SportsSessionRead]) +async def list_sessions(sports_center_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await SportsSessionService(db).list(tenant_id, sports_center_id, offset=pagination.offset, limit=pagination.page_size) + + +@bookings_router.post("", response_model=BookingRead, status_code=status.HTTP_201_CREATED) +async def create_booking(body: BookingCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await BookingService(db).create(tenant_id, body, actor=user) + + +@bookings_router.post("/{booking_id}/confirm", response_model=BookingRead) +async def confirm_booking(booking_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await BookingService(db).confirm(tenant_id, booking_id, actor=user) + + +@bookings_router.post("/{booking_id}/cancel", response_model=BookingRead) +async def cancel_booking(booking_id: UUID, body: BookingCancelRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await BookingService(db).cancel(tenant_id, booking_id, body, actor=user) + + +@waiting_list_router.post("", response_model=WaitingListRead, status_code=status.HTTP_201_CREATED) +async def join_waiting_list(body: WaitingListCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await WaitingListService(db).join(tenant_id, body, actor=user) + + +@reservations_router.post("", response_model=ResourceReservationRead, status_code=status.HTTP_201_CREATED) +async def create_reservation(body: ResourceReservationCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await ResourceReservationService(db).create(tenant_id, body, actor=user) diff --git a/backend/services/sports_center/app/api/v1/competitions.py b/backend/services/sports_center/app/api/v1/competitions.py new file mode 100644 index 0000000..a79edb3 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/competitions.py @@ -0,0 +1,126 @@ +"""Competition & Event Management APIs — Phase 9.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.core.security import get_current_user +from app.schemas.competitions import ( + CompetitionCertificateCreate, + CompetitionCertificateRead, + CompetitionCreate, + CompetitionGroupCreate, + CompetitionGroupRead, + CompetitionJudgeCreate, + CompetitionJudgeRead, + CompetitionMatchCreate, + CompetitionMatchRead, + CompetitionMedalCreate, + CompetitionMedalRead, + CompetitionRankingRead, + CompetitionRead, + CompetitionRegistrationCreate, + CompetitionRegistrationRead, + CompetitionResultCreate, + CompetitionResultRead, + CompetitionTeamCreate, + CompetitionTeamRead, + CompetitionUpdate, +) +from app.services.competitions import ( + CompetitionCertificateService, + CompetitionGroupService, + CompetitionJudgeService, + CompetitionMatchService, + CompetitionMedalService, + CompetitionRegistrationService, + CompetitionResultService, + CompetitionService, + CompetitionTeamService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +competitions_router = APIRouter() +teams_router = APIRouter() +groups_router = APIRouter() +registrations_router = APIRouter() +matches_router = APIRouter() +results_router = APIRouter() +rankings_router = APIRouter() +medals_router = APIRouter() +judges_router = APIRouter() +certificates_router = APIRouter() + + +@competitions_router.post("", response_model=CompetitionRead, status_code=status.HTTP_201_CREATED) +async def create_competition(body: CompetitionCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionService(db).create(tenant_id, body, actor=user) + + +@competitions_router.get("", response_model=list[CompetitionRead]) +async def list_competitions(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await CompetitionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@competitions_router.patch("/{competition_id}", response_model=CompetitionRead) +async def update_competition(competition_id: UUID, body: CompetitionUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionService(db).update(tenant_id, competition_id, body, actor=user) + + +@teams_router.post("", response_model=CompetitionTeamRead, status_code=status.HTTP_201_CREATED) +async def create_team(body: CompetitionTeamCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionTeamService(db).create(tenant_id, body, actor=user) + + +@teams_router.get("", response_model=list[CompetitionTeamRead]) +async def list_teams(competition_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await CompetitionTeamService(db).list(tenant_id, competition_id, offset=pagination.offset, limit=pagination.page_size) + + +@groups_router.post("", response_model=CompetitionGroupRead, status_code=status.HTTP_201_CREATED) +async def create_group(body: CompetitionGroupCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionGroupService(db).create(tenant_id, body, actor=user) + + +@registrations_router.post("", response_model=CompetitionRegistrationRead, status_code=status.HTTP_201_CREATED) +async def register_member(body: CompetitionRegistrationCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionRegistrationService(db).create(tenant_id, body, actor=user) + + +@registrations_router.post("/{registration_id}/confirm", response_model=CompetitionRegistrationRead) +async def confirm_registration(registration_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionRegistrationService(db).confirm(tenant_id, registration_id, actor=user) + + +@matches_router.post("", response_model=CompetitionMatchRead, status_code=status.HTTP_201_CREATED) +async def schedule_match(body: CompetitionMatchCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionMatchService(db).create(tenant_id, body, actor=user) + + +@results_router.post("", response_model=CompetitionResultRead, status_code=status.HTTP_201_CREATED) +async def record_result(body: CompetitionResultCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionResultService(db).create(tenant_id, body, actor=user) + + +@rankings_router.get("", response_model=list[CompetitionRankingRead]) +async def list_rankings(competition_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await CompetitionResultService(db).list_rankings(tenant_id, competition_id, offset=pagination.offset, limit=pagination.page_size) + + +@medals_router.post("", response_model=CompetitionMedalRead, status_code=status.HTTP_201_CREATED) +async def award_medal(body: CompetitionMedalCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionMedalService(db).create(tenant_id, body, actor=user) + + +@judges_router.post("", response_model=CompetitionJudgeRead, status_code=status.HTTP_201_CREATED) +async def assign_judge(body: CompetitionJudgeCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionJudgeService(db).create(tenant_id, body, actor=user) + + +@certificates_router.post("", response_model=CompetitionCertificateRead, status_code=status.HTTP_201_CREATED) +async def issue_certificate(body: CompetitionCertificateCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await CompetitionCertificateService(db).create(tenant_id, body, actor=user) diff --git a/backend/services/sports_center/app/api/v1/health.py b/backend/services/sports_center/app/api/v1/health.py index 3854f3f..a9d6003 100644 --- a/backend/services/sports_center/app/api/v1/health.py +++ b/backend/services/sports_center/app/api/v1/health.py @@ -20,15 +20,18 @@ async def capabilities(): return { "service": settings.service_name, "version": __version__, - "phase": "9.2", + "phase": "9.7", "features": { "foundation": True, "connector_framework": True, "membership_catalog": True, "member_management": True, - "coaches": "foundation_shell", - "attendance_engine": False, - "booking_engine": False, + "coach_staff_management": True, + "booking_engine": True, + "attendance_engine": True, + "training_management": True, + "competition_management": True, + "coaches": True, "accounting_integration": False, "ai": False, }, diff --git a/backend/services/sports_center/app/api/v1/staff.py b/backend/services/sports_center/app/api/v1/staff.py new file mode 100644 index 0000000..00c2058 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/staff.py @@ -0,0 +1,92 @@ +"""Coach & Staff Management APIs — Phase 9.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.core.security import get_current_user +from app.schemas.staff import ( + StaffAssignmentCreate, + StaffAssignmentRead, + StaffAssignmentUpdate, + StaffAvailabilityCreate, + StaffAvailabilityRead, + StaffAvailabilityUpdate, + StaffCertificateCreate, + StaffCertificateRead, + StaffCertificateUpdate, + StaffSkillCreate, + StaffSkillRead, + StaffSkillUpdate, + StaffWorkingHoursCreate, + StaffWorkingHoursRead, + StaffWorkingHoursUpdate, +) +from app.services.staff import ( + StaffAssignmentService, + StaffAvailabilityService, + StaffCertificateService, + StaffSkillService, + StaffWorkingHoursService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +certificates_router = APIRouter() +skills_router = APIRouter() +working_hours_router = APIRouter() +availability_router = APIRouter() +assignments_router = APIRouter() + + +@certificates_router.post("", response_model=StaffCertificateRead, status_code=status.HTTP_201_CREATED) +async def create_certificate(body: StaffCertificateCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await StaffCertificateService(db).create(tenant_id, body, actor=user) + + +@certificates_router.get("", response_model=list[StaffCertificateRead]) +async def list_certificates(coach_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await StaffCertificateService(db).list(tenant_id, coach_id, offset=pagination.offset, limit=pagination.page_size) + + +@skills_router.post("", response_model=StaffSkillRead, status_code=status.HTTP_201_CREATED) +async def create_skill(body: StaffSkillCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await StaffSkillService(db).create(tenant_id, body, actor=user) + + +@skills_router.get("", response_model=list[StaffSkillRead]) +async def list_skills(coach_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await StaffSkillService(db).list(tenant_id, coach_id, offset=pagination.offset, limit=pagination.page_size) + + +@working_hours_router.post("", response_model=StaffWorkingHoursRead, status_code=status.HTTP_201_CREATED) +async def create_working_hours(body: StaffWorkingHoursCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await StaffWorkingHoursService(db).create(tenant_id, body, actor=user) + + +@working_hours_router.get("", response_model=list[StaffWorkingHoursRead]) +async def list_working_hours(coach_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await StaffWorkingHoursService(db).list(tenant_id, coach_id, offset=pagination.offset, limit=pagination.page_size) + + +@availability_router.post("", response_model=StaffAvailabilityRead, status_code=status.HTTP_201_CREATED) +async def create_availability(body: StaffAvailabilityCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await StaffAvailabilityService(db).create(tenant_id, body, actor=user) + + +@availability_router.get("", response_model=list[StaffAvailabilityRead]) +async def list_availability(coach_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await StaffAvailabilityService(db).list(tenant_id, coach_id, offset=pagination.offset, limit=pagination.page_size) + + +@assignments_router.post("", response_model=StaffAssignmentRead, status_code=status.HTTP_201_CREATED) +async def create_assignment(body: StaffAssignmentCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await StaffAssignmentService(db).create(tenant_id, body, actor=user) + + +@assignments_router.get("", response_model=list[StaffAssignmentRead]) +async def list_assignments(coach_id: UUID = Query(...), tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await StaffAssignmentService(db).list(tenant_id, coach_id, offset=pagination.offset, limit=pagination.page_size) diff --git a/backend/services/sports_center/app/api/v1/training.py b/backend/services/sports_center/app/api/v1/training.py new file mode 100644 index 0000000..4d321fc --- /dev/null +++ b/backend/services/sports_center/app/api/v1/training.py @@ -0,0 +1,124 @@ +"""Training Management APIs — Phase 9.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.core.security import get_current_user +from app.schemas.training import ( + AssessmentCreate, + AssessmentRead, + BodyCompositionCreate, + BodyCompositionRead, + ExerciseCreate, + ExerciseRead, + ExerciseUpdate, + MeasurementCreate, + MeasurementRead, + NutritionPlanCreate, + NutritionPlanRead, + PerformanceRecordCreate, + PerformanceRecordRead, + ProgressRecordCreate, + ProgressRecordRead, + TrainingGoalCreate, + TrainingGoalRead, + TrainingProgramCreate, + TrainingProgramRead, + TrainingProgramUpdate, + WorkoutAssignmentCreate, + WorkoutAssignmentRead, + WorkoutPlanCreate, + WorkoutPlanRead, + WorkoutPlanUpdate, +) +from app.services.training import ( + AssessmentService, + BodyCompositionService, + ExerciseService, + MeasurementService, + NutritionPlanService, + PerformanceRecordService, + ProgressRecordService, + TrainingGoalService, + TrainingProgramService, + WorkoutAssignmentService, + WorkoutPlanService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +programs_router = APIRouter() +exercises_router = APIRouter() +workout_plans_router = APIRouter() +workout_assignments_router = APIRouter() +goals_router = APIRouter() +measurements_router = APIRouter() +progress_router = APIRouter() +nutrition_router = APIRouter() +assessments_router = APIRouter() +performance_router = APIRouter() +body_composition_router = APIRouter() + + +@programs_router.post("", response_model=TrainingProgramRead, status_code=status.HTTP_201_CREATED) +async def create_program(body: TrainingProgramCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await TrainingProgramService(db).create(tenant_id, body, actor=user) + + +@programs_router.get("", response_model=list[TrainingProgramRead]) +async def list_programs(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await TrainingProgramService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@exercises_router.post("", response_model=ExerciseRead, status_code=status.HTTP_201_CREATED) +async def create_exercise(body: ExerciseCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await ExerciseService(db).create(tenant_id, body, actor=user) + + +@workout_plans_router.post("", response_model=WorkoutPlanRead, status_code=status.HTTP_201_CREATED) +async def create_workout_plan(body: WorkoutPlanCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await WorkoutPlanService(db).create(tenant_id, body, actor=user) + + +@workout_assignments_router.post("", response_model=WorkoutAssignmentRead, status_code=status.HTTP_201_CREATED) +async def assign_workout(body: WorkoutAssignmentCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await WorkoutAssignmentService(db).create(tenant_id, body, actor=user) + + +@goals_router.post("", response_model=TrainingGoalRead, status_code=status.HTTP_201_CREATED) +async def create_goal(body: TrainingGoalCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await TrainingGoalService(db).create(tenant_id, body, actor=user) + + +@measurements_router.post("", response_model=MeasurementRead, status_code=status.HTTP_201_CREATED) +async def create_measurement(body: MeasurementCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MeasurementService(db).create(tenant_id, body, actor=user) + + +@progress_router.post("", response_model=ProgressRecordRead, status_code=status.HTTP_201_CREATED) +async def create_progress(body: ProgressRecordCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await ProgressRecordService(db).create(tenant_id, body, actor=user) + + +@nutrition_router.post("", response_model=NutritionPlanRead, status_code=status.HTTP_201_CREATED) +async def create_nutrition_plan(body: NutritionPlanCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await NutritionPlanService(db).create(tenant_id, body, actor=user) + + +@assessments_router.post("", response_model=AssessmentRead, status_code=status.HTTP_201_CREATED) +async def create_assessment(body: AssessmentCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await AssessmentService(db).create(tenant_id, body, actor=user) + + +@performance_router.post("", response_model=PerformanceRecordRead, status_code=status.HTTP_201_CREATED) +async def create_performance(body: PerformanceRecordCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await PerformanceRecordService(db).create(tenant_id, body, actor=user) + + +@body_composition_router.post("", response_model=BodyCompositionRead, status_code=status.HTTP_201_CREATED) +async def create_body_composition(body: BodyCompositionCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await BodyCompositionService(db).create(tenant_id, body, actor=user) diff --git a/backend/services/sports_center/app/events/types.py b/backend/services/sports_center/app/events/types.py index a6e0481..8593de0 100644 --- a/backend/services/sports_center/app/events/types.py +++ b/backend/services/sports_center/app/events/types.py @@ -72,3 +72,48 @@ class SportsCenterEventType(str, enum.Enum): WAIVER_CREATED = "sports_center.waiver.created" WAIVER_SIGNED = "sports_center.waiver.signed" MEMBER_DOCUMENT_ATTACHED = "sports_center.member_document.attached" + + # Phase 9.3 — Coach & Staff Management + STAFF_CERTIFICATE_CREATED = "sports_center.staff_certificate.created" + STAFF_CERTIFICATE_UPDATED = "sports_center.staff_certificate.updated" + STAFF_SKILL_CREATED = "sports_center.staff_skill.created" + STAFF_WORKING_HOURS_CREATED = "sports_center.staff_working_hours.created" + STAFF_AVAILABILITY_CHANGED = "sports_center.staff_availability.changed" + STAFF_ASSIGNMENT_CREATED = "sports_center.staff_assignment.created" + + # Phase 9.4 — Scheduling & Booking + SESSION_CREATED = "sports_center.session.created" + SESSION_UPDATED = "sports_center.session.updated" + BOOKING_CREATED = "sports_center.booking.created" + BOOKING_CONFIRMED = "sports_center.booking.confirmed" + BOOKING_CANCELLED = "sports_center.booking.cancelled" + WAITING_LIST_JOINED = "sports_center.waiting_list.joined" + WAITING_LIST_PROMOTED = "sports_center.waiting_list.promoted" + EQUIPMENT_CREATED = "sports_center.equipment.created" + + # Phase 9.5 — Attendance & Access Control + ATTENDANCE_RECORDED = "sports_center.attendance.recorded" + CHECK_IN_COMPLETED = "sports_center.check_in.completed" + CHECK_OUT_COMPLETED = "sports_center.check_out.completed" + ACCESS_DENIED = "sports_center.access.denied" + ACCESS_GRANTED = "sports_center.access.granted" + + # Phase 9.6 — Training Management + TRAINING_PROGRAM_CREATED = "sports_center.training_program.created" + WORKOUT_PLAN_CREATED = "sports_center.workout_plan.created" + WORKOUT_ASSIGNED = "sports_center.workout.assigned" + TRAINING_GOAL_CREATED = "sports_center.training_goal.created" + MEASUREMENT_RECORDED = "sports_center.measurement.recorded" + PROGRESS_RECORDED = "sports_center.progress.recorded" + ASSESSMENT_COMPLETED = "sports_center.assessment.completed" + + # Phase 9.7 — Competition & Event Management + COMPETITION_CREATED = "sports_center.competition.created" + COMPETITION_UPDATED = "sports_center.competition.updated" + COMPETITION_TEAM_CREATED = "sports_center.competition_team.created" + COMPETITION_REGISTRATION_CREATED = "sports_center.competition.registration.created" + COMPETITION_REGISTRATION_CONFIRMED = "sports_center.competition.registration.confirmed" + COMPETITION_MATCH_SCHEDULED = "sports_center.competition.match.scheduled" + COMPETITION_RESULT_RECORDED = "sports_center.competition.result.recorded" + COMPETITION_MEDAL_AWARDED = "sports_center.competition.medal.awarded" + COMPETITION_CERTIFICATE_ISSUED = "sports_center.competition.certificate.issued" diff --git a/backend/services/sports_center/app/models/__init__.py b/backend/services/sports_center/app/models/__init__.py index 6325555..e19b716 100644 --- a/backend/services/sports_center/app/models/__init__.py +++ b/backend/services/sports_center/app/models/__init__.py @@ -42,3 +42,46 @@ from app.models.members import ( # noqa: F401 MembershipCard, Waiver, ) +from app.models.staff import ( # noqa: F401 + StaffAssignment, + StaffAvailability, + StaffCertificate, + StaffSkill, + StaffWorkingHours, +) +from app.models.booking import ( # noqa: F401 + Booking, + Equipment, + ResourceReservation, + SportsSession, + WaitingListEntry, +) +from app.models.attendance import ( # noqa: F401 + AccessLog, + AttendanceRecord, +) +from app.models.training import ( # noqa: F401 + Assessment, + BodyComposition, + Exercise, + Measurement, + NutritionPlan, + PerformanceRecord, + ProgressRecord, + TrainingGoal, + TrainingProgram, + WorkoutAssignment, + WorkoutPlan, +) +from app.models.competitions import ( # noqa: F401 + Competition, + CompetitionCertificate, + CompetitionGroup, + CompetitionJudge, + CompetitionMatch, + CompetitionMedal, + CompetitionRanking, + CompetitionRegistration, + CompetitionResult, + CompetitionTeam, +) diff --git a/backend/services/sports_center/app/models/attendance.py b/backend/services/sports_center/app/models/attendance.py new file mode 100644 index 0000000..a1f84be --- /dev/null +++ b/backend/services/sports_center/app/models/attendance.py @@ -0,0 +1,83 @@ +"""Attendance & Access Control aggregates — Phase 9.5.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, Text +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import AccessResult, AttendanceKind, AttendanceStatus, GUID + + +class AttendanceRecord( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "attendance_records" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + membership_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + session_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + attendance_kind: Mapped[AttendanceKind] = mapped_column(nullable=False) + status: Mapped[AttendanceStatus] = mapped_column(nullable=False) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + device_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + gateway_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + card_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + pass_code: Mapped[str | None] = mapped_column(String(100), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_attendance_records_member", "tenant_id", "member_id"), + Index("ix_attendance_records_recorded", "tenant_id", "recorded_at"), + Index("ix_attendance_records_session", "tenant_id", "session_id"), + Index("ix_attendance_records_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class AccessLog( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "access_logs" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + member_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + membership_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + device_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + gateway_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + access_result: Mapped[AccessResult] = mapped_column(nullable=False) + access_method: Mapped[str | None] = mapped_column(String(50), nullable=True) + credential_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + denial_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_access_logs_member", "tenant_id", "member_id"), + Index("ix_access_logs_recorded", "tenant_id", "recorded_at"), + Index("ix_access_logs_gateway", "tenant_id", "gateway_id"), + Index("ix_access_logs_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/sports_center/app/models/booking.py b/backend/services/sports_center/app/models/booking.py new file mode 100644 index 0000000..e9610b4 --- /dev/null +++ b/backend/services/sports_center/app/models/booking.py @@ -0,0 +1,204 @@ +"""Scheduling & Booking aggregates — Phase 9.4.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + BookingStatus, + GUID, + LifecycleStatus, + RecurrenceKind, + SessionKind, + WaitlistStatus, +) + + +class Equipment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "equipment" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + facility_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + quantity_total: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + quantity_available: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_equipment_tenant_code" + ), + Index("ix_equipment_center", "tenant_id", "sports_center_id"), + Index("ix_equipment_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class SportsSession( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "sports_sessions" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + session_kind: Mapped[SessionKind] = mapped_column( + default=SessionKind.CLASS, nullable=False + ) + sport_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + coach_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + facility_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + court_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + room_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + capacity: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + booked_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + waitlist_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + recurrence_kind: Mapped[RecurrenceKind] = mapped_column( + default=RecurrenceKind.NONE, nullable=False + ) + recurrence_rule: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_sports_sessions_tenant_code" + ), + Index("ix_sports_sessions_center", "tenant_id", "sports_center_id"), + Index("ix_sports_sessions_window", "tenant_id", "starts_at", "ends_at"), + Index("ix_sports_sessions_coach", "tenant_id", "coach_id"), + Index("ix_sports_sessions_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Booking( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "bookings" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + membership_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + booking_number: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[BookingStatus] = mapped_column( + default=BookingStatus.PENDING, nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + confirmed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + cancelled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + cancellation_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "booking_number", name="uq_bookings_tenant_number" + ), + Index("ix_bookings_session", "tenant_id", "session_id"), + Index("ix_bookings_member", "tenant_id", "member_id"), + Index("ix_bookings_tenant_status", "tenant_id", "status"), + Index("ix_bookings_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class WaitingListEntry( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "waiting_list_entries" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + position: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + status: Mapped[WaitlistStatus] = mapped_column( + default=WaitlistStatus.WAITING, nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_waiting_list_session", "tenant_id", "session_id"), + Index("ix_waiting_list_member", "tenant_id", "member_id"), + Index("ix_waiting_list_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ResourceReservation( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "resource_reservations" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + session_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + booking_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + facility_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + equipment_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + status: Mapped[BookingStatus] = mapped_column( + default=BookingStatus.CONFIRMED, nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_resource_reservations_window", "tenant_id", "starts_at", "ends_at"), + Index("ix_resource_reservations_facility", "tenant_id", "facility_id"), + Index("ix_resource_reservations_equipment", "tenant_id", "equipment_id"), + Index("ix_resource_reservations_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/sports_center/app/models/competitions.py b/backend/services/sports_center/app/models/competitions.py new file mode 100644 index 0000000..31506ec --- /dev/null +++ b/backend/services/sports_center/app/models/competitions.py @@ -0,0 +1,319 @@ +"""Competition & Event Management aggregates — Phase 9.7.""" +from __future__ import annotations + +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, Index, Integer, Numeric, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + CompetitionKind, + CompetitionStatus, + GUID, + MatchStatus, + MedalKind, + RegistrationStatus, + ResultOutcome, +) + + +class Competition( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "competitions" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + competition_kind: Mapped[CompetitionKind] = mapped_column( + default=CompetitionKind.TOURNAMENT, nullable=False + ) + sport_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + facility_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + status: Mapped[CompetitionStatus] = mapped_column( + default=CompetitionStatus.DRAFT, nullable=False + ) + starts_on: Mapped[date | None] = mapped_column(Date, nullable=True) + ends_on: Mapped[date | None] = mapped_column(Date, nullable=True) + max_teams: Mapped[int | None] = mapped_column(Integer, nullable=True) + rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_competitions_tenant_code" + ), + Index("ix_competitions_center", "tenant_id", "sports_center_id"), + Index("ix_competitions_tenant_status", "tenant_id", "status"), + Index("ix_competitions_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CompetitionTeam( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "competition_teams" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + competition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + captain_member_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + group_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "competition_id", + "code", + name="uq_competition_teams_tenant_code", + ), + Index("ix_competition_teams_competition", "tenant_id", "competition_id"), + Index("ix_competition_teams_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CompetitionGroup( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "competition_groups" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + competition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "competition_id", + "code", + name="uq_competition_groups_tenant_code", + ), + Index("ix_competition_groups_competition", "tenant_id", "competition_id"), + Index("ix_competition_groups_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CompetitionRegistration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "competition_registrations" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + competition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + team_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + status: Mapped[RegistrationStatus] = mapped_column( + default=RegistrationStatus.PENDING, nullable=False + ) + registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_competition_registrations_competition", "tenant_id", "competition_id"), + Index("ix_competition_registrations_member", "tenant_id", "member_id"), + Index("ix_competition_registrations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CompetitionMatch( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "competition_matches" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + competition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + group_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + home_team_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + away_team_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + facility_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + status: Mapped[MatchStatus] = mapped_column( + default=MatchStatus.SCHEDULED, nullable=False + ) + round_number: Mapped[int | None] = mapped_column(Integer, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "competition_id", + "code", + name="uq_competition_matches_tenant_code", + ), + Index("ix_competition_matches_competition", "tenant_id", "competition_id"), + Index("ix_competition_matches_scheduled", "tenant_id", "scheduled_at"), + Index("ix_competition_matches_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CompetitionResult( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "competition_results" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + competition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + match_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + home_score: Mapped[float | None] = mapped_column(Numeric(12, 4), nullable=True) + away_score: Mapped[float | None] = mapped_column(Numeric(12, 4), nullable=True) + winner_team_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + outcome: Mapped[ResultOutcome] = mapped_column(nullable=False) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_competition_results_match", "tenant_id", "match_id"), + Index("ix_competition_results_competition", "tenant_id", "competition_id"), + Index("ix_competition_results_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CompetitionRanking( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "competition_rankings" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + competition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + team_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + member_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + rank: Mapped[int] = mapped_column(Integer, nullable=False) + points: Mapped[float] = mapped_column(Numeric(12, 4), default=0, nullable=False) + wins: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + losses: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + draws: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_competition_rankings_competition", "tenant_id", "competition_id"), + Index("ix_competition_rankings_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CompetitionMedal( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "competition_medals" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + competition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + team_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + medal_kind: Mapped[MedalKind] = mapped_column(nullable=False) + awarded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_competition_medals_competition", "tenant_id", "competition_id"), + Index("ix_competition_medals_member", "tenant_id", "member_id"), + Index("ix_competition_medals_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CompetitionJudge( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "competition_judges" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + competition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + coach_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + role: Mapped[str] = mapped_column(String(100), nullable=False) + match_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_competition_judges_competition", "tenant_id", "competition_id"), + Index("ix_competition_judges_coach", "tenant_id", "coach_id"), + Index("ix_competition_judges_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class CompetitionCertificate( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "competition_certificates" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + competition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_competition_certificates_competition", "tenant_id", "competition_id"), + Index("ix_competition_certificates_member", "tenant_id", "member_id"), + Index("ix_competition_certificates_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/sports_center/app/models/foundation.py b/backend/services/sports_center/app/models/foundation.py index 0a09853..fa75794 100644 --- a/backend/services/sports_center/app/models/foundation.py +++ b/backend/services/sports_center/app/models/foundation.py @@ -40,6 +40,7 @@ from app.models.types import ( LifecycleStatus, LockerStatus, MembershipStatus, + StaffKind, ) @@ -255,6 +256,14 @@ class Coach( sport_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) qualifications: Mapped[dict | None] = mapped_column(JSON, nullable=True) external_user_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + # Phase 9.3 — staff depth + staff_kind: Mapped[StaffKind] = mapped_column( + default=StaffKind.COACH, nullable=False + ) + bio: Mapped[str | None] = mapped_column(Text, nullable=True) + hire_date: Mapped[date | None] = mapped_column(Date, nullable=True) + payroll_external_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + sports_role_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) __table_args__ = ( UniqueConstraint( diff --git a/backend/services/sports_center/app/models/staff.py b/backend/services/sports_center/app/models/staff.py new file mode 100644 index 0000000..42675a9 --- /dev/null +++ b/backend/services/sports_center/app/models/staff.py @@ -0,0 +1,156 @@ +"""Coach & Staff Management aggregates — Phase 9.3.""" +from __future__ import annotations + +import uuid +from datetime import date, datetime, time + +from sqlalchemy import Boolean, Date, DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import AssignmentKind, GUID, LifecycleStatus + + +class StaffCertificate( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "staff_certificates" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + coach_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + issuer: Mapped[str | None] = mapped_column(String(255), nullable=True) + certificate_number: Mapped[str | None] = mapped_column(String(100), nullable=True) + issued_on: Mapped[date | None] = mapped_column(Date, nullable=True) + expires_on: Mapped[date | None] = mapped_column(Date, nullable=True) + file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_staff_certificates_coach", "tenant_id", "coach_id"), + Index("ix_staff_certificates_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class StaffSkill( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "staff_skills" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + coach_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + skill_code: Mapped[str] = mapped_column(String(50), nullable=False) + skill_name: Mapped[str] = mapped_column(String(255), nullable=False) + level: Mapped[int | None] = mapped_column(Integer, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "coach_id", + "skill_code", + name="uq_staff_skills_tenant_coach_code", + ), + Index("ix_staff_skills_coach", "tenant_id", "coach_id"), + Index("ix_staff_skills_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class StaffWorkingHours( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "staff_working_hours" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + coach_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + day_of_week: Mapped[int] = mapped_column(Integer, nullable=False) + starts_at: Mapped[time] = mapped_column(nullable=False) + ends_at: Mapped[time] = mapped_column(nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + __table_args__ = ( + Index("ix_staff_working_hours_coach", "tenant_id", "coach_id"), + Index("ix_staff_working_hours_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class StaffAvailability( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "staff_availability" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + coach_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + is_available: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_staff_availability_coach", "tenant_id", "coach_id"), + Index("ix_staff_availability_window", "tenant_id", "starts_at", "ends_at"), + Index("ix_staff_availability_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class StaffAssignment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "staff_assignments" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + coach_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + assignment_kind: Mapped[AssignmentKind] = mapped_column(nullable=False) + member_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + sport_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + program_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + starts_on: Mapped[date | None] = mapped_column(Date, nullable=True) + ends_on: Mapped[date | None] = mapped_column(Date, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_staff_assignments_coach", "tenant_id", "coach_id"), + Index("ix_staff_assignments_member", "tenant_id", "member_id"), + Index("ix_staff_assignments_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/sports_center/app/models/training.py b/backend/services/sports_center/app/models/training.py new file mode 100644 index 0000000..9adf5e5 --- /dev/null +++ b/backend/services/sports_center/app/models/training.py @@ -0,0 +1,323 @@ +"""Training Management aggregates — Phase 9.6.""" +from __future__ import annotations + +import uuid +from datetime import date, datetime + +from sqlalchemy import Boolean, Date, DateTime, Index, Integer, Numeric, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import AssessmentKind, GoalStatus, GUID, ProgramStatus, WorkoutStatus + + +class TrainingProgram( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "training_programs" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + sport_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + coach_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + status: Mapped[ProgramStatus] = mapped_column( + default=ProgramStatus.DRAFT, nullable=False + ) + duration_weeks: Mapped[int | None] = mapped_column(Integer, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_training_programs_tenant_code" + ), + Index("ix_training_programs_center", "tenant_id", "sports_center_id"), + Index("ix_training_programs_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Exercise( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "exercises" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + muscle_group: Mapped[str | None] = mapped_column(String(100), nullable=True) + equipment_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + default_sets: Mapped[int | None] = mapped_column(Integer, nullable=True) + default_reps: Mapped[int | None] = mapped_column(Integer, nullable=True) + default_duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_exercises_tenant_code" + ), + Index("ix_exercises_center", "tenant_id", "sports_center_id"), + Index("ix_exercises_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class WorkoutPlan( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "workout_plans" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + program_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + coach_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + status: Mapped[WorkoutStatus] = mapped_column( + default=WorkoutStatus.DRAFT, nullable=False + ) + exercises: Mapped[list | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_workout_plans_tenant_code" + ), + Index("ix_workout_plans_center", "tenant_id", "sports_center_id"), + Index("ix_workout_plans_program", "tenant_id", "program_id"), + Index("ix_workout_plans_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class WorkoutAssignment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "workout_assignments" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + workout_plan_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + coach_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + starts_on: Mapped[date | None] = mapped_column(Date, nullable=True) + ends_on: Mapped[date | None] = mapped_column(Date, nullable=True) + status: Mapped[WorkoutStatus] = mapped_column( + default=WorkoutStatus.ACTIVE, nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_workout_assignments_member", "tenant_id", "member_id"), + Index("ix_workout_assignments_plan", "tenant_id", "workout_plan_id"), + Index("ix_workout_assignments_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class TrainingGoal( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "training_goals" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + program_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + target_value: Mapped[float | None] = mapped_column(Numeric(12, 4), nullable=True) + current_value: Mapped[float | None] = mapped_column(Numeric(12, 4), nullable=True) + unit: Mapped[str | None] = mapped_column(String(50), nullable=True) + target_date: Mapped[date | None] = mapped_column(Date, nullable=True) + status: Mapped[GoalStatus] = mapped_column(default=GoalStatus.ACTIVE, nullable=False) + + __table_args__ = ( + Index("ix_training_goals_member", "tenant_id", "member_id"), + Index("ix_training_goals_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Measurement( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "measurements" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + metric_code: Mapped[str] = mapped_column(String(50), nullable=False) + metric_name: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[float] = mapped_column(Numeric(12, 4), nullable=False) + unit: Mapped[str | None] = mapped_column(String(50), nullable=True) + measured_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + recorded_by_coach_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_measurements_member", "tenant_id", "member_id"), + Index("ix_measurements_measured", "tenant_id", "measured_at"), + Index("ix_measurements_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ProgressRecord( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "progress_records" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + goal_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + workout_assignment_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + metrics: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_progress_records_member", "tenant_id", "member_id"), + Index("ix_progress_records_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class NutritionPlan( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "nutrition_plans" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + coach_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + starts_on: Mapped[date | None] = mapped_column(Date, nullable=True) + ends_on: Mapped[date | None] = mapped_column(Date, nullable=True) + daily_targets: Mapped[dict | None] = mapped_column(JSON, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + __table_args__ = ( + Index("ix_nutrition_plans_member", "tenant_id", "member_id"), + Index("ix_nutrition_plans_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Assessment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "assessments" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + coach_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + assessment_kind: Mapped[AssessmentKind] = mapped_column(nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + assessed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + score: Mapped[float | None] = mapped_column(Numeric(12, 4), nullable=True) + results: Mapped[dict | None] = mapped_column(JSON, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_assessments_member", "tenant_id", "member_id"), + Index("ix_assessments_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class PerformanceRecord( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "performance_records" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + exercise_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + value: Mapped[float] = mapped_column(Numeric(12, 4), nullable=False) + unit: Mapped[str | None] = mapped_column(String(50), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_performance_records_member", "tenant_id", "member_id"), + Index("ix_performance_records_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class BodyComposition( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "body_compositions" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + measured_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + weight_kg: Mapped[float | None] = mapped_column(Numeric(8, 2), nullable=True) + body_fat_percent: Mapped[float | None] = mapped_column(Numeric(6, 2), nullable=True) + muscle_mass_kg: Mapped[float | None] = mapped_column(Numeric(8, 2), nullable=True) + bmi: Mapped[float | None] = mapped_column(Numeric(6, 2), nullable=True) + metrics: Mapped[dict | None] = mapped_column(JSON, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_body_compositions_member", "tenant_id", "member_id"), + Index("ix_body_compositions_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/sports_center/app/models/types.py b/backend/services/sports_center/app/models/types.py index f03172a..511c28f 100644 --- a/backend/services/sports_center/app/models/types.py +++ b/backend/services/sports_center/app/models/types.py @@ -178,3 +178,137 @@ class RuleKind(str, enum.Enum): USAGE = "usage" TRANSFER = "transfer" CUSTOM = "custom" + + +class StaffKind(str, enum.Enum): + COACH = "coach" + TRAINER = "trainer" + NUTRITIONIST = "nutritionist" + MEDICAL = "medical" + RECEPTION = "reception" + MANAGER = "manager" + + +class AssignmentKind(str, enum.Enum): + MEMBER = "member" + SPORT = "sport" + PROGRAM = "program" + + +class BookingStatus(str, enum.Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + COMPLETED = "completed" + NO_SHOW = "no_show" + + +class SessionKind(str, enum.Enum): + CLASS = "class" + PRIVATE = "private" + OPEN = "open" + BLOCKED = "blocked" + + +class WaitlistStatus(str, enum.Enum): + WAITING = "waiting" + PROMOTED = "promoted" + EXPIRED = "expired" + CANCELLED = "cancelled" + + +class RecurrenceKind(str, enum.Enum): + NONE = "none" + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + + +class AttendanceKind(str, enum.Enum): + CHECK_IN = "check_in" + CHECK_OUT = "check_out" + MANUAL = "manual" + GATE = "gate" + + +class AttendanceStatus(str, enum.Enum): + PRESENT = "present" + LATE = "late" + ABSENT = "absent" + EXCUSED = "excused" + + +class AccessResult(str, enum.Enum): + GRANTED = "granted" + DENIED = "denied" + + +class ProgramStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + COMPLETED = "completed" + ARCHIVED = "archived" + + +class WorkoutStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + COMPLETED = "completed" + ARCHIVED = "archived" + + +class GoalStatus(str, enum.Enum): + ACTIVE = "active" + ACHIEVED = "achieved" + ABANDONED = "abandoned" + + +class AssessmentKind(str, enum.Enum): + FITNESS = "fitness" + SKILL = "skill" + BODY_COMPOSITION = "body_composition" + CUSTOM = "custom" + + +class CompetitionKind(str, enum.Enum): + TOURNAMENT = "tournament" + LEAGUE = "league" + MATCH_SERIES = "match_series" + CUSTOM = "custom" + + +class CompetitionStatus(str, enum.Enum): + DRAFT = "draft" + SCHEDULED = "scheduled" + ACTIVE = "active" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class RegistrationStatus(str, enum.Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + WAITLISTED = "waitlisted" + + +class MatchStatus(str, enum.Enum): + SCHEDULED = "scheduled" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class ResultOutcome(str, enum.Enum): + WIN = "win" + LOSS = "loss" + DRAW = "draw" + FORFEIT = "forfeit" + NO_CONTEST = "no_contest" + + +class MedalKind(str, enum.Enum): + GOLD = "gold" + SILVER = "silver" + BRONZE = "bronze" + PARTICIPATION = "participation" diff --git a/backend/services/sports_center/app/permissions/definitions.py b/backend/services/sports_center/app/permissions/definitions.py index 1f2bf17..332f561 100644 --- a/backend/services/sports_center/app/permissions/definitions.py +++ b/backend/services/sports_center/app/permissions/definitions.py @@ -124,6 +124,81 @@ SETTINGS_VIEW = "sports_center.settings.view" SETTINGS_MANAGE = "sports_center.settings.manage" AUDIT_VIEW = "sports_center.audit.view" +# Phase 9.3 — Coach & Staff Management +STAFF_CERTIFICATES_VIEW = "sports_center.staff_certificates.view" +STAFF_CERTIFICATES_MANAGE = "sports_center.staff_certificates.manage" +STAFF_SKILLS_VIEW = "sports_center.staff_skills.view" +STAFF_SKILLS_MANAGE = "sports_center.staff_skills.manage" +STAFF_WORKING_HOURS_VIEW = "sports_center.staff_working_hours.view" +STAFF_WORKING_HOURS_MANAGE = "sports_center.staff_working_hours.manage" +STAFF_AVAILABILITY_VIEW = "sports_center.staff_availability.view" +STAFF_AVAILABILITY_MANAGE = "sports_center.staff_availability.manage" +STAFF_ASSIGNMENTS_VIEW = "sports_center.staff_assignments.view" +STAFF_ASSIGNMENTS_MANAGE = "sports_center.staff_assignments.manage" + +# Phase 9.4 — Scheduling & Booking +EQUIPMENT_VIEW = "sports_center.equipment.view" +EQUIPMENT_MANAGE = "sports_center.equipment.manage" +SESSIONS_VIEW = "sports_center.sessions.view" +SESSIONS_MANAGE = "sports_center.sessions.manage" +BOOKINGS_VIEW = "sports_center.bookings.view" +BOOKINGS_MANAGE = "sports_center.bookings.manage" +WAITING_LIST_VIEW = "sports_center.waiting_list.view" +WAITING_LIST_MANAGE = "sports_center.waiting_list.manage" +RESOURCE_RESERVATIONS_VIEW = "sports_center.resource_reservations.view" +RESOURCE_RESERVATIONS_MANAGE = "sports_center.resource_reservations.manage" + +# Phase 9.5 — Attendance & Access Control +ATTENDANCE_VIEW = "sports_center.attendance.view" +ATTENDANCE_MANAGE = "sports_center.attendance.manage" +CHECK_IN_MANAGE = "sports_center.check_in.manage" +ACCESS_LOGS_VIEW = "sports_center.access_logs.view" + +# Phase 9.6 — Training Management +TRAINING_PROGRAMS_VIEW = "sports_center.training_programs.view" +TRAINING_PROGRAMS_MANAGE = "sports_center.training_programs.manage" +EXERCISES_VIEW = "sports_center.exercises.view" +EXERCISES_MANAGE = "sports_center.exercises.manage" +WORKOUT_PLANS_VIEW = "sports_center.workout_plans.view" +WORKOUT_PLANS_MANAGE = "sports_center.workout_plans.manage" +WORKOUT_ASSIGNMENTS_VIEW = "sports_center.workout_assignments.view" +WORKOUT_ASSIGNMENTS_MANAGE = "sports_center.workout_assignments.manage" +TRAINING_GOALS_VIEW = "sports_center.training_goals.view" +TRAINING_GOALS_MANAGE = "sports_center.training_goals.manage" +MEASUREMENTS_VIEW = "sports_center.measurements.view" +MEASUREMENTS_MANAGE = "sports_center.measurements.manage" +PROGRESS_VIEW = "sports_center.progress.view" +PROGRESS_MANAGE = "sports_center.progress.manage" +NUTRITION_PLANS_VIEW = "sports_center.nutrition_plans.view" +NUTRITION_PLANS_MANAGE = "sports_center.nutrition_plans.manage" +ASSESSMENTS_VIEW = "sports_center.assessments.view" +ASSESSMENTS_MANAGE = "sports_center.assessments.manage" +PERFORMANCE_VIEW = "sports_center.performance.view" +PERFORMANCE_MANAGE = "sports_center.performance.manage" +BODY_COMPOSITION_VIEW = "sports_center.body_composition.view" +BODY_COMPOSITION_MANAGE = "sports_center.body_composition.manage" + +# Phase 9.7 — Competition & Event Management +COMPETITIONS_VIEW = "sports_center.competitions.view" +COMPETITIONS_MANAGE = "sports_center.competitions.manage" +COMPETITION_TEAMS_VIEW = "sports_center.competition_teams.view" +COMPETITION_TEAMS_MANAGE = "sports_center.competition_teams.manage" +COMPETITION_GROUPS_VIEW = "sports_center.competition_groups.view" +COMPETITION_GROUPS_MANAGE = "sports_center.competition_groups.manage" +COMPETITION_REGISTRATIONS_VIEW = "sports_center.competition_registrations.view" +COMPETITION_REGISTRATIONS_MANAGE = "sports_center.competition_registrations.manage" +COMPETITION_MATCHES_VIEW = "sports_center.competition_matches.view" +COMPETITION_MATCHES_MANAGE = "sports_center.competition_matches.manage" +COMPETITION_RESULTS_VIEW = "sports_center.competition_results.view" +COMPETITION_RESULTS_MANAGE = "sports_center.competition_results.manage" +COMPETITION_RANKINGS_VIEW = "sports_center.competition_rankings.view" +COMPETITION_MEDALS_VIEW = "sports_center.competition_medals.view" +COMPETITION_MEDALS_MANAGE = "sports_center.competition_medals.manage" +COMPETITION_JUDGES_VIEW = "sports_center.competition_judges.view" +COMPETITION_JUDGES_MANAGE = "sports_center.competition_judges.manage" +COMPETITION_CERTIFICATES_VIEW = "sports_center.competition_certificates.view" +COMPETITION_CERTIFICATES_MANAGE = "sports_center.competition_certificates.manage" + ALL_PERMISSIONS: list[str] = [ SPORTS_CENTER_VIEW, SPORTS_CENTER_MANAGE, @@ -230,6 +305,71 @@ ALL_PERMISSIONS: list[str] = [ SETTINGS_VIEW, SETTINGS_MANAGE, AUDIT_VIEW, + STAFF_CERTIFICATES_VIEW, + STAFF_CERTIFICATES_MANAGE, + STAFF_SKILLS_VIEW, + STAFF_SKILLS_MANAGE, + STAFF_WORKING_HOURS_VIEW, + STAFF_WORKING_HOURS_MANAGE, + STAFF_AVAILABILITY_VIEW, + STAFF_AVAILABILITY_MANAGE, + STAFF_ASSIGNMENTS_VIEW, + STAFF_ASSIGNMENTS_MANAGE, + EQUIPMENT_VIEW, + EQUIPMENT_MANAGE, + SESSIONS_VIEW, + SESSIONS_MANAGE, + BOOKINGS_VIEW, + BOOKINGS_MANAGE, + WAITING_LIST_VIEW, + WAITING_LIST_MANAGE, + RESOURCE_RESERVATIONS_VIEW, + RESOURCE_RESERVATIONS_MANAGE, + ATTENDANCE_VIEW, + ATTENDANCE_MANAGE, + CHECK_IN_MANAGE, + ACCESS_LOGS_VIEW, + TRAINING_PROGRAMS_VIEW, + TRAINING_PROGRAMS_MANAGE, + EXERCISES_VIEW, + EXERCISES_MANAGE, + WORKOUT_PLANS_VIEW, + WORKOUT_PLANS_MANAGE, + WORKOUT_ASSIGNMENTS_VIEW, + WORKOUT_ASSIGNMENTS_MANAGE, + TRAINING_GOALS_VIEW, + TRAINING_GOALS_MANAGE, + MEASUREMENTS_VIEW, + MEASUREMENTS_MANAGE, + PROGRESS_VIEW, + PROGRESS_MANAGE, + NUTRITION_PLANS_VIEW, + NUTRITION_PLANS_MANAGE, + ASSESSMENTS_VIEW, + ASSESSMENTS_MANAGE, + PERFORMANCE_VIEW, + PERFORMANCE_MANAGE, + BODY_COMPOSITION_VIEW, + BODY_COMPOSITION_MANAGE, + COMPETITIONS_VIEW, + COMPETITIONS_MANAGE, + COMPETITION_TEAMS_VIEW, + COMPETITION_TEAMS_MANAGE, + COMPETITION_GROUPS_VIEW, + COMPETITION_GROUPS_MANAGE, + COMPETITION_REGISTRATIONS_VIEW, + COMPETITION_REGISTRATIONS_MANAGE, + COMPETITION_MATCHES_VIEW, + COMPETITION_MATCHES_MANAGE, + COMPETITION_RESULTS_VIEW, + COMPETITION_RESULTS_MANAGE, + COMPETITION_RANKINGS_VIEW, + COMPETITION_MEDALS_VIEW, + COMPETITION_MEDALS_MANAGE, + COMPETITION_JUDGES_VIEW, + COMPETITION_JUDGES_MANAGE, + COMPETITION_CERTIFICATES_VIEW, + COMPETITION_CERTIFICATES_MANAGE, ] PERMISSION_PREFIXES: tuple[str, ...] = ( @@ -271,4 +411,38 @@ PERMISSION_PREFIXES: tuple[str, ...] = ( "sports_center.events.", "sports_center.settings.", "sports_center.audit.", + "sports_center.staff_certificates.", + "sports_center.staff_skills.", + "sports_center.staff_working_hours.", + "sports_center.staff_availability.", + "sports_center.staff_assignments.", + "sports_center.equipment.", + "sports_center.sessions.", + "sports_center.bookings.", + "sports_center.waiting_list.", + "sports_center.resource_reservations.", + "sports_center.attendance.", + "sports_center.check_in.", + "sports_center.access_logs.", + "sports_center.training_programs.", + "sports_center.exercises.", + "sports_center.workout_plans.", + "sports_center.workout_assignments.", + "sports_center.training_goals.", + "sports_center.measurements.", + "sports_center.progress.", + "sports_center.nutrition_plans.", + "sports_center.assessments.", + "sports_center.performance.", + "sports_center.body_composition.", + "sports_center.competitions.", + "sports_center.competition_teams.", + "sports_center.competition_groups.", + "sports_center.competition_registrations.", + "sports_center.competition_matches.", + "sports_center.competition_results.", + "sports_center.competition_rankings.", + "sports_center.competition_medals.", + "sports_center.competition_judges.", + "sports_center.competition_certificates.", ) diff --git a/backend/services/sports_center/app/repositories/attendance.py b/backend/services/sports_center/app/repositories/attendance.py new file mode 100644 index 0000000..06169b2 --- /dev/null +++ b/backend/services/sports_center/app/repositories/attendance.py @@ -0,0 +1,65 @@ +"""Attendance repositories — Phase 9.5.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import select + +from app.models.attendance import AccessLog, AttendanceRecord +from app.repositories.base import TenantBaseRepository + + +class AttendanceRecordRepository(TenantBaseRepository[AttendanceRecord]): + model = AttendanceRecord + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 20 + ): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.member_id == member_id, + self.model.is_deleted.is_(False), + ) + .order_by(self.model.recorded_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def latest_for_member( + self, tenant_id: UUID, member_id: UUID, *, before: datetime | None = None + ): + clauses = [ + self.model.tenant_id == tenant_id, + self.model.member_id == member_id, + self.model.is_deleted.is_(False), + ] + if before: + clauses.append(self.model.recorded_at <= before) + stmt = select(self.model).where(*clauses).order_by(self.model.recorded_at.desc()).limit(1) + return (await self.session.execute(stmt)).scalar_one_or_none() + + +class AccessLogRepository(TenantBaseRepository[AccessLog]): + model = AccessLog + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 20 + ): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.member_id == member_id, + self.model.is_deleted.is_(False), + ) + .order_by(self.model.recorded_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/sports_center/app/repositories/booking.py b/backend/services/sports_center/app/repositories/booking.py new file mode 100644 index 0000000..49c1835 --- /dev/null +++ b/backend/services/sports_center/app/repositories/booking.py @@ -0,0 +1,119 @@ +"""Scheduling & Booking repositories — Phase 9.4.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.booking import Booking, Equipment, ResourceReservation, SportsSession, WaitingListEntry +from app.models.types import BookingStatus, LifecycleStatus +from app.repositories.base import TenantBaseRepository + + +class EquipmentRepository(TenantBaseRepository[Equipment]): + model = Equipment + + +class SportsSessionRepository(TenantBaseRepository[SportsSession]): + model = SportsSession + + async def list_by_center( + self, tenant_id: UUID, sports_center_id: UUID, *, offset: int = 0, limit: int = 20 + ): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.sports_center_id == sports_center_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_conflicts( + self, + tenant_id: UUID, + sports_center_id: UUID, + starts_at: datetime, + ends_at: datetime, + *, + coach_id: UUID | None = None, + facility_id: UUID | None = None, + exclude_id: UUID | None = None, + ) -> int: + clauses = [ + self.model.tenant_id == tenant_id, + self.model.sports_center_id == sports_center_id, + self.model.is_deleted.is_(False), + self.model.status == LifecycleStatus.ACTIVE, + self.model.starts_at < ends_at, + self.model.ends_at > starts_at, + ] + if coach_id: + clauses.append(self.model.coach_id == coach_id) + if facility_id: + clauses.append(self.model.facility_id == facility_id) + if exclude_id: + clauses.append(self.model.id != exclude_id) + stmt = select(func.count()).select_from(self.model).where(*clauses) + return (await self.session.execute(stmt)).scalar_one() + + +class BookingRepository(TenantBaseRepository[Booking]): + model = Booking + + async def count_active_for_session(self, tenant_id: UUID, session_id: UUID) -> int: + stmt = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.session_id == session_id, + self.model.is_deleted.is_(False), + self.model.status.in_([BookingStatus.PENDING, BookingStatus.CONFIRMED]), + ) + return (await self.session.execute(stmt)).scalar_one() + + +class WaitingListEntryRepository(TenantBaseRepository[WaitingListEntry]): + model = WaitingListEntry + + async def next_position(self, tenant_id: UUID, session_id: UUID) -> int: + stmt = select(func.max(self.model.position)).where( + self.model.tenant_id == tenant_id, + self.model.session_id == session_id, + self.model.is_deleted.is_(False), + ) + current = (await self.session.execute(stmt)).scalar_one_or_none() + return (current or 0) + 1 + + +class ResourceReservationRepository(TenantBaseRepository[ResourceReservation]): + model = ResourceReservation + + async def count_conflicts( + self, + tenant_id: UUID, + starts_at: datetime, + ends_at: datetime, + *, + facility_id: UUID | None = None, + equipment_id: UUID | None = None, + exclude_id: UUID | None = None, + ) -> int: + clauses = [ + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + self.model.status.in_([BookingStatus.PENDING, BookingStatus.CONFIRMED]), + self.model.starts_at < ends_at, + self.model.ends_at > starts_at, + ] + if facility_id: + clauses.append(self.model.facility_id == facility_id) + if equipment_id: + clauses.append(self.model.equipment_id == equipment_id) + if exclude_id: + clauses.append(self.model.id != exclude_id) + stmt = select(func.count()).select_from(self.model).where(*clauses) + return (await self.session.execute(stmt)).scalar_one() diff --git a/backend/services/sports_center/app/repositories/competitions.py b/backend/services/sports_center/app/repositories/competitions.py new file mode 100644 index 0000000..b6451f5 --- /dev/null +++ b/backend/services/sports_center/app/repositories/competitions.py @@ -0,0 +1,106 @@ +"""Competition repositories — Phase 9.7.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.competitions import ( + Competition, + CompetitionCertificate, + CompetitionGroup, + CompetitionJudge, + CompetitionMatch, + CompetitionMedal, + CompetitionRanking, + CompetitionRegistration, + CompetitionResult, + CompetitionTeam, +) +from app.repositories.base import TenantBaseRepository + + +class CompetitionRepository(TenantBaseRepository[Competition]): + model = Competition + + +class CompetitionTeamRepository(TenantBaseRepository[CompetitionTeam]): + model = CompetitionTeam + + async def list_by_competition(self, tenant_id: UUID, competition_id: UUID, *, offset=0, limit=20): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.competition_id == competition_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + return (await self.session.execute(stmt)).scalars().all() + + async def count_by_competition(self, tenant_id: UUID, competition_id: UUID) -> int: + from sqlalchemy import func + + stmt = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.competition_id == competition_id, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one() + + +class CompetitionGroupRepository(TenantBaseRepository[CompetitionGroup]): + model = CompetitionGroup + + +class CompetitionRegistrationRepository(TenantBaseRepository[CompetitionRegistration]): + model = CompetitionRegistration + + +class CompetitionMatchRepository(TenantBaseRepository[CompetitionMatch]): + model = CompetitionMatch + + +class CompetitionResultRepository(TenantBaseRepository[CompetitionResult]): + model = CompetitionResult + + +class CompetitionRankingRepository(TenantBaseRepository[CompetitionRanking]): + model = CompetitionRanking + + async def list_by_competition(self, tenant_id: UUID, competition_id: UUID, *, offset=0, limit=20): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.competition_id == competition_id, + self.model.is_deleted.is_(False), + ) + .order_by(self.model.rank) + .offset(offset) + .limit(limit) + ) + return (await self.session.execute(stmt)).scalars().all() + + async def get_by_team(self, tenant_id: UUID, competition_id: UUID, team_id: UUID): + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.competition_id == competition_id, + self.model.team_id == team_id, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + +class CompetitionMedalRepository(TenantBaseRepository[CompetitionMedal]): + model = CompetitionMedal + + +class CompetitionJudgeRepository(TenantBaseRepository[CompetitionJudge]): + model = CompetitionJudge + + +class CompetitionCertificateRepository(TenantBaseRepository[CompetitionCertificate]): + model = CompetitionCertificate diff --git a/backend/services/sports_center/app/repositories/staff.py b/backend/services/sports_center/app/repositories/staff.py new file mode 100644 index 0000000..cb02d76 --- /dev/null +++ b/backend/services/sports_center/app/repositories/staff.py @@ -0,0 +1,105 @@ +"""Coach & Staff repositories — Phase 9.3.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.staff import ( + StaffAssignment, + StaffAvailability, + StaffCertificate, + StaffSkill, + StaffWorkingHours, +) +from app.repositories.base import TenantBaseRepository + + +class StaffCertificateRepository(TenantBaseRepository[StaffCertificate]): + model = StaffCertificate + + async def list_by_coach(self, tenant_id: UUID, coach_id: UUID, *, offset: int = 0, limit: int = 20): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.coach_id == coach_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class StaffSkillRepository(TenantBaseRepository[StaffSkill]): + model = StaffSkill + + async def list_by_coach(self, tenant_id: UUID, coach_id: UUID, *, offset: int = 0, limit: int = 20): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.coach_id == coach_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class StaffWorkingHoursRepository(TenantBaseRepository[StaffWorkingHours]): + model = StaffWorkingHours + + async def list_by_coach(self, tenant_id: UUID, coach_id: UUID, *, offset: int = 0, limit: int = 20): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.coach_id == coach_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class StaffAvailabilityRepository(TenantBaseRepository[StaffAvailability]): + model = StaffAvailability + + async def list_by_coach(self, tenant_id: UUID, coach_id: UUID, *, offset: int = 0, limit: int = 20): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.coach_id == coach_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class StaffAssignmentRepository(TenantBaseRepository[StaffAssignment]): + model = StaffAssignment + + async def list_by_coach(self, tenant_id: UUID, coach_id: UUID, *, offset: int = 0, limit: int = 20): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.coach_id == coach_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/sports_center/app/repositories/training.py b/backend/services/sports_center/app/repositories/training.py new file mode 100644 index 0000000..1ef5113 --- /dev/null +++ b/backend/services/sports_center/app/repositories/training.py @@ -0,0 +1,111 @@ +"""Training Management repositories — Phase 9.6.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.training import ( + Assessment, + BodyComposition, + Exercise, + Measurement, + NutritionPlan, + PerformanceRecord, + ProgressRecord, + TrainingGoal, + TrainingProgram, + WorkoutAssignment, + WorkoutPlan, +) +from app.repositories.base import TenantBaseRepository + + +class TrainingProgramRepository(TenantBaseRepository[TrainingProgram]): + model = TrainingProgram + + +class ExerciseRepository(TenantBaseRepository[Exercise]): + model = Exercise + + +class WorkoutPlanRepository(TenantBaseRepository[WorkoutPlan]): + model = WorkoutPlan + + +class WorkoutAssignmentRepository(TenantBaseRepository[WorkoutAssignment]): + model = WorkoutAssignment + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 20 + ): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.member_id == member_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + return (await self.session.execute(stmt)).scalars().all() + + +class TrainingGoalRepository(TenantBaseRepository[TrainingGoal]): + model = TrainingGoal + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 20 + ): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.member_id == member_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + return (await self.session.execute(stmt)).scalars().all() + + +class MeasurementRepository(TenantBaseRepository[Measurement]): + model = Measurement + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 20 + ): + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.member_id == member_id, + self.model.is_deleted.is_(False), + ) + .order_by(self.model.measured_at.desc()) + .offset(offset) + .limit(limit) + ) + return (await self.session.execute(stmt)).scalars().all() + + +class ProgressRecordRepository(TenantBaseRepository[ProgressRecord]): + model = ProgressRecord + + +class NutritionPlanRepository(TenantBaseRepository[NutritionPlan]): + model = NutritionPlan + + +class AssessmentRepository(TenantBaseRepository[Assessment]): + model = Assessment + + +class PerformanceRecordRepository(TenantBaseRepository[PerformanceRecord]): + model = PerformanceRecord + + +class BodyCompositionRepository(TenantBaseRepository[BodyComposition]): + model = BodyComposition diff --git a/backend/services/sports_center/app/schemas/attendance.py b/backend/services/sports_center/app/schemas/attendance.py new file mode 100644 index 0000000..924c32b --- /dev/null +++ b/backend/services/sports_center/app/schemas/attendance.py @@ -0,0 +1,86 @@ +"""Attendance & Access Control schemas — Phase 9.5.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import AccessResult, AttendanceKind, AttendanceStatus +from app.schemas.common import ORMBase + + +class AttendanceRecordCreate(BaseModel): + sports_center_id: UUID + branch_id: UUID | None = None + member_id: UUID + membership_id: UUID | None = None + session_id: UUID | None = None + attendance_kind: AttendanceKind + status: AttendanceStatus + recorded_at: datetime + device_id: UUID | None = None + gateway_id: UUID | None = None + card_id: UUID | None = None + pass_code: str | None = Field(default=None, max_length=100) + notes: str | None = None + metadata_json: dict | None = None + + +class AttendanceRecordRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + member_id: UUID + membership_id: UUID | None + session_id: UUID | None + attendance_kind: AttendanceKind + status: AttendanceStatus + recorded_at: datetime + device_id: UUID | None + gateway_id: UUID | None + card_id: UUID | None + pass_code: str | None + notes: str | None + metadata_json: dict | None + is_deleted: bool + + +class AccessLogRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + member_id: UUID | None + membership_id: UUID | None + device_id: UUID | None + gateway_id: UUID | None + access_result: AccessResult + access_method: str | None + credential_ref: str | None + recorded_at: datetime + denial_reason: str | None + metadata_json: dict | None + is_deleted: bool + + +class CheckInRequest(BaseModel): + sports_center_id: UUID + member_id: UUID + membership_id: UUID | None = None + session_id: UUID | None = None + card_id: UUID | None = None + pass_code: str | None = Field(default=None, max_length=100) + device_id: UUID | None = None + gateway_id: UUID | None = None + notes: str | None = None + + +class CheckOutRequest(BaseModel): + sports_center_id: UUID + member_id: UUID + membership_id: UUID | None = None + device_id: UUID | None = None + gateway_id: UUID | None = None + notes: str | None = None diff --git a/backend/services/sports_center/app/schemas/booking.py b/backend/services/sports_center/app/schemas/booking.py new file mode 100644 index 0000000..0452ece --- /dev/null +++ b/backend/services/sports_center/app/schemas/booking.py @@ -0,0 +1,191 @@ +"""Scheduling & Booking schemas — Phase 9.4.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import BookingStatus, LifecycleStatus, RecurrenceKind, SessionKind, WaitlistStatus +from app.schemas.common import ORMBase + + +class EquipmentCreate(BaseModel): + sports_center_id: UUID + branch_id: UUID | None = None + facility_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + quantity_total: int = 1 + quantity_available: int = 1 + attributes: dict | None = None + + +class EquipmentUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + quantity_total: int | None = None + quantity_available: int | None = None + attributes: dict | None = None + + +class EquipmentRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + facility_id: UUID | None + code: str + name: str + description: str | None + status: LifecycleStatus + quantity_total: int + quantity_available: int + attributes: dict | None + is_deleted: bool + + +class SportsSessionCreate(BaseModel): + sports_center_id: UUID + branch_id: UUID | None = None + code: str = Field(max_length=50) + title: str = Field(max_length=255) + description: str | None = None + session_kind: SessionKind = SessionKind.CLASS + sport_id: UUID | None = None + coach_id: UUID | None = None + facility_id: UUID | None = None + court_id: UUID | None = None + room_id: UUID | None = None + starts_at: datetime + ends_at: datetime + capacity: int = 0 + recurrence_kind: RecurrenceKind = RecurrenceKind.NONE + recurrence_rule: dict | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + metadata_json: dict | None = None + + +class SportsSessionUpdate(BaseModel): + title: str | None = Field(default=None, max_length=255) + description: str | None = None + session_kind: SessionKind | None = None + sport_id: UUID | None = None + coach_id: UUID | None = None + facility_id: UUID | None = None + court_id: UUID | None = None + room_id: UUID | None = None + starts_at: datetime | None = None + ends_at: datetime | None = None + capacity: int | None = None + recurrence_kind: RecurrenceKind | None = None + recurrence_rule: dict | None = None + status: LifecycleStatus | None = None + metadata_json: dict | None = None + version: int | None = None + + +class SportsSessionRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + code: str + title: str + description: str | None + session_kind: SessionKind + sport_id: UUID | None + coach_id: UUID | None + facility_id: UUID | None + court_id: UUID | None + room_id: UUID | None + starts_at: datetime + ends_at: datetime + capacity: int + booked_count: int + waitlist_count: int + recurrence_kind: RecurrenceKind + recurrence_rule: dict | None + status: LifecycleStatus + metadata_json: dict | None + version: int + is_deleted: bool + + +class BookingCreate(BaseModel): + sports_center_id: UUID + session_id: UUID + member_id: UUID + membership_id: UUID | None = None + notes: str | None = None + + +class BookingCancelRequest(BaseModel): + cancellation_reason: str | None = Field(default=None, max_length=255) + + +class BookingRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + session_id: UUID + member_id: UUID + membership_id: UUID | None + booking_number: str + status: BookingStatus + notes: str | None + confirmed_at: datetime | None + cancelled_at: datetime | None + cancellation_reason: str | None + version: int + is_deleted: bool + + +class WaitingListCreate(BaseModel): + sports_center_id: UUID + session_id: UUID + member_id: UUID + notes: str | None = None + + +class WaitingListRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + session_id: UUID + member_id: UUID + position: int + status: WaitlistStatus + notes: str | None + is_deleted: bool + + +class ResourceReservationCreate(BaseModel): + sports_center_id: UUID + session_id: UUID | None = None + booking_id: UUID | None = None + facility_id: UUID | None = None + equipment_id: UUID | None = None + starts_at: datetime + ends_at: datetime + quantity: int = 1 + notes: str | None = None + + +class ResourceReservationRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + session_id: UUID | None + booking_id: UUID | None + facility_id: UUID | None + equipment_id: UUID | None + starts_at: datetime + ends_at: datetime + quantity: int + status: BookingStatus + notes: str | None + is_deleted: bool diff --git a/backend/services/sports_center/app/schemas/competitions.py b/backend/services/sports_center/app/schemas/competitions.py new file mode 100644 index 0000000..a123858 --- /dev/null +++ b/backend/services/sports_center/app/schemas/competitions.py @@ -0,0 +1,275 @@ +"""Competition schemas — Phase 9.7.""" +from __future__ import annotations + +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import ( + CompetitionKind, + CompetitionStatus, + MatchStatus, + MedalKind, + RegistrationStatus, + ResultOutcome, +) +from app.schemas.common import ORMBase + + +class CompetitionCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + competition_kind: CompetitionKind = CompetitionKind.TOURNAMENT + sport_id: UUID | None = None + facility_id: UUID | None = None + status: CompetitionStatus = CompetitionStatus.DRAFT + starts_on: date | None = None + ends_on: date | None = None + max_teams: int | None = None + rules: dict | None = None + metadata_json: dict | None = None + + +class CompetitionUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + competition_kind: CompetitionKind | None = None + sport_id: UUID | None = None + facility_id: UUID | None = None + status: CompetitionStatus | None = None + starts_on: date | None = None + ends_on: date | None = None + max_teams: int | None = None + rules: dict | None = None + metadata_json: dict | None = None + version: int | None = None + + +class CompetitionRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + competition_kind: CompetitionKind + sport_id: UUID | None + facility_id: UUID | None + status: CompetitionStatus + starts_on: date | None + ends_on: date | None + max_teams: int | None + rules: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + + +class CompetitionTeamCreate(BaseModel): + sports_center_id: UUID + competition_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + captain_member_id: UUID | None = None + group_id: UUID | None = None + metadata_json: dict | None = None + + +class CompetitionTeamRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + competition_id: UUID + code: str + name: str + captain_member_id: UUID | None + group_id: UUID | None + metadata_json: dict | None + is_deleted: bool + + +class CompetitionGroupCreate(BaseModel): + sports_center_id: UUID + competition_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + sort_order: int = 0 + metadata_json: dict | None = None + + +class CompetitionGroupRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + competition_id: UUID + code: str + name: str + sort_order: int + metadata_json: dict | None + is_deleted: bool + + +class CompetitionRegistrationCreate(BaseModel): + sports_center_id: UUID + competition_id: UUID + member_id: UUID + team_id: UUID | None = None + notes: str | None = None + + +class CompetitionRegistrationConfirm(BaseModel): + pass + + +class CompetitionRegistrationRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + competition_id: UUID + member_id: UUID + team_id: UUID | None + status: RegistrationStatus + registered_at: datetime + notes: str | None + is_deleted: bool + + +class CompetitionMatchCreate(BaseModel): + sports_center_id: UUID + competition_id: UUID + code: str = Field(max_length=50) + group_id: UUID | None = None + home_team_id: UUID | None = None + away_team_id: UUID | None = None + facility_id: UUID | None = None + scheduled_at: datetime | None = None + round_number: int | None = None + metadata_json: dict | None = None + + +class CompetitionMatchRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + competition_id: UUID + group_id: UUID | None + code: str + home_team_id: UUID | None + away_team_id: UUID | None + facility_id: UUID | None + scheduled_at: datetime | None + status: MatchStatus + round_number: int | None + metadata_json: dict | None + version: int + is_deleted: bool + + +class CompetitionResultCreate(BaseModel): + sports_center_id: UUID + competition_id: UUID + match_id: UUID + home_score: float | None = None + away_score: float | None = None + winner_team_id: UUID | None = None + outcome: ResultOutcome + notes: str | None = None + + +class CompetitionResultRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + competition_id: UUID + match_id: UUID + home_score: float | None + away_score: float | None + winner_team_id: UUID | None + outcome: ResultOutcome + recorded_at: datetime + notes: str | None + is_deleted: bool + + +class CompetitionRankingRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + competition_id: UUID + team_id: UUID | None + member_id: UUID | None + rank: int + points: float + wins: int + losses: int + draws: int + metadata_json: dict | None + is_deleted: bool + + +class CompetitionMedalCreate(BaseModel): + sports_center_id: UUID + competition_id: UUID + member_id: UUID + team_id: UUID | None = None + medal_kind: MedalKind + notes: str | None = None + + +class CompetitionMedalRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + competition_id: UUID + member_id: UUID + team_id: UUID | None + medal_kind: MedalKind + awarded_at: datetime + notes: str | None + is_deleted: bool + + +class CompetitionJudgeCreate(BaseModel): + sports_center_id: UUID + competition_id: UUID + coach_id: UUID + role: str = Field(max_length=100) + match_id: UUID | None = None + notes: str | None = None + + +class CompetitionJudgeRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + competition_id: UUID + coach_id: UUID + role: str + match_id: UUID | None + notes: str | None + is_deleted: bool + + +class CompetitionCertificateCreate(BaseModel): + sports_center_id: UUID + competition_id: UUID + member_id: UUID + title: str = Field(max_length=255) + file_ref: str | None = Field(default=None, max_length=255) + notes: str | None = None + + +class CompetitionCertificateRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + competition_id: UUID + member_id: UUID + title: str + file_ref: str | None + issued_at: datetime + notes: str | None + is_deleted: bool diff --git a/backend/services/sports_center/app/schemas/foundation.py b/backend/services/sports_center/app/schemas/foundation.py index 3d226e0..496e207 100644 --- a/backend/services/sports_center/app/schemas/foundation.py +++ b/backend/services/sports_center/app/schemas/foundation.py @@ -13,6 +13,7 @@ from app.models.types import ( LifecycleStatus, LockerStatus, MembershipStatus, + StaffKind, ) from app.schemas.common import ORMBase @@ -280,6 +281,11 @@ class CoachCreate(BaseModel): sport_ids: list | None = None qualifications: dict | None = None external_user_ref: str | None = Field(default=None, max_length=100) + staff_kind: StaffKind = StaffKind.COACH + bio: str | None = None + hire_date: date | None = None + payroll_external_ref: str | None = Field(default=None, max_length=100) + sports_role_id: UUID | None = None class CoachUpdate(BaseModel): @@ -291,6 +297,11 @@ class CoachUpdate(BaseModel): sport_ids: list | None = None qualifications: dict | None = None external_user_ref: str | None = Field(default=None, max_length=100) + staff_kind: StaffKind | None = None + bio: str | None = None + hire_date: date | None = None + payroll_external_ref: str | None = Field(default=None, max_length=100) + sports_role_id: UUID | None = None class CoachRead(ORMBase): @@ -306,6 +317,11 @@ class CoachRead(ORMBase): sport_ids: list | None qualifications: dict | None external_user_ref: str | None + staff_kind: StaffKind + bio: str | None + hire_date: date | None + payroll_external_ref: str | None + sports_role_id: UUID | None is_deleted: bool created_by: str | None updated_by: str | None diff --git a/backend/services/sports_center/app/schemas/staff.py b/backend/services/sports_center/app/schemas/staff.py new file mode 100644 index 0000000..fff97aa --- /dev/null +++ b/backend/services/sports_center/app/schemas/staff.py @@ -0,0 +1,189 @@ +"""Coach & Staff schemas — Phase 9.3.""" +from __future__ import annotations + +from datetime import date, datetime, time +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import AssignmentKind, LifecycleStatus, StaffKind +from app.schemas.common import ORMBase + + +class StaffCertificateCreate(BaseModel): + sports_center_id: UUID + coach_id: UUID + name: str = Field(max_length=255) + issuer: str | None = Field(default=None, max_length=255) + certificate_number: str | None = Field(default=None, max_length=100) + issued_on: date | None = None + expires_on: date | None = None + file_ref: str | None = Field(default=None, max_length=255) + status: LifecycleStatus = LifecycleStatus.ACTIVE + notes: str | None = None + + +class StaffCertificateUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + issuer: str | None = Field(default=None, max_length=255) + certificate_number: str | None = Field(default=None, max_length=100) + issued_on: date | None = None + expires_on: date | None = None + file_ref: str | None = Field(default=None, max_length=255) + status: LifecycleStatus | None = None + notes: str | None = None + + +class StaffCertificateRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + coach_id: UUID + name: str + issuer: str | None + certificate_number: str | None + issued_on: date | None + expires_on: date | None + file_ref: str | None + status: LifecycleStatus + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + + +class StaffSkillCreate(BaseModel): + sports_center_id: UUID + coach_id: UUID + skill_code: str = Field(max_length=50) + skill_name: str = Field(max_length=255) + level: int | None = None + notes: str | None = None + + +class StaffSkillUpdate(BaseModel): + skill_name: str | None = Field(default=None, max_length=255) + level: int | None = None + notes: str | None = None + + +class StaffSkillRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + coach_id: UUID + skill_code: str + skill_name: str + level: int | None + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + + +class StaffWorkingHoursCreate(BaseModel): + sports_center_id: UUID + coach_id: UUID + branch_id: UUID | None = None + day_of_week: int = Field(ge=0, le=6) + starts_at: time + ends_at: time + is_active: bool = True + + +class StaffWorkingHoursUpdate(BaseModel): + branch_id: UUID | None = None + day_of_week: int | None = Field(default=None, ge=0, le=6) + starts_at: time | None = None + ends_at: time | None = None + is_active: bool | None = None + + +class StaffWorkingHoursRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + coach_id: UUID + branch_id: UUID | None + day_of_week: int + starts_at: time + ends_at: time + is_active: bool + is_deleted: bool + created_by: str | None + updated_by: str | None + + +class StaffAvailabilityCreate(BaseModel): + sports_center_id: UUID + coach_id: UUID + starts_at: datetime + ends_at: datetime + is_available: bool = True + reason: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class StaffAvailabilityUpdate(BaseModel): + starts_at: datetime | None = None + ends_at: datetime | None = None + is_available: bool | None = None + reason: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class StaffAvailabilityRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + coach_id: UUID + starts_at: datetime + ends_at: datetime + is_available: bool + reason: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + + +class StaffAssignmentCreate(BaseModel): + sports_center_id: UUID + coach_id: UUID + assignment_kind: AssignmentKind + member_id: UUID | None = None + sport_id: UUID | None = None + program_ref: str | None = Field(default=None, max_length=100) + starts_on: date | None = None + ends_on: date | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + notes: str | None = None + + +class StaffAssignmentUpdate(BaseModel): + assignment_kind: AssignmentKind | None = None + member_id: UUID | None = None + sport_id: UUID | None = None + program_ref: str | None = Field(default=None, max_length=100) + starts_on: date | None = None + ends_on: date | None = None + status: LifecycleStatus | None = None + notes: str | None = None + + +class StaffAssignmentRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + coach_id: UUID + assignment_kind: AssignmentKind + member_id: UUID | None + sport_id: UUID | None + program_ref: str | None + starts_on: date | None + ends_on: date | None + status: LifecycleStatus + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None diff --git a/backend/services/sports_center/app/schemas/training.py b/backend/services/sports_center/app/schemas/training.py new file mode 100644 index 0000000..19818dc --- /dev/null +++ b/backend/services/sports_center/app/schemas/training.py @@ -0,0 +1,335 @@ +"""Training Management schemas — Phase 9.6.""" +from __future__ import annotations + +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import AssessmentKind, GoalStatus, ProgramStatus, WorkoutStatus +from app.schemas.common import ORMBase + + +class TrainingProgramCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + sport_id: UUID | None = None + coach_id: UUID | None = None + status: ProgramStatus = ProgramStatus.DRAFT + duration_weeks: int | None = None + metadata_json: dict | None = None + + +class TrainingProgramUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + sport_id: UUID | None = None + coach_id: UUID | None = None + status: ProgramStatus | None = None + duration_weeks: int | None = None + metadata_json: dict | None = None + version: int | None = None + + +class TrainingProgramRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + sport_id: UUID | None + coach_id: UUID | None + status: ProgramStatus + duration_weeks: int | None + metadata_json: dict | None + version: int + is_deleted: bool + + +class ExerciseCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + muscle_group: str | None = Field(default=None, max_length=100) + equipment_ref: str | None = Field(default=None, max_length=100) + default_sets: int | None = None + default_reps: int | None = None + default_duration_seconds: int | None = None + metadata_json: dict | None = None + + +class ExerciseUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + muscle_group: str | None = Field(default=None, max_length=100) + equipment_ref: str | None = Field(default=None, max_length=100) + default_sets: int | None = None + default_reps: int | None = None + default_duration_seconds: int | None = None + metadata_json: dict | None = None + + +class ExerciseRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + muscle_group: str | None + equipment_ref: str | None + default_sets: int | None + default_reps: int | None + default_duration_seconds: int | None + metadata_json: dict | None + is_deleted: bool + + +class WorkoutPlanCreate(BaseModel): + sports_center_id: UUID + program_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + coach_id: UUID | None = None + status: WorkoutStatus = WorkoutStatus.DRAFT + exercises: list | None = None + metadata_json: dict | None = None + + +class WorkoutPlanUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + coach_id: UUID | None = None + status: WorkoutStatus | None = None + exercises: list | None = None + metadata_json: dict | None = None + version: int | None = None + + +class WorkoutPlanRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + program_id: UUID | None + code: str + name: str + description: str | None + coach_id: UUID | None + status: WorkoutStatus + exercises: list | None + metadata_json: dict | None + version: int + is_deleted: bool + + +class WorkoutAssignmentCreate(BaseModel): + sports_center_id: UUID + workout_plan_id: UUID + member_id: UUID + coach_id: UUID | None = None + starts_on: date | None = None + ends_on: date | None = None + status: WorkoutStatus = WorkoutStatus.ACTIVE + notes: str | None = None + + +class WorkoutAssignmentRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + workout_plan_id: UUID + member_id: UUID + coach_id: UUID | None + starts_on: date | None + ends_on: date | None + status: WorkoutStatus + notes: str | None + is_deleted: bool + + +class TrainingGoalCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + program_id: UUID | None = None + title: str = Field(max_length=255) + description: str | None = None + target_value: float | None = None + current_value: float | None = None + unit: str | None = Field(default=None, max_length=50) + target_date: date | None = None + status: GoalStatus = GoalStatus.ACTIVE + + +class TrainingGoalRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + program_id: UUID | None + title: str + description: str | None + target_value: float | None + current_value: float | None + unit: str | None + target_date: date | None + status: GoalStatus + is_deleted: bool + + +class MeasurementCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + metric_code: str = Field(max_length=50) + metric_name: str = Field(max_length=255) + value: float + unit: str | None = Field(default=None, max_length=50) + measured_at: datetime + recorded_by_coach_id: UUID | None = None + notes: str | None = None + + +class MeasurementRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + metric_code: str + metric_name: str + value: float + unit: str | None + measured_at: datetime + recorded_by_coach_id: UUID | None + notes: str | None + is_deleted: bool + + +class ProgressRecordCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + goal_id: UUID | None = None + workout_assignment_id: UUID | None = None + recorded_at: datetime + summary: str | None = None + metrics: dict | None = None + + +class ProgressRecordRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + goal_id: UUID | None + workout_assignment_id: UUID | None + recorded_at: datetime + summary: str | None + metrics: dict | None + is_deleted: bool + + +class NutritionPlanCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + coach_id: UUID | None = None + title: str = Field(max_length=255) + description: str | None = None + starts_on: date | None = None + ends_on: date | None = None + daily_targets: dict | None = None + is_active: bool = True + + +class NutritionPlanRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + coach_id: UUID | None + title: str + description: str | None + starts_on: date | None + ends_on: date | None + daily_targets: dict | None + is_active: bool + is_deleted: bool + + +class AssessmentCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + coach_id: UUID | None = None + assessment_kind: AssessmentKind + title: str = Field(max_length=255) + assessed_at: datetime + score: float | None = None + results: dict | None = None + notes: str | None = None + + +class AssessmentRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + coach_id: UUID | None + assessment_kind: AssessmentKind + title: str + assessed_at: datetime + score: float | None + results: dict | None + notes: str | None + is_deleted: bool + + +class PerformanceRecordCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + exercise_id: UUID | None = None + recorded_at: datetime + value: float + unit: str | None = Field(default=None, max_length=50) + notes: str | None = None + + +class PerformanceRecordRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + exercise_id: UUID | None + recorded_at: datetime + value: float + unit: str | None + notes: str | None + is_deleted: bool + + +class BodyCompositionCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + measured_at: datetime + weight_kg: float | None = None + body_fat_percent: float | None = None + muscle_mass_kg: float | None = None + bmi: float | None = None + metrics: dict | None = None + notes: str | None = None + + +class BodyCompositionRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + measured_at: datetime + weight_kg: float | None + body_fat_percent: float | None + muscle_mass_kg: float | None + bmi: float | None + metrics: dict | None + notes: str | None + is_deleted: bool diff --git a/backend/services/sports_center/app/services/attendance.py b/backend/services/sports_center/app/services/attendance.py new file mode 100644 index 0000000..2c6d566 --- /dev/null +++ b/backend/services/sports_center/app/services/attendance.py @@ -0,0 +1,249 @@ +"""Attendance & Access Control services — Phase 9.5.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import SportsCenterEventType +from app.models.attendance import AccessLog, AttendanceRecord +from app.models.types import AccessResult, AttendanceKind, AttendanceStatus, AuditAction, MembershipStatus +from app.repositories.attendance import AccessLogRepository, AttendanceRecordRepository +from app.repositories.foundation import MembershipRepository, SportsCenterRepository +from app.repositories.members import MemberRepository, MembershipCardRepository +from app.schemas.attendance import AttendanceRecordCreate, CheckInRequest, CheckOutRequest +from app.services.audit_service import AuditService +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class AttendanceService: + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.centers = SportsCenterRepository(session) + self.members = MemberRepository(session) + self.memberships = MembershipRepository(session) + self.cards = MembershipCardRepository(session) + self.records = AttendanceRecordRepository(session) + self.access_logs = AccessLogRepository(session) + + async def _validate_membership(self, tenant_id: UUID, member_id: UUID, membership_id: UUID | None): + if membership_id is None: + return None + membership = await self.memberships.get(tenant_id, membership_id) + if membership is None or membership.member_id != member_id: + raise AppError("عضویت معتبر نیست", error_code="invalid_membership") + if membership.status not in (MembershipStatus.ACTIVE, MembershipStatus.FROZEN): + raise AppError("عضویت فعال نیست", error_code="membership_not_active") + return membership + + async def _log_access( + self, + tenant_id: UUID, + *, + sports_center_id: UUID, + member_id: UUID | None, + membership_id: UUID | None, + device_id: UUID | None, + gateway_id: UUID | None, + access_result: AccessResult, + access_method: str | None, + credential_ref: str | None, + denial_reason: str | None = None, + actor: CurrentUser, + ) -> AccessLog: + log = AccessLog( + tenant_id=tenant_id, + sports_center_id=sports_center_id, + member_id=member_id, + membership_id=membership_id, + device_id=device_id, + gateway_id=gateway_id, + access_result=access_result, + access_method=access_method, + credential_ref=credential_ref, + recorded_at=datetime.now(timezone.utc), + denial_reason=denial_reason, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.access_logs.add(log) + event = ( + SportsCenterEventType.ACCESS_GRANTED + if access_result == AccessResult.GRANTED + else SportsCenterEventType.ACCESS_DENIED + ) + self.publisher.publish( + tenant_id=tenant_id, + event_type=event, + aggregate_type="access_log", + aggregate_id=log.id, + payload={"member_id": str(member_id) if member_id else None, "result": access_result.value}, + ) + return log + + async def check_in(self, tenant_id: UUID, body: CheckInRequest, *, actor: CurrentUser): + center = await self.centers.get(tenant_id, body.sports_center_id) + if center is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + member = await self.members.get(tenant_id, body.member_id) + if member is None: + await self._log_access( + tenant_id, + sports_center_id=body.sports_center_id, + member_id=None, + membership_id=None, + device_id=body.device_id, + gateway_id=body.gateway_id, + access_result=AccessResult.DENIED, + access_method="check_in", + credential_ref=body.pass_code, + denial_reason="member_not_found", + actor=actor, + ) + await self.session.commit() + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + try: + await self._validate_membership(tenant_id, body.member_id, body.membership_id) + except AppError as exc: + await self._log_access( + tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + membership_id=body.membership_id, + device_id=body.device_id, + gateway_id=body.gateway_id, + access_result=AccessResult.DENIED, + access_method="check_in", + credential_ref=body.pass_code, + denial_reason=exc.error_code, + actor=actor, + ) + await self.session.commit() + raise + now = datetime.now(timezone.utc) + record = AttendanceRecord( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + membership_id=body.membership_id, + session_id=body.session_id, + attendance_kind=AttendanceKind.CHECK_IN, + status=AttendanceStatus.PRESENT, + recorded_at=now, + device_id=body.device_id, + gateway_id=body.gateway_id, + card_id=body.card_id, + pass_code=body.pass_code, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.records.add(record) + await self._log_access( + tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + membership_id=body.membership_id, + device_id=body.device_id, + gateway_id=body.gateway_id, + access_result=AccessResult.GRANTED, + access_method="check_in", + credential_ref=body.pass_code, + actor=actor, + ) + await self.session.commit() + await self.session.refresh(record) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.CHECK_IN_COMPLETED, + aggregate_type="attendance_record", + aggregate_id=record.id, + payload={"member_id": str(body.member_id)}, + ) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.ATTENDANCE_RECORDED, + aggregate_type="attendance_record", + aggregate_id=record.id, + payload={"kind": AttendanceKind.CHECK_IN.value}, + ) + return record + + async def check_out(self, tenant_id: UUID, body: CheckOutRequest, *, actor: CurrentUser): + member = await self.members.get(tenant_id, body.member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + now = datetime.now(timezone.utc) + record = AttendanceRecord( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + membership_id=body.membership_id, + attendance_kind=AttendanceKind.CHECK_OUT, + status=AttendanceStatus.PRESENT, + recorded_at=now, + device_id=body.device_id, + gateway_id=body.gateway_id, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.records.add(record) + await self.session.commit() + await self.session.refresh(record) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.CHECK_OUT_COMPLETED, + aggregate_type="attendance_record", + aggregate_id=record.id, + payload={"member_id": str(body.member_id)}, + ) + return record + + async def create_manual( + self, tenant_id: UUID, body: AttendanceRecordCreate, *, actor: CurrentUser + ): + member = await self.members.get(tenant_id, body.member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + record = AttendanceRecord( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + branch_id=body.branch_id, + member_id=body.member_id, + membership_id=body.membership_id, + session_id=body.session_id, + attendance_kind=body.attendance_kind, + status=body.status, + recorded_at=body.recorded_at, + device_id=body.device_id, + gateway_id=body.gateway_id, + card_id=body.card_id, + pass_code=body.pass_code, + notes=body.notes, + metadata_json=body.metadata_json, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.records.add(record) + await self.session.commit() + await self.session.refresh(record) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.ATTENDANCE_RECORDED, + aggregate_type="attendance_record", + aggregate_id=record.id, + payload={"kind": body.attendance_kind.value}, + ) + return record + + async def list_records(self, tenant_id: UUID, member_id: UUID, *, offset=0, limit=20): + return await self.records.list_by_member(tenant_id, member_id, offset=offset, limit=limit) + + async def list_access_logs(self, tenant_id: UUID, member_id: UUID, *, offset=0, limit=20): + return await self.access_logs.list_by_member(tenant_id, member_id, offset=offset, limit=limit) diff --git a/backend/services/sports_center/app/services/booking.py b/backend/services/sports_center/app/services/booking.py new file mode 100644 index 0000000..8f26bba --- /dev/null +++ b/backend/services/sports_center/app/services/booking.py @@ -0,0 +1,446 @@ +"""Scheduling & Booking services — Phase 9.4.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import SportsCenterEventType +from app.models.booking import Booking, Equipment, ResourceReservation, SportsSession, WaitingListEntry +from app.models.types import AuditAction, BookingStatus, LifecycleStatus, WaitlistStatus +from app.repositories.booking import ( + BookingRepository, + EquipmentRepository, + ResourceReservationRepository, + SportsSessionRepository, + WaitingListEntryRepository, +) +from app.repositories.foundation import SportsCenterRepository +from app.repositories.members import MemberRepository +from app.schemas.booking import ( + BookingCancelRequest, + BookingCreate, + EquipmentCreate, + EquipmentUpdate, + ResourceReservationCreate, + SportsSessionCreate, + SportsSessionUpdate, + WaitingListCreate, +) +from app.services.audit_service import AuditService +from app.validators import ensure_optimistic_version, validate_code, validate_non_empty, validate_non_negative_int +from app.validators.staff import validate_datetime_window +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + if value is not None: + setattr(entity, key, value) + + +def _next_booking_number() -> str: + return f"BK-{uuid4().hex[:10].upper()}" + + +class _BookingBase: + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.centers = SportsCenterRepository(session) + + +class EquipmentService(_BookingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = EquipmentRepository(session) + + async def create(self, tenant_id: UUID, body: EquipmentCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + validate_code(body.code) + validate_non_empty(body.name, field="name") + entity = Equipment( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + branch_id=body.branch_id, + facility_id=body.facility_id, + code=body.code, + name=body.name, + description=body.description, + status=body.status, + quantity_total=body.quantity_total, + quantity_available=body.quantity_available, + attributes=body.attributes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.EQUIPMENT_CREATED, + aggregate_type="equipment", + aggregate_id=entity.id, + payload={"code": body.code}, + ) + return entity + + async def _require_center(self, tenant_id: UUID, sports_center_id: UUID): + center = await self.centers.get(tenant_id, sports_center_id) + if center is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + return center + + async def list(self, tenant_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("تجهیزات یافت نشد", error_code="equipment_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: EquipmentUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + _apply_update(entity, body.model_dump(exclude_unset=True)) + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity + + +class SportsSessionService(_BookingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = SportsSessionRepository(session) + + async def _require_center(self, tenant_id: UUID, sports_center_id: UUID): + center = await self.centers.get(tenant_id, sports_center_id) + if center is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + return center + + async def _ensure_no_conflicts( + self, + tenant_id: UUID, + sports_center_id: UUID, + starts_at: datetime, + ends_at: datetime, + *, + coach_id=None, + facility_id=None, + exclude_id=None, + ) -> None: + validate_datetime_window(starts_at, ends_at) + conflicts = await self.repo.count_conflicts( + tenant_id, + sports_center_id, + starts_at, + ends_at, + coach_id=coach_id, + facility_id=facility_id, + exclude_id=exclude_id, + ) + if conflicts: + raise AppError("تداخل زمانی جلسه وجود دارد", status_code=409, error_code="session_conflict") + + async def create(self, tenant_id: UUID, body: SportsSessionCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + validate_code(body.code) + validate_non_empty(body.title, field="title") + validate_non_negative_int(body.capacity, field="capacity") + await self._ensure_no_conflicts( + tenant_id, + body.sports_center_id, + body.starts_at, + body.ends_at, + coach_id=body.coach_id, + facility_id=body.facility_id, + ) + entity = SportsSession( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + branch_id=body.branch_id, + code=body.code, + title=body.title, + description=body.description, + session_kind=body.session_kind, + sport_id=body.sport_id, + coach_id=body.coach_id, + facility_id=body.facility_id, + court_id=body.court_id, + room_id=body.room_id, + starts_at=body.starts_at, + ends_at=body.ends_at, + capacity=body.capacity, + recurrence_kind=body.recurrence_kind, + recurrence_rule=body.recurrence_rule, + status=body.status, + metadata_json=body.metadata_json, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.SESSION_CREATED, + aggregate_type="sports_session", + aggregate_id=entity.id, + payload={"code": body.code}, + ) + return entity + + async def list(self, tenant_id: UUID, sports_center_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_center(tenant_id, sports_center_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("جلسه یافت نشد", error_code="session_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: SportsSessionUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + starts = data.get("starts_at", entity.starts_at) + ends = data.get("ends_at", entity.ends_at) + coach_id = data.get("coach_id", entity.coach_id) + facility_id = data.get("facility_id", entity.facility_id) + await self._ensure_no_conflicts( + tenant_id, + entity.sports_center_id, + starts, + ends, + coach_id=coach_id, + facility_id=facility_id, + exclude_id=entity.id, + ) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + entity.version += 1 + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.SESSION_UPDATED, + aggregate_type="sports_session", + aggregate_id=entity.id, + payload={"code": entity.code}, + ) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity + + +class BookingService(_BookingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = BookingRepository(session) + self.sessions = SportsSessionRepository(session) + self.waitlist = WaitingListEntryRepository(session) + self.members = MemberRepository(session) + + async def create(self, tenant_id: UUID, body: BookingCreate, *, actor: CurrentUser): + session = await self.sessions.get(tenant_id, body.session_id) + if session is None: + raise NotFoundError("جلسه یافت نشد", error_code="session_not_found") + member = await self.members.get(tenant_id, body.member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + active = await self.repo.count_active_for_session(tenant_id, body.session_id) + if session.capacity > 0 and active >= session.capacity: + raise AppError("ظرفیت جلسه تکمیل است", error_code="session_full") + entity = Booking( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + session_id=body.session_id, + member_id=body.member_id, + membership_id=body.membership_id, + booking_number=_next_booking_number(), + status=BookingStatus.PENDING, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + session.booked_count = active + 1 + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.BOOKING_CREATED, + aggregate_type="booking", + aggregate_id=entity.id, + payload={"session_id": str(body.session_id), "member_id": str(body.member_id)}, + ) + return entity + + async def confirm(self, tenant_id: UUID, booking_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, booking_id) + if entity.status != BookingStatus.PENDING: + raise AppError("رزرو قابل تأیید نیست", error_code="invalid_booking_status") + entity.status = BookingStatus.CONFIRMED + entity.confirmed_at = datetime.now(timezone.utc) + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.BOOKING_CONFIRMED, + aggregate_type="booking", + aggregate_id=entity.id, + payload={"booking_number": entity.booking_number}, + ) + return entity + + async def cancel( + self, + tenant_id: UUID, + booking_id: UUID, + body: BookingCancelRequest, + *, + actor: CurrentUser, + ): + entity = await self.get(tenant_id, booking_id) + if entity.status in (BookingStatus.CANCELLED, BookingStatus.COMPLETED): + raise AppError("رزرو قابل لغو نیست", error_code="invalid_booking_status") + entity.status = BookingStatus.CANCELLED + entity.cancelled_at = datetime.now(timezone.utc) + entity.cancellation_reason = body.cancellation_reason + entity.updated_by = actor.user_id if actor else None + session = await self.sessions.get(tenant_id, entity.session_id) + if session and session.booked_count > 0: + session.booked_count -= 1 + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.BOOKING_CANCELLED, + aggregate_type="booking", + aggregate_id=entity.id, + payload={"booking_number": entity.booking_number}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("رزرو یافت نشد", error_code="booking_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class WaitingListService(_BookingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = WaitingListEntryRepository(session) + self.sessions = SportsSessionRepository(session) + self.members = MemberRepository(session) + + async def join(self, tenant_id: UUID, body: WaitingListCreate, *, actor: CurrentUser): + session = await self.sessions.get(tenant_id, body.session_id) + if session is None: + raise NotFoundError("جلسه یافت نشد", error_code="session_not_found") + member = await self.members.get(tenant_id, body.member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + position = await self.repo.next_position(tenant_id, body.session_id) + entity = WaitingListEntry( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + session_id=body.session_id, + member_id=body.member_id, + position=position, + status=WaitlistStatus.WAITING, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + session.waitlist_count += 1 + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.WAITING_LIST_JOINED, + aggregate_type="waiting_list_entry", + aggregate_id=entity.id, + payload={"session_id": str(body.session_id), "position": position}, + ) + return entity + + async def list(self, tenant_id: UUID, session_id: UUID, *, offset=0, limit=20): + stmt_entries = await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + return [e for e in stmt_entries if e.session_id == session_id] + + +class ResourceReservationService(_BookingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = ResourceReservationRepository(session) + + async def create(self, tenant_id: UUID, body: ResourceReservationCreate, *, actor: CurrentUser): + validate_datetime_window(body.starts_at, body.ends_at) + conflicts = await self.repo.count_conflicts( + tenant_id, + body.starts_at, + body.ends_at, + facility_id=body.facility_id, + equipment_id=body.equipment_id, + ) + if conflicts: + raise AppError("تداخل رزرو منبع وجود دارد", error_code="resource_conflict") + entity = ResourceReservation( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + session_id=body.session_id, + booking_id=body.booking_id, + facility_id=body.facility_id, + equipment_id=body.equipment_id, + starts_at=body.starts_at, + ends_at=body.ends_at, + quantity=body.quantity, + status=BookingStatus.CONFIRMED, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list(self, tenant_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("رزرو منبع یافت نشد", error_code="resource_reservation_not_found") + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity diff --git a/backend/services/sports_center/app/services/competitions.py b/backend/services/sports_center/app/services/competitions.py new file mode 100644 index 0000000..0ad341d --- /dev/null +++ b/backend/services/sports_center/app/services/competitions.py @@ -0,0 +1,520 @@ +"""Competition & Event Management services — Phase 9.7.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import SportsCenterEventType +from app.models.competitions import ( + Competition, + CompetitionCertificate, + CompetitionGroup, + CompetitionJudge, + CompetitionMatch, + CompetitionMedal, + CompetitionRanking, + CompetitionRegistration, + CompetitionResult, + CompetitionTeam, +) +from app.models.types import MatchStatus, RegistrationStatus, ResultOutcome +from app.repositories.competitions import ( + CompetitionCertificateRepository, + CompetitionGroupRepository, + CompetitionJudgeRepository, + CompetitionMatchRepository, + CompetitionMedalRepository, + CompetitionRankingRepository, + CompetitionRegistrationRepository, + CompetitionRepository, + CompetitionResultRepository, + CompetitionTeamRepository, +) +from app.repositories.foundation import CoachRepository, SportsCenterRepository +from app.repositories.members import MemberRepository +from app.schemas.competitions import ( + CompetitionCertificateCreate, + CompetitionCreate, + CompetitionGroupCreate, + CompetitionJudgeCreate, + CompetitionMatchCreate, + CompetitionMedalCreate, + CompetitionRegistrationCreate, + CompetitionResultCreate, + CompetitionTeamCreate, + CompetitionUpdate, +) +from app.validators import ensure_optimistic_version, validate_code, validate_non_empty +from app.validators.members import validate_file_ref +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + if value is not None: + setattr(entity, key, value) + + +class _CompetitionBase: + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + self.session = session + self.publisher = publisher or get_event_publisher() + self.centers = SportsCenterRepository(session) + self.competitions = CompetitionRepository(session) + + async def _require_center(self, tenant_id: UUID, sports_center_id: UUID): + center = await self.centers.get(tenant_id, sports_center_id) + if center is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + return center + + async def _require_competition(self, tenant_id: UUID, competition_id: UUID) -> Competition: + entity = await self.competitions.get(tenant_id, competition_id) + if entity is None: + raise NotFoundError("مسابقه یافت نشد", error_code="competition_not_found") + return entity + + +class CompetitionService(_CompetitionBase): + async def create(self, tenant_id: UUID, body: CompetitionCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + validate_code(body.code) + validate_non_empty(body.name, field="name") + actor_id = actor.user_id if actor else None + entity = Competition( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=body.code, + name=body.name, + description=body.description, + competition_kind=body.competition_kind, + sport_id=body.sport_id, + facility_id=body.facility_id, + status=body.status, + starts_on=body.starts_on, + ends_on=body.ends_on, + max_teams=body.max_teams, + rules=body.rules, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.competitions.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.COMPETITION_CREATED, + aggregate_type="competition", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": body.code}, + ) + return entity + + async def list(self, tenant_id: UUID, *, offset=0, limit=20): + return await self.competitions.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + return await self._require_competition(tenant_id, entity_id) + + async def update(self, tenant_id: UUID, entity_id: UUID, body: CompetitionUpdate, *, actor: CurrentUser): + entity = await self._require_competition(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + _apply_update(entity, body.model_dump(exclude_unset=True, exclude={"version"})) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.COMPETITION_UPDATED, + aggregate_type="competition", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + +class CompetitionTeamService(_CompetitionBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = CompetitionTeamRepository(session) + + async def create(self, tenant_id: UUID, body: CompetitionTeamCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + competition = await self._require_competition(tenant_id, body.competition_id) + if competition.max_teams: + count = await self.repo.count_by_competition(tenant_id, body.competition_id) + if count >= competition.max_teams: + raise AppError("ظرفیت تیم‌ها تکمیل است", status_code=409, error_code="team_capacity_full") + validate_code(body.code) + actor_id = actor.user_id if actor else None + entity = CompetitionTeam( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + competition_id=body.competition_id, + code=body.code, + name=body.name, + captain_member_id=body.captain_member_id, + group_id=body.group_id, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.COMPETITION_TEAM_CREATED, + aggregate_type="competition_team", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"competition_id": str(body.competition_id), "code": body.code}, + ) + return entity + + async def list(self, tenant_id: UUID, competition_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_competition(tenant_id, competition_id, offset=offset, limit=limit) + + +class CompetitionGroupService(_CompetitionBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = CompetitionGroupRepository(session) + + async def create(self, tenant_id: UUID, body: CompetitionGroupCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_competition(tenant_id, body.competition_id) + validate_code(body.code) + actor_id = actor.user_id if actor else None + entity = CompetitionGroup( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + competition_id=body.competition_id, + code=body.code, + name=body.name, + sort_order=body.sort_order, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class CompetitionRegistrationService(_CompetitionBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = CompetitionRegistrationRepository(session) + self.members = MemberRepository(session) + + async def create(self, tenant_id: UUID, body: CompetitionRegistrationCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_competition(tenant_id, body.competition_id) + member = await self.members.get(tenant_id, body.member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + actor_id = actor.user_id if actor else None + entity = CompetitionRegistration( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + competition_id=body.competition_id, + member_id=body.member_id, + team_id=body.team_id, + status=RegistrationStatus.PENDING, + registered_at=datetime.now(timezone.utc), + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.COMPETITION_REGISTRATION_CREATED, + aggregate_type="competition_registration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"competition_id": str(body.competition_id), "member_id": str(body.member_id)}, + ) + return entity + + async def confirm(self, tenant_id: UUID, registration_id: UUID, *, actor: CurrentUser): + entity = await self.repo.get(tenant_id, registration_id) + if entity is None: + raise NotFoundError("ثبت‌نام یافت نشد", error_code="registration_not_found") + entity.status = RegistrationStatus.CONFIRMED + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.COMPETITION_REGISTRATION_CONFIRMED, + aggregate_type="competition_registration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"competition_id": str(entity.competition_id)}, + ) + return entity + + +class CompetitionMatchService(_CompetitionBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = CompetitionMatchRepository(session) + + async def create(self, tenant_id: UUID, body: CompetitionMatchCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_competition(tenant_id, body.competition_id) + validate_code(body.code) + actor_id = actor.user_id if actor else None + entity = CompetitionMatch( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + competition_id=body.competition_id, + group_id=body.group_id, + code=body.code, + home_team_id=body.home_team_id, + away_team_id=body.away_team_id, + facility_id=body.facility_id, + scheduled_at=body.scheduled_at, + status=MatchStatus.SCHEDULED, + round_number=body.round_number, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.COMPETITION_MATCH_SCHEDULED, + aggregate_type="competition_match", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"competition_id": str(body.competition_id), "code": body.code}, + ) + return entity + + +class CompetitionResultService(_CompetitionBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = CompetitionResultRepository(session) + self.matches = CompetitionMatchRepository(session) + self.rankings = CompetitionRankingRepository(session) + + async def _update_rankings( + self, + tenant_id: UUID, + competition_id: UUID, + home_team_id: UUID | None, + away_team_id: UUID | None, + outcome: ResultOutcome, + winner_team_id: UUID | None, + ) -> None: + competition = await self._require_competition(tenant_id, competition_id) + + async def bump(team_id: UUID, *, win: bool = False, draw: bool = False, loss: bool = False) -> None: + ranking = await self.rankings.get_by_team(tenant_id, competition_id, team_id) + if ranking is None: + ranking = CompetitionRanking( + tenant_id=tenant_id, + sports_center_id=competition.sports_center_id, + competition_id=competition_id, + team_id=team_id, + rank=0, + points=0, + wins=0, + losses=0, + draws=0, + ) + await self.rankings.add(ranking) + if win: + ranking.wins += 1 + ranking.points = float(ranking.points) + 3 + elif draw: + ranking.draws += 1 + ranking.points = float(ranking.points) + 1 + elif loss: + ranking.losses += 1 + + if outcome == ResultOutcome.DRAW: + if home_team_id: + await bump(home_team_id, draw=True) + if away_team_id: + await bump(away_team_id, draw=True) + else: + if winner_team_id == home_team_id: + if home_team_id: + await bump(home_team_id, win=True) + if away_team_id: + await bump(away_team_id, loss=True) + elif winner_team_id == away_team_id: + if away_team_id: + await bump(away_team_id, win=True) + if home_team_id: + await bump(home_team_id, loss=True) + + async def create(self, tenant_id: UUID, body: CompetitionResultCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + match = await self.matches.get(tenant_id, body.match_id) + if match is None: + raise NotFoundError("مسابقه/بازی یافت نشد", error_code="match_not_found") + actor_id = actor.user_id if actor else None + now = datetime.now(timezone.utc) + entity = CompetitionResult( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + competition_id=body.competition_id, + match_id=body.match_id, + home_score=body.home_score, + away_score=body.away_score, + winner_team_id=body.winner_team_id, + outcome=body.outcome, + recorded_at=now, + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + match.status = MatchStatus.COMPLETED + await self._update_rankings( + tenant_id, + body.competition_id, + match.home_team_id, + match.away_team_id, + body.outcome, + body.winner_team_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.COMPETITION_RESULT_RECORDED, + aggregate_type="competition_result", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"match_id": str(body.match_id), "outcome": body.outcome.value}, + ) + return entity + + async def list_rankings(self, tenant_id: UUID, competition_id: UUID, *, offset=0, limit=20): + await self._require_competition(tenant_id, competition_id) + rows = await self.rankings.list_by_competition(tenant_id, competition_id, offset=offset, limit=limit) + for idx, row in enumerate(rows, start=1): + row.rank = idx + await self.session.commit() + return rows + + +class CompetitionMedalService(_CompetitionBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = CompetitionMedalRepository(session) + self.members = MemberRepository(session) + + async def create(self, tenant_id: UUID, body: CompetitionMedalCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_competition(tenant_id, body.competition_id) + member = await self.members.get(tenant_id, body.member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + actor_id = actor.user_id if actor else None + entity = CompetitionMedal( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + competition_id=body.competition_id, + member_id=body.member_id, + team_id=body.team_id, + medal_kind=body.medal_kind, + awarded_at=datetime.now(timezone.utc), + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.COMPETITION_MEDAL_AWARDED, + aggregate_type="competition_medal", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"medal_kind": body.medal_kind.value}, + ) + return entity + + +class CompetitionJudgeService(_CompetitionBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = CompetitionJudgeRepository(session) + self.coaches = CoachRepository(session) + + async def create(self, tenant_id: UUID, body: CompetitionJudgeCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_competition(tenant_id, body.competition_id) + coach = await self.coaches.get(tenant_id, body.coach_id) + if coach is None: + raise NotFoundError("داور/مربی یافت نشد", error_code="coach_not_found") + actor_id = actor.user_id if actor else None + entity = CompetitionJudge( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + competition_id=body.competition_id, + coach_id=body.coach_id, + role=body.role, + match_id=body.match_id, + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class CompetitionCertificateService(_CompetitionBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = CompetitionCertificateRepository(session) + self.members = MemberRepository(session) + + async def create(self, tenant_id: UUID, body: CompetitionCertificateCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_competition(tenant_id, body.competition_id) + member = await self.members.get(tenant_id, body.member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + if body.file_ref: + validate_file_ref(body.file_ref) + actor_id = actor.user_id if actor else None + entity = CompetitionCertificate( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + competition_id=body.competition_id, + member_id=body.member_id, + title=body.title, + file_ref=body.file_ref, + issued_at=datetime.now(timezone.utc), + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.COMPETITION_CERTIFICATE_ISSUED, + aggregate_type="competition_certificate", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"competition_id": str(body.competition_id)}, + ) + return entity diff --git a/backend/services/sports_center/app/services/foundation.py b/backend/services/sports_center/app/services/foundation.py index af6fcc8..fef63a4 100644 --- a/backend/services/sports_center/app/services/foundation.py +++ b/backend/services/sports_center/app/services/foundation.py @@ -717,6 +717,8 @@ class CoachService(_BaseService): email=validate_email(body.email), mobile=validate_phone(body.mobile, field="mobile"), status=validate_lifecycle_status(body.status), sport_ids=body.sport_ids, qualifications=body.qualifications, external_user_ref=body.external_user_ref, + staff_kind=body.staff_kind, bio=body.bio, hire_date=body.hire_date, + payroll_external_ref=body.payroll_external_ref, sports_role_id=body.sports_role_id, created_by=actor_id, updated_by=actor_id, ) await self.repo.add(entity) diff --git a/backend/services/sports_center/app/services/staff.py b/backend/services/sports_center/app/services/staff.py new file mode 100644 index 0000000..311dc86 --- /dev/null +++ b/backend/services/sports_center/app/services/staff.py @@ -0,0 +1,409 @@ +"""Coach & Staff Management services — Phase 9.3.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import SportsCenterEventType +from app.models.staff import ( + StaffAssignment, + StaffAvailability, + StaffCertificate, + StaffSkill, + StaffWorkingHours, +) +from app.models.types import AuditAction +from app.repositories.foundation import CoachRepository, SportsCenterRepository +from app.repositories.members import MemberRepository +from app.repositories.staff import ( + StaffAssignmentRepository, + StaffAvailabilityRepository, + StaffCertificateRepository, + StaffSkillRepository, + StaffWorkingHoursRepository, +) +from app.schemas.staff import ( + StaffAssignmentCreate, + StaffAssignmentUpdate, + StaffAvailabilityCreate, + StaffAvailabilityUpdate, + StaffCertificateCreate, + StaffCertificateUpdate, + StaffSkillCreate, + StaffSkillUpdate, + StaffWorkingHoursCreate, + StaffWorkingHoursUpdate, +) +from app.services.audit_service import AuditService +from app.validators import validate_code, validate_non_empty +from app.validators.members import validate_file_ref +from app.validators.staff import ( + validate_assignment_kind, + validate_datetime_window, + validate_day_of_week, + validate_time_window, +) +from shared.exceptions import NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + if value is not None: + setattr(entity, key, value) + + +class _StaffBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.centers = SportsCenterRepository(session) + self.coaches = CoachRepository(session) + + async def _require_center(self, tenant_id: UUID, sports_center_id: UUID): + center = await self.centers.get(tenant_id, sports_center_id) + if center is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + return center + + async def _require_coach(self, tenant_id: UUID, coach_id: UUID): + coach = await self.coaches.get(tenant_id, coach_id) + if coach is None: + raise NotFoundError("مربی/پرسنل یافت نشد", error_code="coach_not_found") + return coach + + +class StaffCertificateService(_StaffBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = StaffCertificateRepository(session) + + async def create(self, tenant_id: UUID, body: StaffCertificateCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_coach(tenant_id, body.coach_id) + validate_non_empty(body.name, field="name") + if body.file_ref: + validate_file_ref(body.file_ref) + entity = StaffCertificate( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + coach_id=body.coach_id, + name=body.name, + issuer=body.issuer, + certificate_number=body.certificate_number, + issued_on=body.issued_on, + expires_on=body.expires_on, + file_ref=body.file_ref, + status=body.status, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.STAFF_CERTIFICATE_CREATED, + aggregate_type="staff_certificate", + aggregate_id=entity.id, + payload={"coach_id": str(body.coach_id), "name": body.name}, + ) + return entity + + async def list(self, tenant_id: UUID, coach_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_coach(tenant_id, coach_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("گواهینامه یافت نشد", error_code="staff_certificate_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: StaffCertificateUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "file_ref" in data and data["file_ref"]: + validate_file_ref(data["file_ref"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.STAFF_CERTIFICATE_UPDATED, + aggregate_type="staff_certificate", + aggregate_id=entity.id, + payload={"coach_id": str(entity.coach_id)}, + ) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity + + +class StaffSkillService(_StaffBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = StaffSkillRepository(session) + + async def create(self, tenant_id: UUID, body: StaffSkillCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_coach(tenant_id, body.coach_id) + validate_code(body.skill_code) + validate_non_empty(body.skill_name, field="skill_name") + entity = StaffSkill( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + coach_id=body.coach_id, + skill_code=body.skill_code, + skill_name=body.skill_name, + level=body.level, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.STAFF_SKILL_CREATED, + aggregate_type="staff_skill", + aggregate_id=entity.id, + payload={"coach_id": str(body.coach_id), "skill_code": body.skill_code}, + ) + return entity + + async def list(self, tenant_id: UUID, coach_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_coach(tenant_id, coach_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("مهارت یافت نشد", error_code="staff_skill_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: StaffSkillUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + _apply_update(entity, body.model_dump(exclude_unset=True)) + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity + + +class StaffWorkingHoursService(_StaffBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = StaffWorkingHoursRepository(session) + + async def create(self, tenant_id: UUID, body: StaffWorkingHoursCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_coach(tenant_id, body.coach_id) + validate_day_of_week(body.day_of_week) + validate_time_window(body.starts_at, body.ends_at) + entity = StaffWorkingHours( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + coach_id=body.coach_id, + branch_id=body.branch_id, + day_of_week=body.day_of_week, + starts_at=body.starts_at, + ends_at=body.ends_at, + is_active=body.is_active, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.STAFF_WORKING_HOURS_CREATED, + aggregate_type="staff_working_hours", + aggregate_id=entity.id, + payload={"coach_id": str(body.coach_id)}, + ) + return entity + + async def list(self, tenant_id: UUID, coach_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_coach(tenant_id, coach_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("ساعات کاری یافت نشد", error_code="staff_working_hours_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: StaffWorkingHoursUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + starts = data.get("starts_at", entity.starts_at) + ends = data.get("ends_at", entity.ends_at) + validate_time_window(starts, ends) + if "day_of_week" in data: + validate_day_of_week(data["day_of_week"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity + + +class StaffAvailabilityService(_StaffBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = StaffAvailabilityRepository(session) + + async def create(self, tenant_id: UUID, body: StaffAvailabilityCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_coach(tenant_id, body.coach_id) + validate_datetime_window(body.starts_at, body.ends_at) + entity = StaffAvailability( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + coach_id=body.coach_id, + starts_at=body.starts_at, + ends_at=body.ends_at, + is_available=body.is_available, + reason=body.reason, + metadata_json=body.metadata_json, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.STAFF_AVAILABILITY_CHANGED, + aggregate_type="staff_availability", + aggregate_id=entity.id, + payload={"coach_id": str(body.coach_id), "is_available": body.is_available}, + ) + return entity + + async def list(self, tenant_id: UUID, coach_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_coach(tenant_id, coach_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("دسترس‌پذیری یافت نشد", error_code="staff_availability_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: StaffAvailabilityUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + starts = data.get("starts_at", entity.starts_at) + ends = data.get("ends_at", entity.ends_at) + validate_datetime_window(starts, ends) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.STAFF_AVAILABILITY_CHANGED, + aggregate_type="staff_availability", + aggregate_id=entity.id, + payload={"coach_id": str(entity.coach_id), "is_available": entity.is_available}, + ) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity + + +class StaffAssignmentService(_StaffBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = StaffAssignmentRepository(session) + self.members = MemberRepository(session) + + async def create(self, tenant_id: UUID, body: StaffAssignmentCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_coach(tenant_id, body.coach_id) + validate_assignment_kind(body.assignment_kind) + if body.member_id: + member = await self.members.get(tenant_id, body.member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + entity = StaffAssignment( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + coach_id=body.coach_id, + assignment_kind=body.assignment_kind, + member_id=body.member_id, + sport_id=body.sport_id, + program_ref=body.program_ref, + starts_on=body.starts_on, + ends_on=body.ends_on, + status=body.status, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.STAFF_ASSIGNMENT_CREATED, + aggregate_type="staff_assignment", + aggregate_id=entity.id, + payload={"coach_id": str(body.coach_id), "assignment_kind": body.assignment_kind.value}, + ) + return entity + + async def list(self, tenant_id: UUID, coach_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_coach(tenant_id, coach_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("تخصیص یافت نشد", error_code="staff_assignment_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: StaffAssignmentUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "assignment_kind" in data: + validate_assignment_kind(data["assignment_kind"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity diff --git a/backend/services/sports_center/app/services/training.py b/backend/services/sports_center/app/services/training.py new file mode 100644 index 0000000..c3bd7d4 --- /dev/null +++ b/backend/services/sports_center/app/services/training.py @@ -0,0 +1,532 @@ +"""Training Management services — Phase 9.6.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import SportsCenterEventType +from app.models.training import ( + Assessment, + BodyComposition, + Exercise, + Measurement, + NutritionPlan, + PerformanceRecord, + ProgressRecord, + TrainingGoal, + TrainingProgram, + WorkoutAssignment, + WorkoutPlan, +) +from app.models.types import AuditAction +from app.repositories.foundation import SportsCenterRepository +from app.repositories.members import MemberRepository +from app.repositories.training import ( + AssessmentRepository, + BodyCompositionRepository, + ExerciseRepository, + MeasurementRepository, + NutritionPlanRepository, + PerformanceRecordRepository, + ProgressRecordRepository, + TrainingGoalRepository, + TrainingProgramRepository, + WorkoutAssignmentRepository, + WorkoutPlanRepository, +) +from app.schemas.training import ( + AssessmentCreate, + BodyCompositionCreate, + ExerciseCreate, + ExerciseUpdate, + MeasurementCreate, + NutritionPlanCreate, + PerformanceRecordCreate, + ProgressRecordCreate, + TrainingGoalCreate, + TrainingProgramCreate, + TrainingProgramUpdate, + WorkoutAssignmentCreate, + WorkoutPlanCreate, + WorkoutPlanUpdate, +) +from app.services.audit_service import AuditService +from app.validators import ensure_optimistic_version, validate_code, validate_non_empty +from shared.exceptions import NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + if value is not None: + setattr(entity, key, value) + + +class _TrainingBase: + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.centers = SportsCenterRepository(session) + self.members = MemberRepository(session) + + async def _require_center(self, tenant_id: UUID, sports_center_id: UUID): + center = await self.centers.get(tenant_id, sports_center_id) + if center is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + return center + + async def _require_member(self, tenant_id: UUID, member_id: UUID): + member = await self.members.get(tenant_id, member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + return member + + +class TrainingProgramService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = TrainingProgramRepository(session) + + async def create(self, tenant_id: UUID, body: TrainingProgramCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + validate_code(body.code) + validate_non_empty(body.name, field="name") + entity = TrainingProgram( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=body.code, + name=body.name, + description=body.description, + sport_id=body.sport_id, + coach_id=body.coach_id, + status=body.status, + duration_weeks=body.duration_weeks, + metadata_json=body.metadata_json, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.TRAINING_PROGRAM_CREATED, + aggregate_type="training_program", + aggregate_id=entity.id, + payload={"code": body.code}, + ) + return entity + + async def list(self, tenant_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("برنامه تمرینی یافت نشد", error_code="training_program_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: TrainingProgramUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + _apply_update(entity, body.model_dump(exclude_unset=True, exclude={"version"})) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity + + +class ExerciseService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = ExerciseRepository(session) + + async def create(self, tenant_id: UUID, body: ExerciseCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + validate_code(body.code) + entity = Exercise( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=body.code, + name=body.name, + description=body.description, + muscle_group=body.muscle_group, + equipment_ref=body.equipment_ref, + default_sets=body.default_sets, + default_reps=body.default_reps, + default_duration_seconds=body.default_duration_seconds, + metadata_json=body.metadata_json, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list(self, tenant_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("تمرین یافت نشد", error_code="exercise_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: ExerciseUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + _apply_update(entity, body.model_dump(exclude_unset=True)) + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity + + +class WorkoutPlanService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = WorkoutPlanRepository(session) + + async def create(self, tenant_id: UUID, body: WorkoutPlanCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + validate_code(body.code) + entity = WorkoutPlan( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + program_id=body.program_id, + code=body.code, + name=body.name, + description=body.description, + coach_id=body.coach_id, + status=body.status, + exercises=body.exercises, + metadata_json=body.metadata_json, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.WORKOUT_PLAN_CREATED, + aggregate_type="workout_plan", + aggregate_id=entity.id, + payload={"code": body.code}, + ) + return entity + + async def list(self, tenant_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("برنامه تمرین یافت نشد", error_code="workout_plan_not_found") + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: WorkoutPlanUpdate, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + _apply_update(entity, body.model_dump(exclude_unset=True, exclude={"version"})) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.session.commit() + return entity + + +class WorkoutAssignmentService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = WorkoutAssignmentRepository(session) + self.plans = WorkoutPlanRepository(session) + + async def create(self, tenant_id: UUID, body: WorkoutAssignmentCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + plan = await self.plans.get(tenant_id, body.workout_plan_id) + if plan is None: + raise NotFoundError("برنامه تمرین یافت نشد", error_code="workout_plan_not_found") + entity = WorkoutAssignment( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + workout_plan_id=body.workout_plan_id, + member_id=body.member_id, + coach_id=body.coach_id, + starts_on=body.starts_on, + ends_on=body.ends_on, + status=body.status, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.WORKOUT_ASSIGNED, + aggregate_type="workout_assignment", + aggregate_id=entity.id, + payload={"member_id": str(body.member_id)}, + ) + return entity + + async def list_by_member(self, tenant_id: UUID, member_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_member(tenant_id, member_id, offset=offset, limit=limit) + + +class TrainingGoalService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = TrainingGoalRepository(session) + + async def create(self, tenant_id: UUID, body: TrainingGoalCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + entity = TrainingGoal( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + program_id=body.program_id, + title=body.title, + description=body.description, + target_value=body.target_value, + current_value=body.current_value, + unit=body.unit, + target_date=body.target_date, + status=body.status, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.TRAINING_GOAL_CREATED, + aggregate_type="training_goal", + aggregate_id=entity.id, + payload={"member_id": str(body.member_id)}, + ) + return entity + + async def list_by_member(self, tenant_id: UUID, member_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_member(tenant_id, member_id, offset=offset, limit=limit) + + +class MeasurementService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = MeasurementRepository(session) + + async def create(self, tenant_id: UUID, body: MeasurementCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + entity = Measurement( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + metric_code=body.metric_code, + metric_name=body.metric_name, + value=body.value, + unit=body.unit, + measured_at=body.measured_at, + recorded_by_coach_id=body.recorded_by_coach_id, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.MEASUREMENT_RECORDED, + aggregate_type="measurement", + aggregate_id=entity.id, + payload={"metric_code": body.metric_code}, + ) + return entity + + async def list_by_member(self, tenant_id: UUID, member_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_member(tenant_id, member_id, offset=offset, limit=limit) + + +class ProgressRecordService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = ProgressRecordRepository(session) + + async def create(self, tenant_id: UUID, body: ProgressRecordCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + entity = ProgressRecord( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + goal_id=body.goal_id, + workout_assignment_id=body.workout_assignment_id, + recorded_at=body.recorded_at, + summary=body.summary, + metrics=body.metrics, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.PROGRESS_RECORDED, + aggregate_type="progress_record", + aggregate_id=entity.id, + payload={"member_id": str(body.member_id)}, + ) + return entity + + +class NutritionPlanService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = NutritionPlanRepository(session) + + async def create(self, tenant_id: UUID, body: NutritionPlanCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + entity = NutritionPlan( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + coach_id=body.coach_id, + title=body.title, + description=body.description, + starts_on=body.starts_on, + ends_on=body.ends_on, + daily_targets=body.daily_targets, + is_active=body.is_active, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_by_member(self, tenant_id: UUID, member_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + +class AssessmentService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = AssessmentRepository(session) + + async def create(self, tenant_id: UUID, body: AssessmentCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + entity = Assessment( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + coach_id=body.coach_id, + assessment_kind=body.assessment_kind, + title=body.title, + assessed_at=body.assessed_at, + score=body.score, + results=body.results, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + tenant_id=tenant_id, + event_type=SportsCenterEventType.ASSESSMENT_COMPLETED, + aggregate_type="assessment", + aggregate_id=entity.id, + payload={"assessment_kind": body.assessment_kind.value}, + ) + return entity + + +class PerformanceRecordService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = PerformanceRecordRepository(session) + + async def create(self, tenant_id: UUID, body: PerformanceRecordCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + entity = PerformanceRecord( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + exercise_id=body.exercise_id, + recorded_at=body.recorded_at, + value=body.value, + unit=body.unit, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class BodyCompositionService(_TrainingBase): + def __init__(self, session: AsyncSession, publisher=None) -> None: + super().__init__(session, publisher) + self.repo = BodyCompositionRepository(session) + + async def create(self, tenant_id: UUID, body: BodyCompositionCreate, *, actor: CurrentUser): + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + entity = BodyComposition( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + measured_at=body.measured_at, + weight_kg=body.weight_kg, + body_fat_percent=body.body_fat_percent, + muscle_mass_kg=body.muscle_mass_kg, + bmi=body.bmi, + metrics=body.metrics, + notes=body.notes, + created_by=actor.user_id if actor else None, + updated_by=actor.user_id if actor else None, + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_by_member(self, tenant_id: UUID, member_id: UUID, *, offset=0, limit=20): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/sports_center/app/tests/test_architecture.py b/backend/services/sports_center/app/tests/test_architecture.py index 60a9701..018e668 100644 --- a/backend/services/sports_center/app/tests/test_architecture.py +++ b/backend/services/sports_center/app/tests/test_architecture.py @@ -136,6 +136,92 @@ def test_member_permissions_defined(): assert "sports_center.memberships.status" in ALL_PERMISSIONS +def test_staff_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS + + assert "sports_center.staff_certificates.manage" in ALL_PERMISSIONS + assert "sports_center.staff_assignments.manage" in ALL_PERMISSIONS + + +def test_booking_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS + + assert "sports_center.bookings.manage" in ALL_PERMISSIONS + assert "sports_center.sessions.manage" in ALL_PERMISSIONS + + +def test_attendance_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS + + assert "sports_center.attendance.manage" in ALL_PERMISSIONS + assert "sports_center.check_in.manage" in ALL_PERMISSIONS + + +def test_training_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS + + assert "sports_center.training_programs.manage" in ALL_PERMISSIONS + assert "sports_center.workout_plans.manage" in ALL_PERMISSIONS + + +def test_competition_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS + + assert "sports_center.competitions.manage" in ALL_PERMISSIONS + assert "sports_center.competition_matches.manage" in ALL_PERMISSIONS + + +def test_competition_events_defined(): + from app.events.types import SportsCenterEventType + + assert SportsCenterEventType.COMPETITION_CREATED.value == "sports_center.competition.created" + assert SportsCenterEventType.COMPETITION_RESULT_RECORDED.value == "sports_center.competition.result.recorded" + + +def test_competition_models_have_no_relationships(): + from app.models import competitions as competition_models + + models = [ + competition_models.Competition, + competition_models.CompetitionTeam, + competition_models.CompetitionGroup, + competition_models.CompetitionRegistration, + competition_models.CompetitionMatch, + competition_models.CompetitionResult, + competition_models.CompetitionRanking, + competition_models.CompetitionMedal, + competition_models.CompetitionJudge, + competition_models.CompetitionCertificate, + ] + for model in models: + assert hasattr(model, "tenant_id") + assert not model.__mapper__.relationships + + +def test_phase_events_defined(): + from app.events.types import SportsCenterEventType + + assert SportsCenterEventType.STAFF_CERTIFICATE_CREATED.value == "sports_center.staff_certificate.created" + assert SportsCenterEventType.BOOKING_CREATED.value == "sports_center.booking.created" + assert SportsCenterEventType.CHECK_IN_COMPLETED.value == "sports_center.check_in.completed" + assert SportsCenterEventType.TRAINING_PROGRAM_CREATED.value == "sports_center.training_program.created" + + +def test_staff_models_have_no_relationships(): + from app.models import staff as staff_models + + models = [ + staff_models.StaffCertificate, + staff_models.StaffSkill, + staff_models.StaffWorkingHours, + staff_models.StaffAvailability, + staff_models.StaffAssignment, + ] + for model in models: + assert hasattr(model, "tenant_id") + assert not model.__mapper__.relationships + + def test_events_defined(): from app.events.types import SportsCenterEventType diff --git a/backend/services/sports_center/app/tests/test_attendance.py b/backend/services/sports_center/app/tests/test_attendance.py new file mode 100644 index 0000000..ce58648 --- /dev/null +++ b/backend/services/sports_center/app/tests/test_attendance.py @@ -0,0 +1,78 @@ +"""Attendance & Access Control tests — Phase 9.5.""" +from __future__ import annotations + +import pytest + +from app.events.types import SportsCenterEventType +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _seed(client, headers): + center = await client.post( + "/api/v1/sports-centers", + json={"code": "att95", "name": "Attendance Center", "status": "active"}, + headers=headers, + ) + assert center.status_code == 201 + center_id = center.json()["id"] + mtype = await client.post( + "/api/v1/membership-types", + json={"sports_center_id": center_id, "code": "std", "name": "Standard"}, + headers=headers, + ) + member = await client.post( + "/api/v1/members", + json={"sports_center_id": center_id, "display_name": "Att Member", "status": "active"}, + headers=headers, + ) + membership = await client.post( + "/api/v1/memberships", + json={ + "sports_center_id": center_id, + "membership_type_id": mtype.json()["id"], + "display_name": "Att Member", + "member_id": member.json()["id"], + }, + headers=headers, + ) + activate = await client.post( + f"/api/v1/memberships/{membership.json()['id']}/activate", + headers=headers, + ) + assert activate.status_code == 200 + return center_id, member.json()["id"], membership.json()["id"] + + +@pytest.mark.asyncio +async def test_check_in_out(client): + headers = tenant_headers(TENANT_A) + center_id, member_id, membership_id = await _seed(client, headers) + + check_in = await client.post( + "/api/v1/check-in", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "membership_id": membership_id, + }, + headers=headers, + ) + assert check_in.status_code == 201, check_in.text + + check_out = await client.post( + "/api/v1/check-out", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "membership_id": membership_id, + }, + headers=headers, + ) + assert check_out.status_code == 201, check_out.text + + history = await client.get( + f"/api/v1/attendance-records?member_id={member_id}", + headers=headers, + ) + assert history.status_code == 200 + assert len(history.json()) >= 2 diff --git a/backend/services/sports_center/app/tests/test_booking.py b/backend/services/sports_center/app/tests/test_booking.py new file mode 100644 index 0000000..ae65a50 --- /dev/null +++ b/backend/services/sports_center/app/tests/test_booking.py @@ -0,0 +1,112 @@ +"""Scheduling & Booking tests — Phase 9.4.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.events.types import SportsCenterEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _seed(client, headers): + center = await client.post( + "/api/v1/sports-centers", + json={"code": "bk94", "name": "Booking Center", "status": "active"}, + headers=headers, + ) + assert center.status_code == 201 + center_id = center.json()["id"] + member = await client.post( + "/api/v1/members", + json={"sports_center_id": center_id, "display_name": "Book Member", "status": "active"}, + headers=headers, + ) + assert member.status_code == 201 + return center_id, member.json()["id"] + + +@pytest.mark.asyncio +async def test_booking_happy_path(client): + headers = tenant_headers(TENANT_A) + center_id, member_id = await _seed(client, headers) + now = datetime.now(timezone.utc) + session = await client.post( + "/api/v1/sessions", + json={ + "sports_center_id": center_id, + "code": "sess1", + "title": "Morning Class", + "starts_at": (now + timedelta(hours=1)).isoformat(), + "ends_at": (now + timedelta(hours=2)).isoformat(), + "capacity": 10, + }, + headers=headers, + ) + assert session.status_code == 201, session.text + session_id = session.json()["id"] + + booking = await client.post( + "/api/v1/bookings", + json={ + "sports_center_id": center_id, + "session_id": session_id, + "member_id": member_id, + }, + headers=headers, + ) + assert booking.status_code == 201, booking.text + booking_id = booking.json()["id"] + + confirm = await client.post(f"/api/v1/bookings/{booking_id}/confirm", headers=headers) + assert confirm.status_code == 200 + assert confirm.json()["status"] == "confirmed" + + +@pytest.mark.asyncio +async def test_session_conflict(client): + headers = tenant_headers(TENANT_A) + center_id, _ = await _seed(client, headers) + now = datetime.now(timezone.utc) + payload = { + "sports_center_id": center_id, + "code": "conf1", + "title": "Conflict A", + "starts_at": (now + timedelta(hours=1)).isoformat(), + "ends_at": (now + timedelta(hours=2)).isoformat(), + "capacity": 5, + } + first = await client.post("/api/v1/sessions", json=payload, headers=headers) + assert first.status_code == 201 + payload["code"] = "conf2" + second = await client.post("/api/v1/sessions", json=payload, headers=headers) + assert second.status_code == 409 or second.status_code == 400 + + +@pytest.mark.asyncio +async def test_booking_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + center_id, member_id = await _seed(client, headers_a) + now = datetime.now(timezone.utc) + session = await client.post( + "/api/v1/sessions", + json={ + "sports_center_id": center_id, + "code": "iso1", + "title": "Iso", + "starts_at": (now + timedelta(hours=1)).isoformat(), + "ends_at": (now + timedelta(hours=2)).isoformat(), + "capacity": 1, + }, + headers=headers_a, + ) + session_id = session.json()["id"] + booking = await client.post( + "/api/v1/bookings", + json={"sports_center_id": center_id, "session_id": session_id, "member_id": member_id}, + headers=headers_a, + ) + booking_id = booking.json()["id"] + other = await client.post(f"/api/v1/bookings/{booking_id}/confirm", headers=headers_b) + assert other.status_code in (404, 403) diff --git a/backend/services/sports_center/app/tests/test_competitions.py b/backend/services/sports_center/app/tests/test_competitions.py new file mode 100644 index 0000000..ea26d8f --- /dev/null +++ b/backend/services/sports_center/app/tests/test_competitions.py @@ -0,0 +1,181 @@ +"""Competition & Event Management tests — Phase 9.7.""" +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import SportsCenterEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _seed(client, headers): + center = await client.post( + "/api/v1/sports-centers", + json={"code": "cp97", "name": "Competition Center", "status": "active"}, + headers=headers, + ) + assert center.status_code == 201 + center_id = center.json()["id"] + member = await client.post( + "/api/v1/members", + json={"sports_center_id": center_id, "display_name": "Competitor", "status": "active"}, + headers=headers, + ) + assert member.status_code == 201 + coach = await client.post( + "/api/v1/coaches", + json={"sports_center_id": center_id, "code": "j1", "display_name": "Judge One"}, + headers=headers, + ) + assert coach.status_code == 201 + return center_id, member.json()["id"], coach.json()["id"] + + +@pytest.mark.asyncio +async def test_competition_happy_path(client): + headers = tenant_headers(TENANT_A) + center_id, member_id, coach_id = await _seed(client, headers) + + competition = await client.post( + "/api/v1/competitions", + json={ + "sports_center_id": center_id, + "code": "cup1", + "name": "Summer Cup", + "max_teams": 8, + }, + headers=headers, + ) + assert competition.status_code == 201, competition.text + competition_id = competition.json()["id"] + + team_a = await client.post( + "/api/v1/competition-teams", + json={ + "sports_center_id": center_id, + "competition_id": competition_id, + "code": "ta", + "name": "Team Alpha", + }, + headers=headers, + ) + team_b = await client.post( + "/api/v1/competition-teams", + json={ + "sports_center_id": center_id, + "competition_id": competition_id, + "code": "tb", + "name": "Team Beta", + }, + headers=headers, + ) + assert team_a.status_code == 201 and team_b.status_code == 201 + + registration = await client.post( + "/api/v1/competition-registrations", + json={ + "sports_center_id": center_id, + "competition_id": competition_id, + "member_id": member_id, + "team_id": team_a.json()["id"], + }, + headers=headers, + ) + assert registration.status_code == 201, registration.text + reg_id = registration.json()["id"] + confirm = await client.post(f"/api/v1/competition-registrations/{reg_id}/confirm", headers=headers) + assert confirm.status_code == 200 + + match = await client.post( + "/api/v1/competition-matches", + json={ + "sports_center_id": center_id, + "competition_id": competition_id, + "code": "m1", + "home_team_id": team_a.json()["id"], + "away_team_id": team_b.json()["id"], + "scheduled_at": datetime.now(timezone.utc).isoformat(), + }, + headers=headers, + ) + assert match.status_code == 201, match.text + + result = await client.post( + "/api/v1/competition-results", + json={ + "sports_center_id": center_id, + "competition_id": competition_id, + "match_id": match.json()["id"], + "home_score": 2, + "away_score": 1, + "winner_team_id": team_a.json()["id"], + "outcome": "win", + }, + headers=headers, + ) + assert result.status_code == 201, result.text + + rankings = await client.get( + f"/api/v1/competition-rankings?competition_id={competition_id}", + headers=headers, + ) + assert rankings.status_code == 200 + assert len(rankings.json()) >= 1 + + medal = await client.post( + "/api/v1/competition-medals", + json={ + "sports_center_id": center_id, + "competition_id": competition_id, + "member_id": member_id, + "medal_kind": "gold", + }, + headers=headers, + ) + assert medal.status_code == 201, medal.text + + judge = await client.post( + "/api/v1/competition-judges", + json={ + "sports_center_id": center_id, + "competition_id": competition_id, + "coach_id": coach_id, + "role": "head_judge", + }, + headers=headers, + ) + assert judge.status_code == 201, judge.text + + events = get_event_publisher().published + assert any(e.event_type == SportsCenterEventType.COMPETITION_CREATED for e in events) + assert any(e.event_type == SportsCenterEventType.COMPETITION_RESULT_RECORDED for e in events) + + +@pytest.mark.asyncio +async def test_competition_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + center_id, _, _ = await _seed(client, headers_a) + competition = await client.post( + "/api/v1/competitions", + json={"sports_center_id": center_id, "code": "iso", "name": "Iso Cup"}, + headers=headers_a, + ) + competition_id = competition.json()["id"] + other = await client.patch( + f"/api/v1/competitions/{competition_id}", + json={"name": "Hacked"}, + headers=headers_b, + ) + assert other.status_code in (404, 403) + + +@pytest.mark.asyncio +async def test_capabilities_phase_97(client): + resp = await client.get("/capabilities") + data = resp.json() + assert data["phase"] == "9.7" + assert data["version"] == "0.9.7.0" + assert data["features"]["competition_management"] is True diff --git a/backend/services/sports_center/app/tests/test_docs.py b/backend/services/sports_center/app/tests/test_docs.py index 5c8710c..bc249d4 100644 --- a/backend/services/sports_center/app/tests/test_docs.py +++ b/backend/services/sports_center/app/tests/test_docs.py @@ -41,11 +41,34 @@ def test_handover_91_doc_exists(): assert "9.2" in text -def test_handover_92_doc_exists(): - path = ROOT / "docs" / "phase-handover" / "phase-9-2.md" +def test_phase_96_doc_exists(): + path = ROOT / "docs" / "sports-center-phase-9-6.md" assert path.exists() text = path.read_text(encoding="utf-8") - assert "9.3" in text + assert "Phase 9.6" in text + assert "Training Management" in text + + +def test_handover_96_doc_exists(): + path = ROOT / "docs" / "phase-handover" / "phase-9-6.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "9.7" in text + + +def test_phase_97_doc_exists(): + path = ROOT / "docs" / "sports-center-phase-9-7.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "Phase 9.7" in text + assert "Competition" in text + + +def test_handover_97_doc_exists(): + path = ROOT / "docs" / "phase-handover" / "phase-9-7.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "9.8" in text def test_module_registry_mentions_sports_center(): diff --git a/backend/services/sports_center/app/tests/test_migration.py b/backend/services/sports_center/app/tests/test_migration.py index 18dc8d8..fdd9638 100644 --- a/backend/services/sports_center/app/tests/test_migration.py +++ b/backend/services/sports_center/app/tests/test_migration.py @@ -34,16 +34,49 @@ EXPECTED_TABLES = { "digital_memberships", "waivers", "member_documents", + "staff_certificates", + "staff_skills", + "staff_working_hours", + "staff_availability", + "staff_assignments", + "equipment", + "sports_sessions", + "bookings", + "waiting_list_entries", + "resource_reservations", + "attendance_records", + "access_logs", + "training_programs", + "exercises", + "workout_plans", + "workout_assignments", + "training_goals", + "measurements", + "progress_records", + "nutrition_plans", + "assessments", + "performance_records", + "body_compositions", + "competitions", + "competition_teams", + "competition_groups", + "competition_registrations", + "competition_matches", + "competition_results", + "competition_rankings", + "competition_medals", + "competition_judges", + "competition_certificates", } def test_alembic_revision_exists(): root = Path(__file__).resolve().parents[2] - rev = root / "alembic" / "versions" / "0003_phase_92_member_management.py" + rev = root / "alembic" / "versions" / "0008_phase_97_competitions.py" assert rev.exists() text = rev.read_text(encoding="utf-8") - assert 'revision = "0003_phase_92_member_management"' in text - assert "0002_phase_91_membership_catalog" in text + assert 'revision = "0008_phase_97_competitions"' in text + assert "0007_phase_96_training" in text def test_expected_tables_in_metadata(): diff --git a/backend/services/sports_center/app/tests/test_staff.py b/backend/services/sports_center/app/tests/test_staff.py new file mode 100644 index 0000000..2da6e48 --- /dev/null +++ b/backend/services/sports_center/app/tests/test_staff.py @@ -0,0 +1,73 @@ +"""Coach & Staff Management tests — Phase 9.3.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import SportsCenterEventType +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _seed(client, headers): + center = await client.post( + "/api/v1/sports-centers", + json={"code": "st93", "name": "Staff Center", "status": "active"}, + headers=headers, + ) + assert center.status_code == 201 + center_id = center.json()["id"] + coach = await client.post( + "/api/v1/coaches", + json={ + "sports_center_id": center_id, + "code": "c1", + "display_name": "Coach Ali", + "staff_kind": "coach", + }, + headers=headers, + ) + assert coach.status_code == 201, coach.text + return center_id, coach.json()["id"] + + +@pytest.mark.asyncio +async def test_staff_management_happy_path(client): + headers = tenant_headers(TENANT_A) + center_id, coach_id = await _seed(client, headers) + + cert = await client.post( + "/api/v1/staff-certificates", + json={ + "sports_center_id": center_id, + "coach_id": coach_id, + "name": "CPR", + "issuer": "Red Cross", + }, + headers=headers, + ) + assert cert.status_code == 201, cert.text + + skill = await client.post( + "/api/v1/staff-skills", + json={ + "sports_center_id": center_id, + "coach_id": coach_id, + "skill_code": "yoga", + "skill_name": "Yoga", + "level": 3, + }, + headers=headers, + ) + assert skill.status_code == 201, skill.text + + events = get_event_publisher().published + assert any(e.event_type == SportsCenterEventType.STAFF_CERTIFICATE_CREATED for e in events) + assert any(e.event_type == SportsCenterEventType.STAFF_SKILL_CREATED for e in events) + + +@pytest.mark.asyncio +async def test_capabilities_phase_93(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + data = resp.json() + assert data["version"] == "0.9.7.0" diff --git a/backend/services/sports_center/app/tests/test_training.py b/backend/services/sports_center/app/tests/test_training.py new file mode 100644 index 0000000..1d42a67 --- /dev/null +++ b/backend/services/sports_center/app/tests/test_training.py @@ -0,0 +1,107 @@ +"""Training Management tests — Phase 9.6.""" +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.events.types import SportsCenterEventType +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _seed(client, headers): + center = await client.post( + "/api/v1/sports-centers", + json={"code": "tr96", "name": "Training Center", "status": "active"}, + headers=headers, + ) + assert center.status_code == 201 + center_id = center.json()["id"] + member = await client.post( + "/api/v1/members", + json={"sports_center_id": center_id, "display_name": "Train Member", "status": "active"}, + headers=headers, + ) + assert member.status_code == 201 + return center_id, member.json()["id"] + + +@pytest.mark.asyncio +async def test_training_happy_path(client): + headers = tenant_headers(TENANT_A) + center_id, member_id = await _seed(client, headers) + + program = await client.post( + "/api/v1/training-programs", + json={"sports_center_id": center_id, "code": "prog1", "name": "Strength"}, + headers=headers, + ) + assert program.status_code == 201, program.text + + exercise = await client.post( + "/api/v1/exercises", + json={"sports_center_id": center_id, "code": "sq", "name": "Squat"}, + headers=headers, + ) + assert exercise.status_code == 201, exercise.text + + plan = await client.post( + "/api/v1/workout-plans", + json={ + "sports_center_id": center_id, + "code": "wp1", + "name": "Week 1", + "program_id": program.json()["id"], + }, + headers=headers, + ) + assert plan.status_code == 201, plan.text + + assignment = await client.post( + "/api/v1/workout-assignments", + json={ + "sports_center_id": center_id, + "workout_plan_id": plan.json()["id"], + "member_id": member_id, + }, + headers=headers, + ) + assert assignment.status_code == 201, assignment.text + + goal = await client.post( + "/api/v1/training-goals", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "title": "Lose 5kg", + "target_value": 5, + "unit": "kg", + }, + headers=headers, + ) + assert goal.status_code == 201, goal.text + + measurement = await client.post( + "/api/v1/measurements", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "metric_code": "weight", + "metric_name": "Weight", + "value": 80.5, + "unit": "kg", + "measured_at": datetime.now(timezone.utc).isoformat(), + }, + headers=headers, + ) + assert measurement.status_code == 201, measurement.text + + +@pytest.mark.asyncio +async def test_capabilities_phase_96(client): + resp = await client.get("/capabilities") + data = resp.json() + assert data["phase"] == "9.6" + assert data["features"]["training_management"] is True + assert data["features"]["booking_engine"] is True + assert data["features"]["attendance_engine"] is True diff --git a/backend/services/sports_center/app/validators/staff.py b/backend/services/sports_center/app/validators/staff.py new file mode 100644 index 0000000..ac0a2d9 --- /dev/null +++ b/backend/services/sports_center/app/validators/staff.py @@ -0,0 +1,41 @@ +"""Coach & Staff validators — Phase 9.3.""" +from __future__ import annotations + +from datetime import datetime, time + +from app.models.types import AssignmentKind, StaffKind +from shared.exceptions import AppError + + +def validate_staff_kind(value: str | StaffKind) -> StaffKind: + if isinstance(value, StaffKind): + return value + try: + return StaffKind(value) + except ValueError as exc: + raise AppError("نوع پرسنل نامعتبر است", error_code="invalid_staff_kind") from exc + + +def validate_assignment_kind(value: str | AssignmentKind) -> AssignmentKind: + if isinstance(value, AssignmentKind): + return value + try: + return AssignmentKind(value) + except ValueError as exc: + raise AppError("نوع تخصیص نامعتبر است", error_code="invalid_assignment_kind") from exc + + +def validate_day_of_week(value: int) -> int: + if value < 0 or value > 6: + raise AppError("روز هفته باید بین ۰ تا ۶ باشد", error_code="invalid_day_of_week") + return value + + +def validate_time_window(starts_at: time, ends_at: time) -> None: + if starts_at >= ends_at: + raise AppError("ساعت پایان باید بعد از شروع باشد", error_code="invalid_time_window") + + +def validate_datetime_window(starts_at: datetime, ends_at: datetime) -> None: + if starts_at >= ends_at: + raise AppError("زمان پایان باید بعد از شروع باشد", error_code="invalid_datetime_window") diff --git a/backend/services/sports_center/scripts/fix_indent.py b/backend/services/sports_center/scripts/fix_indent.py new file mode 100644 index 0000000..a56128b --- /dev/null +++ b/backend/services/sports_center/scripts/fix_indent.py @@ -0,0 +1,15 @@ +from pathlib import Path + +for name in ("staff.py", "booking.py", "attendance.py"): + p = Path("app/services") / name + lines = p.read_text(encoding="utf-8").splitlines() + fixed = [] + for line in lines: + if line.startswith(" await self.session.commit()"): + fixed.append(" await self.session.commit()") + elif line.startswith(" await self.session.refresh("): + fixed.append(" await self.session.refresh(entity)") + else: + fixed.append(line) + p.write_text("\n".join(fixed) + "\n", encoding="utf-8") + print("cleaned", p) diff --git a/backend/services/sports_center/scripts/fix_services.py b/backend/services/sports_center/scripts/fix_services.py new file mode 100644 index 0000000..b4fca7e --- /dev/null +++ b/backend/services/sports_center/scripts/fix_services.py @@ -0,0 +1,17 @@ +from pathlib import Path + +files = list(Path("app/services").glob("staff.py")) + list(Path("app/services").glob("booking.py")) + list(Path("app/services").glob("attendance.py")) + list(Path("app/services").glob("training.py")) + +for p in files: + text = p.read_text(encoding="utf-8") + text = text.replace("actor.subject", "actor.user_id if actor else None") + text = text.replace( + "from app.validators import validate_code, validate_file_ref, validate_non_empty", + "from app.validators import validate_code, validate_non_empty\nfrom app.validators.members import validate_file_ref", + ) + while "await self.audit.log(" in text: + start = text.index("await self.audit.log(") + end = text.index(")", start) + 1 + text = text[:start] + text[end + 1 :] + p.write_text(text, encoding="utf-8") + print("fixed", p) diff --git a/docs-runtime/ai-framework/definition-of-done.md b/docs-runtime/ai-framework/definition-of-done.md new file mode 100644 index 0000000..ea0af1f --- /dev/null +++ b/docs-runtime/ai-framework/definition-of-done.md @@ -0,0 +1,115 @@ +# Definition of Done + +Mandatory exit criteria for every implementation phase. + +A phase **cannot** be marked Complete until every item below is satisfied for **that phase’s boundary**. This document is part of the Enterprise Development Framework ([ADR-018](../architecture/adr/ADR-018.md), [ADR-019](../architecture/adr/ADR-019.md)). + +## Absolute Rules + +1. **Enterprise Phase Discovery first.** A Discovery Record must exist and all Missing→Implement gaps must be closed ([enterprise-phase-discovery.md](enterprise-phase-discovery.md)). +2. **Feature-complete for the phase boundary.** All required business capabilities of *this* phase must be implemented — not sketched, stubbed, or deferred as TODO. +3. **CRUD is never sufficient.** Listing create/read/update/delete endpoints does not equal a production-ready phase. Domain rules, validation, permissions, events, audit, tenancy, tests, and operational surfaces are mandatory. +4. **Derive missing technical components.** If the roadmap or phase brief describes a module only at a high level, the implementation **must** derive every missing technical component required for production readiness **while remaining inside the current phase** ([mandatory-phase-artifacts.md](mandatory-phase-artifacts.md), Discovery gap analysis). +5. **Never pull future-phase functionality forward.** Stay inside Scope; leave Out of Scope untouched ([boundary-rules.md](boundary-rules.md)). +6. **Never claim Complete with open gaps.** Missing artifacts must be implemented before status flips to Complete — not logged as “known limitations” unless truly out of phase boundary with written justification. + +## Phase Definition of Done Checklist + +### Discovery + +- [ ] Enterprise Phase Discovery Record published in the phase doc +- [ ] All Discovery Inputs reviewed (or N/A justified for docs-only phases) +- [ ] Gap analysis complete; Missing→Implement items closed +- [ ] Future-phase and other-service items explicitly excluded + +### Business + +- [ ] Business Analysis for this phase recorded (objectives, actors, capabilities, non-goals) +- [ ] Every in-scope / Discovery-promoted business capability is implemented end-to-end inside the phase boundary +- [ ] Domain invariants and illegal transitions are enforced (not only happy-path CRUD) +- [ ] Seed data included when the phase requires bootstrap/reference data + +### Architecture & Domain + +- [ ] Architecture Validation passed against ADRs and module boundaries +- [ ] Domain Modeling complete (bounded context, aggregates, entities, value objects as needed) +- [ ] Aggregate Design documented and reflected in models +- [ ] Entity Design per [entity-template.md](entity-template.md) +- [ ] DTO Design per [api-template.md](api-template.md) +- [ ] Provider Interfaces defined when external systems are involved + +### Application Layers + +- [ ] Repository Design per [repository-template.md](repository-template.md) +- [ ] Service Design per [service-layer-template.md](service-layer-template.md) +- [ ] Specification Pattern for reusable query/business predicates where applicable +- [ ] Policy Layer for authorization/entitlement/domain policies where applicable +- [ ] Commands for state-changing use cases (explicit command handlers or service command methods) +- [ ] Queries for read use cases (explicit query paths; no accidental write side-effects) +- [ ] Validation Layer (Pydantic + domain validators) +- [ ] Dependency Injection / explicit deps wiring consistent with the service pattern + +### APIs & Contracts + +- [ ] REST APIs for in-scope resources +- [ ] Capability APIs (`/capabilities` or equivalent) when the service exposes discoverable features +- [ ] Health APIs (`/health`) +- [ ] Metrics endpoint or documented N/A with operational alternative +- [ ] Permission APIs or documented permission tree registration for the phase +- [ ] OpenAPI documentation generated/updated and accurate for new routes +- [ ] Pagination, Filtering, Sorting, Searching on list endpoints where collections exist + +### Cross-Cutting + +- [ ] Soft Delete policy applied where master/business records require it +- [ ] Optimistic Locking where concurrent updates are a domain risk +- [ ] Audit Trail for state-changing business actions +- [ ] Outbox Events for cross-service-relevant state changes ([event-template.md](event-template.md)) +- [ ] Tenant Isolation on schema, queries, APIs, and tests +- [ ] Migration(s) in the owning service database only +- [ ] Background Jobs when the phase requires async/scheduled work +- [ ] Configuration via env/settings (no hardcoded secrets/brand) +- [ ] Feature Flags when the phase introduces gated capabilities + +### Quality + +- [ ] Unit Tests +- [ ] Integration / API Tests +- [ ] Architecture Tests (boundary/layering) per service pattern +- [ ] Security Tests +- [ ] Performance Validation (hot paths covered or justified N/A) +- [ ] Documentation complete per [documentation-template.md](documentation-template.md) +- [ ] Self Audit complete ([development-loop.md](development-loop.md)) +- [ ] Enterprise Completeness verification passed ([enterprise-completeness.md](enterprise-completeness.md)) +- [ ] All [quality-gates.md](quality-gates.md) Pass +- [ ] [phase-handover.md](phase-handover.md) filled +- [ ] No TODO/FIXME for claimed deliverables + +## What “Derive Missing Components” Means + +When a phase brief says only “implement X module,” the agent **must** still deliver the full production stack for X inside the phase: + +| If the brief mentions… | Also deliver… | +| --- | --- | +| Entities / tables | Migrations, soft-delete, tenant_id, indexes, audit fields | +| APIs | DTOs, validation, pagination/filter/sort/search, permissions, OpenAPI | +| Business rules | Services, validators, specifications, policies, commands/queries | +| Integrations | Provider interfaces, events/outbox — never foreign DB access | +| Operability | Health, metrics (or justified N/A), config, logging | + +Do **not** invent new business products outside the phase. Do **derive** engineering completeness for what the phase owns. + +## Relationship to Quality Gates + +Definition of Done is the **product completeness** bar. Quality Gates are the **verification** bar. Both must pass ([quality-gates.md](quality-gates.md), [enterprise-completeness.md](enterprise-completeness.md)). + +## Related Documents + +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) +- [Enterprise Completeness](enterprise-completeness.md) +- [Boundary Rules](boundary-rules.md) +- [Development Loop](development-loop.md) +- [Phase Handover](phase-handover.md) +- [ADR-018](../architecture/adr/ADR-018.md) +- [ADR-019](../architecture/adr/ADR-019.md) diff --git a/docs-runtime/ai-framework/development-loop.md b/docs-runtime/ai-framework/development-loop.md new file mode 100644 index 0000000..9ad7f00 --- /dev/null +++ b/docs-runtime/ai-framework/development-loop.md @@ -0,0 +1,163 @@ +# Development Loop + +Permanent implementation lifecycle for every TorbatYar phase. + +Agents and humans follow these stages in order. After Quality Gates or Enterprise Completeness fail, enter the Automatic Repair Loop until all gates pass **and** missing required artifacts are implemented, then complete. + +Canonical mandates: [enterprise-phase-discovery.md](enterprise-phase-discovery.md) · [definition-of-done.md](definition-of-done.md) · [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) · [boundary-rules.md](boundary-rules.md) · [ADR-018](../architecture/adr/ADR-018.md) · [ADR-019](../architecture/adr/ADR-019.md). + +## 1. Enterprise Phase Discovery + +**Mandatory before Implementation.** Full procedure: [enterprise-phase-discovery.md](enterprise-phase-discovery.md). + +1. Read [docs/README.md](../README.md), [AI Framework README](README.md), [master-prompt.md](master-prompt.md). +2. Inspect **all** Discovery Inputs: 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. Confirm Objective, Scope, Out of Scope, Dependencies, and Required Services from [phase-manifest.yaml](phase-manifest.yaml) and the phase brief. +4. Inventory baseline vs target responsibility for **this phase only**. +5. Run the Missing Capability Checklist; never assume CRUD is sufficient. +6. Promote every Missing→Implement gap into Scope; exclude future-phase and other-service items. +7. Publish the **Enterprise Phase Discovery** record in the phase doc. +8. Stop if Discovery reveals ADR/boundary conflicts — resolve via ADR/docs first. + +**Exit:** Complete Discovery Record with gap analysis and promoted implementation scope. + +## 2. Business Analysis + +1. Refine actors, business capabilities, success metrics, and non-goals from Discovery. +2. Align Business Analysis section with Discovery’s Target Responsibility and Promoted Scope. +3. Confirm derived [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) for this phase (do not pull future-phase features). + +**Exit:** Written Business Analysis consistent with the Discovery Record. + +## 3. Architecture Validation + +1. Validate against [module-boundaries.md](../architecture/module-boundaries.md), [boundary-rules.md](boundary-rules.md), [database-architecture.md](../architecture/database-architecture.md), [multi-tenant-architecture.md](../architecture/multi-tenant-architecture.md). +2. Confirm Database-per-Service, tenancy, eventing, and FE/BE separation. +3. Confirm service vs module decision ([service-template.md](service-template.md) vs [module-template.md](module-template.md)). +4. Identify required ADRs; create Proposed/Accepted ADR if a new decision is introduced. +5. Confirm Discovery exclusions: no ownership theft, no future-phase pull-forward, no service duplication. +6. Confirm integration approach is API / Events / Providers only. + +**Exit:** Architecture checklist green or ADR/docs updated to resolve conflicts. + +## 4. Domain Modeling + +1. Model bounded context slice for Discovery-promoted scope only. +2. Aggregate Design — consistency boundaries and invariants. +3. Entity / Value Object Design per [entity-template.md](entity-template.md) (tenant, soft delete, audit, indexes, optimistic locking where needed). +4. DTO Design per [api-template.md](api-template.md). +5. Identify Specifications, Policies, Commands, and Queries needed. +6. Identify Provider Interfaces if external systems are involved. + +**Exit:** Domain model section in the phase doc ready to implement. + +## 5. Implementation + +1. Follow [cursor-guidelines.md](cursor-guidelines.md) implementation order. +2. Implement **every** Discovery gap marked Missing→Implement. +3. Models / entities / migrations (owning DB only). +4. Repositories per [repository-template.md](repository-template.md) (pagination, filter, sort, search, soft delete, tenant filters). +5. Specifications, Policies, Validators. +6. Commands and Queries (packages or equivalent explicit methods). +7. Services per [service-layer-template.md](service-layer-template.md). +8. Events / outbox per [event-template.md](event-template.md). +9. APIs / DTOs / OpenAPI per [api-template.md](api-template.md) — including Health, Capabilities, Metrics, Permission surfaces as required. +10. Configuration, feature flags, seed data, background jobs when required. +11. Dependency injection wiring. +12. Do **not** implement Out of Scope, future-phase, or other-service responsibilities. +13. Do **not** stop at CRUD — deliver domain rules and mandatory artifacts. + +**Exit:** Code matches Discovery-promoted scope and mandatory artifacts; no unrelated service edits. + +## 6. Automated Testing + +1. Apply [testing-template.md](testing-template.md) and [testing-strategy.md](../development/testing-strategy.md). +2. Run the service’s pytest (and relevant sibling suites if contracts changed). +3. Include unit, integration, architecture, security, permission, migration, tenant isolation, and docs validation as required. +4. Include performance validation for hot paths (or justified N/A). +5. Cover Discovery-promoted capabilities — not only happy-path CRUD. + +**Exit:** All required tests pass locally (or in CI for the phase). + +## 7. Self Audit + +1. Re-read Discovery Record, Scope / Out of Scope — reject scope creep and future-phase leakage. +2. Verify every Missing→Implement gap was implemented. +3. Walk [definition-of-done.md](definition-of-done.md) and [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md). +4. Search for TODO/FIXME in touched paths related to claimed deliverables. +5. Verify layering (no business logic in routers/repos). +6. Verify no cross-DB access or foreign model imports ([boundary-rules.md](boundary-rules.md)). +7. Verify secrets/brand not hardcoded. +8. Verify audit/events for state-changing business actions. +9. Verify OpenAPI, permissions, health/capabilities/metrics as applicable. + +**Exit:** Self-audit notes with no open blockers. + +## 8. Documentation Update + +1. Follow [documentation-template.md](documentation-template.md). +2. Ensure Discovery Record remains in the phase doc. +3. Update phase doc, progress, next-steps, module registry, provider registry (if needed), reference contracts/events/APIs as applicable. +4. Update [phase-manifest.yaml](phase-manifest.yaml) / [service-manifest.yaml](service-manifest.yaml) status and versions. +5. Fill [phase-handover.md](phase-handover.md) including Discovery summary and Enterprise Completeness Sign-Off. + +**Exit:** Docs consistent with code; cross-links valid. + +## 9. Enterprise Completeness Verification + +1. Run the procedure in [enterprise-completeness.md](enterprise-completeness.md). +2. Confirm Discovery gaps are closed. +3. For every failed required dimension: treat as a defect to implement (not a waiver). +4. Re-run after fixes. + +**Exit:** All applicable completeness dimensions Pass. + +## 10. Quality Gates + +Run every gate in [quality-gates.md](quality-gates.md), including **Enterprise Phase Discovery**. + +**Exit:** All gates Pass, or Fail with a concrete defect list. + +## 11. Automatic Repair Loop + +While any gate or completeness dimension fails: + +1. Classify defect (discovery gap, business, architecture, security, test, docs, link, tenancy, migration, compatibility, missing artifact, boundary violation). +2. Fix by **implementing the missing required work** or correcting docs/ADR — smallest correct change. +3. Prefer docs/ADR first when rules conflict with code intent. +4. Re-run the failed gate and dependent gates. +5. Do not mark the phase complete while any gate fails. +6. Do not weaken gates to force completion. +7. Do not reclassify current-phase Discovery gaps as “future work” to escape DoD. + +**Exit:** All gates Pass and DoD satisfied. + +## 12. Completion Rules + +A phase may be marked complete only when: + +1. Enterprise Phase Discovery Record is complete and all Missing→Implement gaps are closed. +2. [Definition of Done](definition-of-done.md) checklist is satisfied for the phase boundary. +3. Code and tests for in-scope work are done and green. +4. Documentation and registries are updated. +5. ADR updated/created if required. +6. Enterprise Completeness signed off. +7. Quality gates all Pass. +8. Handover package is complete ([phase-handover.md](phase-handover.md)). +9. [progress.md](../progress.md) records completion; [next-steps.md](../next-steps.md) points to the next milestone. +10. No TODO remains for claimed deliverables. +11. Final verification checklist in [docs/README.md](../README.md) Phase Completion Gate is satisfied. +12. Boundary rules respected throughout. + +After completion: stop. Do not start the next phase unless explicitly requested. + +## Related Documents + +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Master Prompt](master-prompt.md) +- [Definition of Done](definition-of-done.md) +- [Enterprise Completeness](enterprise-completeness.md) +- [Quality Gates](quality-gates.md) +- [Phase Handover](phase-handover.md) +- [Cursor Guidelines](cursor-guidelines.md) +- [Project Principles](../development/project-principles.md) diff --git a/docs-runtime/ai-framework/enterprise-phase-discovery.md b/docs-runtime/ai-framework/enterprise-phase-discovery.md new file mode 100644 index 0000000..3598398 --- /dev/null +++ b/docs-runtime/ai-framework/enterprise-phase-discovery.md @@ -0,0 +1,159 @@ +# Enterprise Phase Discovery + +Mandatory **pre-implementation** process for every phase. + +Before writing production code for a phase, the Framework **MUST** automatically perform Enterprise Phase Discovery. Discovery determines the **complete responsibility** of the current phase and derives every missing production-ready capability inside that phase boundary. + +This rule extends [ADR-018](../architecture/adr/ADR-018.md) via [ADR-019](../architecture/adr/ADR-019.md). Companions: [definition-of-done.md](definition-of-done.md) · [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) · [boundary-rules.md](boundary-rules.md) · [enterprise-completeness.md](enterprise-completeness.md). + +## Absolute Rules + +1. **Discovery runs before Implementation** (and before Domain Modeling finalizes the build list). +2. **Never assume CRUD is sufficient.** +3. **Automatically derive** every missing production-ready capability required for the **current phase**. +4. If anything required for the current phase is missing, it **MUST** become part of the implementation before Complete. +5. **Never move responsibilities from future phases.** +6. **Never violate Service Boundaries** ([boundary-rules.md](boundary-rules.md)). +7. **Never duplicate another service.** + +## Discovery Inputs (mandatory sources) + +The agent must inspect and synthesize **all** of the following that exist for the phase’s service/area: + +| Input | Where to look | +| --- | --- | +| Current Roadmap | [roadmap.md](../roadmap.md), area roadmaps (`*-roadmap.md`), [phase-manifest.yaml](phase-manifest.yaml), [next-steps.md](../next-steps.md) | +| Current Architecture | `docs/architecture/*`, `docs/architecture/adr/*` | +| Service Boundaries | [module-boundaries.md](../architecture/module-boundaries.md), [module-registry.md](../module-registry.md), [service-manifest.yaml](service-manifest.yaml) | +| Previous Phase Handover | `docs/phase-handover/*`, prior phase docs | +| Existing Codebase | `backend/services//` (or owning path) — models, services, repos, APIs, tests | +| Existing APIs | Routers, OpenAPI, [api-reference.md](../reference/api-reference.md), [services-contracts.md](../reference/services-contracts.md) | +| Existing Domain Model | Models, schemas, validators, phase domain sections | +| Existing Events | `app/events/`, outbox, [event-catalog.md](../reference/event-catalog.md) | +| Existing Permissions | `app/permissions/`, registry permission trees | +| Existing Aggregates | Models + service ownership docs | +| Existing Tests | `app/tests/`, pytest suites | +| Existing Documentation | Phase docs, service README, registries, progress | + +Docs-only / registration-only phases still run Discovery against documentation and manifests (codebase may be N/A). + +## Discovery Procedure + +1. **Load context** — Read framework docs, then every Discovery Input above for the current phase. +2. **State phase boundary** — Objective, In Scope, Out of Scope, owning service(s), forbidden foreign ownership, next-phase exclusions. +3. **Inventory baseline** — Record what already exists (aggregates, APIs, events, permissions, tests, docs, ops surfaces). +4. **Derive target responsibility** — From roadmap + architecture + handover + boundaries, state the complete capability set this phase must own when Complete. +5. **Gap analysis** — Diff baseline vs target using the Missing Capability Checklist below. +6. **Promote gaps to Scope** — Every gap that belongs to **this** phase becomes mandatory implementation work (not a TODO, not a “limitation”). +7. **Reject illegal gaps** — Gaps owned by another service → integrate via API/Events/Providers only. Gaps owned by a future phase → leave Out of Scope. +8. **Publish Discovery Record** — Write the Enterprise Phase Discovery section into the phase doc (see template) before coding. +9. **Gate** — Architecture Validation and Implementation may proceed only after the Discovery Record is complete. + +## Missing Capability Checklist + +Identify missing items for the **current phase** (mark Present / Missing→Implement / N/A justified / Future-phase / Other-service): + +| Capability class | Examples | +| --- | --- | +| Business Capabilities | End-to-end use cases, not mere table CRUD | +| Entities | ORM/domain entities for in-scope data | +| Value Objects | Money, codes, periods, identifiers as needed | +| Aggregates | Consistency boundaries and invariants | +| Services | Domain orchestration | +| Policies | Authz / entitlement / domain policies | +| Specifications | Reusable predicates / query specs | +| Repositories | Tenant-scoped persistence | +| Commands | State-changing use cases | +| Queries | Read use cases without write side-effects | +| REST APIs | Resource endpoints for in-scope capabilities | +| Capability APIs | `/capabilities` or equivalent | +| Permission APIs / tree | `{service}.*` registration and checks | +| Health APIs | `/health` | +| Metrics | `/metrics` or justified ops alternative | +| Events | Outbox domain/integration events | +| Background Jobs | Async/scheduled work when required | +| Configurations | Env/settings keys | +| Feature Flags | Gated capabilities when required | +| Provider Interfaces | Protocols for external systems | +| Validation Rules | Edge + domain validators | +| Audit | Trail for state-changing actions | +| Search | Collection search | +| Filtering | Collection filters | +| Sorting | Stable sort keys | +| Pagination | Page/cursor meta | +| Soft Delete | Master/business record policy | +| Optimistic Locking | When concurrency risk exists | +| Tenant Isolation | Schema, queries, APIs, tests | +| Documentation | Phase, registries, OpenAPI, handover | +| Tests | Unit, integration, architecture, security, tenant, etc. | +| Operational Readiness | Health, metrics/logging, config, jobs | +| Developer Experience | README, clear errors, runnable tests | + +## Discovery Record (required in phase doc) + +```markdown +## Enterprise Phase Discovery + +| Field | Value | +| --- | --- | +| Phase ID | | +| Owning service(s) | | +| Discovery date | | +| Inputs reviewed | Roadmap · Architecture · Boundaries · Prior handover · Code · APIs · Domain · Events · Permissions · Aggregates · Tests · Docs | + +### Baseline Inventory (exists today) +- + +### Target Responsibility (this phase when Complete) +- + +### Gap Analysis + +| Capability | Status | Action | +| --- | --- | --- | +| ... | Present / Missing / N/A / Future / Other-service | Implement / Skip-justify / Integrate / Defer | + +### Promoted to Implementation Scope +- + +### Explicitly Excluded (future phase or other service) +- + +### Boundary Confirmations +- [ ] No future-phase pull-forward +- [ ] No service-boundary violation +- [ ] No duplication of another service’s logic +- [ ] CRUD-only delivery rejected +``` + +## Relationship to Other Framework Stages + +| Stage | Relationship | +| --- | --- | +| Business Analysis | Discovery includes and extends BA with codebase/API/test inventory | +| Architecture Validation | Uses Discovery boundary conclusions | +| Domain Modeling | Models only what Discovery promoted into scope | +| Implementation | Must cover every Discovery gap marked Missing→Implement | +| Enterprise Completeness / Quality Gates | Fail if Discovery was skipped or gaps remain unimplemented | +| Definition of Done | Incomplete without a Discovery Record for implementation phases | + +## Failure Policy + +| Situation | Action | +| --- | --- | +| Discovery skipped | Phase incomplete — run Discovery before claiming progress | +| Gap marked Missing but not implemented | Must implement before Complete | +| Agent “assumes CRUD enough” | Fail Business Completeness + Discovery gates | +| Gap belongs to future phase | Document under Excluded; do not implement | +| Gap belongs to another service | Integrate only; do not re-implement | + +## Related Documents + +- [Development Loop](development-loop.md) +- [Definition of Done](definition-of-done.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) +- [Enterprise Completeness](enterprise-completeness.md) +- [Boundary Rules](boundary-rules.md) +- [Quality Gates](quality-gates.md) +- [Phase Template](phase-template.md) +- [ADR-019](../architecture/adr/ADR-019.md) diff --git a/docs-runtime/ai-framework/phase-manifest.yaml b/docs-runtime/ai-framework/phase-manifest.yaml new file mode 100644 index 0000000..70cc959 --- /dev/null +++ b/docs-runtime/ai-framework/phase-manifest.yaml @@ -0,0 +1,1315 @@ +# Phase Manifest + +# Registry of implementation phases for TorbatYar. +# Status: planned | in_progress | complete | deferred +# Agents must keep this file aligned with docs/progress.md and docs/roadmap.md. + +schema_version: 1 +updated: "2026-07-26" +# AF-Discovery complete: Enterprise Phase Discovery mandate (ADR-019). +# AF-Enterprise complete: Enterprise Development Framework upgrade (ADR-018). +# Hospitality Phase 12.1 complete; 12.2 QR Menu & QR Ordering next for Hospitality track. +# Experience 11.2 Component Library complete; next experience-11.3 was completed — next experience-11.4 Template System. +# Experience 11.3 Theme & Layout complete; next experience-11.4 Template System. +# Delivery Platform registration complete; Phase 10.1 Driver Management complete; next delivery-10.2. +# Loyalty Phase 7.1 complete; 7.2 Point Engine next for Loyalty track. + +phases: + - id: core-1 + name: Core Platform + area: Platform + status: complete + dependencies: [] + required_documents: + - docs/architecture/architecture.md + - docs/development/project-principles.md + required_services: + - core-platform + + - id: identity-2 + name: Identity & Access + SSO + area: Platform + status: complete + dependencies: + - core-1 + required_documents: + - docs/architecture/identity-architecture.md + - docs/architecture/adr/ADR-004.md + required_services: + - identity-access + - core-platform + + - id: otp-tenant-3 + name: OTP Login + Tenant Management + area: Platform + status: complete + dependencies: + - identity-2 + required_documents: + - docs/architecture/adr/ADR-005.md + - docs/architecture/multi-tenant-architecture.md + required_services: + - core-platform + + - id: onboarding-4 + name: Tenant Onboarding & Workspace Activation + area: Platform + status: complete + dependencies: + - otp-tenant-3 + required_documents: + - docs/architecture/multi-tenant-architecture.md + - docs/architecture/adr/ADR-008.md + - docs/architecture/adr/ADR-009.md + required_services: + - core-platform + - identity-access + + - id: docs-architecture-D + name: Documentation Architecture Consolidation + area: Platform + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/README.md + - docs/architecture/adr/README.md + required_services: [] + + - id: ai-framework + name: AI Development Framework + area: Platform + status: complete + dependencies: + - docs-architecture-D + required_documents: + - docs/ai-framework/README.md + - docs/ai-framework/master-prompt.md + - docs/ai-framework/development-loop.md + - docs/ai-framework/quality-gates.md + - docs/architecture/adr/ADR-013.md + required_services: [] + + - id: ai-framework-enterprise + name: Enterprise Development Framework Upgrade + area: Platform + description: Docs-only upgrade of the AI Development Framework into an Enterprise Development Framework — Definition of Done, mandatory phase artifacts, enterprise completeness, boundary rules, expanded gates/loop/templates. No business feature code. Extends ADR-013 via ADR-018. Does not modify completed business phases or implementations. + status: complete + dependencies: + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/ai-framework/README.md + - docs/ai-framework/definition-of-done.md + - docs/ai-framework/mandatory-phase-artifacts.md + - docs/ai-framework/enterprise-completeness.md + - docs/ai-framework/boundary-rules.md + - docs/ai-framework/master-prompt.md + - docs/ai-framework/development-loop.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-template.md + - docs/ai-framework/phase-handover.md + - docs/architecture/adr/ADR-018.md + - docs/phase-handover/phase-af-enterprise.md + required_services: [] + completion_criteria: + - DoD, mandatory artifacts, enterprise completeness, and boundary rules documents published + - Master prompt, development loop, quality gates, templates, prompt/cursor rules updated + - ADR-018 accepted; ADR index and glossary updated + - Framework handover complete; no business code changes + - Backward-compatible path docs/ai-framework/ retained + - Quality gates for documentation/architecture/manifest validation passed + + - id: ai-framework-discovery + name: Enterprise Phase Discovery Mandate + area: Platform + description: Docs-only framework enhancement — mandatory Enterprise Phase Discovery before every phase implementation. Agents must inventory roadmap, architecture, boundaries, prior handover, codebase, APIs, domain, events, permissions, aggregates, tests, and docs; derive missing current-phase production capabilities; never assume CRUD; never pull future phases or violate boundaries. ADR-019 extends ADR-018. No business feature code. + status: complete + dependencies: + - ai-framework-enterprise + required_previous_phase: ai-framework-enterprise + required_documents: + - docs/ai-framework/enterprise-phase-discovery.md + - docs/ai-framework/README.md + - docs/ai-framework/master-prompt.md + - docs/ai-framework/development-loop.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-template.md + - docs/ai-framework/phase-handover.md + - docs/ai-framework/definition-of-done.md + - docs/architecture/adr/ADR-019.md + - docs/phase-handover/phase-af-discovery.md + required_services: [] + completion_criteria: + - enterprise-phase-discovery.md published with inputs, procedure, checklist, Discovery Record + - Loop stage 1 is Discovery; quality gates include Discovery gate + - Phase template/handover/prompt/cursor/DoD/completeness updated + - ADR-019 accepted; glossary/progress/manifests updated + - Framework handover complete; no business code changes + - Quality gates for documentation/architecture/manifest validation passed + + # --- Accounting --- + - id: accounting-5.1 + name: Accounting Foundation + area: Accounting + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/phases/Accounting/README.md + - docs/architecture/adr/ADR-010.md + required_services: + - accounting + + - id: accounting-5.2 + name: Double-Entry Posting Engine + area: Accounting + status: complete + dependencies: + - accounting-5.1 + required_documents: + - docs/phases/Accounting/phase-5.2-double-entry-posting-engine.md + - docs/architecture/adr/ADR-010.md + required_services: + - accounting + + - id: accounting-5.3 + name: General Ledger & Fiscal Management + area: Accounting + status: complete + dependencies: + - accounting-5.2 + required_documents: + - docs/phases/Accounting/phase-5.3-general-ledger-fiscal-management.md + required_services: + - accounting + + - id: accounting-5.4 + name: Treasury Management + area: Accounting + status: complete + dependencies: + - accounting-5.3 + required_documents: + - docs/phases/Accounting/phase-5.4-treasury-management.md + required_services: + - accounting + + - id: accounting-5.5 + name: Accounts Receivable & Payable + area: Accounting + status: complete + dependencies: + - accounting-5.4 + required_documents: + - docs/phases/Accounting/phase-5.5-accounts-receivable-payable.md + required_services: + - accounting + + - id: accounting-5.6 + name: Sales Accounting Integration + area: Accounting + status: complete + dependencies: + - accounting-5.5 + required_documents: + - docs/phases/Accounting/phase-5.6-sales-accounting-integration.md + required_services: + - accounting + + - id: accounting-5.7 + name: Purchase & Inventory Accounting + area: Accounting + status: complete + dependencies: + - accounting-5.6 + required_documents: + - docs/phases/Accounting/phase-5.7-purchase-inventory-accounting-integration.md + required_services: + - accounting + + - id: accounting-5.8 + name: Fixed Assets + area: Accounting + status: complete + dependencies: + - accounting-5.7 + required_documents: + - docs/phases/Accounting/phase-5.8-fixed-assets-management.md + required_services: + - accounting + + - id: accounting-5.9 + name: HCM / Payroll Accounting + area: Accounting + status: complete + dependencies: + - accounting-5.8 + required_documents: + - docs/phases/Accounting/phase-5.9-hcm-payroll-accounting.md + required_services: + - accounting + + - id: accounting-5.10 + name: Financial Reporting & BI + area: Accounting + status: complete + dependencies: + - accounting-5.9 + required_documents: + - docs/phases/Accounting/phase-5.10-financial-reporting-business-intelligence.md + required_services: + - accounting + + - id: accounting-5.11 + name: Compliance, Audit & Governance + area: Accounting + status: complete + dependencies: + - accounting-5.10 + required_documents: + - docs/phases/Accounting/phase-5.11-enterprise-compliance-audit-governance.md + required_services: + - accounting + + - id: accounting-5.12 + name: Accounting AI Assistants + area: Accounting + status: planned + dependencies: + - accounting-5.11 + - ai-framework + required_documents: + - docs/architecture/ai-architecture.md + - docs/phases/Accounting/README.md + required_services: + - accounting + + # --- CRM --- + - id: crm-6.0 + name: CRM Service Foundation + area: CRM + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/crm-phase-6-0.md + - docs/phases/CRM/README.md + required_services: + - crm + + - id: crm-6.1 + name: CRM Core Business Entities + area: CRM + status: complete + dependencies: + - crm-6.0 + required_documents: + - docs/crm-phase-6-1.md + required_services: + - crm + + - id: crm-6.2 + name: CRM Sales Process Engine + area: CRM + status: complete + dependencies: + - crm-6.1 + required_documents: + - docs/crm-phase-6-2.md + required_services: + - crm + + - id: crm-6.3 + name: CRM Sales Collaboration + area: CRM + status: complete + dependencies: + - crm-6.2 + required_documents: + - docs/crm-phase-6-3.md + required_services: + - crm + + # --- Loyalty --- + - id: loyalty-7.0 + name: Loyalty Service Foundation + area: Loyalty + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/loyalty-phase-7-0.md + - docs/architecture/adr/ADR-011.md + - docs/phases/Loyalty/README.md + required_services: + - loyalty + + - id: loyalty-7.1 + name: Membership Engine + area: Loyalty + status: complete + dependencies: + - loyalty-7.0 + - ai-framework + required_documents: + - docs/loyalty-phase-7-1.md + - docs/phase-handover/phase-7-1.md + - docs/phases/Loyalty/README.md + - docs/architecture/adr/ADR-011.md + required_services: + - loyalty + + - id: loyalty-7.2 + name: Point Engine + area: Loyalty + status: planned + dependencies: + - loyalty-7.1 + required_documents: + - docs/phases/Loyalty/README.md + - docs/ai-framework/phase-template.md + required_services: + - loyalty + + # --- SMS / Communication --- + - id: communication-8 + name: Enterprise Communication Platform (SMS-first) + area: SMS + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/communication-phase-8.md + - docs/architecture/adr/ADR-012.md + required_services: + - communication + + - id: sms-panel-future + name: Legacy SMS Panel Scaffold Alignment + area: SMS + status: deferred + dependencies: + - communication-8 + required_documents: + - docs/module-registry.md + required_services: + - sms_panel + notes: Prefer Communication service (ADR-012); sms_panel scaffold remains historical/planned alignment only. + + # --- Sports Center (Phase 9.0–9.10) --- + - id: sports-center-9.0 + name: Sports Center Foundation + area: Sports Center + description: Independent sports_center service scaffold, sports_center_db, health/capabilities, permissions sports_center.*, publish-only events, tenant isolation, audit shell. + status: complete + dependencies: + - onboarding-4 + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/architecture/adr/ADR-014.md + - docs/sports-center-phase-9-0.md + - docs/phase-handover/phase-9-0.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - sports_center + completion_criteria: + - Service scaffold under backend/services/sports_center with API → Service → Repository → Model layering + - Alembic initial migration for foundation tables only as scoped + - Health endpoint green; permission tree sports_center.* documented + - Tenant isolation + architecture + docs tests green + - Module registry, manifests, progress, handover updated + - Quality gates passed; no business engines beyond foundation + + - id: sports-center-9.1 + name: Membership Catalog + area: Sports Center + description: Membership types, packages, plans, pricing models, age groups, sport categories, membership rules, renewal/freezing/expiration policies, validation, events, APIs. + status: complete + dependencies: + - sports-center-9.0 + required_previous_phase: sports-center-9.0 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/architecture/adr/ADR-014.md + - docs/sports-center-phase-9-1.md + - docs/phase-handover/phase-9-1.md + - docs/ai-framework/module-template.md + required_services: + - sports_center + completion_criteria: + - Membership catalog aggregates implemented with tenant isolation + - APIs, validators, permissions, events, tests, docs, handover complete + - No member enrollment / coach / attendance / booking engines + + - id: sports-center-9.2 + name: Member Management + area: Sports Center + description: Members, family members, medical information, emergency contacts, membership assignment, cards/QR/digital membership, waivers, documents, attachments, status management. + status: complete + dependencies: + - sports-center-9.1 + required_previous_phase: sports-center-9.1 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/phase-handover/phase-9-1.md + - docs/sports-center-phase-9-2.md + - docs/phase-handover/phase-9-2.md + - docs/ai-framework/module-template.md + required_services: + - sports_center + completion_criteria: + - Member management APIs + validators + tests green + - Consumes Membership Catalog from 9.1; does not recreate catalog tables + - No coach/attendance/booking engines yet + + - id: sports-center-9.3 + name: Coach & Staff Management + area: Sports Center + description: Coaches, trainers, nutritionists, medical staff, reception, managers, working hours, certificates, skills, payroll refs, availability, permissions. + status: planned + dependencies: + - sports-center-9.2 + required_previous_phase: sports-center-9.2 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/ai-framework/module-template.md + required_services: + - sports_center + completion_criteria: + - Coach and staff modules complete with APIs, permissions, events, tests, docs + - No attendance devices or booking engine yet + + - id: sports-center-9.4 + name: Scheduling & Booking + area: Sports Center + description: Sports classes, sessions, timetables, reservations, capacity, waiting lists, recurring sessions, resource allocation, facilities, equipment reservation, calendar. + status: planned + dependencies: + - sports-center-9.3 + required_previous_phase: sports-center-9.3 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + required_services: + - sports_center + completion_criteria: + - Booking and scheduling APIs and rules green + - Tests include conflict/tenant isolation; docs/handover updated + + - id: sports-center-9.5 + name: Attendance & Access Control + area: Sports Center + description: Attendance, QR/RFID/barcode/face-ready check-in, manual attendance, entry/exit gates, late arrival, absence, attendance history. + status: planned + dependencies: + - sports-center-9.4 + required_previous_phase: sports-center-9.4 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + required_services: + - sports_center + completion_criteria: + - Attendance and access control complete with isolation/security tests + - Vendor adapters remain outside business services + + - id: sports-center-9.6 + name: Training Management + area: Sports Center + description: Training programs, exercises, workout plans, goals, measurements, progress tracking, nutrition plans, body composition, assessments, performance records. + status: planned + dependencies: + - sports-center-9.5 + required_previous_phase: sports-center-9.5 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + required_services: + - sports_center + completion_criteria: + - Training/workout modules complete with APIs, events, tests, docs + - No competitions module yet + + - id: sports-center-9.7 + name: Competition & Event Management + area: Sports Center + description: Competitions, tournaments, matches, teams, groups, registrations, rankings, results, medals, certificates, judges, schedules. + status: planned + dependencies: + - sports-center-9.6 + required_previous_phase: sports-center-9.6 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + required_services: + - sports_center + completion_criteria: + - Competitions and events modules complete; no payment posting ownership + - Tests, docs, handover complete + + - id: sports-center-9.8 + name: Financial Integration + area: Sports Center + description: Integrate Accounting, CRM, Loyalty, Communication, payments, invoices, subscriptions, discounts, refunds, installments via API/Events only — no business logic duplication. + status: planned + dependencies: + - sports-center-9.7 + - accounting-5.11 + - crm-6.3 + - loyalty-7.0 + - communication-8 + required_previous_phase: sports-center-9.7 + required_documents: + - docs/sports-center-roadmap.md + - docs/architecture/adr/ADR-010.md + - docs/architecture/adr/ADR-011.md + - docs/architecture/adr/ADR-012.md + - docs/architecture/adr/ADR-014.md + required_services: + - sports_center + - accounting + - crm + - loyalty + - communication + completion_criteria: + - Integrations via API/events only; no journal creation inside Sports Center + - Tests prove no cross-DB access; docs/contracts updated + + - id: sports-center-9.9 + name: AI & Analytics + area: Sports Center + description: Integrate Enterprise AI Platform for recommendations, attendance/retention/churn prediction, workout suggestions, capacity forecasting, dashboards and KPIs — AI optional. + status: planned + dependencies: + - sports-center-9.8 + - ai-framework + required_previous_phase: sports-center-9.8 + required_documents: + - docs/sports-center-roadmap.md + - docs/architecture/ai-architecture.md + - docs/phases/AI/README.md + required_services: + - sports_center + completion_criteria: + - Analytics/AI surfaces documented/implemented per scope + - Core flows work when AI is off + - Tests and docs complete + + - id: sports-center-9.10 + name: Enterprise Validation + area: Sports Center + description: Full AI Framework validation — architecture, security, performance, docs, integration, tenant isolation, migration, API compatibility, dependency checks, self-heal until green. + status: planned + dependencies: + - sports-center-9.9 + required_previous_phase: sports-center-9.9 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-handover.md + - docs/deployment/production.md + required_services: + - sports_center + completion_criteria: + - All Sports Center quality gates passed + - Production readiness notes complete + - Module registry version/phase updated; progress marks 9.10 complete + - No undocumented public API/event/permission + + + # --- Delivery & Fleet Platform (Phase 10.0–10.10) --- + - id: delivery-reg + name: Delivery & Fleet Platform Registration + area: Delivery + description: Documentation-only registration of independent delivery service, delivery_db, phases 10.0–10.10, ADR-015, module/glossary/boundary updates. No business code. + status: complete + dependencies: + - onboarding-4 + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + - docs/architecture/adr/ADR-015.md + - docs/phase-handover/phase-dp-reg.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - delivery + completion_criteria: + - Service registered in service-manifest with delivery_db and delivery.* permissions + - Phases 10.0–10.10 registered in phase-manifest + - Module registry, glossary, module-boundaries, ADR-015 updated + - Progress and next-steps updated; handover complete + - No business code, models, APIs, or migrations in this phase + - Quality gates for documentation/architecture/manifest validation passed + + - id: delivery-10.0 + name: Delivery Platform Foundation + area: Delivery + description: Independent delivery service scaffold, delivery_db, health/capabilities/metrics, permissions delivery.*, publish-only event shells, tenant isolation, audit/config shells, provider/routing-engine contracts only. + status: complete + dependencies: + - delivery-reg + required_previous_phase: delivery-reg + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + - docs/architecture/adr/ADR-015.md + - docs/phase-handover/phase-dp-reg.md + - docs/delivery-phase-10-0.md + - docs/phase-handover/phase-10-0.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - delivery + completion_criteria: + - Service scaffold under backend/services/delivery with API → Service → Repository → Model layering + - Alembic initial migration for foundation tables only as scoped + - Health, capabilities, metrics endpoints; permission tree delivery.* documented + - Tenant isolation + architecture + docs tests green + - No dispatch/routing/tracking engines beyond foundation shells + - Quality gates passed; handover complete + + - id: delivery-10.1 + name: Driver Management + area: Delivery + description: Drivers, profiles, credential refs, status lifecycle, permissions, events, APIs. + status: complete + dependencies: + - delivery-10.0 + required_previous_phase: delivery-10.0 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + - docs/architecture/adr/ADR-015.md + - docs/ai-framework/module-template.md + - docs/delivery-phase-10-1.md + - docs/phase-handover/phase-10-1.md + required_services: + - delivery + completion_criteria: + - Driver management APIs + validators + tests green + - Lifecycle, credentials/documents, outbox, list filter/sort/search + - No fleet/dispatch/routing engines yet + - Quality gates passed; handover complete + + - id: delivery-10.2 + name: Fleet & Vehicle Types + area: Delivery + description: Fleets, vehicle types catalog, vehicles, assignments shells, permissions, events, APIs. + status: planned + dependencies: + - delivery-10.1 + required_previous_phase: delivery-10.1 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Fleet and vehicle modules complete with tenant isolation tests + - No dispatch engine yet + + - id: delivery-10.3 + name: Availability, Shifts & Working Zones + area: Delivery + description: Driver availability, shift management, working zones, readiness rules. + status: planned + dependencies: + - delivery-10.2 + required_previous_phase: delivery-10.2 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Availability/shift/zone APIs + tests green + - No dispatch/routing engines yet + + - id: delivery-10.4 + name: Pricing, Capabilities & Bundles + area: Delivery + description: Delivery pricing shells, capability flags, capability bundles. + status: planned + dependencies: + - delivery-10.3 + required_previous_phase: delivery-10.3 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Pricing/capabilities/bundles APIs + validators + tests green + - No settlement posting ownership + + - id: delivery-10.5 + name: Dispatch Engine + area: Delivery + description: Dispatch engine, job assignment, dispatcher workflows (API), multi-vertical job refs. + status: planned + dependencies: + - delivery-10.4 + required_previous_phase: delivery-10.4 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Dispatch APIs + isolation/security tests green + - Verticals interact via API/events only + - No deep routing optimization yet (10.6) + + - id: delivery-10.6 + name: Routing & Optimization + area: Delivery + description: Routing plans, multi pickup, multi drop, optimization strategies, future routing engine adapters. + status: planned + dependencies: + - delivery-10.5 + required_previous_phase: delivery-10.5 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Routing/optimization modules complete; external engines via adapters + - Tests and docs complete + + - id: delivery-10.7 + name: Tracking & Proof of Delivery + area: Delivery + description: Live tracking, journey timeline, proof of delivery, customer tracking APIs. + status: planned + dependencies: + - delivery-10.6 + required_previous_phase: delivery-10.6 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Tracking and POD APIs + tenant isolation tests green + - Distinct from Communication message delivery tracking + + - id: delivery-10.8 + name: Settlement + area: Delivery + description: Driver/merchant settlement intents; post to Accounting via API/Events only — no journal ownership. + status: planned + dependencies: + - delivery-10.7 + - accounting-5.11 + required_previous_phase: delivery-10.7 + required_documents: + - docs/delivery-roadmap.md + - docs/architecture/adr/ADR-010.md + - docs/architecture/adr/ADR-015.md + required_services: + - delivery + - accounting + completion_criteria: + - Settlement via Accounting APIs/events only; no cross-DB access + - Tests prove no journal creation inside Delivery + + - id: delivery-10.9 + name: Merchant Connector & App Surfaces + area: Delivery + description: Merchant connector for verticals, notifications via Communication, Driver App and Dispatcher Panel API contracts, customer tracking polish. + status: planned + dependencies: + - delivery-10.8 + - communication-8 + required_previous_phase: delivery-10.8 + required_documents: + - docs/delivery-roadmap.md + - docs/architecture/adr/ADR-012.md + - docs/architecture/adr/ADR-015.md + required_services: + - delivery + - communication + completion_criteria: + - Connector + notification client + app API contracts documented/tested + - No SMS provider ownership; UI remains in frontend + + - id: delivery-10.10 + name: Analytics, AI Ready & Enterprise Validation + area: Delivery + description: Fleet analytics shells, AI-ready optional hooks, full AI Framework validation — architecture, security, performance, docs, integration, tenant isolation, migration, API compatibility, self-heal until green. + status: planned + dependencies: + - delivery-10.9 + - ai-framework + required_previous_phase: delivery-10.9 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-handover.md + - docs/architecture/ai-architecture.md + required_services: + - delivery + completion_criteria: + - All Delivery quality gates passed + - Core flows work when AI is off + - Module registry version/phase updated; progress marks 10.10 complete + - No undocumented public API/event/permission + + # --- Experience Platform / Torbat Pages (Phase 11.0–11.10) --- + - id: experience-reg + name: Experience Platform Registration + area: Experience + description: Documentation-only registration of independent experience service, experience_db, phases 11.0–11.10, ADR-016, module/glossary/boundary updates. No business code. Prefer experience over historical website_builder scaffold. + status: complete + dependencies: + - onboarding-4 + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-016.md + - docs/experience-phase-xp-reg.md + - docs/phase-handover/phase-xp-reg.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - experience + completion_criteria: + - Service registered in service-manifest with experience_db and experience.* permissions + - Phases 11.0–11.10 registered in phase-manifest + - Module registry, glossary, module-boundaries, ADR-016 updated + - Progress and next-steps updated; handover complete + - No business code, models, APIs, or migrations in this phase + - Quality gates for documentation/architecture/manifest validation passed + + - id: experience-11.0 + name: Experience Platform Foundation + area: Experience + description: Independent experience service scaffold, experience_db, health/capabilities/metrics, permissions experience.*, publish-only event shells, tenant isolation, audit/config shells, provider contracts only. + status: complete + dependencies: + - experience-reg + required_previous_phase: experience-reg + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-016.md + - docs/phase-handover/phase-xp-reg.md + - docs/experience-phase-11-0.md + - docs/phase-handover/phase-11-0.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - experience + completion_criteria: + - Service scaffold under backend/services/experience with API → Service → Repository → Model layering + - Alembic initial migration for foundation tables only as scoped + - Health, capabilities, metrics endpoints; permission tree experience.* documented + - Tenant isolation + architecture + docs tests green + - No page/theme/template engines beyond foundation shells + - Quality gates passed; handover complete + + - id: experience-11.1 + name: Sites & Page Resources + area: Experience + description: Sites, pages as resources, page types (landing, mini site, menu, bio, profile, event, campaign, portfolio, blog, QR, SEO, media, knowledge, …), lifecycle, permissions, events, APIs. + status: complete + dependencies: + - experience-11.0 + required_previous_phase: experience-11.0 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-016.md + - docs/experience-phase-11-1.md + - docs/phase-handover/phase-11-1.md + - docs/ai-framework/module-template.md + required_services: + - experience + completion_criteria: + - Site and page resource APIs + validators + tests green + - No component versioning / theme / template engines yet + + - id: experience-11.2 + name: Component Library & Versioning + area: Experience + description: Versioned components, component versions, composition shells, permissions, events, APIs. + status: complete + dependencies: + - experience-11.1 + required_previous_phase: experience-11.1 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/experience-phase-11-2.md + - docs/phase-handover/phase-11-2.md + required_services: + - experience + completion_criteria: + - Component library with immutable versions; tenant isolation tests green + - No theme/layout engine yet + + - id: experience-11.3 + name: Theme & Layout Engine + area: Experience + description: Replaceable themes, dynamic layouts, directionality-ready shells. + status: complete + dependencies: + - experience-11.2 + required_previous_phase: experience-11.2 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-008.md + - docs/experience-phase-11-3.md + - docs/phase-handover/phase-11-3.md + required_services: + - experience + completion_criteria: + - Theme and layout APIs + tests green + - No template catalog engine yet + + - id: experience-11.4 + name: Template System + area: Experience + description: Template catalog, instantiation from templates, page-type starter packs. + status: planned + dependencies: + - experience-11.3 + required_previous_phase: experience-11.3 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + required_services: + - experience + completion_criteria: + - Template APIs + validators + tests green + - No forms/surveys engine yet + + - id: experience-11.5 + name: Localization, RTL/LTR & Media + area: Experience + description: Locales, RTL/LTR, media references via Storage only (no binaries in experience_db). + status: planned + dependencies: + - experience-11.4 + required_previous_phase: experience-11.4 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + required_services: + - experience + completion_criteria: + - Localization and media-ref APIs + isolation tests green + - No form/survey/appointment engines yet + + - id: experience-11.6 + name: Forms, Surveys & Appointments + area: Experience + description: Forms, surveys, appointment page shells; submission/booking refs via APIs/events only. + status: planned + dependencies: + - experience-11.5 + required_previous_phase: experience-11.5 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + required_services: + - experience + completion_criteria: + - Forms/surveys/appointment APIs + tests green + - No deep CRM/scheduling ownership; refs only + + - id: experience-11.7 + name: Publishing, Domains, SEO & PWA + area: Experience + description: Publish workflows, custom domain binding refs, SEO metadata/sitemap/robots shells, PWA readiness. + status: planned + dependencies: + - experience-11.6 + required_previous_phase: experience-11.6 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-009.md + required_services: + - experience + completion_criteria: + - Publishing/SEO/PWA APIs + tests green + - DNS/SSL edge remains Core/Nginx patterns; Experience stores binding refs + + - id: experience-11.8 + name: Bundles, Licensing & Feature Toggles + area: Experience + description: Capability bundles (Mini Site, Menu, Bio Link, …), licensing shells, feature toggles coordinated with Core entitlement. + status: planned + dependencies: + - experience-11.7 + required_previous_phase: experience-11.7 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + required_services: + - experience + completion_criteria: + - Bundles/toggles APIs + validators + tests green + - No Core plan-engine ownership + + - id: experience-11.9 + name: Consumer Integrations & Widgets + area: Experience + description: Consumer connector contracts for Hospitality/Marketplace/Sports/CRM/etc., embeddable widgets, Communication notify client. + status: planned + dependencies: + - experience-11.8 + - communication-8 + required_previous_phase: experience-11.8 + required_documents: + - docs/experience-roadmap.md + - docs/architecture/adr/ADR-012.md + - docs/architecture/adr/ADR-016.md + required_services: + - experience + - communication + completion_criteria: + - Connector + widget contracts documented/tested + - No SMS provider ownership; UI remains in frontend + + - id: experience-11.10 + name: Analytics, AI Content & Enterprise Validation + area: Experience + description: Analytics shells, optional AI content hooks, full AI Framework validation — architecture, security, performance, docs, integration, tenant isolation, migration, API compatibility, self-heal until green. + status: planned + dependencies: + - experience-11.9 + - ai-framework + required_previous_phase: experience-11.9 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-handover.md + - docs/architecture/ai-architecture.md + required_services: + - experience + completion_criteria: + - All Experience quality gates passed + - Core flows work when AI is off + - Module registry version/phase updated; progress marks 11.10 complete + - No undocumented public API/event/permission + + # --- Hospitality (Phase 12.0+) — Torbat Food --- + - id: hospitality-12.0 + name: Hospitality Platform Foundation + area: Hospitality + description: Independent hospitality service scaffold, hospitality_db, health/capabilities, bundle licensing + feature toggles, venue/menu/table shells, permissions hospitality.*, publish-only events, tenant isolation, audit shell. Commercial product Torbat Food. + status: complete + dependencies: + - onboarding-4 + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/architecture/adr/ADR-017.md + - docs/hospitality-phase-12-0.md + - docs/phase-handover/phase-12-0.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - hospitality + completion_criteria: + - Service scaffold under backend/services/hospitality with API → Service → Repository → Model layering + - Alembic initial migration for foundation tables only as scoped + - Health + capabilities endpoints; permission tree hospitality.* documented + - Bundle definitions + tenant bundle activation + feature toggles + - Tenant isolation + architecture + docs tests green + - Module registry, manifests, progress, handover updated + - Quality gates passed; no POS/kitchen/ordering engines + + - id: hospitality-12.1 + name: Digital Menu Catalog + area: Hospitality + description: Deep digital menu catalog — modifiers, allergens, availability windows, media refs, multi-language shells. + status: complete + dependencies: + - hospitality-12.0 + required_previous_phase: hospitality-12.0 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/hospitality-phase-12-1.md + - docs/phase-handover/phase-12-1.md + - docs/ai-framework/module-template.md + required_services: + - hospitality + completion_criteria: + - Digital menu catalog APIs + validators + tests green + - No POS / kitchen / ordering engines yet + - Module registry, manifests, progress, handover updated + - Quality gates passed + + - id: hospitality-12.2 + name: QR Menu & QR Ordering + area: Hospitality + description: QR Menu public surfaces and QR Ordering shells consuming the digital menu catalog. + status: planned + dependencies: + - hospitality-12.1 + required_previous_phase: hospitality-12.1 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/ai-framework/module-template.md + required_services: + - hospitality + completion_criteria: + - QR Menu / Ordering APIs + validators + tests green + - No POS / kitchen engines yet + + # Historical alias — superseded by hospitality-12.0 + - id: restaurant-foundation + name: Restaurant / Cafe Foundation (superseded) + area: Restaurant + status: complete + notes: Superseded by hospitality-12.0 (Hospitality Platform / Torbat Food). Kept for manifest continuity. + dependencies: + - onboarding-4 + - ai-framework + required_documents: + - docs/hospitality-phase-12-0.md + - docs/phases/Restaurant/README.md + required_services: + - hospitality + + # --- Marketplace / Ecommerce --- + - id: ecommerce-foundation + name: Ecommerce Foundation + area: Marketplace + status: planned + dependencies: + - onboarding-4 + - ai-framework + required_documents: + - docs/phases/Marketplace/README.md + required_services: + - ecommerce + + - id: marketplace-foundation + name: Marketplace Foundation + area: Marketplace + status: planned + dependencies: + - ecommerce-foundation + - ai-framework + required_documents: + - docs/phases/Marketplace/README.md + required_services: + - marketplace + + # --- Automation --- + - id: automation-foundation + name: Automation Engine Foundation + area: Automation + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Automation/README.md + - docs/architecture/event-driven-architecture.md + required_services: + - automation + + # --- Academy --- + - id: academy-foundation + name: Academy Foundation + area: Academy + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/ai-framework/service-template.md + required_services: + - academy + + # --- Clinic --- + - id: clinic-foundation + name: Clinic / Healthcare Foundation + area: Clinic + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/ai-framework/service-template.md + required_services: + - clinic + + # --- Hotel --- + - id: hotel-foundation + name: Hotel Foundation + area: Hotel + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/ai-framework/service-template.md + required_services: + - hotel + + # --- Tourism --- + - id: tourism-foundation + name: Tourism Foundation + area: Tourism + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/ai-framework/service-template.md + required_services: + - tourism + + # --- Product AI --- + - id: ai-assistant-foundation + name: AI Assistant Foundation + area: AI + status: planned + dependencies: + - ai-framework + required_documents: + - docs/architecture/ai-architecture.md + - docs/phases/AI/README.md + - docs/ai-framework/service-template.md + required_services: + - ai_assistant + + # --- Future Services --- + - id: future-services + name: Future Services Portfolio + area: Future Services + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/roadmap.md + required_services: [] + notes: Catch-all for live_chat, smart_messenger, notification, file_storage, link_shortener, historical website_builder scaffold, and unlisted future modules. Prefer experience (ADR-016) for page/site work. diff --git a/docs-runtime/ai-framework/quality-gates.md b/docs-runtime/ai-framework/quality-gates.md new file mode 100644 index 0000000..5d85cc7 --- /dev/null +++ b/docs-runtime/ai-framework/quality-gates.md @@ -0,0 +1,238 @@ +# Quality Gates + +Mandatory gates before any implementation phase may be marked complete. + +All gates must **Pass**. On failure, enter the Automatic Repair Loop ([development-loop.md](development-loop.md)) and **implement missing required artifacts** — do not waive enterprise requirements. + +Companions: [enterprise-phase-discovery.md](enterprise-phase-discovery.md) · [definition-of-done.md](definition-of-done.md) · [enterprise-completeness.md](enterprise-completeness.md) · [boundary-rules.md](boundary-rules.md) · [ADR-018](../architecture/adr/ADR-018.md) · [ADR-019](../architecture/adr/ADR-019.md). + +## Enterprise Phase Discovery + +| Check | Pass criteria | +| --- | --- | +| Record | Discovery Record present in phase doc before Implementation claimed | +| Inputs | Roadmap, Architecture, Boundaries, Prior handover, Codebase, APIs, Domain, Events, Permissions, Aggregates, Tests, Docs reviewed (or N/A justified for docs-only) | +| Gap analysis | Missing Capability Checklist completed; CRUD-only rejected | +| Promotion | Every Missing→Implement gap implemented before Complete | +| Boundaries | No future-phase pull-forward; no foreign ownership; no service duplication | +| Align | [enterprise-phase-discovery.md](enterprise-phase-discovery.md) | + +## Business Completeness + +| Check | Pass criteria | +| --- | --- | +| Capabilities | Every in-scope / Discovery-promoted business capability delivered end-to-end | +| Not CRUD-only | Domain rules, validators, policies/specs as needed beyond CRUD | +| DoD | [definition-of-done.md](definition-of-done.md) checklist complete for phase boundary | +| No future pull-forward | Out of Scope / future phases untouched | + +## Architecture + +| Check | Pass criteria | +| --- | --- | +| Boundaries | No ownership theft per [module-boundaries.md](../architecture/module-boundaries.md) and [boundary-rules.md](boundary-rules.md) | +| DB isolation | No cross-DB queries/FKs ([ADR-001](../architecture/adr/ADR-001.md)) | +| Layering | Business logic in services/commands; thin API; persistence-only repos | +| FE/BE | No backend imports in frontend; no UI rules in backend ([ADR-002](../architecture/adr/ADR-002.md)) | +| ADR | New architectural decisions recorded; conflicts resolved | +| Events | Outbox-ready; naming per [event-template.md](event-template.md) | +| Domain model | Aggregates/entities/DTOs designed for in-scope work | + +## API Completeness + +| Check | Pass criteria | +| --- | --- | +| REST | In-scope resources exposed with correct methods and DTOs | +| Health | `/health` present for runnable services | +| Capabilities | `/capabilities` (or equivalent) when discoverable features exist | +| Metrics | `/metrics` or justified N/A with operational alternative | +| OpenAPI | Generated/updated and accurate for new/changed routes | +| List UX | Pagination, filtering, sorting, searching on collection endpoints | +| Permission APIs | Permission tree registered; permission listing routes when service pattern requires | + +## Permission Completeness + +| Check | Pass criteria | +| --- | --- | +| Mapping | Every privileged route has a permission check | +| Naming | `{service}.{resource}.{action}` / `{service}.*` trees documented | +| Tests | Denial cases covered | + +## Event Completeness + +| Check | Pass criteria | +| --- | --- | +| Emission | Cross-service-relevant state changes write outbox events | +| Catalog | [event-catalog.md](../reference/event-catalog.md) / registry updated | +| Envelope | Stable envelope fields; additive payloads | + +## Repository Completeness + +| Check | Pass criteria | +| --- | --- | +| Tenant filter | All tenant-owned queries scoped | +| Soft delete | Defaults exclude soft-deleted rows where policy requires | +| List support | Pagination/filter/sort/search supported as API requires | +| No domain logic | Business branching stays in services | + +## Validation Completeness + +| Check | Pass criteria | +| --- | --- | +| Edge | Pydantic request/query validation | +| Domain | Validators / specifications / policies enforce invariants | +| Codes | Stable error codes for illegal transitions | + +## Security + +| Check | Pass criteria | +| --- | --- | +| Auth | Protected routes deny anonymous access | +| Secrets | No hardcoded credentials/brand tokens | +| Errors | No stack/secret leakage | +| Providers | Credentials only in owning service config | +| Align | [security-architecture.md](../architecture/security-architecture.md) | + +## Performance + +| Check | Pass criteria | +| --- | --- | +| Queries | Tenant-scoped lists use sensible indexes; no obvious N+1 in new hot paths | +| Hot paths | Covered or explicitly N/A in phase doc | +| Async | Long-running provider I/O not blocking business transactions when architecture requires async | + +## Testing + +| Check | Pass criteria | +| --- | --- | +| Suite | Required tests from [testing-template.md](testing-template.md) exist and pass | +| Strategy | Aligns with [testing-strategy.md](../development/testing-strategy.md) | +| Claims | Every claimed deliverable has coverage | +| Categories | Unit · Integration · Architecture · Security · Tenant · Permission · Migration · Docs as applicable | + +## Documentation + +| Check | Pass criteria | +| --- | --- | +| Updates | [documentation-template.md](documentation-template.md) checklist done | +| Links | No broken relative links in touched docs | +| Registries | Module/provider/manifests consistent with code | +| Glossary | New terms defined when introduced | +| No TODO | No TODO for claimed deliverables | +| OpenAPI | Documented as part of API surface | + +## Migration + +| Check | Pass criteria | +| --- | --- | +| Alembic | Revision exists for schema changes in owning service | +| Apply | Upgrade smoke succeeds | +| Ownership | Only owning DB migrated | +| Seed | Seed data present when phase requires it | +| Notes | Handover includes migration notes | + +## Backward Compatibility + +| Check | Pass criteria | +| --- | --- | +| APIs | Additive within version unless phase documents breaking change + consumer plan | +| Events | No silent meaning change of existing `event_type` | +| Data | Existing rows remain valid or backfill documented | + +## Tenant Isolation + +| Check | Pass criteria | +| --- | --- | +| Schema | Business tables have `tenant_id` ([ADR-003](../architecture/adr/ADR-003.md)) | +| Queries | Filters applied | +| Tests | Cross-tenant denial covered | +| Admin exceptions | Explicit and audited | + +## Provider Abstraction + +| Check | Pass criteria | +| --- | --- | +| Protocols | External systems behind interfaces in owning service | +| Registry | [provider-registry.md](../provider-registry.md) updated when providers change | +| No leakage | Foreign platforms not implemented inside non-owning modules | + +## Integration Completeness + +| Check | Pass criteria | +| --- | --- | +| Channels | Only API / Events / Providers used cross-service | +| No duplication | Business logic not copied from owning services | +| No foreign DB | Zero cross-DB access | + +## Operational Readiness + +| Check | Pass criteria | +| --- | --- | +| Health | Liveness endpoint green | +| Observability | Logging + metrics (or justified N/A) | +| Config | Env/settings documented; no secrets in code | +| Jobs | Background jobs present when phase requires async/scheduled work | +| Feature flags | Present when gated capabilities are in scope | + +## Maintainability & DX + +| Check | Pass criteria | +| --- | --- | +| Patterns | Matches sibling service conventions or documents intentional deviation | +| DI | Explicit dependency injection | +| README | Service README updated for new run/config surfaces | +| Scope hygiene | No unrelated drive-by refactors | + +## Definition of Done Gate + +| Check | Pass criteria | +| --- | --- | +| DoD | Full [definition-of-done.md](definition-of-done.md) satisfied | +| Completeness | [enterprise-completeness.md](enterprise-completeness.md) sign-off complete | +| Artifacts | Applicable [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) delivered or justified N/A | +| Discovery | [enterprise-phase-discovery.md](enterprise-phase-discovery.md) Record complete; gaps closed | + +## Validation Suites (Framework Phases) + +When changing `docs/ai-framework/` or docs structure, also verify: + +1. **Architecture Validation** — framework links to architecture/ADR correctly; no conflicting rules vs principles. +2. **Documentation Validation** — all listed framework docs exist; index complete. +3. **Cross Reference Validation** — manifests ↔ registries ↔ phase areas agree on names/status where claimed. +4. **Broken Link Validation** — relative Markdown links resolve. +5. **Template Validation** — every template referenced from README exists and has Related Documents. +6. **Manifest Validation** — YAML parses; required fields present; phase/service IDs unique. +7. **Enterprise Rule Validation** — DoD, Discovery, mandatory artifacts, completeness, and boundary docs are cross-linked from master-prompt, loop, gates, and phase template. + +## Sign-Off + +- [ ] Enterprise Phase Discovery +- [ ] Business Completeness +- [ ] Architecture +- [ ] API Completeness +- [ ] Permission Completeness +- [ ] Event Completeness +- [ ] Repository Completeness +- [ ] Validation Completeness +- [ ] Security +- [ ] Performance +- [ ] Testing +- [ ] Documentation +- [ ] Migration +- [ ] Backward Compatibility +- [ ] Tenant Isolation +- [ ] Provider Abstraction +- [ ] Integration Completeness +- [ ] Operational Readiness +- [ ] Maintainability & DX +- [ ] Definition of Done / Enterprise Completeness + +## Related Documents + +- [Development Loop](development-loop.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Master Prompt](master-prompt.md) +- [Definition of Done](definition-of-done.md) +- [Enterprise Completeness](enterprise-completeness.md) +- [docs/README.md](../README.md) Phase Completion Gate +- [Project Principles](../development/project-principles.md) diff --git a/docs-runtime/ai-framework/service-manifest.yaml b/docs-runtime/ai-framework/service-manifest.yaml new file mode 100644 index 0000000..b912148 --- /dev/null +++ b/docs-runtime/ai-framework/service-manifest.yaml @@ -0,0 +1,904 @@ +# Service Manifest + +# Registry of independent deployable services for TorbatYar. +# Keep aligned with docs/module-registry.md and runtime compose ports when wired. + +schema_version: 1 +updated: "2026-07-25" + +services: + - id: core-platform + name: Core Platform + owner: Platform + path: backend/core-service + database: core_platform_db + api_prefix: /api/v1 + permission_prefix: core.* + dependencies: + - postgres + - redis + - celery + - shared-lib + events: + - tenant.* + - domain.* + - subscription.* + - feature_access.* + health_endpoint: /health + documentation: + - docs/architecture/ + - docs/reference/ + current_version: 0.4.x + current_phase: onboarding-4 + status: active + + - id: identity-access + name: Identity & Access + owner: Platform + path: backend/services/identity-access + database: identity_access_db + api_prefix: /api/v1 + permission_prefix: identity.* + dependencies: + - keycloak + - core-platform + - shared-lib + events: + - user.registered + - tenant_member.added + - tenant_member.removed + health_endpoint: /health + documentation: + - docs/architecture/identity-architecture.md + current_version: 0.2.x + current_phase: identity-2 + status: active + api_port: 8001 + + - id: accounting + name: Accounting + owner: Platform + path: backend/services/accounting + database: accounting_db + api_prefix: /api/v1 + permission_prefix: accounting.* + dependencies: + - core-platform + - shared-lib + events: + - voucher.posted + - ledger.updated + - cash.received + - settlement.completed + - sales_invoice.posted + health_endpoint: /health + documentation: + - docs/phases/Accounting/README.md + current_version: 0.5.11.0 + current_phase: accounting-5.11 + status: active + api_port: 8002 + + - id: crm + name: CRM + owner: Platform + path: backend/services/crm + database: crm_db + api_prefix: /api/v1 + permission_prefix: crm.* + dependencies: + - core-platform + - shared-lib + events: + - crm.lead.* + - crm.contact.* + - crm.organization.* + - crm.opportunity.* + - crm.pipeline.* + - crm.task.* + - crm.meeting.* + - crm.call.* + - crm.timeline.* + - crm.comment.* + - crm.mention.* + health_endpoint: /health + documentation: + - docs/crm-phase-6-3.md + - docs/phases/CRM/README.md + current_version: 0.6.3.0 + current_phase: crm-6.3 + status: active + api_port: 8003 + + - id: loyalty + name: Enterprise Loyalty Platform + owner: Platform + path: backend/services/loyalty + database: loyalty_db + api_prefix: /api/v1 + permission_prefix: loyalty.* + dependencies: + - core-platform + - shared-lib + events: + - loyalty.program.* + - loyalty.tier.* + - loyalty.member.* + - loyalty.point_account.* + - loyalty.reward.* + - loyalty.campaign.* + health_endpoint: /health + documentation: + - docs/loyalty-phase-7-0.md + - docs/loyalty-phase-7-1.md + - docs/phase-handover/phase-7-1.md + - docs/phases/Loyalty/README.md + - docs/architecture/adr/ADR-011.md + current_version: 0.7.1.0 + current_phase: loyalty-7.1 + status: active + api_port: 8004 + + - id: communication + name: Enterprise Communication Platform + owner: Platform + path: backend/services/communication + database: communication_db + api_prefix: /api/v1 + permission_prefix: communication.* + dependencies: + - core-platform + - shared-lib + events: + - communication.message.* + - communication.provider.failover + - communication.otp.* + - communication.queue.dead_letter + - communication.webhook.received + health_endpoint: /health + documentation: + - docs/communication-phase-8.md + - docs/architecture/adr/ADR-012.md + current_version: 0.8.10.0 + current_phase: communication-8 + status: active + api_port: 8005 + + - id: sports_center + name: Sports Center Platform + description: Independent multi-tenant sports club / gym / facility operations platform (members, memberships, coaches, attendance, booking, programs, competitions, integrations). + owner: Platform + path: backend/services/sports_center + database: sports_center_db + api_prefix: /api/v1 + permission_prefix: sports_center.* + health_endpoint: /health + configuration: + - SPORTS_CENTER_DATABASE_URL + - AUTH_REQUIRED + - tenant resolution via X-Tenant-ID / shared middleware + - entitlement feature keys sports_center.* + dependencies: + - core-platform + - shared-lib + optional_dependencies: + - accounting + - crm + - loyalty + - communication + events: + - sports_center.member.* + - sports_center.membership.* + - sports_center.membership_type.* + - sports_center.membership_package.* + - sports_center.membership_plan.* + - sports_center.pricing_model.* + - sports_center.age_group.* + - sports_center.sport_category.* + - sports_center.membership_rule.* + - sports_center.renewal_policy.* + - sports_center.freezing_rule.* + - sports_center.expiration_policy.* + - sports_center.coach.* + - sports_center.attendance.* + - sports_center.booking.* + - sports_center.session.* + - sports_center.program.* + - sports_center.workout.* + - sports_center.competition.* + - sports_center.event.* + public_apis: + - /health + - /capabilities + - /api/v1/sports-centers + - /api/v1/membership-types + - /api/v1/membership-packages + - /api/v1/membership-plans + - /api/v1/pricing-models + - /api/v1/age-groups + - /api/v1/sport-categories + - /api/v1/membership-rules + - /api/v1/renewal-policies + - /api/v1/freezing-rules + - /api/v1/expiration-policies + - /api/v1/members + - /api/v1/family-members + - /api/v1/emergency-contacts + - /api/v1/medical-information + - /api/v1/membership-cards + - /api/v1/digital-memberships + - /api/v1/waivers + - /api/v1/member-documents + - /api/v1/memberships + - /api/v1/coaches + - /api/v1/facilities + internal_apis: + - service-to-service refs for Accounting / CRM / Loyalty / Communication (token-gated; not public) + tenant_aware: true + audit_enabled: true + documentation: + - docs/sports-center-roadmap.md + - docs/sports-center-phase-9-0.md + - docs/sports-center-phase-9-1.md + - docs/sports-center-phase-9-2.md + - docs/phase-handover/phase-9-2.md + - docs/phases/SportsCenter/README.md + - docs/architecture/adr/ADR-014.md + - docs/module-registry.md + current_version: 0.9.2.0 + current_phase: sports-center-9.2 + current_status: active + status: active + future_modules: + - members + - membership + - membership_types + - coaches + - attendance + - booking + - facilities + - equipment + - programs + - workouts + - competitions + - events + - medical + - nutrition + - locker + - mobile + - reports + - analytics + - integrations + + + - id: delivery + name: Delivery & Fleet Platform + description: Independent multi-tenant logistics platform (Torbat Driver) — drivers, fleet, dispatch, routing, tracking, POD, settlement intents, merchant connectors. Consumed by Restaurant, Marketplace, Pharmacy, Clinic, Sports Center, and future verticals via API/Events only. + owner: Platform + path: backend/services/delivery + database: delivery_db + api_prefix: /api/v1 + permission_prefix: delivery.* + health_endpoint: /health + configuration: + - DELIVERY_DATABASE_URL + - AUTH_REQUIRED + - tenant resolution via X-Tenant-ID / shared middleware + - entitlement feature keys delivery.* + dependencies: + - core-platform + - shared-lib + optional_dependencies: + - accounting + - crm + - loyalty + - communication + events: + - delivery.organization.* + - delivery.hub.* + - delivery.external_provider.* + - delivery.routing_engine.* + - delivery.configuration.* + - delivery.setting.* + - delivery.driver.* + - delivery.fleet.* + - delivery.vehicle.* + - delivery.dispatch.* + - delivery.route.* + - delivery.tracking.* + - delivery.pod.* + - delivery.settlement.* + - delivery.shift.* + - delivery.zone.* + public_apis: + - /health + - /capabilities + - /metrics + - /api/v1/organizations + - /api/v1/hubs + - /api/v1/external-providers + - /api/v1/routing-engines + - /api/v1/configurations + - /api/v1/settings + - /api/v1/audit + - /api/v1/drivers + - /api/v1/permissions/catalog + internal_apis: + - service-to-service merchant connector and settlement refs (token-gated; not public) + tenant_aware: true + audit_enabled: true + documentation: + - docs/delivery-roadmap.md + - docs/delivery-phase-10-0.md + - docs/delivery-phase-10-1.md + - docs/phases/Delivery/README.md + - docs/phase-handover/phase-dp-reg.md + - docs/phase-handover/phase-10-0.md + - docs/phase-handover/phase-10-1.md + - docs/architecture/adr/ADR-015.md + - docs/module-registry.md + current_version: 0.10.1.0 + current_phase: delivery-10.1 + current_status: active + status: active + api_port: 8007 + commercial_product: Torbat Driver + future_modules: + - fleet + - vehicle_types + - vehicles + - availability + - shifts + - working_zones + - pricing + - capabilities + - bundles + - dispatch + - routing + - optimization + - tracking + - proof_of_delivery + - settlement + - merchant_connector + - notifications + - driver_app_apis + - dispatcher_panel_apis + - customer_tracking + - fleet_analytics + - ai_hooks + - external_providers + + - id: experience + name: Enterprise Experience Platform + description: Independent multi-tenant experience platform (Torbat Pages) — sites, pages-as-resources, versioned components, themes, layouts, templates, localization, forms, SEO/PWA, bundles, widgets. Consumed by Hospitality, Marketplace, Sports Center, CRM, and future verticals via API/Events only. + owner: Platform + path: backend/services/experience + database: experience_db + api_prefix: /api/v1 + permission_prefix: experience.* + health_endpoint: /health + configuration: + - EXPERIENCE_DATABASE_URL + - AUTH_REQUIRED + - tenant resolution via X-Tenant-ID / shared middleware + - entitlement feature keys experience.* + dependencies: + - core-platform + - shared-lib + optional_dependencies: + - file_storage + - crm + - loyalty + - communication + - accounting + - sports_center + - delivery + - ai_assistant + - automation + events: + - experience.workspace.* + - experience.locale_profile.* + - experience.external_provider.* + - experience.render_engine.* + - experience.configuration.* + - experience.setting.* + - experience.site.* + - experience.page.* + - experience.site_domain.* + - experience.component.* + - experience.component_version.* + - experience.page_component.* + - experience.theme.* + - experience.layout.* + - experience.template.* + - experience.form.* + - experience.publish.* + - experience.domain.* + - experience.bundle.* + - experience.widget.* + public_apis: + - /health + - /capabilities + - /metrics + - /api/v1/workspaces + - /api/v1/locale-profiles + - /api/v1/external-providers + - /api/v1/render-engines + - /api/v1/configurations + - /api/v1/settings + - /api/v1/audit + - /api/v1/sites + - /api/v1/pages + - /api/v1/site-domains + - /api/v1/components + - /api/v1/component-versions + - /api/v1/page-component-placements + - /api/v1/themes + - /api/v1/layouts + - /api/v1/site-theme-assignments + - /api/v1/page-layout-assignments + internal_apis: + - service-to-service consumer connector refs (token-gated; not public) + tenant_aware: true + audit_enabled: true + documentation: + - docs/experience-roadmap.md + - docs/experience-phase-xp-reg.md + - docs/experience-phase-11-0.md + - docs/experience-phase-11-1.md + - docs/experience-phase-11-2.md + - docs/experience-phase-11-3.md + - docs/phases/Experience/README.md + - docs/phase-handover/phase-xp-reg.md + - docs/phase-handover/phase-11-0.md + - docs/phase-handover/phase-11-1.md + - docs/phase-handover/phase-11-2.md + - docs/phase-handover/phase-11-3.md + - docs/architecture/adr/ADR-016.md + - docs/module-registry.md + current_version: 0.11.3.0 + current_phase: experience-11.3 + current_status: active + status: active + api_port: 8008 + commercial_product: Torbat Pages + future_modules: + - sites + - pages + - page_types + - components + - component_versions + - themes + - layouts + - templates + - locales + - media_refs + - forms + - surveys + - appointments + - publishing + - domains + - seo + - pwa + - bundles + - feature_toggles + - widgets + - consumer_connectors + - analytics + - ai_hooks + - audit + - configuration + + - id: hospitality + name: Hospitality Platform + description: Independent multi-tenant F&B / hospitality operations platform (Torbat Food) — venues, digital menu catalog, tables, bundle licensing, feature toggles; future QR ordering/POS/kitchen/reservation/connectors. Integrates Accounting, CRM, Loyalty, Communication, Delivery, Website Builder, AI via API/Events only. + owner: Platform + path: backend/services/hospitality + database: hospitality_db + api_prefix: /api/v1 + permission_prefix: hospitality.* + health_endpoint: /health + configuration: + - HOSPITALITY_DATABASE_URL + - AUTH_REQUIRED + - tenant resolution via X-Tenant-ID / shared middleware + - entitlement / bundle feature keys hospitality.* + dependencies: + - core-platform + - shared-lib + optional_dependencies: + - accounting + - crm + - loyalty + - communication + - delivery + - website_builder + events: + - hospitality.venue.* + - hospitality.branch.* + - hospitality.menu.* + - hospitality.menu_category.* + - hospitality.menu_item.* + - hospitality.allergen.* + - hospitality.modifier_group.* + - hospitality.modifier_option.* + - hospitality.menu_item_modifier.* + - hospitality.menu_item_allergen.* + - hospitality.availability_window.* + - hospitality.menu_media_ref.* + - hospitality.menu_localization.* + - hospitality.dining_area.* + - hospitality.table.* + - hospitality.bundle_definition.* + - hospitality.tenant_bundle.* + - hospitality.feature_toggle.* + - hospitality.configuration.* + - hospitality.setting.* + public_apis: + - /health + - /capabilities + - /api/v1/venues + - /api/v1/branches + - /api/v1/dining-areas + - /api/v1/tables + - /api/v1/menus + - /api/v1/menu-categories + - /api/v1/menu-items + - /api/v1/allergens + - /api/v1/modifier-groups + - /api/v1/modifier-options + - /api/v1/menu-item-modifiers + - /api/v1/menu-item-allergens + - /api/v1/availability-windows + - /api/v1/menu-media-refs + - /api/v1/menu-localizations + - /api/v1/bundle-definitions + - /api/v1/tenant-bundles + - /api/v1/feature-toggles + - /api/v1/roles + - /api/v1/permissions + - /api/v1/configurations + - /api/v1/events + - /api/v1/settings + internal_apis: + - service-to-service refs for Accounting / CRM / Loyalty / Communication / Delivery / Website (token-gated; not public) + tenant_aware: true + audit_enabled: true + documentation: + - docs/hospitality-roadmap.md + - docs/hospitality-phase-12-0.md + - docs/hospitality-phase-12-1.md + - docs/phase-handover/phase-12-0.md + - docs/phase-handover/phase-12-1.md + - docs/phases/Hospitality/README.md + - docs/architecture/adr/ADR-017.md + - docs/module-registry.md + current_version: 0.12.1.0 + current_phase: hospitality-12.1 + current_status: active + status: active + api_port: 8009 + commercial_product: Torbat Food + future_modules: + - venues + - menus + - digital_menu + - qr_menu + - qr_ordering + - pos_lite + - pos_pro + - kitchen + - reservation + - table_service + - delivery_connector + - accounting_connector + - crm_connector + - loyalty_connector + - communication_connector + - website_connector + - analytics + - ai_assistant + + - id: restaurant + name: Restaurant / Cafe (historical scaffold) + owner: Platform + path: backend/services/restaurant + database: hospitality_db + api_prefix: /api/v1 + permission_prefix: hospitality.* + dependencies: + - hospitality + events: + - hospitality.* + health_endpoint: /health + documentation: + - docs/phases/Restaurant/README.md + - docs/hospitality-phase-12-0.md + - docs/hospitality-phase-12-1.md + current_version: 0.12.1.0 + current_phase: hospitality-12.1 + status: superseded + notes: Pointer only — runtime is hospitality service (ADR-017). + + - id: ecommerce + name: Ecommerce + owner: TBD + path: backend/services/ecommerce + database: ecommerce_db + api_prefix: /api/v1 + permission_prefix: ecommerce.* + dependencies: + - core-platform + events: + - order.* + - product.* + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: ecommerce-foundation + status: scaffolded + + - id: marketplace + name: Marketplace + owner: TBD + path: null + database: TBD + api_prefix: TBD + permission_prefix: marketplace.* + dependencies: + - ecommerce + - identity-access + - accounting + events: [] + health_endpoint: TBD + documentation: + - docs/phases/Marketplace/README.md + current_version: 0.0.0 + current_phase: marketplace-foundation + status: planned + + - id: automation + name: Automation + owner: TBD + path: null + database: TBD + api_prefix: TBD + permission_prefix: automation.* + dependencies: [] + events: [] + health_endpoint: TBD + documentation: + - docs/phases/Automation/README.md + current_version: 0.0.0 + current_phase: automation-foundation + status: planned + + - id: academy + name: Academy + owner: TBD + path: null + database: academy_db + api_prefix: /api/v1 + permission_prefix: academy.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/phases/Future/README.md + current_version: 0.0.0 + current_phase: academy-foundation + status: planned + + - id: clinic + name: Clinic + owner: TBD + path: null + database: clinic_db + api_prefix: /api/v1 + permission_prefix: clinic.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/phases/Future/README.md + current_version: 0.0.0 + current_phase: clinic-foundation + status: planned + + - id: hotel + name: Hotel + owner: TBD + path: null + database: hotel_db + api_prefix: /api/v1 + permission_prefix: hotel.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/phases/Future/README.md + current_version: 0.0.0 + current_phase: hotel-foundation + status: planned + + - id: tourism + name: Tourism + owner: TBD + path: null + database: tourism_db + api_prefix: /api/v1 + permission_prefix: tourism.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/phases/Future/README.md + current_version: 0.0.0 + current_phase: tourism-foundation + status: planned + + - id: ai_assistant + name: AI Assistant + owner: TBD + path: backend/services/ai_assistant + database: ai_assistant_db + api_prefix: /api/v1 + permission_prefix: ai_assistant.* + dependencies: + - core-platform + events: + - assistant.* + health_endpoint: /health + documentation: + - docs/architecture/ai-architecture.md + - docs/phases/AI/README.md + current_version: 0.0.0 + current_phase: ai-assistant-foundation + status: scaffolded + + - id: website_builder + name: Website Builder + owner: TBD + path: backend/services/website_builder + database: website_builder_db + api_prefix: /api/v1 + permission_prefix: website_builder.* + dependencies: + - file_storage + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + - docs/architecture/adr/ADR-016.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + notes: Prefer experience service (ADR-016 / Torbat Pages) for new page/site work. Historical scaffold only. + + - id: live_chat + name: Live Chat + owner: TBD + path: backend/services/live_chat + database: live_chat_db + api_prefix: /api/v1 + permission_prefix: live_chat.* + dependencies: + - core-platform + events: + - conversation.* + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: smart_messenger + name: Smart Messenger + owner: TBD + path: backend/services/smart_messenger + database: smart_messenger_db + api_prefix: /api/v1 + permission_prefix: smart_messenger.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: sms_panel + name: SMS Panel + owner: TBD + path: backend/services/sms_panel + database: sms_panel_db + api_prefix: /api/v1 + permission_prefix: sms_panel.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + - docs/architecture/adr/ADR-012.md + current_version: 0.0.0 + current_phase: sms-panel-future + status: scaffolded + notes: Prefer communication service for new messaging work (ADR-012). + + - id: notification + name: Notification + owner: TBD + path: backend/services/notification + database: notification_db + api_prefix: /api/v1 + permission_prefix: notification.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: file_storage + name: File Storage + owner: TBD + path: backend/services/file_storage + database: file_storage_db + api_prefix: /api/v1 + permission_prefix: file_storage.* + dependencies: [] + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: link_shortener + name: Link Shortener + owner: TBD + path: backend/services/link_shortener + database: link_shortener_db + api_prefix: /api/v1 + permission_prefix: link_shortener.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: frontend + name: Frontend + owner: Platform + path: frontend + database: null + api_prefix: consumes /api/v1 + permission_prefix: UI gated by /me roles + dependencies: + - core-platform + - identity-access + - keycloak + events: [] + health_endpoint: null + documentation: + - docs/frontend/README.md + current_version: Next-15.5.x + current_phase: onboarding-4 + status: active diff --git a/docs-runtime/glossary.md b/docs-runtime/glossary.md new file mode 100644 index 0000000..000e5a3 --- /dev/null +++ b/docs-runtime/glossary.md @@ -0,0 +1,217 @@ +# Glossary + +Canonical definitions for TorbatYar. Prefer these terms in docs and code comments. + +## Platform & Tenancy + +| Term | Definition | +| --- | --- | +| **Tenant** | A customer workspace/organization on the platform. Owns domains, branding, memberships, and subscriptions. | +| **Workspace** | Product synonym for an operational Tenant after onboarding. | +| **Organization** | Business entity represented by a Tenant (not a separate table today). | +| **Member** | **Platform:** a user with a membership row linking them to a Tenant. **Sports Center:** see Member (Sports) — do not conflate. | +| **Platform Member** | Preferred unambiguous term for a user linked to a Tenant via `tenant_memberships`. | +| **Tenant Membership (Core)** | `core_platform_db.tenant_memberships` — source of truth for workspace roles/ownership. | +| **Tenant Membership (Identity)** | `identity_access_db.tenant_memberships` — SSO listing membership; not operational authz. | +| **Workspace Activation** | Completing onboarding so tenant moves to `active` with `onboarding_completed=true`. | +| **Current Tenant** | `users.current_tenant_id` — workspace selected for the session context. | +| **Domain** | Hostname mapped to a tenant (subdomain or custom). | +| **Primary Domain** | Domain marked `is_primary` for the tenant. | +| **White Label** | Per-tenant branding (colors, logo, favicon, name) applied at runtime. | +| **Platform Base Domain** | `PLATFORM_BASE_DOMAIN` (e.g. `torbatyar.ir`) used to mint `{slug}.{base}`. | + +## Identity & Access + +| Term | Definition | +| --- | --- | +| **Identity** | Who the actor is (Keycloak subject + Core/Identity profiles). | +| **Platform User** | User recorded in Core `users` (mobile-required). | +| **SSO** | Central Keycloak OpenID Connect login for staff. | +| **OTP** | One-time password sent via SMS for mobile verification/login. | +| **Keycloak Sub** | Stable IdP subject id stored as `keycloak_sub`. | +| **JIT Provisioning** | Creating/linking Core user from Keycloak JWT on first resolve. | +| **Handoff** | One-time session token from Keycloak theme mobile flow redeemed by frontend. | +| **BFF** | Backend-for-frontend (Identity token exchange). | +| **Role** | Named authorization level (platform/tenant roles). | +| **Permission** | Fine-grained allow rule (future trees; today largely role + entitlement). | +| **Entitlement** | Plan/feature gate: whether a tenant may use a feature_key. | +| **Feature Key** | `{service}.{resource}.{action}` string checked by Core. | +| **Internal Service Token** | Machine credential for service-to-service calls. | + +## Modular Product + +| Term | Definition | +| --- | --- | +| **Module** | Installable/activateable product capability with clear DB and API ownership. | +| **Service** | Deployable backend unit implementing one or more modules. | +| **Provider** | External vendor adapter (SMS, payment, storage, AI model, tax). | +| **Service Registry** | Core table of internal services (`service_key`, base URL, health). | +| **Module Registry** | Core table + docs inventory of modules and activation. | +| **Subscription** | Tenant’s plan binding (`tenant_subscriptions`). | +| **Plan** | Packaged set of features/limits (e.g. FREE, STARTER). | + +## Accounting (future) + +| Term | Definition | +| --- | --- | +| **Ledger** | Books of account for a tenant. | +| **Account** | Chart-of-accounts node (planned 4-level structure). | +| **Journal** | Collection of journal entries. | +| **Journal Entry** | Double-entry header document. | +| **Journal Line** | Debit/credit line under an entry. | +| **Voucher** | Business document that may result in postings. | +| **Posting** | Act of writing validated lines to the ledger. | +| **Posting Engine** | Sole authorized component to create journal entries. | +| **Cost Center** | Analytical dimension for costs. | +| **Project** | Analytical dimension for project accounting. | + +## CRM / Commerce / Ops + +| Term | Definition | +| --- | --- | +| **Lead** | Prospective customer record (CRM-owned). | +| **Contact** | Person record in Sales CRM. | +| **Organization (CRM)** | Business account / company record in Sales CRM (not the platform Tenant). | +| **Opportunity** | Qualified sales opportunity in a pipeline. | +| **Pipeline / Stage** | Configurable sales process steps. | +| **Sales Playbook** | Versioned sales checklist assigned to pipeline/opportunity. | +| **Sales Forecast** | Rule-based pipeline/weighted/committed forecast (no ML). | +| **Sales Activity** | Call, meeting, task, email, follow-up, demo, or custom activity. | +| **Sales Timeline** | Immutable CRM collaboration event stream. | +| **Quote (Sales)** | CRM sales quote — not an accounting invoice. | +| **Loyalty Program** | Tenant-scoped loyalty program configuration (Loyalty-owned). | +| **Loyalty Member** | Loyalty-owned membership record in a program (distinct from Platform Member / Sports Member). | +| **Membership Lifecycle** | Loyalty state machine: enroll/activate/renew/freeze/resume/cancel/expire/transfer. | +| **Loyalty Member** | Enrolled participant in a Loyalty Program; belongs to exactly one Tenant. | +| **Membership Tier** | Ranked level within a Loyalty Program. | +| **Point Account** | Loyalty points account shell; balances come only from immutable ledger entries. | +| **Communication Platform** | Independent shared service owning outbound messaging, providers, templates, queue, OTP, and delivery tracking. | +| **Provider Failover** | Automatic switch to the next priority provider when send fails or circuit is open. | +| **Dynamic Contact Source** | API-configured resolver that fetches destinations at send time without copying business-module rows. | +| **Order** | Purchase intent/fulfillment record (ecommerce/restaurant). | +| **Inventory** | Stock levels for sellable items. | + +## Sports Center + +| Term | Definition | +| --- | --- | +| **Member (Sports)** | Enrolled athlete or club customer profile in Sports Center (`members` table) — not a Platform Member and not a Loyalty Member. | +| **Membership (Sports)** | Active enrollment instance linking a Member to a Membership Type from the catalog; supports freeze/activate/cancel. | +| **Family Member (Sports)** | Household/relationship link under a Sports Member (may optionally reference another Member). | +| **Emergency Contact** | Contact person recorded for a Sports Member for emergency use. | +| **Medical Information (Sports)** | Privacy-aware clearance/notes shell for a Sports Member — not an EHR; binaries via Storage refs. | +| **Membership Card** | Physical/digital/QR card issued to a Sports Member (payload strings only until attendance devices). | +| **Digital Membership** | Digital pass / wallet shell for a Sports Member (`pass_code` + optional token/deep-link refs). | +| **Waiver** | Liability/consent record for a Sports Member; sign action stores signature/file refs. | +| **Coach** | Sports Center staff record who coaches members/sessions (Sports Center–owned). | +| **Trainer** | Synonym/role variant of Coach; prefer **Coach** in APIs unless a distinct trainer role is modeled. | +| **Program** | Structured training program definition owned by Sports Center (not a Loyalty Program). | +| **Workout** | Concrete workout definition or assigned workout instance under a Program or coach plan. | +| **Attendance** | Check-in/check-out or presence record for a member at a facility/session. | +| **Booking** | Reservation of a facility, court, session, or equipment slot. | +| **Facility** | Physical sports space unit (building area, room, hall) owned by Sports Center. | +| **Court** | Bookable facility subtype (e.g. tennis/futsal court). | +| **Session** | Scheduled time-bound activity (class, training, rental) that may be booked or attended. | +| **Locker** | Assignable locker unit or locker status record in Sports Center. | +| **Access Device** | Hardware or logical reader used for attendance/access control (adapter-owned credentials stay out of other services). | +| **Competition** | Organized contest or tournament record owned by Sports Center. | +| **Membership Plan** | Commercial definition of sports membership benefits/duration (Membership Types). | +| **Package** | Bundled sports offering (sessions/classes/services) sold under Membership Types. | +| **Renewal** | Extending an active sports Membership for a new period. | +| **Freeze** | Temporary suspension of a sports Membership without full cancellation. | +| **Transfer** | Moving a sports Membership between eligible members/accounts per business rules. | +| **Sports Event** | Calendar/competition event in Sports Center — distinct from domain/integration **Events** on the bus. | + + +## Delivery & Fleet Platform + +| Term | Definition | +| --- | --- | +| **Delivery Platform** | Independent shared logistics service (`delivery`) owning drivers, fleet, dispatch, routing, tracking, POD, and settlement intents — commercial product **Torbat Driver**. | +| **Torbat Driver** | Commercial product name for the Delivery & Fleet Platform. | +| **Driver (Delivery)** | Courier/operator profile owned by Delivery — not a Platform Member and not a Sports Coach. | +| **Fleet** | Group of vehicles/drivers managed as an operational unit in Delivery. | +| **Vehicle Type** | Catalog class of vehicle (bike, car, van, …) with capability flags. | +| **Dispatch** | Assignment of delivery jobs to drivers/vehicles. | +| **Delivery Job** | Logistics work unit referenced by external vertical order IDs — Delivery does not own the vertical order aggregate. | +| **Working Zone** | Geo/operational area where drivers may accept jobs. | +| **Shift (Delivery)** | Scheduled working window for a driver. | +| **Multi Pickup / Multi Drop** | Route with multiple pickup and/or drop stops on one job/route. | +| **Proof of Delivery (POD)** | Evidence that a drop was completed (signature/photo/code refs). | +| **Customer Tracking** | Tokenized tracking view for end customers — distinct from dispatcher tracking. | +| **Merchant Connector** | API/event contract for verticals to request logistics without embedding Delivery logic. | +| **Settlement (Delivery)** | Intent to settle driver/merchant amounts; journals only via Accounting Posting Engine. | +| **Message Delivery** | Communication-owned SMS/email/push delivery status — **not** physical logistics. | + +## Experience Platform + +| Term | Definition | +| --- | --- | +| **Experience Platform** | Independent shared digital experience service (`experience`) owning sites, pages-as-resources, versioned components, themes, layouts, templates, forms, SEO/PWA shells, bundles, and widgets — commercial product **Torbat Pages**. | +| **Torbat Pages** | Commercial product name for the Experience Platform. | +| **Page Resource** | First-class tenant-scoped page entity with identity, lifecycle, and permissions — not a CMS blob owned by a vertical. | +| **Component (Experience)** | Versioned building block composed into pages; versions are immutable once published. | +| **Theme (Experience)** | Replaceable visual pack applied to sites/pages without rewriting page trees. | +| **Layout (Experience)** | Dynamic composition structure for placing components. | +| **Template (Experience)** | Starter definition used to instantiate sites/pages of a given type. | +| **Page Type** | Classification of page purpose (landing, mini site, digital menu, bio link, portfolio, blog, QR, …). | +| **Experience Bundle** | Licensed capability pack (e.g. Digital Menu, Bio Link, Custom Domain) gated via entitlement. | +| **Widget (Experience)** | Embeddable experience surface for consumer apps/sites. | +| **Consumer Connector (Experience)** | API/event contract for verticals to bind entity refs into pages without embedding Experience logic. | +| **Website Builder (historical)** | Scaffolded stub service — prefer Experience Platform for new page/site work ([ADR-016](architecture/adr/ADR-016.md)). | + +## Hospitality Platform + +| Term | Definition | +| --- | --- | +| **Hospitality Platform** | Independent F&B operations service (`hospitality`) owning venues, menus, tables, bundles, and feature toggles — commercial product **Torbat Food**. | +| **Torbat Food** | Commercial product name for the Hospitality Platform. | +| **Venue** | Root hospitality unit for a tenant (cafe, restaurant, bakery, cloud kitchen, …). | +| **Venue Format** | Configurable classification of a venue — not a hardcoded business engine. | +| **Digital Menu Catalog** | Menu depth layer: allergens, modifiers, availability windows, media refs, localizations (Phase 12.1). | +| **Modifier Group** | Selection-rule container (size/extras) linked to menu items; not a POS engine. | +| **Availability Window** | Time-of-day / weekday window scoping menu, category, or item availability. | +| **Menu Media Ref** | Storage `file_ref` pointer for menu imagery; Hospitality does not own blobs. | +| **Bundle (Hospitality)** | Licensable capability pack (e.g. Digital Menu, POS Lite, Kitchen) gated via entitlement / tenant activation. | +| **Feature Toggle (Hospitality)** | Per-tenant/venue capability flag used for discovery and gating. | +| **Dining Table** | Seating/table shell — QR/ordering engines come in later phases. | +| **Restaurant (historical)** | Scaffold alias — prefer Hospitality Platform ([ADR-017](architecture/adr/ADR-017.md)). | + +## Technical + +| Term | Definition | +| --- | --- | +| **Database-per-Service** | Each service owns its DB; no cross-DB queries. | +| **Outbox / Inbox** | Reliable event publish/consume tables. | +| **Event Envelope** | Standard event metadata wrapper. | +| **API-First** | Contracts precede UI implementation. | +| **ADR** | Architecture Decision Record. | +| **Phase** | Time-boxed delivery with completion gate. | +| **AI Development Framework** | Permanent docs under `docs/ai-framework/` governing AI-assisted implementation (process — not the product AI Assistant). Also called **Enterprise Development Framework** after [ADR-018](architecture/adr/ADR-018.md). | +| **Enterprise Development Framework** | Alias for the upgraded AI Development Framework: production-ready phases by default via DoD, mandatory artifacts, completeness, and boundary rules. | +| **Definition of Done (phase)** | Mandatory exit checklist: feature-complete for the phase boundary; CRUD never sufficient ([definition-of-done.md](ai-framework/definition-of-done.md)). | +| **Mandatory Phase Artifacts** | Default engineering stack every implementation phase must deliver when applicable ([mandatory-phase-artifacts.md](ai-framework/mandatory-phase-artifacts.md)). | +| **Enterprise Completeness** | Pre-close verification dimensions that must pass before Complete ([enterprise-completeness.md](ai-framework/enterprise-completeness.md)). | +| **Enterprise Phase Discovery** | Mandatory pre-implementation inventory of roadmap, architecture, boundaries, prior handover, and existing code/APIs/domain/events/permissions/aggregates/tests/docs; derives missing current-phase capabilities ([enterprise-phase-discovery.md](ai-framework/enterprise-phase-discovery.md), [ADR-019](architecture/adr/ADR-019.md)). | +| **Boundary Rules** | Hard constraints: no foreign ownership, no logic duplication, no cross-DB access, API/Events/Providers only, no future-phase pull-forward. | +| **Tenant-Aware** | Request/data path always scoped to a tenant. | +| **Audit Log** | Immutable-ish record of who did what, when. | + +## Phase Numbering Note + +Internal docs: **فاز ۳** = OTP + Tenant Management; **فاز ۴** = Onboarding/Workspace Activation. Some briefs labeled onboarding as “Phase 3”. Always disambiguate using [progress.md](progress.md). + +## Related Documents + +- [Module Registry](module-registry.md) +- [Provider Registry](provider-registry.md) +- [Architecture Overview](architecture/architecture.md) +- [AI / Enterprise Development Framework](ai-framework/README.md) +- [Enterprise Phase Discovery](ai-framework/enterprise-phase-discovery.md) +- [Definition of Done](ai-framework/definition-of-done.md) +- [ADR-018](architecture/adr/ADR-018.md) +- [ADR-019](architecture/adr/ADR-019.md) +- [Sports Center Roadmap](sports-center-roadmap.md) +- [Delivery Roadmap](delivery-roadmap.md) +- [Experience Roadmap](experience-roadmap.md) +- [Hospitality Roadmap](hospitality-roadmap.md) diff --git a/docs-runtime/module-registry.md b/docs-runtime/module-registry.md new file mode 100644 index 0000000..4807c2f --- /dev/null +++ b/docs-runtime/module-registry.md @@ -0,0 +1,899 @@ +# Module Registry + +Canonical inventory of modules. Template: [module-template.md](templates/module-template.md). +Architecture boundaries: [module-boundaries.md](architecture/module-boundaries.md). + +Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** + +--- + +## core-platform + +| Field | Value | +| --- | --- | +| Name | Core Platform | +| Description | Tenants, domains, plans, entitlements, registries, onboarding, audit, outbox | +| Owner | Platform | +| Status | Active | +| Dependencies | Postgres, Redis, Celery | +| Internal Dependencies | shared-lib | +| External Dependencies | Payamak (OTP), Keycloak (JWT validate), SSL provision host | +| Database Ownership | Sole owner | +| Database | `core_platform_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `core.*` / platform admin deps | +| Events | `tenant.*`, `domain.*`, `subscription.*`, `feature_access.*` | +| Event Producers | Core services/workers | +| Event Consumers | Future business services | +| Provider Dependencies | Payamak, Let's Encrypt (via ops/SSL task) | +| AI Dependencies | None | +| Documentation | [architecture/](architecture/), [reference/](reference/) | +| Current Phase | Phase 4 complete; white-label polish in progress | +| Version | 0.4.x | +| Version Compatibility | Identity ≥ phase 2 | +| Migration Version | Alembic head (incl. `0005_tenant_onboarding`) | +| Tenant Aware | Yes | +| Permission Tree | platform_admin + membership roles | +| Future Plans | Payment gateway, DNS verify, advanced permissions | + +--- + +## identity-access + +| Field | Value | +| --- | --- | +| Name | Identity & Access | +| Description | OIDC BFF, profiles, mobile OTP handoff, Keycloak sync | +| Owner | Platform | +| Status | Active | +| Dependencies | Keycloak, Core OTP API | +| Internal Dependencies | shared-lib | +| External Dependencies | Keycloak, Core | +| Database Ownership | Sole owner | +| Database | `identity_access_db` | +| API Prefix | `/api/v1/auth`, `/api/v1/users`, `/api/v1/tenants/{id}/members` | +| Permission Prefix | Identity admin roles | +| Events | `user.registered`, `tenant_member.added`, `tenant_member.removed` | +| Event Producers | Identity service | +| Event Consumers | Core (future sync) | +| Provider Dependencies | Keycloak | +| AI Dependencies | None | +| Documentation | [identity-architecture.md](architecture/identity-architecture.md), service README | +| Current Phase | Phase 2+ delivered | +| Version | 0.2.x | +| Version Compatibility | Core with JWT + mobile | +| Migration Version | Identity Alembic head | +| Tenant Aware | Partial (membership listing) | +| Permission Tree | platform_admin for user/member admin APIs | +| Future Plans | Richer sync with Core memberships | + +--- + +## frontend + +| Field | Value | +| --- | --- | +| Name | Frontend | +| Description | Next.js UI: SSO, onboarding, dashboard, tenant site | +| Owner | Platform | +| Status | Active | +| Dependencies | Core API, Identity API, Keycloak | +| Internal Dependencies | None (no backend imports) | +| External Dependencies | Browser | +| Database Ownership | None | +| Database | — | +| API Prefix | Consumes `/api/v1` | +| Permission Prefix | UI gated by roles from `/me` | +| Events | N/A (consumer only via API) | +| Event Producers | None | +| Event Consumers | None | +| Provider Dependencies | None directly | +| AI Dependencies | None | +| Documentation | [frontend/README.md](../frontend/README.md) | +| Current Phase | White-label runtime partial | +| Version | Next 15.5.x | +| Version Compatibility | Core/Identity current | +| Migration Version | N/A | +| Tenant Aware | Yes (host + context) | +| Permission Tree | Mirrors Core roles in UI | +| Future Plans | Module UIs per business phase | + +--- + +## accounting + +| Field | Value | +| --- | --- | +| Name | Accounting | +| Description | 4-level COA, double-entry, posting engine, GL, treasury, AR/AP, sales integration | +| Owner | Platform | +| Status | Active (Phases 5.1–5.11) | +| Dependencies | Core entitlement | +| Internal Dependencies | Posting Engine (owned here) | +| External Dependencies | Tax/e-invoice providers (future) | +| Database Ownership | Sole owner | +| Database | `accounting_db` | +| API Prefix | `/api/v1` (service on port 8002) | +| Permission Prefix | `accounting.*`, `treasury.*`, `receivable.*`, `payable.*`, `sales_accounting.*` | +| Events | `voucher.posted`, `ledger.updated`, `cash.received`, `settlement.completed`, `sales_invoice.posted` | +| Event Producers | Accounting | +| Event Consumers | Reporting, compliance (planned) | +| Provider Dependencies | Payment/tax (planned) | +| AI Dependencies | Optional assistants only (Phase 5.12) | +| Documentation | [phases/Accounting](phases/Accounting/README.md), service README | +| Current Phase | 5.11 complete; 5.12 (AI) next | +| Version | 0.5.11.0 | +| Version Compatibility | Requires Core entitlement | +| Migration Version | `0002_phases_57_511` | +| Tenant Aware | Yes (required) | +| Permission Tree | `accounting.*`, `treasury.*`, `receivable.*`, `payable.*`, `sales_accounting.*` | +| Future Plans | Phases 5.7–5.12 (purchase/inventory, assets, payroll, reporting, compliance, AI) | + +--- + +## crm + +| Field | Value | +| --- | --- | +| Name | CRM | +| Description | Sales CRM: leads, contacts, organizations, opportunities, pipelines, playbooks, forecasts, goals, targets, win/loss, activities, tasks, meetings, calls, timeline, comments, mentions, team collaboration, quotes, tags, addresses, custom fields, notes, attachments, audit | +| Owner | Platform | +| Status | Active (Phase 6.3 collaboration — CRM Core Platform complete) | +| Dependencies | Core entitlement; File Storage + Product Service + Notification (reference/contracts only) | +| Internal Dependencies | None — Sales CRM only | +| External Dependencies | Platform providers via contracts only (Automation, Customer360, Notification, Analytics, AI, Communication, Helpdesk, FileStorage, ProductService) | +| Database Ownership | Sole owner | +| Database | `crm_db` | +| API Prefix | `/api/v1` (service on port 8003) | +| Permission Prefix | `crm.*`, `crm.opportunities.*`, `crm.pipelines.*`, `crm.playbooks.*`, `crm.forecasts.*`, `crm.goals.*`, `crm.targets.*`, `crm.activities.*`, `crm.tasks.*`, `crm.meetings.*`, `crm.calls.*`, `crm.timeline.*`, `crm.comments.*`, `crm.mentions.*`, `crm.team.*`, `crm.leads.*`, `crm.contacts.*`, `crm.organizations.*`, `crm.notes.*`, `crm.attachments.*`, `crm.customfields.*` | +| Events | `crm.opportunity.*`, `crm.pipeline.changed`, `crm.stage.changed`, `crm.playbook.*`, `crm.forecast.updated`, `crm.activity.*`, `crm.task.completed`, `crm.meeting.finished`, `crm.call.logged`, `crm.timeline.updated`, `crm.comment.created`, `crm.mention.created`, `crm.lead.*`, `crm.contact.created`, `crm.organization.created`, `crm.note.created`, `crm.attachment.added`, `crm.quote.created` | +| Event Producers | CRM | +| Event Consumers | Future platforms (not consumed in 6.3) | +| Provider Dependencies | Contracts only — no implementations | +| AI Dependencies | Optional via `AIProvider` contract only | +| Documentation | [crm-phase-6-0.md](crm-phase-6-0.md), [crm-phase-6-1.md](crm-phase-6-1.md), [crm-phase-6-2.md](crm-phase-6-2.md), [crm-phase-6-3.md](crm-phase-6-3.md), [phases/CRM](phases/CRM/README.md), service README | +| Current Phase | 6.3 complete (CRM Core Platform) | +| Version | 0.6.3.0 | +| Version Compatibility | Requires Core entitlement | +| Migration Version | `0004_phase_63_collaboration` | +| Tenant Aware | Yes (required) | +| Permission Tree | `crm.*` | +| Future Plans | Explicitly scoped Sales CRM slices only; no platform ownership | + +--- + +## loyalty + +| Field | Value | +| --- | --- | +| Name | Enterprise Loyalty Platform | +| Description | Shared loyalty: programs, members, tiers, point accounts, rewards, campaigns; reusable by all business modules | +| Owner | Platform | +| Status | Active (Phase 7.1 Membership Engine) | +| Dependencies | Core entitlement (feature-key gate deferred platform-wide; JWT + tenant header enforced) | +| Internal Dependencies | None — independent of CRM | +| External Dependencies | Platform providers via contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage) | +| Database Ownership | Sole owner | +| Database | `loyalty_db` | +| API Prefix | `/api/v1` (service on port 8004) | +| Permission Prefix | `loyalty.*`, `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`, `loyalty.point_accounts.*`, `loyalty.rewards.*`, `loyalty.campaigns.*`, `loyalty.audit.*` | +| Events | `loyalty.program.*`, `loyalty.tier.*`, `loyalty.member.*`, `loyalty.point_account.*`, `loyalty.reward.*`, `loyalty.campaign.*` | +| Event Producers | Loyalty (transactional outbox) | +| Event Consumers | Future business modules / Customer360 (not consumed in 7.1) | +| Provider Dependencies | Contracts only — no implementations | +| AI Dependencies | Optional via `AIProvider` contract only | +| Documentation | [loyalty-phase-7-0.md](loyalty-phase-7-0.md), [loyalty-phase-7-1.md](loyalty-phase-7-1.md), [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md), [phases/Loyalty](phases/Loyalty/README.md), service README | +| Current Phase | 7.1 complete (Membership Engine); 7.2 not started | +| Version | 0.7.1.0 | +| Version Compatibility | Designed for Core entitlement; runtime feature-check deferred | +| Migration Version | `0002_phase_71_membership` | +| Tenant Aware | Yes (required) | +| Permission Tree | `loyalty.*` (view/manage inheritance supported) | +| Future Plans | Phases 7.2–7.10 (points ledger, rewards, campaigns, coupon/voucher, wallet/gift, partner, integrations, AI/analytics, production readiness) | + +--- + +## communication + +| Field | Value | +| --- | --- | +| Name | Enterprise Communication Platform | +| Description | Independent shared messaging: providers, router, SMS, templates, dynamic contacts, queue, delivery tracking, OTP, webhooks, monitoring | +| Owner | Platform | +| Status | Active (Phase 8.0–8.10 complete) | +| Dependencies | Core entitlement (optional gate); no other business modules required | +| Internal Dependencies | None — independent of CRM / Loyalty / Restaurant | +| External Dependencies | SMS providers (Payamak, mock); future email/push/social/voice adapters | +| Database Ownership | Sole owner | +| Database | `communication_db` | +| API Prefix | `/api/v1` (service on port 8005); `/health`, `/capabilities` | +| Permission Prefix | `communication.*` | +| Events | `communication.message.*`, `communication.provider.*`, `communication.otp.*`, `communication.queue.*`, `communication.webhook.*`, `communication.template.*` | +| Event Producers | Communication | +| Event Consumers | Any subscribed business module (via API/events only) | +| Provider Dependencies | Payamak (SMS), Mock; stubs for smtp/fcm/whatsapp/telegram/rubika/voice | +| AI Dependencies | None | +| Documentation | [communication-phase-8.md](communication-phase-8.md), ADR-012, service README | +| Current Phase | 8.10 complete | +| Version | 0.8.10.0 | +| Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED | +| Migration Version | `0001_initial` | +| Tenant Aware | Yes (required) | +| Permission Tree | `communication.*` | +| Future Plans | Worker drain, additional channel adapters, optional Core OTP handoff | + +--- + +## sports_center + +| Field | Value | +| --- | --- | +| Name | Sports Center Platform | +| Description | Independent sports platform: foundation, membership catalog, and member management (profiles, assignment, cards/waivers/documents) | +| Owner | Platform | +| Status | Active (Phase 9.2 member management) | +| Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication via API/Events when phases require them | +| Internal Dependencies | Foundation + catalog + member modules owned by this service | +| External Dependencies | Access-device vendors (adapters later); payment via Accounting (future) | +| Database Ownership | Sole owner | +| Database | `sports_center_db` | +| API Prefix | `/api/v1` (service on port 8006); `/health`, `/capabilities` | +| Permission Prefix | `sports_center.*` | +| Events | `sports_center.member.*`, `membership.*`, `family_member.*`, `emergency_contact.*`, `medical_information.*`, `membership_card.*`, `digital_membership.*`, `waiver.*`, `member_document.*`, catalog events, `coach.*`, `facility.*`, `device.*`, `locker.*` | +| Event Producers | Sports Center | +| Event Consumers | Accounting, CRM, Loyalty, Communication, Analytics (future consumers) | +| Provider Dependencies | Connector interfaces only | +| AI Dependencies | Optional via contracts only (Phase 9.9) | +| Documentation | [sports-center-phase-9-0.md](sports-center-phase-9-0.md), [sports-center-phase-9-1.md](sports-center-phase-9-1.md), [sports-center-phase-9-2.md](sports-center-phase-9-2.md), [phase-handover/phase-9-2.md](phase-handover/phase-9-2.md), [sports-center-roadmap.md](sports-center-roadmap.md), [phases/SportsCenter](phases/SportsCenter/README.md), [ADR-014](architecture/adr/ADR-014.md) | +| Current Phase | 9.2 complete (member management); next `sports-center-9.3` | +| Version | 0.9.2.0 | +| Version Compatibility | Requires Core entitlement; integrations per Phase 9.8 | +| Migration Version | `0003_phase_92_member_management` | +| Tenant Aware | Yes (required) | +| Permission Tree | `sports_center.*` | +| Future Plans | Phases 9.3–9.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +### Modules (owned by sports_center) + +| Module Key | Name | Status | Phase | +| --- | --- | --- | --- | +| `sports_center.sports_centers` | Sports Centers | Active (shell) | 9.0 | +| `sports_center.branches` | Branches | Active (shell) | 9.0 | +| `sports_center.sports` | Sports catalog | Active (shell) | 9.0 | +| `sports_center.membership_types` | Membership Types | Active (catalog) | 9.1 | +| `sports_center.memberships` | Memberships (assignment + status) | Active | 9.2 | +| `sports_center.sport_categories` | Sport Categories | Active | 9.1 | +| `sports_center.age_groups` | Age Groups | Active | 9.1 | +| `sports_center.pricing_models` | Pricing Models | Active | 9.1 | +| `sports_center.membership_packages` | Membership Packages | Active | 9.1 | +| `sports_center.membership_plans` | Membership Plans | Active | 9.1 | +| `sports_center.membership_rules` | Membership Rules | Active | 9.1 | +| `sports_center.renewal_policies` | Renewal Policies | Active | 9.1 | +| `sports_center.freezing_rules` | Freezing Rules | Active | 9.1 | +| `sports_center.expiration_policies` | Expiration Policies | Active | 9.1 | +| `sports_center.members` | Members | Active | 9.2 | +| `sports_center.family_members` | Family Members | Active | 9.2 | +| `sports_center.emergency_contacts` | Emergency Contacts | Active | 9.2 | +| `sports_center.medical` | Medical Information | Active (shell) | 9.2 | +| `sports_center.membership_cards` | Membership Cards | Active | 9.2 | +| `sports_center.digital_memberships` | Digital Memberships | Active | 9.2 | +| `sports_center.waivers` | Waivers | Active | 9.2 | +| `sports_center.member_documents` | Member Documents | Active | 9.2 | +| `sports_center.coaches` | Coaches | Active (shell) | 9.0 | +| `sports_center.roles` | Sports Roles | Active (shell) | 9.0 | +| `sports_center.permissions` | Sports Permissions | Active (shell) | 9.0 | +| `sports_center.facilities` | Facilities | Active (shell) | 9.0 | +| `sports_center.courts` | Courts | Active (shell) | 9.0 | +| `sports_center.rooms` | Rooms | Active (shell) | 9.0 | +| `sports_center.locker_rooms` | Locker Rooms | Active (shell) | 9.0 | +| `sports_center.lockers` | Lockers | Active (shell) | 9.0 | +| `sports_center.devices` | Devices | Active (shell) | 9.0 | +| `sports_center.device_providers` | Device Providers | Active (shell) | 9.0 | +| `sports_center.attendance_gateways` | Attendance Gateways | Active (shell) | 9.0 | +| `sports_center.configuration` | Configuration | Active (shell) | 9.0 | +| `sports_center.events` | Sports Events | Active (shell) | 9.0 | +| `sports_center.settings` | Settings | Active (shell) | 9.0 | +| `sports_center.audit` | Audit | Active | 9.0 | +| `sports_center.attendance` | Attendance engine | Planned | 9.5 | +| `sports_center.booking` | Booking | Planned | 9.4 | +| `sports_center.equipment` | Equipment | Planned | 9.4 | +| `sports_center.programs` | Programs | Planned | 9.6 | +| `sports_center.workouts` | Workouts | Planned | 9.6 | +| `sports_center.competitions` | Competitions | Planned | 9.7 | +| `sports_center.competitions_events` | Competition events depth | Planned | 9.7 | +| `sports_center.nutrition` | Nutrition | Planned | Later slice | +| `sports_center.mobile` | Mobile | Planned | Cross-cutting | +| `sports_center.reports` | Reports | Planned | 9.9 | +| `sports_center.analytics` | Analytics | Planned | 9.9 | +| `sports_center.integrations` | Integrations | Planned | 9.8 | + +| Module | Responsibilities | Non-responsibilities | +| --- | --- | --- | +| Members | Sports member profiles; links to platform user refs | Platform Tenant Member; Loyalty Member | +| Membership | Active membership instances; freeze/renewal/transfer | Accounting invoices ownership | +| Membership Types | Plans, packages, commercial definitions | Core SaaS plans | +| Coaches | Coach/trainer records and assignments | Identity user admin | +| Attendance | Check-in/out records | Physical device firmware | +| Booking | Reservations and conflict rules | Payment capture ownership | +| Facilities | Courts, rooms, zones | Building IoT cloud | +| Equipment | Equipment inventory for booking/tracking | Procurement accounting | +| Programs | Training programs | Generic LMS / Academy ownership | +| Workouts | Workout definitions / assignments | Wearable vendor platforms | +| Competitions | Competition definitions / results shells | External federation systems | +| Events | Sports calendar events | Domain/integration event bus | +| Medical | Clearance / note refs (privacy-aware) | Hospital EHR systems | +| Nutrition | Nutrition plan shells | Dietitian marketplace | +| Locker | Locker assignment status | Smart-lock vendor cloud | +| Mobile | Mobile API contracts / deep links | Native app store publishing (ops) | +| Reports | Operational reports | Central BI warehouse ownership | +| Analytics | Metrics export shells | Product AI platform | +| Integrations | Adapters to Accounting/CRM/Loyalty/Communication | Foreign DB ownership | + +--- + +## delivery + +| Field | Value | +| --- | --- | +| Name | Delivery & Fleet Platform (Torbat Driver) | +| Description | Independent logistics platform (Torbat Driver): foundation orgs/hubs/config/providers/routing-engine shells; drivers active; fleet/dispatch in later phases | +| Owner | Platform | +| Status | Active (Phase 10.1 drivers) | +| Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication / verticals via API/Events when phases require them | +| Internal Dependencies | Modules owned by this service only | +| External Dependencies | Routing engines and fleet providers (adapters); notifications via Communication; Storage file refs for driver docs | +| Database Ownership | Sole owner | +| Database | `delivery_db` | +| API Prefix | `/api/v1` (service on port 8007); `/health`, `/capabilities`, `/metrics` | +| Permission Prefix | `delivery.*` | +| Events | `delivery.organization.*`, `delivery.hub.*`, `delivery.external_provider.*`, `delivery.routing_engine.*`, `delivery.configuration.*`, `delivery.setting.*`, `delivery.driver.*` (+ planned fleet/dispatch/...) | +| Event Producers | Delivery | +| Event Consumers | Accounting, Communication, CRM, Loyalty, Restaurant, Marketplace, Clinic, Sports Center, Analytics (future) | +| Provider Dependencies | External routing/fleet provider interfaces (planned); Storage refs for documents | +| AI Dependencies | Optional via contracts only (Phase 10.10) | +| Documentation | [delivery-phase-10-1.md](delivery-phase-10-1.md), [phase-handover/phase-10-1.md](phase-handover/phase-10-1.md), [delivery-phase-10-0.md](delivery-phase-10-0.md), [delivery-roadmap.md](delivery-roadmap.md), [phases/Delivery](phases/Delivery/README.md), [ADR-015](architecture/adr/ADR-015.md) | +| Current Phase | 10.1 complete (drivers); next `delivery-10.2` | +| Version | 0.10.1.0 | +| Version Compatibility | Requires Core entitlement; integrations per later phases | +| Migration Version | `0002_phase_101_drivers` | +| Tenant Aware | Yes (required) | +| Permission Tree | `delivery.*` | +| Future Plans | Phases 10.0–10.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | +| Commercial Product | Torbat Driver | + +### Modules (owned by delivery) + +| Module Key | Name | Status | Phase | +| --- | --- | --- | --- | +| `delivery.organizations` | Delivery Organizations | Active (shell) | 10.0 | +| `delivery.hubs` | Delivery Hubs | Active (shell) | 10.0 | +| `delivery.roles` | Delivery Roles | Active (shell) | 10.0 | +| `delivery.permissions_catalog` | Delivery Permissions Catalog | Active (shell) | 10.0 | +| `delivery.routing_engines` | Routing Engine Registrations | Active (shell) | 10.0 | +| `delivery.settings` | Settings | Active (shell) | 10.0 | +| `delivery.drivers` | Driver Management | Active | 10.1 | +| `delivery.fleet` | Fleet Management | Planned | 10.2 | +| `delivery.vehicle_types` | Vehicle Types | Planned | 10.2 | +| `delivery.vehicles` | Vehicles | Planned | 10.2 | +| `delivery.availability` | Availability | Planned | 10.3 | +| `delivery.shifts` | Shift Management | Planned | 10.3 | +| `delivery.working_zones` | Working Zones | Planned | 10.3 | +| `delivery.pricing` | Pricing | Planned | 10.4 | +| `delivery.capabilities` | Capabilities | Planned | 10.4 | +| `delivery.bundles` | Capability Bundles | Planned | 10.4 | +| `delivery.dispatch` | Dispatch Engine | Planned | 10.5 | +| `delivery.routing` | Routing | Planned | 10.6 | +| `delivery.optimization` | Optimization | Planned | 10.6 | +| `delivery.tracking` | Tracking | Planned | 10.7 | +| `delivery.proof_of_delivery` | Proof Of Delivery | Planned | 10.7 | +| `delivery.customer_tracking` | Customer Tracking | Planned | 10.7 / 10.9 | +| `delivery.settlement` | Settlement | Planned | 10.8 | +| `delivery.merchant_connector` | Merchant Connector | Planned | 10.9 | +| `delivery.notifications` | Notifications (Communication client) | Planned | 10.9 | +| `delivery.driver_app_apis` | Driver App APIs | Planned | 10.9 | +| `delivery.dispatcher_panel_apis` | Dispatcher Panel APIs | Planned | 10.9 | +| `delivery.fleet_analytics` | Fleet Analytics | Planned | 10.10 | +| `delivery.ai_hooks` | AI Ready Hooks | Planned | 10.10 | +| `delivery.external_providers` | External Providers Ready | Active (shell) | 10.0 | +| `delivery.audit` | Audit | Active | 10.0 | +| `delivery.configuration` | Configuration | Active (shell) | 10.0 | + +| Module | Responsibilities | Non-responsibilities | +| --- | --- | --- | +| Drivers | Courier/driver profiles and status | Identity user admin; Platform Member | +| Fleet / Vehicles | Fleet units and vehicle catalog | Procurement accounting | +| Dispatch | Job assignment engine | Owning vertical order aggregates | +| Routing | Route plans and multi stop | Owning map vendor credentials outside adapters | +| Tracking / POD | Journey + proof refs | Communication message delivery status | +| Settlement | Settlement intents | JournalEntry / Posting Engine | +| Merchant Connector | Job intake contracts | Restaurant menu / Marketplace catalog | +| Notifications | Call Communication APIs | SMS/email provider ownership | + +--- + +## experience + +| Field | Value | +| --- | --- | +| Name | Enterprise Experience Platform (Torbat Pages) | +| Description | Independent experience platform: sites, pages-as-resources, versioned components, themes, layouts, templates, localization, forms/surveys/appointments, SEO/PWA, bundles, widgets for all verticals | +| Owner | Platform | +| Status | Active (Phase 11.0 foundation complete) | +| Dependencies | Core entitlement; Storage / CRM / Loyalty / Communication / Hospitality / Marketplace / Sports / Delivery / AI / Automation via API/Events when phases require them | +| Internal Dependencies | Modules owned by this service only | +| External Dependencies | File Storage for media refs; notifications via Communication | +| Database Ownership | Sole owner | +| Database | `experience_db` | +| API Prefix | `/api/v1` (port 8008); `/health`, `/capabilities`, `/metrics` | +| Permission Prefix | `experience.*` | +| Events | `experience.workspace.*`, `experience.locale_profile.*`, `experience.external_provider.*`, `experience.render_engine.*`, `experience.configuration.*`, `experience.setting.*`, `experience.site.*`, `experience.page.*`, `experience.site_domain.*`, `experience.component.*`, `experience.component_version.*`, `experience.page_component.*`, `experience.theme.*`, `experience.layout.*`, `experience.site_theme.*`, `experience.page_layout.*` (+ planned template/…) | +| Event Producers | Experience | +| Event Consumers | Hospitality, Marketplace, Sports Center, CRM, Loyalty, Communication, Accounting, Clinic, Hotel, Tourism, Analytics (future) | +| Provider Dependencies | Optional theme/component marketplace adapters (planned) | +| AI Dependencies | Optional via contracts only (Phase 11.10) | +| Documentation | [experience-roadmap.md](experience-roadmap.md), [phases/Experience](phases/Experience/README.md), [experience-phase-11-3.md](experience-phase-11-3.md), [phase-handover/phase-11-3.md](phase-handover/phase-11-3.md), [ADR-016](architecture/adr/ADR-016.md) | +| Current Phase | 11.3 complete; next `experience-11.4` | +| Version | 0.11.3.0 | +| Version Compatibility | Requires Core entitlement; integrations per later phases | +| Migration Version | `0002_phase_111_sites_pages` | +| Tenant Aware | Yes (required) | +| Permission Tree | `experience.*` | +| Future Plans | Phases 11.0–11.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | +| Commercial Product | Torbat Pages | + +### Modules (owned by experience) + +| Module Key | Name | Status | Phase | +| --- | --- | --- | --- | +| `experience.workspaces` | Workspaces | Complete | 11.0 | +| `experience.locale_profiles` | Locale Profiles | Complete | 11.0 | +| `experience.external_providers` | External Providers | Complete | 11.0 | +| `experience.render_engines` | Render Engines | Complete | 11.0 | +| `experience.audit` | Audit | Complete | 11.0 | +| `experience.configuration` | Configuration | Complete | 11.0 | +| `experience.sites` | Sites | Complete | 11.1 | +| `experience.pages` | Page Resources | Complete | 11.1 | +| `experience.page_types` | Page Types | Complete | 11.1 | +| `experience.domains` | Site Domains | Complete | 11.1 | +| `experience.components` | Components | Complete | 11.2 | +| `experience.component_versions` | Component Versions | Complete | 11.2 | +| `experience.page_component_placements` | Page Component Placements | Complete | 11.2 | +| `experience.themes` | Themes | Complete | 11.3 | +| `experience.layouts` | Layouts | Complete | 11.3 | +| `experience.site_theme_assignments` | Site Theme Assignments | Complete | 11.3 | +| `experience.page_layout_assignments` | Page Layout Assignments | Complete | 11.3 | +| `experience.templates` | Templates | Planned | 11.4 | +| `experience.locales` | Localization | Planned | 11.5 | +| `experience.media_refs` | Media References | Planned | 11.5 | +| `experience.forms` | Forms | Planned | 11.6 | +| `experience.surveys` | Surveys | Planned | 11.6 | +| `experience.appointments` | Appointment Pages | Planned | 11.6 | +| `experience.publishing` | Publishing | Planned | 11.7 | +| `experience.seo` | SEO | Planned | 11.7 | +| `experience.pwa` | PWA Readiness | Planned | 11.7 | +| `experience.bundles` | Capability Bundles | Planned | 11.8 | +| `experience.feature_toggles` | Feature Toggles | Planned | 11.8 | +| `experience.widgets` | Widgets | Planned | 11.9 | +| `experience.consumer_connectors` | Consumer Connectors | Planned | 11.9 | +| `experience.analytics` | Analytics | Planned | 11.10 | +| `experience.ai_hooks` | AI Content Hooks | Planned | 11.10 | + +| Module | Responsibilities | Non-responsibilities | +| --- | --- | --- | +| Sites / Pages | Site and page resources + lifecycle | Vertical menu/product source of truth | +| Components | Versioned building blocks | Binary asset storage | +| Themes / Layouts | Replaceable themes; dynamic layouts | Core white-label tenant brand ownership | +| Templates | Starter packs and instantiation | Frontend builder UI | +| Forms / Surveys | Form/survey definitions + submission shells | Communication provider ownership | +| Publishing / SEO / PWA | Publish workflow + SEO/PWA shells | Nginx/DNS/SSL edge ownership | +| Bundles / Toggles | Commercial packs + feature flags | Core plan/subscription engine | +| Widgets / Connectors | Embed + vertical integration contracts | Owning CRM/Hospitality aggregates | +| Analytics / AI | Shells + optional AI contracts | Mandatory AI for publish | + +--- + +## restaurant + +| Field | Value | +| --- | --- | +| Name | Restaurant / Cafe (historical) | +| Description | Superseded by Hospitality Platform — see hospitality | +| Owner | Platform | +| Status | Superseded | +| Documentation | [phases/Restaurant](phases/Restaurant/README.md), [hospitality](#hospitality) | +| Current Phase | hospitality-12.0 | +| Version | 0.12.0.0 | +| Notes | Pointer only; runtime is `hospitality` | + +--- + +## hospitality + +| Field | Value | +| --- | --- | +| Name | Hospitality Platform | +| Description | Independent F&B platform (Torbat Food): venues, digital menu catalog, tables, bundle licensing, feature toggles; future QR ordering/POS/kitchen/reservation/connectors | +| Owner | Platform | +| Status | Active (Phase 12.1 Digital Menu Catalog) | +| Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication / Delivery / Experience via API/Events when phases require them | +| Internal Dependencies | None — independent of CRM / Loyalty / Delivery / Experience DBs | +| External Dependencies | Payment provider (future); Storage refs for media | +| Database Ownership | Sole owner | +| Database | `hospitality_db` | +| API Prefix | `/api/v1` (service on port 8009); `/health`, `/capabilities` | +| Permission Prefix | `hospitality.*` | +| Events | `hospitality.venue.*`, `hospitality.menu.*`, `hospitality.allergen.*`, `hospitality.modifier_*`, `hospitality.availability_window.*`, `hospitality.menu_media_ref.*`, `hospitality.menu_localization.*`, `hospitality.tenant_bundle.*`, `hospitality.feature_toggle.*`, … | +| Event Producers | Hospitality | +| Event Consumers | Accounting, CRM, Loyalty, Communication, Delivery, Experience (subscribed) | +| Provider Dependencies | Contracts only through 12.1 | +| AI Dependencies | Optional | +| Documentation | [hospitality-phase-12-1.md](hospitality-phase-12-1.md), [hospitality-phase-12-0.md](hospitality-phase-12-0.md), [hospitality-roadmap.md](hospitality-roadmap.md), ADR-017, service README | +| Current Phase | 12.1 complete | +| Version | 0.12.1.0 | +| Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED | +| Migration Version | `0002_phase_121_digital_menu_catalog` | +| Tenant Aware | Yes (required) | +| Permission Tree | `hospitality.*` | +| Commercial Product | Torbat Food | +| Future Plans | QR Menu & QR Ordering (12.2), Table Service, POS, Kitchen, connectors | + +--- + +## ecommerce + +| Field | Value | +| --- | --- | +| Name | Ecommerce | +| Description | Store builder, catalog, cart, orders, shipments | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core, file_storage (future) | +| Internal Dependencies | — | +| External Dependencies | Payment, shipping (future) | +| Database Ownership | Sole owner | +| Database | `ecommerce_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `ecommerce.*` | +| Events | `order.*`, `product.*` (planned) | +| Event Producers | Ecommerce | +| Event Consumers | Accounting, notification | +| Provider Dependencies | Payment, S3 | +| AI Dependencies | Optional | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `ecommerce.*` | +| Future Plans | Marketplace handoff | + +--- + +## marketplace + +| Field | Value | +| --- | --- | +| Name | Marketplace | +| Description | Multi-vendor market capabilities | +| Owner | TBD | +| Status | Planned | +| Dependencies | ecommerce, identity, accounting | +| Internal Dependencies | ecommerce | +| External Dependencies | Payment | +| Database Ownership | TBD (likely dedicated DB) | +| Database | TBD | +| API Prefix | TBD | +| Permission Prefix | `marketplace.*` | +| Events | TBD | +| Event Producers | TBD | +| Event Consumers | TBD | +| Provider Dependencies | Payment | +| AI Dependencies | Optional | +| Documentation | [phases/Marketplace](phases/Marketplace/README.md) | +| Current Phase | Future | +| Version | 0.0.0 | +| Version Compatibility | TBD | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `marketplace.*` | +| Future Plans | After ecommerce foundation | + +--- + +## website_builder + +| Field | Value | +| --- | --- | +| Name | Website Builder | +| Description | Historical scaffold for sites/pages/blocks — prefer Enterprise Experience Platform (`experience` / Torbat Pages) for new work (ADR-016) | +| Owner | TBD | +| Status | Scaffolded (deferred; prefer `experience`) | +| Dependencies | file_storage | +| Internal Dependencies | — | +| External Dependencies | S3 | +| Database Ownership | Sole owner (historical scaffold) | +| Database | `website_builder_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `website_builder.*` | +| Events | TBD | +| Event Producers | Website Builder | +| Event Consumers | — | +| Provider Dependencies | S3 | +| AI Dependencies | Optional copy assist | +| Documentation | service README; [ADR-016](architecture/adr/ADR-016.md) | +| Current Phase | Not started — prefer Experience Phases 11.0+ | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `website_builder.*` | +| Future Plans | Do not expand; migrate capability intent to `experience` | + +--- + +## live_chat + +| Field | Value | +| --- | --- | +| Name | Live Chat | +| Description | Embeddable widget, conversations, agent routing | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core, optional CRM/AI | +| Internal Dependencies | — | +| External Dependencies | — | +| Database Ownership | Sole owner | +| Database | `live_chat_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `live_chat.*` | +| Events | `conversation.*` (planned) | +| Event Producers | Live Chat | +| Event Consumers | CRM, AI | +| Provider Dependencies | — | +| AI Dependencies | Optional handoff | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `live_chat.*` | +| Future Plans | Widget on tenant sites | + +--- + +## ai_assistant + +| Field | Value | +| --- | --- | +| Name | AI Assistant | +| Description | Intelligent chat, KB, handoff to humans | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core entitlement, model provider | +| Internal Dependencies | Independent — must not own money/compliance | +| External Dependencies | AI model provider | +| Database Ownership | Sole owner | +| Database | `ai_assistant_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `ai_assistant.*` | +| Events | `assistant.*` (planned) | +| Event Producers | AI Assistant | +| Event Consumers | Live Chat, CRM | +| Provider Dependencies | AI model provider | +| AI Dependencies | Self | +| Documentation | [ai-architecture.md](architecture/ai-architecture.md), [phases/AI](phases/AI/README.md) | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `ai_assistant.*` | +| Future Plans | Provider-agnostic adapters | + +--- + +## smart_messenger + +| Field | Value | +| --- | --- | +| Name | Smart Messenger | +| Description | Social channels, unified inbox, automations | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core | +| Internal Dependencies | Optional CRM | +| External Dependencies | Social network APIs | +| Database Ownership | Sole owner | +| Database | `smart_messenger_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `smart_messenger.*` | +| Events | TBD | +| Event Producers | Smart Messenger | +| Event Consumers | CRM | +| Provider Dependencies | Channel providers | +| AI Dependencies | Optional | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `smart_messenger.*` | +| Future Plans | Channel provider registry entries | + +--- + +## sms_panel + +| Field | Value | +| --- | --- | +| Name | SMS Panel | +| Description | Campaign SMS, templates, queues, multi-provider | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core | +| Internal Dependencies | — | +| External Dependencies | SMS providers | +| Database Ownership | Sole owner | +| Database | `sms_panel_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `sms_panel.*` | +| Events | TBD | +| Event Producers | SMS Panel | +| Event Consumers | — | +| Provider Dependencies | Payamak and others | +| AI Dependencies | None | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `sms_panel.*` | +| Future Plans | Separate from Core OTP path | + +--- + +## link_shortener + +| Field | Value | +| --- | --- | +| Name | Link Shortener | +| Description | Short links, custom domains, click analytics | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core | +| Internal Dependencies | — | +| External Dependencies | — | +| Database Ownership | Sole owner | +| Database | `link_shortener_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `link_shortener.*` | +| Events | TBD | +| Event Producers | Link Shortener | +| Event Consumers | Campaign analytics | +| Provider Dependencies | — | +| AI Dependencies | None | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `link_shortener.*` | +| Future Plans | Custom domain SSL via edge | + +--- + +## notification + +| Field | Value | +| --- | --- | +| Name | Notification | +| Description | Email, SMS, push, webhook, in-app fanout | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core | +| Internal Dependencies | — | +| External Dependencies | Email/SMS/push providers | +| Database Ownership | Sole owner | +| Database | `notification_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `notification.*` | +| Events | Consumes many domain events | +| Event Producers | Notification (delivery logs) | +| Event Consumers | Notification | +| Provider Dependencies | SMS/email providers | +| AI Dependencies | None | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `notification.*` | +| Future Plans | Central delivery for all modules | + +--- + +## file_storage + +| Field | Value | +| --- | --- | +| Name | File Storage | +| Description | Tenant-aware uploads, assets, S3 gateway | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | S3-compatible provider | +| Internal Dependencies | — | +| External Dependencies | S3 | +| Database Ownership | Sole owner | +| Database | `file_storage_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `file_storage.*` | +| Events | TBD | +| Event Producers | File Storage | +| Event Consumers | website_builder, ecommerce | +| Provider Dependencies | S3-compatible | +| AI Dependencies | None | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `file_storage.*` | +| Future Plans | Signed URL pattern | + +--- + +## automation (platform capability) + +| Field | Value | +| --- | --- | +| Name | Automation | +| Description | Cross-module workflow automation | +| Owner | TBD | +| Status | Planned | +| Dependencies | Event bus maturity | +| Internal Dependencies | Multiple modules | +| External Dependencies | — | +| Database Ownership | TBD | +| Database | TBD | +| API Prefix | TBD | +| Permission Prefix | `automation.*` | +| Events | Trigger/action events | +| Event Producers | Automation engine | +| Event Consumers | Modules | +| Provider Dependencies | — | +| AI Dependencies | Optional | +| Documentation | [phases/Automation](phases/Automation/README.md) | +| Current Phase | Future | +| Version | 0.0.0 | +| Version Compatibility | Requires real message bus | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `automation.*` | +| Future Plans | After event bus | + +--- + +## Related Documents + +- [Provider Registry](provider-registry.md) +- [Roadmap](roadmap.md) +- [Architecture Overview](architecture/architecture.md) +- [AI / Enterprise Development Framework](ai-framework/README.md) +- [Phase Manifest](ai-framework/phase-manifest.yaml) +- [Service Manifest](ai-framework/service-manifest.yaml) +- [ADR-013](architecture/adr/ADR-013.md) +- [ADR-018](architecture/adr/ADR-018.md) +- [ADR-019](architecture/adr/ADR-019.md) +- [Sports Center Roadmap](sports-center-roadmap.md) +- [ADR-014](architecture/adr/ADR-014.md) +- [Delivery Roadmap](delivery-roadmap.md) +- [ADR-015](architecture/adr/ADR-015.md) +- [Experience Roadmap](experience-roadmap.md) +- [ADR-016](architecture/adr/ADR-016.md) diff --git a/docs-runtime/next-steps.md b/docs-runtime/next-steps.md new file mode 100644 index 0000000..1042ef3 --- /dev/null +++ b/docs-runtime/next-steps.md @@ -0,0 +1,46 @@ +# Next Steps + +> Immediate next milestone only. History → [progress.md](progress.md). Future map → [roadmap.md](roadmap.md). + +## Current Milestone: Phase 11.4 — Experience Template System + +Follow the permanent [Enterprise / AI Development Framework](ai-framework/README.md) ([enterprise-phase-discovery.md](ai-framework/enterprise-phase-discovery.md), [definition-of-done.md](ai-framework/definition-of-done.md), [cursor-guidelines.md](ai-framework/cursor-guidelines.md), [development-loop.md](ai-framework/development-loop.md), [quality-gates.md](ai-framework/quality-gates.md)). Production readiness is the default — CRUD is never sufficient. Discovery runs automatically before Implementation. + +### Why + +Phase 11.3 Theme & Layout Engine is complete (replaceable themes, dynamic layouts, directionality shells, assignments). + +Mandatory entry: [phase-handover/phase-11-3.md](phase-handover/phase-11-3.md). + +### Scope + +1. Template catalog, instantiation from templates, page-type starter packs +2. Permissions, events, APIs on top of themes/layouts from 11.3 +3. Do **not** implement forms/surveys engine yet + +### Exit Criteria + +- [ ] Template APIs + validators + tests green +- [ ] Docs: `docs/experience-phase-11-4.md` + progress/registry + handover +- [ ] Quality gates + phase handover per AI framework + +### After This Milestone + +Phase 11.5 — Localization, RTL/LTR & Media. + +### Other open tracks (not this milestone) + +- Hospitality later 12.x +- Delivery 10.2+ (Fleet & Vehicle Types) — Phase 10.1 Driver Management complete +- Loyalty 7.2 Point Engine +- Sports Center 9.3 Coach & Staff Management + +## Related Documents + +- [Progress](progress.md) +- [Phase Handover 11.3](phase-handover/phase-11-3.md) +- [Experience Phase 11.3](experience-phase-11-3.md) +- [Experience Roadmap](experience-roadmap.md) +- [ADR-016](architecture/adr/ADR-016.md) +- [Phases / Experience](phases/Experience/README.md) +- [AI Development Framework](ai-framework/README.md) diff --git a/docs-runtime/progress.md b/docs-runtime/progress.md new file mode 100644 index 0000000..c95912c --- /dev/null +++ b/docs-runtime/progress.md @@ -0,0 +1,555 @@ +# Progress + +> Completed work only. Future → [roadmap.md](roadmap.md). Next milestone → [next-steps.md](next-steps.md). + +## Phase 1 — Core Platform ✅ + +### Infrastructure & structure +- [x] Full project structure with mandatory `backend/` / `frontend/` separation +- [x] Shared library `backend/shared-lib` +- [x] Architecture section: Frontend & Backend Separation + +### Core service +- [x] Config, database, cache, logging, base security +- [x] Models, APIs, Entitlement, Celery, Outbox +- [x] Tests and documentation + +### Frontend +- [x] Independent Next.js, API client, white-label theme baseline + +--- + +## Phase 2 — Identity & Access + SSO ✅ + +- [x] `shared/auth/jwt.py`, `shared/auth/roles.py`, Identity events in `shared/events.py` +- [x] Identity service + `identity_access_db` +- [x] Keycloak realm/clients/theme import +- [x] Core API protection (`AUTH_REQUIRED`, role deps) +- [x] Frontend SSO flow (`/login`, `/auth/callback`, `/dashboard`) +- [x] Compose services Identity + Frontend; Postgres init for identity DB + +--- + +## Phase 3 — OTP Login + Tenant Management ✅ + +- [x] Core `users` + OTP request/verify + Payamak +- [x] Local JWT (HS256) for OTP users +- [x] Admin tenant CRUD + owner filtering +- [x] Frontend admin OTP/tenant UI (later aligned to central SSO) +- [x] `test_otp_auth.py` + +--- + +## Phase 4 — Tenant Onboarding & Workspace Activation ✅ + +> Brief alias: sometimes called “Phase 3” externally — see [phase-numbering decision](decisions/technical/phase-numbering.md). + +- [x] Core `tenant_memberships`, tenant lifecycle, branding columns, domain primary/verification fields +- [x] `users.current_tenant_id`, FREE/STARTER plan seed, onboarding APIs +- [x] `UserService` / `MembershipService` / `OnboardingService` / `TenantContextService` +- [x] Frontend onboarding wizard, tenant dashboard, tenant switcher +- [x] `test_onboarding.py` + regression suite +- [x] JIT Core user from Keycloak JWT (`UserService.resolve_current`) +- [x] Automatic tenant subdomain SSL (Celery → SSH → `provision_ssl.py`) +- [x] Reverse proxy (Nginx) + base TLS + tenant SSL automation + +### Intentionally incomplete after Phase 4 (tracked in roadmap, not claimed done) +Payment gateway · real DNS/TXT custom-domain verify · legacy `POST /admin/tenants` auto membership/plan · first business module + +--- + +## Phase D — Documentation Architecture Consolidation ✅ + +- [x] Target `docs/` tree (architecture, adr, development, reference, deployment, templates, phases, decisions) +- [x] Moved canonical docs with git history where possible +- [x] Deprecated stubs for old paths +- [x] ADR system initialized (ADR-001 … ADR-010) +- [x] Project principles, coding standards, testing/branching/release strategies +- [x] Module registry + provider registry + glossary +- [x] Deployment runbooks (production, SSL, monitoring, backup, restore, DR) +- [x] Roadmap / next-steps separation from progress +- [x] Phase area docs for Accounting, CRM, Restaurant, Marketplace, Automation, AI, Future +- [x] Cross-links updated in root and package READMEs + +--- + +## Phase 5.1–5.11 — Accounting Module ✅ + +- [x] Phases 5.1–5.6: Foundation through Sales Integration (see prior entry) +- [x] Phase 5.7: Purchase & Inventory accounting, InventoryValuationEngine +- [x] Phase 5.8: Fixed Assets, DepreciationEngine, asset lifecycle +- [x] Phase 5.9: HCM/Payroll, PayrollEngine, payroll accounting +- [x] Phase 5.10: Financial Reporting Engine, dashboards, export framework +- [x] Phase 5.11: Compliance, Audit Framework, Governance, SoD +- [x] Alembic migration `0002_phases_57_511` +- [x] 21 tests passing + +--- + +## Accounting Enterprise Frontend (in SuperApp) ✅ baseline + +- [x] Design system tokens (light/dark) + DS components (`components/ds`) +- [x] TanStack Query providers, Sonner toasts, ColorMode +- [x] Typed `accounting-api` client (JWT + `X-Tenant-ID`, no mocks) +- [x] Accounting shell + routes: dashboard, COA, fiscal, currencies, cost centers, projects, ledger, setup, suppliers +- [x] Vouchers list/create/detail with validate/post/reverse/cancel + draft edit (ADR-010) +- [x] Foundation masters: edit/archive drawers, fiscal year/period actions, COA template import wizard +- [x] Setup checklist + completion scoreboard on dashboard +- [x] Treasury, customers, assets, payroll, reports, audit, settings pages (real APIs) +- [x] Catalog tile available → `/accounting` +- [x] Frontend docs under `docs/frontend/` + +Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export file delivery. + +--- + +## Accounting Frontend Integration (FE ↔ BE 5.1–5.11) ✅ + +- [x] Full route/menu scan vs `/api/v1` routers; gap matrix executed +- [x] Domain rewires: payroll employees, compliance policies/risks, settlements, sales-accounting post, purchase GR + valuation, asset schedule, monitoring health +- [x] FE client: settlements, salesAccounting, purchaseInventory, compliance approvals/risks, ops PATCH +- [x] Ops documents: detail + edit; treasury receipt/payment cash/bank binding; voucher list pagination +- [x] BE list endpoints for settlements, risks, purchase posting-profiles (integration completeness) +- [x] Report: [accounting-frontend-integration-report.md](accounting-frontend-integration-report.md) +- [x] Scoreboard honesty for partial/ops-backed modules; AI remains blocked + +--- + +## Accounting Frontend Completion Loop (business workflows) ✅ + +- [x] Full sidebar/submenu audit (118 routes); generic BusinessDocs stubs replaced +- [x] Thin BE APIs on existing models: asset transfer/dispose/revaluation; payroll contracts/components/payrolls; compliance workflows/approvals list/SoD/controls/violations +- [x] Domain screens: `WorkflowScreens.tsx` + typed `SpecializedOpsPage` for budget/integration/documents/settings +- [x] Money thousand-separators + party/item AJAX across operational forms +- [x] Audit report: [audits/accounting-frontend-completion-report.md](audits/accounting-frontend-completion-report.md) +- [x] Menu QA tracker: [frontend/accounting-menu-qa.md](frontend/accounting-menu-qa.md) + +Remaining (honest): Phase 5.12 AI blocked; budget/DMS/integration still typed-ops interim until dedicated engines exist. + +--- + +## Phase 6.0 — CRM Service Foundation ✅ + +- [x] `backend/services/crm` service scaffold (API / services / repositories / models) +- [x] Base aggregates: Lead, Contact, Organization, Opportunity, Pipeline, PipelineStage, SalesActivity, Quote +- [x] Publish-only event contracts (`crm.lead.*`, `crm.opportunity.*`, …) +- [x] CRM APIs only (`/api/v1/leads|contacts|organizations|pipelines|opportunities|activities|quotes`) +- [x] Platform provider contracts only (Automation, Customer360, Notification, Analytics, AI, Communication, Helpdesk) +- [x] Permissions `crm.*` trees; tenant isolation via `X-Tenant-ID` +- [x] Alembic `0001_initial` + `crm_db` wiring (compose port 8003) +- [x] Architecture / dependency / repository / migration / API / permission / tenant / docs tests +- [x] Docs: [crm-phase-6-0.md](crm-phase-6-0.md), module registry, phase area README + +--- + +## Phase 6.1 — Enterprise CRM Core Business Entities ✅ + +- [x] Expanded Lead / Contact / Organization (numbers, multi-contact, soft delete, audit actors) +- [x] Lookups: lead_sources, lead_statuses, contact_types, organization_types, organization_industries +- [x] Lead assignments, tag engine, reusable addresses, custom fields + values +- [x] Versioned notes, attachment file-storage references (no binary), CRM audit log +- [x] Services / repositories / validators / permissions / events for Phase 6.1 +- [x] Alembic `0002_phase_61_entities` +- [x] API coverage under `/api/v1` for entities + lookups +- [x] Tests: model, repository, service, API, permission, migration, validation, audit, tenant, docs +- [x] Docs: [crm-phase-6-1.md](crm-phase-6-1.md) + +--- + +## Phase 6.2 — Enterprise Sales Process Engine ✅ + +- [x] Opportunity engine (number, title, revenue, probability, priority, close dates, soft delete, actors) +- [x] Unlimited pipelines + pipeline stages (start/won/lost/closed flags, required fields, exit validation) +- [x] Stage history, opportunity products (product_ref only), contact roles, competitors +- [x] Sales playbooks (versioned) + steps + assignments + completion tracking +- [x] Win/loss categories, reasons, immutable opportunity_win_loss snapshot +- [x] Rule-based forecasts (pipeline / weighted / committed; monthly/quarterly/annual) +- [x] Sales goals + targets with achievement % +- [x] Sales process definitions + sales_audit trail +- [x] Services / validators / events / permissions (`crm.pipelines|playbooks|forecasts|goals|targets.*`) +- [x] Alembic `0003_phase_62_sales_engine`; version `0.6.2.0` +- [x] API coverage for sales engine under `/api/v1` +- [x] Tests: pipeline, stage, playbook, forecast, win/loss, business rules, migration, permission, tenant, docs +- [x] Docs: [crm-phase-6-2.md](crm-phase-6-2.md) + +--- + +## Phase 6.3 — Enterprise Sales Collaboration Platform ✅ + +- [x] Expanded sales activities (types, priority, CRM entity links, participants, reminders) +- [x] Tasks + checklists + completion tracking +- [x] Meetings + attendees + finish/outcome +- [x] Call logs (opportunity-linked) + call outcomes +- [x] Email threads/messages metadata only (no delivery) +- [x] Immutable sales timeline + timeline event links +- [x] Threaded/versioned comments, mentions, bookmarks, favorites +- [x] Sales team roles/members/assignments + notification preferences +- [x] Collaboration audit; events `crm.task|meeting|call|timeline|comment|mention.*` +- [x] Permissions `crm.tasks|meetings|calls|timeline|comments|mentions|team.*` +- [x] Alembic `0004_phase_63_collaboration`; version `0.6.3.0` +- [x] Tests + docs: [crm-phase-6-3.md](crm-phase-6-3.md) + +### CRM Core Platform + +- [x] Phases 6.0–6.3 complete (Foundation, Master Data, Sales Engine, Collaboration) + +--- + +## Phase 7.0 — Loyalty Service Foundation ✅ + +- [x] `backend/services/loyalty` service scaffold (API / services / repositories / models) +- [x] Foundation aggregates: LoyaltyProgram, MembershipTier, Member, PointAccount, Reward, Campaign, LoyaltyAuditLog, OutboxEvent +- [x] No mutable balance on PointAccount — direct balance mutation forbidden (`extra=forbid`) +- [x] Optimistic locking on Program / Member / PointAccount; campaign `rule_version` +- [x] Publish-only events via transactional outbox (`loyalty.program|tier|member|point_account|reward|campaign.*`) +- [x] Loyalty APIs only (`/api/v1/programs|tiers|members|point-accounts|rewards|campaigns|audit`) +- [x] Platform provider contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage) +- [x] Permissions `loyalty.*` trees with manage/view inheritance; tenant isolation via `X-Tenant-ID` +- [x] Alembic `0001_initial` + `loyalty_db` wiring (compose port 8004) +- [x] Soft-delete unique reclaim; IntegrityError → 409; null required-field → 422; production config guards +- [x] Enterprise validation & self-heal (incl. removal of incomplete 7.1 residue) — **52 tests passed** +- [x] Docs: [loyalty-phase-7-0.md](loyalty-phase-7-0.md), [loyalty-phase-7-0-audit.md](loyalty-phase-7-0-audit.md), ADR-011, module registry, phase area README + +## Phase 7.1 — Membership Engine ✅ + +- [x] Lifecycle state machine (activate / renew / freeze / resume / cancel / expire / transfer) +- [x] Member lifecycle fields + `MembershipLifecycleEvent` append-only history +- [x] Program soft-delete cascade policy: reject when blocking members exist +- [x] Permissions `loyalty.members.{activate,renew,freeze,resume,cancel,expire,transfer,lifecycle.view}` +- [x] Events `loyalty.member.{activated,renewed,frozen,resumed,cancelled,expired,transferred}` +- [x] Alembic `0002_phase_71_membership`; version `0.7.1.0` +- [x] Tests green (59) including `test_phase71.py` +- [x] Docs: [loyalty-phase-7-1.md](loyalty-phase-7-1.md), [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md), manifests, registry + +--- + +## Phase 8.0–8.10 — Enterprise Communication Platform ✅ + +- [x] Independent `communication-service` / `communication_db` (port 8005) — not owned by CRM/Loyalty/Restaurant +- [x] Provider framework + registry (mock, payamak; stubs for email/push/whatsapp/telegram/rubika/voice) +- [x] Communication router with priority, automatic failover, retry, circuit breaker +- [x] SMS platform: sender numbers, balance, rate limits, provider logs +- [x] Template engine: variables, locale, versioning, approval, preview, rendering +- [x] Dynamic contact sources (manual, CSV, CRM/Loyalty/Restaurant/Marketplace/Accounting/Website/External REST) — resolve only, no duplication +- [x] Queue engine: async queue, scheduling, priority, backoff retry, DLQ, batch +- [x] Delivery tracking: queued/sent/delivered/failed/expired/cancelled + timeline +- [x] OTP platform: generate/verify/expire, rate limit, brute-force lock, multi-channel path +- [x] Incoming provider webhooks with signature verification +- [x] Health `/health`, Capability `/capabilities`, providers status, monitoring stats +- [x] Permissions `communication.*`; events `communication.*`; audit on every send +- [x] Alembic `0001_initial`; version `0.8.10.0` +- [x] Tests: architecture, health/security, providers/SMS/failover/queue, templates/contacts/OTP/webhooks, validators +- [x] Docs: [communication-phase-8.md](communication-phase-8.md), ADR-012, module/provider registry, progress + +--- + +## Phase AF — AI Development Framework ✅ + +> Documentation-only. No business functionality. No modifications to existing services, schemas, or APIs. + +- [x] Permanent area [`docs/ai-framework/`](ai-framework/README.md) +- [x] Master prompt, development loop, phase handover, quality gates +- [x] Templates: phase, service, module, entity, repository, service-layer, API, event, testing, documentation +- [x] Manifests: [phase-manifest.yaml](ai-framework/phase-manifest.yaml), [service-manifest.yaml](ai-framework/service-manifest.yaml) +- [x] Prompt rules + Cursor guidelines +- [x] Docs index / module registry / progress integration +- [x] ADR-013 Accepted +- [x] Architecture, documentation, cross-reference, link, template, and manifest validation + +--- + +## Phase AF-Enterprise — Enterprise Development Framework Upgrade ✅ + +> Documentation-only. Upgrades the AI Development Framework into an Enterprise Development Framework. No business feature code. No changes to completed business phases or implementations. + +- [x] [Definition of Done](ai-framework/definition-of-done.md) +- [x] [Mandatory Phase Artifacts](ai-framework/mandatory-phase-artifacts.md) +- [x] [Enterprise Completeness](ai-framework/enterprise-completeness.md) +- [x] [Boundary Rules](ai-framework/boundary-rules.md) +- [x] Master prompt, development loop, quality gates, phase/handover/testing/documentation templates upgraded +- [x] Layer templates (service, module, entity, repository, service-layer, API, event) upgraded +- [x] Prompt rules + Cursor guidelines upgraded +- [x] Project principles, testing strategy, docs index, glossary updated +- [x] [ADR-018](architecture/adr/ADR-018.md) Accepted (extends ADR-013) +- [x] Phase registered: `ai-framework-enterprise` in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Handover: [phase-handover/phase-af-enterprise.md](phase-handover/phase-af-enterprise.md) +- [x] Backward-compatible path `docs/ai-framework/` retained; no business services modified + +--- + +## Phase AF-Discovery — Enterprise Phase Discovery Mandate ✅ + +> Documentation-only. Adds mandatory Enterprise Phase Discovery before every phase. No business feature code. No changes to completed business phases or implementations. + +- [x] [Enterprise Phase Discovery](ai-framework/enterprise-phase-discovery.md) +- [x] Development loop stage 1 = Discovery; quality gates include Discovery +- [x] Phase template, handover, DoD, completeness, mandatory artifacts, prompt/cursor rules updated +- [x] Master prompt + project principles updated +- [x] [ADR-019](architecture/adr/ADR-019.md) Accepted (extends ADR-018) +- [x] Phase registered: `ai-framework-discovery` in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Handover: [phase-handover/phase-af-discovery.md](phase-handover/phase-af-discovery.md) +- [x] No business services modified + +--- + +## Phase SC-Reg — Sports Center Platform Registration ✅ + +> Documentation-only registration. No business code, models, APIs, migrations, or repositories. + +- [x] Phase roadmap 9.0–9.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml) +- [x] Planned modules registered in [module-registry.md](module-registry.md#sports_center) +- [x] Sports terminology added to [glossary.md](glossary.md#sports-center) +- [x] Roadmap doc: [sports-center-roadmap.md](sports-center-roadmap.md) +- [x] ADR-014 Accepted — Independent Sports Center Platform +- [x] Module boundaries + phase area README updated +- [x] Manifest / cross-reference / documentation / module validation passed + +--- + +## Phase 9.0 — Sports Center Platform Foundation ✅ + +- [x] `backend/services/sports_center` service scaffold (API / services / repositories / models) +- [x] Foundation aggregates: SportsCenter, Branch, Sport, MembershipType, Membership, Coach, SportsRole, SportsPermission, Facility, Court, Room, LockerRoom, Locker, DeviceProvider, Device, AttendanceGateway, SportsConfiguration, SportsEvent, SportsSetting, SportsAuditLog +- [x] Adapter-based connector framework (QR/RFID/Barcode/Fingerprint/Face/Turnstile/Door/Payment/Attendance) — interfaces only +- [x] Platform provider contracts only (Accounting, CRM, Loyalty, Communication, Notification, Storage, AI, Identity, Customer360) +- [x] Publish-only events (`sports_center.member|membership|coach|facility|device|attendance|locker.*`) +- [x] Sports APIs under `/api/v1` + device connect/disconnect + locker assign/release +- [x] Permissions `sports_center.*` trees; tenant isolation via `X-Tenant-ID` +- [x] Alembic `0001_initial` + `sports_center_db` wiring (compose port 8006) +- [x] Architecture / API / permissions / migration / dependency / connectors / docs tests +- [x] Docs: [sports-center-phase-9-0.md](sports-center-phase-9-0.md), [phase-handover/phase-9-0.md](phase-handover/phase-9-0.md), ADR-014, module registry +- [x] No business workflow engines (booking/attendance lifecycle deferred) + +--- + +## Phase 9.1 — Sports Center Membership Catalog ✅ + +- [x] Catalog aggregates: SportCategory, AgeGroup, PricingModel, MembershipPackage, MembershipPlan, MembershipRule, RenewalPolicy, FreezingRule, ExpirationPolicy +- [x] MembershipType extended with catalog refs (package/plan/pricing/age/category/policies), transferability, sort order +- [x] Validators for age range, pricing/billing/rule kinds, money amounts, catalog ref integrity +- [x] Publish-only catalog events; permissions trees for catalog modules +- [x] APIs under `/api/v1/sport-categories|age-groups|pricing-models|membership-packages|membership-plans|membership-rules|renewal-policies|freezing-rules|expiration-policies` +- [x] `/capabilities` endpoint; version `0.9.1.0` +- [x] Alembic `0002_phase_91_membership_catalog` +- [x] Tests: catalog API, tenant isolation, age validation, architecture, docs +- [x] Docs: [sports-center-phase-9-1.md](sports-center-phase-9-1.md), [phase-handover/phase-9-1.md](phase-handover/phase-9-1.md) +- [x] No member enrollment / coach / attendance / booking engines + +--- + +## Phase 9.2 — Sports Center Member Management ✅ + +- [x] Members aggregate + family members, emergency contacts, medical information shells +- [x] Membership assignment (`member_id`) consuming Phase 9.1 catalog types +- [x] Status management: activate / suspend / cancel / freeze / unfreeze +- [x] Membership cards (QR/physical/digital), digital membership passes, waivers (sign), member documents (Storage refs) +- [x] Validators, permissions (`sports_center.members.*` trees), publish-only events +- [x] Alembic `0003_phase_92_member_management`; version `0.9.2.0` +- [x] Tests: members API, tenant isolation, transitions, architecture, migration, docs +- [x] Docs: [sports-center-phase-9-2.md](sports-center-phase-9-2.md), [phase-handover/phase-9-2.md](phase-handover/phase-9-2.md) +- [x] No coach / attendance / booking engines + +--- + + +## Phase DP-Reg — Delivery & Fleet Platform Registration ✅ + +> Documentation-only registration. No business code, models, APIs, migrations, or repositories. + +- [x] Phase roadmap 10.0–10.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml) (`delivery`, `delivery_db`, port 8007) +- [x] Planned modules registered in [module-registry.md](module-registry.md#delivery) +- [x] Delivery terminology added to [glossary.md](glossary.md#delivery--fleet-platform) +- [x] Roadmap doc: [delivery-roadmap.md](delivery-roadmap.md) +- [x] ADR-015 Accepted — Independent Delivery & Fleet Platform +- [x] Module boundaries + phase area README updated +- [x] Handover: [phase-handover/phase-dp-reg.md](phase-handover/phase-dp-reg.md) +- [x] Manifest / cross-reference / documentation / module validation passed + +--- + +## Phase XP-Reg — Experience Platform Registration ✅ + +> Documentation-only registration. No business code, models, APIs, migrations, or repositories. + +- [x] Phase roadmap 11.0–11.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml) (`experience`, `experience_db`, port 8008) +- [x] Planned modules registered in [module-registry.md](module-registry.md#experience) +- [x] Experience terminology added to [glossary.md](glossary.md#experience-platform) +- [x] Roadmap doc: [experience-roadmap.md](experience-roadmap.md) +- [x] Phase doc: [experience-phase-xp-reg.md](experience-phase-xp-reg.md) +- [x] ADR-016 Accepted — Independent Enterprise Experience Platform (Torbat Pages) +- [x] Module boundaries + database architecture + schema notes + phase area README updated +- [x] Historical `website_builder` marked prefer-`experience` (ADR-016) +- [x] Handover: [phase-handover/phase-xp-reg.md](phase-handover/phase-xp-reg.md) +- [x] Manifest / cross-reference / documentation / module validation passed + +--- + +## Phase 11.0 — Experience Platform Foundation ✅ + +- [x] Independent `backend/services/experience` (`experience_db`, port **8008**, version **0.11.0.0**, commercial product **Torbat Pages**) +- [x] Foundation aggregates: Workspace, LocaleProfile, ExternalProviderConfig, RenderEngineRegistration, Configuration, Setting, Role/Permission shells, AuditLog +- [x] `GET /health`, `/capabilities`, `/metrics`; permissions `experience.*`; publish-only events +- [x] Provider contracts only (Storage, CRM, Hospitality, Marketplace, Sports, Delivery, Communication, Loyalty, Accounting, AI, Automation, …) +- [x] Alembic `0001_initial`; compose + `.env.example` + `init-dbs.sql` wiring +- [x] Tests: architecture, API, tenant isolation, permissions, migration, dependency, security, docs +- [x] Docs: [experience-phase-11-0.md](experience-phase-11-0.md), [phase-handover/phase-11-0.md](phase-handover/phase-11-0.md) +- [x] No sites/pages/page-builder/theme/template/CMS/forms engines + +--- + +## Phase 11.1 — Experience Sites & Page Resources ✅ + +- [x] Sites under workspaces (multiple sites); draft/publish/archive lifecycle +- [x] Pages-as-resources with page types (landing, menu, bio, portfolio, QR, blog, …) +- [x] Site domain/subdomain binding refs (`experience_site_domains`) +- [x] Permissions `experience.sites.*` / `experience.pages.*` / `experience.domains.*` +- [x] Events `experience.site.*` / `experience.page.*` / `experience.site_domain.*` +- [x] Alembic `0002_phase_111_sites_pages`; version `0.11.1.0` +- [x] Tests: sites API, tenant isolation, publish/archive rules, architecture, migration, docs +- [x] Docs: [experience-phase-11-1.md](experience-phase-11-1.md), [phase-handover/phase-11-1.md](phase-handover/phase-11-1.md) +- [x] No page builder / theme / template engines + +--- + +## Phase 11.2 — Experience Component Library & Versioning ✅ + +- [x] Versioned component catalog (`ExperienceComponent`) with kinds + lifecycle +- [x] Immutable published component versions (`ExperienceComponentVersion`) +- [x] Page composition shells (`PageComponentPlacement` slot pins) +- [x] Policies, specifications, commands, queries; list filter/sort/search +- [x] Permissions `experience.components.*`; events `experience.component*` / `experience.page_component.*` +- [x] Alembic `0003_phase_112_components`; version `0.11.2.0` +- [x] Tests: policies, API flow, immutability, tenant isolation, architecture, migration, docs +- [x] Docs: [experience-phase-11-2.md](experience-phase-11-2.md), [phase-handover/phase-11-2.md](phase-handover/phase-11-2.md) +- [x] No theme / layout engines + +--- + +## Phase 11.3 — Experience Theme & Layout Engine ✅ + +- [x] Replaceable theme packs (`ExperienceTheme`) with tokens + directionality +- [x] Dynamic layouts (`ExperienceLayout`) with regions/slots shells +- [x] Site theme + page layout assignments (swap without rewriting pages) +- [x] Policies, specifications, commands, queries; list filter/sort/search +- [x] Permissions `experience.themes.*` / `experience.layouts.*` +- [x] Events `experience.theme.*` / `experience.layout.*` / `experience.site_theme.*` / `experience.page_layout.*` +- [x] Alembic `0004_phase_113_themes_layouts`; version `0.11.3.0` +- [x] Tests: policies, API flow, tenant isolation, architecture, migration, docs +- [x] Docs: [experience-phase-11-3.md](experience-phase-11-3.md), [phase-handover/phase-11-3.md](phase-handover/phase-11-3.md) +- [x] No template catalog engine + +--- + +## Phase 12.0 — Hospitality Platform Foundation ✅ + +- [x] Independent `backend/services/hospitality` (`hospitality_db`, port **8009**, version **0.12.0.0**, commercial product **Torbat Food**) +- [x] Foundation aggregates: Venue, Branch, DiningArea, DiningTable, Menu, MenuCategory, MenuItem, BundleDefinition, TenantBundle, FeatureToggle, roles/permissions, configuration/settings/events/audit +- [x] Feature-based architecture: bundle licensing + feature toggles + `/capabilities` discovery +- [x] Venue formats catalog (cafe → catering) without hardcoded engines +- [x] Publish-only events `hospitality.*`; permissions `hospitality.*` +- [x] Provider contracts only (Accounting, CRM, Loyalty, Communication, Delivery, Experience/Website, AI, …) +- [x] Alembic `0001_initial`; compose + `.env.example` wiring +- [x] Tests: architecture, API, tenant isolation, permissions, bundles, migration, dependency, security, performance, docs +- [x] Docs: [hospitality-phase-12-0.md](hospitality-phase-12-0.md), [phase-handover/phase-12-0.md](phase-handover/phase-12-0.md), [hospitality-roadmap.md](hospitality-roadmap.md), ADR-017 +- [x] Historical `restaurant-foundation` / restaurant scaffold superseded by hospitality +- [x] No POS / kitchen / ordering / reservation engines + +--- + +## Phase 12.1 — Digital Menu Catalog ✅ + +- [x] Catalog aggregates: Allergen, ModifierGroup, ModifierOption, MenuItemModifier, MenuItemAllergen, AvailabilityWindow, MenuMediaRef, MenuLocalization +- [x] Additive MenuItem fields (prep time, calories, dietary flags, tags, primary_media_ref) +- [x] Catalog APIs + validators + permissions + publish-only events +- [x] Alembic `0002_phase_121_digital_menu_catalog`; version **0.12.1.0**; capabilities `digital_menu_catalog` +- [x] Tests: catalog happy path, tenant isolation, validation, architecture/migration/docs updates +- [x] Docs: [hospitality-phase-12-1.md](hospitality-phase-12-1.md), [phase-handover/phase-12-1.md](phase-handover/phase-12-1.md) +- [x] No POS / kitchen / QR ordering / reservation engines + +--- + + +## Phase 10.0 — Delivery Platform Foundation ✅ + +- [x] `backend/services/delivery` service scaffold (API / services / repositories / models) +- [x] Foundation aggregates: DeliveryOrganization, DeliveryHub, DeliveryRole, DeliveryPermission, ExternalProviderConfig, RoutingEngineRegistration, DeliveryConfiguration, DeliverySetting, DeliveryAuditLog +- [x] Platform provider contracts (Accounting, CRM, Loyalty, Communication, Notification, Storage, AI, Identity, RoutingEngine, Fleet) +- [x] Publish-only events (`delivery.organization|hub|external_provider|routing_engine|configuration|setting.*`) +- [x] Health `/health`, Capabilities `/capabilities`, Metrics `/metrics` +- [x] APIs under `/api/v1` for organizations, hubs, external-providers, routing-engines, configurations, settings, audit +- [x] Permissions `delivery.*` trees; tenant isolation via `X-Tenant-ID` +- [x] Alembic `0001_initial` + `delivery_db` wiring (compose port 8007); version `0.10.0.0` +- [x] Architecture / API / tenant / permissions / migration / dependency / security / docs tests +- [x] Docs: [delivery-phase-10-0.md](delivery-phase-10-0.md), [phase-handover/phase-10-0.md](phase-handover/phase-10-0.md) +- [x] No driver/fleet/dispatch/routing/tracking engines (deferred) + +--- + +## Phase 10.1 — Driver Management ✅ + +- [x] Aggregates: Driver, DriverCredential, DriverDocument, DriverLifecycleEvent, OutboxEvent +- [x] Lifecycle: activate / suspend / resume / deactivate / block / unblock / archive + append-only history +- [x] Soft delete + optimistic locking; list pagination / filter / sort / search +- [x] Commands, queries, policies, specifications, validators +- [x] APIs `/api/v1/drivers` + `/api/v1/permissions/catalog`; capabilities/metrics `phase: 10.1` +- [x] Permissions `delivery.drivers.*`; events `delivery.driver.*` via transactional outbox +- [x] Alembic `0002_phase_101_drivers`; version **0.10.1.0** +- [x] Tests: lifecycle, credentials/documents, tenant, architecture, migration, permissions, performance indexes, docs +- [x] Docs: [delivery-phase-10-1.md](delivery-phase-10-1.md), [phase-handover/phase-10-1.md](phase-handover/phase-10-1.md) +- [x] No fleet / dispatch / routing / tracking engines (deferred) + +--- + +## Related Documents + +- [Next Steps](next-steps.md) +- [Roadmap](roadmap.md) +- [Frontend docs](frontend/README.md) +- [docs/README.md](README.md) +- [CRM Phase 6.0](crm-phase-6-0.md) +- [CRM Phase 6.1](crm-phase-6-1.md) +- [CRM Phase 6.2](crm-phase-6-2.md) +- [CRM Phase 6.3](crm-phase-6-3.md) +- [Loyalty Phase 7.0](loyalty-phase-7-0.md) +- [Loyalty Phase 7.1](loyalty-phase-7-1.md) +- [Communication Phase 8](communication-phase-8.md) +- [Sports Center Phase 9.0](sports-center-phase-9-0.md) +- [Sports Center Phase 9.1](sports-center-phase-9-1.md) +- [Sports Center Phase 9.2](sports-center-phase-9-2.md) +- [Phase Handover 9.0](phase-handover/phase-9-0.md) +- [Phase Handover 9.1](phase-handover/phase-9-1.md) +- [Phase Handover 9.2](phase-handover/phase-9-2.md) +- [Sports Center Roadmap](sports-center-roadmap.md) +- [Delivery Roadmap](delivery-roadmap.md) +- [Delivery Phase 10.0](delivery-phase-10-0.md) +- [Delivery Phase 10.1](delivery-phase-10-1.md) +- [Phase Handover DP-Reg](phase-handover/phase-dp-reg.md) +- [Phase Handover 10.0](phase-handover/phase-10-0.md) +- [Phase Handover 10.1](phase-handover/phase-10-1.md) +- [Experience Roadmap](experience-roadmap.md) +- [Experience Phase XP-Reg](experience-phase-xp-reg.md) +- [Experience Phase 11.0](experience-phase-11-0.md) +- [Experience Phase 11.1](experience-phase-11-1.md) +- [Experience Phase 11.2](experience-phase-11-2.md) +- [Experience Phase 11.3](experience-phase-11-3.md) +- [Phase Handover XP-Reg](phase-handover/phase-xp-reg.md) +- [Phase Handover 11.0](phase-handover/phase-11-0.md) +- [Phase Handover 11.1](phase-handover/phase-11-1.md) +- [AI / Enterprise Development Framework](ai-framework/README.md) +- [Phase Handover AF-Enterprise](phase-handover/phase-af-enterprise.md) +- [Phase Handover AF-Discovery](phase-handover/phase-af-discovery.md) +- [ADR-013](architecture/adr/ADR-013.md) +- [ADR-018](architecture/adr/ADR-018.md) +- [ADR-019](architecture/adr/ADR-019.md) +- [ADR-014](architecture/adr/ADR-014.md) +- [ADR-015](architecture/adr/ADR-015.md) +- [ADR-016](architecture/adr/ADR-016.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Hospitality Phase 12.0](hospitality-phase-12-0.md) +- [Hospitality Phase 12.1](hospitality-phase-12-1.md) +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.0](phase-handover/phase-12-0.md) +- [Phase Handover 12.1](phase-handover/phase-12-1.md) diff --git a/docs/README.md b/docs/README.md index abe6869..be1fa45 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,158 +1,177 @@ -# TorbatYar Documentation - -Permanent source of truth for architecture, standards, registries, deployment, and phase planning. - -## Start Here (every implementation phase) - -1. This file -2. [ai-framework/](ai-framework/) — permanent AI Development Framework (read before coding) -3. [architecture/](architecture/) and [architecture/adr/](architecture/adr/) -4. [development/project-principles.md](development/project-principles.md) -5. [development/coding-standards.md](development/coding-standards.md) -6. [development/testing-strategy.md](development/testing-strategy.md) -7. [module-registry.md](module-registry.md) -8. [provider-registry.md](provider-registry.md) -9. [glossary.md](glossary.md) -10. Relevant [phases/](phases/) docs - -If a change affects architecture, APIs, providers, or module boundaries: **update documentation first**, then code. - -## Navigation - -### Status & Planning - -| Document | Responsibility | -| --- | --- | -| [progress.md](progress.md) | Completed work only | -| [roadmap.md](roadmap.md) | Future roadmap only | -| [next-steps.md](next-steps.md) | Immediate next milestone only | -| [crm-phase-6-0.md](crm-phase-6-0.md) | CRM Service Foundation (Phase 6.0) | -| [crm-phase-6-1.md](crm-phase-6-1.md) | CRM Core Business Entities (Phase 6.1) | -| [crm-phase-6-2.md](crm-phase-6-2.md) | CRM Enterprise Sales Process Engine (Phase 6.2) | -| [crm-phase-6-3.md](crm-phase-6-3.md) | CRM Enterprise Sales Collaboration (Phase 6.3) | -| [loyalty-phase-7-0.md](loyalty-phase-7-0.md) | Loyalty Service Foundation (Phase 7.0) | -| [communication-phase-8.md](communication-phase-8.md) | Enterprise Communication Platform (Phase 8) | -| [sports-center-roadmap.md](sports-center-roadmap.md) | Sports Center Platform roadmap (Phases 9.0–9.10) | - -### Architecture - -| Document | Responsibility | -| --- | --- | -| [architecture/architecture.md](architecture/architecture.md) | Overview | -| [architecture/module-boundaries.md](architecture/module-boundaries.md) | Ownership boundaries | -| [architecture/database-architecture.md](architecture/database-architecture.md) | DB architecture | -| [architecture/multi-tenant-architecture.md](architecture/multi-tenant-architecture.md) | Tenancy | -| [architecture/deployment-architecture.md](architecture/deployment-architecture.md) | Runtime topology | -| [architecture/security-architecture.md](architecture/security-architecture.md) | Security | -| [architecture/identity-architecture.md](architecture/identity-architecture.md) | Identity | -| [architecture/authorization-architecture.md](architecture/authorization-architecture.md) | Authz | -| [architecture/integration-architecture.md](architecture/integration-architecture.md) | Integrations | -| [architecture/event-driven-architecture.md](architecture/event-driven-architecture.md) | Events | -| [architecture/service-architecture.md](architecture/service-architecture.md) | Service layering | -| [architecture/ai-architecture.md](architecture/ai-architecture.md) | AI rules | -| [architecture/compliance-architecture.md](architecture/compliance-architecture.md) | Compliance | -| [architecture/adr/](architecture/adr/) | ADRs (one decision per file) | - -### Development - -| Document | Responsibility | -| --- | --- | -| [development/developer-guide.md](development/developer-guide.md) | How to run/develop | -| [development/project-principles.md](development/project-principles.md) | Mandatory principles | -| [development/coding-standards.md](development/coding-standards.md) | Coding conventions | -| [development/testing-strategy.md](development/testing-strategy.md) | Testing | -| [development/branching-strategy.md](development/branching-strategy.md) | Git branches | -| [development/release-strategy.md](development/release-strategy.md) | Releases | - -### Reference - -| Document | Responsibility | -| --- | --- | -| [reference/database-schema.md](reference/database-schema.md) | Schema reference | -| [reference/services-contracts.md](reference/services-contracts.md) | Service contracts | -| [reference/api-reference.md](reference/api-reference.md) | API index | -| [reference/event-catalog.md](reference/event-catalog.md) | Events | -| [reference/provider-reference.md](reference/provider-reference.md) | Provider details | - -### Deployment - -| Document | Responsibility | -| --- | --- | -| [deployment/deployment.md](deployment/deployment.md) | Deploy overview | -| [deployment/production.md](deployment/production.md) | Production | -| [deployment/ssl.md](deployment/ssl.md) | TLS / tenant SSL | -| [deployment/monitoring.md](deployment/monitoring.md) | Monitoring | -| [deployment/backup.md](deployment/backup.md) | Backup | -| [deployment/restore.md](deployment/restore.md) | Restore | -| [deployment/disaster-recovery.md](deployment/disaster-recovery.md) | DR | - -### Registries & Glossary - -| Document | Responsibility | -| --- | --- | -| [module-registry.md](module-registry.md) | All modules | -| [provider-registry.md](provider-registry.md) | All providers | -| [glossary.md](glossary.md) | Terms | - -### Frontend - -| Document | Responsibility | -| --- | --- | -| [frontend/README.md](frontend/README.md) | Accounting UI & design system index | - -### AI Development Framework - -| Document | Responsibility | -| --- | --- | -| [ai-framework/README.md](ai-framework/README.md) | Framework purpose, usage, reading order | -| [ai-framework/master-prompt.md](ai-framework/master-prompt.md) | Global AI implementation rules | -| [ai-framework/development-loop.md](ai-framework/development-loop.md) | Permanent implementation lifecycle | -| [ai-framework/quality-gates.md](ai-framework/quality-gates.md) | Mandatory quality gates | -| [ai-framework/phase-manifest.yaml](ai-framework/phase-manifest.yaml) | Phase registry | -| [ai-framework/service-manifest.yaml](ai-framework/service-manifest.yaml) | Service registry | -| [ai-framework/prompt-rules.md](ai-framework/prompt-rules.md) | Phase prompt rules | -| [ai-framework/cursor-guidelines.md](ai-framework/cursor-guidelines.md) | Cursor workflow | -| [architecture/adr/ADR-013.md](architecture/adr/ADR-013.md) | Framework ADR | -| [architecture/adr/ADR-014.md](architecture/adr/ADR-014.md) | Sports Center Platform ADR | - -### Decisions, Templates, Phases - -| Path | Responsibility | -| --- | --- | -| [decisions/](decisions/) | Non-architectural decisions | -| [templates/](templates/) | Required document templates (short forms) | -| [ai-framework/](ai-framework/) | AI implementation templates & lifecycle | -| [phases/](phases/) | Phase area documentation | - -## Phase Completion Gate - -No implementation phase is complete until: - -- [ ] Code completed -- [ ] Tests passed -- [ ] Documentation updated -- [ ] ADR updated (if required) -- [ ] Module Registry updated -- [ ] Provider Registry updated (if required) -- [ ] Progress updated -- [ ] Next Steps updated -- [ ] Architecture validation passed -- [ ] AI framework quality gates passed ([ai-framework/quality-gates.md](ai-framework/quality-gates.md)) -- [ ] Phase handover completed ([ai-framework/phase-handover.md](ai-framework/phase-handover.md)) -- [ ] Manifests updated (if phase/service registration changed) -- [ ] No TODO remains -- [ ] Self review completed -- [ ] Final verification completed - -## Architecture Guard - -Before modifying application code, verify consistency with principles, ADRs, registries, tenancy, database architecture, coding standards, and testing strategy. On conflict: **stop**, explain, correct docs/ADR first. - -## Deprecated Paths - -Older paths may remain as stubs pointing here. Do not add new content to deprecated files. - -## Related - -- Root [README.md](../README.md) -- [current-architecture-review.md](current-architecture-review.md) (historical review; superseded by this structure) +# TorbatYar Documentation + +Permanent source of truth for architecture, standards, registries, deployment, and phase planning. + +## Start Here (every implementation phase) + +1. This file +2. [ai-framework/](ai-framework/) — permanent Enterprise / AI Development Framework (read before coding; [ADR-013](architecture/adr/ADR-013.md), [ADR-018](architecture/adr/ADR-018.md)) +3. [architecture/](architecture/) and [architecture/adr/](architecture/adr/) +4. [development/project-principles.md](development/project-principles.md) +5. [development/coding-standards.md](development/coding-standards.md) +6. [development/testing-strategy.md](development/testing-strategy.md) +7. [module-registry.md](module-registry.md) +8. [provider-registry.md](provider-registry.md) +9. [glossary.md](glossary.md) +10. Relevant [phases/](phases/) docs + +If a change affects architecture, APIs, providers, or module boundaries: **update documentation first**, then code. + +## Navigation + +### Status & Planning + +| Document | Responsibility | +| --- | --- | +| [progress.md](progress.md) | Completed work only | +| [roadmap.md](roadmap.md) | Future roadmap only | +| [next-steps.md](next-steps.md) | Immediate next milestone only | +| [crm-phase-6-0.md](crm-phase-6-0.md) | CRM Service Foundation (Phase 6.0) | +| [crm-phase-6-1.md](crm-phase-6-1.md) | CRM Core Business Entities (Phase 6.1) | +| [crm-phase-6-2.md](crm-phase-6-2.md) | CRM Enterprise Sales Process Engine (Phase 6.2) | +| [crm-phase-6-3.md](crm-phase-6-3.md) | CRM Enterprise Sales Collaboration (Phase 6.3) | +| [loyalty-phase-7-0.md](loyalty-phase-7-0.md) | Loyalty Service Foundation (Phase 7.0) | +| [communication-phase-8.md](communication-phase-8.md) | Enterprise Communication Platform (Phase 8 — Production Ready SMS MVP) | +| [communication-roadmap.md](communication-roadmap.md) | Communication future channels & optional ops (roadmap only) | +| [sports-center-roadmap.md](sports-center-roadmap.md) | Sports Center Platform roadmap (Phases 9.0–9.10) | +| [delivery-roadmap.md](delivery-roadmap.md) | Delivery & Fleet Platform roadmap (Phases 10.0–10.10) | +| [delivery-phase-10-0.md](delivery-phase-10-0.md) | Delivery Platform Foundation (Phase 10.0) | +| [experience-roadmap.md](experience-roadmap.md) | Experience Platform roadmap (Phases 11.0–11.10) | + +### Architecture + +| Document | Responsibility | +| --- | --- | +| [architecture/architecture.md](architecture/architecture.md) | Overview | +| [architecture/module-boundaries.md](architecture/module-boundaries.md) | Ownership boundaries | +| [architecture/database-architecture.md](architecture/database-architecture.md) | DB architecture | +| [architecture/multi-tenant-architecture.md](architecture/multi-tenant-architecture.md) | Tenancy | +| [architecture/deployment-architecture.md](architecture/deployment-architecture.md) | Runtime topology | +| [architecture/security-architecture.md](architecture/security-architecture.md) | Security | +| [architecture/identity-architecture.md](architecture/identity-architecture.md) | Identity | +| [architecture/authorization-architecture.md](architecture/authorization-architecture.md) | Authz | +| [architecture/integration-architecture.md](architecture/integration-architecture.md) | Integrations | +| [architecture/event-driven-architecture.md](architecture/event-driven-architecture.md) | Events | +| [architecture/service-architecture.md](architecture/service-architecture.md) | Service layering | +| [architecture/ai-architecture.md](architecture/ai-architecture.md) | AI rules | +| [architecture/compliance-architecture.md](architecture/compliance-architecture.md) | Compliance | +| [architecture/adr/](architecture/adr/) | ADRs (one decision per file) | + +### Development + +| Document | Responsibility | +| --- | --- | +| [development/developer-guide.md](development/developer-guide.md) | How to run/develop | +| [development/project-principles.md](development/project-principles.md) | Mandatory principles | +| [development/coding-standards.md](development/coding-standards.md) | Coding conventions | +| [development/testing-strategy.md](development/testing-strategy.md) | Testing | +| [development/branching-strategy.md](development/branching-strategy.md) | Git branches | +| [development/release-strategy.md](development/release-strategy.md) | Releases | + +### Reference + +| Document | Responsibility | +| --- | --- | +| [reference/database-schema.md](reference/database-schema.md) | Schema reference | +| [reference/services-contracts.md](reference/services-contracts.md) | Service contracts | +| [reference/api-reference.md](reference/api-reference.md) | API index | +| [reference/event-catalog.md](reference/event-catalog.md) | Events | +| [reference/provider-reference.md](reference/provider-reference.md) | Provider details | + +### Deployment + +| Document | Responsibility | +| --- | --- | +| [deployment/deployment.md](deployment/deployment.md) | Deploy overview | +| [deployment/production.md](deployment/production.md) | Production | +| [deployment/ssl.md](deployment/ssl.md) | TLS / tenant SSL | +| [deployment/monitoring.md](deployment/monitoring.md) | Monitoring | +| [deployment/backup.md](deployment/backup.md) | Backup | +| [deployment/restore.md](deployment/restore.md) | Restore | +| [deployment/disaster-recovery.md](deployment/disaster-recovery.md) | DR | + +### Registries & Glossary + +| Document | Responsibility | +| --- | --- | +| [module-registry.md](module-registry.md) | All modules | +| [provider-registry.md](provider-registry.md) | All providers | +| [glossary.md](glossary.md) | Terms | + +### Frontend + +| Document | Responsibility | +| --- | --- | +| [frontend/README.md](frontend/README.md) | Accounting UI & design system index | + +### AI / Enterprise Development Framework + +| Document | Responsibility | +| --- | --- | +| [ai-framework/README.md](ai-framework/README.md) | Framework purpose, usage, reading order | +| [ai-framework/master-prompt.md](ai-framework/master-prompt.md) | Global implementation rules | +| [ai-framework/enterprise-phase-discovery.md](ai-framework/enterprise-phase-discovery.md) | Mandatory pre-implementation discovery | +| [ai-framework/definition-of-done.md](ai-framework/definition-of-done.md) | Mandatory Definition of Done | +| [ai-framework/mandatory-phase-artifacts.md](ai-framework/mandatory-phase-artifacts.md) | Artifacts every phase includes by default | +| [ai-framework/enterprise-completeness.md](ai-framework/enterprise-completeness.md) | Pre-close completeness verification | +| [ai-framework/boundary-rules.md](ai-framework/boundary-rules.md) | Service/phase/integration hard boundaries | +| [ai-framework/development-loop.md](ai-framework/development-loop.md) | Permanent implementation lifecycle | +| [ai-framework/quality-gates.md](ai-framework/quality-gates.md) | Mandatory quality gates | +| [ai-framework/phase-manifest.yaml](ai-framework/phase-manifest.yaml) | Phase registry | +| [ai-framework/service-manifest.yaml](ai-framework/service-manifest.yaml) | Service registry | +| [ai-framework/prompt-rules.md](ai-framework/prompt-rules.md) | Phase prompt rules | +| [ai-framework/cursor-guidelines.md](ai-framework/cursor-guidelines.md) | Cursor workflow | +| [architecture/adr/ADR-013.md](architecture/adr/ADR-013.md) | Framework ADR | +| [architecture/adr/ADR-018.md](architecture/adr/ADR-018.md) | Enterprise Completeness Mandate | +| [architecture/adr/ADR-019.md](architecture/adr/ADR-019.md) | Mandatory Enterprise Phase Discovery | +| [architecture/adr/ADR-014.md](architecture/adr/ADR-014.md) | Sports Center Platform ADR | +| [architecture/adr/ADR-015.md](architecture/adr/ADR-015.md) | Delivery & Fleet Platform ADR | +| [architecture/adr/ADR-016.md](architecture/adr/ADR-016.md) | Experience Platform ADR | + +### Decisions, Templates, Phases + +| Path | Responsibility | +| --- | --- | +| [decisions/](decisions/) | Non-architectural decisions | +| [templates/](templates/) | Required document templates (short forms) | +| [ai-framework/](ai-framework/) | AI implementation templates & lifecycle | +| [phases/](phases/) | Phase area documentation | + +## Phase Completion Gate + +No implementation phase is complete until: + +- [ ] Business Analysis completed for the phase +- [ ] Enterprise Phase Discovery completed; Missing→Implement gaps closed ([ai-framework/enterprise-phase-discovery.md](ai-framework/enterprise-phase-discovery.md)) +- [ ] Code completed for **all** in-scope business capabilities (CRUD is never sufficient) +- [ ] Mandatory phase artifacts delivered ([ai-framework/mandatory-phase-artifacts.md](ai-framework/mandatory-phase-artifacts.md)) +- [ ] Definition of Done satisfied ([ai-framework/definition-of-done.md](ai-framework/definition-of-done.md)) +- [ ] Tests passed +- [ ] Documentation updated +- [ ] ADR updated (if required) +- [ ] Module Registry updated +- [ ] Provider Registry updated (if required) +- [ ] Progress updated +- [ ] Next Steps updated +- [ ] Architecture validation passed +- [ ] Boundary rules respected ([ai-framework/boundary-rules.md](ai-framework/boundary-rules.md)) +- [ ] Enterprise Completeness signed off ([ai-framework/enterprise-completeness.md](ai-framework/enterprise-completeness.md)) +- [ ] AI/Enterprise framework quality gates passed ([ai-framework/quality-gates.md](ai-framework/quality-gates.md)) +- [ ] Phase handover completed ([ai-framework/phase-handover.md](ai-framework/phase-handover.md)) +- [ ] Manifests updated (if phase/service registration changed) +- [ ] No TODO remains +- [ ] Self review completed +- [ ] Final verification completed + +## Architecture Guard + +Before modifying application code, verify consistency with principles, ADRs, registries, tenancy, database architecture, coding standards, testing strategy, and the Enterprise / AI Development Framework. On conflict: **stop**, explain, correct docs/ADR first. + +## Deprecated Paths + +Older paths may remain as stubs pointing here. Do not add new content to deprecated files. + +## Related + +- Root [README.md](../README.md) +- [current-architecture-review.md](current-architecture-review.md) (historical review; superseded by this structure) diff --git a/docs/ai-framework/README.md b/docs/ai-framework/README.md index e65c7b9..b1a8ed4 100644 --- a/docs/ai-framework/README.md +++ b/docs/ai-framework/README.md @@ -1,16 +1,32 @@ -# AI Development Framework +# AI / Enterprise Development Framework -Permanent framework that every future AI-assisted implementation phase must follow. +Permanent **Enterprise Development Framework** that every future AI-assisted (and human) implementation phase must follow. -This area does **not** implement business features. It defines how AI agents and humans plan, implement, validate, document, and complete phases without violating TorbatYar architecture. +This area does **not** implement business features. It defines how agents and humans plan, implement, validate, document, and complete phases at **production quality by default** without violating TorbatYar architecture. + +Historical name: *AI Development Framework* ([ADR-013](../architecture/adr/ADR-013.md)). Enterprise completeness mandate: [ADR-018](../architecture/adr/ADR-018.md). Folder path `docs/ai-framework/` is retained for backward compatibility. ## Purpose 1. Encode global project rules once (never duplicate them in phase prompts). -2. Standardize the implementation lifecycle from requirements to completion. -3. Provide reusable templates for services, modules, entities, layers, APIs, events, tests, and docs. -4. Register phases and services in machine-readable manifests. -5. Enforce quality gates before any phase may be marked complete. +2. Make **enterprise production readiness the default** for every phase of every service. +3. Provide a mandatory [Enterprise Phase Discovery](enterprise-phase-discovery.md), [Definition of Done](definition-of-done.md), [Mandatory Phase Artifacts](mandatory-phase-artifacts.md), [Enterprise Completeness](enterprise-completeness.md), and [Boundary Rules](boundary-rules.md). +4. Standardize the implementation lifecycle from Discovery to Completion. +5. Provide reusable templates for services, modules, entities, layers, APIs, events, tests, and docs. +6. Register phases and services in machine-readable manifests. +7. Enforce quality gates before any phase may be marked complete. + +## Single Source of Truth + +| Concern | Authority | +| --- | --- | +| Which documents to load | [runtime-read-policy.md](runtime-read-policy.md) | +| How to implement any phase | This framework (`docs/ai-framework/`) | +| Technical architecture decisions | `docs/architecture/` + ADRs | +| What was completed / what’s next | `progress.md` / `next-steps.md` / `roadmap.md` | +| Module ownership | `module-boundaries.md` + `module-registry.md` | + +Phase prompts describe **only** the current phase. Permanent rules live here. Agents must not wait for a phase prompt to restate enterprise artifacts. ## Usage @@ -19,9 +35,7 @@ This area does **not** implement business features. It defines how AI agents and | AI agent (Cursor / other) | Read [cursor-guidelines.md](cursor-guidelines.md) and [prompt-rules.md](prompt-rules.md); load [master-prompt.md](master-prompt.md); follow [development-loop.md](development-loop.md) | | Phase author | Copy [phase-template.md](phase-template.md); register in [phase-manifest.yaml](phase-manifest.yaml); deliver [phase-handover.md](phase-handover.md) | | Service designer | Follow [service-template.md](service-template.md); register in [service-manifest.yaml](service-manifest.yaml) | -| Reviewer | Validate against [quality-gates.md](quality-gates.md) | - -Phase prompts must describe **only** the current phase. Permanent rules live here. +| Reviewer | Validate against [quality-gates.md](quality-gates.md), [enterprise-phase-discovery.md](enterprise-phase-discovery.md), [definition-of-done.md](definition-of-done.md), [enterprise-completeness.md](enterprise-completeness.md) | ## Reading Order @@ -30,37 +44,43 @@ Every implementation phase starts here: 1. [docs/README.md](../README.md) 2. This file (`docs/ai-framework/README.md`) 3. [master-prompt.md](master-prompt.md) -4. [development-loop.md](development-loop.md) -5. [prompt-rules.md](prompt-rules.md) · [cursor-guidelines.md](cursor-guidelines.md) -6. [quality-gates.md](quality-gates.md) -7. Relevant templates (service / module / entity / repository / service-layer / api / event / testing / documentation) -8. [phase-manifest.yaml](phase-manifest.yaml) · [service-manifest.yaml](service-manifest.yaml) -9. Then architecture, ADRs, development standards, registries, glossary, and the phase’s own docs +4. [enterprise-phase-discovery.md](enterprise-phase-discovery.md) · [definition-of-done.md](definition-of-done.md) · [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) · [boundary-rules.md](boundary-rules.md) +5. [development-loop.md](development-loop.md) +6. [prompt-rules.md](prompt-rules.md) · [cursor-guidelines.md](cursor-guidelines.md) +7. [quality-gates.md](quality-gates.md) · [enterprise-completeness.md](enterprise-completeness.md) +8. Relevant templates (service / module / entity / repository / service-layer / api / event / testing / documentation) +9. [phase-manifest.yaml](phase-manifest.yaml) · [service-manifest.yaml](service-manifest.yaml) +10. Then architecture, ADRs, development standards, registries, glossary, and the phase’s own docs ## Lifecycle Canonical lifecycle: [development-loop.md](development-loop.md). ``` -Requirements Analysis +Enterprise Phase Discovery + → Business Analysis → Architecture Validation - → Implementation + → Domain Modeling (aggregates, entities, DTOs) + → Implementation (Discovery gaps + mandatory artifacts) → Automated Testing → Self Audit → Documentation Update + → Enterprise Completeness Verification → Quality Gates - → Automatic Repair Loop (until green) - → Completion (handover + registry/progress updates) + → Automatic Repair Loop (until green — implement missing required work) + → Completion (DoD + handover + registry/progress updates) ``` -No phase is complete until every gate in [quality-gates.md](quality-gates.md) passes and [phase-handover.md](phase-handover.md) deliverables are filled. +No phase is complete until Discovery gaps are closed, [definition-of-done.md](definition-of-done.md) is satisfied, every gate in [quality-gates.md](quality-gates.md) passes, [enterprise-completeness.md](enterprise-completeness.md) is signed off, and [phase-handover.md](phase-handover.md) deliverables are filled. + +**CRUD is never sufficient.** ## Relationship with Architecture | Architecture doc | Framework use | | --- | --- | | [architecture/architecture.md](../architecture/architecture.md) | System map and permanent reading list | -| [module-boundaries.md](../architecture/module-boundaries.md) | Ownership checks before new modules/services | +| [module-boundaries.md](../architecture/module-boundaries.md) | Ownership checks — mirrored in [boundary-rules.md](boundary-rules.md) | | [database-architecture.md](../architecture/database-architecture.md) | DB ownership / migration rules | | [multi-tenant-architecture.md](../architecture/multi-tenant-architecture.md) | Tenant awareness gates | | [service-architecture.md](../architecture/service-architecture.md) | Layering (API → Service → Repository → Model) | @@ -68,12 +88,14 @@ No phase is complete until every gate in [quality-gates.md](quality-gates.md) pa | [ai-architecture.md](../architecture/ai-architecture.md) | Product AI independence (distinct from this framework) | | [security-architecture.md](../architecture/security-architecture.md) · [authorization-architecture.md](../architecture/authorization-architecture.md) | Security / permission gates | -**Distinction:** Product AI (`ai_assistant` / AI phase area) is a business capability. This framework is **development process** documentation for AI-assisted implementation of any module. +**Distinction:** Product AI (`ai_assistant` / AI phase area) is a business capability. This framework is **development process** documentation for implementation of any module. ## Relationship with ADR - Architecture decisions remain one decision per file under [architecture/adr/](../architecture/adr/). -- Framework adoption is recorded in [ADR-013](../architecture/adr/ADR-013.md). +- Framework adoption: [ADR-013](../architecture/adr/ADR-013.md). +- Enterprise completeness mandate: [ADR-018](../architecture/adr/ADR-018.md). +- Enterprise Phase Discovery mandate: [ADR-019](../architecture/adr/ADR-019.md). - If a phase introduces a new architectural decision, create an ADR **before** conflicting code. - Never overwrite an accepted ADR — supersede it. - Template: [templates/adr-template.md](../templates/adr-template.md). @@ -88,7 +110,7 @@ No phase is complete until every gate in [quality-gates.md](quality-gates.md) pa | [branching-strategy.md](../development/branching-strategy.md) · [release-strategy.md](../development/release-strategy.md) | Delivery hygiene | | [developer-guide.md](../development/developer-guide.md) | How to run services locally | -Framework templates **extend** these standards; they do not replace them. Prefer existing [docs/templates/](../templates/) for registry-style docs; use `docs/ai-framework/*-template.md` for AI implementation phases. +Framework templates **extend** these standards; they do not replace them. Prefer existing [docs/templates/](../templates/) for registry-style docs; use `docs/ai-framework/*-template.md` for implementation phases. ## Relationship with Phase Documents @@ -101,13 +123,18 @@ Framework templates **extend** these standards; they do not replace them. Prefer | Phase authoring template | [phase-template.md](phase-template.md) | | Phase exit package | [phase-handover.md](phase-handover.md) | -Legacy phase template (registry-oriented): [templates/phase-template.md](../templates/phase-template.md). AI phases use the richer [phase-template.md](phase-template.md) in this folder. +Legacy phase template (registry-oriented): [templates/phase-template.md](../templates/phase-template.md). AI/enterprise phases use the richer [phase-template.md](phase-template.md) in this folder. ## Document Index | Document | Responsibility | | --- | --- | -| [master-prompt.md](master-prompt.md) | Global rules for every AI implementation | +| [master-prompt.md](master-prompt.md) | Global rules for every implementation | +| [enterprise-phase-discovery.md](enterprise-phase-discovery.md) | Mandatory pre-implementation discovery | +| [definition-of-done.md](definition-of-done.md) | Mandatory phase Definition of Done | +| [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) | Artifacts every phase includes by default | +| [enterprise-completeness.md](enterprise-completeness.md) | Pre-close completeness verification | +| [boundary-rules.md](boundary-rules.md) | Service/phase/integration hard boundaries | | [development-loop.md](development-loop.md) | Permanent implementation lifecycle | | [phase-handover.md](phase-handover.md) | Required exit deliverables | | [phase-template.md](phase-template.md) | Reusable phase brief | @@ -115,8 +142,8 @@ Legacy phase template (registry-oriented): [templates/phase-template.md](../temp | [module-template.md](module-template.md) | Module inside an existing service | | [entity-template.md](entity-template.md) | Entity / schema standards | | [repository-template.md](repository-template.md) | Repository standards | -| [service-layer-template.md](service-layer-template.md) | Service layer standards | -| [api-template.md](api-template.md) | REST / DTO / versioning / errors | +| [service-layer-template.md](service-layer-template.md) | Service / commands / queries / policies / specs | +| [api-template.md](api-template.md) | REST / DTO / versioning / errors / OpenAPI | | [event-template.md](event-template.md) | Domain & integration events | | [testing-template.md](testing-template.md) | Mandatory tests | | [documentation-template.md](documentation-template.md) | Post-phase documentation updates | @@ -126,21 +153,39 @@ Legacy phase template (registry-oriented): [templates/phase-template.md](../temp | [prompt-rules.md](prompt-rules.md) | How future phase prompts must behave | | [cursor-guidelines.md](cursor-guidelines.md) | Cursor-specific workflow | -## Phase AF Handover (Framework Creation) +## Phase AF-Discovery Handover (Enterprise Phase Discovery) | Field | Value | | --- | --- | -| Phase ID | `ai-framework` | +| Phase ID | `ai-framework-discovery` | | Status | Complete | -| ADR | [ADR-013](../architecture/adr/ADR-013.md) | -| Reusable Components | All templates + manifests + loop/gates under this folder | +| ADR | [ADR-019](../architecture/adr/ADR-019.md) (extends [ADR-018](../architecture/adr/ADR-018.md)) | +| Handover | [phase-handover/phase-af-discovery.md](../phase-handover/phase-af-discovery.md) | +| Reusable Components | Discovery procedure, Missing Capability Checklist, Discovery Record template, Discovery quality gate | | Public APIs | N/A (documentation only) | | Events | N/A | -| Extension Points | New permanent rules → extend framework docs; new phases → phase-template + manifests | -| Known Limitations | Does not implement product AI Assistant; does not replace `docs/templates/` short forms | -| Migration Notes | N/A | -| Dependencies | Existing documentation architecture (Phase D) | -| Next Phase Entry | [next-steps.md](../next-steps.md) — Loyalty 7.1 using this framework | +| Extension Points | Extend Discovery checklist when new capability classes become permanent | +| Known Limitations | Does not retrofit completed business phases; docs-only Discovery still required | +| Migration Notes | N/A — docs-only | +| Dependencies | ADR-018 enterprise framework | +| Next Phase Entry | Resume business milestones per [next-steps.md](../next-steps.md) — Discovery runs automatically | + +## Phase AF-Enterprise Handover (Framework Upgrade) + +| Field | Value | +| --- | --- | +| Phase ID | `ai-framework-enterprise` | +| Status | Complete | +| ADR | [ADR-018](../architecture/adr/ADR-018.md) (extends [ADR-013](../architecture/adr/ADR-013.md)) | +| Handover | [phase-handover/phase-af-enterprise.md](../phase-handover/phase-af-enterprise.md) | +| Reusable Components | DoD, mandatory artifacts, enterprise completeness, boundary rules + upgraded templates/gates/loop | +| Public APIs | N/A (documentation only) | +| Events | N/A | +| Extension Points | New permanent rules → extend framework docs + ADR if architectural | +| Known Limitations | Does not retrofit completed business phases/code; does not replace `docs/templates/` short forms | +| Migration Notes | N/A — docs-only; backward-compatible paths | +| Dependencies | ADR-013 framework baseline | +| Next Phase Entry | Resume business milestones per [next-steps.md](../next-steps.md) — using this upgraded framework | ## Related Documents @@ -150,3 +195,5 @@ Legacy phase template (registry-oriented): [templates/phase-template.md](../temp - [Reference](../reference/) - [Deployment](../deployment/) - [ADR-013 — AI Development Framework](../architecture/adr/ADR-013.md) +- [ADR-018 — Enterprise Completeness Mandate](../architecture/adr/ADR-018.md) +- [ADR-019 — Mandatory Enterprise Phase Discovery](../architecture/adr/ADR-019.md) diff --git a/docs/ai-framework/api-template.md b/docs/ai-framework/api-template.md index 530cd5f..a427e2a 100644 --- a/docs/ai-framework/api-template.md +++ b/docs/ai-framework/api-template.md @@ -1,8 +1,9 @@ # API Template -REST / DTO / validation / versioning / error standards for service APIs. +REST / DTO / validation / versioning / error / OpenAPI standards for service APIs. -Short contract form: [templates/api-template.md](../templates/api-template.md). +Short contract form: [templates/api-template.md](../templates/api-template.md). +Enterprise default: [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) · [definition-of-done.md](definition-of-done.md). ## REST Standards @@ -13,10 +14,27 @@ Short contract form: [templates/api-template.md](../templates/api-template.md). | Methods | GET read · POST create/actions · PATCH/PUT update · DELETE remove/archive as designed | | Auth | JWT (and documented internal tokens); deps in `api/deps.py` | | Tenant | `X-Tenant-ID` (and documented alternatives); reject when missing on tenant routes | -| Pagination | Shared page meta pattern | -| Thin routers | Validate → authorize → service call → map response | +| Pagination | Shared page meta pattern — **required** on collection list endpoints | +| Filtering | Query params for common attributes — **required** when lists are filterable | +| Sorting | Stable sort keys documented — **required** on collection lists | +| Searching | Text/search params when users must find by name/code/etc. | +| Thin routers | Validate → authorize → command/query/service call → map response | -Health endpoints may live at `/health` (and `/capabilities` when applicable) outside `/api/v1`. +## Operational & Capability Surfaces + +| Endpoint | Purpose | Required | +| --- | --- | --- | +| `GET /health` | Liveness / basic readiness signal | Every runnable service | +| `GET /capabilities` | Feature/channel/bundle discovery | Platform / discoverable services | +| `GET /metrics` | Operational metrics | Prefer yes; else justified N/A | +| Permission routes / registry | List or document `{service}.*` tree | Privileged services | + +## OpenAPI Documentation + +1. FastAPI-generated OpenAPI must include all new/changed public routes before Complete. +2. Operation summaries, response models, and error shapes should be accurate. +3. Export or link OpenAPI in service README / reference docs when contracts stabilize. +4. Undocumented public routes fail the API Completeness gate ([quality-gates.md](quality-gates.md)). ## DTO Rules @@ -25,11 +43,12 @@ Health endpoints may live at `/health` (and `/capabilities` when applicable) out 3. Do not return ORM models directly from routers. 4. External refs are explicit fields (`crm_contact_ref`, etc.). 5. Document breaking field changes in phase handover. +6. List responses include page metadata (items + total/page/size or cursor as per service pattern). ## Validation Rules 1. Request body/query validated by Pydantic. -2. Domain rules enforced in services/validators — not only at the edge. +2. Domain rules enforced in services/validators/policies/specs — not only at the edge. 3. `tenant_id` in body does not override resolved tenant context. 4. Path IDs are UUIDs/strings as per service; always combined with tenant scope server-side. diff --git a/docs/ai-framework/boundary-rules.md b/docs/ai-framework/boundary-rules.md new file mode 100644 index 0000000..956aa48 --- /dev/null +++ b/docs/ai-framework/boundary-rules.md @@ -0,0 +1,56 @@ +# Boundary Rules + +Hard constraints for every implementation phase. Violations block completion. + +Canonical ownership: [module-boundaries.md](../architecture/module-boundaries.md). Process mandate: [ADR-018](../architecture/adr/ADR-018.md). + +## Service & Module Boundaries + +1. **Never implement responsibilities owned by another service.** +2. **Never duplicate business logic** that already lives in an owning service — consume it. +3. **Never access another service’s database** (no cross-DB queries, FKs, or shared schemas). +4. **Integrate only through** versioned HTTP APIs, domain/integration Events, and Provider interfaces. +5. **Never import** another service’s models, repositories, or private modules. + +## Phase Boundaries + +1. **Never move functionality from future phases into the current phase.** +2. **Never silently expand Scope** into adjacent modules “while we’re here.” +3. **Out of Scope is binding** — document exclusions and respect them. +4. High-level roadmap text authorizes **deriving production engineering** for the current phase ([definition-of-done.md](definition-of-done.md), [enterprise-phase-discovery.md](enterprise-phase-discovery.md)), **not** pulling next-phase product features forward. + +## Data & Logic Boundaries + +| Allowed | Forbidden | +| --- | --- | +| External ID / ref fields (`*_ref`) | Cross-service foreign keys | +| HTTP clients to published APIs | Reading foreign ORM models | +| Outbox → event bus → consumers | Dual-writing the same journal/aggregate in two services | +| Provider adapters in the **owning** service | Implementing SMS/payment/etc. inside a non-owning vertical | +| Shared-lib envelopes/exceptions/JWT helpers | Shared-lib business workflows | + +## Accounting & Platform Specials + +1. Journal entries only via Accounting Posting Engine ([ADR-010](../architecture/adr/ADR-010.md)). +2. Messaging providers only inside Communication ([ADR-012](../architecture/adr/ADR-012.md)). +3. Product AI remains optional and independent ([ai-architecture.md](../architecture/ai-architecture.md)). + +## Frontend / Backend + +1. UI only under `frontend/` ([ADR-002](../architecture/adr/ADR-002.md)). +2. Backend never embeds UI rules as the source of truth. +3. Frontend never imports backend packages. + +## Enforcement + +- Architecture Validation stage ([development-loop.md](development-loop.md)) +- Architecture / Integration gates ([quality-gates.md](quality-gates.md)) +- Architecture tests in the service suite ([testing-template.md](testing-template.md)) + +## Related Documents + +- [Module Boundaries](../architecture/module-boundaries.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Master Prompt](master-prompt.md) +- [Service Template](service-template.md) +- [Module Template](module-template.md) diff --git a/docs/ai-framework/context-cache-policy.md b/docs/ai-framework/context-cache-policy.md new file mode 100644 index 0000000..54bfd82 --- /dev/null +++ b/docs/ai-framework/context-cache-policy.md @@ -0,0 +1,135 @@ +# Context Cache Policy + +**Incremental Context Loading** for TorbatYar AI-assisted phases. + +Works with [runtime-read-policy.md](runtime-read-policy.md) and [service-snapshot-policy.md](service-snapshot-policy.md). The read policy decides **which** documents may load; this policy decides **whether to re-read** a document or reuse cached context. **[project-index.yaml](project-index.yaml) is always reloaded first** — never scan folders to resolve paths. + +## Goals + +1. Prevent re-reading unchanged documentation between phases. +2. Keep phase entry context fresh where milestones and manifests change every phase. +3. Never skip loading a document the read policy requires — cache reuse applies only when content is unchanged. + +## Cache model + +Maintain a **session context cache** keyed by file path (or stable doc ID). For each cached entry store at minimum: + +| Field | Purpose | +| --- | --- | +| Path | Canonical doc path | +| Content hash or mtime | Change detection | +| Loaded at | Last full read timestamp | +| Phase ID | Phase that last verified the entry | + +Between phases in the same agent track (same service or framework workstream), prefer cache hits over disk reads. + +## Always reload (every new phase) + +These files **MUST** be read from disk at the start of every phase — never served from cache without a fresh read: + +| Document | Path | +| --- | --- | +| Project Index | [project-index.yaml](project-index.yaml) | +| Phase manifest | [phase-manifest.yaml](phase-manifest.yaml) | +| Service manifest | [service-manifest.yaml](service-manifest.yaml) | +| Service snapshot | From Project Index `snapshot_file` for active service | +| Next steps | [next-steps.md](../next-steps.md) | +| Progress | [progress.md](../progress.md) | +| Latest phase handover | From Project Index `latest_handover` for active service | + +Also always load the **current phase brief** (phase doc or prompt) when assigned — it is new each phase. + +## Cache-eligible (reload only if changed) + +On phase entry, load these **only when** the file changed since last cache entry (hash/mtime/git diff) or the cache has no entry: + +| Category | Paths | +| --- | --- | +| Docs index | [docs/README.md](../README.md) | +| Module registry | [module-registry.md](../module-registry.md) | +| Glossary | [glossary.md](../glossary.md) | +| Roadmap | From Project Index `roadmap_file` for active service | +| Architecture references | Individual files under `docs/architecture/*` already loaded | +| ADR references | Individual files under `docs/architecture/adr/*` already loaded | +| Service README | `backend/services//README.md` for the owning service | +| Completed handovers (superseded) | Older Complete handovers when snapshot + latest handover suffice | +| Framework documents | All normative docs under `docs/ai-framework/` except manifests listed in Always reload | +| Reference / development / deployment | Individual triggered files under `docs/reference/*`, `docs/development/*`, `docs/deployment/*` | + +If unchanged: **reuse cached content** — do not re-read. + +## Never re-read unchanged framework documents + +After the first load in a workstream, treat unchanged framework docs as cache hits, including: + +- [master-prompt.md](master-prompt.md) +- [development-loop.md](development-loop.md) +- [enterprise-phase-discovery.md](enterprise-phase-discovery.md) +- [runtime-read-policy.md](runtime-read-policy.md) +- [context-cache-policy.md](context-cache-policy.md) +- [service-snapshot-policy.md](service-snapshot-policy.md) +- [definition-of-done.md](definition-of-done.md) +- [quality-gates.md](quality-gates.md) +- [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) +- Templates and companions loaded per read-policy triggers + +**Exception:** If a framework doc was edited during the current or prior phase (handover notes a framework upgrade, or mtime/hash changed), invalidate cache and reload that file only. + +## Phase entry procedure + +1. Follow [runtime-read-policy.md](runtime-read-policy.md) execution entry — **Project Index first**. +2. **Always reload** the [Always reload](#always-reload-every-new-phase) set. +3. **Apply** read policy to determine additional in-scope paths (never folder scans). +4. For each in-scope path not in Always reload: **check cache** → reload only if changed. +5. Run [Enterprise Phase Discovery](enterprise-phase-discovery.md) from index + snapshot + reloaded inputs. +6. After phase completion: update Project Index; regenerate snapshot; invalidate cache for modified docs. + +## Cache invalidation + +Invalidate (force reload on next access) when: + +| Event | Action | +| --- | --- | +| Phase completes | Update Project Index; regenerate snapshot; invalidate progress, next-steps, manifests, handover | +| New service registered | Add Project Index entry; add service-manifest entry | +| Doc edited in phase | Invalidate that path | +| Framework upgrade phase | Invalidate changed framework docs only | +| New ADR accepted | Invalidate that ADR path and any dependent cached architecture refs | +| Service switch | Clear service-scoped cache (roadmap, service README, service handovers); retain platform/framework cache if unchanged | + +Do **not** invalidate the full cache on every phase — only changed paths and always-reload set. + +## Relationship to Runtime Read Policy + +| Policy | Responsibility | +| --- | --- | +| [project-index.yaml](project-index.yaml) | Single discovery entry — services, snapshots, roadmaps, handovers | +| [service-snapshot-policy.md](service-snapshot-policy.md) | Compact service state — always reload via index path | +| [runtime-read-policy.md](runtime-read-policy.md) | Tiers, triggers, NEVER AUTO READ — what may load | +| [context-cache-policy.md](context-cache-policy.md) | Incremental loading — whether to re-read or reuse | + +Read policy ALWAYS READ tier splits as: + +- **Always reload every phase:** manifests, progress, next-steps, current phase handover +- **Cache-eligible after first load:** module-registry, glossary, framework docs, and other stable entries per tables above + +READ ONCE PER SERVICE aligns with cache: first read populates cache; subsequent phases reuse until changed. + +READ ONLY IF REFERENCED: load on first trigger; cache thereafter until changed. + +## Backward compatibility + +- Completed business phases unchanged. +- Agents without explicit cache state should still follow read policy; cache is an optimization, not a skip of required synthesis. +- Discovery remains mandatory — snapshot + cached docs count as reviewed inputs when unchanged. +- Path `docs/ai-framework/` and `docs/service-snapshots/` remain canonical. + +## Related Documents + +- [Project Index](project-index.yaml) +- [Service Snapshot Policy](service-snapshot-policy.md) +- [Runtime Read Policy](runtime-read-policy.md) +- [Master Prompt](master-prompt.md) +- [Development Loop](development-loop.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Quality Gates](quality-gates.md) diff --git a/docs/ai-framework/cursor-guidelines.md b/docs/ai-framework/cursor-guidelines.md index 101af01..43e3ffe 100644 --- a/docs/ai-framework/cursor-guidelines.md +++ b/docs/ai-framework/cursor-guidelines.md @@ -2,78 +2,86 @@ Cursor-specific workflow for TorbatYar implementation phases. -General prompt behavior: [prompt-rules.md](prompt-rules.md). Lifecycle: [development-loop.md](development-loop.md). +General prompt behavior: [prompt-rules.md](prompt-rules.md). Lifecycle: [development-loop.md](development-loop.md). +Enterprise defaults: [enterprise-phase-discovery.md](enterprise-phase-discovery.md) · [definition-of-done.md](definition-of-done.md) · [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) · [ADR-018](../architecture/adr/ADR-018.md) · [ADR-019](../architecture/adr/ADR-019.md). -## Reading Order +## Document Loading -1. [docs/README.md](../README.md) -2. [AI Framework README](README.md) -3. [master-prompt.md](master-prompt.md) -4. [development-loop.md](development-loop.md) · [quality-gates.md](quality-gates.md) -5. [prompt-rules.md](prompt-rules.md) (this workflow’s companion) -6. Phase brief + [phase-manifest.yaml](phase-manifest.yaml) entry -7. [service-manifest.yaml](service-manifest.yaml) for touched services -8. `docs/architecture/*` and `docs/architecture/adr/*` -9. [project-principles.md](../development/project-principles.md) · [coding-standards.md](../development/coding-standards.md) · [testing-strategy.md](../development/testing-strategy.md) -10. [module-registry.md](../module-registry.md) · [provider-registry.md](../provider-registry.md) · [glossary.md](../glossary.md) -11. Relevant [phases/](../phases/) and reference docs -12. Applicable templates (service/module/entity/repository/service-layer/api/event/testing/documentation) +Follow [Runtime Read Policy](runtime-read-policy.md). -Do not start coding before Architecture Validation completes. +Do not start coding before **Enterprise Phase Discovery**, Business Analysis, and Architecture Validation complete. + +## Discovery Order (before coding) + +1. Follow [Runtime Read Policy](runtime-read-policy.md) +2. Inventory existing codebase, APIs, domain model, events, permissions, aggregates, tests, docs +3. Publish Discovery Record (baseline, target, gaps, promoted scope, exclusions) +4. Only then proceed to Domain Modeling / Implementation ## Implementation Order 1. ADR/docs if a new decision is required -2. Models / migrations ([entity-template.md](entity-template.md)) -3. Repositories ([repository-template.md](repository-template.md)) -4. Validators + permissions -5. Services + events ([service-layer-template.md](service-layer-template.md), [event-template.md](event-template.md)) -6. API routers + DTOs ([api-template.md](api-template.md)) -7. Provider adapters only if this service owns them -8. Frontend only if the phase explicitly includes UI -9. Keep diffs scoped to the phase — no drive-by refactors +2. Domain modeling notes in the phase doc (aggregates, entities, DTOs) for Discovery-promoted scope +3. Models / migrations ([entity-template.md](entity-template.md)) +4. Repositories with tenant/soft-delete/page/filter/sort/search ([repository-template.md](repository-template.md)) +5. Specifications · Policies · Validators +6. Commands · Queries · Services + events ([service-layer-template.md](service-layer-template.md), [event-template.md](event-template.md)) +7. API routers + DTOs + OpenAPI ([api-template.md](api-template.md)); Health / Capabilities / Metrics / Permissions as required +8. Configuration, feature flags, seeds, background jobs when required +9. Provider adapters only if this service owns them +10. Frontend only if the phase explicitly includes UI +11. Keep diffs scoped to the phase — no drive-by refactors +12. Close every Discovery Missing→Implement gap — do not stop at CRUD ## Documentation Order -1. Draft/update phase doc during or immediately after implementation -2. Update registries and manifests -3. Update reference catalogs when public surface changed -4. Update progress (completed) and next-steps (next milestone) -5. Fill handover ([phase-handover.md](phase-handover.md)) -6. ADR index if a new ADR was added -7. Follow [documentation-template.md](documentation-template.md) +1. Publish Discovery Record in the phase doc **before** claiming Implementation progress +2. Draft/update phase doc (Business Analysis + Domain Model) during or immediately after implementation +3. Update registries and manifests +4. Update reference catalogs when public surface changed +5. Update progress (completed) and next-steps (next milestone) +6. Fill handover ([phase-handover.md](phase-handover.md)) including Discovery summary + Enterprise Completeness + DoD +7. ADR index if a new ADR was added +8. Follow [documentation-template.md](documentation-template.md) ## Validation Order 1. Automated tests ([testing-template.md](testing-template.md)) -2. Self audit (scope creep, unfinished claimed work, layering, tenancy, secrets) -3. Quality gates ([quality-gates.md](quality-gates.md)) -4. Link / cross-reference / manifest checks for doc changes -5. Repair loop until green +2. Self audit (Discovery gaps closed, scope creep, unfinished claimed work, layering, tenancy, secrets, mandatory artifacts) +3. Enterprise Completeness ([enterprise-completeness.md](enterprise-completeness.md)) +4. Quality gates ([quality-gates.md](quality-gates.md)) including Discovery gate +5. Definition of Done ([definition-of-done.md](definition-of-done.md)) +6. Link / cross-reference / manifest checks for doc changes +7. Repair loop until green — **implement** missing required work ## Loop Behavior - Follow [development-loop.md](development-loop.md) stages strictly. -- On gate failure: fix root cause, re-validate, do not skip gates. +- On gate failure: fix root cause (implement missing artifact), re-validate, do not skip gates. - On architecture conflict: stop coding; fix ADR/docs first. -- Prefer existing sibling service patterns (Accounting, CRM, Loyalty, Communication). +- On boundary temptation (other service / future phase): stop; integrate or defer per [boundary-rules.md](boundary-rules.md). +- Prefer existing sibling service patterns (Accounting, CRM, Loyalty, Communication, platform services). ## Completion Behavior -- Phase is complete only when Completion Rules in [development-loop.md](development-loop.md) are satisfied. -- Print a concise completion summary for the user. +- Phase is complete only when Completion Rules in [development-loop.md](development-loop.md), Discovery gaps closed, and [definition-of-done.md](definition-of-done.md) are satisfied. +- Print a concise completion summary for the user (include Discovery + completeness/DoD confirmation). - **Stop** — do not begin the next phase unless the user explicitly requests it. - Do not commit unless the user asks ([project git norms](../development/branching-strategy.md)). ## Cursor Tooling Notes - Use the repo’s docs and code as source of truth; do not invent registries. +- Discovery must inspect the real codebase/APIs/tests — do not Discovery from the phase brief alone. - Run pytest for the touched service before claiming tests passed. -- For docs-only phases (like framework creation), run documentation/link/manifest validation instead of service pytest. +- For docs-only phases (like framework upgrades), run documentation/link/manifest validation instead of service pytest; still produce a Discovery Record against docs/manifests. +- Never modify unrelated completed business phases when upgrading the framework. ## Related Documents - [Master Prompt](master-prompt.md) - [Prompt Rules](prompt-rules.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Definition of Done](definition-of-done.md) - [Quality Gates](quality-gates.md) - [Developer Guide](../development/developer-guide.md) diff --git a/docs/ai-framework/definition-of-done.md b/docs/ai-framework/definition-of-done.md new file mode 100644 index 0000000..ea0af1f --- /dev/null +++ b/docs/ai-framework/definition-of-done.md @@ -0,0 +1,115 @@ +# Definition of Done + +Mandatory exit criteria for every implementation phase. + +A phase **cannot** be marked Complete until every item below is satisfied for **that phase’s boundary**. This document is part of the Enterprise Development Framework ([ADR-018](../architecture/adr/ADR-018.md), [ADR-019](../architecture/adr/ADR-019.md)). + +## Absolute Rules + +1. **Enterprise Phase Discovery first.** A Discovery Record must exist and all Missing→Implement gaps must be closed ([enterprise-phase-discovery.md](enterprise-phase-discovery.md)). +2. **Feature-complete for the phase boundary.** All required business capabilities of *this* phase must be implemented — not sketched, stubbed, or deferred as TODO. +3. **CRUD is never sufficient.** Listing create/read/update/delete endpoints does not equal a production-ready phase. Domain rules, validation, permissions, events, audit, tenancy, tests, and operational surfaces are mandatory. +4. **Derive missing technical components.** If the roadmap or phase brief describes a module only at a high level, the implementation **must** derive every missing technical component required for production readiness **while remaining inside the current phase** ([mandatory-phase-artifacts.md](mandatory-phase-artifacts.md), Discovery gap analysis). +5. **Never pull future-phase functionality forward.** Stay inside Scope; leave Out of Scope untouched ([boundary-rules.md](boundary-rules.md)). +6. **Never claim Complete with open gaps.** Missing artifacts must be implemented before status flips to Complete — not logged as “known limitations” unless truly out of phase boundary with written justification. + +## Phase Definition of Done Checklist + +### Discovery + +- [ ] Enterprise Phase Discovery Record published in the phase doc +- [ ] All Discovery Inputs reviewed (or N/A justified for docs-only phases) +- [ ] Gap analysis complete; Missing→Implement items closed +- [ ] Future-phase and other-service items explicitly excluded + +### Business + +- [ ] Business Analysis for this phase recorded (objectives, actors, capabilities, non-goals) +- [ ] Every in-scope / Discovery-promoted business capability is implemented end-to-end inside the phase boundary +- [ ] Domain invariants and illegal transitions are enforced (not only happy-path CRUD) +- [ ] Seed data included when the phase requires bootstrap/reference data + +### Architecture & Domain + +- [ ] Architecture Validation passed against ADRs and module boundaries +- [ ] Domain Modeling complete (bounded context, aggregates, entities, value objects as needed) +- [ ] Aggregate Design documented and reflected in models +- [ ] Entity Design per [entity-template.md](entity-template.md) +- [ ] DTO Design per [api-template.md](api-template.md) +- [ ] Provider Interfaces defined when external systems are involved + +### Application Layers + +- [ ] Repository Design per [repository-template.md](repository-template.md) +- [ ] Service Design per [service-layer-template.md](service-layer-template.md) +- [ ] Specification Pattern for reusable query/business predicates where applicable +- [ ] Policy Layer for authorization/entitlement/domain policies where applicable +- [ ] Commands for state-changing use cases (explicit command handlers or service command methods) +- [ ] Queries for read use cases (explicit query paths; no accidental write side-effects) +- [ ] Validation Layer (Pydantic + domain validators) +- [ ] Dependency Injection / explicit deps wiring consistent with the service pattern + +### APIs & Contracts + +- [ ] REST APIs for in-scope resources +- [ ] Capability APIs (`/capabilities` or equivalent) when the service exposes discoverable features +- [ ] Health APIs (`/health`) +- [ ] Metrics endpoint or documented N/A with operational alternative +- [ ] Permission APIs or documented permission tree registration for the phase +- [ ] OpenAPI documentation generated/updated and accurate for new routes +- [ ] Pagination, Filtering, Sorting, Searching on list endpoints where collections exist + +### Cross-Cutting + +- [ ] Soft Delete policy applied where master/business records require it +- [ ] Optimistic Locking where concurrent updates are a domain risk +- [ ] Audit Trail for state-changing business actions +- [ ] Outbox Events for cross-service-relevant state changes ([event-template.md](event-template.md)) +- [ ] Tenant Isolation on schema, queries, APIs, and tests +- [ ] Migration(s) in the owning service database only +- [ ] Background Jobs when the phase requires async/scheduled work +- [ ] Configuration via env/settings (no hardcoded secrets/brand) +- [ ] Feature Flags when the phase introduces gated capabilities + +### Quality + +- [ ] Unit Tests +- [ ] Integration / API Tests +- [ ] Architecture Tests (boundary/layering) per service pattern +- [ ] Security Tests +- [ ] Performance Validation (hot paths covered or justified N/A) +- [ ] Documentation complete per [documentation-template.md](documentation-template.md) +- [ ] Self Audit complete ([development-loop.md](development-loop.md)) +- [ ] Enterprise Completeness verification passed ([enterprise-completeness.md](enterprise-completeness.md)) +- [ ] All [quality-gates.md](quality-gates.md) Pass +- [ ] [phase-handover.md](phase-handover.md) filled +- [ ] No TODO/FIXME for claimed deliverables + +## What “Derive Missing Components” Means + +When a phase brief says only “implement X module,” the agent **must** still deliver the full production stack for X inside the phase: + +| If the brief mentions… | Also deliver… | +| --- | --- | +| Entities / tables | Migrations, soft-delete, tenant_id, indexes, audit fields | +| APIs | DTOs, validation, pagination/filter/sort/search, permissions, OpenAPI | +| Business rules | Services, validators, specifications, policies, commands/queries | +| Integrations | Provider interfaces, events/outbox — never foreign DB access | +| Operability | Health, metrics (or justified N/A), config, logging | + +Do **not** invent new business products outside the phase. Do **derive** engineering completeness for what the phase owns. + +## Relationship to Quality Gates + +Definition of Done is the **product completeness** bar. Quality Gates are the **verification** bar. Both must pass ([quality-gates.md](quality-gates.md), [enterprise-completeness.md](enterprise-completeness.md)). + +## Related Documents + +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) +- [Enterprise Completeness](enterprise-completeness.md) +- [Boundary Rules](boundary-rules.md) +- [Development Loop](development-loop.md) +- [Phase Handover](phase-handover.md) +- [ADR-018](../architecture/adr/ADR-018.md) +- [ADR-019](../architecture/adr/ADR-019.md) diff --git a/docs/ai-framework/development-loop.md b/docs/ai-framework/development-loop.md index 59ec54e..9aed97f 100644 --- a/docs/ai-framework/development-loop.md +++ b/docs/ai-framework/development-loop.md @@ -2,109 +2,171 @@ Permanent implementation lifecycle for every TorbatYar phase. -Agents and humans follow these stages in order. After Quality Gates fail, enter the Automatic Repair Loop until all gates pass, then complete. +Agents and humans follow these stages in order. After Quality Gates or Enterprise Completeness fail, enter the Automatic Repair Loop until all gates pass **and** missing required artifacts are implemented, then complete. -## 1. Requirements Analysis +**Document loading:** Start from [project-index.yaml](project-index.yaml) per [runtime-read-policy.md](runtime-read-policy.md) execution entry. Never scan repository folders. -1. Read [docs/README.md](../README.md), [AI Framework README](README.md), [master-prompt.md](master-prompt.md). -2. Read architecture (`docs/architecture/*`, `docs/architecture/adr/*`), development standards, registries, glossary. -3. Read the phase brief (or create from [phase-template.md](phase-template.md)). -4. Confirm Objective, Scope, Out of Scope, Dependencies, and Required Services from [phase-manifest.yaml](phase-manifest.yaml). -5. List aggregates, APIs, events, permissions, and docs that must change. -6. Stop if requirements conflict with ADRs or module boundaries — resolve via ADR/docs first. +Canonical mandates: [project-index.yaml](project-index.yaml) · [runtime-read-policy.md](runtime-read-policy.md) · [context-cache-policy.md](context-cache-policy.md) · [service-snapshot-policy.md](service-snapshot-policy.md) · [enterprise-phase-discovery.md](enterprise-phase-discovery.md) · [definition-of-done.md](definition-of-done.md) · [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) · [boundary-rules.md](boundary-rules.md) · [ADR-018](../architecture/adr/ADR-018.md) · [ADR-019](../architecture/adr/ADR-019.md). -**Exit:** Written understanding of in-scope deliverables and explicit non-goals. +## 1. Enterprise Phase Discovery -## 2. Architecture Validation +**Mandatory before Implementation.** Full procedure: [enterprise-phase-discovery.md](enterprise-phase-discovery.md). -1. Validate against [module-boundaries.md](../architecture/module-boundaries.md), [database-architecture.md](../architecture/database-architecture.md), [multi-tenant-architecture.md](../architecture/multi-tenant-architecture.md). +1. Load [project-index.yaml](project-index.yaml); resolve active service entry; follow [runtime-read-policy.md](runtime-read-policy.md) execution entry (steps 2–8). +2. Synthesize **Discovery Inputs** from Project Index + snapshot + policy mapping per [enterprise-phase-discovery.md](enterprise-phase-discovery.md). +3. Confirm Objective, Scope, Out of Scope, Dependencies, and Required Services from [phase-manifest.yaml](phase-manifest.yaml) and the phase brief. +4. Inventory baseline vs target responsibility for **this phase only**. +5. Run the Missing Capability Checklist; never assume CRUD is sufficient. +6. Promote every Missing→Implement gap into Scope; exclude future-phase and other-service items. +7. Publish the **Enterprise Phase Discovery** record in the phase doc. +8. Stop if Discovery reveals ADR/boundary conflicts — resolve via ADR/docs first. + +**Exit:** Complete Discovery Record with gap analysis and promoted implementation scope. + +## 2. Business Analysis + +1. Refine actors, business capabilities, success metrics, and non-goals from Discovery. +2. Align Business Analysis section with Discovery’s Target Responsibility and Promoted Scope. +3. Confirm derived [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) for this phase (do not pull future-phase features). + +**Exit:** Written Business Analysis consistent with the Discovery Record. + +## 3. Architecture Validation + +1. Follow [Runtime Read Policy](runtime-read-policy.md) for boundary and architecture docs required by this stage. 2. Confirm Database-per-Service, tenancy, eventing, and FE/BE separation. 3. Confirm service vs module decision ([service-template.md](service-template.md) vs [module-template.md](module-template.md)). 4. Identify required ADRs; create Proposed/Accepted ADR if a new decision is introduced. -5. Confirm no ownership theft (e.g. CRM must not own Loyalty or Communication). +5. Confirm Discovery exclusions: no ownership theft, no future-phase pull-forward, no service duplication. +6. Confirm integration approach is API / Events / Providers only. **Exit:** Architecture checklist green or ADR/docs updated to resolve conflicts. -## 3. Implementation +## 4. Domain Modeling + +1. Model bounded context slice for Discovery-promoted scope only. +2. Aggregate Design — consistency boundaries and invariants. +3. Entity / Value Object Design per [entity-template.md](entity-template.md) (tenant, soft delete, audit, indexes, optimistic locking where needed). +4. DTO Design per [api-template.md](api-template.md). +5. Identify Specifications, Policies, Commands, and Queries needed. +6. Identify Provider Interfaces if external systems are involved. + +**Exit:** Domain model section in the phase doc ready to implement. + +## 5. Implementation 1. Follow [cursor-guidelines.md](cursor-guidelines.md) implementation order. -2. Models / entities per [entity-template.md](entity-template.md). -3. Repositories per [repository-template.md](repository-template.md). -4. Services per [service-layer-template.md](service-layer-template.md). -5. Validators, permissions, events per [event-template.md](event-template.md). -6. APIs / DTOs per [api-template.md](api-template.md). -7. Migrations owned by the service database only. -8. Do not implement out-of-scope features. +2. Implement **every** Discovery gap marked Missing→Implement. +3. Models / entities / migrations (owning DB only). +4. Repositories per [repository-template.md](repository-template.md) (pagination, filter, sort, search, soft delete, tenant filters). +5. Specifications, Policies, Validators. +6. Commands and Queries (packages or equivalent explicit methods). +7. Services per [service-layer-template.md](service-layer-template.md). +8. Events / outbox per [event-template.md](event-template.md). +9. APIs / DTOs / OpenAPI per [api-template.md](api-template.md) — including Health, Capabilities, Metrics, Permission surfaces as required. +10. Configuration, feature flags, seed data, background jobs when required. +11. Dependency injection wiring. +12. Do **not** implement Out of Scope, future-phase, or other-service responsibilities. +13. Do **not** stop at CRUD — deliver domain rules and mandatory artifacts. -**Exit:** Code matches phase scope; no unrelated service edits. +**Exit:** Code matches Discovery-promoted scope and mandatory artifacts; no unrelated service edits. -## 4. Automated Testing +## 6. Automated Testing -1. Apply [testing-template.md](testing-template.md) and [testing-strategy.md](../development/testing-strategy.md). +1. Follow [Runtime Read Policy](runtime-read-policy.md); apply [testing-template.md](testing-template.md) when the Testing stage or gate requires it. 2. Run the service’s pytest (and relevant sibling suites if contracts changed). -3. Include tenant isolation negatives for new tenant-scoped APIs. -4. Include permission, migration, architecture/boundary, and docs validation tests when the service pattern requires them. +3. Include unit, integration, architecture, security, permission, migration, tenant isolation, and docs validation as required. +4. Include performance validation for hot paths (or justified N/A). +5. Cover Discovery-promoted capabilities — not only happy-path CRUD. **Exit:** All required tests pass locally (or in CI for the phase). -## 5. Self Audit +## 7. Self Audit -1. Re-read Scope / Out of Scope — reject scope creep. -2. Search for TODO/FIXME in touched paths related to claimed deliverables. -3. Verify layering (no business logic in routers/repos). -4. Verify no cross-DB access or foreign model imports. -5. Verify secrets/brand not hardcoded. -6. Verify audit/events for state-changing business actions. +1. Re-read Discovery Record, Scope / Out of Scope — reject scope creep and future-phase leakage. +2. Verify every Missing→Implement gap was implemented. +3. Walk [definition-of-done.md](definition-of-done.md) and [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md). +4. Search for TODO/FIXME in touched paths related to claimed deliverables. +5. Verify layering (no business logic in routers/repos). +6. Verify no cross-DB access or foreign model imports ([boundary-rules.md](boundary-rules.md)). +7. Verify secrets/brand not hardcoded. +8. Verify audit/events for state-changing business actions. +9. Verify OpenAPI, permissions, health/capabilities/metrics as applicable. **Exit:** Self-audit notes with no open blockers. -## 6. Documentation Update +## 8. Documentation Update 1. Follow [documentation-template.md](documentation-template.md). -2. Update phase doc, progress, next-steps, module registry, provider registry (if needed), reference contracts/events/APIs as applicable. -3. Update [phase-manifest.yaml](phase-manifest.yaml) / [service-manifest.yaml](service-manifest.yaml) status and versions. -4. Fill [phase-handover.md](phase-handover.md) sections for the phase (or phase-specific handover section in the phase doc). +2. Ensure Discovery Record remains in the phase doc. +3. Update phase doc, progress, next-steps, module registry, provider registry (if needed), reference contracts/events/APIs as applicable. +4. Update [phase-manifest.yaml](phase-manifest.yaml) / [service-manifest.yaml](service-manifest.yaml) status and versions. +5. Fill [phase-handover.md](phase-handover.md) including Discovery summary and Enterprise Completeness Sign-Off. +6. Update [project-index.yaml](project-index.yaml) for the owning service (version, phase, next phase, handover, snapshot path). +7. Regenerate service snapshot per [service-snapshot-policy.md](service-snapshot-policy.md). +8. Invalidate [context-cache-policy.md](context-cache-policy.md) entries for every doc modified in this phase. -**Exit:** Docs consistent with code; cross-links valid. +**Exit:** Docs consistent with code; Project Index and snapshot updated; cache invalidated for changed paths. -## 7. Quality Gates +## 9. Enterprise Completeness Verification -Run every gate in [quality-gates.md](quality-gates.md): +1. Run the procedure in [enterprise-completeness.md](enterprise-completeness.md). +2. Confirm Discovery gaps are closed. +3. For every failed required dimension: treat as a defect to implement (not a waiver). +4. Re-run after fixes. -- Architecture · Security · Performance · Testing · Documentation · Migration · Backward Compatibility · Tenant Isolation +**Exit:** All applicable completeness dimensions Pass. + +## 10. Quality Gates + +Run every gate in [quality-gates.md](quality-gates.md), including **Enterprise Phase Discovery**. **Exit:** All gates Pass, or Fail with a concrete defect list. -## 8. Automatic Repair Loop +## 11. Automatic Repair Loop -While any gate fails: +While any gate or completeness dimension fails: -1. Classify defect (architecture, security, test, docs, link, tenancy, migration, compatibility). -2. Fix the smallest correct change (prefer docs/ADR first when rules conflict). -3. Re-run the failed gate and dependent gates. -4. Do not mark the phase complete while any gate fails. -5. Do not weaken gates to force completion. +1. Classify defect (discovery gap, business, architecture, security, test, docs, link, tenancy, migration, compatibility, missing artifact, boundary violation). +2. Fix by **implementing the missing required work** or correcting docs/ADR — smallest correct change. +3. Prefer docs/ADR first when rules conflict with code intent. +4. Re-run the failed gate and dependent gates. +5. Do not mark the phase complete while any gate fails. +6. Do not weaken gates to force completion. +7. Do not reclassify current-phase Discovery gaps as “future work” to escape DoD. -**Exit:** All gates Pass. +**Exit:** All gates Pass and DoD satisfied. -## 9. Completion Rules +## 12. Completion Rules A phase may be marked complete only when: -1. Code and tests for in-scope work are done and green. -2. Documentation and registries are updated. -3. ADR updated/created if required. -4. Quality gates all Pass. -5. Handover package is complete ([phase-handover.md](phase-handover.md)). -6. [progress.md](../progress.md) records completion; [next-steps.md](../next-steps.md) points to the next milestone. -7. No TODO remains for claimed deliverables. -8. Final verification checklist in [docs/README.md](../README.md) Phase Completion Gate is satisfied. +1. Enterprise Phase Discovery Record is complete and all Missing→Implement gaps are closed. +2. [Definition of Done](definition-of-done.md) checklist is satisfied for the phase boundary. +3. Code and tests for in-scope work are done and green. +4. Documentation and registries are updated. +5. ADR updated/created if required. +6. Enterprise Completeness signed off. +7. Quality gates all Pass. +8. Handover package is complete ([phase-handover.md](phase-handover.md)). +9. Service snapshot regenerated and Project Index updated ([service-snapshot-policy.md](service-snapshot-policy.md), [project-index.yaml](project-index.yaml)). +10. [progress.md](../progress.md) records completion; [next-steps.md](../next-steps.md) points to the next milestone. +11. No TODO remains for claimed deliverables. +12. Final verification checklist in [docs/README.md](../README.md) Phase Completion Gate is satisfied. +13. Boundary rules respected throughout. After completion: stop. Do not start the next phase unless explicitly requested. ## Related Documents +- [Project Index](project-index.yaml) +- [Runtime Read Policy](runtime-read-policy.md) +- [Context Cache Policy](context-cache-policy.md) +- [Service Snapshot Policy](service-snapshot-policy.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) - [Master Prompt](master-prompt.md) +- [Definition of Done](definition-of-done.md) +- [Enterprise Completeness](enterprise-completeness.md) - [Quality Gates](quality-gates.md) - [Phase Handover](phase-handover.md) - [Cursor Guidelines](cursor-guidelines.md) diff --git a/docs/ai-framework/documentation-template.md b/docs/ai-framework/documentation-template.md index 44c2f9f..4a41e7d 100644 --- a/docs/ai-framework/documentation-template.md +++ b/docs/ai-framework/documentation-template.md @@ -4,19 +4,20 @@ How documentation must be updated after every phase. ## Principle -Code and documentation ship together. A phase that changes behavior without docs is incomplete ([project-principles.md](../development/project-principles.md)). +Code and documentation ship together. A phase that changes behavior without docs is incomplete ([project-principles.md](../development/project-principles.md)). Enterprise completeness requires documentation completeness ([enterprise-completeness.md](enterprise-completeness.md)). ## Update Checklist ### Always -- [ ] Phase document created/updated (from [phase-template.md](phase-template.md)) -- [ ] [phase-handover.md](phase-handover.md) sections filled (or equivalent in phase doc) +- [ ] Phase document created/updated (from [phase-template.md](phase-template.md)) including Enterprise Phase Discovery Record, Business Analysis, and Domain Model +- [ ] [phase-handover.md](phase-handover.md) sections filled (or equivalent in phase doc) including Discovery summary, Enterprise Completeness Sign-Off, and Definition of Done - [ ] [progress.md](../progress.md) — completed work only - [ ] [next-steps.md](../next-steps.md) — immediate next milestone only - [ ] [module-registry.md](../module-registry.md) — version, phase, events, permissions, migration - [ ] [phase-manifest.yaml](phase-manifest.yaml) — status / dependencies - [ ] [service-manifest.yaml](service-manifest.yaml) — version / phase / APIs / events if service changed +- [ ] OpenAPI reflects new/changed routes (for API phases) ### When Applicable @@ -32,13 +33,15 @@ Code and documentation ship together. A phase that changes behavior without docs - [ ] Service README under `backend/services//` - [ ] Phase area README under [phases/](../phases/) - [ ] Frontend docs under [frontend/](../frontend/) if UI shipped +- [ ] Mandatory artifact N/A justifications recorded ### Never - Do not put future work into `progress.md` - Do not overwrite accepted ADRs -- Do not duplicate [master-prompt.md](master-prompt.md) rules into phase prompts +- Do not duplicate [master-prompt.md](master-prompt.md) / DoD / completeness rules into phase prompts - Do not leave broken links or undocumented public surfaces for the phase +- Do not mark Complete while documenting missing current-phase artifacts as “limitations” ## Cross-Link Requirements @@ -48,16 +51,19 @@ Every new phase doc must link at least: - Related ADR(s) - Module registry entry - Development standards (principles / testing) as needed -- AI framework loop/gates when the phase is AI-implemented +- AI/Enterprise framework: Discovery, loop, gates, DoD (when AI-implemented) - Progress / next-steps ## Validation -Before completion, run Documentation + Cross Reference + Broken Link validation from [quality-gates.md](quality-gates.md). +Before completion, run Documentation + Cross Reference + Broken Link validation from [quality-gates.md](quality-gates.md), and confirm Enterprise Completeness documentation dimension. ## Related Documents - [docs/README.md](../README.md) - [Phase Template](phase-template.md) - [Phase Handover](phase-handover.md) +- [Definition of Done](definition-of-done.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Enterprise Completeness](enterprise-completeness.md) - [templates/](../templates/) (registry-oriented short templates) diff --git a/docs/ai-framework/enterprise-completeness.md b/docs/ai-framework/enterprise-completeness.md new file mode 100644 index 0000000..6beb3bf --- /dev/null +++ b/docs/ai-framework/enterprise-completeness.md @@ -0,0 +1,85 @@ +# Enterprise Completeness + +Before closing every phase, the Framework **must** verify the dimensions below. If anything required for the **current phase** is missing, it **must** be implemented before status may become Complete. + +Companion docs: [enterprise-phase-discovery.md](enterprise-phase-discovery.md) · [definition-of-done.md](definition-of-done.md) · [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) · [quality-gates.md](quality-gates.md) · [ADR-018](../architecture/adr/ADR-018.md) · [ADR-019](../architecture/adr/ADR-019.md). + +## Completeness Dimensions + +| Dimension | Pass means | +| --- | --- | +| Discovery completeness | Discovery Record present; Missing→Implement gaps closed; boundaries respected | +| Business completeness | All in-scope / Discovery-promoted capabilities delivered; CRUD-only rejected; domain rules enforced | +| Architectural completeness | Boundaries, layering, ADRs, FE/BE, DB-per-service respected | +| API completeness | REST + health + capabilities/metrics as required; OpenAPI accurate; list UX (page/filter/sort/search) | +| Permission completeness | Every privileged route mapped; permission tree documented; denial tests | +| Capability completeness | Discoverable features exposed; entitlement keys documented when gated | +| Event completeness | State changes that matter cross-service emit outbox events; catalog updated | +| Migration completeness | Owning-service Alembic revision(s); upgrade smoke; notes in handover | +| Repository completeness | Tenant-scoped persistence; soft-delete defaults; no business logic in repos | +| Validation completeness | Edge + domain validators; illegal transitions rejected with stable codes | +| Security completeness | Authn/authz; no secret leakage; provider credentials owned correctly | +| Performance completeness | Indexes; no obvious N+1; hot paths validated or justified N/A | +| Documentation completeness | Phase, registries, manifests, reference, handover current | +| Testing completeness | Unit, integration, architecture, security, tenant, docs tests as required | +| Tenant isolation | Schema + query + API + negative tests | +| Provider abstraction | External systems behind protocols; registry updated | +| Integration completeness | Only API / Events / Provider interfaces — no foreign DB or duplicated logic | +| Operational readiness | Health, metrics/logging, config, jobs when required | +| Developer experience | Service README, OpenAPI, clear errors, runnable tests | +| Maintainability | Clear layering, no drive-by refactors, patterns match sibling services | +| Self Audit | Scope check, TODO scan, layering/tenancy/secrets/events verified | + +## Automatic Verification Procedure + +1. Confirm [enterprise-phase-discovery.md](enterprise-phase-discovery.md) Record and closed gaps. +2. Walk [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) for every in-scope aggregate/API. +3. Walk [definition-of-done.md](definition-of-done.md) checklist. +4. Run [quality-gates.md](quality-gates.md) (includes Discovery + expanded enterprise gates). +5. Record results in the phase handover **Enterprise Completeness Sign-Off**. +6. If any required item fails: implement the missing work, then re-verify (Automatic Repair Loop). + +## Failure Policy + +| Situation | Action | +| --- | --- | +| Missing required artifact for current phase | Implement it now; do not mark Complete | +| Artifact belongs to a future phase | Leave it out; document under Out of Scope / Known Limitations | +| Artifact belongs to another service | Integrate via API/Events/Providers only ([boundary-rules.md](boundary-rules.md)) | +| Roadmap text is high-level | Derive production components for this phase only — do not invent next-phase products | + +## Sign-Off Block (copy into handover) + +```markdown +### Enterprise Completeness Sign-Off + +- [ ] Discovery completeness +- [ ] Business completeness +- [ ] Architectural completeness +- [ ] API completeness +- [ ] Permission completeness +- [ ] Capability completeness +- [ ] Event completeness +- [ ] Migration completeness +- [ ] Repository completeness +- [ ] Validation completeness +- [ ] Security completeness +- [ ] Performance completeness +- [ ] Documentation completeness +- [ ] Testing completeness +- [ ] Tenant isolation +- [ ] Provider abstraction +- [ ] Integration completeness +- [ ] Operational readiness +- [ ] Developer experience +- [ ] Maintainability +- [ ] Self Audit +``` + +## Related Documents + +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Quality Gates](quality-gates.md) +- [Definition of Done](definition-of-done.md) +- [Boundary Rules](boundary-rules.md) +- [Phase Handover](phase-handover.md) diff --git a/docs/ai-framework/enterprise-phase-discovery.md b/docs/ai-framework/enterprise-phase-discovery.md new file mode 100644 index 0000000..a1dce96 --- /dev/null +++ b/docs/ai-framework/enterprise-phase-discovery.md @@ -0,0 +1,167 @@ +# Enterprise Phase Discovery + +Mandatory **pre-implementation** process for every phase. + +Before writing production code for a phase, the Framework **MUST** automatically perform Enterprise Phase Discovery. Discovery determines the **complete responsibility** of the current phase and derives every missing production-ready capability inside that phase boundary. + +This rule extends [ADR-018](../architecture/adr/ADR-018.md) via [ADR-019](../architecture/adr/ADR-019.md). Companions: [project-index.yaml](project-index.yaml) · [runtime-read-policy.md](runtime-read-policy.md) · [context-cache-policy.md](context-cache-policy.md) · [service-snapshot-policy.md](service-snapshot-policy.md) · [definition-of-done.md](definition-of-done.md) · [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) · [boundary-rules.md](boundary-rules.md) · [enterprise-completeness.md](enterprise-completeness.md). + +**Document loading:** Start from [project-index.yaml](project-index.yaml); resolve snapshot, roadmap, and handover from index — never scan folders. + +## Absolute Rules + +1. **Discovery runs before Implementation** (and before Domain Modeling finalizes the build list). +2. **Never assume CRUD is sufficient.** +3. **Automatically derive** every missing production-ready capability required for the **current phase**. +4. If anything required for the current phase is missing, it **MUST** become part of the implementation before Complete. +5. **Never move responsibilities from future phases.** +6. **Never violate Service Boundaries** ([boundary-rules.md](boundary-rules.md)). +7. **Never duplicate another service.** + +## Discovery Inputs (mandatory sources) + +The agent must **synthesize** every input below that applies to the phase’s service/area. **Load [project-index.yaml](project-index.yaml) first**; resolve paths from the active service entry; then follow Runtime Read Policy and Context Cache Policy. Never scan repository folders. + +| Input | Required | Primary source | +| --- | --- | --- | +| Service discovery | Yes | **Project Index** active service entry | +| Service baseline | Yes | Project Index + service snapshot | +| Current Roadmap | Yes | Snapshot + policy | +| Current Architecture | When triggered | Snapshot `active_adrs` + policy | +| Service Boundaries | Yes | Snapshot + manifests | +| Previous Phase Handover | Yes | Project Index `latest_handover` | +| Existing Codebase | Yes | Code delta verification | +| Existing APIs | Yes | Snapshot `public_apis` + code | +| Existing Domain Model | Yes | Snapshot + code | +| Existing Events | Yes | Snapshot `published_events` + code | +| Existing Permissions | Yes | Snapshot + code | +| Existing Aggregates | Yes | Snapshot modules + code | +| Existing Tests | Yes | Codebase | +| Existing Documentation | Yes | Snapshot + policy | + +Docs-only / registration-only phases still run Discovery against documentation and manifests (codebase may be N/A). + +## Discovery Procedure + +1. **Load context** — [project-index.yaml](project-index.yaml) execution entry: index → policies → snapshot → manifests → latest handover; additional docs per Runtime Read Policy only. +2. **State phase boundary** — Objective, In Scope, Out of Scope, owning service(s), forbidden foreign ownership, next-phase exclusions. +3. **Inventory baseline** — From Project Index + snapshot (APIs, events, modules, phases, limitations, TODOs); verify deltas against codebase — not completed phase doc history. +4. **Derive target responsibility** — From snapshot + roadmap + handover + boundaries, state the complete capability set this phase must own when Complete. +5. **Gap analysis** — Diff baseline vs target using the Missing Capability Checklist below. +6. **Promote gaps to Scope** — Every gap that belongs to **this** phase becomes mandatory implementation work (not a TODO, not a “limitation”). +7. **Reject illegal gaps** — Gaps owned by another service → integrate via API/Events/Providers only. Gaps owned by a future phase → leave Out of Scope. +8. **Publish Discovery Record** — Write the Enterprise Phase Discovery section into the phase doc (see template) before coding. +9. **Gate** — Architecture Validation and Implementation may proceed only after the Discovery Record is complete. + +## Missing Capability Checklist + +Identify missing items for the **current phase** (mark Present / Missing→Implement / N/A justified / Future-phase / Other-service): + +| Capability class | Examples | +| --- | --- | +| Business Capabilities | End-to-end use cases, not mere table CRUD | +| Entities | ORM/domain entities for in-scope data | +| Value Objects | Money, codes, periods, identifiers as needed | +| Aggregates | Consistency boundaries and invariants | +| Services | Domain orchestration | +| Policies | Authz / entitlement / domain policies | +| Specifications | Reusable predicates / query specs | +| Repositories | Tenant-scoped persistence | +| Commands | State-changing use cases | +| Queries | Read use cases without write side-effects | +| REST APIs | Resource endpoints for in-scope capabilities | +| Capability APIs | `/capabilities` or equivalent | +| Permission APIs / tree | `{service}.*` registration and checks | +| Health APIs | `/health` | +| Metrics | `/metrics` or justified ops alternative | +| Events | Outbox domain/integration events | +| Background Jobs | Async/scheduled work when required | +| Configurations | Env/settings keys | +| Feature Flags | Gated capabilities when required | +| Provider Interfaces | Protocols for external systems | +| Validation Rules | Edge + domain validators | +| Audit | Trail for state-changing actions | +| Search | Collection search | +| Filtering | Collection filters | +| Sorting | Stable sort keys | +| Pagination | Page/cursor meta | +| Soft Delete | Master/business record policy | +| Optimistic Locking | When concurrency risk exists | +| Tenant Isolation | Schema, queries, APIs, tests | +| Documentation | Phase, registries, OpenAPI, handover | +| Tests | Unit, integration, architecture, security, tenant, etc. | +| Operational Readiness | Health, metrics/logging, config, jobs | +| Developer Experience | README, clear errors, runnable tests | + +## Discovery Record (required in phase doc) + +```markdown +## Enterprise Phase Discovery + +| Field | Value | +| --- | --- | +| Phase ID | | +| Owning service(s) | | +| Discovery date | | +| Inputs reviewed | Roadmap · Architecture · Boundaries · Prior handover · Code · APIs · Domain · Events · Permissions · Aggregates · Tests · Docs | + +### Baseline Inventory (exists today) +- + +### Target Responsibility (this phase when Complete) +- + +### Gap Analysis + +| Capability | Status | Action | +| --- | --- | --- | +| ... | Present / Missing / N/A / Future / Other-service | Implement / Skip-justify / Integrate / Defer | + +### Promoted to Implementation Scope +- + +### Explicitly Excluded (future phase or other service) +- + +### Boundary Confirmations +- [ ] No future-phase pull-forward +- [ ] No service-boundary violation +- [ ] No duplication of another service’s logic +- [ ] CRUD-only delivery rejected +``` + +## Relationship to Other Framework Stages + +| Stage | Relationship | +| --- | --- | +| Business Analysis | Discovery includes and extends BA with codebase/API/test inventory | +| Architecture Validation | Uses Discovery boundary conclusions | +| Domain Modeling | Models only what Discovery promoted into scope | +| Implementation | Must cover every Discovery gap marked Missing→Implement | +| Enterprise Completeness / Quality Gates | Fail if Discovery was skipped or gaps remain unimplemented | +| Definition of Done | Incomplete without a Discovery Record for implementation phases | + +## Failure Policy + +| Situation | Action | +| --- | --- | +| Discovery skipped | Phase incomplete — run Discovery before claiming progress | +| Gap marked Missing but not implemented | Must implement before Complete | +| Agent “assumes CRUD enough” | Fail Business Completeness + Discovery gates | +| Gap belongs to future phase | Document under Excluded; do not implement | +| Gap belongs to another service | Integrate only; do not re-implement | + +## Related Documents + +- [Project Index](project-index.yaml) +- [Runtime Read Policy](runtime-read-policy.md) +- [Context Cache Policy](context-cache-policy.md) +- [Service Snapshot Policy](service-snapshot-policy.md) +- [Development Loop](development-loop.md) +- [Definition of Done](definition-of-done.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) +- [Enterprise Completeness](enterprise-completeness.md) +- [Boundary Rules](boundary-rules.md) +- [Quality Gates](quality-gates.md) +- [Phase Template](phase-template.md) +- [ADR-019](../architecture/adr/ADR-019.md) diff --git a/docs/ai-framework/entity-template.md b/docs/ai-framework/entity-template.md index 6362b0d..f64dc46 100644 --- a/docs/ai-framework/entity-template.md +++ b/docs/ai-framework/entity-template.md @@ -39,17 +39,26 @@ Standards for domain entities / ORM models in every service. | Rule | Detail | | --- | --- | -| Default for master/business records | Prefer `deleted_at` / `is_deleted` pattern used by sibling modules | +| Default for master/business records | Prefer `deleted_at` / `is_deleted` pattern used by sibling modules — **mandatory** unless justified N/A | | Queries | Default repositories exclude soft-deleted rows | | Uniqueness | Unique constraints must consider soft-delete strategy (partial unique indexes when required) | | Hard delete | Only when phase explicitly allows (e.g. ephemeral logs with retention policy) | +## Optimistic Locking + +| Rule | Detail | +| --- | --- | +| When | Concurrent updates are a domain risk (shared editable aggregates) | +| How | `version` / row-version column; conflict → 409 with stable error code | +| Tests | Concurrent update conflict covered when locking is enabled | + ## Audit 1. Every business action must be auditable ([project-principles.md](../development/project-principles.md)). 2. Prefer `created_by` / `updated_by` (actor ids) on mutable entities when the service pattern uses them. 3. Sensitive domains keep append-only audit tables (CRM/Loyalty/Accounting patterns). 4. Audit rows are tenant-scoped and immutable after insert. +5. Aggregate design must state which entity is the consistency boundary for the phase. ## Validation @@ -62,9 +71,11 @@ Standards for domain entities / ORM models in every service. ## Entity Checklist (per new table) - [ ] Table name + model documented in phase +- [ ] Aggregate ownership stated - [ ] `tenant_id` (if business data) - [ ] Indexes / uniques defined - [ ] Soft delete policy stated +- [ ] Optimistic locking stated (Yes / N/A with reason) - [ ] Audit fields or audit table linkage - [ ] Migration included - [ ] Schema reference updated when public/shared docs require it ([database-schema.md](../reference/database-schema.md)) @@ -73,5 +84,6 @@ Standards for domain entities / ORM models in every service. - [Database Architecture](../architecture/database-architecture.md) - [Repository Template](repository-template.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) - [Coding Standards](../development/coding-standards.md) - [ADR-001](../architecture/adr/ADR-001.md) · [ADR-003](../architecture/adr/ADR-003.md) diff --git a/docs/ai-framework/event-template.md b/docs/ai-framework/event-template.md index 451e225..8b53e18 100644 --- a/docs/ai-framework/event-template.md +++ b/docs/ai-framework/event-template.md @@ -1,70 +1,75 @@ -# Event Template - -Standards for domain and integration events. - -## Domain Events - -- Emitted when an aggregate state change may matter inside or outside the service. -- Written via outbox in the same DB transaction as the write ([ADR-006](../architecture/adr/ADR-006.md)). -- Consumers treat events as facts; producers do not assume synchronous handling. - -## Integration Events - -- Cross-service contracts for interoperability (same envelope). -- Stable payload fields; evolve additively when possible. -- Consumers must be idempotent (inbox / `event_id`). - -## Naming - -Preferred forms (follow the owning service’s established style): - -| Style | Example | When | -| --- | --- | --- | -| `{aggregate}.{past_tense}` | `tenant.created` | Core / generic | -| `{service}.{aggregate}.{past_tense}` | `crm.lead.created`, `loyalty.member.enrolled` | Namespaced business services | - -Use past tense. Do not use generic names like `update` without the aggregate. - -## Versioning - -1. Envelope fields remain stable ([event-driven-architecture.md](../architecture/event-driven-architecture.md)). -2. Payload additive changes preferred. -3. Breaking payload changes: new event type or explicit `payload_version` documented in catalog and handover. -4. Never reuse an `event_type` string for a different meaning. - -## Payload Rules - -Envelope (canonical): - -```json -{ - "event_id": "uuid", - "event_type": "service.aggregate.past_tense", - "aggregate_type": "aggregate", - "aggregate_id": "uuid", - "tenant_id": "uuid|null", - "source_service": "service-name", - "payload": {}, - "occurred_at": "ISO-8601" -} -``` - -Payload must: - -- Include enough IDs for consumers to fetch details via API if needed -- Avoid embedding secrets or large binaries -- Prefer refs over duplicated foreign aggregates -- Remain JSON-serializable - -## Catalog & Registration - -1. List new events in the phase doc and [event-catalog.md](../reference/event-catalog.md). -2. Update [module-registry.md](../module-registry.md) Events fields. -3. Update [service-manifest.yaml](service-manifest.yaml) event list. - -## Related Documents - -- [Event-Driven Architecture](../architecture/event-driven-architecture.md) -- [ADR-006](../architecture/adr/ADR-006.md) -- [Services Contracts](../reference/services-contracts.md) -- [Service Layer Template](service-layer-template.md) +# Event Template + +Standards for domain and integration events. + +## Domain Events + +- Emitted when an aggregate state change may matter inside or outside the service. +- Written via outbox in the same DB transaction as the write ([ADR-006](../architecture/adr/ADR-006.md)). +- Consumers treat events as facts; producers do not assume synchronous handling. + +## Integration Events + +- Cross-service contracts for interoperability (same envelope). +- Stable payload fields; evolve additively when possible. +- Consumers must be idempotent (inbox / `event_id`). + +## Naming + +Preferred forms (follow the owning service’s established style): + +| Style | Example | When | +| --- | --- | --- | +| `{aggregate}.{past_tense}` | `tenant.created` | Core / generic | +| `{service}.{aggregate}.{past_tense}` | `crm.lead.created`, `loyalty.member.enrolled` | Namespaced business services | + +Use past tense. Do not use generic names like `update` without the aggregate. + +## Versioning + +1. Envelope fields remain stable ([event-driven-architecture.md](../architecture/event-driven-architecture.md)). +2. Payload additive changes preferred. +3. Breaking payload changes: new event type or explicit `payload_version` documented in catalog and handover. +4. Never reuse an `event_type` string for a different meaning. + +## Payload Rules + +Envelope (canonical): + +```json +{ + "event_id": "uuid", + "event_type": "service.aggregate.past_tense", + "aggregate_type": "aggregate", + "aggregate_id": "uuid", + "tenant_id": "uuid|null", + "source_service": "service-name", + "payload": {}, + "occurred_at": "ISO-8601" +} +``` + +Payload must: + +- Include enough IDs for consumers to fetch details via API if needed +- Avoid embedding secrets or large binaries +- Prefer refs over duplicated foreign aggregates +- Remain JSON-serializable + +## Catalog & Registration + +1. List new events in the phase doc and [event-catalog.md](../reference/event-catalog.md). +2. Update [module-registry.md](../module-registry.md) Events fields. +3. Update [service-manifest.yaml](service-manifest.yaml) event list. + +## Completeness + +Outbox events for cross-service-relevant state changes are a **mandatory phase artifact** unless justified N/A ([mandatory-phase-artifacts.md](mandatory-phase-artifacts.md), [enterprise-completeness.md](enterprise-completeness.md)). Missing event emission for in-scope integrations fails the Event Completeness gate. + +## Related Documents + +- [Event-Driven Architecture](../architecture/event-driven-architecture.md) +- [ADR-006](../architecture/adr/ADR-006.md) +- [Services Contracts](../reference/services-contracts.md) +- [Service Layer Template](service-layer-template.md) +- [Quality Gates](quality-gates.md) diff --git a/docs/ai-framework/mandatory-phase-artifacts.md b/docs/ai-framework/mandatory-phase-artifacts.md new file mode 100644 index 0000000..f643906 --- /dev/null +++ b/docs/ai-framework/mandatory-phase-artifacts.md @@ -0,0 +1,99 @@ +# Mandatory Phase Artifacts + +Every implementation phase **automatically** includes the artifacts below unless the phase is explicitly **documentation-only** or an item is marked **N/A** with written justification tied to the phase boundary. + +This list is normative. Agents must not wait for the phase prompt to restate it. ([ADR-018](../architecture/adr/ADR-018.md)) + +## Artifact Matrix + +| # | Artifact | Required when | Primary standard | +| --- | --- | --- | --- | +| 0 | Enterprise Phase Discovery | Always (before Implementation) | [enterprise-phase-discovery.md](enterprise-phase-discovery.md) | +| 1 | Business Analysis | Always (implementation phases) | [phase-template.md](phase-template.md) · [definition-of-done.md](definition-of-done.md) | +| 2 | Architecture Validation | Always | [development-loop.md](development-loop.md) · ADRs · [module-boundaries.md](../architecture/module-boundaries.md) | +| 3 | Domain Modeling | New/changed domain surface | Phase doc Domain Model section | +| 4 | Aggregate Design | New/changed aggregates | [entity-template.md](entity-template.md) · DDD aggregate rules | +| 5 | Entity Design | New/changed tables/models | [entity-template.md](entity-template.md) | +| 6 | DTO Design | Any API surface | [api-template.md](api-template.md) | +| 7 | Repository Design | Persistence for aggregates | [repository-template.md](repository-template.md) | +| 8 | Service Design | Business logic | [service-layer-template.md](service-layer-template.md) | +| 9 | Specification Pattern | Reusable predicates / query specs | `app/specifications/` · service-layer template | +| 10 | Policy Layer | Authz, entitlement, domain policies | `app/policies/` · authorization architecture | +| 11 | Commands | State-changing use cases | `app/commands/` or explicit service command methods | +| 12 | Queries | Read use cases | `app/queries/` or explicit query services | +| 13 | REST APIs | Externalizable capabilities | [api-template.md](api-template.md) | +| 14 | Capability APIs | Platform/discoverable services | `/capabilities` | +| 15 | Health APIs | Every runnable service | `/health` | +| 16 | Metrics | Runnable services with ops surface | `/metrics` or justified N/A | +| 17 | Permission APIs / tree | Privileged operations | `{service}.*` · permission registration | +| 18 | OpenAPI Documentation | Any new/changed HTTP routes | FastAPI OpenAPI / exported schema | +| 19 | Validation Layer | Any input or domain rule | Pydantic + `app/validators/` | +| 20 | Pagination | Collection list endpoints | Shared page meta | +| 21 | Filtering | List endpoints with attributes | Query params + repository filters | +| 22 | Sorting | List endpoints | Stable sort keys documented | +| 23 | Searching | List endpoints with text find | Search params + indexed fields where needed | +| 24 | Soft Delete | Master/business records | [entity-template.md](entity-template.md) | +| 25 | Optimistic Locking | Concurrent update risk | Version/row version column | +| 26 | Audit Trail | State-changing business actions | Audit tables / audit service | +| 27 | Outbox Events | Cross-service-relevant changes | [event-template.md](event-template.md) · [ADR-006](../architecture/adr/ADR-006.md) | +| 28 | Tenant Isolation | All business data | [ADR-003](../architecture/adr/ADR-003.md) | +| 29 | Migration | Schema changes | Alembic in owning service only | +| 30 | Seed Data | Bootstrap/reference data required | Idempotent seeds documented | +| 31 | Background Jobs | Async/scheduled work in scope | Celery/worker pattern of the service | +| 32 | Configuration | Always for runtime services | Env / settings — no secrets in code | +| 33 | Feature Flags | Gated capabilities in scope | Entitlement / toggle pattern | +| 34 | Provider Interfaces | External systems | `app/providers/` protocols · [provider-registry.md](../provider-registry.md) | +| 35 | Dependency Injection | Always | Explicit FastAPI/deps or constructor injection | +| 36 | Unit Tests | Always when logic exists | [testing-template.md](testing-template.md) | +| 37 | Integration Tests | Always when APIs/flows exist | [testing-template.md](testing-template.md) | +| 38 | Architecture Tests | Service/module code phases | Boundary / layering tests | +| 39 | Security Tests | Privileged or tenant APIs | Authn/authz negatives | +| 40 | Performance Validation | Hot paths or lists | Indexes, N+1 guard, timing N/A justified | +| 41 | Documentation | Always | [documentation-template.md](documentation-template.md) | +| 42 | Self Audit | Always | [development-loop.md](development-loop.md) | +| 43 | Service Snapshot | Service-scoped phases on Complete | [service-snapshot-policy.md](service-snapshot-policy.md) · Project Index `snapshot_file` | +| 44 | Project Index Update | Service registration or phase Complete | [project-index.yaml](project-index.yaml) | + +## Package Layout Expectation + +For implementation phases inside a service, prefer (create when missing for in-scope work): + +``` +app/ + api/v1/ + commands/ + queries/ + policies/ + specifications/ + validators/ + providers/ + services/ + repositories/ + models/ + schemas/ + events/ + permissions/ + tests/ +``` + +Existing services that use equivalent patterns (command methods on services, inline specs) may keep their style **if** responsibilities are equally covered and documented. New services should follow the layout above. + +## N/A Rules + +An artifact may be N/A only when: + +1. The phase is documentation-only / registration-only, **or** +2. The artifact cannot apply inside the phase boundary (e.g. no list endpoints → Searching N/A), **and** +3. The phase doc or handover records the justification. + +N/A is **never** allowed for Tenant Isolation, Documentation, Self Audit, Architecture Validation, Service Snapshot (when service-scoped), Project Index update (when service-scoped), or Definition of Done overall on implementation phases that touch business data. + +## Related Documents + +- [Project Index](project-index.yaml) +- [Service Snapshot Policy](service-snapshot-policy.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Definition of Done](definition-of-done.md) +- [Enterprise Completeness](enterprise-completeness.md) +- [Service Template](service-template.md) +- [Module Template](module-template.md) diff --git a/docs/ai-framework/master-prompt.md b/docs/ai-framework/master-prompt.md index d8f3594..35f32b1 100644 --- a/docs/ai-framework/master-prompt.md +++ b/docs/ai-framework/master-prompt.md @@ -4,28 +4,53 @@ Permanent instructions for every AI-assisted implementation on TorbatYar. **Do not copy this file into phase prompts.** Phase prompts reference it and describe only the current phase ([prompt-rules.md](prompt-rules.md)). +The framework is the **single source of truth** for how phases are executed ([ADR-013](../architecture/adr/ADR-013.md), [ADR-018](../architecture/adr/ADR-018.md)). Enterprise production quality is the **default** — not an optional add-on prompt. + +**Document loading:** Start from [project-index.yaml](project-index.yaml) — then [Runtime Read Policy](runtime-read-policy.md), [Context Cache Policy](context-cache-policy.md), service snapshot, manifests, latest handover. Never scan repository folders for discovery. + ## Global Project Rules 1. TorbatYar is a multi-tenant, modular, API-first, microservice-ready SuperApp SaaS. 2. Source of truth for docs: [`docs/README.md`](../README.md). Code and docs ship together. 3. Brand, colors, and secrets are never hardcoded — config / `.env` / database only. 4. Frontend (`frontend/`) and Backend (`backend/`) are strictly separated ([ADR-002](../architecture/adr/ADR-002.md)). -5. No phase is complete with failing tests, missing docs, broken links, or leftover TODOs for claimed work. +5. No phase is complete with failing tests, missing docs, broken links, leftover TODOs for claimed work, or incomplete Definition of Done. 6. If implementation conflicts with architecture or ADRs: **stop**, explain, fix docs/ADR first, then code. 7. Do not modify unrelated completed modules when executing a scoped phase. -8. Prefer extending existing patterns from Core, Identity, Accounting, CRM, Loyalty, and Communication over inventing new structures. +8. Prefer extending existing patterns from Core, Identity, Accounting, CRM, Loyalty, Communication, and later platform services over inventing new structures. +9. **CRUD is never sufficient** for phase completion ([definition-of-done.md](definition-of-done.md)). + +## Enterprise Completeness Rules + +1. **Enterprise Phase Discovery is mandatory** before Implementation ([enterprise-phase-discovery.md](enterprise-phase-discovery.md), [ADR-019](../architecture/adr/ADR-019.md)). +2. Every implementation phase automatically includes all [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) applicable to its boundary. +3. Satisfy [definition-of-done.md](definition-of-done.md) before marking Complete. +4. Verify [enterprise-completeness.md](enterprise-completeness.md) dimensions; implement any missing **current-phase** requirement before Complete. +5. If the roadmap describes a module only at a high level, **derive** every missing technical component for production readiness **inside this phase** — without pulling future-phase product features forward. +6. Quality gates include Discovery and enterprise completeness checks ([quality-gates.md](quality-gates.md)). +7. Never assume CRUD is sufficient; Discovery gap analysis rejects CRUD-only scope. ## Architecture Rules 1. Database-per-service — no cross-DB queries or foreign keys ([ADR-001](../architecture/adr/ADR-001.md)). 2. Inter-service communication: REST, Webhook, Async Event, Outbox/Inbox only ([ADR-006](../architecture/adr/ADR-006.md)). -3. Internal layering: `API → Services → Repositories → Models` ([service-architecture.md](../architecture/service-architecture.md)). -4. Business logic only in Services; repositories are persistence-only; routers stay thin ([project-principles.md](../development/project-principles.md)). -5. Respect [module-boundaries.md](../architecture/module-boundaries.md) — never take ownership of another module’s aggregates. +3. Internal layering: `API → Services → Repositories → Models` ([service-architecture.md](../architecture/service-architecture.md)), with Commands, Queries, Policies, Specifications, Validators as required. +4. Business logic only in Services (or command handlers); repositories are persistence-only; routers stay thin ([project-principles.md](../development/project-principles.md)). +5. Respect [module-boundaries.md](../architecture/module-boundaries.md) and [boundary-rules.md](boundary-rules.md) — never take ownership of another module’s aggregates. 6. Accounting journal entries only via Posting Engine ([ADR-010](../architecture/adr/ADR-010.md)). 7. Product AI is optional and independent ([ai-architecture.md](../architecture/ai-architecture.md)) — core flows must work when AI is off. 8. New architectural decisions require a new ADR before conflicting implementation ([adr/](../architecture/adr/)). +## Boundary Rules (summary) + +Full text: [boundary-rules.md](boundary-rules.md). + +1. Never implement responsibilities owned by another service. +2. Never duplicate business logic across services. +3. Never access another service database. +4. Only integrate through APIs, Events, and Provider interfaces. +5. Never move functionality from future phases into the current phase. + ## Multi-Tenancy Rules 1. Every business table carries `tenant_id` ([ADR-003](../architecture/adr/ADR-003.md)). @@ -42,7 +67,7 @@ Permanent instructions for every AI-assisted implementation on TorbatYar. 3. Cross-service references use external IDs / refs only — never shared tables. 4. Provider adapters live inside the owning service (e.g. SMS providers in Communication only — [ADR-012](../architecture/adr/ADR-012.md)). 5. Shared library (`backend/shared-lib`) holds envelopes, JWT helpers, exceptions — not business workflows. -6. Register new services in [service-manifest.yaml](service-manifest.yaml) and [module-registry.md](../module-registry.md). +6. Register new services in [service-manifest.yaml](service-manifest.yaml), [project-index.yaml](project-index.yaml), and [module-registry.md](../module-registry.md). ## Documentation Rules @@ -54,6 +79,7 @@ Permanent instructions for every AI-assisted implementation on TorbatYar. 6. Cross-link architecture, ADR, registries, development standards, and reference docs. 7. No undocumented public API, event, permission tree, or migration for the phase. 8. Framework permanent rules live under `docs/ai-framework/` — do not duplicate them elsewhere. +9. OpenAPI must reflect new/changed routes before Complete. ## Coding Rules @@ -64,25 +90,30 @@ Permanent instructions for every AI-assisted implementation on TorbatYar. 5. Events: `{aggregate}.{past_tense}` (or service-prefixed where already established, e.g. `crm.*`, `loyalty.*`). 6. Migrations: reviewed Alembic revisions; no silent destructive changes without phase scope. 7. No wildcard imports; raise shared exceptions with stable error codes. -8. Optimistic locking / audit actors where the domain requires them (follow sibling services). +8. Soft delete, optimistic locking, audit actors, pagination/filter/sort/search where collections and concurrency require them ([mandatory-phase-artifacts.md](mandatory-phase-artifacts.md)). +9. Prefer explicit Commands, Queries, Policies, Specifications packages (or equivalent documented patterns). +10. Dependency injection via constructors / FastAPI deps — no hidden globals for domain services. ## Quality Rules 1. Pass all gates in [quality-gates.md](quality-gates.md). 2. Mandatory tests per [testing-template.md](testing-template.md) and [testing-strategy.md](../development/testing-strategy.md). -3. Architecture, tenant isolation, permission, security, and documentation validation are required for service/module phases. -4. Automatic repair loop until gates are green ([development-loop.md](development-loop.md)). +3. Architecture, tenant isolation, permission, security, performance, and documentation validation are required for service/module phases. +4. Automatic repair loop until gates are green — **implement missing required artifacts**, do not weaken gates ([development-loop.md](development-loop.md)). 5. Self-audit before claiming completion ([phase-handover.md](phase-handover.md)). +6. Enterprise Completeness sign-off required ([enterprise-completeness.md](enterprise-completeness.md)). ## AI Implementation Rules -1. Read framework + architecture + phase docs before writing code ([cursor-guidelines.md](cursor-guidelines.md)). -2. Implement only the current phase scope — no speculative features. -3. Do not weaken security, tenancy, or boundaries to “make it work.” -4. Prefer contracts/protocols for platform dependencies; do not implement foreign platforms inside a business module. -5. When blocked by missing architecture: create/update ADR and docs first. -6. Never invent permanent global rules inside a phase prompt — extend this framework instead. -7. On inconsistency: repair automatically and re-validate. +1. Load [project-index.yaml](project-index.yaml) first; then Runtime Read Policy, Context Cache Policy, service snapshot, manifests, latest handover; follow [cursor-guidelines.md](cursor-guidelines.md) for implementation order. Never scan folders to discover services or docs. +2. Run [Enterprise Phase Discovery](enterprise-phase-discovery.md) before Implementation — baseline from service snapshot + latest handover; synthesize remaining inputs per policy mapping. +3. Implement only the current phase scope (Discovery-promoted) — no speculative future-phase features. +4. Do not weaken security, tenancy, or boundaries to “make it work.” +5. Prefer contracts/protocols for platform dependencies; do not implement foreign platforms inside a business module. +6. When blocked by missing architecture: create/update ADR and docs first. +7. Never invent permanent global rules inside a phase prompt — extend this framework instead. +8. On inconsistency: repair automatically and re-validate. +9. Production readiness is automatic — do not ask whether to add health, permissions, tests, audit, events, or OpenAPI when they apply to the phase. ## Dependency Rules @@ -97,37 +128,46 @@ Permanent instructions for every AI-assisted implementation on TorbatYar. Mirror of [project-principles.md](../development/project-principles.md): -- Tenant-aware · Auditable · Service-layer logic · Thin API · Tests required · Docs required · No TODO in completed phases · No cross-tenant query · Posting Engine only for journals · Compliance independent · AI independent · Event-ready · API-first · Documented · Database-per-service · FE/BE separation · No hardcoded brand/secrets · Docs before conflicting code · Phase completion gate. +- Tenant-aware · Auditable · Service-layer logic · Thin API · Tests required · Docs required · No TODO in completed phases · No cross-tenant query · Posting Engine only for journals · Compliance independent · AI independent · Event-ready · API-first · Documented · Database-per-service · FE/BE separation · No hardcoded brand/secrets · Docs before conflicting code · Phase completion gate · **Enterprise DoD** · **CRUD never sufficient** · **Boundary rules enforced** · **Enterprise Phase Discovery mandatory**. ## Rules for Adding New Services 1. Follow [service-template.md](service-template.md). -2. Create `backend/services//` with independent DB, Alembic, health endpoint, permissions, events. -3. Register in Core service/module registry (when wiring), [service-manifest.yaml](service-manifest.yaml), [module-registry.md](../module-registry.md). -4. Document public APIs in [reference/](../reference/) as contracts stabilize. +2. Create `backend/services//` with independent DB, Alembic, health, capabilities, metrics (or justified N/A), permissions, events, providers, commands/queries/policies/specs as applicable. +3. Register in Core service/module registry (when wiring), [service-manifest.yaml](service-manifest.yaml), [project-index.yaml](project-index.yaml), [module-registry.md](../module-registry.md). +4. Document public APIs in [reference/](../reference/) as contracts stabilize; keep OpenAPI accurate. 5. Add compose/env samples only when the phase scopes runtime wiring. -6. Create ADR when the service introduces a platform-level ownership decision (see Loyalty ADR-011, Communication ADR-012). +6. Create ADR when the service introduces a platform-level ownership decision. 7. UI belongs in `frontend/` only. ## Rules for Adding New Modules 1. Prefer a module inside an existing owning service when the domain clearly belongs there ([module-template.md](module-template.md)). -2. If the capability is shared across many business domains, create an independent service instead (Loyalty, Communication pattern). +2. If the capability is shared across many business domains, create an independent service instead. 3. Update module registry, phase docs, permissions, events, and tests. -4. Do not expand a Sales CRM module into Customer360, Marketing, or Messaging ownership. +4. Deliver full mandatory artifacts for the module’s phase boundary — not CRUD-only. ## Rules for Extending Existing Services 1. Stay within published boundaries; do not absorb foreign aggregates. 2. Additive migrations preferred; breaking API changes require versioning strategy ([api-template.md](api-template.md)). 3. Preserve tenant isolation and audit trails. -4. Update events/catalog, permissions, tests, progress, next-steps, and handover. +4. Update events/catalog, permissions, tests, progress, next-steps, handover, [project-index.yaml](project-index.yaml), and regenerate service snapshot per [service-snapshot-policy.md](service-snapshot-policy.md). 5. Do not “drive-by” refactor unrelated packages. 6. Backward compatibility is a quality gate unless the phase explicitly documents a breaking change with migration notes. ## Related Documents -- [AI Framework README](README.md) +- [Project Index](project-index.yaml) +- [Runtime Read Policy](runtime-read-policy.md) +- [Context Cache Policy](context-cache-policy.md) +- [Service Snapshot Policy](service-snapshot-policy.md) +- [AI / Enterprise Framework README](README.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Definition of Done](definition-of-done.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) +- [Enterprise Completeness](enterprise-completeness.md) +- [Boundary Rules](boundary-rules.md) - [Development Loop](development-loop.md) - [Quality Gates](quality-gates.md) - [Architecture Overview](../architecture/architecture.md) diff --git a/docs/ai-framework/module-template.md b/docs/ai-framework/module-template.md index 9e617a5..ac0a3af 100644 --- a/docs/ai-framework/module-template.md +++ b/docs/ai-framework/module-template.md @@ -19,12 +19,15 @@ Registry field template: [templates/module-template.md](../templates/module-temp ## Design Steps -1. Confirm ownership in [module-boundaries.md](../architecture/module-boundaries.md) and [module-registry.md](../module-registry.md). -2. Define Objective / Scope / Out of Scope with [phase-template.md](phase-template.md). -3. Add entities ([entity-template.md](entity-template.md)), repositories, services, validators, permissions, events, APIs. -4. Additive Alembic migration in the **owning** service only. -5. Tests + docs + handover. -6. Update module registry “Current Phase” / version / events / permissions. +1. Confirm ownership in [module-boundaries.md](../architecture/module-boundaries.md), [boundary-rules.md](boundary-rules.md), and [module-registry.md](../module-registry.md). +2. Run [Enterprise Phase Discovery](enterprise-phase-discovery.md); publish Discovery Record before coding. +3. Define Objective / Scope / Out of Scope / Business Analysis with [phase-template.md](phase-template.md). +3. Domain model: aggregates, entities, DTOs. +4. Add entities ([entity-template.md](entity-template.md)), repositories, specifications, policies, commands/queries, services, validators, permissions, events, APIs (incl. list page/filter/sort/search). +5. Additive Alembic migration in the **owning** service only; seeds/jobs/flags when required. +6. Tests + docs + handover with Definition of Done and Enterprise Completeness. +7. Update module registry “Current Phase” / version / events / permissions. +8. Do **not** stop at CRUD; do **not** pull future-phase features forward. ## Module Checklist @@ -50,10 +53,12 @@ Registry field template: [templates/module-template.md](../templates/module-temp | --- | --- | | Models | [entity-template.md](entity-template.md) | | Repositories | [repository-template.md](repository-template.md) | -| Services | [service-layer-template.md](service-layer-template.md) | -| APIs | [api-template.md](api-template.md) | +| Specifications / Policies | [service-layer-template.md](service-layer-template.md) | +| Commands / Queries / Services | [service-layer-template.md](service-layer-template.md) | +| APIs / OpenAPI | [api-template.md](api-template.md) | | Events | [event-template.md](event-template.md) | | Tests | [testing-template.md](testing-template.md) | +| Artifacts | [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) | ### Tenant Awareness @@ -79,6 +84,9 @@ List what this module must **not** own (copy from parent boundaries and phase Ou - [Service Template](service-template.md) - [Master Prompt](master-prompt.md) +- [Definition of Done](definition-of-done.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Boundary Rules](boundary-rules.md) - [Module Boundaries](../architecture/module-boundaries.md) - [Module Registry](../module-registry.md) - [templates/module-template.md](../templates/module-template.md) diff --git a/docs/ai-framework/phase-handover.md b/docs/ai-framework/phase-handover.md index fddc6ba..09fa240 100644 --- a/docs/ai-framework/phase-handover.md +++ b/docs/ai-framework/phase-handover.md @@ -4,6 +4,8 @@ Every implementation phase must deliver this package before completion. Copy the sections below into the phase document (or attach a phase-specific handover file) and fill every field. Empty sections are only allowed when marked **N/A** with justification. +Enterprise mandates: [enterprise-phase-discovery.md](enterprise-phase-discovery.md) · [definition-of-done.md](definition-of-done.md) · [enterprise-completeness.md](enterprise-completeness.md) · [service-snapshot-policy.md](service-snapshot-policy.md) · [project-index.yaml](project-index.yaml) · [ADR-018](../architecture/adr/ADR-018.md) · [ADR-019](../architecture/adr/ADR-019.md). + ## Metadata | Field | Value | @@ -16,6 +18,22 @@ Copy the sections below into the phase document (or attach a phase-specific hand | Date | | | ADR(s) | | +## Enterprise Phase Discovery Summary + +| Item | Detail | +| --- | --- | +| Discovery Record location | Phase doc section link | +| Gaps promoted & closed | | +| Exclusions (future / other service) | | +| CRUD-only rejected | Yes | + +## Business Analysis Summary + +| Item | Detail | +| --- | --- | +| Capabilities delivered | | +| Explicit non-goals honored | | + ## Reusable Components List libraries, engines, providers, shared helpers, or patterns introduced for reuse. @@ -30,7 +48,7 @@ List libraries, engines, providers, shared helpers, or patterns introduced for r | --- | --- | --- | --- | | | | | | -Link detailed contracts: [api-reference.md](../reference/api-reference.md), [services-contracts.md](../reference/services-contracts.md). +Include Health / Capabilities / Metrics when applicable. Link detailed contracts: [api-reference.md](../reference/api-reference.md), [services-contracts.md](../reference/services-contracts.md). Confirm OpenAPI updated. ## Events @@ -40,6 +58,12 @@ Link detailed contracts: [api-reference.md](../reference/api-reference.md), [ser Catalog: [event-catalog.md](../reference/event-catalog.md). Standards: [event-template.md](event-template.md). +## Permissions + +| Permission | Routes / actions | +| --- | --- | +| | | + ## Extension Points Document intentional hooks for future phases (provider protocols, strategy interfaces, feature flags, entitlement keys). @@ -50,7 +74,8 @@ Document intentional hooks for future phases (provider protocols, strategy inter ## Known Limitations -Explicit non-goals and temporary constraints (must not be silently omitted). +Explicit non-goals and temporary constraints (must not be silently omitted). +**Do not** list missing current-phase mandatory artifacts here — those must be implemented before Complete. - @@ -61,7 +86,7 @@ Explicit non-goals and temporary constraints (must not be silently omitted). | Alembic revision(s) | | | Upgrade steps | | | Downgrade support | | -| Data backfill | | +| Data backfill / seeds | | | Breaking changes | None / described | ## Dependencies @@ -70,14 +95,84 @@ Explicit non-goals and temporary constraints (must not be silently omitted). | --- | --- | --- | | | Core / Service / Provider / Docs | | +## Boundary Compliance + +- [ ] No foreign service ownership implemented +- [ ] No business logic duplication +- [ ] No cross-DB access +- [ ] Integrations only via API / Events / Providers +- [ ] No future-phase functionality pulled forward + +## Enterprise Completeness Sign-Off + +Copy from [enterprise-completeness.md](enterprise-completeness.md): + +- [ ] Discovery completeness +- [ ] Business completeness +- [ ] Architectural completeness +- [ ] API completeness +- [ ] Permission completeness +- [ ] Capability completeness +- [ ] Event completeness +- [ ] Migration completeness +- [ ] Repository completeness +- [ ] Validation completeness +- [ ] Security completeness +- [ ] Performance completeness +- [ ] Documentation completeness +- [ ] Testing completeness +- [ ] Tenant isolation +- [ ] Provider abstraction +- [ ] Integration completeness +- [ ] Operational readiness +- [ ] Developer experience +- [ ] Maintainability +- [ ] Self Audit + +## Definition of Done + +- [ ] [definition-of-done.md](definition-of-done.md) checklist satisfied for this phase boundary +- [ ] [enterprise-phase-discovery.md](enterprise-phase-discovery.md) Record complete; Missing→Implement gaps closed +- [ ] CRUD-only delivery rejected / not claimed as complete +- [ ] Applicable [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) delivered or justified N/A + +## Service Snapshot Update + +Regenerate after every Complete phase for the owning service ([service-snapshot-policy.md](service-snapshot-policy.md)). + +| Field | Value | +| --- | --- | +| Snapshot path | From Project Index `snapshot_file` | +| Snapshot version | | +| Last updated | | +| Fields changed | | + +Confirm all required snapshot fields reflect this phase's deliverables. + +## Project Index Update + +Update [project-index.yaml](project-index.yaml) for the owning service on every Complete phase and on service registration. + +| Field | Value | +| --- | --- | +| Service identifier | | +| Current version | | +| Current phase | | +| Next phase | | +| Latest handover | | +| Snapshot file | | + ## Next Phase Entry What the next phase must read and assume: -1. This handover + phase doc -2. Updated [module-registry.md](../module-registry.md) / manifests -3. Open limitations that become next-phase scope -4. Suggested next phase ID from [phase-manifest.yaml](phase-manifest.yaml) / [next-steps.md](../next-steps.md) +1. [project-index.yaml](project-index.yaml) active service entry (primary discovery) +2. Service snapshot at Project Index `snapshot_file` +3. This handover + phase doc (including Discovery exclusions that become next-phase scope) +4. Updated [module-registry.md](../module-registry.md) / manifests +5. Open limitations that become next-phase scope +6. Suggested next phase ID from Project Index `next_phase` / [next-steps.md](../next-steps.md) +7. Enterprise Framework rules — no need to restate in the next prompt | Field | Value | | --- | --- | @@ -87,17 +182,28 @@ What the next phase must read and assume: ## Completion Sign-Off +- [ ] Project Index updated ([project-index.yaml](project-index.yaml)) +- [ ] Service snapshot regenerated ([service-snapshot-policy.md](service-snapshot-policy.md)) - [ ] Quality gates passed ([quality-gates.md](quality-gates.md)) +- [ ] Enterprise Phase Discovery gate passed - [ ] Tests green - [ ] Documentation updated ([documentation-template.md](documentation-template.md)) - [ ] Progress / next-steps / registries updated - [ ] No TODO for claimed deliverables - [ ] Self audit completed ([development-loop.md](development-loop.md)) +- [ ] Enterprise Completeness signed off +- [ ] Definition of Done satisfied ## Related Documents +- [Project Index](project-index.yaml) +- [Service Snapshot Policy](service-snapshot-policy.md) - [Phase Template](phase-template.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) - [Development Loop](development-loop.md) +- [Definition of Done](definition-of-done.md) +- [Enterprise Completeness](enterprise-completeness.md) +- [Boundary Rules](boundary-rules.md) - [Module Registry](../module-registry.md) - [Progress](../progress.md) - [Next Steps](../next-steps.md) diff --git a/docs/ai-framework/phase-template.md b/docs/ai-framework/phase-template.md index 734e9e5..6f215e8 100644 --- a/docs/ai-framework/phase-template.md +++ b/docs/ai-framework/phase-template.md @@ -4,6 +4,8 @@ Reusable template for every implementation phase. Copy to `docs/-phase- @@ -18,11 +20,67 @@ Registry-oriented short form also exists at [templates/phase-template.md](../tem | Depends On | | | ADR(s) | | | Manifest | [phase-manifest.yaml](phase-manifest.yaml) | +| Framework | [ADR-013](../architecture/adr/ADR-013.md) · [ADR-018](../architecture/adr/ADR-018.md) · [ADR-019](../architecture/adr/ADR-019.md) | ## Objective One paragraph: measurable outcome of this phase. +## Enterprise Phase Discovery + +> **Required before Implementation.** Copy the full Discovery Record from [enterprise-phase-discovery.md](enterprise-phase-discovery.md). + +| Field | Value | +| --- | --- | +| Discovery date | | +| Inputs reviewed | Roadmap · Architecture · Boundaries · Prior handover · Code · APIs · Domain · Events · Permissions · Aggregates · Tests · Docs | + +### Baseline Inventory (exists today) + +- + +### Target Responsibility (this phase when Complete) + +- + +### Gap Analysis + +| Capability | Status | Action | +| --- | --- | --- | +| Business Capabilities | | | +| Entities / Value Objects / Aggregates | | | +| Services / Policies / Specifications | | | +| Repositories / Commands / Queries | | | +| REST / Capability / Permission / Health / Metrics APIs | | | +| Events / Jobs / Config / Feature Flags / Providers | | | +| Validation / Audit / Search / Filter / Sort / Pagination | | | +| Soft Delete / Optimistic Locking / Tenant Isolation | | | +| Documentation / Tests / Ops / DX | | | + +### Promoted to Implementation Scope + +- + +### Explicitly Excluded (future phase or other service) + +- + +### Boundary Confirmations + +- [ ] No future-phase pull-forward +- [ ] No service-boundary violation +- [ ] No duplication of another service’s logic +- [ ] CRUD-only delivery rejected + +## Business Analysis + +| Item | Detail | +| --- | --- | +| Actors | | +| Business capabilities (in phase) | | +| Success metrics | | +| Non-goals | | + ## Scope ### In Scope @@ -35,22 +93,60 @@ One paragraph: measurable outcome of this phase. | --- | --- | --- | | | new / extend | | -### Models +### Domain Model -| Entity | Soft delete | Audit | Tenant | Notes | -| --- | --- | --- | --- | --- | -| | Yes/No | Yes/No | Yes | | +| Aggregate | Entities | Invariants | +| --- | --- | --- | +| | | | + +### Models / Entities + +| Entity | Soft delete | Audit | Tenant | Optimistic lock | Notes | +| --- | --- | --- | --- | --- | --- | +| | Yes/No | Yes/No | Yes | Yes/No | | Standards: [entity-template.md](entity-template.md). +### DTOs + +| Schema | Create | Update | Read | List | +| --- | --- | --- | --- | --- | +| | | | | | + +Standards: [api-template.md](api-template.md). + ### Repositories -| Repository | Aggregates | Tenant filter | -| --- | --- | --- | -| | | Required | +| Repository | Aggregates | Tenant filter | Page/Filter/Sort/Search | +| --- | --- | --- | --- | +| | | Required | | Standards: [repository-template.md](repository-template.md). +### Specifications + +| Specification | Purpose | +| --- | --- | +| | | + +### Policies + +| Policy | Purpose | +| --- | --- | +| | | + +### Commands + +| Command | Effect | +| --- | --- | +| | | + +### Queries + +| Query | Result | +| --- | --- | +| | | + ### Services | Service class | Responsibilities | @@ -81,11 +177,29 @@ Standards: [event-template.md](event-template.md). ### APIs -| Method | Path | Permission | -| --- | --- | --- | -| | `/api/v1/...` | | +| Method | Path | Permission | Notes | +| --- | --- | --- | --- | +| GET | `/health` | public/ops | | +| GET | `/capabilities` | as designed | | +| GET | `/metrics` | as designed | or N/A | +| | `/api/v1/...` | | include page/filter/sort/search on lists | -Standards: [api-template.md](api-template.md). +Standards: [api-template.md](api-template.md). OpenAPI must be updated. + +### Providers + +| Interface | Owning service | Notes | +| --- | --- | --- | +| | | or N/A | + +### Configuration / Feature Flags / Jobs / Seeds + +| Kind | Items | Required? | +| --- | --- | --- | +| Configuration | | Yes for runtime | +| Feature flags | | If gated | +| Background jobs | | If async/scheduled | +| Seed data | | If bootstrap/reference needed | ### Tests @@ -93,11 +207,12 @@ Mandatory categories (mark N/A with reason only if truly not applicable): - [ ] Unit - [ ] Repository -- [ ] Service +- [ ] Service / Command / Query - [ ] Integration / API - [ ] Migration - [ ] Permission - [ ] Security +- [ ] Architecture / Boundary - [ ] Performance (if hot path) - [ ] Tenant Isolation - [ ] Documentation Validation @@ -110,25 +225,37 @@ Standards: [testing-template.md](testing-template.md). - [ ] Progress / Next Steps / Roadmap as applicable - [ ] Module Registry / Provider Registry - [ ] Reference (API / events / schema) if public surface changed +- [ ] OpenAPI - [ ] ADR if architectural decision introduced - [ ] Manifests updated -- [ ] Handover completed +- [ ] Handover completed (incl. Enterprise Completeness Sign-Off) Standards: [documentation-template.md](documentation-template.md). +### Mandatory Artifacts Trace + +Confirm each applicable row from [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) is In Scope, Done, or N/A (justified): + +- [ ] Trace completed in phase doc or handover appendix + ## Out of Scope -Explicit exclusions (prevent scope creep): +Explicit exclusions (prevent scope creep and future-phase pull-forward): - ## Completion Criteria - [ ] Objective met +- [ ] Enterprise Phase Discovery Record complete; Missing→Implement gaps closed +- [ ] Business Analysis complete - [ ] Scope delivered; Out of Scope untouched +- [ ] [Definition of Done](definition-of-done.md) satisfied +- [ ] [Enterprise Completeness](enterprise-completeness.md) signed off - [ ] Tests green - [ ] Quality gates passed ([quality-gates.md](quality-gates.md)) - [ ] [phase-handover.md](phase-handover.md) filled +- [ ] Boundary rules respected ([boundary-rules.md](boundary-rules.md)) - [ ] No TODO for claimed work - [ ] Development loop completed ([development-loop.md](development-loop.md)) @@ -146,8 +273,12 @@ Explicit exclusions (prevent scope creep): ## Related Documents -- [AI Framework](README.md) +- [AI / Enterprise Framework](README.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) - [Master Prompt](master-prompt.md) +- [Definition of Done](definition-of-done.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) +- [Boundary Rules](boundary-rules.md) - [Module Registry](../module-registry.md) - [Architecture Overview](../architecture/architecture.md) - [Project Principles](../development/project-principles.md) diff --git a/docs/ai-framework/prompt-rules.md b/docs/ai-framework/prompt-rules.md index fead4a2..577949a 100644 --- a/docs/ai-framework/prompt-rules.md +++ b/docs/ai-framework/prompt-rules.md @@ -1,68 +1,75 @@ -# Prompt Rules - -How every future AI phase prompt must behave. - -## Core Rules - -1. **Never duplicate permanent instructions.** Do not paste [master-prompt.md](master-prompt.md), project principles, or entire architecture docs into a phase prompt. -2. **Only describe the current phase.** Objective, Scope, Out of Scope, modules/services touched, completion criteria, and phase-specific constraints. -3. **Always use the framework documents.** Instruct the agent to read and follow `docs/ai-framework/` plus architecture, ADRs, registries, and development standards. -4. **Reference, don’t rewrite.** Link to templates, gates, and manifests instead of redefining them. -5. **No silent scope expansion.** Out of Scope must be explicit; agents must not implement adjacent phases. -6. **No business logic in framework prompts.** Framework docs stay process-only; phase prompts carry feature intent. -7. **Completion stops the agent.** After gates pass and handover is done, stop — do not start the next phase unless asked. - -## Required Phase Prompt Skeleton - -```markdown -# Phase - -Follow the permanent AI Development Framework: -- docs/ai-framework/README.md -- docs/ai-framework/master-prompt.md -- docs/ai-framework/development-loop.md -- docs/ai-framework/quality-gates.md -- docs/ai-framework/cursor-guidelines.md -- docs/ai-framework/prompt-rules.md - -Also read docs/README.md, architecture/*, ADRs, development/*, module-registry, glossary, -phase-manifest.yaml, service-manifest.yaml, and this phase’s documents. - -## Objective -... - -## Scope -... - -## Out of Scope -... - -## Required Templates -(list only those that apply: service/module/entity/repository/service-layer/api/event/testing/documentation) - -## Completion Criteria -- Development loop completed -- Quality gates passed -- Phase handover completed -- Registries/manifests/progress/next-steps updated -- Stop after this phase only -``` - -## Forbidden Prompt Behaviors - -- Re-stating Database-per-Service, tenancy, or FE/BE rules in full (link instead) -- Asking to “also quickly do” the next phase -- Allowing TODO leftovers for claimed work -- Instructing agents to bypass quality gates -- Overwriting accepted ADRs - -## When to Extend the Framework - -If a permanent rule is missing, update `docs/ai-framework/` (and ADR if architectural) in a dedicated docs change — do not bury permanent rules inside a single phase prompt. - -## Related Documents - -- [Master Prompt](master-prompt.md) -- [Cursor Guidelines](cursor-guidelines.md) -- [Phase Template](phase-template.md) -- [docs/README.md](../README.md) +# Prompt Rules + +How every future AI phase prompt must behave. + +## Core Rules + +1. **Never duplicate permanent instructions.** Do not paste [master-prompt.md](master-prompt.md), Definition of Done, enterprise completeness checklists, project principles, or entire architecture docs into a phase prompt. +2. **Only describe the current phase.** Objective, Scope, Out of Scope, modules/services touched, completion criteria, and phase-specific constraints. +3. **Follow Runtime Read Policy.** Instruct the agent to follow [runtime-read-policy.md](runtime-read-policy.md); do not duplicate document lists in phase prompts. +4. **Enterprise quality is automatic.** Do not ask whether to add health, permissions, tests, audit, events, OpenAPI, pagination, or other [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) — the framework already requires them when applicable. +5. **Enterprise Phase Discovery is automatic.** Agents must run [enterprise-phase-discovery.md](enterprise-phase-discovery.md) before Implementation — do not restate the full Discovery procedure in the phase prompt. +6. **Reference, don’t rewrite.** Link to templates, gates, DoD, Discovery, and manifests instead of redefining them. +7. **No silent scope expansion.** Out of Scope must be explicit; agents must not implement adjacent or future phases ([boundary-rules.md](boundary-rules.md)). +8. **No business logic in framework prompts.** Framework docs stay process-only; phase prompts carry feature intent. +9. **Completion stops the agent.** After Discovery gaps closed + DoD + gates + handover are done, stop — do not start the next phase unless asked. +10. **CRUD is never enough.** Phase prompts must not imply that CRUD-only delivery completes a phase. + +## Required Phase Prompt Skeleton + +```markdown +# Phase <ID> — <Title> + +Follow [Runtime Read Policy](runtime-read-policy.md). + +Permanent framework rules live under `docs/ai-framework/` — link to them; do not paste ([prompt-rules.md](prompt-rules.md)). + +## Objective +... + +## Scope +... + +## Out of Scope +(explicit; do not pull future phases forward) + +## Required Templates +(list only those that apply: service/module/entity/repository/service-layer/api/event/testing/documentation) + +## Completion Criteria +- Enterprise Phase Discovery completed (gaps promoted & closed) +- Business Analysis + Domain Modeling completed +- Mandatory phase artifacts delivered for this boundary +- Definition of Done satisfied (CRUD is not sufficient) +- Enterprise Completeness signed off +- Development loop completed +- Quality gates passed +- Phase handover completed +- Registries/manifests/progress/next-steps updated +- Stop after this phase only +``` + +## Forbidden Prompt Behaviors + +- Re-stating Database-per-Service, tenancy, FE/BE, DoD, Discovery procedure, or completeness matrices in full (link instead) +- Asking to “also quickly do” the next phase +- Allowing TODO leftovers for claimed work +- Instructing agents to bypass quality gates or mark missing artifacts as “limitations” +- Overwriting accepted ADRs +- Treating CRUD as the definition of done +- Skipping Discovery or coding before the Discovery Record exists +- Waiving tenant isolation, audit, events, or tests for convenience + +## When to Extend the Framework + +If a permanent rule is missing, update `docs/ai-framework/` (and ADR if architectural) in a dedicated docs change — do not bury permanent rules inside a single phase prompt. + +## Related Documents + +- [Runtime Read Policy](runtime-read-policy.md) +- [Master Prompt](master-prompt.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Definition of Done](definition-of-done.md) +- [Cursor Guidelines](cursor-guidelines.md) +- [Phase Template](phase-template.md) +- [docs/README.md](../README.md) diff --git a/docs/ai-framework/quality-gates.md b/docs/ai-framework/quality-gates.md index 0267fec..003ef6f 100644 --- a/docs/ai-framework/quality-gates.md +++ b/docs/ai-framework/quality-gates.md @@ -2,18 +2,109 @@ Mandatory gates before any implementation phase may be marked complete. -All gates must **Pass**. On failure, enter the Automatic Repair Loop ([development-loop.md](development-loop.md)). +All gates must **Pass**. On failure, enter the Automatic Repair Loop ([development-loop.md](development-loop.md)) and **implement missing required artifacts** — do not waive enterprise requirements. + +Companions: [enterprise-phase-discovery.md](enterprise-phase-discovery.md) · [definition-of-done.md](definition-of-done.md) · [enterprise-completeness.md](enterprise-completeness.md) · [boundary-rules.md](boundary-rules.md) · [service-snapshot-policy.md](service-snapshot-policy.md) · [project-index.yaml](project-index.yaml) · [ADR-018](../architecture/adr/ADR-018.md) · [ADR-019](../architecture/adr/ADR-019.md). + +## Enterprise Phase Discovery + +| Check | Pass criteria | +| --- | --- | +| Record | Discovery Record present in phase doc before Implementation claimed | +| Inputs | Roadmap, Architecture, Boundaries, Prior handover, Codebase, APIs, Domain, Events, Permissions, Aggregates, Tests, Docs reviewed (or N/A justified for docs-only) | +| Gap analysis | Missing Capability Checklist completed; CRUD-only rejected | +| Promotion | Every Missing→Implement gap implemented before Complete | +| Boundaries | No future-phase pull-forward; no foreign ownership; no service duplication | +| Align | [enterprise-phase-discovery.md](enterprise-phase-discovery.md) | + +## Project Index + +| Check | Pass criteria | +| --- | --- | +| Exists | [project-index.yaml](project-index.yaml) present and valid YAML | +| Coverage | Every registered service in [service-manifest.yaml](service-manifest.yaml) has an index entry | +| Fields | All required fields per service (name, product, identifier, status, version, phases, snapshot, roadmap, handover, manifests, registry, permission, DB, API port, prefixes, endpoints) | +| Consistency | Index agrees with service-manifest, latest handover, and snapshot for owning service | +| Freshness | Updated on service registration and phase Complete (`current_version`, `current_phase`, `next_phase`, `latest_handover`, `snapshot_file`) | +| Discovery | Agents resolve paths from index — no repository folder scans | +| Align | [project-index.yaml](project-index.yaml) · [runtime-read-policy.md](runtime-read-policy.md) | + +## Service Snapshot + +| Check | Pass criteria | +| --- | --- | +| Exists | Project Index `snapshot_file` present for service-scoped Complete phases (or initial snapshot built on first entry) | +| Fields | All required fields from [service-snapshot-policy.md](service-snapshot-policy.md) populated | +| Freshness | Regenerated after this phase; `last_updated` and `last_handover_reference` match completion | +| Consistency | Snapshot agrees with handover, manifests, and in-scope codebase | +| Usage | Next phase may use snapshot as primary state — no required re-read of completed phase docs | +| Align | [service-snapshot-policy.md](service-snapshot-policy.md) | + +## Business Completeness + +| Check | Pass criteria | +| --- | --- | +| Capabilities | Every in-scope / Discovery-promoted business capability delivered end-to-end | +| Not CRUD-only | Domain rules, validators, policies/specs as needed beyond CRUD | +| DoD | [definition-of-done.md](definition-of-done.md) checklist complete for phase boundary | +| No future pull-forward | Out of Scope / future phases untouched | ## Architecture | Check | Pass criteria | | --- | --- | -| Boundaries | No ownership theft per [module-boundaries.md](../architecture/module-boundaries.md) | +| Boundaries | No ownership theft per [module-boundaries.md](../architecture/module-boundaries.md) and [boundary-rules.md](boundary-rules.md) | | DB isolation | No cross-DB queries/FKs ([ADR-001](../architecture/adr/ADR-001.md)) | -| Layering | Business logic in services; thin API; persistence-only repos | +| Layering | Business logic in services/commands; thin API; persistence-only repos | | FE/BE | No backend imports in frontend; no UI rules in backend ([ADR-002](../architecture/adr/ADR-002.md)) | | ADR | New architectural decisions recorded; conflicts resolved | | Events | Outbox-ready; naming per [event-template.md](event-template.md) | +| Domain model | Aggregates/entities/DTOs designed for in-scope work | + +## API Completeness + +| Check | Pass criteria | +| --- | --- | +| REST | In-scope resources exposed with correct methods and DTOs | +| Health | `/health` present for runnable services | +| Capabilities | `/capabilities` (or equivalent) when discoverable features exist | +| Metrics | `/metrics` or justified N/A with operational alternative | +| OpenAPI | Generated/updated and accurate for new/changed routes | +| List UX | Pagination, filtering, sorting, searching on collection endpoints | +| Permission APIs | Permission tree registered; permission listing routes when service pattern requires | + +## Permission Completeness + +| Check | Pass criteria | +| --- | --- | +| Mapping | Every privileged route has a permission check | +| Naming | `{service}.{resource}.{action}` / `{service}.*` trees documented | +| Tests | Denial cases covered | + +## Event Completeness + +| Check | Pass criteria | +| --- | --- | +| Emission | Cross-service-relevant state changes write outbox events | +| Catalog | [event-catalog.md](../reference/event-catalog.md) / registry updated | +| Envelope | Stable envelope fields; additive payloads | + +## Repository Completeness + +| Check | Pass criteria | +| --- | --- | +| Tenant filter | All tenant-owned queries scoped | +| Soft delete | Defaults exclude soft-deleted rows where policy requires | +| List support | Pagination/filter/sort/search supported as API requires | +| No domain logic | Business branching stays in services | + +## Validation Completeness + +| Check | Pass criteria | +| --- | --- | +| Edge | Pydantic request/query validation | +| Domain | Validators / specifications / policies enforce invariants | +| Codes | Stable error codes for illegal transitions | ## Security @@ -40,6 +131,7 @@ All gates must **Pass**. On failure, enter the Automatic Repair Loop ([developme | Suite | Required tests from [testing-template.md](testing-template.md) exist and pass | | Strategy | Aligns with [testing-strategy.md](../development/testing-strategy.md) | | Claims | Every claimed deliverable has coverage | +| Categories | Unit · Integration · Architecture · Security · Tenant · Permission · Migration · Docs as applicable | ## Documentation @@ -50,6 +142,7 @@ All gates must **Pass**. On failure, enter the Automatic Repair Loop ([developme | Registries | Module/provider/manifests consistent with code | | Glossary | New terms defined when introduced | | No TODO | No TODO for claimed deliverables | +| OpenAPI | Documented as part of API surface | ## Migration @@ -58,6 +151,7 @@ All gates must **Pass**. On failure, enter the Automatic Repair Loop ([developme | Alembic | Revision exists for schema changes in owning service | | Apply | Upgrade smoke succeeds | | Ownership | Only owning DB migrated | +| Seed | Seed data present when phase requires it | | Notes | Handover includes migration notes | ## Backward Compatibility @@ -77,6 +171,52 @@ All gates must **Pass**. On failure, enter the Automatic Repair Loop ([developme | Tests | Cross-tenant denial covered | | Admin exceptions | Explicit and audited | +## Provider Abstraction + +| Check | Pass criteria | +| --- | --- | +| Protocols | External systems behind interfaces in owning service | +| Registry | [provider-registry.md](../provider-registry.md) updated when providers change | +| No leakage | Foreign platforms not implemented inside non-owning modules | + +## Integration Completeness + +| Check | Pass criteria | +| --- | --- | +| Channels | Only API / Events / Providers used cross-service | +| No duplication | Business logic not copied from owning services | +| No foreign DB | Zero cross-DB access | + +## Operational Readiness + +| Check | Pass criteria | +| --- | --- | +| Health | Liveness endpoint green | +| Observability | Logging + metrics (or justified N/A) | +| Config | Env/settings documented; no secrets in code | +| Jobs | Background jobs present when phase requires async/scheduled work | +| Feature flags | Present when gated capabilities are in scope | + +## Maintainability & DX + +| Check | Pass criteria | +| --- | --- | +| Patterns | Matches sibling service conventions or documents intentional deviation | +| DI | Explicit dependency injection | +| README | Service README updated for new run/config surfaces | +| Scope hygiene | No unrelated drive-by refactors | + +## Definition of Done Gate + +| Check | Pass criteria | +| --- | --- | +| DoD | Full [definition-of-done.md](definition-of-done.md) satisfied | +| Completeness | [enterprise-completeness.md](enterprise-completeness.md) sign-off complete | +| Artifacts | Applicable [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md) delivered or justified N/A | +| Discovery | [enterprise-phase-discovery.md](enterprise-phase-discovery.md) Record complete; gaps closed | +| Snapshot | [service-snapshot-policy.md](service-snapshot-policy.md) regenerated when service-scoped | +| Project Index | [project-index.yaml](project-index.yaml) updated when service-scoped | + ## Validation Suites (Framework Phases) When changing `docs/ai-framework/` or docs structure, also verify: @@ -87,10 +227,21 @@ When changing `docs/ai-framework/` or docs structure, also verify: 4. **Broken Link Validation** — relative Markdown links resolve. 5. **Template Validation** — every template referenced from README exists and has Related Documents. 6. **Manifest Validation** — YAML parses; required fields present; phase/service IDs unique. +7. **Enterprise Rule Validation** — DoD, Discovery, mandatory artifacts, completeness, boundary docs, and Project Index cross-linked from master-prompt, loop, gates, and phase template. +8. **Project Index Validation** — YAML parses; every service-manifest entry indexed; paths resolve; no orphan snapshot references. ## Sign-Off +- [ ] Enterprise Phase Discovery +- [ ] Project Index +- [ ] Service Snapshot +- [ ] Business Completeness - [ ] Architecture +- [ ] API Completeness +- [ ] Permission Completeness +- [ ] Event Completeness +- [ ] Repository Completeness +- [ ] Validation Completeness - [ ] Security - [ ] Performance - [ ] Testing @@ -98,10 +249,20 @@ When changing `docs/ai-framework/` or docs structure, also verify: - [ ] Migration - [ ] Backward Compatibility - [ ] Tenant Isolation +- [ ] Provider Abstraction +- [ ] Integration Completeness +- [ ] Operational Readiness +- [ ] Maintainability & DX +- [ ] Definition of Done / Enterprise Completeness ## Related Documents +- [Project Index](project-index.yaml) +- [Service Snapshot Policy](service-snapshot-policy.md) - [Development Loop](development-loop.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) - [Master Prompt](master-prompt.md) +- [Definition of Done](definition-of-done.md) +- [Enterprise Completeness](enterprise-completeness.md) - [docs/README.md](../README.md) Phase Completion Gate - [Project Principles](../development/project-principles.md) diff --git a/docs/ai-framework/repository-template.md b/docs/ai-framework/repository-template.md index 9ed61cb..0d108bb 100644 --- a/docs/ai-framework/repository-template.md +++ b/docs/ai-framework/repository-template.md @@ -34,13 +34,16 @@ Repositories must **not**: ```text get_by_id(tenant_id, id) -> Model | None -list(tenant_id, *, filters, pagination) -> Sequence[Model] +list(tenant_id, *, filters, sort, search, pagination) -> Sequence[Model] + total/meta add(entity) / save(entity) -# optional: soft_delete(tenant_id, id) +soft_delete(tenant_id, id) # when soft-delete policy applies +# optional: update with version check for optimistic locking ``` Follow existing service repository base classes when present (`repositories/base.py`). +Compose reusable predicates via specifications when the service uses `app/specifications/`. + ## Tenant Isolation 1. Every query for tenant-owned tables includes `tenant_id`. @@ -56,5 +59,6 @@ See [testing-template.md](testing-template.md) — Repository section. - [Service Architecture](../architecture/service-architecture.md) - [Entity Template](entity-template.md) - [Service Layer Template](service-layer-template.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) - [Coding Standards](../development/coding-standards.md) - [Project Principles](../development/project-principles.md) diff --git a/docs/ai-framework/runtime-read-policy.md b/docs/ai-framework/runtime-read-policy.md new file mode 100644 index 0000000..347cced --- /dev/null +++ b/docs/ai-framework/runtime-read-policy.md @@ -0,0 +1,162 @@ +# Runtime Read Policy + +**Authoritative source for document loading** during every AI-assisted phase on TorbatYar. + +All framework stages ([development-loop.md](development-loop.md), [enterprise-phase-discovery.md](enterprise-phase-discovery.md), [master-prompt.md](master-prompt.md)) defer to this policy. When any other document says “read all …”, “read framework + architecture …”, or lists unconditional doc paths, **this policy wins**. + +**Incremental loading:** [context-cache-policy.md](context-cache-policy.md) governs whether to re-read or reuse cached context between phases. This policy still defines *what* may load; the cache policy defines *when to re-read* unchanged files. + +**Service state:** [service-snapshot-policy.md](service-snapshot-policy.md) provides compact per-service state. **[project-index.yaml](project-index.yaml) is the single runtime discovery entry point** — resolve service, snapshot, roadmap, and handover paths from the index; never scan repository folders. + +## Execution entry (every phase) + +At phase start, in **exact** order: + +1. Load [Project Index](project-index.yaml). +2. Load [Runtime Read Policy](runtime-read-policy.md) (this document). +3. Load [Context Cache Policy](context-cache-policy.md). +4. Load the current **service snapshot** — path from Project Index `snapshot_file` for the active service. +5. Read [phase-manifest.yaml](phase-manifest.yaml). +6. Read [service-manifest.yaml](service-manifest.yaml). +7. Read **only** the latest handover — path from Project Index `latest_handover` for the active service. +8. Read additional documents only when this policy requires them. + +**Never scan** folders to discover services, handovers, roadmaps, snapshots, ADRs, or architecture documents — resolve through Project Index. + +## Loading tiers + +Agents MUST load documents only from the tiers below. Do not bulk-read directories or unrelated services. + +### ALWAYS READ + +Required context at phase start. Split by [context-cache-policy.md](context-cache-policy.md): + +**Always reload from disk (every new phase):** + +| Document | Path | +| --- | --- | +| Project Index | [project-index.yaml](project-index.yaml) | +| Service snapshot | From Project Index `snapshot_file` for active service | +| Phase manifest | [phase-manifest.yaml](phase-manifest.yaml) | +| Service manifest | [service-manifest.yaml](service-manifest.yaml) | +| Progress | [progress.md](../progress.md) | +| Next steps | [next-steps.md](../next-steps.md) | +| Latest phase handover | From Project Index `latest_handover` for active service | + +Also load the **current phase brief** (phase doc or prompt) when assigned — new each phase. + +**Required but cache-eligible (reload only if changed):** + +| Document | Path | +| --- | --- | +| Module registry | [module-registry.md](../module-registry.md) | +| Glossary | [glossary.md](../glossary.md) | +| Docs index | [docs/README.md](../README.md) | +| Definition of Done | [definition-of-done.md](definition-of-done.md) | +| Quality gates | [quality-gates.md](quality-gates.md) | +| Development loop | [development-loop.md](development-loop.md) | +| Enterprise Phase Discovery | [enterprise-phase-discovery.md](enterprise-phase-discovery.md) | +| Runtime Read Policy | [runtime-read-policy.md](runtime-read-policy.md) | +| Context Cache Policy | [context-cache-policy.md](context-cache-policy.md) | +| Service Snapshot Policy | [service-snapshot-policy.md](service-snapshot-policy.md) | + +Do **not** auto-read latest completed handover or completed phase documents when a valid service snapshot exists — use snapshot + latest handover only per [service-snapshot-policy.md](service-snapshot-policy.md). + +### READ ONCE PER SERVICE + +Load once per service when entering that service’s work; cache per [context-cache-policy.md](context-cache-policy.md) — do not re-read on every sub-step or subsequent phase unless content changed: + +| Document | Path | +| --- | --- | +| Service roadmap | From Project Index `roadmap_file` for active service | +| Service README | `backend/services/<name>/README.md` | + +Do **not** load latest completed phase documents when snapshot is valid — snapshot supersedes historical phase doc reconstruction. + +### READ ONLY IF REFERENCED + +Load individual files from these areas **only** when required by one of the [Additional load triggers](#additional-load-triggers) below — never as bulk directory reads: + +| Area | Path | +| --- | --- | +| Architecture | `docs/architecture/*` | +| ADRs | `docs/architecture/adr/*` | +| Reference contracts | `docs/reference/*` | +| Development standards | `docs/development/*` | +| Deployment | `docs/deployment/*` | + +Examples: load [ADR-001](../architecture/adr/ADR-001.md) when tenancy or DB-per-service is in scope; load [testing-strategy.md](../development/testing-strategy.md) when running the Testing stage; load [event-catalog.md](../reference/event-catalog.md) when validating events. After first load, **cache** individual architecture, ADR, reference, development, and deployment files until changed. + +### NEVER AUTO READ + +Do not load unless an [Additional load trigger](#additional-load-triggers) explicitly requires a specific file: + +| Category | Examples | +| --- | --- | +| Other services | Roadmaps, phase docs, handovers, snapshots of services not in current scope | +| Completed phase history | Completed phase documents when a valid service snapshot exists | +| Frontend | `frontend/` documentation (unless the phase scopes frontend work) | +| Historical / audit | Old audit reports, superseded handovers, archived docs | +| Bulk paths | `architecture/*`, `reference/*`, `development/*`, `deployment/*`, `phase-handover/*`, `service-snapshots/*` as directory scans | +| Repository tree scan | Any folder walk to discover services, docs, or paths not listed in Project Index | + +## Additional load triggers + +Beyond the tiers above, load a document **only** when: + +1. **Required by the current phase** — listed in the phase brief, phase doc, or promoted Discovery scope. +2. **Required by the current service** — owning service README, templates, or in-service docs for work in progress. +3. **Referenced by [phase-manifest.yaml](phase-manifest.yaml)** — `required_docs`, deliverables, or phase metadata for this phase ID. +4. **Referenced by [service-manifest.yaml](service-manifest.yaml)** — service entry, dependencies, or required docs for the owning service. +5. **Referenced by [project-index.yaml](project-index.yaml)** — snapshot, roadmap, handover, or ADR path for the active service. +6. **Referenced by the latest handover** — explicit paths beyond index entries. +7. **Required by a compatibility or validation rule** — e.g. [quality-gates.md](quality-gates.md) gate, [boundary-rules.md](boundary-rules.md), or a cited ADR during Architecture Validation. + +When a trigger applies, load **only the referenced files**, not sibling or parent directories. + +## Discovery inputs (policy mapping) + +[Enterprise Phase Discovery](enterprise-phase-discovery.md) synthesizes inputs below. Use this mapping instead of reading every path unconditionally: + +| Discovery input | Load via | +| --- | --- | +| Service baseline state | **Project Index** + **service snapshot** ([service-snapshot-policy.md](service-snapshot-policy.md)) | +| Current Roadmap | Project Index `roadmap_file` + snapshot + manifests | +| Current Architecture | READ ONLY IF REFERENCED (manifest, handover, phase, gate, ADR, or snapshot `active_adrs`) | +| Service Boundaries | Snapshot + ALWAYS READ registries/manifests; boundary docs when referenced | +| Previous Phase Handover | Project Index `latest_handover` (always reload) | +| Existing Codebase | Inspect `backend/services/<name>/` for delta verification (not full reconstruction) | +| Existing APIs | Snapshot `public_apis` summary; codebase/OpenAPI for current-phase changes | +| Existing Domain Model | Snapshot + codebase + current phase doc | +| Existing Events | Snapshot `published_events`; codebase for current-phase changes | +| Existing Permissions | Snapshot `permission_prefix` + codebase | +| Existing Aggregates | Codebase models; snapshot modules — not completed phase docs | +| Existing Tests | Codebase `app/tests/` | +| Existing Documentation | Snapshot + ALWAYS READ tier + current phase doc + triggered reference files | + +## Framework and template docs + +Load framework templates ([cursor-guidelines.md](cursor-guidelines.md), [testing-template.md](testing-template.md), [api-template.md](api-template.md), etc.) **when the active development-loop stage or quality gate requires them**, not preemptively. + +[master-prompt.md](master-prompt.md) summarizes permanent rules; ALWAYS READ tier already includes loop, gates, DoD, and Discovery. + +## Backward compatibility + +- Completed business phases and their handovers are unchanged. +- Legacy prompts or docs that instruct unconditional reads are **superseded** by this policy; agents follow tiers and triggers above. +- Path `docs/ai-framework/` remains canonical; no relocation required. +- Enterprise Phase Discovery remains mandatory; Discovery uses snapshot + targeted loading, not full-tree or historical phase doc reads. +- Cache reuse is an optimization; if change detection is unavailable, follow read tiers normally. +- Services without a snapshot: path from Project Index; build per [service-snapshot-policy.md](service-snapshot-policy.md) fallback rules. +- New services: add Project Index entry on registration; never discover by scanning. + +## Related Documents + +- [Project Index](project-index.yaml) +- [Service Snapshot Policy](service-snapshot-policy.md) +- [Context Cache Policy](context-cache-policy.md) +- [Master Prompt](master-prompt.md) +- [Development Loop](development-loop.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Quality Gates](quality-gates.md) +- [Phase Handover](phase-handover.md) diff --git a/docs/ai-framework/service-layer-template.md b/docs/ai-framework/service-layer-template.md index fd376cd..9001397 100644 --- a/docs/ai-framework/service-layer-template.md +++ b/docs/ai-framework/service-layer-template.md @@ -1,17 +1,21 @@ # Service Layer Template -Standards for business logic services. +Standards for business logic services, commands, queries, policies, and specifications. + +Enterprise default: phases deliver these layers for in-scope capabilities — CRUD-only services are insufficient ([definition-of-done.md](definition-of-done.md), [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md)). ## Responsibility Services: - Own domain rules and orchestration -- Validate invariants (via validators) -- Authorize at domain level when required (with permission deps at API) +- Validate invariants (via validators / specifications) +- Apply policies (authorization, entitlement, domain policy objects) +- Expose **Commands** (state changes) and **Queries** (reads without write side-effects) - Write audit records - Write outbox/domain events in the **same transaction** as state changes - Call other services only via HTTP clients / events — never via foreign repos +- Receive dependencies via explicit injection (constructor / factory / FastAPI deps) Services must **not**: @@ -19,17 +23,52 @@ Services must **not**: - Emit raw SQL bypassing repositories (except rare documented cases) - Perform provider I/O without going through provider adapters owned by the service - Mutate another aggregate’s invariants across boundaries without explicit domain rules +- Duplicate another service’s business logic ([boundary-rules.md](boundary-rules.md)) + +## Commands + +| Rule | Detail | +| --- | --- | +| Location | `app/commands/` or clearly named command methods on services | +| Naming | Verb phrases (`CreateMember`, `FreezeMembership`) | +| Effect | Single use-case state change; emit audit + outbox as required | +| Idempotency | Document for retried operations | + +## Queries + +| Rule | Detail | +| --- | --- | +| Location | `app/queries/` or query services / read methods | +| Effect | Read-only; no accidental writes or event emission | +| Lists | Support pagination, filtering, sorting, searching as API requires | + +## Specifications + +| Rule | Detail | +| --- | --- | +| Location | `app/specifications/` | +| Purpose | Reusable query predicates and business specification objects | +| Use | Repositories/services compose specs instead of duplicating filter logic | + +## Policies + +| Rule | Detail | +| --- | --- | +| Location | `app/policies/` | +| Purpose | Authorization, entitlement, and domain policy decisions reusable across commands | +| Note | Route-level permission deps remain; policies encode richer domain rules | ## Conventions | Topic | Rule | | --- | --- | -| Location | `app/services/` | +| Location | `app/services/` (+ commands/queries/policies/specifications) | | Naming | `{Capability}Service` | -| Dependencies | Repositories, validators, event publisher, provider protocols | +| Dependencies | Repositories, validators, specs, policies, event publisher, provider protocols | | DTOs | Accept/return schema objects or typed domain results — not ORM leakage to API | | Errors | Raise shared/domain exceptions with stable codes | | Idempotency | Document for retried operations (OTP, webhooks, messaging) | +| DI | Explicit wiring; no hidden service locators for domain logic | ## Transaction & Events @@ -44,12 +83,13 @@ Services must **not**: ## Testing -Service tests cover business rules with DB or fakes — see [testing-template.md](testing-template.md). +Service / command / query / policy / specification tests cover business rules with DB or fakes — see [testing-template.md](testing-template.md). ## Related Documents - [Repository Template](repository-template.md) - [API Template](api-template.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) - [Project Principles](../development/project-principles.md) - [Service Architecture](../architecture/service-architecture.md) - [Event-Driven Architecture](../architecture/event-driven-architecture.md) diff --git a/docs/ai-framework/service-snapshot-policy.md b/docs/ai-framework/service-snapshot-policy.md new file mode 100644 index 0000000..dac575e --- /dev/null +++ b/docs/ai-framework/service-snapshot-policy.md @@ -0,0 +1,128 @@ +# Service Snapshot Policy + +**Service Snapshot Architecture** — one compact, machine-readable snapshot per service representing current implementation state. + +Works with [runtime-read-policy.md](runtime-read-policy.md), [context-cache-policy.md](context-cache-policy.md), and [project-index.yaml](project-index.yaml). Snapshot file paths are resolved from Project Index — never discovered by scanning `docs/service-snapshots/`. + +## Goals + +1. Eliminate repeated full-project analysis across long-running implementations. +2. Minimize token usage by replacing multi-document state reconstruction with one YAML file per service. +3. Keep snapshots regenerated automatically after every completed phase. + +## Snapshot location + +Resolved from [project-index.yaml](project-index.yaml) field `snapshot_file` per service: + +``` +docs/service-snapshots/<service>.yaml +``` + +Examples (index entries): `hospitality.yaml`, `delivery.yaml`, `experience.yaml`, `crm.yaml`, `accounting.yaml`, `loyalty.yaml`, `communication.yaml`, `sports-center.yaml`. + +Framework-only workstreams (no owning service) do not require a snapshot file. + +Template: [docs/service-snapshots/_template.yaml](../service-snapshots/_template.yaml). + +## Required fields + +Each snapshot MUST be concise and machine-readable. Required top-level fields: + +| Field | Description | +| --- | --- | +| `service_name` | Canonical service ID (matches [service-manifest.yaml](service-manifest.yaml)) | +| `commercial_product` | Product / bundle name exposed to tenants | +| `current_version` | Semver or service version string | +| `current_phase` | Active phase ID | +| `last_completed_phase` | Most recent Complete phase ID | +| `next_phase` | Recommended next phase ID from manifest / next-steps | +| `completed_phases` | List of Complete phase IDs | +| `remaining_phases` | List of planned but incomplete phase IDs | +| `registered_modules` | Module IDs owned by this service | +| `enabled_capabilities` | Capability keys / feature flags active | +| `enabled_bundles` | Commercial bundles including this service | +| `public_apis` | Summary list — method, path, permission (not full OpenAPI) | +| `published_events` | Summary list — event_type, version | +| `permission_prefix` | e.g. `accounting.*` | +| `active_adrs` | ADR IDs governing this service | +| `integration_contracts` | Cross-service API/event/provider refs | +| `known_limitations` | Explicit non-goals and temporary constraints | +| `open_todos` | Open TODOs for in-scope work (empty when phase Complete) | +| `last_handover_reference` | Path to latest Complete handover | +| `last_updated` | ISO-8601 date of last regeneration | + +Optional metadata: `schema_version`, `snapshot_version` (increment on each regeneration). + +## Execution entry (every phase) + +Part of the global execution order in [runtime-read-policy.md](runtime-read-policy.md): + +1. Load [Project Index](project-index.yaml). +2. Load [Runtime Read Policy](runtime-read-policy.md). +3. Load [Context Cache Policy](context-cache-policy.md). +4. Load service snapshot — Project Index `snapshot_file` for the active service. +5. Read [phase-manifest.yaml](phase-manifest.yaml). +6. Read [service-manifest.yaml](service-manifest.yaml). +7. Read only latest handover — Project Index `latest_handover`. +8. Additional documents per Runtime Read Policy only. + +Use snapshot + index for baseline inventory. Do not re-read completed phase documents when snapshot is valid. + +## Fallback reconstruction + +Re-read completed phase documents, registries, or codebase-wide analysis **only** when: + +| Trigger | Action | +| --- | --- | +| Snapshot missing | Build initial snapshot from manifests, latest handover, codebase; then persist | +| Snapshot invalid | YAML parse error, missing required fields, or manifest/service ID mismatch — rebuild | +| Snapshot version changed | `snapshot_version` or `last_updated` differs from cache — reload snapshot (not historical phases) | +| Handover requests additional context | Load only paths explicitly listed in handover | +| Compatibility validation requires it | Quality gate or validation rule cites specific historical doc | + +Never bulk-read all completed phase docs when a valid snapshot exists. + +## Regeneration (mandatory after every completed phase) + +When a phase marks **Complete** for a service: + +1. Derive updated field values from handover, manifests, codebase delta, and registries. +2. Write snapshot to Project Index `snapshot_file` path with all required fields. +3. Increment `snapshot_version`. +4. Set `last_updated` to completion date. +5. Set `last_handover_reference` to the handover just completed. +6. Update [project-index.yaml](project-index.yaml): `current_version`, `current_phase`, `next_phase`, `latest_handover`, `snapshot_file`. +7. Clear resolved items from `open_todos`; carry forward known limitations. +8. Pass the **Service Snapshot** and **Project Index** quality gates ([quality-gates.md](quality-gates.md)). + +Framework-only phases: N/A unless they define snapshot schema/policy (update policy docs only). + +## Relationship to other policies + +| Policy | Role | +| --- | --- | +| [project-index.yaml](project-index.yaml) | Discovery entry — paths to snapshots, roadmaps, handovers | +| [runtime-read-policy.md](runtime-read-policy.md) | What may load beyond index + snapshot | +| [context-cache-policy.md](context-cache-policy.md) | Whether to re-read unchanged docs | +| [service-snapshot-policy.md](service-snapshot-policy.md) | Snapshot schema and regeneration | + +Discovery baseline inventory SHOULD come from the snapshot first, then latest handover for delta, then codebase for verification — not from re-reading every completed phase doc. + +## Backward compatibility + +- Completed business phases and handovers are unchanged. +- Services without a snapshot yet: first phase entry builds snapshot per fallback rules; no retrofit of historical phases required. +- Path `docs/service-snapshots/` is additive; no business service code changes required by this policy alone. + +- New services: add Project Index entry on registration in same phase as [service-manifest.yaml](service-manifest.yaml). + +## Related Documents + +- [Project Index](project-index.yaml) +- [Runtime Read Policy](runtime-read-policy.md) +- [Context Cache Policy](context-cache-policy.md) +- [Enterprise Phase Discovery](enterprise-phase-discovery.md) +- [Development Loop](development-loop.md) +- [Mandatory Phase Artifacts](mandatory-phase-artifacts.md) +- [Phase Handover](phase-handover.md) +- [Quality Gates](quality-gates.md) diff --git a/docs/ai-framework/service-template.md b/docs/ai-framework/service-template.md index a295c33..0b70591 100644 --- a/docs/ai-framework/service-template.md +++ b/docs/ai-framework/service-template.md @@ -23,6 +23,10 @@ backend/services/<name>/ models/ schemas/ services/ + commands/ # state-changing use cases (or equivalent) + queries/ # read use cases (or equivalent) + policies/ # authz / domain policies + specifications/ repositories/ providers/ # adapters if this service owns providers events/ @@ -36,6 +40,8 @@ backend/services/<name>/ README.md ``` +Mandatory artifact defaults: [mandatory-phase-artifacts.md](mandatory-phase-artifacts.md). Boundaries: [boundary-rules.md](boundary-rules.md). + ## Ownership | Owns | Must not own | @@ -119,23 +125,27 @@ Reference: [database-architecture.md](../architecture/database-architecture.md), | Endpoint | Purpose | | --- | --- | | `GET /health` | Liveness (and basic dependency signal as appropriate) | -| Optional `GET /capabilities` | Feature/channel discovery for platform services | +| `GET /capabilities` | Feature/channel discovery for platform services | +| `GET /metrics` | Operational metrics (or justified N/A) | -Register health URL in [service-manifest.yaml](service-manifest.yaml). +Register health URL in [service-manifest.yaml](service-manifest.yaml). Keep OpenAPI accurate for `/api/v1` routes. ## Registration Checklist - [ ] [service-manifest.yaml](service-manifest.yaml) - [ ] [module-registry.md](../module-registry.md) - [ ] ADR if platform ownership decision -- [ ] Phase doc + handover +- [ ] Phase doc + handover (DoD + Enterprise Completeness) - [ ] Compose/port docs when runtime is in scope - [ ] Tests per [testing-template.md](testing-template.md) +- [ ] Mandatory artifacts for foundation phase delivered ## Related Documents - [Module Template](module-template.md) - [Phase Template](phase-template.md) +- [Definition of Done](definition-of-done.md) +- [Boundary Rules](boundary-rules.md) - [Module Registry](../module-registry.md) - [Service Architecture](../architecture/service-architecture.md) - [ADR-001](../architecture/adr/ADR-001.md) diff --git a/docs/ai-framework/testing-template.md b/docs/ai-framework/testing-template.md index b78d89f..9518b14 100644 --- a/docs/ai-framework/testing-template.md +++ b/docs/ai-framework/testing-template.md @@ -2,38 +2,46 @@ Mandatory test categories for implementation phases. -Baseline strategy: [testing-strategy.md](../development/testing-strategy.md). +Baseline strategy: [testing-strategy.md](../development/testing-strategy.md). +Enterprise mandate: [definition-of-done.md](definition-of-done.md) · [ADR-018](../architecture/adr/ADR-018.md). Mark a category **N/A** only with written justification (e.g. docs-only phase). ## Unit -- Pure validators, mappers, permission helpers, formatting, template rendering without I/O. +- Pure validators, mappers, permission helpers, specifications, policies, formatting, template rendering without I/O. - Deterministic; no real network. ## Repository -- Query correctness, pagination, unique constraints behavior. +- Query correctness, pagination, filtering, sorting, searching. - Soft-delete defaults. - **Tenant filter** present on tenant-owned entities. - Cross-tenant get/update returns empty/denied. +- Optimistic lock conflict behavior when versioning is used. -## Service +## Service / Command / Query -- Business rules and invariants. +- Business rules and invariants (commands). +- Read-side correctness without write side-effects (queries). - Audit/event emission expectations (outbox rows or publisher fakes). - Domain error codes for illegal transitions. +- Policy denials where policies are in scope. ## Integration / API - HTTP contracts: status codes, auth, permission denials. - Tenant header requirements. +- Pagination / filter / sort / search query params. +- OpenAPI route presence for new endpoints (smoke or schema assertion when pattern exists). +- Health / capabilities / metrics when the phase owns them. - End-to-end flows for the phase’s primary use cases. ## Migration - Upgrade applies on clean DB. - Smoke query against new tables/columns. +- Seed data applies when required. - Downgrade only if the service supports it — document if not. ## Permission @@ -49,25 +57,28 @@ Mark a category **N/A** only with written justification (e.g. docs-only phase). ## Performance -- Required for hot paths (entitlement, routing, queue claim, posting) when the phase touches them. +- Required for hot paths (entitlement, routing, queue claim, posting, large lists) when the phase touches them. - Otherwise document N/A. -- Avoid unbounded queries; assert basic indexes usage indirectly via acceptable timing in CI only when stable. +- Avoid unbounded queries; assert basic index presence or acceptable timing in CI only when stable. ## Tenant Isolation - At least one cross-tenant denial per new tenant-scoped API/aggregate. - No list endpoint returns other tenants’ rows. +## Architecture / Boundary + +- No forbidden imports across services. +- Layering rules (API thin; no business logic in repos). +- No future-phase modules accidentally introduced (when detectable). +- Follow sibling static checks used by CRM/Loyalty/Communication/platform services. + ## Documentation Validation - Phase doc exists and matches delivered surface. - Registry/manifest entries updated. - Critical links resolve (tests or scripted checks when the service already has `test_docs` / architecture tests — follow sibling pattern). - -## Architecture / Boundary (when service has them) - -- No forbidden imports across services. -- Layering rules (optional static checks already used by CRM/Loyalty/Communication). +- DoD / completeness sections present in handover for implementation phases. ## Phase Rules @@ -75,10 +86,12 @@ Mark a category **N/A** only with written justification (e.g. docs-only phase). 2. Failing or missing required tests → phase incomplete. 3. Do not use production data. 4. Prefer fakes for OTP, SMS providers, time. +5. CRUD-only test coverage without domain-rule tests is insufficient when business rules exist. ## Related Documents - [Testing Strategy](../development/testing-strategy.md) - [Quality Gates](quality-gates.md) - [Development Loop](development-loop.md) +- [Definition of Done](definition-of-done.md) - [Project Principles](../development/project-principles.md) diff --git a/docs/architecture/adr/ADR-013.md b/docs/architecture/adr/ADR-013.md index 07fc8fc..e782c0d 100644 --- a/docs/architecture/adr/ADR-013.md +++ b/docs/architecture/adr/ADR-013.md @@ -53,3 +53,4 @@ Product AI (`ai_assistant`) is a business capability. Implementation process for - [Quality Gates](../../ai-framework/quality-gates.md) - [Phase Manifest](../../ai-framework/phase-manifest.yaml) - [Project Principles](../../development/project-principles.md) +- [ADR-018 — Enterprise Completeness Mandate](ADR-018.md) (extends this ADR) diff --git a/docs/architecture/adr/ADR-015.md b/docs/architecture/adr/ADR-015.md index d8ff4ad..8749cc1 100644 --- a/docs/architecture/adr/ADR-015.md +++ b/docs/architecture/adr/ADR-015.md @@ -1,4 +1,4 @@ -# ADR-015: Independent Delivery & Fleet Platform Service +# ADR-015: Independent Delivery & Fleet Platform Service | Field | Value | | --- | --- | diff --git a/docs/architecture/adr/ADR-017.md b/docs/architecture/adr/ADR-017.md new file mode 100644 index 0000000..1028add --- /dev/null +++ b/docs/architecture/adr/ADR-017.md @@ -0,0 +1,70 @@ +# ADR-017: Independent Hospitality Platform Service + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-07-25 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Cafes, coffee shops, restaurants, fast food, bakeries, pastry shops, ice cream and juice bars, cloud kitchens, food courts, catering, and take-away all need shared operational capabilities (venues, menus, tables, POS connectors, kitchen, reservations, delivery connectors). Embedding this inside CRM, Loyalty, Accounting, Delivery, Experience, or Sports Center would couple unrelated domains and block reuse. + +TorbatYar already isolates shared platforms as independent services (Loyalty [ADR-011](ADR-011.md), Communication [ADR-012](ADR-012.md), Sports Center [ADR-014](ADR-014.md), Delivery [ADR-015](ADR-015.md), Experience [ADR-016](ADR-016.md)). Food & beverage operations need the same independence with feature-based bundles and capability discovery. + +Commercial product name: **Torbat Food**. + +The historical `restaurant` scaffold / `restaurant-foundation` phase ID is evolved into the Hospitality Platform (`hospitality`, Phase 12.x). + +## Decision + +1. Introduce `hospitality` as an **independent Hospitality Platform service** with sole ownership of `hospitality_db` ([ADR-001](ADR-001.md)). +2. Permission prefix: `hospitality.*`. Publish-only domain events: `hospitality.*`. +3. Row-level multi-tenancy via `tenant_id` ([ADR-003](ADR-003.md)). +4. Feature-based architecture with **bundle-based licensing**, capability discovery, feature toggles, and permission gating. Hidden bundles must not expose APIs, menus, or permissions. +5. Venue formats are configurable catalog values (cafe, restaurant, bakery, …) — never hardcoded format-specific engines in foundation. +6. Consume Accounting, CRM, Loyalty, Communication, Delivery, Experience / Website Builder, and AI **only** via REST API and Events — never shared tables or foreign model imports. +7. Financial journals only through Accounting Posting Engine ([ADR-010](ADR-010.md)). +8. Messaging only through Communication ([ADR-012](ADR-012.md)). +9. Loyalty points/rewards only through Loyalty ([ADR-011](ADR-011.md)). +10. Last-mile logistics only through Delivery ([ADR-015](ADR-015.md)). +11. Public page shells only through Experience ([ADR-016](ADR-016.md)) when websites/menus-as-pages are required. +12. Implementation phases are registered as **Phase 12.0+** in [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml); roadmap in [hospitality-roadmap.md](../../hospitality-roadmap.md). +13. Product AI remains optional and external ([ai-architecture.md](../ai-architecture.md)). Core hospitality flows work when AI is off. + +## Consequences + +### Positive + +- Clear ownership for F&B / hospitality operations +- Bundle licensing enables SMB → enterprise packaging (Digital Menu → POS Pro → Kitchen) +- Reusable integrations with platform services + +### Negative + +- Additional deployable service and database +- Eventual consistency for cross-service customer / order views + +### Neutral + +- Historical `backend/services/restaurant` README remains a pointer; runtime lives under `backend/services/hospitality` + +## Alternatives Considered + +1. Keep a narrow Restaurant-only service — rejected (blocks bakery/cloud kitchen/food court reuse). +2. Hospitality tables inside Core — rejected (violates ADR-001 and module boundaries). +3. Embed menus inside CRM — rejected (CRM is Sales-only). +4. Embed operational POS inside Experience — rejected (Experience owns pages/themes, not F&B operations). + +## Related Documents + +- [Hospitality Phase 12.0](../../hospitality-phase-12-0.md) +- [Phase Handover 12.0](../../phase-handover/phase-12-0.md) +- [Hospitality Roadmap](../../hospitality-roadmap.md) +- [Module Registry — hospitality](../../module-registry.md#hospitality) +- [Module Boundaries](../module-boundaries.md) +- [Phase Manifest](../../ai-framework/phase-manifest.yaml) +- [Service Manifest](../../ai-framework/service-manifest.yaml) +- [ADR-001](ADR-001.md) · [ADR-003](ADR-003.md) · [ADR-010](ADR-010.md) · [ADR-011](ADR-011.md) · [ADR-012](ADR-012.md) · [ADR-015](ADR-015.md) · [ADR-016](ADR-016.md) diff --git a/docs/architecture/adr/ADR-018.md b/docs/architecture/adr/ADR-018.md new file mode 100644 index 0000000..164857a --- /dev/null +++ b/docs/architecture/adr/ADR-018.md @@ -0,0 +1,66 @@ +# 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) diff --git a/docs/architecture/adr/ADR-019.md b/docs/architecture/adr/ADR-019.md new file mode 100644 index 0000000..83904a6 --- /dev/null +++ b/docs/architecture/adr/ADR-019.md @@ -0,0 +1,61 @@ +# 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) diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 2596fad..11571db 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -1,26 +1,31 @@ -# Architecture Decision Records (ADR) - -One decision per file. Never overwrite an accepted ADR — supersede it. - -| ADR | Title | Status | -| --- | --- | --- | -| [ADR-001](ADR-001.md) | Database-per-Service | Accepted | -| [ADR-002](ADR-002.md) | Strict Frontend / Backend Separation | Accepted | -| [ADR-003](ADR-003.md) | Row-Level Multi-Tenancy with tenant_id | Accepted | -| [ADR-004](ADR-004.md) | Central SSO with Keycloak | Accepted | -| [ADR-005](ADR-005.md) | Mandatory Mobile Identity with OTP | Accepted | -| [ADR-006](ADR-006.md) | Transactional Outbox / Inbox for Events | Accepted | -| [ADR-007](ADR-007.md) | Dual Tenant Membership Tables | Accepted | -| [ADR-008](ADR-008.md) | White-Label Branding via Config and Tenant Profile | Accepted | -| [ADR-009](ADR-009.md) | Nginx Edge with Automatic Tenant SSL Expansion | Accepted | -| [ADR-010](ADR-010.md) | Posting Engine Ownership for Accounting Entries | Accepted | -| [ADR-011](ADR-011.md) | Independent Enterprise Loyalty Platform Service | Accepted | -| [ADR-012](ADR-012.md) | Independent Enterprise Communication Platform | Accepted | -| [ADR-013](ADR-013.md) | Permanent AI Development Framework | Accepted | -| [ADR-014](ADR-014.md) | Independent Sports Center Platform Service | Accepted | - -Template: [adr-template.md](../../templates/adr-template.md) - -AI implementation workflow: [docs/ai-framework/](../../ai-framework/README.md) - -Statuses: `Proposed` · `Accepted` · `Deprecated` · `Superseded` +# Architecture Decision Records (ADR) + +One decision per file. Never overwrite an accepted ADR — supersede it. + +| ADR | Title | Status | +| --- | --- | --- | +| [ADR-001](ADR-001.md) | Database-per-Service | Accepted | +| [ADR-002](ADR-002.md) | Strict Frontend / Backend Separation | Accepted | +| [ADR-003](ADR-003.md) | Row-Level Multi-Tenancy with tenant_id | Accepted | +| [ADR-004](ADR-004.md) | Central SSO with Keycloak | Accepted | +| [ADR-005](ADR-005.md) | Mandatory Mobile Identity with OTP | Accepted | +| [ADR-006](ADR-006.md) | Transactional Outbox / Inbox for Events | Accepted | +| [ADR-007](ADR-007.md) | Dual Tenant Membership Tables | Accepted | +| [ADR-008](ADR-008.md) | White-Label Branding via Config and Tenant Profile | Accepted | +| [ADR-009](ADR-009.md) | Nginx Edge with Automatic Tenant SSL Expansion | Accepted | +| [ADR-010](ADR-010.md) | Posting Engine Ownership for Accounting Entries | Accepted | +| [ADR-011](ADR-011.md) | Independent Enterprise Loyalty Platform Service | Accepted | +| [ADR-012](ADR-012.md) | Independent Enterprise Communication Platform | Accepted | +| [ADR-013](ADR-013.md) | Permanent AI Development Framework | Accepted | +| [ADR-014](ADR-014.md) | Independent Sports Center Platform Service | Accepted | +| [ADR-015](ADR-015.md) | Independent Delivery & Fleet Platform Service | Accepted | +| [ADR-016](ADR-016.md) | Independent Enterprise Experience Platform Service | Accepted | +| [ADR-017](ADR-017.md) | Independent Hospitality Platform Service | Accepted | +| [ADR-018](ADR-018.md) | Enterprise Completeness Mandate for the AI Development Framework | Accepted | +| [ADR-019](ADR-019.md) | Mandatory Enterprise Phase Discovery | Accepted | + +Template: [adr-template.md](../../templates/adr-template.md) + +AI / Enterprise implementation workflow: [docs/ai-framework/](../../ai-framework/README.md) ([ADR-013](ADR-013.md), [ADR-018](ADR-018.md), [ADR-019](ADR-019.md)) + +Statuses: `Proposed` · `Accepted` · `Deprecated` · `Superseded` diff --git a/docs/architecture/ai-architecture.md b/docs/architecture/ai-architecture.md index 258147d..ee5b9e2 100644 --- a/docs/architecture/ai-architecture.md +++ b/docs/architecture/ai-architecture.md @@ -26,3 +26,5 @@ Model providers are registered like any other provider ([provider-registry.md](. - [Compliance Architecture](compliance-architecture.md) - [Phases / AI](../phases/AI/README.md) - [Project Principles](../development/project-principles.md) +- [Enterprise / AI Development Framework](../ai-framework/README.md) (implementation process — distinct from this product AI architecture) +- [ADR-013](adr/ADR-013.md) · [ADR-018](adr/ADR-018.md) · [ADR-019](adr/ADR-019.md) diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index 68c2f9f..12c1fee 100644 --- a/docs/architecture/architecture.md +++ b/docs/architecture/architecture.md @@ -65,7 +65,7 @@ Build a **multi-tenant**, **modular**, **API-first**, **microservice-ready** Sup Every implementation phase begins by reading: 1. [docs/README.md](../README.md) -2. [AI Development Framework](../ai-framework/README.md) (`docs/ai-framework/*`) +2. [Enterprise / AI Development Framework](../ai-framework/README.md) (`docs/ai-framework/*` — [ADR-013](adr/ADR-013.md), [ADR-018](adr/ADR-018.md), [ADR-019](adr/ADR-019.md)) 3. This folder (`docs/architecture/*` and `adr/*`) 4. [project-principles.md](../development/project-principles.md) 5. [coding-standards.md](../development/coding-standards.md) diff --git a/docs/architecture/database-architecture.md b/docs/architecture/database-architecture.md index d231866..aff8933 100644 --- a/docs/architecture/database-architecture.md +++ b/docs/architecture/database-architecture.md @@ -15,8 +15,11 @@ | 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 (future) | `website_builder_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` | @@ -24,7 +27,6 @@ | Link Shortener (future) | `link_shortener_db` | | Notification (future) | `notification_db` | | File Storage (future) | `file_storage_db` | -| Restaurant (future) | `restaurant_db` | ## Hard Rules diff --git a/docs/architecture/module-boundaries.md b/docs/architecture/module-boundaries.md index 258efdb..918b443 100644 --- a/docs/architecture/module-boundaries.md +++ b/docs/architecture/module-boundaries.md @@ -1,70 +1,91 @@ -# 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. - -### 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 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 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, product AI platform, Automation engine. - -## 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) -- [Module Registry](../module-registry.md) -- [Sports Center Roadmap](../sports-center-roadmap.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) diff --git a/docs/delivery-phase-10-0.md b/docs/delivery-phase-10-0.md new file mode 100644 index 0000000..5b51735 --- /dev/null +++ b/docs/delivery-phase-10-0.md @@ -0,0 +1,133 @@ +# Phase 10.0 — Delivery Platform Foundation + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | delivery | +| Version | 0.10.0.0 | +| Database | `delivery_db` | +| API Port | 8007 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-015 | +| Commercial Product | Torbat Driver | + +## Goal + +Establish the **Delivery & Fleet Platform** as a completely independent enterprise microservice foundation (commercial product: **Torbat Driver**). + +Reusable by Hospitality, Marketplace, Store, Pharmacy, Clinic, Sports Center, and future services via **API + Events only** — no shared databases, no business logic duplication. + +This phase implements foundation shells and discovery surfaces only. It does **not** implement driver workflows, dispatch, routing engines, tracking, or settlement engines. + +## Scope Delivered + +### In Scope + +- Service scaffold under `backend/services/delivery` (API → Service → Repository → Model) +- Foundation aggregates: DeliveryOrganization, DeliveryHub, DeliveryRole, DeliveryPermission, ExternalProviderConfig, RoutingEngineRegistration, DeliveryConfiguration, DeliverySetting, DeliveryAuditLog +- Health `/health`, Capability `/capabilities`, Metrics `/metrics` +- Permissions `delivery.*` trees (foundation + planned leaves) +- Publish-only events `delivery.*` (organization/hub/provider/routing/config/setting) +- Platform provider contracts (Accounting, CRM, Loyalty, Communication, Notification, Storage, AI, Identity, RoutingEngine, Fleet) +- Tenant isolation via `X-Tenant-ID` +- Alembic `0001_initial` + compose wiring (port 8007) +- Tests: architecture, API/tenant, permissions, migration, dependency, security, docs + +### Out of Scope + +- Driver management (10.1) +- Fleet / vehicle types (10.2) +- Availability / shifts / zones (10.3) +- Pricing / capability bundles (10.4) +- Dispatch engine (10.5) +- Routing / optimization execution (10.6) +- Tracking / POD (10.7) +- Settlement posting (10.8) +- Merchant connector / app UI (10.9) +- Analytics / AI (10.10) + +## Service Boundaries + +| Delivery owns | Delivery does not own | +| --- | --- | +| Aggregates above | Accounting / Posting Engine | +| Delivery HTTP APIs under `/api/v1/*` | CRM sales entities | +| `delivery.*` permissions | Loyalty ledger / points | +| Publish-only delivery events | Communication / SMS providers / message delivery timeline | +| External provider & routing-engine **registrations** | Vertical order aggregates | +| Tenant delivery configuration | Identity / Storage blobs / AI inference | + +## Published Events (Phase 10.0) + +| Event | Aggregate | +| --- | --- | +| `delivery.organization.created` / `updated` | organization | +| `delivery.hub.created` / `updated` | hub | +| `delivery.external_provider.registered` / `updated` | external_provider_config | +| `delivery.routing_engine.registered` / `updated` | routing_engine_registration | +| `delivery.configuration.created` / `updated` | configuration | +| `delivery.setting.upserted` | setting | + +## API Contracts + +| Resource | Prefix | +| --- | --- | +| Organizations | `/api/v1/organizations` | +| Hubs | `/api/v1/hubs` | +| External providers | `/api/v1/external-providers` | +| Routing engines | `/api/v1/routing-engines` | +| Configurations | `/api/v1/configurations` | +| Settings | `/api/v1/settings` | +| Audit | `/api/v1/audit` | +| Health / Capabilities / Metrics | `/health`, `/capabilities`, `/metrics` | + +## Permissions + +`delivery.*` trees covering organizations, hubs, external providers, routing engines, configurations, settings, audit, plus planned leaves for drivers/fleet/dispatch/routing/tracking/settlement. + +## Architecture Decisions + +1. Database-per-service (`delivery_db`) — ADR-001 / ADR-015 +2. Row-level `tenant_id` — ADR-003 +3. Event publish contracts via `EventEnvelope` — ADR-006 +4. Vertical-agnostic logistics platform — no hardcoded Hospitality/Marketplace rules +5. External routing/fleet providers behind Protocols — vendor logic outside business services +6. Soft delete + actor audit + delivery audit log +7. Optimistic locking on organizations, providers, routing engines, configurations + +## Folder Structure + +``` +backend/services/delivery/ + app/ + api/v1/ + core/ + middlewares/ + models/ + repositories/ + services/ + validators/ + schemas/ + events/ + permissions/ + providers/ + policies/ + specifications/ + commands/ + queries/ + tests/ + alembic/versions/0001_initial.py + scripts/ensure_db.py + README.md +``` + +## Tests Executed + +Architecture · API foundation flow · tenant isolation · permissions · migration · dependency · security · docs + +## Related Documents + +- [Phase Handover 10.0](phase-handover/phase-10-0.md) +- [Delivery Roadmap](delivery-roadmap.md) +- [ADR-015](architecture/adr/ADR-015.md) +- [Module Registry](module-registry.md#delivery) +- [Progress](progress.md) diff --git a/docs/delivery-phase-10-1.md b/docs/delivery-phase-10-1.md new file mode 100644 index 0000000..31960c0 --- /dev/null +++ b/docs/delivery-phase-10-1.md @@ -0,0 +1,184 @@ +# Phase 10.1 — Driver Management + +| Field | Value | +| --- | --- | +| Identifier | `delivery-10.1` | +| Status | Complete | +| Module | delivery | +| Service | `delivery-service` | +| Version | `0.10.1.0` | +| Database | `delivery_db` | +| API Port | 8007 | +| Depends On | Phase 10.0 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-015 | +| Commercial Product | Torbat Driver | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Enterprise Phase Discovery Summary + +| Item | Detail | +| --- | --- | +| Capabilities required | Driver profiles, credential/document refs, status lifecycle, list UX (filter/sort/search/page), audit, outbox events, permissions | +| Gaps closed | Driver aggregate + lifecycle policy + credentials/documents + transactional outbox + permission leaves | +| Exclusions | Fleet/vehicles (10.2), availability/shifts/zones (10.3), dispatch (10.5), routing execution (10.6), tracking/POD (10.7) | +| CRUD-only rejected | Yes — lifecycle machine, credentials, documents, outbox, specs, policies required | + +## Goal + +Deliver production-ready **Driver Management** for the Delivery & Fleet Platform (Torbat Driver): profiles, credential and document references, status lifecycle with append-only history, tenant-isolated APIs, audit, and outbox events — without fleet, dispatch, routing, or tracking engines. + +## Scope Delivered + +### In Scope + +- Aggregates: `Driver`, `DriverCredential`, `DriverDocument`, `DriverLifecycleEvent`, `OutboxEvent` +- Lifecycle: activate, suspend, resume, deactivate, block, unblock, archive +- Soft delete + optimistic locking on drivers +- List: pagination, filtering (status/org/hub), sorting, searching +- Commands / queries / policies / specifications / validators +- Permissions `delivery.drivers.*` leaves + catalog discovery API +- Publish-only events `delivery.driver.*` via transactional outbox +- Health / capabilities / metrics updated (`phase: 10.1`, `drivers: true`) +- Alembic `0002_phase_101_drivers` +- Tests: unit, API/integration, tenant, architecture, migration, permissions, performance indexes, docs + +### Out of Scope + +- Fleet / vehicle types / vehicles (10.2) +- Availability / shifts / working zones (10.3) +- Pricing / capability bundles (10.4) +- Dispatch engine (10.5) +- Routing / optimization execution (10.6) +- Tracking / POD (10.7) +- Settlement (10.8) +- Merchant connector / app UI (10.9) +- Analytics / AI (10.10) + +## Service Boundaries + +| Delivery owns | Delivery does not own | +| --- | --- | +| Driver profiles and lifecycle in `delivery_db` | Identity user administration | +| Credential/document **metadata** + Storage file refs | File blobs (Storage) | +| External user / CRM contact refs | CRM contact master | +| Driver permissions `delivery.drivers.*` | Communication message delivery | +| Outbox `delivery.driver.*` events | Accounting journals | + +## Domain Model + +### Driver statuses + +`pending` → `active` ↔ `suspended` → `inactive` → `archived` (terminal); `block` → `blocked` → `unblock` → `inactive`. + +| Action | Allowed from | To | Reason required | +| --- | --- | --- | --- | +| activate | pending, inactive, suspended | active | No | +| suspend | active | suspended | Yes | +| resume | suspended | active | No | +| deactivate | active, suspended | inactive | Yes | +| block | pending, active, suspended, inactive | blocked | Yes | +| unblock | blocked | inactive | No | +| archive | inactive, blocked | archived | Yes | + +### Aggregates + +| Entity | Soft delete | Optimistic lock | Tenant | +| --- | --- | --- | --- | +| Driver | Yes | Yes | Yes | +| DriverCredential | Yes | No | Yes | +| DriverDocument | Yes | No | Yes | +| DriverLifecycleEvent | No (append-only) | No | Yes | +| OutboxEvent | N/A | N/A | Yes | + +## APIs + +| Method | Path | Permission | +| --- | --- | --- | +| POST | `/api/v1/drivers` | `delivery.drivers.create` | +| GET | `/api/v1/drivers` | `delivery.drivers.view` | +| GET | `/api/v1/drivers/{id}` | `delivery.drivers.view` | +| PATCH | `/api/v1/drivers/{id}` | `delivery.drivers.update` | +| POST | `/api/v1/drivers/{id}/activate` | `delivery.drivers.activate` | +| POST | `/api/v1/drivers/{id}/suspend` | `delivery.drivers.suspend` | +| POST | `/api/v1/drivers/{id}/resume` | `delivery.drivers.resume` | +| POST | `/api/v1/drivers/{id}/deactivate` | `delivery.drivers.deactivate` | +| POST | `/api/v1/drivers/{id}/block` | `delivery.drivers.block` | +| POST | `/api/v1/drivers/{id}/unblock` | `delivery.drivers.unblock` | +| POST | `/api/v1/drivers/{id}/archive` | `delivery.drivers.archive` | +| GET | `/api/v1/drivers/{id}/lifecycle` | `delivery.drivers.lifecycle.view` | +| POST/GET | `/api/v1/drivers/{id}/credentials` | credentials.manage / view | +| POST/GET | `/api/v1/drivers/{id}/documents` | documents.manage / view | +| POST | `/api/v1/drivers/{id}/delete` | `delivery.drivers.delete` | +| GET | `/api/v1/permissions/catalog` | `delivery.view` | +| GET | `/health`, `/capabilities`, `/metrics` | Public | + +List query params: `page`, `page_size`, `status`, `organization_id`, `hub_id`, `q`, `sort_by`, `sort_dir`. + +## Events + +| Event | Aggregate | +| --- | --- | +| `delivery.driver.created` | driver | +| `delivery.driver.updated` | driver | +| `delivery.driver.activated` / `suspended` / `resumed` / `deactivated` / `blocked` / `unblocked` / `archived` | driver | +| `delivery.driver.status_changed` | driver | +| `delivery.driver.deleted` | driver | +| `delivery.driver.credential_added` | driver_credential | +| `delivery.driver.document_attached` | driver_document | + +Persisted via transactional outbox (`outbox_events`) per ADR-006; mirrored in-memory under test. + +## Permissions + +`delivery.drivers.view|create|update|delete|activate|suspend|resume|deactivate|block|unblock|archive|lifecycle.view|credentials.view|credentials.manage|documents.view|documents.manage|manage` + +## Migration + +| Item | Detail | +| --- | --- | +| Alembic | `0002_phase_101_drivers` (down_revision `0001_initial`) | +| Breaking | None — additive tables | +| Backfill | None | + +## Architecture Decisions + +1. Driver status machine owned by Delivery validators/policies — not Identity +2. Credential/document binaries remain Storage refs only +3. Transactional outbox introduced for driver mutations (backward-compatible with foundation in-memory publisher) +4. No fleet/vehicle/dispatch tables in this phase +5. Soft delete + optimistic lock on Driver; append-only lifecycle history + +## Folder Additions + +``` +app/models/drivers.py +app/models/outbox.py +app/validators/drivers.py +app/policies/drivers.py +app/specifications/drivers.py +app/repositories/drivers.py +app/services/drivers.py +app/schemas/drivers.py +app/commands/drivers.py +app/queries/drivers.py +app/api/v1/drivers.py +app/api/v1/permissions.py +alembic/versions/0002_phase_101_drivers.py +``` + +## Tests + +- Unit: lifecycle transition matrix +- Integration: lifecycle flow, credentials/documents, filter/sort/search, optimistic lock, soft delete, tenant isolation +- Architecture / migration / permissions / performance indexes / docs / security (auth required) + +## Definition of Done + +All Enterprise Quality Gates for Phase 10.1 satisfied. See [phase-handover/phase-10-1.md](phase-handover/phase-10-1.md). + +## Related Documents + +- [Delivery Roadmap](delivery-roadmap.md) +- [Phase 10.0](delivery-phase-10-0.md) +- [Handover 10.1](phase-handover/phase-10-1.md) +- [ADR-015](architecture/adr/ADR-015.md) diff --git a/docs/delivery-roadmap.md b/docs/delivery-roadmap.md index 4a0d672..559f171 100644 --- a/docs/delivery-roadmap.md +++ b/docs/delivery-roadmap.md @@ -1,160 +1,160 @@ -# Delivery & Fleet Platform — Roadmap - -> Registration document. **No implementation in this phase.** -> Framework: [ai-framework/](ai-framework/README.md) · ADR: [ADR-015](architecture/adr/ADR-015.md) · Manifests: [phase-manifest.yaml](ai-framework/phase-manifest.yaml), [service-manifest.yaml](ai-framework/service-manifest.yaml) - -| Field | Value | -| --- | --- | -| Service | `delivery` | -| Commercial Product | Torbat Driver | -| Database | `delivery_db` | -| Permission Prefix | `delivery.*` | -| API Port (planned) | 8007 | -| Phases | 10.0 – 10.10 | -| Status | Registration complete; next 10.0 Foundation | - -## Vision - -Deliver an independent, multi-tenant **Delivery & Fleet Platform** inside TorbatYar so Restaurant, Cafe, Marketplace, Store, Pharmacy, Clinic, Sports Center, and future services can request logistics without owning drivers, fleets, dispatch, or routing — and without duplicating business logic or sharing databases. - -## Business Scope - -In scope (over Phases 10.0–10.10): - -- Driver management and operational readiness -- Fleet management and vehicle types -- Dispatch engine and assignment -- Routing, multi pickup / multi drop, optimization -- Live tracking and proof of delivery -- Availability, shift management, working zones -- Pricing and capability bundles -- Settlement (via Accounting APIs/events only) -- Merchant connector contracts for vertical services -- Customer tracking APIs -- Notifications via Communication only -- Fleet analytics shells; AI-ready hooks (optional) -- External providers and future routing engines behind adapters -- Driver App / Dispatcher Panel **API contracts** (UI in frontend) - -## Architecture Scope - -| Rule | Reference | -| --- | --- | -| Independent service + `delivery_db` | [ADR-015](architecture/adr/ADR-015.md), [ADR-001](architecture/adr/ADR-001.md) | -| Layering API → Service → Repository → Model | [service-architecture.md](architecture/service-architecture.md) | -| Tenant-aware business tables | [ADR-003](architecture/adr/ADR-003.md) | -| Outbox-ready events `delivery.*` | [ADR-006](architecture/adr/ADR-006.md) | -| No journal ownership | [ADR-010](architecture/adr/ADR-010.md) | -| Messaging via Communication only | [ADR-012](architecture/adr/ADR-012.md) | -| Loyalty via Loyalty service only | [ADR-011](architecture/adr/ADR-011.md) | -| AI optional / independent | [ai-architecture.md](architecture/ai-architecture.md) | -| FE/BE separation for Driver App / Dispatcher Panel | [ADR-002](architecture/adr/ADR-002.md) | -| Implementation via AI Framework | [ai-framework/](ai-framework/README.md) | - -## Module Map - -| Module | Responsibility | Introduced | -| --- | --- | --- | -| Drivers | Driver profiles, credentials refs, status | 10.1 | -| Fleet | Fleet units and ownership shells | 10.2 | -| Vehicle Types | Catalog of vehicle classes/capabilities | 10.2 | -| Vehicles | Concrete vehicles assigned to fleets/drivers | 10.2 | -| Availability | Online/offline and capacity readiness | 10.3 | -| Shifts | Shift definitions and assignments | 10.3 | -| Working Zones | Geo / operational zones | 10.3 | -| Pricing | Delivery pricing rules shells | 10.4 | -| Capabilities | Capability flags for jobs/vehicles/drivers | 10.4 | -| Bundles | Packaged capability/pricing offerings | 10.4 | -| Dispatch | Dispatch engine and assignment | 10.5 | -| Routing | Route plans; multi pickup / multi drop | 10.6 | -| Optimization | Route/job optimization strategies | 10.6 | -| Tracking | Live location and journey timeline | 10.7 | -| Proof of Delivery | POD capture (signature/photo/code refs) | 10.7 | -| Settlement | Settlement intents; Accounting posts externally | 10.8 | -| Merchant Connector | Vertical order/job intake contracts | 10.9 | -| Notifications | Outbound notify via Communication client | 10.9 | -| Driver App APIs | Mobile driver contract surfaces | 10.9 | -| Dispatcher Panel APIs | Ops panel contract surfaces | 10.9 | -| Customer Tracking | Public/customer tracking token APIs | 10.7 / 10.9 | -| Fleet Analytics | Metrics/export shells | 10.10 | -| AI Hooks | Optional AI contracts (dispatch assist, ETA) | 10.10 | -| External Providers | Fleet/routing provider adapters | 10.0+ | -| Audit / Config / Health | Foundation shells | 10.0 | - -Canonical inventory: [module-registry.md](module-registry.md#delivery). - -## Integration Map - -``` -Verticals (Restaurant / Marketplace / Store / Pharmacy / Clinic / Sports Center / …) - │ - │ API / Events (job refs only — no shared DB) - ▼ - Delivery Platform ──API/Events──▶ Accounting (settlement → Posting Engine) - Delivery Platform ──API/Events──▶ Communication (SMS/push/OTP notifications) - Delivery Platform ──API/Events──▶ Loyalty (optional earn refs) - Delivery Platform ──API/Events──▶ CRM (contact refs only) - Delivery Platform ──API─────────▶ Core (entitlement, tenant) - Delivery Platform ──Adapters───▶ External routing / fleet providers -``` - -Forbidden: cross-DB queries, embedding SMS providers inside Delivery, creating JournalEntry locally, owning Restaurant/Marketplace order aggregates. - -## Disambiguation - -| Term | Owner | -| --- | --- | -| Message delivery (queued/sent/delivered) | Communication | -| Physical / courier delivery jobs | Delivery Platform (`delivery`) | - -## Phase Map - -| Phase | ID | Name | Status | -| --- | --- | --- | --- | -| Reg | `delivery-reg` | Platform Registration | Complete | -| 10.0 | `delivery-10.0` | Foundation | Planned | -| 10.1 | `delivery-10.1` | Driver Management | Planned | -| 10.2 | `delivery-10.2` | Fleet & Vehicle Types | Planned | -| 10.3 | `delivery-10.3` | Availability, Shifts & Working Zones | Planned | -| 10.4 | `delivery-10.4` | Pricing, Capabilities & Bundles | Planned | -| 10.5 | `delivery-10.5` | Dispatch Engine | Planned | -| 10.6 | `delivery-10.6` | Routing & Optimization | Planned | -| 10.7 | `delivery-10.7` | Tracking & Proof of Delivery | Planned | -| 10.8 | `delivery-10.8` | Settlement | Planned | -| 10.9 | `delivery-10.9` | Merchant Connector & App Surfaces | Planned | -| 10.10 | `delivery-10.10` | Analytics, AI Ready & Enterprise Validation | Planned | - -Details (dependencies, required docs/services, completion criteria): [phase-manifest.yaml](ai-framework/phase-manifest.yaml). - -## Platform Expose (every implementation phase) - -Health API · Capability API · Metrics · Events · REST APIs · Permission APIs - -## Future Expansion - -- Additional routing engine adapters without vertical changes -- Third-party fleet marketplaces behind provider protocols -- Deeper ETA / surge pricing engines when scoped -- Cross-tenant platform-admin fleet analytics (explicit + audited) - -## Out of Scope - -- Owning Accounting ledgers or Posting Engine -- Owning Sales CRM aggregates -- Owning Loyalty ledger / campaigns -- Owning SMS/email/push providers (Communication owns them) -- Owning Restaurant/Marketplace/Clinic/Sports Center order domains -- Implementing Phase 10.x business code in the registration phase -- Driver App / Dispatcher Panel UI (frontend phases) - -## Related Documents - -- [Phase Area README](phases/Delivery/README.md) -- [ADR-015](architecture/adr/ADR-015.md) -- [Module Registry](module-registry.md#delivery) -- [Glossary — Delivery](glossary.md#delivery--fleet-platform) -- [Service Manifest](ai-framework/service-manifest.yaml) -- [Progress](progress.md) -- [Roadmap](roadmap.md) -- [Next Steps](next-steps.md) +# Delivery & Fleet Platform — Roadmap + +> Platform roadmap. Implementation proceeds phase-by-phase under the AI Framework. +> Framework: [ai-framework/](ai-framework/README.md) · ADR: [ADR-015](architecture/adr/ADR-015.md) · Manifests: [phase-manifest.yaml](ai-framework/phase-manifest.yaml), [service-manifest.yaml](ai-framework/service-manifest.yaml) + +| Field | Value | +| --- | --- | +| Service | `delivery` | +| Commercial Product | Torbat Driver | +| Database | `delivery_db` | +| Permission Prefix | `delivery.*` | +| API Port (planned) | 8007 | +| Phases | 10.0 – 10.10 | +| Status | Phase 10.1 Driver Management complete; next 10.2 Fleet & Vehicle Types | + +## Vision + +Deliver an independent, multi-tenant **Delivery & Fleet Platform** inside TorbatYar so Restaurant, Cafe, Marketplace, Store, Pharmacy, Clinic, Sports Center, and future services can request logistics without owning drivers, fleets, dispatch, or routing — and without duplicating business logic or sharing databases. + +## Business Scope + +In scope (over Phases 10.0–10.10): + +- Driver management and operational readiness +- Fleet management and vehicle types +- Dispatch engine and assignment +- Routing, multi pickup / multi drop, optimization +- Live tracking and proof of delivery +- Availability, shift management, working zones +- Pricing and capability bundles +- Settlement (via Accounting APIs/events only) +- Merchant connector contracts for vertical services +- Customer tracking APIs +- Notifications via Communication only +- Fleet analytics shells; AI-ready hooks (optional) +- External providers and future routing engines behind adapters +- Driver App / Dispatcher Panel **API contracts** (UI in frontend) + +## Architecture Scope + +| Rule | Reference | +| --- | --- | +| Independent service + `delivery_db` | [ADR-015](architecture/adr/ADR-015.md), [ADR-001](architecture/adr/ADR-001.md) | +| Layering API → Service → Repository → Model | [service-architecture.md](architecture/service-architecture.md) | +| Tenant-aware business tables | [ADR-003](architecture/adr/ADR-003.md) | +| Outbox-ready events `delivery.*` | [ADR-006](architecture/adr/ADR-006.md) | +| No journal ownership | [ADR-010](architecture/adr/ADR-010.md) | +| Messaging via Communication only | [ADR-012](architecture/adr/ADR-012.md) | +| Loyalty via Loyalty service only | [ADR-011](architecture/adr/ADR-011.md) | +| AI optional / independent | [ai-architecture.md](architecture/ai-architecture.md) | +| FE/BE separation for Driver App / Dispatcher Panel | [ADR-002](architecture/adr/ADR-002.md) | +| Implementation via AI Framework | [ai-framework/](ai-framework/README.md) | + +## Module Map + +| Module | Responsibility | Introduced | +| --- | --- | --- | +| Drivers | Driver profiles, credentials refs, status | 10.1 | +| Fleet | Fleet units and ownership shells | 10.2 | +| Vehicle Types | Catalog of vehicle classes/capabilities | 10.2 | +| Vehicles | Concrete vehicles assigned to fleets/drivers | 10.2 | +| Availability | Online/offline and capacity readiness | 10.3 | +| Shifts | Shift definitions and assignments | 10.3 | +| Working Zones | Geo / operational zones | 10.3 | +| Pricing | Delivery pricing rules shells | 10.4 | +| Capabilities | Capability flags for jobs/vehicles/drivers | 10.4 | +| Bundles | Packaged capability/pricing offerings | 10.4 | +| Dispatch | Dispatch engine and assignment | 10.5 | +| Routing | Route plans; multi pickup / multi drop | 10.6 | +| Optimization | Route/job optimization strategies | 10.6 | +| Tracking | Live location and journey timeline | 10.7 | +| Proof of Delivery | POD capture (signature/photo/code refs) | 10.7 | +| Settlement | Settlement intents; Accounting posts externally | 10.8 | +| Merchant Connector | Vertical order/job intake contracts | 10.9 | +| Notifications | Outbound notify via Communication client | 10.9 | +| Driver App APIs | Mobile driver contract surfaces | 10.9 | +| Dispatcher Panel APIs | Ops panel contract surfaces | 10.9 | +| Customer Tracking | Public/customer tracking token APIs | 10.7 / 10.9 | +| Fleet Analytics | Metrics/export shells | 10.10 | +| AI Hooks | Optional AI contracts (dispatch assist, ETA) | 10.10 | +| External Providers | Fleet/routing provider adapters | 10.0+ | +| Audit / Config / Health | Foundation shells | 10.0 | + +Canonical inventory: [module-registry.md](module-registry.md#delivery). + +## Integration Map + +``` +Verticals (Restaurant / Marketplace / Store / Pharmacy / Clinic / Sports Center / …) + │ + │ API / Events (job refs only — no shared DB) + ▼ + Delivery Platform ──API/Events──▶ Accounting (settlement → Posting Engine) + Delivery Platform ──API/Events──▶ Communication (SMS/push/OTP notifications) + Delivery Platform ──API/Events──▶ Loyalty (optional earn refs) + Delivery Platform ──API/Events──▶ CRM (contact refs only) + Delivery Platform ──API─────────▶ Core (entitlement, tenant) + Delivery Platform ──Adapters───▶ External routing / fleet providers +``` + +Forbidden: cross-DB queries, embedding SMS providers inside Delivery, creating JournalEntry locally, owning Restaurant/Marketplace order aggregates. + +## Disambiguation + +| Term | Owner | +| --- | --- | +| Message delivery (queued/sent/delivered) | Communication | +| Physical / courier delivery jobs | Delivery Platform (`delivery`) | + +## Phase Map + +| Phase | ID | Name | Status | +| --- | --- | --- | --- | +| Reg | `delivery-reg` | Platform Registration | Complete | +| 10.0 | `delivery-10.0` | Foundation | Complete | +| 10.1 | `delivery-10.1` | Driver Management | Complete | +| 10.2 | `delivery-10.2` | Fleet & Vehicle Types | Planned | +| 10.3 | `delivery-10.3` | Availability, Shifts & Working Zones | Planned | +| 10.4 | `delivery-10.4` | Pricing, Capabilities & Bundles | Planned | +| 10.5 | `delivery-10.5` | Dispatch Engine | Planned | +| 10.6 | `delivery-10.6` | Routing & Optimization | Planned | +| 10.7 | `delivery-10.7` | Tracking & Proof of Delivery | Planned | +| 10.8 | `delivery-10.8` | Settlement | Planned | +| 10.9 | `delivery-10.9` | Merchant Connector & App Surfaces | Planned | +| 10.10 | `delivery-10.10` | Analytics, AI Ready & Enterprise Validation | Planned | + +Details (dependencies, required docs/services, completion criteria): [phase-manifest.yaml](ai-framework/phase-manifest.yaml). + +## Platform Expose (every implementation phase) + +Health API · Capability API · Metrics · Events · REST APIs · Permission APIs + +## Future Expansion + +- Additional routing engine adapters without vertical changes +- Third-party fleet marketplaces behind provider protocols +- Deeper ETA / surge pricing engines when scoped +- Cross-tenant platform-admin fleet analytics (explicit + audited) + +## Out of Scope + +- Owning Accounting ledgers or Posting Engine +- Owning Sales CRM aggregates +- Owning Loyalty ledger / campaigns +- Owning SMS/email/push providers (Communication owns them) +- Owning Restaurant/Marketplace/Clinic/Sports Center order domains +- Implementing Phase 10.x business code in the registration phase +- Driver App / Dispatcher Panel UI (frontend phases) + +## Related Documents + +- [Phase Area README](phases/Delivery/README.md) +- [ADR-015](architecture/adr/ADR-015.md) +- [Module Registry](module-registry.md#delivery) +- [Glossary — Delivery](glossary.md#delivery--fleet-platform) +- [Service Manifest](ai-framework/service-manifest.yaml) +- [Progress](progress.md) +- [Roadmap](roadmap.md) +- [Next Steps](next-steps.md) - [Phase Handover DP-Reg](phase-handover/phase-dp-reg.md) diff --git a/docs/development/coding-standards.md b/docs/development/coding-standards.md index e58eb60..a36ac9d 100644 --- a/docs/development/coding-standards.md +++ b/docs/development/coding-standards.md @@ -9,7 +9,15 @@ backend/<service>/app/ models/ schemas/ # Pydantic DTOs services/ # business logic + commands/ # state-changing use cases (preferred for new work) + queries/ # read use cases (preferred for new work) + policies/ # authz / domain policies + specifications/ # reusable predicates repositories/ + providers/ # owning-service adapters only + validators/ + events/ + permissions/ middlewares/ workers/ tests/ @@ -23,6 +31,7 @@ frontend/ public/ ``` +Enterprise layer expectations: [ai-framework/mandatory-phase-artifacts.md](../ai-framework/mandatory-phase-artifacts.md), [ai-framework/service-layer-template.md](../ai-framework/service-layer-template.md). Existing services may use equivalent patterns without a forced layout retrofit. ## Naming | Kind | Convention | @@ -99,3 +108,5 @@ Resource nouns, plural collections, verbs only for non-CRUD actions (`/suspend`, - [Project Principles](project-principles.md) - [Developer Guide](developer-guide.md) - [Service Architecture](../architecture/service-architecture.md) +- [Enterprise / AI Development Framework](../ai-framework/README.md) +- [Service Layer Template](../ai-framework/service-layer-template.md) diff --git a/docs/development/project-principles.md b/docs/development/project-principles.md index 5ab44e7..749b805 100644 --- a/docs/development/project-principles.md +++ b/docs/development/project-principles.md @@ -22,6 +22,11 @@ These principles are mandatory for every implementation phase. 18. **No hardcoded brand or secrets.** Config/env/database only. 19. **Documentation before conflicting code.** If implementation conflicts with architecture, stop and fix docs/ADR first. 20. **Phase completion gate.** Follow the checklist in [docs/README.md](../README.md) and [ai-framework/quality-gates.md](../ai-framework/quality-gates.md). +21. **Enterprise Definition of Done.** Every phase must satisfy [ai-framework/definition-of-done.md](../ai-framework/definition-of-done.md). CRUD is never sufficient. +22. **Mandatory production artifacts by default.** Deliver applicable items from [ai-framework/mandatory-phase-artifacts.md](../ai-framework/mandatory-phase-artifacts.md) without waiting for the phase prompt to restate them. +23. **Boundary discipline.** Never own another service’s responsibilities, duplicate its logic, access its database, or pull future-phase features forward ([ai-framework/boundary-rules.md](../ai-framework/boundary-rules.md)). +24. **Enterprise Completeness verification.** Sign off [ai-framework/enterprise-completeness.md](../ai-framework/enterprise-completeness.md) before Complete; implement any missing current-phase requirement first ([ADR-018](../architecture/adr/ADR-018.md)). +25. **Enterprise Phase Discovery.** Before Implementation, run [ai-framework/enterprise-phase-discovery.md](../ai-framework/enterprise-phase-discovery.md): inventory roadmap, architecture, boundaries, prior handover, codebase, APIs, domain, events, permissions, aggregates, tests, and docs; promote missing current-phase capabilities into Scope ([ADR-019](../architecture/adr/ADR-019.md)). ## Related Documents @@ -29,5 +34,9 @@ These principles are mandatory for every implementation phase. - [Testing Strategy](testing-strategy.md) - [Architecture Overview](../architecture/architecture.md) - [Module Registry](../module-registry.md) -- [AI Development Framework](../ai-framework/README.md) +- [AI / Enterprise Development Framework](../ai-framework/README.md) +- [Enterprise Phase Discovery](../ai-framework/enterprise-phase-discovery.md) +- [Definition of Done](../ai-framework/definition-of-done.md) - [ADR-013](../architecture/adr/ADR-013.md) +- [ADR-018](../architecture/adr/ADR-018.md) +- [ADR-019](../architecture/adr/ADR-019.md) diff --git a/docs/development/testing-strategy.md b/docs/development/testing-strategy.md index 7fee11b..34c1a35 100644 --- a/docs/development/testing-strategy.md +++ b/docs/development/testing-strategy.md @@ -4,17 +4,18 @@ | Layer | Purpose | | --- | --- | -| Unit | Pure domain logic, validators, mappers | -| Service | Business rules with DB/fakes | -| Repository | Query correctness, tenant filters | -| API | HTTP contracts, authz, status codes | +| Unit | Pure domain logic, validators, mappers, specifications, policies | +| Service / Command / Query | Business rules with DB/fakes; read-side without write side-effects | +| Repository | Query correctness, tenant filters, soft-delete, page/filter/sort/search | +| API | HTTP contracts, authz, status codes, OpenAPI smoke when pattern exists | | Integration | Multi-component flows (onboarding, OTP, SSO handoff) | -| Migration | Upgrade/downgrade smoke on clean DB | +| Migration | Upgrade/downgrade smoke on clean DB; seeds when required | | Tenant | Cross-tenant isolation negatives | -| Performance | Hot entitlement/resolve paths as needed | +| Performance | Hot entitlement/resolve/list paths as needed | | Security | Auth bypass attempts, token scope checks | -| Architecture | Boundary rules (optional lint/CI later) | -| Documentation validation | Links, registry completeness for the phase | +| Architecture | Boundary/layering rules (service suites) | +| Documentation validation | Links, registry completeness, DoD/handover for the phase | +| Permission | Privileged route denial cases | ## Rules @@ -23,6 +24,8 @@ 3. Do not rely on production data for assertions. 4. Prefer deterministic time/OTP fakes. 5. Phase is incomplete if tests fail or are absent for claimed deliverables. +6. CRUD-only coverage without domain-rule tests is insufficient when business rules exist ([definition-of-done.md](../ai-framework/definition-of-done.md)). +7. Architecture, security, and documentation validation are required for service/module implementation phases ([testing-template.md](../ai-framework/testing-template.md)). ## Commands (current) @@ -41,3 +44,4 @@ pytest -q - [Branching Strategy](branching-strategy.md) - [AI Framework Testing Template](../ai-framework/testing-template.md) - [Quality Gates](../ai-framework/quality-gates.md) +- [Definition of Done](../ai-framework/definition-of-done.md) diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 897dc45..a4c7455 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -22,6 +22,7 @@ Enterprise Accounting UI lives inside the TorbatYar SuperApp (`frontend/`), not | [frontend-performance.md](frontend-performance.md) | Splitting & lists | | [frontend-accessibility.md](frontend-accessibility.md) | WCAG AA baseline | | [frontend-testing.md](frontend-testing.md) | Quality gate checklist | +| [healthcare-design-system.md](healthcare-design-system.md) | Torbat Health UI extension | ## Hard Rules diff --git a/docs/frontend/component-library.md b/docs/frontend/component-library.md index 6053401..8d0ea5f 100644 --- a/docs/frontend/component-library.md +++ b/docs/frontend/component-library.md @@ -22,12 +22,29 @@ Located in `frontend/components/ds/`. Import via `@/components/ds`. +## Healthcare extension (`frontend/components/healthcare/ui/`) + +See [healthcare-design-system.md](healthcare-design-system.md). Uses global DS + `--health-accent*` tokens. Portal shells in `HealthcarePortalShell`, public chrome in `PublicHealthcareLayout`. + ## Dates - Never use native `type="date"` (Gregorian browser picker) in Accounting. - Always use `DatePicker` for input and `JalaliDateText` / `formatJalali` for display. - API payloads remain `YYYY-MM-DD` Gregorian. +## Money + +- Always use `MoneyInput` from `@/components/ds` for amount / price / debit / credit fields. +- Display uses thousand separators (`1,500,000`); RHF/API values stay plain (`1500000`). +- Helpers: `formatMoney`, `formatMoneyGrouped`, `parseMoneyInput` in `frontend/lib/utils.ts`. +- Never show raw ungrouped numbers in tables — use `formatMoney(...)`. + +## Parties & items (AJAX) + +- Party fields: `PartyCombobox` (`frontend/components/accounting/EntityCombobox.tsx`) — loads customers/suppliers from AR/AP. +- Line items: `ItemCombobox` — loads `/api/v1/ops/items` (کارت کالا). +- Free-text party/item entry is not allowed on operational invoices/orders when master data exists. + ## Accounting helpers - `CompletionScoreboard` — `frontend/components/accounting/CompletionScoreboard.tsx` (reads `lib/accounting-scoreboard.ts`). diff --git a/docs/frontend/design-system.md b/docs/frontend/design-system.md index b4c8ecf..6a0af8a 100644 --- a/docs/frontend/design-system.md +++ b/docs/frontend/design-system.md @@ -38,3 +38,7 @@ Every data view must support: loading (`LoadingState`), empty (`EmptyState`), er ## Dates (Accounting) All date pickers are **Jalali/Shamsi** via `DatePicker`. Native Gregorian browser pickers are forbidden. API still receives Gregorian ISO. + +## Money (Accounting) + +All price/amount fields use `MoneyInput` with live thousand separators. Stored/submitted values are plain numeric strings without separators. Tables/readouts use `formatMoney`. diff --git a/docs/frontend/form-patterns.md b/docs/frontend/form-patterns.md index 0b450bb..9d6b00d 100644 --- a/docs/frontend/form-patterns.md +++ b/docs/frontend/form-patterns.md @@ -5,5 +5,9 @@ - **Submit**: disable while `mutation.isPending` - **Dirty**: rely on RHF `formState.isDirty` for future autosave - **Server validation**: map `AccountingApiError.message` to toast (field mapping when backend returns field errors) +- **Money**: `MoneyInput` only — never plain `<Input type="number">` for currency; parse with `parseMoneyInput` before API if needed +- **Party / item**: `PartyCombobox` / `ItemCombobox` — AJAX from registered masters, not free text Voucher create uses `useFieldArray` with minimum two balanced lines (enforced by Zod + backend). + +Operational invoices/orders use line item comboboxes bound to inventory items and party comboboxes bound to customers/suppliers. diff --git a/docs/frontend/healthcare-design-system.md b/docs/frontend/healthcare-design-system.md new file mode 100644 index 0000000..b8e0aaf --- /dev/null +++ b/docs/frontend/healthcare-design-system.md @@ -0,0 +1,64 @@ +# Healthcare Design System + +Extends the global DS ([design-system.md](design-system.md)) — **do not** create parallel tokens or components outside `@/components/ds` and `@/components/healthcare/ui`. + +## Tokens (`styles/globals.css`) + +| Token | Role | +| --- | --- | +| `--health-accent` | Primary healthcare brand (teal) | +| `--health-accent-soft` | Soft fills, icon backgrounds | +| `--health-calm` | Secondary calm accent | +| `--health-success` / `--warning` / `--danger` | Clinical status semantics | + +Light and dark modes share the same token names. + +## Healthcare UI (`frontend/components/healthcare/ui/`) + +| Component | Purpose | +| --- | --- | +| `MedicalStatusChip` | Appointment / prescription / visit status | +| `ClinicCard`, `DoctorCard`, `PatientCard` | Entity cards | +| `AppointmentCard`, `PrescriptionCard` | Workflow cards | +| `StatWidget` | Dashboard KPI | +| `TimelineItem` | Patient journey / status timeline | +| `QueueRow` | Reception / doctor queue | +| `SearchResultRow` | Public find-doctor/clinic results | + +Import: `@/components/healthcare/ui`. + +## Layouts + +| Layout | Path | Use | +| --- | --- | --- | +| `PublicHealthcareLayout` | `/healthcare/site/*` | Marketing + find + book | +| `HealthcarePortalShell` | Portal routes | Role-based sidebar + mobile drawer | +| Hub | `/healthcare/hub` | Portal selector | + +Portal nav: `frontend/lib/healthcare-portals.ts`. + +## Page modules + +Shared logic in `frontend/components/healthcare/pages/`: + +- `shared.tsx` — loaders, metrics, audit tables, integration providers +- `patient.tsx`, `doctor.tsx`, `reception.tsx`, `clinic.tsx`, `hospital.tsx`, `pharmacy.tsx`, `admin.tsx`, `public.tsx`, `hub.tsx` + +Route files re-export these modules (thin `page.tsx`). + +## API + +`frontend/lib/healthcare-api.ts` — JWT + `X-Tenant-ID`; patient portal adds `X-Patient-ID`. BFF: `/api/healthcare/*`. + +## Rules + +1. No mock data — all lists/forms hit `healthcareApi`. +2. Reuse `@/components/ds` for forms, tables, dialogs. +3. RTL-first Persian copy. +4. Legacy paths (`/healthcare/clinics`, …) redirect to new portal routes. + +## Entry points + +- SuperApp catalog → `/healthcare/hub` +- Public site → `/healthcare/site` +- Mobile hub → `/healthcare/mobile/[portal]` diff --git a/docs/glossary.md b/docs/glossary.md index 252a9bd..000e5a3 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,149 +1,217 @@ -# Glossary - -Canonical definitions for TorbatYar. Prefer these terms in docs and code comments. - -## Platform & Tenancy - -| Term | Definition | -| --- | --- | -| **Tenant** | A customer workspace/organization on the platform. Owns domains, branding, memberships, and subscriptions. | -| **Workspace** | Product synonym for an operational Tenant after onboarding. | -| **Organization** | Business entity represented by a Tenant (not a separate table today). | -| **Member** | **Platform:** a user with a membership row linking them to a Tenant. **Sports Center:** see Member (Sports) — do not conflate. | -| **Platform Member** | Preferred unambiguous term for a user linked to a Tenant via `tenant_memberships`. | -| **Tenant Membership (Core)** | `core_platform_db.tenant_memberships` — source of truth for workspace roles/ownership. | -| **Tenant Membership (Identity)** | `identity_access_db.tenant_memberships` — SSO listing membership; not operational authz. | -| **Workspace Activation** | Completing onboarding so tenant moves to `active` with `onboarding_completed=true`. | -| **Current Tenant** | `users.current_tenant_id` — workspace selected for the session context. | -| **Domain** | Hostname mapped to a tenant (subdomain or custom). | -| **Primary Domain** | Domain marked `is_primary` for the tenant. | -| **White Label** | Per-tenant branding (colors, logo, favicon, name) applied at runtime. | -| **Platform Base Domain** | `PLATFORM_BASE_DOMAIN` (e.g. `torbatyar.ir`) used to mint `{slug}.{base}`. | - -## Identity & Access - -| Term | Definition | -| --- | --- | -| **Identity** | Who the actor is (Keycloak subject + Core/Identity profiles). | -| **Platform User** | User recorded in Core `users` (mobile-required). | -| **SSO** | Central Keycloak OpenID Connect login for staff. | -| **OTP** | One-time password sent via SMS for mobile verification/login. | -| **Keycloak Sub** | Stable IdP subject id stored as `keycloak_sub`. | -| **JIT Provisioning** | Creating/linking Core user from Keycloak JWT on first resolve. | -| **Handoff** | One-time session token from Keycloak theme mobile flow redeemed by frontend. | -| **BFF** | Backend-for-frontend (Identity token exchange). | -| **Role** | Named authorization level (platform/tenant roles). | -| **Permission** | Fine-grained allow rule (future trees; today largely role + entitlement). | -| **Entitlement** | Plan/feature gate: whether a tenant may use a feature_key. | -| **Feature Key** | `{service}.{resource}.{action}` string checked by Core. | -| **Internal Service Token** | Machine credential for service-to-service calls. | - -## Modular Product - -| Term | Definition | -| --- | --- | -| **Module** | Installable/activateable product capability with clear DB and API ownership. | -| **Service** | Deployable backend unit implementing one or more modules. | -| **Provider** | External vendor adapter (SMS, payment, storage, AI model, tax). | -| **Service Registry** | Core table of internal services (`service_key`, base URL, health). | -| **Module Registry** | Core table + docs inventory of modules and activation. | -| **Subscription** | Tenant’s plan binding (`tenant_subscriptions`). | -| **Plan** | Packaged set of features/limits (e.g. FREE, STARTER). | - -## Accounting (future) - -| Term | Definition | -| --- | --- | -| **Ledger** | Books of account for a tenant. | -| **Account** | Chart-of-accounts node (planned 4-level structure). | -| **Journal** | Collection of journal entries. | -| **Journal Entry** | Double-entry header document. | -| **Journal Line** | Debit/credit line under an entry. | -| **Voucher** | Business document that may result in postings. | -| **Posting** | Act of writing validated lines to the ledger. | -| **Posting Engine** | Sole authorized component to create journal entries. | -| **Cost Center** | Analytical dimension for costs. | -| **Project** | Analytical dimension for project accounting. | - -## CRM / Commerce / Ops - -| Term | Definition | -| --- | --- | -| **Lead** | Prospective customer record (CRM-owned). | -| **Contact** | Person record in Sales CRM. | -| **Organization (CRM)** | Business account / company record in Sales CRM (not the platform Tenant). | -| **Opportunity** | Qualified sales opportunity in a pipeline. | -| **Pipeline / Stage** | Configurable sales process steps. | -| **Sales Playbook** | Versioned sales checklist assigned to pipeline/opportunity. | -| **Sales Forecast** | Rule-based pipeline/weighted/committed forecast (no ML). | -| **Sales Activity** | Call, meeting, task, email, follow-up, demo, or custom activity. | -| **Sales Timeline** | Immutable CRM collaboration event stream. | -| **Quote (Sales)** | CRM sales quote — not an accounting invoice. | -| **Loyalty Program** | Tenant-scoped loyalty program configuration (Loyalty-owned). | -| **Loyalty Member** | Loyalty-owned membership record in a program (distinct from Platform Member / Sports Member). | -| **Membership Lifecycle** | Loyalty state machine: enroll/activate/renew/freeze/resume/cancel/expire/transfer. | -| **Loyalty Member** | Enrolled participant in a Loyalty Program; belongs to exactly one Tenant. | -| **Membership Tier** | Ranked level within a Loyalty Program. | -| **Point Account** | Loyalty points account shell; balances come only from immutable ledger entries. | -| **Communication Platform** | Independent shared service owning outbound messaging, providers, templates, queue, OTP, and delivery tracking. | -| **Provider Failover** | Automatic switch to the next priority provider when send fails or circuit is open. | -| **Dynamic Contact Source** | API-configured resolver that fetches destinations at send time without copying business-module rows. | -| **Order** | Purchase intent/fulfillment record (ecommerce/restaurant). | -| **Inventory** | Stock levels for sellable items. | - -## Sports Center - -| Term | Definition | -| --- | --- | -| **Member (Sports)** | Enrolled athlete or club customer profile in Sports Center (`members` table) — not a Platform Member and not a Loyalty Member. | -| **Membership (Sports)** | Active enrollment instance linking a Member to a Membership Type from the catalog; supports freeze/activate/cancel. | -| **Family Member (Sports)** | Household/relationship link under a Sports Member (may optionally reference another Member). | -| **Emergency Contact** | Contact person recorded for a Sports Member for emergency use. | -| **Medical Information (Sports)** | Privacy-aware clearance/notes shell for a Sports Member — not an EHR; binaries via Storage refs. | -| **Membership Card** | Physical/digital/QR card issued to a Sports Member (payload strings only until attendance devices). | -| **Digital Membership** | Digital pass / wallet shell for a Sports Member (`pass_code` + optional token/deep-link refs). | -| **Waiver** | Liability/consent record for a Sports Member; sign action stores signature/file refs. | -| **Coach** | Sports Center staff record who coaches members/sessions (Sports Center–owned). | -| **Trainer** | Synonym/role variant of Coach; prefer **Coach** in APIs unless a distinct trainer role is modeled. | -| **Program** | Structured training program definition owned by Sports Center (not a Loyalty Program). | -| **Workout** | Concrete workout definition or assigned workout instance under a Program or coach plan. | -| **Attendance** | Check-in/check-out or presence record for a member at a facility/session. | -| **Booking** | Reservation of a facility, court, session, or equipment slot. | -| **Facility** | Physical sports space unit (building area, room, hall) owned by Sports Center. | -| **Court** | Bookable facility subtype (e.g. tennis/futsal court). | -| **Session** | Scheduled time-bound activity (class, training, rental) that may be booked or attended. | -| **Locker** | Assignable locker unit or locker status record in Sports Center. | -| **Access Device** | Hardware or logical reader used for attendance/access control (adapter-owned credentials stay out of other services). | -| **Competition** | Organized contest or tournament record owned by Sports Center. | -| **Membership Plan** | Commercial definition of sports membership benefits/duration (Membership Types). | -| **Package** | Bundled sports offering (sessions/classes/services) sold under Membership Types. | -| **Renewal** | Extending an active sports Membership for a new period. | -| **Freeze** | Temporary suspension of a sports Membership without full cancellation. | -| **Transfer** | Moving a sports Membership between eligible members/accounts per business rules. | -| **Sports Event** | Calendar/competition event in Sports Center — distinct from domain/integration **Events** on the bus. | - -## Technical - -| Term | Definition | -| --- | --- | -| **Database-per-Service** | Each service owns its DB; no cross-DB queries. | -| **Outbox / Inbox** | Reliable event publish/consume tables. | -| **Event Envelope** | Standard event metadata wrapper. | -| **API-First** | Contracts precede UI implementation. | -| **ADR** | Architecture Decision Record. | -| **Phase** | Time-boxed delivery with completion gate. | -| **AI Development Framework** | Permanent docs under `docs/ai-framework/` governing AI-assisted implementation (process — not the product AI Assistant). | -| **Tenant-Aware** | Request/data path always scoped to a tenant. | -| **Audit Log** | Immutable-ish record of who did what, when. | - -## Phase Numbering Note - -Internal docs: **فاز ۳** = OTP + Tenant Management; **فاز ۴** = Onboarding/Workspace Activation. Some briefs labeled onboarding as “Phase 3”. Always disambiguate using [progress.md](progress.md). - -## Related Documents - -- [Module Registry](module-registry.md) -- [Provider Registry](provider-registry.md) -- [Architecture Overview](architecture/architecture.md) -- [AI Development Framework](ai-framework/README.md) -- [Sports Center Roadmap](sports-center-roadmap.md) +# Glossary + +Canonical definitions for TorbatYar. Prefer these terms in docs and code comments. + +## Platform & Tenancy + +| Term | Definition | +| --- | --- | +| **Tenant** | A customer workspace/organization on the platform. Owns domains, branding, memberships, and subscriptions. | +| **Workspace** | Product synonym for an operational Tenant after onboarding. | +| **Organization** | Business entity represented by a Tenant (not a separate table today). | +| **Member** | **Platform:** a user with a membership row linking them to a Tenant. **Sports Center:** see Member (Sports) — do not conflate. | +| **Platform Member** | Preferred unambiguous term for a user linked to a Tenant via `tenant_memberships`. | +| **Tenant Membership (Core)** | `core_platform_db.tenant_memberships` — source of truth for workspace roles/ownership. | +| **Tenant Membership (Identity)** | `identity_access_db.tenant_memberships` — SSO listing membership; not operational authz. | +| **Workspace Activation** | Completing onboarding so tenant moves to `active` with `onboarding_completed=true`. | +| **Current Tenant** | `users.current_tenant_id` — workspace selected for the session context. | +| **Domain** | Hostname mapped to a tenant (subdomain or custom). | +| **Primary Domain** | Domain marked `is_primary` for the tenant. | +| **White Label** | Per-tenant branding (colors, logo, favicon, name) applied at runtime. | +| **Platform Base Domain** | `PLATFORM_BASE_DOMAIN` (e.g. `torbatyar.ir`) used to mint `{slug}.{base}`. | + +## Identity & Access + +| Term | Definition | +| --- | --- | +| **Identity** | Who the actor is (Keycloak subject + Core/Identity profiles). | +| **Platform User** | User recorded in Core `users` (mobile-required). | +| **SSO** | Central Keycloak OpenID Connect login for staff. | +| **OTP** | One-time password sent via SMS for mobile verification/login. | +| **Keycloak Sub** | Stable IdP subject id stored as `keycloak_sub`. | +| **JIT Provisioning** | Creating/linking Core user from Keycloak JWT on first resolve. | +| **Handoff** | One-time session token from Keycloak theme mobile flow redeemed by frontend. | +| **BFF** | Backend-for-frontend (Identity token exchange). | +| **Role** | Named authorization level (platform/tenant roles). | +| **Permission** | Fine-grained allow rule (future trees; today largely role + entitlement). | +| **Entitlement** | Plan/feature gate: whether a tenant may use a feature_key. | +| **Feature Key** | `{service}.{resource}.{action}` string checked by Core. | +| **Internal Service Token** | Machine credential for service-to-service calls. | + +## Modular Product + +| Term | Definition | +| --- | --- | +| **Module** | Installable/activateable product capability with clear DB and API ownership. | +| **Service** | Deployable backend unit implementing one or more modules. | +| **Provider** | External vendor adapter (SMS, payment, storage, AI model, tax). | +| **Service Registry** | Core table of internal services (`service_key`, base URL, health). | +| **Module Registry** | Core table + docs inventory of modules and activation. | +| **Subscription** | Tenant’s plan binding (`tenant_subscriptions`). | +| **Plan** | Packaged set of features/limits (e.g. FREE, STARTER). | + +## Accounting (future) + +| Term | Definition | +| --- | --- | +| **Ledger** | Books of account for a tenant. | +| **Account** | Chart-of-accounts node (planned 4-level structure). | +| **Journal** | Collection of journal entries. | +| **Journal Entry** | Double-entry header document. | +| **Journal Line** | Debit/credit line under an entry. | +| **Voucher** | Business document that may result in postings. | +| **Posting** | Act of writing validated lines to the ledger. | +| **Posting Engine** | Sole authorized component to create journal entries. | +| **Cost Center** | Analytical dimension for costs. | +| **Project** | Analytical dimension for project accounting. | + +## CRM / Commerce / Ops + +| Term | Definition | +| --- | --- | +| **Lead** | Prospective customer record (CRM-owned). | +| **Contact** | Person record in Sales CRM. | +| **Organization (CRM)** | Business account / company record in Sales CRM (not the platform Tenant). | +| **Opportunity** | Qualified sales opportunity in a pipeline. | +| **Pipeline / Stage** | Configurable sales process steps. | +| **Sales Playbook** | Versioned sales checklist assigned to pipeline/opportunity. | +| **Sales Forecast** | Rule-based pipeline/weighted/committed forecast (no ML). | +| **Sales Activity** | Call, meeting, task, email, follow-up, demo, or custom activity. | +| **Sales Timeline** | Immutable CRM collaboration event stream. | +| **Quote (Sales)** | CRM sales quote — not an accounting invoice. | +| **Loyalty Program** | Tenant-scoped loyalty program configuration (Loyalty-owned). | +| **Loyalty Member** | Loyalty-owned membership record in a program (distinct from Platform Member / Sports Member). | +| **Membership Lifecycle** | Loyalty state machine: enroll/activate/renew/freeze/resume/cancel/expire/transfer. | +| **Loyalty Member** | Enrolled participant in a Loyalty Program; belongs to exactly one Tenant. | +| **Membership Tier** | Ranked level within a Loyalty Program. | +| **Point Account** | Loyalty points account shell; balances come only from immutable ledger entries. | +| **Communication Platform** | Independent shared service owning outbound messaging, providers, templates, queue, OTP, and delivery tracking. | +| **Provider Failover** | Automatic switch to the next priority provider when send fails or circuit is open. | +| **Dynamic Contact Source** | API-configured resolver that fetches destinations at send time without copying business-module rows. | +| **Order** | Purchase intent/fulfillment record (ecommerce/restaurant). | +| **Inventory** | Stock levels for sellable items. | + +## Sports Center + +| Term | Definition | +| --- | --- | +| **Member (Sports)** | Enrolled athlete or club customer profile in Sports Center (`members` table) — not a Platform Member and not a Loyalty Member. | +| **Membership (Sports)** | Active enrollment instance linking a Member to a Membership Type from the catalog; supports freeze/activate/cancel. | +| **Family Member (Sports)** | Household/relationship link under a Sports Member (may optionally reference another Member). | +| **Emergency Contact** | Contact person recorded for a Sports Member for emergency use. | +| **Medical Information (Sports)** | Privacy-aware clearance/notes shell for a Sports Member — not an EHR; binaries via Storage refs. | +| **Membership Card** | Physical/digital/QR card issued to a Sports Member (payload strings only until attendance devices). | +| **Digital Membership** | Digital pass / wallet shell for a Sports Member (`pass_code` + optional token/deep-link refs). | +| **Waiver** | Liability/consent record for a Sports Member; sign action stores signature/file refs. | +| **Coach** | Sports Center staff record who coaches members/sessions (Sports Center–owned). | +| **Trainer** | Synonym/role variant of Coach; prefer **Coach** in APIs unless a distinct trainer role is modeled. | +| **Program** | Structured training program definition owned by Sports Center (not a Loyalty Program). | +| **Workout** | Concrete workout definition or assigned workout instance under a Program or coach plan. | +| **Attendance** | Check-in/check-out or presence record for a member at a facility/session. | +| **Booking** | Reservation of a facility, court, session, or equipment slot. | +| **Facility** | Physical sports space unit (building area, room, hall) owned by Sports Center. | +| **Court** | Bookable facility subtype (e.g. tennis/futsal court). | +| **Session** | Scheduled time-bound activity (class, training, rental) that may be booked or attended. | +| **Locker** | Assignable locker unit or locker status record in Sports Center. | +| **Access Device** | Hardware or logical reader used for attendance/access control (adapter-owned credentials stay out of other services). | +| **Competition** | Organized contest or tournament record owned by Sports Center. | +| **Membership Plan** | Commercial definition of sports membership benefits/duration (Membership Types). | +| **Package** | Bundled sports offering (sessions/classes/services) sold under Membership Types. | +| **Renewal** | Extending an active sports Membership for a new period. | +| **Freeze** | Temporary suspension of a sports Membership without full cancellation. | +| **Transfer** | Moving a sports Membership between eligible members/accounts per business rules. | +| **Sports Event** | Calendar/competition event in Sports Center — distinct from domain/integration **Events** on the bus. | + + +## Delivery & Fleet Platform + +| Term | Definition | +| --- | --- | +| **Delivery Platform** | Independent shared logistics service (`delivery`) owning drivers, fleet, dispatch, routing, tracking, POD, and settlement intents — commercial product **Torbat Driver**. | +| **Torbat Driver** | Commercial product name for the Delivery & Fleet Platform. | +| **Driver (Delivery)** | Courier/operator profile owned by Delivery — not a Platform Member and not a Sports Coach. | +| **Fleet** | Group of vehicles/drivers managed as an operational unit in Delivery. | +| **Vehicle Type** | Catalog class of vehicle (bike, car, van, …) with capability flags. | +| **Dispatch** | Assignment of delivery jobs to drivers/vehicles. | +| **Delivery Job** | Logistics work unit referenced by external vertical order IDs — Delivery does not own the vertical order aggregate. | +| **Working Zone** | Geo/operational area where drivers may accept jobs. | +| **Shift (Delivery)** | Scheduled working window for a driver. | +| **Multi Pickup / Multi Drop** | Route with multiple pickup and/or drop stops on one job/route. | +| **Proof of Delivery (POD)** | Evidence that a drop was completed (signature/photo/code refs). | +| **Customer Tracking** | Tokenized tracking view for end customers — distinct from dispatcher tracking. | +| **Merchant Connector** | API/event contract for verticals to request logistics without embedding Delivery logic. | +| **Settlement (Delivery)** | Intent to settle driver/merchant amounts; journals only via Accounting Posting Engine. | +| **Message Delivery** | Communication-owned SMS/email/push delivery status — **not** physical logistics. | + +## Experience Platform + +| Term | Definition | +| --- | --- | +| **Experience Platform** | Independent shared digital experience service (`experience`) owning sites, pages-as-resources, versioned components, themes, layouts, templates, forms, SEO/PWA shells, bundles, and widgets — commercial product **Torbat Pages**. | +| **Torbat Pages** | Commercial product name for the Experience Platform. | +| **Page Resource** | First-class tenant-scoped page entity with identity, lifecycle, and permissions — not a CMS blob owned by a vertical. | +| **Component (Experience)** | Versioned building block composed into pages; versions are immutable once published. | +| **Theme (Experience)** | Replaceable visual pack applied to sites/pages without rewriting page trees. | +| **Layout (Experience)** | Dynamic composition structure for placing components. | +| **Template (Experience)** | Starter definition used to instantiate sites/pages of a given type. | +| **Page Type** | Classification of page purpose (landing, mini site, digital menu, bio link, portfolio, blog, QR, …). | +| **Experience Bundle** | Licensed capability pack (e.g. Digital Menu, Bio Link, Custom Domain) gated via entitlement. | +| **Widget (Experience)** | Embeddable experience surface for consumer apps/sites. | +| **Consumer Connector (Experience)** | API/event contract for verticals to bind entity refs into pages without embedding Experience logic. | +| **Website Builder (historical)** | Scaffolded stub service — prefer Experience Platform for new page/site work ([ADR-016](architecture/adr/ADR-016.md)). | + +## Hospitality Platform + +| Term | Definition | +| --- | --- | +| **Hospitality Platform** | Independent F&B operations service (`hospitality`) owning venues, menus, tables, bundles, and feature toggles — commercial product **Torbat Food**. | +| **Torbat Food** | Commercial product name for the Hospitality Platform. | +| **Venue** | Root hospitality unit for a tenant (cafe, restaurant, bakery, cloud kitchen, …). | +| **Venue Format** | Configurable classification of a venue — not a hardcoded business engine. | +| **Digital Menu Catalog** | Menu depth layer: allergens, modifiers, availability windows, media refs, localizations (Phase 12.1). | +| **Modifier Group** | Selection-rule container (size/extras) linked to menu items; not a POS engine. | +| **Availability Window** | Time-of-day / weekday window scoping menu, category, or item availability. | +| **Menu Media Ref** | Storage `file_ref` pointer for menu imagery; Hospitality does not own blobs. | +| **Bundle (Hospitality)** | Licensable capability pack (e.g. Digital Menu, POS Lite, Kitchen) gated via entitlement / tenant activation. | +| **Feature Toggle (Hospitality)** | Per-tenant/venue capability flag used for discovery and gating. | +| **Dining Table** | Seating/table shell — QR/ordering engines come in later phases. | +| **Restaurant (historical)** | Scaffold alias — prefer Hospitality Platform ([ADR-017](architecture/adr/ADR-017.md)). | + +## Technical + +| Term | Definition | +| --- | --- | +| **Database-per-Service** | Each service owns its DB; no cross-DB queries. | +| **Outbox / Inbox** | Reliable event publish/consume tables. | +| **Event Envelope** | Standard event metadata wrapper. | +| **API-First** | Contracts precede UI implementation. | +| **ADR** | Architecture Decision Record. | +| **Phase** | Time-boxed delivery with completion gate. | +| **AI Development Framework** | Permanent docs under `docs/ai-framework/` governing AI-assisted implementation (process — not the product AI Assistant). Also called **Enterprise Development Framework** after [ADR-018](architecture/adr/ADR-018.md). | +| **Enterprise Development Framework** | Alias for the upgraded AI Development Framework: production-ready phases by default via DoD, mandatory artifacts, completeness, and boundary rules. | +| **Definition of Done (phase)** | Mandatory exit checklist: feature-complete for the phase boundary; CRUD never sufficient ([definition-of-done.md](ai-framework/definition-of-done.md)). | +| **Mandatory Phase Artifacts** | Default engineering stack every implementation phase must deliver when applicable ([mandatory-phase-artifacts.md](ai-framework/mandatory-phase-artifacts.md)). | +| **Enterprise Completeness** | Pre-close verification dimensions that must pass before Complete ([enterprise-completeness.md](ai-framework/enterprise-completeness.md)). | +| **Enterprise Phase Discovery** | Mandatory pre-implementation inventory of roadmap, architecture, boundaries, prior handover, and existing code/APIs/domain/events/permissions/aggregates/tests/docs; derives missing current-phase capabilities ([enterprise-phase-discovery.md](ai-framework/enterprise-phase-discovery.md), [ADR-019](architecture/adr/ADR-019.md)). | +| **Boundary Rules** | Hard constraints: no foreign ownership, no logic duplication, no cross-DB access, API/Events/Providers only, no future-phase pull-forward. | +| **Tenant-Aware** | Request/data path always scoped to a tenant. | +| **Audit Log** | Immutable-ish record of who did what, when. | + +## Phase Numbering Note + +Internal docs: **فاز ۳** = OTP + Tenant Management; **فاز ۴** = Onboarding/Workspace Activation. Some briefs labeled onboarding as “Phase 3”. Always disambiguate using [progress.md](progress.md). + +## Related Documents + +- [Module Registry](module-registry.md) +- [Provider Registry](provider-registry.md) +- [Architecture Overview](architecture/architecture.md) +- [AI / Enterprise Development Framework](ai-framework/README.md) +- [Enterprise Phase Discovery](ai-framework/enterprise-phase-discovery.md) +- [Definition of Done](ai-framework/definition-of-done.md) +- [ADR-018](architecture/adr/ADR-018.md) +- [ADR-019](architecture/adr/ADR-019.md) +- [Sports Center Roadmap](sports-center-roadmap.md) +- [Delivery Roadmap](delivery-roadmap.md) +- [Experience Roadmap](experience-roadmap.md) +- [Hospitality Roadmap](hospitality-roadmap.md) diff --git a/docs/healthcare-roadmap.md b/docs/healthcare-roadmap.md new file mode 100644 index 0000000..9ccc0cb --- /dev/null +++ b/docs/healthcare-roadmap.md @@ -0,0 +1,165 @@ +# Healthcare Platform Roadmap + +> Future only for Healthcare phases. Completed work → [progress.md](progress.md). Module inventory → [module-registry.md](module-registry.md#healthcare). + +Independent **Healthcare Platform** (commercial product: **Torbat Health**): clinics, doctors, appointments, patient portal, medical records, pharmacy network, and Delivery integration — reusable across tenant workspaces. + +Implementation of every phase must follow the [Enterprise / AI Development Framework](ai-framework/README.md) ([ADR-013](architecture/adr/ADR-013.md), [ADR-018](architecture/adr/ADR-018.md), [ADR-019](architecture/adr/ADR-019.md)). + +--- + +## Phase Overview + +| Phase | Identifier | Title | Status | +| --- | --- | --- | --- | +| 13.0 | `healthcare-13-0` | Healthcare Platform Foundation | Planned | +| 13.1 | `healthcare-13-1` | Appointment Engine | Planned | +| 13.2 | `healthcare-13-2` | Doctor Panel | Planned | +| 13.3 | `healthcare-13-3` | Clinic Management | Planned | +| 13.4 | `healthcare-13-4` | Patient Portal | Planned | +| 13.5 | `healthcare-13-5` | Medical Record | Planned | +| 13.6 | `healthcare-13-6` | Pharmacy Network | Planned | +| 13.7 | `healthcare-13-7` | Delivery Integration | Planned | + +Phase documents: [phases/Healthcare/README.md](phases/Healthcare/README.md). + +--- + +## Phase 13.0 — Healthcare Platform Foundation + +**Objective:** Independent `healthcare` service scaffold with tenant-aware foundation aggregates, permissions, publish-only events, provider contracts, health/capabilities — no appointment/clinical workflow engines. + +**In scope (planned):** + +- Service folder `backend/services/healthcare` (future implementation) +- Database `healthcare_db`; API prefix `/api/v1`; port **8010** (reserved) +- Foundation shells: Clinic, Branch, Department, HealthcareRole, HealthcarePermission, Doctor (profile shell), Patient (profile shell), ExternalProviderConfig, HealthcareConfiguration, HealthcareSetting, HealthcareAuditLog, OutboxEvent +- Provider contracts only: Identity, Communication, CRM, Loyalty, Accounting, Delivery, File Storage, AI +- Permissions `healthcare.*`; events `healthcare.clinic.*`, `healthcare.doctor.*`, `healthcare.patient.*`, … +- Alembic `0001_initial`; architecture / tenant / permission / migration / docs tests + +**Out of scope:** Appointment booking engine, doctor panel workflows, clinic admin ops, patient portal UI/APIs, EHR/clinical documents, pharmacy catalog, real Delivery dispatch. + +→ [phase-13-0-healthcare-foundation.md](phases/Healthcare/phase-13-0-healthcare-foundation.md) + +--- + +## Phase 13.1 — Appointment Engine + +**Objective:** Scheduling engine — availability, slots, appointment lifecycle, reminders via Communication contracts. + +**In scope (planned):** Schedules, availability windows, appointment types, appointments (book/confirm/cancel/reschedule/no-show), waiting list shells, appointment events, `healthcare.appointments.*` permissions. + +**Out of scope:** Doctor panel UX, clinic billing, full patient portal, medical record content, pharmacy fulfillment, Delivery jobs. + +→ [phase-13-1-appointment-engine.md](phases/Healthcare/phase-13-1-appointment-engine.md) + +--- + +## Phase 13.2 — Doctor Panel + +**Objective:** Doctor-facing operational APIs — daily schedule, patient queue, visit notes shells, prescription intent refs. + +**In scope (planned):** Doctor dashboard aggregates, visit sessions, queue management, note shells (metadata + Storage refs), handoff to appointment engine. + +**Out of scope:** Clinic-wide admin, patient self-service portal, immutable clinical record engine, pharmacy inventory, Delivery tracking. + +→ [phase-13-2-doctor-panel.md](phases/Healthcare/phase-13-2-doctor-panel.md) + +--- + +## Phase 13.3 — Clinic Management + +**Objective:** Clinic administrator operations — staff, departments, services catalog, operating hours, policies. + +**In scope (planned):** Clinic services catalog, staff assignments, department hierarchy, clinic policies, admin audit extensions. + +**Out of scope:** Platform tenant admin (Core), Accounting postings, CRM sales pipeline, patient portal, EHR legal archive, pharmacy wholesale. + +→ [phase-13-3-clinic-management.md](phases/Healthcare/phase-13-3-clinic-management.md) + +--- + +## Phase 13.4 — Patient Portal + +**Objective:** Patient-facing APIs — profile, appointments, documents refs, notifications — no frontend in backend phases. + +**In scope (planned):** Patient self-service profile, appointment list/book/cancel (via appointment engine), document download refs, notification preferences (Communication client). + +**Out of scope:** Identity user admin, Loyalty campaigns, full medical record write path, pharmacy checkout, Delivery driver UI. + +→ [phase-13-4-patient-portal.md](phases/Healthcare/phase-13-4-patient-portal.md) + +--- + +## Phase 13.5 — Medical Record + +**Objective:** Structured medical record shells — encounters, diagnoses refs, allergies, medications list, immunization refs, access control. + +**In scope (planned):** MedicalRecord aggregate, Encounter, Condition/Medication/Allergy shells, record access policies, audit trail, Storage refs for attachments. + +**Out of scope:** National EHR exchange, HL7/FHIR mandatory compliance, pharmacy dispensing, Accounting invoices, AI diagnosis. + +→ [phase-13-5-medical-record.md](phases/Healthcare/phase-13-5-medical-record.md) + +--- + +## Phase 13.6 — Pharmacy Network + +**Objective:** Pharmacy partner network — pharmacy registry, prescription routing intents, fulfillment status shells. + +**In scope (planned):** Pharmacy, PrescriptionOrder, PrescriptionLine, fulfillment status lifecycle, events for downstream consumers. + +**Out of scope:** Drug inventory accounting, payment capture, Delivery dispatch (Phase 13.7), CRM marketing. + +→ [phase-13-6-pharmacy-network.md](phases/Healthcare/phase-13-6-pharmacy-network.md) + +--- + +## Phase 13.7 — Delivery Integration + +**Objective:** Integrate prescription / medical supply delivery via Delivery platform contracts — job intake, status sync, no duplicate logistics domain. + +**In scope (planned):** DeliveryIntegrationRegistration, DeliveryJobIntent, status webhook/event consumption shells, Communication notifications on status change. + +**Out of scope:** Owning drivers/fleet/dispatch (Delivery service), Hospitality/Restaurant orders, Experience pages. + +→ [phase-13-7-delivery-integration.md](phases/Healthcare/phase-13-7-delivery-integration.md) + +--- + +## Cross-Module Dependencies + +| Platform | Relationship | +| --- | --- | +| Core | Entitlement, tenant resolution, memberships | +| Identity | User/profile refs for doctors and patients | +| Communication | Appointment reminders, OTP, notifications | +| CRM | Optional contact sync (refs only) | +| Loyalty | Optional patient loyalty (refs only) | +| Accounting | Future billing/settlement intents (refs only) | +| Delivery | Prescription/supply delivery jobs (Phase 13.7) | +| File Storage | Document and imaging refs | +| Experience | Clinic marketing sites (pages owned by Experience) | + +--- + +## Boundary Reminders + +- Healthcare owns clinic/clinical/appointment/pharmacy **domain** aggregates in `healthcare_db` only. +- No cross-database foreign keys; no importing other services' models. +- Financial postings only through Accounting Posting Engine ([ADR-010](architecture/adr/ADR-010.md)). +- Logistics only through Delivery platform ([ADR-015](architecture/adr/ADR-015.md)). +- Messaging only through Communication platform ([ADR-012](architecture/adr/ADR-012.md)). + +--- + +## Related Documents + +- [Module Registry — healthcare](module-registry.md#healthcare) +- [Module Boundaries — Healthcare](architecture/module-boundaries.md) +- [Progress](progress.md) +- [Roadmap](roadmap.md) +- [Next Steps](next-steps.md) +- [Phase area README](phases/Healthcare/README.md) +- [AI / Enterprise Development Framework](ai-framework/README.md) diff --git a/docs/hospitality-phase-12-0.md b/docs/hospitality-phase-12-0.md new file mode 100644 index 0000000..eeb3a36 --- /dev/null +++ b/docs/hospitality-phase-12-0.md @@ -0,0 +1,102 @@ +# Phase 12.0 — Hospitality Platform Foundation + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | hospitality | +| Commercial Product | Torbat Food | +| Version | 0.12.0.0 | +| Database | `hospitality_db` | +| API Port | 8009 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-017 | +| Prior phase id | `restaurant-foundation` (evolved) | + +## Goal + +Establish the **Hospitality Platform** as a completely independent enterprise microservice foundation capable of serving Cafe, Coffee Shop, Restaurant, Fast Food, Bakery, Pastry, Ice Cream, Juice Bar, Cloud Kitchen, Food Court, Catering, and Take Away — without hardcoding format-specific business engines. + +Works standalone and inside TorbatYar SuperApp. Integrations with Accounting, CRM, Loyalty, Communication, Delivery, Website Builder, and AI are **API + Events only**. + +## Scope (In) + +- Independent `backend/services/hospitality` service + `hospitality_db` +- Feature-based architecture shells: bundle definitions, tenant bundle activation, feature toggles, capability discovery +- Foundation aggregates: venues, branches, dining areas, tables, menus, menu categories, menu items, roles, permissions, configurations, settings, events, audit +- Health + `/capabilities` +- Permissions `hospitality.*` (including bundle-gated permission keys registered for later phases) +- Publish-only events `hospitality.*` +- Provider contracts only (Accounting, CRM, Loyalty, Communication, Delivery, Website Builder, AI, …) +- Compose / env wiring on port 8009 +- Quality-gate tests + docs + handover + +## Out of Scope + +- POS Lite / POS Pro engines +- Kitchen display / ticket engines +- QR ordering / reservation engines +- Payment posting / Accounting journals +- Delivery dispatch ownership +- Website Builder pages +- AI inference + +## Owned Modules (aggregates) + +Independent aggregates in `app/models/foundation.py` (UUID refs only, no ORM relationship graphs). + +## Published Events + +| Event | Aggregate | +| --- | --- | +| `hospitality.venue.created` / `updated` | venue | +| `hospitality.branch.created` / `updated` | branch | +| `hospitality.menu.created` / `updated` | menu | +| `hospitality.menu_category.created` | menu_category | +| `hospitality.menu_item.created` | menu_item | +| `hospitality.dining_area.created` | dining_area | +| `hospitality.table.created` | dining_table | +| `hospitality.bundle_definition.created` | bundle_definition | +| `hospitality.tenant_bundle.activated` / `deactivated` | tenant_bundle | +| `hospitality.feature_toggle.upserted` | feature_toggle | +| `hospitality.role.created` | hospitality_role | +| `hospitality.permission.created` | hospitality_permission | +| `hospitality.configuration.created` / `updated` | hospitality_configuration | +| `hospitality.setting.upserted` | hospitality_setting | + +## API Contracts (foundation) + +| Resource | Prefix | +| --- | --- | +| Venues | `/api/v1/venues` | +| Branches | `/api/v1/branches` | +| Dining areas / tables | `/api/v1/dining-areas`, `/api/v1/tables` | +| Menus / categories / items | `/api/v1/menus`, `/menu-categories`, `/menu-items` | +| Bundles / toggles | `/api/v1/bundle-definitions`, `/tenant-bundles`, `/feature-toggles` | +| Roles / permissions | `/api/v1/roles`, `/permissions` | +| Configuration / events / settings | `/api/v1/configurations`, `/events`, `/settings` | +| Health / capabilities | `/health`, `/capabilities` | + +## Permissions + +`hospitality.*` trees covering venues, menus, bundles, feature toggles, audit, and registered bundle-gated keys (`digital_menu`, `pos_lite`, `kitchen`, connectors, …). + +## Architecture Decisions + +1. Database-per-service (`hospitality_db`) — ADR-001 / ADR-017 +2. Row-level `tenant_id` — ADR-003 +3. Event publish contracts via `EventEnvelope` — ADR-006 +4. Venue-format-agnostic model — no hardcoded F&B engines +5. Bundle-based licensing + feature toggles for capability discovery +6. Soft delete + actor audit + hospitality audit log +7. Optimistic locking on venues, menus, tenant bundles, configurations + +## Tests Executed + +Architecture · API foundation · tenant isolation · permissions · bundles · migration · dependency · security · performance · docs + +## Related Documents + +- [Phase Handover 11.0](phase-handover/phase-12-0.md) +- [Hospitality Roadmap](hospitality-roadmap.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Module Registry](module-registry.md#hospitality) +- [Progress](progress.md) diff --git a/docs/hospitality-phase-12-1.md b/docs/hospitality-phase-12-1.md new file mode 100644 index 0000000..342590d --- /dev/null +++ b/docs/hospitality-phase-12-1.md @@ -0,0 +1,80 @@ +# Phase 12.1 — Digital Menu Catalog + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | hospitality | +| Commercial Product | Torbat Food | +| Version | 0.12.1.0 | +| Database | `hospitality_db` | +| API Port | 8009 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-017 | +| Prior phase | [12.0 Foundation](hospitality-phase-12-0.md) | + +## Goal + +Extend the Hospitality Platform digital menu with catalog depth — allergens, modifier groups/options, availability windows, Storage media refs, and multi-language shells — without introducing POS, kitchen, QR ordering, or reservation engines. + +## Scope (In) + +- Catalog aggregates: Allergen, ModifierGroup, ModifierOption, MenuItemModifier, MenuItemAllergen, AvailabilityWindow, MenuMediaRef, MenuLocalization +- Additive MenuItem fields: preparation_minutes, calories, spicy_level, dietary flags, tags, primary_media_ref +- Catalog APIs under `/api/v1` + validators + permissions `hospitality.allergens|modifier_*|availability_windows|menu_media|menu_localizations.*` +- Publish-only catalog events `hospitality.*` +- Alembic `0002_phase_121_digital_menu_catalog` +- Capability flag `digital_menu_catalog: true`; version `0.12.1.0` + +## Out of Scope + +- POS Lite / POS Pro +- Kitchen display / tickets +- QR Menu rendering / QR Ordering +- Reservation / Table Service engines +- Payment posting / Delivery dispatch +- Binary media storage (refs to Storage only) + +## Owned Modules (aggregates) + +Independent aggregates in `app/models/catalog.py` (UUID refs only). Foundation menu shells from 12.0 remain owners of Menu / Category / Item core rows. + +## Published Events + +| Event | Aggregate | +| --- | --- | +| `hospitality.allergen.created` | allergen | +| `hospitality.modifier_group.created` | modifier_group | +| `hospitality.modifier_option.created` | modifier_option | +| `hospitality.menu_item_modifier.linked` | menu_item_modifier | +| `hospitality.menu_item_allergen.linked` | menu_item_allergen | +| `hospitality.availability_window.created` | availability_window | +| `hospitality.menu_media_ref.created` | menu_media_ref | +| `hospitality.menu_localization.upserted` | menu_localization | + +## API Contracts (catalog) + +| Resource | Prefix | +| --- | --- | +| Allergens | `/api/v1/allergens` | +| Modifier groups / options | `/api/v1/modifier-groups`, `/modifier-options` | +| Item ↔ modifier / allergen links | `/api/v1/menu-item-modifiers/link`, `/menu-item-allergens/link` | +| Availability windows | `/api/v1/availability-windows` | +| Menu media refs | `/api/v1/menu-media-refs` | +| Menu localizations | `/api/v1/menu-localizations/upsert` | + +## Permissions + +Catalog trees: `hospitality.allergens.*`, `hospitality.modifier_groups.*`, `hospitality.modifier_options.*`, `hospitality.availability_windows.*`, `hospitality.menu_media.*`, `hospitality.menu_localizations.*`. + +## Quality Gates + +All gates passed (architecture, dependency, migration, tenant isolation, API, permissions, security, performance, documentation). See [phase-handover/phase-12-1.md](phase-handover/phase-12-1.md). + +## Related Documents + +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.1](phase-handover/phase-12-1.md) +- [Phase 12.0](hospitality-phase-12-0.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Module Registry — hospitality](module-registry.md#hospitality) +- [AI Development Framework](ai-framework/README.md) +- [Progress](progress.md) · [Next Steps](next-steps.md) diff --git a/docs/hospitality-phase-12-2.md b/docs/hospitality-phase-12-2.md new file mode 100644 index 0000000..465ebff --- /dev/null +++ b/docs/hospitality-phase-12-2.md @@ -0,0 +1,40 @@ +# Phase 12.2 — QR Menu & QR Ordering + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | hospitality | +| Commercial Product | Torbat Food | +| Version | 0.12.2.0 | +| Database | `hospitality_db` | +| API Port | 8009 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-017 | +| Prior phase | [12.1 Digital Menu Catalog](hospitality-phase-12-1.md) | + +## Goal + +Add QR Menu public surfaces and QR Ordering / cart shells that consume the digital menu catalog — without POS, kitchen, payment, or reservation engines. + +## Scope (In) + +- Aggregates: QrCode, QrMenuSession, QrOrderingSession, Cart, CartLine +- APIs under `/api/v1/qr-codes|qr-menu-sessions|qr-ordering-sessions|carts|cart-lines` +- Permissions + publish-only events +- Alembic `0003_phase_122_qr_menu_ordering` +- Capabilities `qr_menu`, `qr_ordering`; version `0.12.2.0` + +## Out of Scope + +- POS Lite / POS Pro, Kitchen tickets, Payment posting, Reservation engine + +## Quality Gates + +Passed (architecture, dependency, migration, tenant, API, permissions, docs). See handover. + +## Related Documents + +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.2](phase-handover/phase-12-2.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Module Registry](module-registry.md#hospitality) +- [Service Snapshot](service-snapshots/hospitality.yaml) diff --git a/docs/hospitality-phase-12-3.md b/docs/hospitality-phase-12-3.md new file mode 100644 index 0000000..d20a3e1 --- /dev/null +++ b/docs/hospitality-phase-12-3.md @@ -0,0 +1,42 @@ +# Phase 12.3 — Table Service & Reservations + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | hospitality | +| Commercial Product | Torbat Food | +| Version | 0.12.3.0 | +| Database | `hospitality_db` | +| API Port | 8009 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-017 | +| Prior phase | [12.2 QR Menu & QR Ordering](hospitality-phase-12-2.md) | + +## Goal + +Add dine-in Table Service & Reservation workflows — guest reservations, table assignments, in-service +requests (call waiter / water / bill), and walk-in waitlist — without POS, kitchen, or payment engines. + +## Scope (In) + +- Aggregates: Reservation, TableAssignment, ServiceRequest, WaitlistEntry +- APIs under `/api/v1/reservations|table-assignments|service-requests|waitlist` +- Status transition endpoints (`/status`, `/release`) with terminal-state guards +- Permissions + publish-only events +- Alembic `0004_phase_123_table_service_reservations` +- Capabilities `table_service`, `reservation`; version `0.12.3.0` + +## Out of Scope + +- POS Lite / POS Pro, Kitchen tickets, Payment posting + +## Quality Gates + +Passed (architecture, dependency, migration, tenant, API, permissions, docs). See handover. + +## Related Documents + +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.3](phase-handover/phase-12-3.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Module Registry](module-registry.md#hospitality) +- [Service Snapshot](service-snapshots/hospitality.yaml) diff --git a/docs/hospitality-phase-12-4.md b/docs/hospitality-phase-12-4.md new file mode 100644 index 0000000..208b0fb --- /dev/null +++ b/docs/hospitality-phase-12-4.md @@ -0,0 +1,42 @@ +# Phase 12.4 — POS Lite + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | hospitality | +| Commercial Product | Torbat Food | +| Version | 0.12.4.0 | +| Database | `hospitality_db` | +| API Port | 8009 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-017 | +| Prior phase | [12.3 Table Service & Reservations](hospitality-phase-12-3.md) | + +## Goal + +Add a lightweight POS shell — registers, shifts, tickets, and ticket lines — for small venue +formats, without payment posting or accounting entries. + +## Scope (In) + +- Aggregates: PosRegister, PosShift, PosTicket, PosTicketLine +- APIs under `/api/v1/pos-registers|pos-shifts|pos-tickets|pos-ticket-lines` +- Shift open (via create) / close; ticket void +- Permissions + publish-only events +- Alembic `0005_phase_124_pos_lite` +- Capabilities `pos_lite`; version `0.12.4.0` + +## Out of Scope + +- POS Pro (discounts, taxes, payments, floor plans), Kitchen tickets, Payment posting + +## Quality Gates + +Passed (architecture, dependency, migration, tenant, API, permissions, docs). See handover. + +## Related Documents + +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.4](phase-handover/phase-12-4.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Module Registry](module-registry.md#hospitality) +- [Service Snapshot](service-snapshots/hospitality.yaml) diff --git a/docs/hospitality-phase-12-5.md b/docs/hospitality-phase-12-5.md new file mode 100644 index 0000000..b04f171 --- /dev/null +++ b/docs/hospitality-phase-12-5.md @@ -0,0 +1,41 @@ +# Phase 12.5 — POS Pro + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | hospitality | +| Commercial Product | Torbat Food | +| Version | 0.12.5.0 | +| Database | `hospitality_db` | +| API Port | 8009 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-017 | +| Prior phase | [12.4 POS Lite](hospitality-phase-12-4.md) | + +## Goal + +Extend POS Lite with full-featured POS capabilities — payments, discounts, tax lines, floor +plans, and stations — for larger venue formats, without accounting posting. + +## Scope (In) + +- Aggregates: PosPayment, PosDiscount, PosTaxLine, PosFloorPlan, PosStation +- APIs under `/api/v1/pos-payments|pos-discounts|pos-tax-lines|pos-floor-plans|pos-stations` +- Permissions + publish-only events +- Alembic `0006_phase_125_pos_pro` +- Capabilities `pos_pro`; version `0.12.5.0` + +## Out of Scope + +- Kitchen tickets, accounting journal posting, real payment gateway integration + +## Quality Gates + +Passed (architecture, dependency, migration, tenant, API, permissions, docs). See handover. + +## Related Documents + +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.5](phase-handover/phase-12-5.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Module Registry](module-registry.md#hospitality) +- [Service Snapshot](service-snapshots/hospitality.yaml) diff --git a/docs/hospitality-phase-12-6.md b/docs/hospitality-phase-12-6.md new file mode 100644 index 0000000..621f4cb --- /dev/null +++ b/docs/hospitality-phase-12-6.md @@ -0,0 +1,42 @@ +# Phase 12.6 — Kitchen + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | hospitality | +| Commercial Product | Torbat Food | +| Version | 0.12.6.0 | +| Database | `hospitality_db` | +| API Port | 8009 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-017 | +| Prior phase | [12.5 POS Pro](hospitality-phase-12-5.md) | + +## Goal + +Add a kitchen display / ticket routing shell consuming POS tickets and QR ordering sessions by +reference only (`source_type` + `source_id` UUID) — no direct writes into POS/QR aggregates. + +## Scope (In) + +- Aggregates: KitchenStation, KitchenTicket, KitchenTicketItem +- APIs under `/api/v1/kitchen-stations|kitchen-tickets|kitchen-ticket-items` +- Status transition endpoints (`/status`) with terminal-state guards (`bumped`, `cancelled`) +- Permissions + publish-only events +- Alembic `0007_phase_126_kitchen` +- Capabilities `kitchen`, `kitchen_engine`; version `0.12.6.0` + +## Out of Scope + +- Delivery / Accounting / CRM / Loyalty / Communication / Website connectors, Analytics, AI + +## Quality Gates + +Passed (architecture, dependency, migration, tenant, API, permissions, docs). See handover. + +## Related Documents + +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.6](phase-handover/phase-12-6.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Module Registry](module-registry.md#hospitality) +- [Service Snapshot](service-snapshots/hospitality.yaml) diff --git a/docs/hospitality-phase-12-7.md b/docs/hospitality-phase-12-7.md new file mode 100644 index 0000000..7f5221c --- /dev/null +++ b/docs/hospitality-phase-12-7.md @@ -0,0 +1,49 @@ +# Phase 12.7 — Connectors + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | hospitality | +| Commercial Product | Torbat Food | +| Version | 0.12.7.0 | +| Database | `hospitality_db` | +| API Port | 8009 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-017 | +| Prior phase | [12.6 Kitchen](hospitality-phase-12-6.md) | + +## Goal + +Add an outbound connector/dispatch shell for external integration platforms — delivery, +accounting, CRM, loyalty, communication, and website builder — registered and dispatched by +reference only. No real network calls (no httpx) and no cross-service imports; dispatch results +are simulated locally via no-op mock provider implementations of the existing platform Protocols. + +## Scope (In) + +- Aggregates: ConnectorRegistration, ConnectorDispatch +- `app/providers/mocks.py` — no-op async mocks implementing `AccountingProvider`, `CRMProvider`, + `LoyaltyProvider`, `CommunicationProvider`, `DeliveryProvider`, `WebsiteBuilderProvider` +- APIs under `/api/v1/connector-registrations|connector-dispatches` +- Dispatch service sets `status=sent` via mock provider when registration is `active`, else + `status=failed` +- Permissions + publish-only events +- Alembic `0008_phase_127_connectors` +- Capabilities `delivery_integration`, `accounting_integration`, `crm_integration`, + `loyalty_integration`, `communication_integration`, `website_integration`; version `0.12.7.0` + +## Out of Scope + +- Real connector SDKs / httpx calls to external platforms +- Analytics, AI Assistant + +## Quality Gates + +Passed (architecture, dependency, migration, tenant, API, permissions, docs). See handover. + +## Related Documents + +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.7](phase-handover/phase-12-7.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Module Registry](module-registry.md#hospitality) +- [Service Snapshot](service-snapshots/hospitality.yaml) diff --git a/docs/hospitality-phase-12-8.md b/docs/hospitality-phase-12-8.md new file mode 100644 index 0000000..c282064 --- /dev/null +++ b/docs/hospitality-phase-12-8.md @@ -0,0 +1,45 @@ +# Phase 12.8 — Analytics + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | hospitality | +| Commercial Product | Torbat Food | +| Version | 0.12.8.0 | +| Database | `hospitality_db` | +| API Port | 8009 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-017 | +| Prior phase | [12.7 Connectors](hospitality-phase-12-7.md) | + +## Goal + +Add a local analytics reporting shell that counts rows in this service's own tables only +(venues, menus, POS tickets, reservations, kitchen tickets, connector activity) — no external +analytics engine and no cross-service reads. + +## Scope (In) + +- Aggregates: AnalyticsReportDefinition, AnalyticsSnapshot +- APIs under `/api/v1/analytics-reports`, `/api/v1/analytics-snapshots` (+ `/refresh`) +- `POST /analytics-snapshots/refresh` computes a `metrics` JSON payload via simple local + `COUNT(*)` queries for each `metric_key` on the report definition +- Permissions + publish-only events +- Alembic `0009_phase_128_analytics` +- Capability `analytics`; version `0.12.8.0` + +## Out of Scope + +- AI Assistant (12.9) +- Cross-service / warehouse analytics, BI dashboards, scheduled jobs + +## Quality Gates + +Passed (architecture, dependency, migration, tenant, API, permissions, docs). See handover. + +## Related Documents + +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.8](phase-handover/phase-12-8.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Module Registry](module-registry.md#hospitality) +- [Service Snapshot](service-snapshots/hospitality.yaml) diff --git a/docs/hospitality-roadmap.md b/docs/hospitality-roadmap.md new file mode 100644 index 0000000..1335973 --- /dev/null +++ b/docs/hospitality-roadmap.md @@ -0,0 +1,58 @@ +# Hospitality Platform Roadmap (Torbat Food) + +| Field | Value | +| --- | --- | +| Service | `hospitality` | +| Database | `hospitality_db` | +| Commercial Product | Torbat Food | +| ADR | [ADR-017](architecture/adr/ADR-017.md) | +| Port | 8009 | + +## Principles + +- Independent platform for Cafe → Catering venue formats +- Feature-based architecture + bundle licensing +- Integrations only via API / Events +- Never skip phases; one phase per agent run + +## Phases + +| Phase | Name | Status | +| --- | --- | --- | +| 12.0 | Foundation | Complete | +| 12.1 | Digital Menu Catalog | Complete | +| 12.2 | QR Menu & QR Ordering | Complete | +| 12.3 | Table Service & Reservations | Complete | +| 12.4 | POS Lite | Complete | +| 12.5 | POS Pro | Complete | +| 12.6 | Kitchen | Complete | +| 12.7 | Delivery / Accounting / CRM / Loyalty / Communication / Website Connectors | Complete | +| 12.8 | Analytics | Complete | +| 12.9 | AI Assistant | Planned | +| 12.10 | Enterprise Validation | Planned | + +## Bundle Catalog (licensing) + +Digital Menu · QR Menu · QR Ordering · POS Lite · POS Pro · Kitchen · Reservation · Table Service · Delivery Connector · Accounting Connector · CRM Connector · Loyalty Connector · Communication Connector · Website Connector · Analytics · AI Assistant + +## Related Documents + +- [Phase 12.0](hospitality-phase-12-0.md) +- [Phase 12.1](hospitality-phase-12-1.md) +- [Phase 12.2](hospitality-phase-12-2.md) +- [Phase 12.3](hospitality-phase-12-3.md) +- [Phase 12.4](hospitality-phase-12-4.md) +- [Phase 12.5](hospitality-phase-12-5.md) +- [Phase 12.6](hospitality-phase-12-6.md) +- [Phase 12.7](hospitality-phase-12-7.md) +- [Phase 12.8](hospitality-phase-12-8.md) +- [Phase Handover 12.8](phase-handover/phase-12-8.md) +- [Phase Handover 12.7](phase-handover/phase-12-7.md) +- [Phase Handover 12.6](phase-handover/phase-12-6.md) +- [Phase Handover 12.5](phase-handover/phase-12-5.md) +- [Phase Handover 12.4](phase-handover/phase-12-4.md) +- [Phase Handover 12.3](phase-handover/phase-12-3.md) +- [Phase Handover 12.2](phase-handover/phase-12-2.md) +- [Phase Handover 12.0](phase-handover/phase-12-0.md) +- [Phases / Restaurant](phases/Restaurant/README.md) (historical area alias) +- [Phases / Hospitality](phases/Hospitality/README.md) diff --git a/docs/phase-handover/phase-10-0.md b/docs/phase-handover/phase-10-0.md new file mode 100644 index 0000000..fa9ffaa --- /dev/null +++ b/docs/phase-handover/phase-10-0.md @@ -0,0 +1,110 @@ +# Phase Handover — 10.0 Delivery Platform Foundation + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `delivery-10.0` | +| Title | Delivery Platform Foundation | +| Status | Complete | +| Service(s) | `delivery` | +| Version | 0.10.0.0 | +| Date | 2026-07-25 | +| ADR(s) | [ADR-015](../architecture/adr/ADR-015.md) | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| TenantBaseRepository | `app/repositories/base.py` | All later Delivery repos | +| InMemoryEventPublisher | `app/events/publisher.py` | Replace with outbox later | +| Provider Protocols | `app/providers/contracts.py` | RoutingEngine / Fleet adapters | +| Permission enforcement | `app/api/permissions.py` | Loyalty-style require_permissions | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| GET | `/health` | Public | Liveness | +| GET | `/capabilities` | Public | Feature discovery | +| GET | `/metrics` | Public | Metrics discovery shell | +| * | `/api/v1/organizations` | JWT + `delivery.organizations.*` | CRUD + soft delete | +| * | `/api/v1/hubs` | JWT + `delivery.hubs.*` | CRUD + soft delete | +| * | `/api/v1/external-providers` | JWT + `delivery.external_providers.*` | Registration shells | +| * | `/api/v1/routing-engines` | JWT + `delivery.routing_engines.*` | Future engines ready | +| * | `/api/v1/configurations` | JWT + `delivery.configurations.*` | Policy shells | +| PUT/GET | `/api/v1/settings` | JWT + `delivery.settings.*` | Upsert/list | +| GET | `/api/v1/audit` | JWT + `delivery.audit.view` | Entity timeline | + +## Events + +| Event type | Domain / Integration | Payload summary | Version | +| --- | --- | --- | --- | +| `delivery.organization.created` | Delivery | code, name | 0.10.0.0 | +| `delivery.organization.updated` | Delivery | code | 0.10.0.0 | +| `delivery.hub.created` / `updated` | Delivery | code, organization_id | 0.10.0.0 | +| `delivery.external_provider.registered` / `updated` | Delivery | code, adapter_key | 0.10.0.0 | +| `delivery.routing_engine.registered` / `updated` | Delivery | code, adapter_key | 0.10.0.0 | +| `delivery.configuration.created` / `updated` | Delivery | code | 0.10.0.0 | +| `delivery.setting.upserted` | Delivery | key | 0.10.0.0 | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| RoutingEngineProvider | Adapter implementing Protocol | Verticals calling routers directly | +| FleetProvider | Adapter implementing Protocol | Credentials outside Delivery | +| ExternalProviderConfig | Register adapter_key + credentials_ref | Embedding vendor SDKs in services layer | +| Capability flags | Expand `/capabilities` per phase | Claiming engines before implemented | + +## Known Limitations + +- No driver / fleet / dispatch / routing / tracking engines +- Event publisher is in-memory (no durable outbox drain yet) +- Metrics are discovery shells, not live counters +- Driver App / Dispatcher Panel UI deferred to frontend +- Compose health depends on postgres being available + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0001_initial` | +| Upgrade steps | `python scripts/ensure_db.py && alembic upgrade head` | +| Downgrade support | `metadata.drop_all` | +| Data backfill | None | +| Breaking changes | None (new service) | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| ADR-015 / DP-Reg handover | Docs | Boundaries | +| Core Platform | Service | Entitlement (runtime) | +| shared-lib | Library | JWT, events, exceptions | +| Postgres | Infra | `delivery_db` | + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `delivery-10.1` Driver Management | +| Blockers for next phase | None | +| Entry checklist | Read this handover + [delivery-phase-10-0.md](../delivery-phase-10-0.md) + ADR-015; implement drivers only — no dispatch yet | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] No unfinished claimed deliverables +- [x] Self audit completed + +## Related Documents + +- [Delivery Phase 10.0](../delivery-phase-10-0.md) +- [Delivery Roadmap](../delivery-roadmap.md) +- [ADR-015](../architecture/adr/ADR-015.md) +- [Phase Manifest](../ai-framework/phase-manifest.yaml) +- [Service Manifest](../ai-framework/service-manifest.yaml) diff --git a/docs/phase-handover/phase-10-1.md b/docs/phase-handover/phase-10-1.md new file mode 100644 index 0000000..e7725dd --- /dev/null +++ b/docs/phase-handover/phase-10-1.md @@ -0,0 +1,164 @@ +# Phase Handover — 10.1 Driver Management + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `delivery-10.1` | +| Title | Driver Management | +| Status | Complete | +| Service(s) | `delivery` | +| Version | 0.10.1.0 | +| Date | 2026-07-25 | +| ADR(s) | [ADR-015](../architecture/adr/ADR-015.md), ADR-001, ADR-003, ADR-006 | + +## Enterprise Phase Discovery Summary + +| Item | Detail | +| --- | --- | +| Discovery Record location | [delivery-phase-10-1.md](../delivery-phase-10-1.md#enterprise-phase-discovery-summary) | +| Gaps promoted & closed | Driver aggregate, lifecycle, credentials/documents, outbox, list UX, permission leaves | +| Exclusions (future / other service) | Fleet 10.2; availability 10.3; dispatch 10.5; Identity/CRM/Storage ownership | +| CRUD-only rejected | Yes | + +## Business Analysis Summary + +| Item | Detail | +| --- | --- | +| Capabilities delivered | Driver profiles; status lifecycle; credential & document refs; audit; outbox events; permission catalog | +| Explicit non-goals honored | No fleet, dispatch, routing execution, tracking, settlement | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| DriverLifecyclePolicy | `app/policies/drivers.py` | Later availability/dispatch readiness checks | +| DriverListSpec | `app/specifications/drivers.py` | Pattern for fleet/vehicle lists | +| TransactionalEventPublisher | `app/events/publisher.py` | Prefer for all new Delivery mutations | +| DriverCommands / DriverQueries | `app/commands|queries/drivers.py` | CQ thin wrappers | +| TenantBaseRepository | `app/repositories/base.py` | Continued | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| GET | `/health` | Public | Liveness | +| GET | `/capabilities` | Public | `phase: 10.1`, `drivers: true` | +| GET | `/metrics` | Public | Driver metric keys | +| * | `/api/v1/drivers` | JWT + `delivery.drivers.*` | CRUD + lifecycle + credentials/documents | +| GET | `/api/v1/permissions/catalog` | JWT + `delivery.view` | Static permission discovery | +| * | Foundation routes from 10.0 | Unchanged | Backward compatible | + +## Events + +| Event type | Domain / Integration | Payload summary | Version | +| --- | --- | --- | --- | +| `delivery.driver.created` | Delivery | code, status, organization_id | 0.10.1.0 | +| `delivery.driver.updated` | Delivery | code | 0.10.1.0 | +| `delivery.driver.status_changed` | Delivery | action, from/to status | 0.10.1.0 | +| `delivery.driver.activated` / `suspended` / `resumed` / `deactivated` / `blocked` / `unblocked` / `archived` | Delivery | code, status | 0.10.1.0 | +| `delivery.driver.deleted` | Delivery | code | 0.10.1.0 | +| `delivery.driver.credential_added` | Delivery | driver_id, kind | 0.10.1.0 | +| `delivery.driver.document_attached` | Delivery | driver_id, kind | 0.10.1.0 | + +## Permissions + +| Permission | Routes / actions | +| --- | --- | +| `delivery.drivers.view` | GET list/detail | +| `delivery.drivers.create` | POST create | +| `delivery.drivers.update` | PATCH profile | +| `delivery.drivers.delete` | POST soft delete | +| `delivery.drivers.activate\|suspend\|resume\|deactivate\|block\|unblock\|archive` | Lifecycle POSTs | +| `delivery.drivers.lifecycle.view` | GET lifecycle history | +| `delivery.drivers.credentials.*` | Credential POST/GET | +| `delivery.drivers.documents.*` | Document POST/GET | +| `delivery.drivers.manage` | Wildcard manage leaf | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Driver.capability_tags | Later pricing/bundles (10.4) | Encoding vertical order rules | +| external_user_ref / CRM ref | Identity/CRM link only | Owning users/contacts in Delivery | +| Storage file refs | Attachments via Storage | Storing blobs in delivery_db | +| Outbox drain | Worker/publisher later | Sync cross-DB writes | + +## Known Limitations + +- No availability/shift/zone readiness (10.3) +- No vehicle assignment (10.2) +- Outbox drain worker not yet implemented (rows persisted; mirror in tests) +- Metrics remain discovery-oriented (no live Prometheus scrape counters) +- Driver App UI remains frontend (API only) + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0002_phase_101_drivers` | +| Upgrade steps | `alembic upgrade head` | +| Downgrade support | Drops additive driver/outbox tables | +| Data backfill / seeds | None | +| Breaking changes | None | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| Phase 10.0 foundation | Service | Org/hub validation | +| shared-lib | Library | JWT, events, exceptions | +| ADR-015 / ADR-006 | Docs | Boundaries / outbox | +| Postgres `delivery_db` | Infra | Persistence | + +## Boundary Compliance + +- [x] No foreign service ownership implemented +- [x] No business logic duplication +- [x] No cross-DB access +- [x] Integrations only via API / Events / Providers +- [x] No future-phase functionality pulled forward + +## Enterprise Completeness Sign-Off + +- [x] Business completeness +- [x] Architectural completeness +- [x] API completeness +- [x] Permission completeness +- [x] Capability completeness +- [x] Event completeness +- [x] Migration completeness +- [x] Repository completeness +- [x] Validation completeness +- [x] Security completeness +- [x] Performance completeness +- [x] Documentation completeness +- [x] Test completeness +- [x] Operational readiness +- [x] Developer experience +- [x] Self audit + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `delivery-10.2` Fleet & Vehicle Types | +| Blockers for next phase | None | +| Entry checklist | Read this handover + [delivery-phase-10-1.md](../delivery-phase-10-1.md) + ADR-015; implement fleets/vehicle types/vehicles only — no dispatch yet | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] No unfinished claimed deliverables +- [x] Self audit completed + +## Related Documents + +- [Delivery Phase 10.1](../delivery-phase-10-1.md) +- [Delivery Roadmap](../delivery-roadmap.md) +- [ADR-015](../architecture/adr/ADR-015.md) +- [Phase Manifest](../ai-framework/phase-manifest.yaml) +- [Service Manifest](../ai-framework/service-manifest.yaml) diff --git a/docs/phase-handover/phase-12-0.md b/docs/phase-handover/phase-12-0.md new file mode 100644 index 0000000..696e42d --- /dev/null +++ b/docs/phase-handover/phase-12-0.md @@ -0,0 +1,68 @@ +# Phase Handover — 12.0 Hospitality Platform Foundation + +## Summary + +Phase 12.0 delivered the independent **Hospitality Platform** microservice (`hospitality-service`, `hospitality_db`, port **8009**, version **0.12.0.0**, commercial product **Torbat Food**). Foundation aggregates, bundle/feature-toggle licensing shells, permissions, publish-only events, APIs, migration, and docs are in place. **No POS / kitchen / ordering / reservation engines** were implemented. + +Evolves planned phase id `restaurant-foundation` into `hospitality-12.0`. + +## What Changed + +| Area | Change | +| --- | --- | +| Service | New `backend/services/hospitality` | +| Database | `hospitality_db` + Alembic `0001_initial` | +| ADR | ADR-017 Accepted | +| Runtime | docker-compose service on 8009; `.env.example` keys | +| Docs | Phase doc, roadmap, handover, registries, manifests | + +## Public Surface + +- `/health`, `/capabilities` +- `/api/v1/venues|branches|dining-areas|tables|menus|menu-categories|menu-items` +- `/api/v1/bundle-definitions|tenant-bundles|feature-toggles` +- `/api/v1/roles|permissions|configurations|events|settings` +- Events: `hospitality.*` (publish-only) +- Permissions: `hospitality.*` + +## Migration Notes + +New database `hospitality_db` (sole owner). No changes to Core/CRM/Loyalty/Communication/Delivery/Sports Center schemas. + +## Quality Gates + +| Gate | Result | +| --- | --- | +| Architecture | Pass | +| Dependency | Pass | +| Backward Compatibility | Pass (new service) | +| Health / Capability | Pass | +| Feature Toggle / Bundle | Pass | +| Permission | Pass | +| API | Pass | +| Repository / Service | Pass | +| Migration | Pass | +| Tenant Isolation | Pass | +| Security | Pass | +| Performance | Pass | +| Documentation | Pass | +| Integration (contracts only) | Pass | +| Self Audit | Pass | + +## Known Follow-ups (not in this phase) + +- Digital Menu / QR Menu depth (catalog engines) +- POS Lite / POS Pro +- Kitchen, Reservation, Table Service engines +- Connector implementations for Accounting / CRM / Loyalty / Communication / Delivery / Website + +## Next Phase + +Phase 12.1 — Digital Menu Catalog (per [hospitality-roadmap.md](../hospitality-roadmap.md)). + +## Related Documents + +- [hospitality-phase-12-0.md](../hospitality-phase-12-0.md) +- [hospitality-roadmap.md](../hospitality-roadmap.md) +- [ADR-017](../architecture/adr/ADR-017.md) +- [module-registry.md](../module-registry.md#hospitality) diff --git a/docs/phase-handover/phase-12-1.md b/docs/phase-handover/phase-12-1.md new file mode 100644 index 0000000..b2b8dbf --- /dev/null +++ b/docs/phase-handover/phase-12-1.md @@ -0,0 +1,114 @@ +# Phase Handover — 12.1 Digital Menu Catalog + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | hospitality-12.1 | +| Title | Digital Menu Catalog | +| Status | Complete | +| Service(s) | hospitality | +| Version | 0.12.1.0 | +| Date | 2026-07-25 | +| ADR(s) | ADR-017 (no new ADR — catalog depth within Hospitality boundary) | + +## Summary + +Phase 12.1 deepened the **Torbat Food** digital menu catalog on `hospitality-service` / `hospitality_db` (port **8009**, version **0.12.1.0**). Additive migration `0002_phase_121_digital_menu_catalog` added catalog tables and MenuItem enrichment fields. **No POS / kitchen / QR ordering / reservation engines.** + +## What Changed + +| Area | Change | +| --- | --- | +| Models | `app/models/catalog.py` + MenuItem additive columns | +| Migration | Alembic `0002_phase_121_digital_menu_catalog` | +| APIs | Allergens, modifiers, availability, media refs, localizations | +| Events / Permissions | Catalog `hospitality.*` types and trees | +| Capabilities | `phase: 12.1`, `digital_menu_catalog: true` | +| Tests | `test_catalog.py` + updated architecture/migration/docs/API | + +## Public APIs + +| Method | Path | Notes | +| --- | --- | --- | +| POST/GET | `/api/v1/allergens` | Venue-scoped allergen catalog | +| POST/GET | `/api/v1/modifier-groups` | Selection rules only | +| POST/GET | `/api/v1/modifier-options` | Price deltas | +| POST | `/api/v1/menu-item-modifiers/link` | Link item → group | +| POST | `/api/v1/menu-item-allergens/link` | Link item → allergen | +| POST/GET | `/api/v1/availability-windows` | Time windows by scope | +| POST/GET | `/api/v1/menu-media-refs` | Storage file_ref only | +| POST | `/api/v1/menu-localizations/upsert` | Multi-language shells | + +Plus existing 12.0 foundation surfaces unchanged (additive MenuItem fields). + +## Events + +| Event type | Aggregate | +| --- | --- | +| `hospitality.allergen.created` | allergen | +| `hospitality.modifier_group.created` | modifier_group | +| `hospitality.modifier_option.created` | modifier_option | +| `hospitality.menu_item_modifier.linked` | menu_item_modifier | +| `hospitality.menu_item_allergen.linked` | menu_item_allergen | +| `hospitality.availability_window.created` | availability_window | +| `hospitality.menu_media_ref.created` | menu_media_ref | +| `hospitality.menu_localization.upserted` | menu_localization | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Media `file_ref` | Resolve via Storage API/Events | Do not store blobs in hospitality_db | +| Availability windows | Consume in QR Menu / Ordering later | Do not embed ordering engine here | +| Modifier selection rules | Drive cart/POS later | Do not implement cart/checkout in 12.1 | + +## Known Limitations + +- No QR Menu public rendering or ordering cart +- No kitchen ticket mapping from modifiers +- Media is reference-only (no binary upload API in Hospitality) +- Bundle gating of catalog APIs not enforced at middleware yet (permission keys registered) + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0002_phase_121_digital_menu_catalog` (down_revision `0001_initial`) | +| Upgrade steps | Run alembic upgrade head on `hospitality_db` | +| Downgrade support | Drops catalog tables; removes additive MenuItem columns | +| Data backfill | N/A (new nullable / defaulted columns) | +| Breaking changes | None — additive only | + +## Quality Gates + +| Gate | Result | +| --- | --- | +| Architecture | Pass | +| Dependency | Pass | +| Backward Compatibility | Pass (additive) | +| Health / Capability | Pass | +| Permission | Pass | +| API | Pass | +| Repository / Service | Pass | +| Migration | Pass | +| Tenant Isolation | Pass | +| Security | Pass | +| Performance | Pass | +| Documentation | Pass | +| Integration (contracts only) | Pass | +| Self Audit | Pass | + +## Next Phase Entry + +1. This handover + [hospitality-phase-12-1.md](../hospitality-phase-12-1.md) +2. Updated module registry / manifests (version `0.12.1.0`) +3. Phase **12.2 — QR Menu & QR Ordering** (do not skip; no POS yet) + +## Related Documents + +- [hospitality-phase-12-1.md](../hospitality-phase-12-1.md) +- [hospitality-roadmap.md](../hospitality-roadmap.md) +- [phase-handover/phase-12-0.md](phase-12-0.md) +- [ADR-017](../architecture/adr/ADR-017.md) +- [module-registry.md](../module-registry.md#hospitality) diff --git a/docs/phase-handover/phase-12-2.md b/docs/phase-handover/phase-12-2.md new file mode 100644 index 0000000..d3f50a0 --- /dev/null +++ b/docs/phase-handover/phase-12-2.md @@ -0,0 +1,48 @@ +# Phase Handover — 12.2 QR Menu & QR Ordering + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | hospitality-12.2 | +| Title | QR Menu & QR Ordering | +| Status | Complete | +| Service(s) | hospitality | +| Version | 0.12.2.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-017 (no new ADR) | + +## Summary + +QR codes, guest menu sessions, ordering sessions, and cart/line shells on `hospitality_db`. Submit is status-only — **no POS/kitchen/payment**. + +## Public APIs + +| Method | Path | +| --- | --- | +| CRUD/list | `/api/v1/qr-codes`, `/by-token/{token}` | +| CRUD/list | `/api/v1/qr-menu-sessions`, `/by-token/{token}` | +| CRUD + submit | `/api/v1/qr-ordering-sessions`, `/{id}/submit` | +| CRUD | `/api/v1/carts`, `/api/v1/cart-lines` | + +## Events + +`hospitality.qr_code.created`, `qr_menu_session.created`, `qr_ordering_session.created|submitted`, `cart.created`, `cart_line.added` + +## Migration + +`0003_phase_122_qr_menu_ordering` (additive) + +## Known Limitations + +- No kitchen routing from submitted sessions +- No payment / Accounting journals +- Public unauthenticated guest auth via token only (tenant still required) + +## Next Phase Entry + +Phase **12.3 — Table Service & Reservations** + +## Service Snapshot + +Regenerated: `docs/service-snapshots/hospitality.yaml` diff --git a/docs/phase-handover/phase-12-3.md b/docs/phase-handover/phase-12-3.md new file mode 100644 index 0000000..070092e --- /dev/null +++ b/docs/phase-handover/phase-12-3.md @@ -0,0 +1,49 @@ +# Phase Handover — 12.3 Table Service & Reservations + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | hospitality-12.3 | +| Title | Table Service & Reservations | +| Status | Complete | +| Service(s) | hospitality | +| Version | 0.12.3.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-017 (no new ADR) | + +## Summary + +Reservations, table assignments, in-service requests, and waitlist entries on `hospitality_db`. +All status transitions are shell-only — **no POS/kitchen/payment**. + +## Public APIs + +| Method | Path | +| --- | --- | +| CRUD/list + status | `/api/v1/reservations`, `/{id}/status` | +| CRUD/list + release | `/api/v1/table-assignments`, `/{id}/release` | +| CRUD/list + status | `/api/v1/service-requests`, `/{id}/status` | +| CRUD/list + status | `/api/v1/waitlist`, `/{id}/status` | + +## Events + +`hospitality.reservation.created|status_changed`, `table_assignment.created`, `service_request.created`, `waitlist_entry.created` + +## Migration + +`0004_phase_123_table_service_reservations` (additive) + +## Known Limitations + +- No POS / kitchen / payment engines +- No automatic table-to-reservation matching engine +- Public unauthenticated guest access not applicable (staff-facing only) + +## Next Phase Entry + +Phase **12.4 — POS Lite** + +## Service Snapshot + +Regenerated: `docs/service-snapshots/hospitality.yaml` diff --git a/docs/phase-handover/phase-12-4.md b/docs/phase-handover/phase-12-4.md new file mode 100644 index 0000000..5e39cbd --- /dev/null +++ b/docs/phase-handover/phase-12-4.md @@ -0,0 +1,50 @@ +# Phase Handover — 12.4 POS Lite + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | hospitality-12.4 | +| Title | POS Lite | +| Status | Complete | +| Service(s) | hospitality | +| Version | 0.12.4.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-017 (no new ADR) | + +## Summary + +Lightweight POS shell — registers, shifts, tickets, and ticket lines — on `hospitality_db`. +No payment posting, no accounting entries, no kitchen dispatch. + +## Public APIs + +| Method | Path | +| --- | --- | +| CRUD/list | `/api/v1/pos-registers` | +| Create (open) / list + close | `/api/v1/pos-shifts`, `/{id}/close` | +| CRUD/list + void | `/api/v1/pos-tickets`, `/{id}/void` | +| Create/list | `/api/v1/pos-ticket-lines` | + +## Events + +`hospitality.pos_register.created`, `pos_shift.opened|closed`, `pos_ticket.created|voided`, +`pos_ticket_line.added` + +## Migration + +`0005_phase_124_pos_lite` (additive) + +## Known Limitations + +- No payment posting / accounting entries +- No kitchen dispatch +- One open shift per register enforced at service layer only + +## Next Phase Entry + +Phase **12.5 — POS Pro** + +## Service Snapshot + +Regenerated: `docs/service-snapshots/hospitality.yaml` diff --git a/docs/phase-handover/phase-12-5.md b/docs/phase-handover/phase-12-5.md new file mode 100644 index 0000000..aea4047 --- /dev/null +++ b/docs/phase-handover/phase-12-5.md @@ -0,0 +1,51 @@ +# Phase Handover — 12.5 POS Pro + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | hospitality-12.5 | +| Title | POS Pro | +| Status | Complete | +| Service(s) | hospitality | +| Version | 0.12.5.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-017 (no new ADR) | + +## Summary + +Payments, discounts, tax lines, floor plans, and stations on `hospitality_db`. Payment records +are local only — no accounting journal posting, no real payment gateway integration. + +## Public APIs + +| Method | Path | +| --- | --- | +| CRUD/list | `/api/v1/pos-payments` | +| CRUD/list | `/api/v1/pos-discounts` | +| CRUD/list | `/api/v1/pos-tax-lines` | +| CRUD/list | `/api/v1/pos-floor-plans` | +| CRUD/list | `/api/v1/pos-stations` | + +## Events + +`hospitality.pos_payment.recorded`, `pos_discount.applied`, `pos_tax_line.added`, +`pos_floor_plan.created`, `pos_station.created` + +## Migration + +`0006_phase_125_pos_pro` (additive) + +## Known Limitations + +- No accounting journal posting +- No real payment gateway integration (local records only) +- No kitchen dispatch + +## Next Phase Entry + +Phase **12.6 — Kitchen** + +## Service Snapshot + +Regenerated: `docs/service-snapshots/hospitality.yaml` diff --git a/docs/phase-handover/phase-12-6.md b/docs/phase-handover/phase-12-6.md new file mode 100644 index 0000000..089358a --- /dev/null +++ b/docs/phase-handover/phase-12-6.md @@ -0,0 +1,49 @@ +# Phase Handover — 12.6 Kitchen + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | hospitality-12.6 | +| Title | Kitchen | +| Status | Complete | +| Service(s) | hospitality | +| Version | 0.12.6.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-017 (no new ADR) | + +## Summary + +Kitchen display / ticket routing shell on `hospitality_db`. Tickets reference their POS ticket or +QR ordering session source by UUID only — no cross-aggregate relationships. + +## Public APIs + +| Method | Path | +| --- | --- | +| CRUD/list | `/api/v1/kitchen-stations` | +| CRUD/list + status | `/api/v1/kitchen-tickets`, `/{id}/status` | +| CRUD/list + status | `/api/v1/kitchen-ticket-items`, `/{id}/status` | + +## Events + +`hospitality.kitchen_station.created`, `kitchen_ticket.created|status_changed`, +`kitchen_ticket_item.added` + +## Migration + +`0007_phase_126_kitchen` (additive) + +## Known Limitations + +- No real KDS vendor integration +- No automatic routing from POS/QR ordering — kitchen tickets created explicitly by reference +- No connector integrations yet + +## Next Phase Entry + +Phase **12.7 — Delivery / Accounting / CRM / Loyalty / Communication / Website Connectors** + +## Service Snapshot + +Regenerated: `docs/service-snapshots/hospitality.yaml` diff --git a/docs/phase-handover/phase-12-7.md b/docs/phase-handover/phase-12-7.md new file mode 100644 index 0000000..a9c1207 --- /dev/null +++ b/docs/phase-handover/phase-12-7.md @@ -0,0 +1,49 @@ +# Phase Handover — 12.7 Connectors + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | hospitality-12.7 | +| Title | Connectors | +| Status | Complete | +| Service(s) | hospitality | +| Version | 0.12.7.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-017 (no new ADR) | + +## Summary + +Outbound connector registration/dispatch shell on `hospitality_db` for delivery, accounting, CRM, +loyalty, communication, and website builder integrations. Dispatches are simulated locally via +no-op mock implementations of the existing platform provider Protocols — no httpx, no +cross-service imports. + +## Public APIs + +| Method | Path | +| --- | --- | +| CRUD/list | `/api/v1/connector-registrations` | +| CRUD/list | `/api/v1/connector-dispatches` | + +## Events + +`hospitality.connector_registration.created`, `connector_dispatch.created|status_changed` + +## Migration + +`0008_phase_127_connectors` (additive) + +## Known Limitations + +- No real connector SDKs; dispatch responses are mock-generated +- No retry/backoff or webhook-based ack handling +- No fiscal device or KDS vendor integration (see Kitchen phase) + +## Next Phase Entry + +Phase **12.8 — Analytics** + +## Service Snapshot + +Regenerated: `docs/service-snapshots/hospitality.yaml` diff --git a/docs/phase-handover/phase-12-8.md b/docs/phase-handover/phase-12-8.md new file mode 100644 index 0000000..5b55c49 --- /dev/null +++ b/docs/phase-handover/phase-12-8.md @@ -0,0 +1,48 @@ +# Phase Handover — 12.8 Analytics + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | hospitality-12.8 | +| Title | Analytics | +| Status | Complete | +| Service(s) | hospitality | +| Version | 0.12.8.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-017 (no new ADR) | + +## Summary + +Local analytics reporting shell on `hospitality_db`. Report definitions declare which local +`metric_keys` to track; snapshots refresh by counting rows in this service's own tables only +(no external analytics engine, no cross-service reads). + +## Public APIs + +| Method | Path | +| --- | --- | +| CRUD/list | `/api/v1/analytics-reports` | +| List/get | `/api/v1/analytics-snapshots` | +| Refresh | `POST /api/v1/analytics-snapshots/refresh` | + +## Events + +`hospitality.analytics_report_definition.created`, `analytics_snapshot.refreshed` + +## Migration + +`0009_phase_128_analytics` (additive) + +## Known Limitations + +- Metrics limited to simple local `COUNT(*)` per registered metric key +- No scheduled refresh jobs, dashboards, or cross-service warehouse joins + +## Next Phase Entry + +Phase **12.9 — AI Assistant** + +## Service Snapshot + +Regenerated: `docs/service-snapshots/hospitality.yaml` diff --git a/docs/phase-handover/phase-13-0.md b/docs/phase-handover/phase-13-0.md new file mode 100644 index 0000000..3f5e63d --- /dev/null +++ b/docs/phase-handover/phase-13-0.md @@ -0,0 +1,19 @@ +# Phase 13.0 Handover — Healthcare Foundation + +| Field | Value | +| --- | --- | +| Phase | healthcare-13-0 | +| Version | 0.13.0.0 | +| Migration | 0001_initial | +| Tests | 31 passing | + +## Delivered + +- Clinic, Branch, Department, Doctor, Patient shells +- External provider configs, configurations, settings, audit +- Platform provider contracts (Accounting, CRM, Loyalty, Communication, Delivery, Storage, AI, Identity) +- Tenant isolation, permissions tree, capabilities endpoint + +## Next + +- Phase 13.1: Appointment Engine diff --git a/docs/phase-handover/phase-13-1.md b/docs/phase-handover/phase-13-1.md new file mode 100644 index 0000000..15e356f --- /dev/null +++ b/docs/phase-handover/phase-13-1.md @@ -0,0 +1,20 @@ +# Phase 13.1 Handover — Appointment Engine + +| Field | Value | +| --- | --- | +| Phase | healthcare-13-1 | +| Version | 0.13.1.0 | +| Migration | 0002_phase_131_appointments | +| Tests | test_phase_131.py | + +## Delivered + +- Schedule, AvailabilityWindow, AppointmentType, Appointment, WaitingListEntry +- Lifecycle: draft → confirmed → checked_in → completed / cancelled / no_show +- Conflict detection on overlapping doctor slots +- Communication reminder mock on confirm (`MockCommunicationProvider`) +- Events: `healthcare.appointment.*`, `healthcare.schedule.created` + +## Next + +- Phase 13.2: Doctor Panel diff --git a/docs/phase-handover/phase-13-2.md b/docs/phase-handover/phase-13-2.md new file mode 100644 index 0000000..0587938 --- /dev/null +++ b/docs/phase-handover/phase-13-2.md @@ -0,0 +1,19 @@ +# Phase 13.2 Handover — Doctor Panel + +| Field | Value | +| --- | --- | +| Phase | healthcare-13-2 | +| Version | 0.13.2.0 | +| Migration | 0003_phase_132_doctor_panel | +| Tests | test_phase_132.py | + +## Delivered + +- VisitSession, VisitNote aggregates +- Doctor dashboard/queue APIs under `/doctor-panel/*` +- Visit lifecycle: start → finish → appointment completed +- Events: `healthcare.visit_session.*`, `healthcare.visit_note.*` + +## Next + +- Phase 13.3: Clinic Management diff --git a/docs/phase-handover/phase-13-3.md b/docs/phase-handover/phase-13-3.md new file mode 100644 index 0000000..aaac1fa --- /dev/null +++ b/docs/phase-handover/phase-13-3.md @@ -0,0 +1,18 @@ +# Phase 13.3 Handover — Clinic Management + +| Field | Value | +| --- | --- | +| Phase | healthcare-13-3 | +| Version | 0.13.3.0 | +| Migration | 0004_phase_133_clinic_management | +| Tests | test_phase_133.py | + +## Delivered + +- ClinicService catalog, StaffAssignment, ClinicPolicy, OperatingHoursPolicy +- Policy validator: cancellation_window ≤ booking_lead_time +- Events: `healthcare.clinic_service.*`, `healthcare.staff_assignment.*` + +## Next + +- Phase 13.4: Patient Portal diff --git a/docs/phase-handover/phase-13-4.md b/docs/phase-handover/phase-13-4.md new file mode 100644 index 0000000..46c8c8d --- /dev/null +++ b/docs/phase-handover/phase-13-4.md @@ -0,0 +1,19 @@ +# Phase 13.4 Handover — Patient Portal + +| Field | Value | +| --- | --- | +| Phase | healthcare-13-4 | +| Version | 0.13.4.0 | +| Migration | 0005_phase_134_patient_portal | +| Tests | test_phase_134.py | + +## Delivered + +- PatientPortalProfile, PatientDocumentRef, PatientNotificationPreference +- APIs under `/api/v1/patient-portal/*` with `X-Patient-ID` header scoping +- Appointment self-service via appointment engine (no duplicate logic) +- Patient isolation tests + +## Next + +- Phase 13.5: Medical Record diff --git a/docs/phase-handover/phase-13-5.md b/docs/phase-handover/phase-13-5.md new file mode 100644 index 0000000..57d306d --- /dev/null +++ b/docs/phase-handover/phase-13-5.md @@ -0,0 +1,19 @@ +# Phase 13.5 Handover — Medical Record + +| Field | Value | +| --- | --- | +| Phase | healthcare-13-5 | +| Version | 0.13.5.0 | +| Migration | 0006_phase_135_medical_record | +| Tests | test_phase_135.py | + +## Delivered + +- MedicalRecord, Encounter, Condition, Allergy, MedicationStatement, ImmunizationRecord +- MedicalRecordAccessLog for access audit +- Encounter finalize immutability +- Events: `healthcare.medical_record.*`, `healthcare.encounter.*` + +## Next + +- Phase 13.6: Pharmacy Network diff --git a/docs/phase-handover/phase-13-6.md b/docs/phase-handover/phase-13-6.md new file mode 100644 index 0000000..064491b --- /dev/null +++ b/docs/phase-handover/phase-13-6.md @@ -0,0 +1,19 @@ +# Phase 13.6 Handover — Pharmacy Network + +| Field | Value | +| --- | --- | +| Phase | healthcare-13-6 | +| Version | 0.13.6.0 | +| Migration | 0007_phase_136_pharmacy_network | +| Tests | test_phase_136.py | + +## Delivered + +- Pharmacy registry, PrescriptionOrder, PrescriptionLine, PrescriptionStatusHistory +- Fulfillment lifecycle: submitted → accepted → preparing → ready → picked_up / cancelled +- Communication mock on ready status +- Pharmacy tenant isolation for assigned orders + +## Next + +- Phase 13.7: Delivery Integration diff --git a/docs/phase-handover/phase-13-7.md b/docs/phase-handover/phase-13-7.md new file mode 100644 index 0000000..56ce348 --- /dev/null +++ b/docs/phase-handover/phase-13-7.md @@ -0,0 +1,24 @@ +# Phase 13.7 Handover — Delivery Integration + +| Field | Value | +| --- | --- | +| Phase | healthcare-13-7 | +| Version | 0.13.7.0 | +| Migration | 0008_phase_137_delivery_integration | +| Tests | test_phase_137.py | + +## Delivered + +- DeliveryIntegrationRegistration, DeliveryJobIntent, DeliveryJobStatusSnapshot +- Job intent idempotency via `idempotency_key` +- `MockDeliveryProvider` for job submit; webhook status ingest +- No cross-service imports to Delivery (boundary tests green) +- Events: `healthcare.delivery_job_intent.*` + +## Blockers + +- None for backend shell; real Delivery platform connector awaits Delivery 10.9+ production API + +## Next + +- Frontend phases / national e-prescription ADR (future) diff --git a/docs/phase-handover/phase-9-3.md b/docs/phase-handover/phase-9-3.md new file mode 100644 index 0000000..7cf8cc5 --- /dev/null +++ b/docs/phase-handover/phase-9-3.md @@ -0,0 +1,16 @@ +# Phase Handover — 9.3 Sports Center Coach & Staff Management + +> Mandatory starting point for Phase 9.4 — Scheduling & Booking. + +## Overview + +Phase 9.3 delivered coach/staff depth: certificates, skills, working hours, availability, assignments, extended coach profile. Version **0.9.3.0**. No attendance devices or booking engine. + +## Next Phase Entry Point + +Start Phase **9.4** from this document. + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green diff --git a/docs/phase-handover/phase-9-4.md b/docs/phase-handover/phase-9-4.md new file mode 100644 index 0000000..08f8234 --- /dev/null +++ b/docs/phase-handover/phase-9-4.md @@ -0,0 +1,16 @@ +# Phase Handover — 9.4 Sports Center Scheduling & Booking + +> Mandatory starting point for Phase 9.5 — Attendance & Access Control. + +## Overview + +Phase 9.4 delivered booking engine with sessions, bookings, waiting list, equipment, resource reservations, conflict detection. Version **0.9.4.0**. + +## Next Phase Entry Point + +Start Phase **9.5** from this document. + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green diff --git a/docs/phase-handover/phase-9-5.md b/docs/phase-handover/phase-9-5.md new file mode 100644 index 0000000..de34809 --- /dev/null +++ b/docs/phase-handover/phase-9-5.md @@ -0,0 +1,16 @@ +# Phase Handover — 9.5 Sports Center Attendance & Access Control + +> Mandatory starting point for Phase 9.6 — Training Management. + +## Overview + +Phase 9.5 delivered attendance recording, check-in/out, access logs, membership validation. Version **0.9.5.0**. Vendor SDKs remain outside business services. + +## Next Phase Entry Point + +Start Phase **9.6** from this document. + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green diff --git a/docs/phase-handover/phase-9-6.md b/docs/phase-handover/phase-9-6.md new file mode 100644 index 0000000..9e64a4a --- /dev/null +++ b/docs/phase-handover/phase-9-6.md @@ -0,0 +1,24 @@ +# Phase Handover — 9.6 Sports Center Training Management + +> Mandatory starting point for Phase 9.7 — Competition & Event Management. + +## Overview + +Phase 9.6 delivered **Training Management** on `sports-center-service` / `sports_center_db` (port **8006**, version **0.9.6.0**). Training programs, exercises, workout plans/assignments, goals, measurements, progress, nutrition plans, assessments, performance and body composition are in place. **Competitions were not implemented.** + +## Completed Scope + +- Eleven training aggregates + Alembic `0007_phase_96_training` +- APIs, permissions, publish-only events, tests green +- `/capabilities` phase `9.6`, `training_management: true` + +## Next Phase Entry Point + +Start Phase **9.7** from this document. + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Manifests / snapshot updated diff --git a/docs/phase-handover/phase-9-7.md b/docs/phase-handover/phase-9-7.md new file mode 100644 index 0000000..66eb95d --- /dev/null +++ b/docs/phase-handover/phase-9-7.md @@ -0,0 +1,24 @@ +# Phase Handover — 9.7 Sports Center Competition & Event Management + +> Mandatory starting point for Phase 9.8 — Financial Integration. + +## Overview + +Phase 9.7 delivered **Competition & Event Management** on `sports-center-service` / `sports_center_db` (port **8006**, version **0.9.7.0**). Competitions, teams, groups, registrations, matches, results, rankings, medals, judges, and certificates are in place. **No Accounting postings or financial integration.** + +## Completed Scope + +- Ten competition aggregates + Alembic `0008_phase_97_competitions` +- APIs, permissions, publish-only events, tests green +- `/capabilities` phase `9.7`, `competition_management: true` + +## Next Phase Entry Point + +Start Phase **9.8** from this document. Integrate Accounting, CRM, Loyalty, Communication via API/events only. + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Manifests / snapshot updated diff --git a/docs/phase-handover/phase-af-context-cache-policy.md b/docs/phase-handover/phase-af-context-cache-policy.md new file mode 100644 index 0000000..0c5fd9a --- /dev/null +++ b/docs/phase-handover/phase-af-context-cache-policy.md @@ -0,0 +1,146 @@ +# Phase Handover — AF-Context-Cache-Policy (Incremental Context Loading) + +| Field | Value | +| --- | --- | +| Phase ID | `ai-framework-context-cache-policy` | +| Title | Context Cache Policy — Incremental Context Loading | +| Status | Complete | +| Service(s) | N/A (documentation / process only) | +| Version | Framework context-cache-1.0 | +| Date | 2026-07-26 | +| ADR(s) | Extends [ADR-018](../architecture/adr/ADR-018.md), [ADR-019](../architecture/adr/ADR-019.md) (process; no new ADR) | + +## Business Analysis Summary + +| Item | Detail | +| --- | --- | +| Capabilities delivered | Incremental Context Loading via context cache; prevents re-reading unchanged docs between phases while keeping manifests and milestone docs fresh | +| Explicit non-goals honored | No business service changes; no completed business phase retrofits; no relocation of `docs/ai-framework/` | + +## Enterprise Phase Discovery Summary (this docs phase) + +| Item | Detail | +| --- | --- | +| Discovery Record location | This handover + [context-cache-policy.md](../ai-framework/context-cache-policy.md) | +| Gaps promoted & closed | Unconditional re-reads between phases → cache-eligible vs always-reload split | +| Exclusions | All business phases and services | +| CRUD-only rejected | N/A (process rule) | + +## Enhancement Catalog + +### New normative document + +| Document | Purpose | +| --- | --- | +| [context-cache-policy.md](../ai-framework/context-cache-policy.md) | Cache model, always-reload set, cache-eligible set, framework doc reuse, invalidation, phase entry procedure | + +### Core execution upgrades + +| Document | Enhancement | +| --- | --- | +| [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) | ALWAYS READ split into always-reload vs cache-eligible; READ ONCE PER SERVICE includes service README; architecture/ADR caching | +| [master-prompt.md](../ai-framework/master-prompt.md) | Document loading references cache policy; AI rules use incremental loading | +| [development-loop.md](../ai-framework/development-loop.md) | Stage 1 cache-aware loading; Documentation Update invalidates changed cache entries | +| [enterprise-phase-discovery.md](../ai-framework/enterprise-phase-discovery.md) | Discovery load step uses cache policy; inputs synthesized from reloaded + cached docs | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Context Cache Policy | `docs/ai-framework/context-cache-policy.md` | Every future phase in same workstream | +| Always reload set | Same | Manifests, progress, next-steps, current handover | +| Cache-eligible set | Same + runtime-read-policy | README, registries, glossary, roadmap, architecture/ADR refs, service README, completed handovers, framework docs | + +## Public APIs + +N/A — documentation/process only. + +## Events + +N/A. + +## Permissions + +N/A. + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| New always-reload doc | Add to context-cache-policy + runtime-read-policy always-reload table | Skipping required synthesis because doc is cached | +| New cache-eligible category | Add to cache-eligible table with invalidation rules | Full cache flush every phase | + +## Known Limitations + +- Cache is an agent-session optimization; change detection depends on mtime/hash/git diff when available. +- Does not retrofit completed business phase prompts. +- Active current handover always reloads; completed handovers are cache-eligible. + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | N/A | +| Upgrade steps | On next phase entry: always reload manifests/progress/next-steps/current handover; reuse unchanged cached docs | +| Breaking changes | None for runtime; reduced doc fan-out between phases | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| AF-Runtime-Read-Policy | Docs | Tiered loading baseline | +| AF-Discovery / ADR-019 | Docs | Enterprise Phase Discovery | +| AF-Enterprise / ADR-018 | Docs | Enterprise framework baseline | + +## Boundary Compliance + +- [x] No foreign service ownership implemented +- [x] No business logic duplication +- [x] No cross-DB access +- [x] No future-phase business functionality pulled forward +- [x] No business service code modified + +## Enterprise Completeness Sign-Off + +- [x] Discovery completeness (cache-aware Discovery load) +- [x] Business completeness (framework process) +- [x] Documentation completeness +- [x] Self Audit + +## Definition of Done + +- [x] context-cache-policy.md published +- [x] master-prompt.md, development-loop.md, enterprise-phase-discovery.md, runtime-read-policy.md updated +- [x] Framework handover complete +- [x] No business code changes + +## Next Phase Entry + +1. [context-cache-policy.md](../ai-framework/context-cache-policy.md) — phase entry procedure +2. [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) — what may load +3. Always reload: phase-manifest.yaml, service-manifest.yaml, next-steps.md, progress.md, current phase handover +4. Resume business milestones per [next-steps.md](../next-steps.md) when explicitly requested + +| Field | Value | +| --- | --- | +| Recommended next phase | Per [next-steps.md](../next-steps.md) (business track) — not started by this phase | +| Blockers for next phase | None from this upgrade | +| Entry checklist | Always reload set → cache check remaining in-scope docs → Discovery → implement promoted gaps only | + +## Completion Sign-Off + +- [x] Context Cache Policy integrated into framework +- [x] Incremental Context Loading defined +- [x] Framework handover complete +- [x] No TODO for claimed deliverables +- [x] No business service modifications + +## Related Documents + +- [Context Cache Policy](../ai-framework/context-cache-policy.md) +- [Runtime Read Policy](../ai-framework/runtime-read-policy.md) +- [Master Prompt](../ai-framework/master-prompt.md) +- [Development Loop](../ai-framework/development-loop.md) +- [Enterprise Phase Discovery](../ai-framework/enterprise-phase-discovery.md) +- [Phase Handover AF-Runtime-Read-Policy](phase-af-runtime-read-policy.md) +- [Phase Handover AF-Discovery](phase-af-discovery.md) diff --git a/docs/phase-handover/phase-af-discovery.md b/docs/phase-handover/phase-af-discovery.md new file mode 100644 index 0000000..bd93003 --- /dev/null +++ b/docs/phase-handover/phase-af-discovery.md @@ -0,0 +1,181 @@ +# Phase Handover — AF-Discovery (Enterprise Phase Discovery Mandate) + +| Field | Value | +| --- | --- | +| Phase ID | `ai-framework-discovery` | +| Title | Enterprise Phase Discovery Mandate | +| Status | Complete | +| Service(s) | N/A (documentation / process only) | +| Version | Framework discovery-1.0 (extends ADR-018) | +| Date | 2026-07-26 | +| ADR(s) | [ADR-019](../architecture/adr/ADR-019.md) (extends [ADR-018](../architecture/adr/ADR-018.md), [ADR-013](../architecture/adr/ADR-013.md)) | + +## Business Analysis Summary + +| Item | Detail | +| --- | --- | +| Capabilities delivered | Mandatory pre-implementation Discovery so every phase derives complete current-phase responsibility from roadmap + architecture + boundaries + prior handover + live codebase/APIs/domain/events/permissions/aggregates/tests/docs | +| Explicit non-goals honored | No business feature implementation; no retrofit of completed business phases/code; no future-phase pull-forward | + +## Enterprise Phase Discovery Summary (this docs phase) + +| Item | Detail | +| --- | --- | +| Discovery Record location | This handover + framework docs | +| Gaps promoted & closed | Missing Discovery process → published; loop/gates/templates updated | +| Exclusions | All business phases | +| CRUD-only rejected | Yes (process rule) | + +## Enhancement Catalog + +### New normative document + +| Document | Purpose | +| --- | --- | +| [enterprise-phase-discovery.md](../ai-framework/enterprise-phase-discovery.md) | Absolute rules, Discovery Inputs, procedure, Missing Capability Checklist, Discovery Record template, failure policy | + +### ADR + +| ADR | Decision | +| --- | --- | +| [ADR-019](../architecture/adr/ADR-019.md) | Mandatory Enterprise Phase Discovery before Implementation | + +### Core execution upgrades + +| Document | Enhancement | +| --- | --- | +| [development-loop.md](../ai-framework/development-loop.md) | Stage 1 = Enterprise Phase Discovery; completion requires closed gaps | +| [quality-gates.md](../ai-framework/quality-gates.md) | New Enterprise Phase Discovery gate + sign-off | +| [master-prompt.md](../ai-framework/master-prompt.md) | Discovery mandatory; AI rules inventory inputs | +| [README.md](../ai-framework/README.md) | Lifecycle starts with Discovery; index + AF-Discovery handover | +| [definition-of-done.md](../ai-framework/definition-of-done.md) | Discovery checklist section | +| [mandatory-phase-artifacts.md](../ai-framework/mandatory-phase-artifacts.md) | Artifact #0 Discovery | +| [enterprise-completeness.md](../ai-framework/enterprise-completeness.md) | Discovery completeness dimension | +| [phase-template.md](../ai-framework/phase-template.md) | Full Discovery Record section | +| [phase-handover.md](../ai-framework/phase-handover.md) | Discovery summary + Discovery completeness sign-off | +| [prompt-rules.md](../ai-framework/prompt-rules.md) · [cursor-guidelines.md](../ai-framework/cursor-guidelines.md) | Discovery automatic; Discovery Order before coding | +| [documentation-template.md](../ai-framework/documentation-template.md) | Discovery Record required in phase docs | +| [project-principles.md](../development/project-principles.md) | Principle 25 | +| [docs/README.md](../README.md) · [glossary.md](../glossary.md) · [roadmap.md](../roadmap.md) · [next-steps.md](../next-steps.md) · [progress.md](../progress.md) · [module-registry.md](../module-registry.md) | Cross-links | +| [phase-manifest.yaml](../ai-framework/phase-manifest.yaml) | Phase `ai-framework-discovery` Complete | +| [ADR index](../architecture/adr/README.md) | ADR-019 listed | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Discovery procedure | `docs/ai-framework/enterprise-phase-discovery.md` | Every future phase | +| Missing Capability Checklist | same | Gap analysis | +| Discovery Record template | same + phase-template | Copy into every phase doc | +| Discovery quality gate | `docs/ai-framework/quality-gates.md` | Reviewer checklist | + +## Public APIs + +N/A — documentation/process only. + +## Events + +N/A. + +## Permissions + +N/A. + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| New Discovery input source | Add to Discovery Inputs table + ADR if architectural | Skipping Discovery | +| New capability class in checklist | Update checklist + DoD/completeness | Using checklist to pull future-phase products | + +## Known Limitations + +- Does not retrofit completed business phases with Discovery Records. +- Docs-only phases still require a Discovery Record against documentation/manifests (code may be N/A). +- Does not implement product features. + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | N/A | +| Upgrade steps | Next business phase must publish Discovery Record before Implementation | +| Breaking changes | None for runtime; process bar higher for **future** phases | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| ADR-018 / AF-Enterprise | Docs | Enterprise framework baseline | +| ADR-013 / Phase AF | Docs | Original framework | + +## Boundary Compliance + +- [x] No foreign service ownership implemented +- [x] No business logic duplication +- [x] No cross-DB access +- [x] No future-phase business functionality pulled forward + +## Enterprise Completeness Sign-Off + +- [x] Discovery completeness +- [x] Business completeness (framework process) +- [x] Architectural completeness (ADR-019) +- [x] API completeness (N/A — docs) +- [x] Permission completeness (N/A — docs) +- [x] Capability completeness +- [x] Event completeness (N/A — docs) +- [x] Migration completeness (N/A — docs) +- [x] Repository completeness (N/A — docs) +- [x] Validation completeness +- [x] Security completeness +- [x] Performance completeness +- [x] Documentation completeness +- [x] Testing completeness (docs-phase validation) +- [x] Tenant isolation (rules retained) +- [x] Provider abstraction (rules retained) +- [x] Integration completeness (boundary rules) +- [x] Operational readiness (in Discovery checklist) +- [x] Developer experience +- [x] Maintainability +- [x] Self Audit + +## Definition of Done + +- [x] Framework Discovery mandate DoD satisfied for this docs-only phase +- [x] Applicable artifacts delivered +- [x] CRUD-only N/A (no business CRUD) + +## Next Phase Entry + +1. This handover + [enterprise-phase-discovery.md](../ai-framework/enterprise-phase-discovery.md) +2. [ADR-019](../architecture/adr/ADR-019.md) +3. Resume business milestones per [next-steps.md](../next-steps.md) — **Discovery runs automatically; do not restate the procedure in the phase prompt** +4. Do not start a business phase from this handover unless explicitly requested + +| Field | Value | +| --- | --- | +| Recommended next phase | Per [next-steps.md](../next-steps.md) (business track) — not started by this phase | +| Blockers for next phase | None from this upgrade | +| Entry checklist | Run Discovery first; publish Discovery Record; then implement promoted gaps only | + +## Completion Sign-Off + +- [x] Quality gates passed (documentation / architecture / cross-reference / template / manifest validation) +- [x] Enterprise Phase Discovery gate defined and satisfied for this docs phase +- [x] Documentation updated +- [x] Progress / next-steps / registries / manifests updated +- [x] No TODO for claimed deliverables +- [x] Self audit completed +- [x] Enterprise Completeness signed off +- [x] Definition of Done satisfied + +## Related Documents + +- [Enterprise Phase Discovery](../ai-framework/enterprise-phase-discovery.md) +- [AI / Enterprise Framework README](../ai-framework/README.md) +- [ADR-019](../architecture/adr/ADR-019.md) +- [ADR-018](../architecture/adr/ADR-018.md) +- [Phase Handover AF-Enterprise](phase-af-enterprise.md) +- [Progress](../progress.md) +- [Next Steps](../next-steps.md) diff --git a/docs/phase-handover/phase-af-enterprise.md b/docs/phase-handover/phase-af-enterprise.md new file mode 100644 index 0000000..12d0e4c --- /dev/null +++ b/docs/phase-handover/phase-af-enterprise.md @@ -0,0 +1,199 @@ +# Phase Handover — AF-Enterprise (Enterprise Development Framework Upgrade) + +| Field | Value | +| --- | --- | +| Phase ID | `ai-framework-enterprise` | +| Title | Enterprise Development Framework Upgrade | +| Status | Complete | +| Service(s) | N/A (documentation / process only) | +| Version | Framework enterprise-1.0 (extends ADR-013 baseline) | +| Date | 2026-07-25 | +| ADR(s) | [ADR-018](../architecture/adr/ADR-018.md) (extends [ADR-013](../architecture/adr/ADR-013.md)) | + +## Business Analysis Summary + +| Item | Detail | +| --- | --- | +| Capabilities delivered | Permanent enterprise execution rules so every future phase ships production-ready by default | +| Explicit non-goals honored | No Delivery/Restaurant/CRM/business feature implementation; no retrofit of completed business phases/code | + +## Enhancement Catalog + +### New normative documents + +| Document | Purpose | +| --- | --- | +| [definition-of-done.md](../ai-framework/definition-of-done.md) | Mandatory phase DoD; CRUD never sufficient; derive missing technical components inside phase | +| [mandatory-phase-artifacts.md](../ai-framework/mandatory-phase-artifacts.md) | Full artifact matrix (Business Analysis → Self Audit) applied automatically | +| [enterprise-completeness.md](../ai-framework/enterprise-completeness.md) | Pre-close verification dimensions + sign-off block | +| [boundary-rules.md](../ai-framework/boundary-rules.md) | No foreign ownership, no logic duplication, no cross-DB, API/Events/Providers only, no future-phase pull-forward | + +### Core execution upgrades + +| Document | Enhancement | +| --- | --- | +| [README.md](../ai-framework/README.md) | Rebranded as Enterprise / AI Framework; single source of truth; expanded index & lifecycle | +| [master-prompt.md](../ai-framework/master-prompt.md) | Enterprise Completeness Rules; DoD; boundary summary; auto production artifacts | +| [development-loop.md](../ai-framework/development-loop.md) | Business Analysis → Domain Modeling → Implementation → Completeness → Gates → Repair (implement missing work) | +| [quality-gates.md](../ai-framework/quality-gates.md) | Expanded gates: Business/API/Permission/Event/Repository/Validation/Provider/Integration/Ops/DX/DoD | +| [prompt-rules.md](../ai-framework/prompt-rules.md) | Enterprise quality automatic; CRUD forbidden as DoD; thin phase prompts | +| [cursor-guidelines.md](../ai-framework/cursor-guidelines.md) | Reading/implementation/validation order includes DoD, artifacts, completeness | + +### Template upgrades + +| Template | Enhancement | +| --- | --- | +| [phase-template.md](../ai-framework/phase-template.md) | Business Analysis, Domain Model, Specs/Policies/Commands/Queries, operational APIs, artifact trace, DoD criteria | +| [phase-handover.md](../ai-framework/phase-handover.md) | Boundary compliance, Enterprise Completeness Sign-Off, Definition of Done | +| [testing-template.md](../ai-framework/testing-template.md) | Commands/Queries, architecture, OpenAPI, list UX, DoD-aware rules | +| [documentation-template.md](../ai-framework/documentation-template.md) | OpenAPI, completeness, forbid “limitations” for missing current-phase artifacts | +| [service-template.md](../ai-framework/service-template.md) | commands/queries/policies/specifications layout; health/capabilities/metrics | +| [module-template.md](../ai-framework/module-template.md) | Full layer stack + boundary/DoD discipline | +| [entity-template.md](../ai-framework/entity-template.md) | Soft delete mandatory default; optimistic locking section | +| [repository-template.md](../ai-framework/repository-template.md) | Page/filter/sort/search + specs | +| [service-layer-template.md](../ai-framework/service-layer-template.md) | Commands, Queries, Specifications, Policies, DI | +| [api-template.md](../ai-framework/api-template.md) | OpenAPI, capabilities/metrics, list UX mandatory | +| [event-template.md](../ai-framework/event-template.md) | Event completeness gate linkage | + +### Supporting documentation + +| Document | Change | +| --- | --- | +| [project-principles.md](../development/project-principles.md) | Principles 21–24 (DoD, artifacts, boundaries, completeness) | +| [testing-strategy.md](../development/testing-strategy.md) | Expanded layers + CRUD insufficiency rule | +| [docs/README.md](../README.md) | Framework index + Phase Completion Gate expanded | +| [architecture.md](../architecture/architecture.md) | Framework reading entry updated | +| Root [README.md](../../README.md) | Framework row updated | +| [glossary.md](../glossary.md) | Enterprise Framework / DoD / artifacts / completeness / boundary terms | +| [roadmap.md](../roadmap.md) · [next-steps.md](../next-steps.md) · [progress.md](../progress.md) · [module-registry.md](../module-registry.md) | Cross-links; progress records AF-Enterprise complete | +| [phase-manifest.yaml](../ai-framework/phase-manifest.yaml) | Phase `ai-framework-enterprise` registered Complete | +| [ADR index](../architecture/adr/README.md) | ADR-018 listed; duplicate ADR-016 row cleaned | + +### Architecture decision + +| ADR | Decision | +| --- | --- | +| [ADR-018](../architecture/adr/ADR-018.md) | Enterprise Completeness Mandate — extends ADR-013; framework is single source of truth for production-ready phases by default | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Definition of Done | `docs/ai-framework/definition-of-done.md` | Every future phase | +| Mandatory Artifacts matrix | `docs/ai-framework/mandatory-phase-artifacts.md` | Auto-include checklist | +| Completeness sign-off | `docs/ai-framework/enterprise-completeness.md` | Copy into every handover | +| Boundary rules | `docs/ai-framework/boundary-rules.md` | Architecture + integration enforcement | +| Expanded quality gates | `docs/ai-framework/quality-gates.md` | Reviewer checklist | + +## Public APIs + +N/A — documentation/process only. + +## Events + +N/A. + +## Permissions + +N/A. + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| New permanent process rule | Update `docs/ai-framework/` + ADR if architectural | Burying rules in a single phase prompt | +| New mandatory artifact | Add to mandatory-phase-artifacts + DoD + gates | Retrofitting completed business code without an explicit phase | +| Service package conventions | Prefer commands/queries/policies/specs layout for **new** work | Forcing unrelated refactors of completed services | + +## Known Limitations + +- Does **not** retrofit completed business phases or existing service codebases to the new package layout. +- Equivalent patterns (command methods on services, inline specs) remain acceptable when responsibilities are equally covered. +- Does not replace short forms under `docs/templates/`. +- Does not implement product AI Assistant features. + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | N/A | +| Upgrade steps | Agents/humans read upgraded framework before next business phase | +| Downgrade support | N/A | +| Data backfill / seeds | N/A | +| Breaking changes | None for runtime; process bar is higher for **future** phases (intentional) | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| ADR-013 / Phase AF | Docs | Baseline framework | +| docs architecture (Phase D) | Docs | Documentation structure | + +## Boundary Compliance + +- [x] No foreign service ownership implemented +- [x] No business logic duplication +- [x] No cross-DB access +- [x] Integrations only via API / Events / Providers (N/A — no runtime integrations) +- [x] No future-phase business functionality pulled forward + +## Enterprise Completeness Sign-Off + +- [x] Business completeness (framework process capabilities) +- [x] Architectural completeness (ADR-018 + links) +- [x] API completeness (N/A — docs) +- [x] Permission completeness (N/A — docs) +- [x] Capability completeness (framework capabilities documented) +- [x] Event completeness (N/A — docs) +- [x] Migration completeness (N/A — docs) +- [x] Repository completeness (N/A — docs) +- [x] Validation completeness (framework validation suites described) +- [x] Security completeness (security gates retained/expanded) +- [x] Performance completeness (performance gates retained) +- [x] Documentation completeness +- [x] Testing completeness (testing template/strategy updated; docs-phase validation) +- [x] Tenant isolation (rules retained/emphasized) +- [x] Provider abstraction (rules retained/emphasized) +- [x] Integration completeness (boundary rules) +- [x] Operational readiness (health/metrics/config in mandatory artifacts) +- [x] Developer experience (README, prompt rules, cursor guidelines) +- [x] Maintainability (single source of truth; thin phase prompts) +- [x] Self Audit + +## Definition of Done + +- [x] Framework upgrade DoD satisfied for this docs-only phase boundary +- [x] CRUD-only delivery N/A (no business CRUD) +- [x] Applicable mandatory artifacts for a docs phase delivered (analysis, architecture validation, documentation, self audit, gates) + +## Next Phase Entry + +1. This handover + [ai-framework/README.md](../ai-framework/README.md) +2. [ADR-018](../architecture/adr/ADR-018.md) +3. Resume business milestones per [next-steps.md](../next-steps.md) — **using the upgraded framework without restating enterprise rules in the phase prompt** +4. Do not start a business phase from this handover unless explicitly requested + +| Field | Value | +| --- | --- | +| Recommended next phase | Per [next-steps.md](../next-steps.md) (business track) — not started by this phase | +| Blockers for next phase | None from this upgrade | +| Entry checklist | Read DoD + mandatory artifacts + completeness + boundary rules once; follow loop/gates | + +## Completion Sign-Off + +- [x] Quality gates passed (documentation / architecture / cross-reference / template / manifest validation for framework phase) +- [x] Tests N/A for runtime; docs validation performed via review +- [x] Documentation updated +- [x] Progress / next-steps / registries / manifests updated +- [x] No TODO for claimed deliverables +- [x] Self audit completed +- [x] Enterprise Completeness signed off +- [x] Definition of Done satisfied + +## Related Documents + +- [AI / Enterprise Framework README](../ai-framework/README.md) +- [ADR-013](../architecture/adr/ADR-013.md) +- [ADR-018](../architecture/adr/ADR-018.md) +- [Progress](../progress.md) +- [Next Steps](../next-steps.md) diff --git a/docs/phase-handover/phase-af-project-index.md b/docs/phase-handover/phase-af-project-index.md new file mode 100644 index 0000000..336d7e2 --- /dev/null +++ b/docs/phase-handover/phase-af-project-index.md @@ -0,0 +1,154 @@ +# Phase Handover — AF-Project-Index (Project Index — Runtime Discovery) + +| Field | Value | +| --- | --- | +| Phase ID | `ai-framework-project-index` | +| Title | Project Index — Single Runtime Discovery Entry Point | +| Status | Complete | +| Service(s) | N/A (documentation / process only) | +| Version | Framework project-index-1.0 | +| Date | 2026-07-26 | +| ADR(s) | Extends [ADR-018](../architecture/adr/ADR-018.md), [ADR-019](../architecture/adr/ADR-019.md) (process; no new ADR) | + +## Business Analysis Summary + +| Item | Detail | +| --- | --- | +| Capabilities delivered | Authoritative `project-index.yaml` for all registered services; execution starts from index; no repository folder scans for discovery | +| Explicit non-goals honored | No business service code changes; no completed business phase retrofits; no pre-generated per-service snapshot YAML files | + +## Enterprise Phase Discovery Summary (this docs phase) + +| Item | Detail | +| --- | --- | +| Discovery Record location | This handover + [project-index.yaml](../ai-framework/project-index.yaml) | +| Gaps promoted & closed | Ad-hoc path discovery → Project Index resolution for services, snapshots, roadmaps, handovers | +| Exclusions | All business phases and services | +| CRUD-only rejected | N/A (process rule) | + +## Enhancement Catalog + +### New authoritative index + +| Document | Purpose | +| --- | --- | +| [project-index.yaml](../ai-framework/project-index.yaml) | One entry per registered service with all required discovery fields; `execution_order`; shared references | + +### Core execution upgrades + +| Document | Enhancement | +| --- | --- | +| [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) | Project Index step 1; never scan folders; paths from index | +| [context-cache-policy.md](../ai-framework/context-cache-policy.md) | Index always reload; update on registration/Complete | +| [service-snapshot-policy.md](../ai-framework/service-snapshot-policy.md) | Snapshot paths from index; index updated on regeneration | +| [master-prompt.md](../ai-framework/master-prompt.md) | Index-first loading; registration updates index | +| [development-loop.md](../ai-framework/development-loop.md) | Discovery from index; Documentation Update updates index | +| [enterprise-phase-discovery.md](../ai-framework/enterprise-phase-discovery.md) | Project Index as primary discovery source | +| [mandatory-phase-artifacts.md](../ai-framework/mandatory-phase-artifacts.md) | Artifact #44 Project Index Update | +| [phase-handover.md](../ai-framework/phase-handover.md) | Project Index Update section | +| [quality-gates.md](../ai-framework/quality-gates.md) | Mandatory Project Index validation gate | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Project Index | `docs/ai-framework/project-index.yaml` | First file loaded every execution | +| Execution order | Same `execution_order` block | Machine-readable phase entry | +| Service entries | Same `services` list | Resolve snapshot, roadmap, handover paths | + +## Public APIs + +N/A — documentation/process only. + +## Events + +N/A. + +## Permissions + +N/A. + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| New service | Add index entry + service-manifest entry in same phase | Discovering services by scanning `backend/services/` | +| New index field | Add to policy + template + validation gate | Full repository tree walks | + +## Known Limitations + +- Index seeded from [service-manifest.yaml](../ai-framework/service-manifest.yaml) at framework upgrade time; per-service snapshots not pre-generated. +- `latest_handover: null` for services without handover files yet. +- Agents must keep index aligned with manifests on every registration and Complete phase. + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | N/A | +| Upgrade steps | Load `project-index.yaml` first on every execution; update index on next service Complete phase | +| Breaking changes | None for runtime; mandatory index-first discovery for future phases | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| AF-Service-Snapshot | Docs | Snapshot paths in index | +| AF-Context-Cache-Policy | Docs | Incremental loading | +| AF-Runtime-Read-Policy | Docs | Load tiers | + +## Boundary Compliance + +- [x] No foreign service ownership implemented +- [x] No business logic duplication +- [x] No cross-DB access +- [x] No business service code modified + +## Project Index Update + +| Field | Value | +| --- | --- | +| Index path | docs/ai-framework/project-index.yaml | +| Services indexed | 27 (all service-manifest entries) | +| Last updated | 2026-07-26 | + +## Enterprise Completeness Sign-Off + +- [x] Discovery completeness (index-based discovery) +- [x] Documentation completeness +- [x] Self Audit + +## Definition of Done + +- [x] project-index.yaml published with all registered services +- [x] All listed framework docs updated +- [x] Project Index quality gate added +- [x] Framework handover complete +- [x] No business code changes + +## Next Phase Entry + +1. [project-index.yaml](../ai-framework/project-index.yaml) — **always first** +2. Execution order steps 2–8 per [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) +3. Update index on service registration and phase Complete +4. Resume business milestones per [next-steps.md](../next-steps.md) when explicitly requested + +| Field | Value | +| --- | --- | +| Recommended next phase | Per [next-steps.md](../next-steps.md) (experience-11.4) — not started by this phase | +| Blockers for next phase | None | +| Entry checklist | Project Index → policies → snapshot → manifests → latest handover → Discovery | + +## Completion Sign-Off + +- [x] Project Index integrated as single discovery entry point +- [x] Mandatory Project Index validation gate added +- [x] Framework handover complete +- [x] No business service modifications + +## Related Documents + +- [Project Index](../ai-framework/project-index.yaml) +- [Runtime Read Policy](../ai-framework/runtime-read-policy.md) +- [Service Snapshot Policy](../ai-framework/service-snapshot-policy.md) +- [Phase Handover AF-Service-Snapshot](phase-af-service-snapshot.md) diff --git a/docs/phase-handover/phase-af-runtime-read-policy.md b/docs/phase-handover/phase-af-runtime-read-policy.md new file mode 100644 index 0000000..c57e889 --- /dev/null +++ b/docs/phase-handover/phase-af-runtime-read-policy.md @@ -0,0 +1,146 @@ +# Phase Handover — AF-Runtime-Read-Policy (Runtime Read Policy Integration) + +| Field | Value | +| --- | --- | +| Phase ID | `ai-framework-runtime-read-policy` | +| Title | Runtime Read Policy Integration | +| Status | Complete | +| Service(s) | N/A (documentation / process only) | +| Version | Framework runtime-read-1.0 | +| Date | 2026-07-26 | +| ADR(s) | Extends [ADR-018](../architecture/adr/ADR-018.md), [ADR-019](../architecture/adr/ADR-019.md) (process; no new ADR) | + +## Business Analysis Summary + +| Item | Detail | +| --- | --- | +| Capabilities delivered | Authoritative tiered document-loading policy; framework stages defer to Runtime Read Policy instead of unconditional bulk reads | +| Explicit non-goals honored | No business service changes; no completed business phase retrofits; no relocation of `docs/ai-framework/` | + +## Enterprise Phase Discovery Summary (this docs phase) + +| Item | Detail | +| --- | --- | +| Discovery Record location | This handover + [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) | +| Gaps promoted & closed | Unconditional “read all …” instructions in loop/master-prompt/Discovery → replaced with policy tiers and triggers | +| Exclusions | All business phases and services | +| CRUD-only rejected | N/A (process rule) | + +## Enhancement Catalog + +### Authoritative policy document + +| Document | Purpose | +| --- | --- | +| [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) | ALWAYS READ · READ ONCE PER SERVICE · READ ONLY IF REFERENCED · NEVER AUTO READ; additional load triggers; Discovery input mapping; backward compatibility | + +### Core execution upgrades + +| Document | Enhancement | +| --- | --- | +| [master-prompt.md](../ai-framework/master-prompt.md) | Document-loading authority statement; AI rules use policy instead of “read framework + architecture + phase docs” | +| [development-loop.md](../ai-framework/development-loop.md) | Stage 1 and Architecture Validation use tiered loading; testing-strategy loaded when referenced | +| [enterprise-phase-discovery.md](../ai-framework/enterprise-phase-discovery.md) | Discovery Inputs table with load tiers; procedure step 1 uses policy mapping | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Runtime Read Policy | `docs/ai-framework/runtime-read-policy.md` | Every future phase and framework change | +| Discovery input → tier mapping | Same + enterprise-phase-discovery.md | Discovery without bulk reads | +| Additional load triggers | runtime-read-policy.md | Manifest, handover, phase, validation-driven loads | + +## Public APIs + +N/A — documentation/process only. + +## Events + +N/A. + +## Permissions + +N/A. + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| New ALWAYS READ doc | Add to policy + cross-link from loop/master-prompt if normative | Bulk-reading whole directories | +| New Discovery input | Add row with load tier in Discovery + policy mapping | Auto-reading other services’ roadmaps/handovers | + +## Known Limitations + +- Does not retrofit completed business phase prompts or handovers with new load lists. +- Legacy docs outside the three updated files may still contain “read all …” wording; **runtime-read-policy.md supersedes** those instructions. +- Frontend docs remain out of scope unless the phase scopes frontend work. + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | N/A | +| Upgrade steps | Agents follow [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) on next phase entry | +| Breaking changes | None for runtime; reduced default doc fan-out for future phases | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| AF-Discovery / ADR-019 | Docs | Enterprise Phase Discovery baseline | +| AF-Enterprise / ADR-018 | Docs | Enterprise framework baseline | + +## Boundary Compliance + +- [x] No foreign service ownership implemented +- [x] No business logic duplication +- [x] No cross-DB access +- [x] No future-phase business functionality pulled forward +- [x] No business service code modified + +## Enterprise Completeness Sign-Off + +- [x] Discovery completeness (policy mapping for Discovery inputs) +- [x] Business completeness (framework process) +- [x] Architectural completeness (policy preserves boundary/ADR-on-demand loading) +- [x] Documentation completeness +- [x] Self Audit + +## Definition of Done + +- [x] Runtime Read Policy published as authoritative loader +- [x] master-prompt.md, development-loop.md, enterprise-phase-discovery.md updated +- [x] Backward compatibility documented +- [x] Framework handover complete +- [x] No business code changes + +## Next Phase Entry + +1. [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) — load tiers before any other non-triggered docs +2. [master-prompt.md](../ai-framework/master-prompt.md) · [development-loop.md](../ai-framework/development-loop.md) · [enterprise-phase-discovery.md](../ai-framework/enterprise-phase-discovery.md) +3. Prior framework handovers remain valid; load only the **latest handover for the current service** per policy +4. Resume business milestones per [next-steps.md](../next-steps.md) when explicitly requested + +| Field | Value | +| --- | --- | +| Recommended next phase | Per [next-steps.md](../next-steps.md) (business track) — not started by this phase | +| Blockers for next phase | None from this upgrade | +| Entry checklist | ALWAYS READ tier → READ ONCE PER SERVICE → Discovery with policy mapping → implement promoted gaps only | + +## Completion Sign-Off + +- [x] Runtime Read Policy integrated into master-prompt, development-loop, enterprise-phase-discovery +- [x] Unconditional bulk-read instructions replaced in updated framework docs +- [x] Framework handover complete +- [x] No TODO for claimed deliverables +- [x] No business service modifications + +## Related Documents + +- [Runtime Read Policy](../ai-framework/runtime-read-policy.md) +- [Master Prompt](../ai-framework/master-prompt.md) +- [Development Loop](../ai-framework/development-loop.md) +- [Enterprise Phase Discovery](../ai-framework/enterprise-phase-discovery.md) +- [Quality Gates](../ai-framework/quality-gates.md) +- [Phase Handover AF-Discovery](phase-af-discovery.md) +- [Phase Handover AF-Enterprise](phase-af-enterprise.md) diff --git a/docs/phase-handover/phase-af-service-snapshot.md b/docs/phase-handover/phase-af-service-snapshot.md new file mode 100644 index 0000000..a74c85b --- /dev/null +++ b/docs/phase-handover/phase-af-service-snapshot.md @@ -0,0 +1,166 @@ +# Phase Handover — AF-Service-Snapshot (Service Snapshot Architecture) + +| Field | Value | +| --- | --- | +| Phase ID | `ai-framework-service-snapshot` | +| Title | Service Snapshot Architecture | +| Status | Complete | +| Service(s) | N/A (documentation / process only) | +| Version | Framework service-snapshot-1.0 | +| Date | 2026-07-26 | +| ADR(s) | Extends [ADR-018](../architecture/adr/ADR-018.md), [ADR-019](../architecture/adr/ADR-019.md) (process; no new ADR) | + +## Business Analysis Summary + +| Item | Detail | +| --- | --- | +| Capabilities delivered | One compact YAML snapshot per service as primary runtime truth; eliminates repeated reconstruction from completed phase documents; mandatory regeneration after each Complete phase | +| Explicit non-goals honored | No business service code changes; no completed business phase retrofits; no pre-populated business snapshots | + +## Enterprise Phase Discovery Summary (this docs phase) + +| Item | Detail | +| --- | --- | +| Discovery Record location | This handover + [service-snapshot-policy.md](../ai-framework/service-snapshot-policy.md) | +| Gaps promoted & closed | Repeated multi-doc state reconstruction → service snapshot first; completed phase doc re-reads eliminated when snapshot valid | +| Exclusions | All business phases and services | +| CRUD-only rejected | N/A (process rule) | + +## Enhancement Catalog + +### New normative document + +| Document | Purpose | +| --- | --- | +| [service-snapshot-policy.md](../ai-framework/service-snapshot-policy.md) | Required fields, execution entry, fallback reconstruction, regeneration rules, primary runtime truth | + +### New snapshot infrastructure + +| Path | Purpose | +| --- | --- | +| [docs/service-snapshots/](../service-snapshots/) | Snapshot storage directory | +| [docs/service-snapshots/_template.yaml](../service-snapshots/_template.yaml) | Machine-readable template | +| [docs/service-snapshots/README.md](../service-snapshots/README.md) | Index and policy link | + +### Core execution upgrades + +| Document | Enhancement | +| --- | --- | +| [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) | Execution entry; snapshot in always-reload; Discovery mapping uses snapshot baseline | +| [context-cache-policy.md](../ai-framework/context-cache-policy.md) | Snapshot always reloads; superseded handovers cache-eligible | +| [master-prompt.md](../ai-framework/master-prompt.md) | Snapshot-first loading; regeneration on service extension | +| [development-loop.md](../ai-framework/development-loop.md) | Snapshot entry in Discovery; regeneration in Documentation Update; completion rule | +| [enterprise-phase-discovery.md](../ai-framework/enterprise-phase-discovery.md) | Baseline from snapshot; no completed phase doc reconstruction | +| [mandatory-phase-artifacts.md](../ai-framework/mandatory-phase-artifacts.md) | Artifact #43 Service Snapshot | +| [phase-handover.md](../ai-framework/phase-handover.md) | Service Snapshot Update section; next-phase entry uses snapshot | +| [quality-gates.md](../ai-framework/quality-gates.md) | Mandatory Service Snapshot gate + sign-off | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Service Snapshot Policy | `docs/ai-framework/service-snapshot-policy.md` | Every service-scoped phase | +| Snapshot template | `docs/service-snapshots/_template.yaml` | Copy per service on first Complete phase | +| Service Snapshot gate | `docs/ai-framework/quality-gates.md` | Regeneration verification | + +## Public APIs + +N/A — documentation/process only. + +## Events + +N/A. + +## Permissions + +N/A. + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| New snapshot field | Add to policy + template + regeneration procedure | Storing full OpenAPI or phase doc content in snapshot | +| New service | Create `<service>.yaml` on first Complete phase | Skipping snapshot gate | + +## Known Limitations + +- No business service snapshots pre-generated — first service phase after this upgrade builds initial snapshot. +- Snapshot summarizes state; full contracts remain in reference docs when triggered by read policy. +- Framework-only phases do not require a service snapshot file. + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | N/A | +| Upgrade steps | Next service-scoped Complete phase must regenerate `docs/service-snapshots/<service>.yaml` | +| Breaking changes | None for runtime; reduced doc fan-out for future phases | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| AF-Context-Cache-Policy | Docs | Incremental loading | +| AF-Runtime-Read-Policy | Docs | Tiered loading | +| AF-Discovery / ADR-019 | Docs | Discovery baseline | + +## Boundary Compliance + +- [x] No foreign service ownership implemented +- [x] No business logic duplication +- [x] No cross-DB access +- [x] No future-phase business functionality pulled forward +- [x] No business service code modified + +## Service Snapshot Update + +| Field | Value | +| --- | --- | +| Snapshot path | N/A (framework phase) | +| Snapshot version | N/A | +| Last updated | 2026-07-26 | +| Fields changed | Policy, template, gates, framework cross-links | + +## Enterprise Completeness Sign-Off + +- [x] Discovery completeness (snapshot-based baseline) +- [x] Business completeness (framework process) +- [x] Documentation completeness +- [x] Self Audit + +## Definition of Done + +- [x] service-snapshot-policy.md published +- [x] All listed framework docs updated +- [x] Service Snapshot quality gate added +- [x] Framework handover complete +- [x] No business code changes + +## Next Phase Entry + +1. [service-snapshot-policy.md](../ai-framework/service-snapshot-policy.md) execution entry order +2. Load `docs/service-snapshots/<service>.yaml` when present; build on first service Complete phase if missing +3. [runtime-read-policy.md](../ai-framework/runtime-read-policy.md) · [context-cache-policy.md](../ai-framework/context-cache-policy.md) +4. Resume business milestones per [next-steps.md](../next-steps.md) when explicitly requested + +| Field | Value | +| --- | --- | +| Recommended next phase | Per [next-steps.md](../next-steps.md) (business track) — not started by this phase | +| Blockers for next phase | None; initial per-service snapshots created on next Complete phase per service | +| Entry checklist | Policies → snapshot → manifests → latest handover → Discovery → implement → regenerate snapshot | + +## Completion Sign-Off + +- [x] Service Snapshot Architecture integrated +- [x] Mandatory quality gate added +- [x] Framework handover complete +- [x] No TODO for claimed deliverables +- [x] No business service modifications + +## Related Documents + +- [Service Snapshot Policy](../ai-framework/service-snapshot-policy.md) +- [Runtime Read Policy](../ai-framework/runtime-read-policy.md) +- [Context Cache Policy](../ai-framework/context-cache-policy.md) +- [Phase Handover AF-Context-Cache-Policy](phase-af-context-cache-policy.md) +- [Phase Handover AF-Runtime-Read-Policy](phase-af-runtime-read-policy.md) diff --git a/docs/phase-handover/phase-dp-reg.md b/docs/phase-handover/phase-dp-reg.md index ca51c90..beea4b5 100644 --- a/docs/phase-handover/phase-dp-reg.md +++ b/docs/phase-handover/phase-dp-reg.md @@ -1,105 +1,105 @@ -# Phase Handover — DP-Reg Delivery & Fleet Platform Registration - -## Metadata - -| Field | Value | -| --- | --- | -| Phase ID | `delivery-reg` | -| Title | Delivery & Fleet Platform Registration | -| Status | Complete | -| Service(s) | `delivery` (registered; not yet implemented) | -| Version | n/a (docs-only) | -| Date | 2026-07-25 | -| ADR(s) | [ADR-015](../architecture/adr/ADR-015.md) | - -## Reusable Components - -| Component | Location | Reuse notes | -| --- | --- | --- | -| Platform registration pattern | Same as Sports Center SC-Reg | Docs + ADR + manifests before code | -| Service template | [service-template.md](../ai-framework/service-template.md) | Use in Phase 10.0 | -| Phase template | [phase-template.md](../ai-framework/phase-template.md) | Use for 10.x phase docs | - -## Public APIs - -| Method | Path | Auth / Permission | Notes | -| --- | --- | --- | --- | -| — | — | — | N/A — registration only; APIs begin in Phase 10.0 | - -Planned surfaces (not implemented): `/health`, `/capabilities`, `/api/v1/*` under `delivery.*` permissions. - -## Events - -| Event type | Domain / Integration | Payload summary | Version | -| --- | --- | --- | --- | -| `delivery.*` (planned) | Delivery domain | Publish-only; catalog grows per phase | Planned | - -## Extension Points - -| Extension point | How to extend | Forbidden uses | -| --- | --- | --- | -| Routing engine providers | Adapter protocol in Delivery service | Verticals calling external routers directly | -| Fleet / courier providers | Provider registry + adapters | Credentials outside Delivery | -| Merchant connectors | Versioned REST/events for job intake | Shared DB with Restaurant/Marketplace | -| AI assist hooks | Optional contracts; core works offline | Hard dependency on AI for dispatch | - -## Known Limitations - -- No `backend/services/delivery` code yet — Phase 10.0 -- No Alembic migrations yet -- No compose wiring / runtime health yet -- Driver App / Dispatcher Panel UI deferred to frontend phases -- Order ownership remains in vertical services - -## Migration Notes - -| Item | Detail | -| --- | --- | -| Alembic revision(s) | N/A (docs-only) | -| Upgrade steps | N/A | -| Downgrade support | N/A | -| Data backfill | N/A | -| Breaking changes | None | - -## Dependencies - -| Dependency | Type | Required for | -| --- | --- | --- | -| AI Framework | Docs | Development loop / gates | -| ADR-001 / 003 / 006 / 010 / 011 / 012 | Architecture | Boundaries | -| Core Platform | Service | Entitlement (from 10.0) | -| Communication | Service | Notifications (from 10.9) | -| Accounting | Service | Settlement posts (from 10.8) | - -## Next Phase Entry - -| Field | Value | -| --- | --- | -| Recommended next phase | `delivery-10.0` Foundation | -| Blockers for next phase | None for scaffold; runtime compose optional until wired | -| Entry checklist | Read this handover + [delivery-roadmap.md](../delivery-roadmap.md) + [ADR-015](../architecture/adr/ADR-015.md) + [service-template.md](../ai-framework/service-template.md); implement only foundation shells — no dispatch/routing engines yet | - -What the next phase must read and assume: - -1. This handover + [delivery-roadmap.md](../delivery-roadmap.md) -2. Updated [module-registry.md](../module-registry.md#delivery) / manifests -3. Open limitations above become Phase 10.0 scope (scaffold only) -4. Suggested next phase ID: `delivery-10.0` - -## Completion Sign-Off - -- [x] Quality gates passed (documentation / architecture / manifest / cross-reference / links) -- [x] Tests green (N/A service pytest — docs-only; validation script/checks) -- [x] Documentation updated -- [x] Progress / next-steps / registries updated -- [x] No TODO for claimed deliverables -- [x] Self audit completed - -## Related Documents - -- [Delivery Roadmap](../delivery-roadmap.md) -- [Phase Area](../phases/Delivery/README.md) -- [ADR-015](../architecture/adr/ADR-015.md) -- [Phase Manifest](../ai-framework/phase-manifest.yaml) -- [Service Manifest](../ai-framework/service-manifest.yaml) +# Phase Handover — DP-Reg Delivery & Fleet Platform Registration + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `delivery-reg` | +| Title | Delivery & Fleet Platform Registration | +| Status | Complete | +| Service(s) | `delivery` (registered; not yet implemented) | +| Version | n/a (docs-only) | +| Date | 2026-07-25 | +| ADR(s) | [ADR-015](../architecture/adr/ADR-015.md) | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Platform registration pattern | Same as Sports Center SC-Reg | Docs + ADR + manifests before code | +| Service template | [service-template.md](../ai-framework/service-template.md) | Use in Phase 10.0 | +| Phase template | [phase-template.md](../ai-framework/phase-template.md) | Use for 10.x phase docs | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| — | — | — | N/A — registration only; APIs begin in Phase 10.0 | + +Planned surfaces (not implemented): `/health`, `/capabilities`, `/api/v1/*` under `delivery.*` permissions. + +## Events + +| Event type | Domain / Integration | Payload summary | Version | +| --- | --- | --- | --- | +| `delivery.*` (planned) | Delivery domain | Publish-only; catalog grows per phase | Planned | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Routing engine providers | Adapter protocol in Delivery service | Verticals calling external routers directly | +| Fleet / courier providers | Provider registry + adapters | Credentials outside Delivery | +| Merchant connectors | Versioned REST/events for job intake | Shared DB with Restaurant/Marketplace | +| AI assist hooks | Optional contracts; core works offline | Hard dependency on AI for dispatch | + +## Known Limitations + +- No `backend/services/delivery` code yet — Phase 10.0 +- No Alembic migrations yet +- No compose wiring / runtime health yet +- Driver App / Dispatcher Panel UI deferred to frontend phases +- Order ownership remains in vertical services + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | N/A (docs-only) | +| Upgrade steps | N/A | +| Downgrade support | N/A | +| Data backfill | N/A | +| Breaking changes | None | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| AI Framework | Docs | Development loop / gates | +| ADR-001 / 003 / 006 / 010 / 011 / 012 | Architecture | Boundaries | +| Core Platform | Service | Entitlement (from 10.0) | +| Communication | Service | Notifications (from 10.9) | +| Accounting | Service | Settlement posts (from 10.8) | + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `delivery-10.0` Foundation | +| Blockers for next phase | None for scaffold; runtime compose optional until wired | +| Entry checklist | Read this handover + [delivery-roadmap.md](../delivery-roadmap.md) + [ADR-015](../architecture/adr/ADR-015.md) + [service-template.md](../ai-framework/service-template.md); implement only foundation shells — no dispatch/routing engines yet | + +What the next phase must read and assume: + +1. This handover + [delivery-roadmap.md](../delivery-roadmap.md) +2. Updated [module-registry.md](../module-registry.md#delivery) / manifests +3. Open limitations above become Phase 10.0 scope (scaffold only) +4. Suggested next phase ID: `delivery-10.0` + +## Completion Sign-Off + +- [x] Quality gates passed (documentation / architecture / manifest / cross-reference / links) +- [x] Tests green (N/A service pytest — docs-only; validation script/checks) +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] No unfinished claimed deliverables +- [x] Self audit completed + +## Related Documents + +- [Delivery Roadmap](../delivery-roadmap.md) +- [Phase Area](../phases/Delivery/README.md) +- [ADR-015](../architecture/adr/ADR-015.md) +- [Phase Manifest](../ai-framework/phase-manifest.yaml) +- [Service Manifest](../ai-framework/service-manifest.yaml) - [Quality Gates](../ai-framework/quality-gates.md) diff --git a/docs/phases/Delivery/README.md b/docs/phases/Delivery/README.md index fc187dc..e3acef2 100644 --- a/docs/phases/Delivery/README.md +++ b/docs/phases/Delivery/README.md @@ -1,31 +1,35 @@ -# Delivery & Fleet Platform Phase Area - -Independent enterprise Delivery & Fleet Platform (`delivery-service`, `delivery_db`, planned port 8007). Commercial product: **Torbat Driver**. - -## Status - -| Phase | Title | Status | -| --- | --- | --- | -| Reg | Platform Registration | Complete | -| 10.0 | Platform Foundation | Next | -| 10.1 | Driver Management | Planned | -| 10.2 | Fleet & Vehicle Types | Planned | -| 10.3 | Availability, Shifts & Working Zones | Planned | -| 10.4 | Pricing, Capabilities & Bundles | Planned | -| 10.5 | Dispatch Engine | Planned | -| 10.6 | Routing & Optimization | Planned | -| 10.7 | Tracking & Proof of Delivery | Planned | -| 10.8 | Settlement | Planned | -| 10.9 | Merchant Connector & App Surfaces | Planned | -| 10.10 | Analytics, AI Ready & Enterprise Validation | Planned | - -## Documents - -- [Roadmap](../../delivery-roadmap.md) -- [Handover DP-Reg](../../phase-handover/phase-dp-reg.md) -- [ADR-015](../../architecture/adr/ADR-015.md) -- [AI Framework](../../ai-framework/README.md) - -## Boundary reminder - +# Delivery & Fleet Platform Phase Area + +Independent enterprise Delivery & Fleet Platform (`delivery-service`, `delivery_db`, port 8007). Commercial product: **Torbat Driver**. + +## Status + +| Phase | Title | Status | +| --- | --- | --- | +| Reg | Platform Registration | Complete | +| 10.0 | Platform Foundation | Complete | +| 10.1 | Driver Management | Complete | +| 10.2 | Fleet & Vehicle Types | Next | +| 10.3 | Availability, Shifts & Working Zones | Planned | +| 10.4 | Pricing, Capabilities & Bundles | Planned | +| 10.5 | Dispatch Engine | Planned | +| 10.6 | Routing & Optimization | Planned | +| 10.7 | Tracking & Proof of Delivery | Planned | +| 10.8 | Settlement | Planned | +| 10.9 | Merchant Connector & App Surfaces | Planned | +| 10.10 | Analytics, AI Ready & Enterprise Validation | Planned | + +## Documents + +- [Roadmap](../../delivery-roadmap.md) +- [Phase 10.0](../../delivery-phase-10-0.md) +- [Phase 10.1](../../delivery-phase-10-1.md) +- [Handover DP-Reg](../../phase-handover/phase-dp-reg.md) +- [Handover 10.0](../../phase-handover/phase-10-0.md) +- [Handover 10.1](../../phase-handover/phase-10-1.md) +- [ADR-015](../../architecture/adr/ADR-015.md) +- [AI Framework](../../ai-framework/README.md) + +## Boundary reminder + Delivery owns logistics domain only. Consume Accounting, CRM, Loyalty, Communication, Core, and vertical job refs via API + Events. Do not confuse with Communication message delivery tracking. diff --git a/docs/phases/Future/README.md b/docs/phases/Future/README.md index 3f92c32..59407df 100644 --- a/docs/phases/Future/README.md +++ b/docs/phases/Future/README.md @@ -10,7 +10,8 @@ Catch-all for capabilities not yet given a dedicated phase folder. - Payment gateway integration - Advanced permission trees & member invites - File storage + S3 provider -- Website builder, live chat, SMS panel, link shortener, notification (see module registry) +- Website builder (historical; prefer Experience / Torbat Pages), live chat, SMS panel, link shortener, notification (see module registry) +- Experience Platform (registered — see [phases/Experience](../Experience/README.md)) ## Rules diff --git a/docs/phases/Healthcare/README.md b/docs/phases/Healthcare/README.md new file mode 100644 index 0000000..829ce3f --- /dev/null +++ b/docs/phases/Healthcare/README.md @@ -0,0 +1,45 @@ +# Healthcare Platform — Phase Area + +> Canonical phase documents for the independent **Healthcare Platform** (Torbat Health). +> Roadmap summary → [healthcare-roadmap.md](../../healthcare-roadmap.md). Module inventory → [module-registry.md](../../module-registry.md#healthcare). + +| Phase | Document | Status | +| --- | --- | --- | +| HC-Reg | (registration — see [progress.md](../../progress.md)) | Complete (docs) | +| 13.0 | [phase-13-0-healthcare-foundation.md](phase-13-0-healthcare-foundation.md) | Planned | +| 13.1 | [phase-13-1-appointment-engine.md](phase-13-1-appointment-engine.md) | Planned | +| 13.2 | [phase-13-2-doctor-panel.md](phase-13-2-doctor-panel.md) | Planned | +| 13.3 | [phase-13-3-clinic-management.md](phase-13-3-clinic-management.md) | Planned | +| 13.4 | [phase-13-4-patient-portal.md](phase-13-4-patient-portal.md) | Planned | +| 13.5 | [phase-13-5-medical-record.md](phase-13-5-medical-record.md) | Planned | +| 13.6 | [phase-13-6-pharmacy-network.md](phase-13-6-pharmacy-network.md) | Planned | +| 13.7 | [phase-13-7-delivery-integration.md](phase-13-7-delivery-integration.md) | Planned | + +## Service (planned) + +| Field | Value | +| --- | --- | +| Service key | `healthcare` | +| Database | `healthcare_db` | +| API port | 8010 (reserved) | +| Permission prefix | `healthcare.*` | +| Commercial product | Torbat Health | + +## Implementation Rules + +Every phase implementation must follow: + +- [Enterprise Phase Discovery](../../ai-framework/enterprise-phase-discovery.md) +- [Definition of Done](../../ai-framework/definition-of-done.md) +- [Boundary Rules](../../ai-framework/boundary-rules.md) +- [Module Boundaries](../../architecture/module-boundaries.md) + +**Documentation-only registration (HC-Reg)** does not include backend code, migrations, or APIs. + +## Related Documents + +- [Healthcare Roadmap](../../healthcare-roadmap.md) +- [Module Registry](../../module-registry.md) +- [Progress](../../progress.md) +- [Roadmap](../../roadmap.md) +- [AI / Enterprise Development Framework](../../ai-framework/README.md) diff --git a/docs/phases/Healthcare/phase-13-0-healthcare-foundation.md b/docs/phases/Healthcare/phase-13-0-healthcare-foundation.md new file mode 100644 index 0000000..7881363 --- /dev/null +++ b/docs/phases/Healthcare/phase-13-0-healthcare-foundation.md @@ -0,0 +1,208 @@ +# Phase: Healthcare Platform Foundation + +| Field | Value | +| --- | --- | +| Identifier | `healthcare-13-0` | +| Status | Planned | +| Owner | Platform | +| Module(s) | `healthcare.*` foundation modules | +| Service(s) | `healthcare` | +| Depends On | Core entitlement; Identity (profile refs) | +| ADR(s) | TBD (Healthcare independent platform — to be accepted at implementation) | +| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) | +| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) | + +## Objective + +Establish an independent, tenant-aware Healthcare service scaffold with foundation aggregates, permissions, publish-only events, provider contracts, health/capabilities/metrics endpoints, and audit — without appointment, clinical workflow, pharmacy, or delivery engines. + +## Enterprise Phase Discovery + +> **Required before Implementation.** + +| Field | Value | +| --- | --- | +| Discovery date | TBD at implementation | +| Inputs reviewed | [healthcare-roadmap.md](../../healthcare-roadmap.md) · [module-boundaries.md](../../architecture/module-boundaries.md) · [module-registry.md](../../module-registry.md#healthcare) · prior platform foundations (Delivery, Hospitality, Experience) | + +### Baseline Inventory (exists today) + +- Documentation registration (HC-Reg) complete +- No `backend/services/healthcare` runtime + +### Target Responsibility (this phase when Complete) + +- Independent service `healthcare` / `healthcare_db` (port 8010) +- Foundation aggregates and CRUD/list APIs with tenant isolation +- Permissions `healthcare.*`; transactional outbox events +- Provider Protocol stubs (Communication, CRM, Loyalty, Accounting, Delivery, Storage, AI, Identity) +- Architecture, tenant, permission, migration, security, docs tests green + +### Gap Analysis + +| Capability | Status | Action | +| --- | --- | --- | +| Service scaffold | Missing | Implement at 13.0 | +| Foundation aggregates | Missing | Implement at 13.0 | +| Appointment engine | Future | Phase 13.1 | +| Doctor panel / clinic admin | Future | Phases 13.2–13.3 | +| Patient portal / EHR / pharmacy | Future | Phases 13.4–13.6 | +| Delivery integration | Future | Phase 13.7 | + +### Promoted to Implementation Scope + +- Clinic, Branch, Department shells +- Doctor and Patient profile shells (platform user refs only) +- Roles, permissions catalog, configuration, settings, audit, outbox +- Health, capabilities, metrics endpoints + +### Explicitly Excluded (future phase or other service) + +- Appointment booking, schedules, slots (13.1) +- Visit sessions, queues, doctor workflows (13.2) +- Clinic services catalog admin (13.3) +- Patient self-service portal (13.4) +- Medical record encounters (13.5) +- Pharmacy network (13.6) +- Delivery job dispatch (13.7) +- Identity user administration (Identity) +- Communication provider ownership (Communication) +- Accounting journal posting (Accounting) + +### Boundary Confirmations + +- [x] No future-phase pull-forward (documentation) +- [x] No service-boundary violation (documentation) +- [x] No duplication of Delivery / Communication / Accounting domains +- [x] CRUD-only delivery rejected for implementation phase + +## Business Analysis + +| Item | Detail | +| --- | --- | +| Actors | Platform admin, clinic admin (future), doctor (future), patient (future) | +| Business capabilities (in phase) | Register clinics/branches; doctor/patient profile shells; tenant config | +| Success metrics | Service boots; tenant-isolated foundation APIs; tests green | +| Non-goals | Clinical workflows, billing, pharmacy, logistics | + +## Scope + +### In Scope + +- Service scaffold under `backend/services/healthcare` +- Database `healthcare_db`; Alembic `0001_initial` +- Foundation models, repositories, services, validators, permissions, events +- `/health`, `/capabilities`, `/metrics`, `/api/v1/*` foundation routes +- Compose + env wiring (port 8010) + +### Modules + +| Module | Change type | Notes | +| --- | --- | --- | +| `healthcare.clinics` | new | Clinic aggregate shell | +| `healthcare.branches` | new | Branch under clinic | +| `healthcare.departments` | new | Department shell | +| `healthcare.doctors` | new | Doctor profile shell | +| `healthcare.patients` | new | Patient profile shell | +| `healthcare.roles` | new | Healthcare roles | +| `healthcare.permissions_catalog` | new | Permission definitions | +| `healthcare.configuration` | new | Tenant configuration | +| `healthcare.settings` | new | Settings key-value | +| `healthcare.external_providers` | new | Provider registration shells | +| `healthcare.audit` | new | Audit log | + +### Domain Model + +| Aggregate | Entities | Invariants | +| --- | --- | --- | +| Clinic | Branch, Department | Tenant-scoped; soft delete where applicable | +| Doctor | — | Links to Identity user ref; no Identity ownership | +| Patient | — | Links to Identity user ref; privacy-aware | +| HealthcareConfiguration | Setting | Tenant-scoped keys | + +### Permissions + +| Permission | Description | +| --- | --- | +| `healthcare.clinics.view` | List/read clinics | +| `healthcare.clinics.manage` | Create/update/archive clinics | +| `healthcare.doctors.view` | List/read doctors | +| `healthcare.doctors.manage` | Manage doctor profiles | +| `healthcare.patients.view` | List/read patients | +| `healthcare.patients.manage` | Manage patient profiles | +| `healthcare.settings.manage` | Manage healthcare settings | + +### Events + +| Event | When | +| --- | --- | +| `healthcare.clinic.created` | Clinic created | +| `healthcare.clinic.updated` | Clinic updated | +| `healthcare.doctor.created` | Doctor profile created | +| `healthcare.patient.created` | Patient profile created | +| `healthcare.setting.updated` | Setting changed | + +### APIs + +| Method | Path | Permission | Notes | +| --- | --- | --- | --- | +| GET | `/health` | public/ops | Liveness | +| GET | `/capabilities` | authenticated | Phase/capability discovery | +| GET | `/metrics` | ops | Basic metrics | +| GET/POST/PATCH | `/api/v1/clinics` | `healthcare.clinics.*` | List with page/filter/sort/search | +| GET/POST/PATCH | `/api/v1/doctors` | `healthcare.doctors.*` | Doctor profiles | +| GET/POST/PATCH | `/api/v1/patients` | `healthcare.patients.*` | Patient profiles | +| GET/PATCH | `/api/v1/settings` | `healthcare.settings.*` | Settings | + +### Providers + +| Interface | Owning service | Notes | +| --- | --- | --- | +| CommunicationProvider | Communication | Contracts only | +| DeliveryProvider | Delivery | Contracts only | +| StorageProvider | File Storage | Document refs | +| CRMProvider | CRM | Optional contact sync | +| AccountingProvider | Accounting | Future billing intents | + +## Out of Scope + +- Appointment engine (13.1) +- Doctor panel workflows (13.2) +- Clinic management catalog (13.3) +- Patient portal (13.4) +- Medical records (13.5) +- Pharmacy network (13.6) +- Delivery integration (13.7) +- Frontend UI + +## Completion Criteria + +- [ ] Objective met +- [ ] Enterprise Phase Discovery Record complete at implementation +- [ ] Definition of Done satisfied +- [ ] Tests green +- [ ] Handover completed +- [ ] Module registry version `0.13.0.0` + +## Architecture Impact + +- New independent service and database per database-per-service rule ([ADR-001](../../architecture/adr/ADR-001.md)) +- Module boundaries updated at implementation + +## Database Impact + +- New `healthcare_db`; migration `0001_initial` + +## Risks + +- Premature clinical feature pull-forward from later phases +- Confusion with Sports Center `medical_information` shells — Healthcare owns clinical domain + +## Related Documents + +- [Healthcare Roadmap](../../healthcare-roadmap.md) +- [Phase area README](README.md) +- [Module Registry](../../module-registry.md#healthcare) +- [Module Boundaries](../../architecture/module-boundaries.md) +- [Progress](../../progress.md) +- [Next Steps](../../next-steps.md) diff --git a/docs/phases/Healthcare/phase-13-1-appointment-engine.md b/docs/phases/Healthcare/phase-13-1-appointment-engine.md new file mode 100644 index 0000000..52e6aae --- /dev/null +++ b/docs/phases/Healthcare/phase-13-1-appointment-engine.md @@ -0,0 +1,117 @@ +# Phase: Appointment Engine + +| Field | Value | +| --- | --- | +| Identifier | `healthcare-13-1` | +| Status | Planned | +| Owner | Platform | +| Module(s) | `healthcare.appointments`, `healthcare.schedules`, `healthcare.availability` | +| Service(s) | `healthcare` | +| Depends On | `healthcare-13-0` (clinics, doctors, patients) | +| ADR(s) | TBD | +| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) | +| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) | + +## Objective + +Deliver a tenant-aware appointment scheduling engine: doctor/clinic availability, bookable slots, appointment lifecycle (book, confirm, cancel, reschedule, no-show), waiting list shells, and reminder intents via Communication contracts — without doctor panel UX, patient portal, or clinical record content. + +## Enterprise Phase Discovery + +> **Required before Implementation.** + +| Field | Value | +| --- | --- | +| Discovery date | TBD at implementation | +| Inputs reviewed | Phase 13.0 handover · [healthcare-roadmap.md](../../healthcare-roadmap.md) · Communication contracts | + +### Baseline Inventory (exists today) + +- Phase 13.0 foundation (when complete): clinics, doctors, patients + +### Target Responsibility (this phase when Complete) + +- Schedule and availability management per doctor/clinic/department +- Appointment types and appointment aggregate with lifecycle transitions +- Slot generation and conflict detection +- Waiting list entry shells +- Events `healthcare.appointment.*`, `healthcare.schedule.*` +- Reminder dispatch via Communication provider (no provider ownership) + +### Explicitly Excluded + +- Doctor panel queue UX (13.2) +- Clinic services catalog admin (13.3) +- Patient self-service booking UI (13.4 — API may expose book for portal later) +- Visit notes / diagnoses (13.5) +- Pharmacy orders (13.6) +- Delivery jobs (13.7) + +### Boundary Confirmations + +- [x] No Communication template/provider ownership +- [x] No CRM opportunity or Loyalty point mutation +- [x] No Experience appointment page engine ownership + +## Business Analysis + +| Item | Detail | +| --- | --- | +| Actors | Clinic scheduler, doctor (indirect), patient (indirect) | +| Business capabilities | Define availability; book/reschedule/cancel; waitlist; reminders | +| Success metrics | Conflict-free booking; idempotent lifecycle; tenant isolation | +| Non-goals | Telemedicine video, payment at booking, insurance eligibility | + +## Scope + +### In Scope + +- Aggregates: Schedule, AvailabilityWindow, AppointmentType, Appointment, WaitingListEntry +- Lifecycle: draft → confirmed → checked_in → completed / cancelled / no_show +- Commands: book, confirm, cancel, reschedule, mark_no_show +- List APIs with filter/sort/search by doctor, clinic, date range, status +- Permissions `healthcare.appointments.*`, `healthcare.schedules.*` +- Alembic `0002_phase_131_appointments`; version `0.13.1.0` + +### Modules + +| Module | Change type | Notes | +| --- | --- | --- | +| `healthcare.schedules` | new | Recurring/one-off schedules | +| `healthcare.availability` | new | Availability windows | +| `healthcare.appointment_types` | new | Visit type catalog | +| `healthcare.appointments` | new | Appointment engine | +| `healthcare.waiting_list` | new | Waitlist shells | + +### Events + +| Event | When | +| --- | --- | +| `healthcare.appointment.booked` | Appointment created | +| `healthcare.appointment.confirmed` | Confirmed | +| `healthcare.appointment.cancelled` | Cancelled | +| `healthcare.appointment.rescheduled` | Rescheduled | +| `healthcare.appointment.no_show` | Marked no-show | +| `healthcare.appointment.reminder_scheduled` | Reminder intent emitted | + +## Out of Scope + +- Doctor panel daily queue (13.2) +- Clinic admin staff roster (13.3) +- Patient portal routes (13.4) +- Medical record encounters (13.5) +- Pharmacy fulfillment (13.6) +- Delivery dispatch (13.7) + +## Completion Criteria + +- [ ] Appointment lifecycle APIs + tests green +- [ ] Conflict detection on overlapping slots +- [ ] Communication reminder contract invoked (mock in tests) +- [ ] Handover + registry at `0.13.1.0` + +## Related Documents + +- [Phase 13.0](phase-13-0-healthcare-foundation.md) +- [Healthcare Roadmap](../../healthcare-roadmap.md) +- [Module Registry](../../module-registry.md#healthcare) diff --git a/docs/phases/Healthcare/phase-13-2-doctor-panel.md b/docs/phases/Healthcare/phase-13-2-doctor-panel.md new file mode 100644 index 0000000..309dfe1 --- /dev/null +++ b/docs/phases/Healthcare/phase-13-2-doctor-panel.md @@ -0,0 +1,89 @@ +# Phase: Doctor Panel + +| Field | Value | +| --- | --- | +| Identifier | `healthcare-13-2` | +| Status | Planned | +| Owner | Platform | +| Module(s) | `healthcare.doctor_panel`, `healthcare.visit_sessions`, `healthcare.visit_notes` | +| Service(s) | `healthcare` | +| Depends On | `healthcare-13-0`, `healthcare-13-1` | +| ADR(s) | TBD | +| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) | +| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) | + +## Objective + +Provide doctor-facing operational APIs: daily schedule view, patient queue for the current session, visit session lifecycle, and visit note shells (metadata + Storage refs) — without clinic-wide admin, patient portal UI, or full medical record engine. + +## Enterprise Phase Discovery + +> **Required before Implementation.** + +| Field | Value | +| --- | --- | +| Discovery date | TBD at implementation | +| Inputs reviewed | Phase 13.1 handover · appointment engine APIs | + +### Target Responsibility (this phase when Complete) + +- Doctor dashboard queries (today's appointments, queue order) +- VisitSession aggregate linked to Appointment +- VisitNote shells with Storage file refs (no binary storage) +- Queue actions: call next, start visit, finish visit, defer +- Permissions `healthcare.doctor_panel.*`, `healthcare.visit_sessions.*` +- Events `healthcare.visit_session.*`, `healthcare.visit_note.*` + +### Explicitly Excluded + +- Clinic staff management (13.3) +- Patient self-service (13.4) +- Structured EHR / legal medical record (13.5) +- E-prescription to pharmacy (13.6) +- Frontend doctor UI (frontend phase) + +## Business Analysis + +| Item | Detail | +| --- | --- | +| Actors | Doctor, clinic nurse (optional delegate) | +| Business capabilities | View schedule; manage queue; document visit notes (shells) | +| Success metrics | Queue ordering consistent; visit tied to appointment | +| Non-goals | Billing codes, insurance claims, AI scribe | + +## Scope + +### In Scope + +- Aggregates: VisitSession, VisitNote, DoctorQueueEntry (or query-only view) +- APIs scoped to authenticated doctor context +- Link visit completion → appointment status `completed` +- Alembic `0003_phase_132_doctor_panel`; version `0.13.2.0` + +### Permissions + +| Permission | Description | +| --- | --- | +| `healthcare.doctor_panel.view` | Dashboard and queue read | +| `healthcare.visit_sessions.manage` | Start/finish/defer visits | +| `healthcare.visit_notes.manage` | Create/update note shells | + +## Out of Scope + +- Clinic management (13.3) +- Patient portal (13.4) +- Medical record legal archive (13.5) +- Pharmacy routing (13.6) +- Delivery (13.7) + +## Completion Criteria + +- [ ] Doctor-scoped APIs + tenant isolation tests +- [ ] Visit session lifecycle tied to appointments +- [ ] Handover + registry at `0.13.2.0` + +## Related Documents + +- [Phase 13.1](phase-13-1-appointment-engine.md) +- [Healthcare Roadmap](../../healthcare-roadmap.md) +- [Module Registry](../../module-registry.md#healthcare) diff --git a/docs/phases/Healthcare/phase-13-3-clinic-management.md b/docs/phases/Healthcare/phase-13-3-clinic-management.md new file mode 100644 index 0000000..be71241 --- /dev/null +++ b/docs/phases/Healthcare/phase-13-3-clinic-management.md @@ -0,0 +1,91 @@ +# Phase: Clinic Management + +| Field | Value | +| --- | --- | +| Identifier | `healthcare-13-3` | +| Status | Planned | +| Owner | Platform | +| Module(s) | `healthcare.clinic_services`, `healthcare.staff_assignments`, `healthcare.clinic_policies` | +| Service(s) | `healthcare` | +| Depends On | `healthcare-13-0` | +| ADR(s) | TBD | +| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) | +| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) | + +## Objective + +Enable clinic administrator operations: services catalog, staff-to-department assignments, operating hours policies, and clinic-level configuration — without platform tenant admin (Core), Accounting postings, or patient/doctor clinical workflows owned elsewhere. + +## Enterprise Phase Discovery + +> **Required before Implementation.** + +| Field | Value | +| --- | --- | +| Discovery date | TBD at implementation | +| Inputs reviewed | Phase 13.0 foundation · clinic admin actor analysis | + +### Target Responsibility (this phase when Complete) + +- ClinicService catalog (consultation types, durations, pricing refs — not Accounting invoices) +- StaffAssignment linking doctors/staff to departments and clinics +- ClinicPolicy aggregates (cancellation rules, booking lead time, privacy flags) +- Department hierarchy extensions +- Permissions `healthcare.clinic_admin.*`, `healthcare.clinic_services.*` +- Events `healthcare.clinic_service.*`, `healthcare.staff_assignment.*` + +### Explicitly Excluded + +- Core tenant/membership admin +- CRM sales pipelines +- Appointment slot engine internals (13.1 — consume only) +- Patient portal (13.4) +- Medical records (13.5) + +## Business Analysis + +| Item | Detail | +| --- | --- | +| Actors | Clinic administrator, platform admin (read-only cross-clinic) | +| Business capabilities | Manage services, staff roster, policies, hours | +| Success metrics | Admin APIs tenant-scoped; policies enforced by validators in later phases | +| Non-goals | Payroll, inventory procurement, marketing campaigns | + +## Scope + +### In Scope + +- Aggregates: ClinicService, StaffAssignment, ClinicPolicy, OperatingHoursPolicy +- CRUD + list with page/filter/sort/search +- Validators for policy consistency (e.g. cancellation window ≤ booking lead time) +- Alembic `0004_phase_133_clinic_management`; version `0.13.3.0` + +### Modules + +| Module | Change type | Notes | +| --- | --- | --- | +| `healthcare.clinic_services` | new | Service catalog | +| `healthcare.staff_assignments` | new | Staff ↔ department | +| `healthcare.clinic_policies` | new | Booking/cancellation policies | +| `healthcare.operating_hours` | extend | Clinic-level hours | + +## Out of Scope + +- Doctor panel queue (13.2) +- Patient portal (13.4) +- EHR (13.5) +- Pharmacy (13.6) +- Delivery (13.7) +- Accounting journal entries + +## Completion Criteria + +- [ ] Clinic admin APIs + permission tests +- [ ] Policy validators documented +- [ ] Handover + registry at `0.13.3.0` + +## Related Documents + +- [Phase 13.0](phase-13-0-healthcare-foundation.md) +- [Healthcare Roadmap](../../healthcare-roadmap.md) +- [Module Registry](../../module-registry.md#healthcare) diff --git a/docs/phases/Healthcare/phase-13-4-patient-portal.md b/docs/phases/Healthcare/phase-13-4-patient-portal.md new file mode 100644 index 0000000..6438adc --- /dev/null +++ b/docs/phases/Healthcare/phase-13-4-patient-portal.md @@ -0,0 +1,95 @@ +# Phase: Patient Portal + +| Field | Value | +| --- | --- | +| Identifier | `healthcare-13-4` | +| Status | Planned | +| Owner | Platform | +| Module(s) | `healthcare.patient_portal`, `healthcare.patient_documents` | +| Service(s) | `healthcare` | +| Depends On | `healthcare-13-0`, `healthcare-13-1` | +| ADR(s) | TBD | +| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) | +| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-019.md) · [ADR-019](../../architecture/adr/ADR-019.md) | + +## Objective + +Expose patient-facing self-service APIs: profile read/update (healthcare fields only), appointment list/book/cancel via appointment engine, document download refs, and notification preferences — without Identity admin, full medical record write path, or frontend UI in this phase. + +## Enterprise Phase Discovery + +> **Required before Implementation.** + +| Field | Value | +| --- | --- | +| Discovery date | TBD at implementation | +| Inputs reviewed | Phase 13.1 appointment APIs · Identity profile refs · Communication preferences | + +### Target Responsibility (this phase when Complete) + +- Patient-scoped API surface (authenticated patient context) +- Profile extensions: emergency contact refs, preferred clinic, language +- Appointment self-service: list upcoming/past, book (policy-aware), cancel/reschedule +- PatientDocumentRef listing (Storage refs; upload via Storage contract) +- NotificationPreference shells → Communication client +- Permissions `healthcare.patient_portal.*` +- Events `healthcare.patient_portal.*` + +### Explicitly Excluded + +- Identity credential/password admin +- Loyalty campaigns and point earn +- Medical record clinical edits by patient (13.5 read-only subset may be exposed later) +- Pharmacy checkout (13.6) +- Delivery tracking UI (13.7) +- Experience public site (Experience service) + +## Business Analysis + +| Item | Detail | +| --- | --- | +| Actors | Patient (authenticated end user) | +| Business capabilities | Self-service appointments; view shared documents; manage comms prefs | +| Success metrics | Patient can only access own tenant-scoped data | +| Non-goals | Guest booking without auth, family proxy accounts (future) | + +## Scope + +### In Scope + +- Aggregates: PatientPortalProfile (extends Patient), PatientDocumentRef, PatientNotificationPreference +- APIs under `/api/v1/patient-portal/*` or patient-scoped deps on existing routes +- Integration with appointment engine commands (no duplicate appointment logic) +- Alembic `0005_phase_134_patient_portal`; version `0.13.4.0` + +### APIs (planned) + +| Method | Path | Permission | Notes | +| --- | --- | --- | --- | +| GET | `/api/v1/patient-portal/me` | patient context | Profile | +| PATCH | `/api/v1/patient-portal/me` | patient context | Limited fields | +| GET | `/api/v1/patient-portal/appointments` | patient context | Own appointments | +| POST | `/api/v1/patient-portal/appointments` | patient context | Book via engine | +| POST | `/api/v1/patient-portal/appointments/{id}/cancel` | patient context | Cancel | +| GET | `/api/v1/patient-portal/documents` | patient context | Storage refs | + +## Out of Scope + +- Doctor panel (13.2) +- Clinic admin (13.3) +- Full EHR write (13.5) +- Pharmacy (13.6) +- Delivery (13.7) +- Frontend patient app + +## Completion Criteria + +- [ ] Patient isolation tests (cannot read other patients) +- [ ] Appointment self-service flows green +- [ ] Handover + registry at `0.13.4.0` + +## Related Documents + +- [Phase 13.1](phase-13-1-appointment-engine.md) +- [Healthcare Roadmap](../../healthcare-roadmap.md) +- [Module Registry](../../module-registry.md#healthcare) diff --git a/docs/phases/Healthcare/phase-13-5-medical-record.md b/docs/phases/Healthcare/phase-13-5-medical-record.md new file mode 100644 index 0000000..af3a113 --- /dev/null +++ b/docs/phases/Healthcare/phase-13-5-medical-record.md @@ -0,0 +1,90 @@ +# Phase: Medical Record + +| Field | Value | +| --- | --- | +| Identifier | `healthcare-13-5` | +| Status | Planned | +| Owner | Platform | +| Module(s) | `healthcare.medical_records`, `healthcare.encounters`, `healthcare.clinical_lists` | +| Service(s) | `healthcare` | +| Depends On | `healthcare-13-0`, `healthcare-13-2` | +| ADR(s) | TBD | +| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) | +| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) | + +## Objective + +Introduce structured medical record shells: MedicalRecord per patient, Encounter linked to VisitSession/Appointment, conditions/allergies/medications lists, immunization refs, and access-control policies — without national EHR exchange, mandatory HL7/FHIR compliance, or pharmacy dispensing. + +## Enterprise Phase Discovery + +> **Required before Implementation.** + +| Field | Value | +| --- | --- | +| Discovery date | TBD at implementation | +| Inputs reviewed | Phase 13.2 visit sessions · privacy/regulatory non-goals | + +### Target Responsibility (this phase when Complete) + +- MedicalRecord aggregate (one per patient per clinic or tenant policy) +- Encounter records with immutable finalized state +- Clinical list items: Condition, Allergy, MedicationStatement (shells) +- ImmunizationRecord refs +- MedicalRecordAccessPolicy (doctor role, department, break-glass audit) +- Append-only clinical audit for record access and amendments +- Permissions `healthcare.medical_records.*`, `healthcare.encounters.*` +- Events `healthcare.medical_record.*`, `healthcare.encounter.*` + +### Explicitly Excluded + +- Sports Center `medical_information` (sports clearance only) +- National health information exchange +- AI diagnosis / treatment recommendation +- Pharmacy inventory and dispensing (13.6) +- Binary imaging PACS (Storage refs only) + +## Business Analysis + +| Item | Detail | +| --- | --- | +| Actors | Doctor, authorized clinic staff, patient (read subset via 13.4 later) | +| Business capabilities | Document encounters; maintain problem/medication/allergy lists | +| Success metrics | Finalized encounters immutable; access logged | +| Non-goals | Legal e-signature archive, regulated prescription format | + +## Scope + +### In Scope + +- Aggregates: MedicalRecord, Encounter, Condition, Allergy, MedicationStatement, ImmunizationRecord, MedicalRecordAccessLog +- Lifecycle: encounter draft → in_progress → finalized (immutable) +- Amendment pattern for finalized records (addendum, not silent edit) +- Storage refs for attachments/lab results metadata +- Alembic `0006_phase_135_medical_record`; version `0.13.5.0` + +### Domain Model + +| Aggregate | Entities | Invariants | +| --- | --- | --- | +| MedicalRecord | Encounter, Condition, Allergy, MedicationStatement | Patient-scoped; access policy enforced | +| Encounter | — | Links VisitSession; finalized = immutable body | + +## Out of Scope + +- Pharmacy orders (13.6) +- Delivery of supplies (13.7) +- Accounting clinical billing codes +- Patient portal full record export (partial read may extend 13.4) + +## Completion Criteria + +- [ ] Encounter finalize immutability tests +- [ ] Access policy + audit tests +- [ ] Handover + registry at `0.13.5.0` + +## Related Documents + +- [Phase 13.2](phase-13-2-doctor-panel.md) +- [Healthcare Roadmap](../../healthcare-roadmap.md) +- [Module Registry](../../module-registry.md#healthcare) diff --git a/docs/phases/Healthcare/phase-13-6-pharmacy-network.md b/docs/phases/Healthcare/phase-13-6-pharmacy-network.md new file mode 100644 index 0000000..c9830a9 --- /dev/null +++ b/docs/phases/Healthcare/phase-13-6-pharmacy-network.md @@ -0,0 +1,87 @@ +# Phase: Pharmacy Network + +| Field | Value | +| --- | --- | +| Identifier | `healthcare-13-6` | +| Status | Planned | +| Owner | Platform | +| Module(s) | `healthcare.pharmacies`, `healthcare.prescriptions`, `healthcare.prescription_orders` | +| Service(s) | `healthcare` | +| Depends On | `healthcare-13-0`, `healthcare-13-5` | +| ADR(s) | TBD | +| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) | +| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-019.md) · [ADR-019](../../architecture/adr/ADR-019.md) | + +## Objective + +Build a pharmacy partner network within Healthcare: pharmacy registry, prescription orders routed from medical encounters, fulfillment status lifecycle, and events for downstream consumers — without drug inventory accounting, payment capture, or Delivery dispatch (Phase 13.7). + +## Enterprise Phase Discovery + +> **Required before Implementation.** + +| Field | Value | +| --- | --- | +| Discovery date | TBD at implementation | +| Inputs reviewed | Phase 13.5 encounters · pharmacy actor workflows | + +### Target Responsibility (this phase when Complete) + +- Pharmacy aggregate (partner registry, contact, hours, service area refs) +- PrescriptionOrder + PrescriptionLine linked to Encounter/VisitSession +- Fulfillment lifecycle: submitted → accepted → preparing → ready → picked_up / cancelled +- Pharmacy staff permissions separate from clinic doctor permissions +- Events `healthcare.pharmacy.*`, `healthcare.prescription_order.*` +- Communication notifications on status changes (client only) + +### Explicitly Excluded + +- Drug wholesale inventory and Accounting COGS +- Payment gateway / POS +- Delivery driver assignment (13.7) +- CRM marketing to pharmacies +- Regulated e-prescription national gateway (future ADR) + +## Business Analysis + +| Item | Detail | +| --- | --- | +| Actors | Doctor (prescribe intent), pharmacy staff, patient (status view via 13.4) | +| Business capabilities | Route prescriptions to network pharmacies; track fulfillment | +| Success metrics | Order state machine enforced; tenant + pharmacy isolation | +| Non-goals | Controlled substance scheduling compliance (future) | + +## Scope + +### In Scope + +- Aggregates: Pharmacy, PrescriptionOrder, PrescriptionLine, PrescriptionStatusHistory +- APIs for clinic submit and pharmacy accept/prepare/complete +- Validators: lines reference medication codes/shells from medical record +- Alembic `0007_phase_136_pharmacy_network`; version `0.13.6.0` + +### Permissions + +| Permission | Description | +| --- | --- | +| `healthcare.prescriptions.submit` | Clinic submits order | +| `healthcare.pharmacies.manage` | Admin registry | +| `healthcare.prescription_orders.fulfill` | Pharmacy staff updates status | + +## Out of Scope + +- Delivery integration (13.7) +- Accounting invoices +- Marketplace vendor model + +## Completion Criteria + +- [ ] Prescription lifecycle tests +- [ ] Pharmacy tenant isolation (partner sees only assigned orders) +- [ ] Handover + registry at `0.13.6.0` + +## Related Documents + +- [Phase 13.5](phase-13-5-medical-record.md) +- [Healthcare Roadmap](../../healthcare-roadmap.md) +- [Module Registry](../../module-registry.md#healthcare) diff --git a/docs/phases/Healthcare/phase-13-7-delivery-integration.md b/docs/phases/Healthcare/phase-13-7-delivery-integration.md new file mode 100644 index 0000000..28cac60 --- /dev/null +++ b/docs/phases/Healthcare/phase-13-7-delivery-integration.md @@ -0,0 +1,110 @@ +# Phase: Delivery Integration + +| Field | Value | +| --- | --- | +| Identifier | `healthcare-13-7` | +| Status | Planned | +| Owner | Platform | +| Module(s) | `healthcare.delivery_integration`, `healthcare.delivery_job_intents` | +| Service(s) | `healthcare` | +| Depends On | `healthcare-13-6`, Delivery platform (`delivery-10.0+`) | +| ADR(s) | TBD; aligns with [ADR-015](../../architecture/adr/ADR-015.md) | +| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) | +| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) | + +## Objective + +Integrate prescription and medical-supply home delivery via Delivery platform contracts: register delivery integration, emit delivery job intents from ready prescription orders, consume status events/webhooks, and notify patients — without owning drivers, fleet, dispatch, or routing (Delivery service domain). + +## Enterprise Phase Discovery + +> **Required before Implementation.** + +| Field | Value | +| --- | --- | +| Discovery date | TBD at implementation | +| Inputs reviewed | Phase 13.6 prescription ready state · [ADR-015](../../architecture/adr/ADR-015.md) · Delivery merchant connector phases | + +### Target Responsibility (this phase when Complete) + +- DeliveryIntegrationRegistration per tenant/clinic/pharmacy +- DeliveryJobIntent aggregate referencing PrescriptionOrder (UUID ref, no cross-DB FK) +- Submit intent → Delivery API contract (mock in tests until Delivery 10.9+) +- Inbound status sync: queued → picked_up → in_transit → delivered / failed +- Patient notification via Communication on milestone statuses +- Permissions `healthcare.delivery_integration.*` +- Events `healthcare.delivery_job_intent.*` + +### Explicitly Excluded + +- Driver/fleet/dispatch/routing aggregates (Delivery) +- Proof-of-delivery binary storage (Delivery + Storage) +- Restaurant/Hospitality order types +- Duplicate tracking timeline (subscribe to Delivery events only) + +### Boundary Confirmations + +- [x] Healthcare does not own logistics domain +- [x] No cross-database foreign keys to `delivery_db` +- [x] Job intake follows Delivery merchant connector pattern + +## Business Analysis + +| Item | Detail | +| --- | --- | +| Actors | Pharmacy staff, patient, delivery dispatcher (Delivery UI) | +| Business capabilities | Request delivery for ready prescriptions; surface status to patient | +| Success metrics | Idempotent job creation; status mirrors Delivery without duplication | +| Non-goals | Cold-chain IoT, route optimization, driver payroll | + +## Scope + +### In Scope + +- Aggregates: DeliveryIntegrationRegistration, DeliveryJobIntent, DeliveryJobStatusSnapshot +- Commands: create_intent, cancel_intent (before pickup) +- Webhook/event handler stubs for Delivery status updates +- Alembic `0008_phase_137_delivery_integration`; version `0.13.7.0` +- Capability flag `delivery_integration` + +### Providers + +| Interface | Owning service | Notes | +| --- | --- | --- | +| DeliveryProvider | Delivery | Job submit + status read | +| CommunicationProvider | Communication | Patient SMS/push on status | + +### APIs (planned) + +| Method | Path | Permission | Notes | +| --- | --- | --- | --- | +| POST | `/api/v1/delivery-integrations` | `healthcare.delivery_integration.manage` | Register | +| POST | `/api/v1/prescription-orders/{id}/delivery-intents` | `healthcare.delivery_integration.submit` | Create job intent | +| GET | `/api/v1/delivery-job-intents` | `healthcare.delivery_integration.view` | List/filter | +| POST | `/api/v1/webhooks/delivery-status` | signed webhook | Status ingest | + +## Out of Scope + +- Implementing Delivery dispatch engine +- Driver mobile app +- Accounting delivery fees posting (future Accounting integration) +- Frontend tracking map + +## Completion Criteria + +- [ ] Job intent idempotency tests +- [ ] Status sync from mocked Delivery provider +- [ ] Boundary tests (no delivery model imports) +- [ ] Handover + registry at `0.13.7.0` + +## Architecture Impact + +- Confirms Healthcare ↔ Delivery integration boundary in module-boundaries.md at implementation + +## Related Documents + +- [Phase 13.6](phase-13-6-pharmacy-network.md) +- [Delivery Roadmap](../../delivery-roadmap.md) +- [ADR-015](../../architecture/adr/ADR-015.md) +- [Healthcare Roadmap](../../healthcare-roadmap.md) +- [Module Registry](../../module-registry.md#healthcare) diff --git a/docs/phases/Hospitality/README.md b/docs/phases/Hospitality/README.md new file mode 100644 index 0000000..fe25ebf --- /dev/null +++ b/docs/phases/Hospitality/README.md @@ -0,0 +1,29 @@ +# Phase Area: Hospitality (Torbat Food) + +| Field | Value | +| --- | --- | +| Status | Active (Phase 12.3 Table Service & Reservations) | +| Module(s) | hospitality | +| Commercial Product | Torbat Food | +| Depends On | Core, AI Framework | +| Template | [phase-template.md](../../ai-framework/phase-template.md) | +| ADR | [ADR-017](../../architecture/adr/ADR-017.md) | + +## Goal + +Enterprise Hospitality Platform for Cafe, Coffee Shop, Restaurant, Fast Food, Bakery, Pastry, Ice Cream, Juice Bar, Cloud Kitchen, Food Court, Catering, and Take Away — standalone or inside TorbatYar SuperApp. + +## Current Phase + +[Phase 12.3 — Table Service & Reservations](../../hospitality-phase-12-3.md) (complete). Next: 12.4 POS Lite. + +## Related Documents + +- [Hospitality Roadmap](../../hospitality-roadmap.md) +- [Phase 12.0](../../hospitality-phase-12-0.md) +- [Phase 12.1](../../hospitality-phase-12-1.md) +- [Phase 12.2](../../hospitality-phase-12-2.md) +- [Phase 12.3](../../hospitality-phase-12-3.md) +- [Module Registry — hospitality](../../module-registry.md#hospitality) +- [Service README](../../../backend/services/hospitality/README.md) +- Historical Restaurant area: [Restaurant README](../Restaurant/README.md) diff --git a/docs/phases/Restaurant/README.md b/docs/phases/Restaurant/README.md index 7929018..bda21d9 100644 --- a/docs/phases/Restaurant/README.md +++ b/docs/phases/Restaurant/README.md @@ -2,18 +2,20 @@ | Field | Value | | --- | --- | -| Status | Planned (candidate first business module) | -| Module(s) | restaurant | -| Depends On | Core, white-label public tenant site | -| Template | [phase-template.md](../../templates/phase-template.md) | +| Status | Evolved into Hospitality Platform (Phase 12.0) | +| Module(s) | hospitality (was restaurant scaffold) | +| Commercial Product | Torbat Food | +| Depends On | Core, AI Framework | +| Canonical docs | [Hospitality](../Hospitality/README.md) | ## Goal -Digital menu and cafe/restaurant operations on tenant domains with optional guest auth local to `restaurant_db`. +Digital menu and cafe/restaurant operations — now delivered as the broader **Hospitality Platform** covering Cafe through Catering. ## Related Documents -- [Module Registry — restaurant](../../module-registry.md#restaurant) -- [Product decision](../../decisions/product/first-business-module.md) -- [Next Steps](../../next-steps.md) -- [Service README](../../../backend/services/restaurant/README.md) +- [Hospitality Phase 12.0](../../hospitality-phase-12-0.md) +- [Hospitality Roadmap](../../hospitality-roadmap.md) +- [Module Registry — hospitality](../../module-registry.md#hospitality) +- [ADR-017](../../architecture/adr/ADR-017.md) +- [Service README](../../../backend/services/hospitality/README.md) diff --git a/docs/phases/SportsCenter/README.md b/docs/phases/SportsCenter/README.md index 3d323cf..a7d00b8 100644 --- a/docs/phases/SportsCenter/README.md +++ b/docs/phases/SportsCenter/README.md @@ -9,12 +9,12 @@ Independent enterprise Sports Center Platform (`sports-center-service`, `sports_ | 9.0 | Platform Foundation | Complete | | 9.1 | Membership Catalog | Complete | | 9.2 | Member Management | Complete | -| 9.3 | Coach & Staff Management | Next | -| 9.4 | Scheduling & Booking | Planned | -| 9.5 | Attendance & Access Control | Planned | -| 9.6 | Training Management | Planned | -| 9.7 | Competition & Event Management | Planned | -| 9.8 | Financial Integration | Planned | +| 9.3 | Coach & Staff Management | Complete | +| 9.4 | Scheduling & Booking | Complete | +| 9.5 | Attendance & Access Control | Complete | +| 9.6 | Training Management | Complete | +| 9.7 | Competition & Event Management | Complete | +| 9.8 | Financial Integration | Next | | 9.9 | AI & Analytics | Planned | | 9.10 | Enterprise Validation | Planned | @@ -24,9 +24,14 @@ Independent enterprise Sports Center Platform (`sports-center-service`, `sports_ - [Phase 9.0](../../sports-center-phase-9-0.md) - [Phase 9.1](../../sports-center-phase-9-1.md) - [Phase 9.2](../../sports-center-phase-9-2.md) -- [Handover 9.0](../../phase-handover/phase-9-0.md) -- [Handover 9.1](../../phase-handover/phase-9-1.md) +- [Phase 9.3](../../sports-center-phase-9-3.md) +- [Phase 9.4](../../sports-center-phase-9-4.md) +- [Phase 9.5](../../sports-center-phase-9-5.md) +- [Phase 9.6](../../sports-center-phase-9-6.md) +- [Phase 9.7](../../sports-center-phase-9-7.md) - [Handover 9.2](../../phase-handover/phase-9-2.md) +- [Handover 9.6](../../phase-handover/phase-9-6.md) +- [Handover 9.7](../../phase-handover/phase-9-7.md) - [ADR-014](../../architecture/adr/ADR-014.md) - [AI Framework](../../ai-framework/README.md) diff --git a/docs/provider-registry.md b/docs/provider-registry.md index 2134b0f..1cfacea 100644 --- a/docs/provider-registry.md +++ b/docs/provider-registry.md @@ -85,7 +85,7 @@ Inventory of external providers. Template: [provider-template.md](templates/prov | Capabilities | Object upload/download, tenant-scoped buckets/prefixes | | Version | TBD | | Dependencies | File Storage module | -| Supported Modules | file_storage, website_builder, ecommerce media | +| Supported Modules | file_storage, experience (media refs), website_builder (historical), ecommerce media | | Configuration | Endpoint, keys, bucket (TBD) | | API | S3 API | | Status | Planned | @@ -130,6 +130,42 @@ Inventory of external providers. Template: [provider-template.md](templates/prov --- + +## Delivery Routing Engine (planned) + +| Field | Value | +| --- | --- | +| Provider Name | TBD (pluggable routing engines) | +| Country | TBD | +| Capabilities | Route planning, ETA, multi pickup/drop optimization | +| Version | TBD | +| Dependencies | Delivery Platform adapters | +| Supported Modules | delivery | +| Configuration | Tenant provider_configs / env per adapter | +| API | Provider HTTP/SDK behind Delivery protocols | +| Status | Planned | +| Documentation | [delivery-roadmap.md](delivery-roadmap.md), [ADR-015](architecture/adr/ADR-015.md) | +| Tests | TBD (from Phase 10.6) | + +--- + +## External Fleet Provider (planned) + +| Field | Value | +| --- | --- | +| Provider Name | TBD | +| Country | TBD | +| Capabilities | External courier/fleet fulfillment | +| Version | TBD | +| Dependencies | Delivery Platform adapters | +| Supported Modules | delivery | +| Configuration | Tenant credentials in Delivery only | +| API | Provider HTTP behind Delivery protocols | +| Status | Planned | +| Documentation | [delivery-roadmap.md](delivery-roadmap.md), [ADR-015](architecture/adr/ADR-015.md) | +| Tests | TBD | + +--- ## Related Documents - [Integration Architecture](architecture/integration-architecture.md) diff --git a/docs/reference/database-schema.md b/docs/reference/database-schema.md index 3d5807b..90401b6 100644 --- a/docs/reference/database-schema.md +++ b/docs/reference/database-schema.md @@ -1,291 +1,309 @@ -# طرح دیتابیس (Database Schema) - -> Reference only. Architecture rules → [database-architecture.md](../architecture/database-architecture.md). -> Dual memberships → [ADR-007](../architecture/adr/ADR-007.md) · [Glossary](../glossary.md). - -## بخش ۱ — دیتابیس Core Platform (`core_platform_db`) — فاز ۱ - -همه مدل‌ها با SQLAlchemy 2.x و Alembic پیاده شده‌اند. شناسه‌ها از نوع UUID و -زمان‌ها timezone-aware هستند. - -### جدول `tenants` -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| name | varchar(255) | نام مستأجر (`business_name` در onboarding) | -| slug | varchar(100) unique | شناسه یکتا برای resolve | -| status | enum | `draft` / `pending_activation` / `active` / `suspended` / `archived` (+ مقادیر قدیمی `inactive`/`deleted` برای سازگاری) | -| owner_user_id | UUID null | مالک (از Identity Service) | -| business_type | varchar(100) null | نوع کسب‌وکار (فاز ۴ — onboarding) | -| default_locale | varchar(20) | پیش‌فرض `fa-IR` | -| timezone | varchar(50) | پیش‌فرض `Asia/Tehran` | -| primary_color | varchar(20) null | برندینگ | -| secondary_color | varchar(20) null | برندینگ | -| logo_url | varchar(500) null | برندینگ | -| favicon_url | varchar(500) null | برندینگ | -| onboarding_completed | bool | پیش‌فرض `false`؛ با `POST .../complete` به `true` تغییر می‌کند | -| created_at, updated_at | timestamptz | | - -ایندکس‌ها: `slug` (unique)، `status`. - -> فیلدهای `business_type` تا `onboarding_completed` در migration `0005_tenant_onboarding` -> اضافه شده‌اند (فاز ۴ — Tenant Onboarding). - -### جدول `domains` -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| tenant_id | UUID (FK→tenants) | | -| domain | varchar(255) unique | | -| domain_type | enum | subdomain / custom_domain | -| is_verified | bool | | -| verified_at | timestamptz null | | -| is_primary | bool | دامنهٔ اصلی/پیش‌فرض tenant (زیردامنهٔ خودکار onboarding) — فاز ۴ | -| verification_status | enum | `pending` / `verified` / `failed` — فاز ۴ | -| created_at | timestamptz | | - -ایندکس‌ها: `domain` (unique)، `tenant_id`. - -### جدول `plans` -id, code(unique), name, description, is_active, created_at. ایندکس: `code`. - -### جدول `features` -id, feature_key(unique), name, description, service_key, is_active. -ایندکس‌ها: `feature_key` (unique)، `service_key`. -مثال feature_key: `accounting.invoice.create`، `crm.lead.create`، -`ecommerce.product.create`، `live_chat.widget.enable`، `ai_assistant.chat.reply`. - -### جدول `plan_features` -id, plan_id(FK), feature_id(FK), limit_value(null=نامحدود), -limit_period(enum: day/month/year/total), is_enabled. -محدودیت یکتا: (plan_id, feature_id). ایندکس‌ها روی هر دو کلید خارجی. - -### جدول `tenant_subscriptions` -id, tenant_id(FK), plan_id(FK), status(enum: trialing/active/past_due/ -canceled/expired), starts_at, ends_at, trial_ends_at, created_at. -ایندکس‌ها: `tenant_id`، `status`. - -### جدول `tenant_feature_access` -override سفارشی دسترسی: id, tenant_id(FK), feature_id(FK), is_enabled, -custom_limit_value(null=نامحدود), used_value, reset_at. -محدودیت یکتا: (tenant_id, feature_id). ایندکس‌ها روی هر دو کلید خارجی. - -### جدول `service_registry` -id, service_key(unique), name, base_url, status(enum), health_check_url, -created_at. ایندکس: `service_key`. - -### جدول `module_registry` -id, module_key(unique), name, description, is_system_module, is_active. -ایندکس: `module_key`. - -### جدول `tenant_module_access` -id, tenant_id(FK), module_id(FK), is_enabled, enabled_at, disabled_at. -محدودیت یکتا: (tenant_id, module_id). ایندکس: `tenant_id`. - -### جدول `internal_service_tokens` -id, service_key, token_hash(unique), scopes, expires_at, is_active, created_at. -فقط hash توکن ذخیره می‌شود. ایندکس‌ها: `service_key`، `token_hash`. - -### جدول `outbox_events` -id, event_type, aggregate_type, aggregate_id, tenant_id, payload(JSON), -status(enum: pending/processed/failed), retry_count, created_at, processed_at. -ایندکس‌ها: `status`، `tenant_id`. - -### جدول `inbox_events` -id, event_id(unique), source_service, event_type, tenant_id, payload(JSON), -processed_at. ایندکس‌ها: `event_id`، `tenant_id`. - -### جدول `audit_logs` -id, tenant_id, actor_user_id, action, resource_type, resource_id, -metadata(JSON), ip_address, user_agent, created_at. -ایندکس‌ها: `tenant_id`، `created_at`. - -### جدول `users` (OTP + لینک SSO — فاز ۳) -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| mobile | varchar(15) unique | شماره موبایل (09xxxxxxxxx) — **الزامی** | -| keycloak_sub | varchar(100) null unique | لینک به کاربر Keycloak (SSO) | -| email | varchar(255) null | ایمیل (اختیاری، برای لینک SSO) | -| mobile_verified | bool | تأیید OTP — true پس از verify موفق | -| role | enum | user / pending_tenant_admin / tenant_admin / platform_admin | -| status | enum | active / inactive / suspended | -| current_tenant_id | UUID null (FK→tenants, `ON DELETE SET NULL`) | tenant انتخاب‌شده جاری کاربر — فاز ۴ | -| created_at, updated_at | timestamptz | | - -ایندکس‌ها: `mobile` (unique)، `keycloak_sub` (unique)، `status`. - -**نقش‌ها بر اساس context OTP:** -- `public` → `user` (ثبت‌نام عمومی) -- `admin` → `pending_tenant_admin` (پنل ادمین tenant) -- `PLATFORM_ADMIN_MOBILES` → `platform_admin` - -### جدول `tenant_memberships` (فاز ۴ — Tenant Onboarding) - -> **توجه:** این جدول در `core_platform_db` است و با جدول هم‌نام در -> `identity_access_db` (بخش ۳ همین سند) **کاملاً متفاوت و بدون ارتباط -> مستقیم** است. جدول Core عضویت واقعی کاربر↔workspace را برای جریان -> onboarding/authorization مدیریت می‌کند. - -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| tenant_id | UUID (FK→tenants, cascade) | | -| user_id | UUID (FK→users, cascade) | | -| role | enum | `platform_admin` / `tenant_owner` / `tenant_admin` / `tenant_editor` / `tenant_viewer` | -| status | enum | `active` / `invited` / `disabled` | -| is_owner | bool | فقط یک یا چند owner فعال در هر tenant (حداقل یکی الزامی) | -| created_at, updated_at | timestamptz | | - -محدودیت یکتا: `(tenant_id, user_id)`. ایندکس‌ها: `tenant_id`، `user_id`، `role`، `status`. - -**قواعد:** -- هنگام `POST /onboarding/tenant`، کاربر سازنده به‌صورت خودکار عضویت - `tenant_owner` / `is_owner=true` / `status=active` می‌گیرد. -- `POST /onboarding/tenant/{id}/complete` بررسی می‌کند که حداقل یک owner - فعال وجود داشته باشد؛ در غیر این صورت خطای `owner_required`. -- کاربر می‌تواند در آینده چند عضویت (چند tenant) داشته باشد؛ `users.current_tenant_id` - مشخص می‌کند کدام‌یک «جاری» است. - -### جدول `plans` و `tenant_subscriptions` (seed پیش‌فرض — فاز ۴) -ساختار جدول‌ها از فاز ۱ بدون تغییر باقی مانده (نگاه کنید به پایین همین -بخش)؛ در فاز ۴ موارد زیر اضافه شد: -- Migration `0005_tenant_onboarding` دو پلن `FREE` و `STARTER` را با - `ON CONFLICT (code) DO NOTHING` seed می‌کند (idempotent). -- `PlanService.ensure_default_plan()` همان پلن `FREE` را در زمان اجرا نیز - به‌صورت idempotent تضمین می‌کند (برای محیط‌های SQLite تست که migration - اجرا نمی‌شود). -- `POST /onboarding/tenant` به‌صورت خودکار یک `tenant_subscriptions` با - `plan=FREE`، `status=active` می‌سازد. - -## بخش ۴ — دیتابیس Accounting (`accounting_db`) — فاز ۵.۱ تا ۵.۶ - -Migration: `0001_initial` در `backend/services/accounting/alembic/versions/`. - -### Foundation (5.1) -`chart_of_accounts`, `accounts`, `currencies`, `exchange_rates`, `fiscal_years`, -`fiscal_periods`, `cost_centers`, `projects`, `dimensions`, `accounting_settings`, -`document_number_sequences`, `tenant_accounting_configurations` - -### Posting (5.2) -`vouchers`, `voucher_lines`, `journals`, `journal_entries`, `posting_logs`, -`posting_errors`, `posting_references`, `accounting_audit_logs` - -### Ledger (5.3) -`general_ledgers`, `ledger_balances`, `trial_balance_snapshots`, `balance_snapshots`, -`closing_entries`, `opening_entries`, `accounting_calendars` - -### Treasury (5.4) -`cash_boxes`, `cash_transactions`, `banks`, `bank_accounts`, `bank_transactions`, -`cheque_books`, `cheques`, `transfers`, `receipt_vouchers`, `payment_vouchers`, -`reconciliations`, `treasury_settings` - -### AR/AP (5.5) -`customer_accounts`, `supplier_accounts`, `receivable_invoices`, `payable_invoices`, -`settlements`, `settlement_allocations`, `credit_notes`, `debit_notes`, -`advance_payments`, `advance_receipts`, `customer_statements`, `supplier_statements`, -`aging_snapshots` - -### Sales Accounting (5.6) -`sales_posting_profiles`, `accounting_profiles`, `posting_rules`, -`sales_accounting_configurations`, `revenue_recognitions`, `revenue_schedules`, -`accounting_previews`, `posting_simulations` - -### Purchase/Inventory (5.7) -`purchase_posting_profiles`, `inventory_posting_profiles`, `inventory_valuation_methods`, -`inventory_valuation_history`, `inventory_cost_adjustments`, `purchase_accounting_configurations`, -`inventory_accounting_configurations`, `inventory_snapshots` - -### Fixed Assets (5.8) -`asset_categories`, `asset_groups`, `assets`, `asset_depreciations`, `depreciation_schedules`, -`asset_transfers`, `asset_disposals`, `asset_revaluations`, `asset_impairments`, -`asset_accounting_profiles`, `asset_history`, `asset_locations` - -### Payroll (5.9) -`departments`, `positions`, `employees`, `employment_contracts`, `salary_components`, -`payroll_periods`, `payrolls`, `payroll_items`, `benefits`, `allowances`, `deductions`, -`employee_loans`, `employee_advances`, `payroll_accounting_profiles`, `payroll_history`, -`payroll_settlements` - -### Reporting (5.10) -`financial_reports`, `report_templates`, `dashboards`, `dashboard_widgets`, `kpis`, -`report_snapshots`, `scheduled_reports`, `report_exports`, `report_history`, `report_configurations` - -### Compliance (5.11) -`audit_records`, `audit_history`, `compliance_policies`, `governance_rules`, -`approval_workflows`, `approval_steps`, `approval_requests`, `delegations`, -`risk_records`, `policy_violations`, `evidence`, `retention_policies`, `control_definitions` - -همه جداول دارای `tenant_id` (UUID، بدون FK بین DB). - ---- - -## بخش ۲ — طراحی اولیه دیتابیس سرویس‌های آینده (بدون migration) - -> accounting_db اکنون پیاده‌سازی شده — جزئیات در بخش ۴ بالا. -> crm_db پیاده‌سازی شده — Phases 6.0–6.3. -> loyalty_db پیاده‌سازی شده — Phase 7.0 foundation: -> `loyalty_programs`, `membership_tiers`, `members`, `point_accounts`, -> `rewards`, `campaigns`, `loyalty_audit_logs`. -> communication_db پیاده‌سازی شده — Phase 8.0–8.10: -> `provider_configs`, `sender_numbers`, `message_templates`, `manual_contacts`, -> `contact_sources`, `messages`, `queue_items`, `delivery_events`, `provider_logs`, -> `otp_challenges`, `webhook_receipts`, `communication_audit_logs`. -- **ecommerce_db:** products، categories، inventory، orders، order_items، - payments، shipments، discounts، carts. -- **website_builder_db:** sites، pages، blocks، forms، menus، media، content. -- **live_chat_db:** widgets، conversations، messages، agents، routing_rules. -- **ai_assistant_db:** assistants، knowledge_bases، documents، chat_sessions، - messages، handoffs. -- **smart_messenger_db:** channels، accounts، threads، messages، automations. -- **sms_panel_db:** providers، templates، campaigns، messages، send_queue. -- **link_shortener_db:** links، domains، clicks، campaigns. -- **notification_db:** notifications، channels، templates، delivery_logs. -- **file_storage_db:** files، buckets، access_policies. -- **restaurant_db:** menus، menu_items، tables، orders، kitchen_tickets، - payments (loyalty via `loyalty_db` / Loyalty service). - -هر سرویس دسترسی قابلیت‌ها را از Core (`check_feature_access`) استعلام می‌کند و -هرگز مستقیماً به `core_platform_db` متصل نمی‌شود. - -## بخش ۳ — دیتابیس Identity & Access (`identity_access_db`) — فاز ۲ - -### جدول `user_profiles` -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| keycloak_sub | varchar unique | شناسه کاربر در Keycloak | -| email | varchar | | -| username | varchar null | | -| display_name | varchar null | | -| mobile | varchar(15) null unique | شماره موبایل — **الزامی برای کاربران جدید** | -| mobile_verified | bool | تأیید OTP | -| mobile_verified_at | timestamptz null | زمان تأیید | -| core_user_id | UUID null | لینک به `core_platform_db.users` | -| status | enum | active / inactive / suspended | -| created_at, updated_at | timestamptz | | - -ایندکس‌ها: `keycloak_sub` (unique)، `email`، `mobile` (unique). - -**قانون:** کاربر تازه‌ثبت‌نام‌شده تا پیش از `mobile_verified=true` کامل محسوب نمی‌شود. -کاربران SSO قدیمی بدون موبایل باید از `/auth/verify-mobile` تکمیل کنند. - -### جدول `tenant_memberships` - -> **توجه:** این جدول مربوط به `identity_access_db` (لایهٔ هویت SSO) است و -> **جدول متفاوتی** نسبت به `tenant_memberships` در `core_platform_db` است که -> در بخش ۱ (فاز ۴ — Tenant Onboarding) مستند شده. این دو جدول در دو سرویس/ -> دیتابیس جدا زندگی می‌کنند و sync خودکار بین آن‌ها وجود ندارد؛ منبع حقیقت -> (source of truth) برای نقش عملیاتی کاربر در یک workspace، جدول Core است. - -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| tenant_id | UUID | مرجع به tenant در Core (بدون FK بین DB) | -| user_id | UUID (FK→user_profiles) | | -| role | enum | tenant_admin / tenant_member | -| is_active | bool | | -| joined_at | timestamptz | | - -محدودیت یکتا: (tenant_id, user_id). ایندکس‌ها: `tenant_id`, `user_id`. - +# طرح دیتابیس (Database Schema) + +> Reference only. Architecture rules → [database-architecture.md](../architecture/database-architecture.md). +> Dual memberships → [ADR-007](../architecture/adr/ADR-007.md) · [Glossary](../glossary.md). + +## بخش ۱ — دیتابیس Core Platform (`core_platform_db`) — فاز ۱ + +همه مدل‌ها با SQLAlchemy 2.x و Alembic پیاده شده‌اند. شناسه‌ها از نوع UUID و +زمان‌ها timezone-aware هستند. + +### جدول `tenants` +| ستون | نوع | توضیح | +| --- | --- | --- | +| id | UUID (PK) | | +| name | varchar(255) | نام مستأجر (`business_name` در onboarding) | +| slug | varchar(100) unique | شناسه یکتا برای resolve | +| status | enum | `draft` / `pending_activation` / `active` / `suspended` / `archived` (+ مقادیر قدیمی `inactive`/`deleted` برای سازگاری) | +| owner_user_id | UUID null | مالک (از Identity Service) | +| business_type | varchar(100) null | نوع کسب‌وکار (فاز ۴ — onboarding) | +| default_locale | varchar(20) | پیش‌فرض `fa-IR` | +| timezone | varchar(50) | پیش‌فرض `Asia/Tehran` | +| primary_color | varchar(20) null | برندینگ | +| secondary_color | varchar(20) null | برندینگ | +| logo_url | varchar(500) null | برندینگ | +| favicon_url | varchar(500) null | برندینگ | +| onboarding_completed | bool | پیش‌فرض `false`؛ با `POST .../complete` به `true` تغییر می‌کند | +| created_at, updated_at | timestamptz | | + +ایندکس‌ها: `slug` (unique)، `status`. + +> فیلدهای `business_type` تا `onboarding_completed` در migration `0005_tenant_onboarding` +> اضافه شده‌اند (فاز ۴ — Tenant Onboarding). + +### جدول `domains` +| ستون | نوع | توضیح | +| --- | --- | --- | +| id | UUID (PK) | | +| tenant_id | UUID (FK→tenants) | | +| domain | varchar(255) unique | | +| domain_type | enum | subdomain / custom_domain | +| is_verified | bool | | +| verified_at | timestamptz null | | +| is_primary | bool | دامنهٔ اصلی/پیش‌فرض tenant (زیردامنهٔ خودکار onboarding) — فاز ۴ | +| verification_status | enum | `pending` / `verified` / `failed` — فاز ۴ | +| created_at | timestamptz | | + +ایندکس‌ها: `domain` (unique)، `tenant_id`. + +### جدول `plans` +id, code(unique), name, description, is_active, created_at. ایندکس: `code`. + +### جدول `features` +id, feature_key(unique), name, description, service_key, is_active. +ایندکس‌ها: `feature_key` (unique)، `service_key`. +مثال feature_key: `accounting.invoice.create`، `crm.lead.create`، +`ecommerce.product.create`، `live_chat.widget.enable`، `ai_assistant.chat.reply`. + +### جدول `plan_features` +id, plan_id(FK), feature_id(FK), limit_value(null=نامحدود), +limit_period(enum: day/month/year/total), is_enabled. +محدودیت یکتا: (plan_id, feature_id). ایندکس‌ها روی هر دو کلید خارجی. + +### جدول `tenant_subscriptions` +id, tenant_id(FK), plan_id(FK), status(enum: trialing/active/past_due/ +canceled/expired), starts_at, ends_at, trial_ends_at, created_at. +ایندکس‌ها: `tenant_id`، `status`. + +### جدول `tenant_feature_access` +override سفارشی دسترسی: id, tenant_id(FK), feature_id(FK), is_enabled, +custom_limit_value(null=نامحدود), used_value, reset_at. +محدودیت یکتا: (tenant_id, feature_id). ایندکس‌ها روی هر دو کلید خارجی. + +### جدول `service_registry` +id, service_key(unique), name, base_url, status(enum), health_check_url, +created_at. ایندکس: `service_key`. + +### جدول `module_registry` +id, module_key(unique), name, description, is_system_module, is_active. +ایندکس: `module_key`. + +### جدول `tenant_module_access` +id, tenant_id(FK), module_id(FK), is_enabled, enabled_at, disabled_at. +محدودیت یکتا: (tenant_id, module_id). ایندکس: `tenant_id`. + +### جدول `internal_service_tokens` +id, service_key, token_hash(unique), scopes, expires_at, is_active, created_at. +فقط hash توکن ذخیره می‌شود. ایندکس‌ها: `service_key`، `token_hash`. + +### جدول `outbox_events` +id, event_type, aggregate_type, aggregate_id, tenant_id, payload(JSON), +status(enum: pending/processed/failed), retry_count, created_at, processed_at. +ایندکس‌ها: `status`، `tenant_id`. + +### جدول `inbox_events` +id, event_id(unique), source_service, event_type, tenant_id, payload(JSON), +processed_at. ایندکس‌ها: `event_id`، `tenant_id`. + +### جدول `audit_logs` +id, tenant_id, actor_user_id, action, resource_type, resource_id, +metadata(JSON), ip_address, user_agent, created_at. +ایندکس‌ها: `tenant_id`، `created_at`. + +### جدول `users` (OTP + لینک SSO — فاز ۳) +| ستون | نوع | توضیح | +| --- | --- | --- | +| id | UUID (PK) | | +| mobile | varchar(15) unique | شماره موبایل (09xxxxxxxxx) — **الزامی** | +| keycloak_sub | varchar(100) null unique | لینک به کاربر Keycloak (SSO) | +| email | varchar(255) null | ایمیل (اختیاری، برای لینک SSO) | +| mobile_verified | bool | تأیید OTP — true پس از verify موفق | +| role | enum | user / pending_tenant_admin / tenant_admin / platform_admin | +| status | enum | active / inactive / suspended | +| current_tenant_id | UUID null (FK→tenants, `ON DELETE SET NULL`) | tenant انتخاب‌شده جاری کاربر — فاز ۴ | +| created_at, updated_at | timestamptz | | + +ایندکس‌ها: `mobile` (unique)، `keycloak_sub` (unique)، `status`. + +**نقش‌ها بر اساس context OTP:** +- `public` → `user` (ثبت‌نام عمومی) +- `admin` → `pending_tenant_admin` (پنل ادمین tenant) +- `PLATFORM_ADMIN_MOBILES` → `platform_admin` + +### جدول `tenant_memberships` (فاز ۴ — Tenant Onboarding) + +> **توجه:** این جدول در `core_platform_db` است و با جدول هم‌نام در +> `identity_access_db` (بخش ۳ همین سند) **کاملاً متفاوت و بدون ارتباط +> مستقیم** است. جدول Core عضویت واقعی کاربر↔workspace را برای جریان +> onboarding/authorization مدیریت می‌کند. + +| ستون | نوع | توضیح | +| --- | --- | --- | +| id | UUID (PK) | | +| tenant_id | UUID (FK→tenants, cascade) | | +| user_id | UUID (FK→users, cascade) | | +| role | enum | `platform_admin` / `tenant_owner` / `tenant_admin` / `tenant_editor` / `tenant_viewer` | +| status | enum | `active` / `invited` / `disabled` | +| is_owner | bool | فقط یک یا چند owner فعال در هر tenant (حداقل یکی الزامی) | +| created_at, updated_at | timestamptz | | + +محدودیت یکتا: `(tenant_id, user_id)`. ایندکس‌ها: `tenant_id`، `user_id`، `role`، `status`. + +**قواعد:** +- هنگام `POST /onboarding/tenant`، کاربر سازنده به‌صورت خودکار عضویت + `tenant_owner` / `is_owner=true` / `status=active` می‌گیرد. +- `POST /onboarding/tenant/{id}/complete` بررسی می‌کند که حداقل یک owner + فعال وجود داشته باشد؛ در غیر این صورت خطای `owner_required`. +- کاربر می‌تواند در آینده چند عضویت (چند tenant) داشته باشد؛ `users.current_tenant_id` + مشخص می‌کند کدام‌یک «جاری» است. + +### جدول `plans` و `tenant_subscriptions` (seed پیش‌فرض — فاز ۴) +ساختار جدول‌ها از فاز ۱ بدون تغییر باقی مانده (نگاه کنید به پایین همین +بخش)؛ در فاز ۴ موارد زیر اضافه شد: +- Migration `0005_tenant_onboarding` دو پلن `FREE` و `STARTER` را با + `ON CONFLICT (code) DO NOTHING` seed می‌کند (idempotent). +- `PlanService.ensure_default_plan()` همان پلن `FREE` را در زمان اجرا نیز + به‌صورت idempotent تضمین می‌کند (برای محیط‌های SQLite تست که migration + اجرا نمی‌شود). +- `POST /onboarding/tenant` به‌صورت خودکار یک `tenant_subscriptions` با + `plan=FREE`، `status=active` می‌سازد. + +## بخش ۴ — دیتابیس Accounting (`accounting_db`) — فاز ۵.۱ تا ۵.۶ + +Migration: `0001_initial` در `backend/services/accounting/alembic/versions/`. + +### Foundation (5.1) +`chart_of_accounts`, `accounts`, `currencies`, `exchange_rates`, `fiscal_years`, +`fiscal_periods`, `cost_centers`, `projects`, `dimensions`, `accounting_settings`, +`document_number_sequences`, `tenant_accounting_configurations` + +### Posting (5.2) +`vouchers`, `voucher_lines`, `journals`, `journal_entries`, `posting_logs`, +`posting_errors`, `posting_references`, `accounting_audit_logs` + +### Ledger (5.3) +`general_ledgers`, `ledger_balances`, `trial_balance_snapshots`, `balance_snapshots`, +`closing_entries`, `opening_entries`, `accounting_calendars` + +### Treasury (5.4) +`cash_boxes`, `cash_transactions`, `banks`, `bank_accounts`, `bank_transactions`, +`cheque_books`, `cheques`, `transfers`, `receipt_vouchers`, `payment_vouchers`, +`reconciliations`, `treasury_settings` + +### AR/AP (5.5) +`customer_accounts`, `supplier_accounts`, `receivable_invoices`, `payable_invoices`, +`settlements`, `settlement_allocations`, `credit_notes`, `debit_notes`, +`advance_payments`, `advance_receipts`, `customer_statements`, `supplier_statements`, +`aging_snapshots` + +### Sales Accounting (5.6) +`sales_posting_profiles`, `accounting_profiles`, `posting_rules`, +`sales_accounting_configurations`, `revenue_recognitions`, `revenue_schedules`, +`accounting_previews`, `posting_simulations` + +### Purchase/Inventory (5.7) +`purchase_posting_profiles`, `inventory_posting_profiles`, `inventory_valuation_methods`, +`inventory_valuation_history`, `inventory_cost_adjustments`, `purchase_accounting_configurations`, +`inventory_accounting_configurations`, `inventory_snapshots` + +### Fixed Assets (5.8) +`asset_categories`, `asset_groups`, `assets`, `asset_depreciations`, `depreciation_schedules`, +`asset_transfers`, `asset_disposals`, `asset_revaluations`, `asset_impairments`, +`asset_accounting_profiles`, `asset_history`, `asset_locations` + +### Payroll (5.9) +`departments`, `positions`, `employees`, `employment_contracts`, `salary_components`, +`payroll_periods`, `payrolls`, `payroll_items`, `benefits`, `allowances`, `deductions`, +`employee_loans`, `employee_advances`, `payroll_accounting_profiles`, `payroll_history`, +`payroll_settlements` + +### Reporting (5.10) +`financial_reports`, `report_templates`, `dashboards`, `dashboard_widgets`, `kpis`, +`report_snapshots`, `scheduled_reports`, `report_exports`, `report_history`, `report_configurations` + +### Compliance (5.11) +`audit_records`, `audit_history`, `compliance_policies`, `governance_rules`, +`approval_workflows`, `approval_steps`, `approval_requests`, `delegations`, +`risk_records`, `policy_violations`, `evidence`, `retention_policies`, `control_definitions` + +همه جداول دارای `tenant_id` (UUID، بدون FK بین DB). + +--- + +## بخش ۲ — طراحی اولیه دیتابیس سرویس‌های آینده (بدون migration) + +> accounting_db اکنون پیاده‌سازی شده — جزئیات در بخش ۴ بالا. +> crm_db پیاده‌سازی شده — Phases 6.0–6.3. +> loyalty_db پیاده‌سازی شده — Phase 7.0 foundation: +> `loyalty_programs`, `membership_tiers`, `members`, `point_accounts`, +> `rewards`, `campaigns`, `loyalty_audit_logs`. +> communication_db پیاده‌سازی شده — Phase 8.0–8.10: +> `provider_configs`, `sender_numbers`, `message_templates`, `manual_contacts`, +> `contact_sources`, `messages`, `queue_items`, `delivery_events`, `provider_logs`, +> `otp_challenges`, `webhook_receipts`, `communication_audit_logs`. +> delivery_db پیاده‌سازی شده — Phase 10.1 drivers: +> `delivery_organizations`, `delivery_hubs`, `delivery_roles`, `delivery_permissions`, +> `external_provider_configs`, `routing_engine_registrations`, `delivery_configurations`, +> `delivery_settings`, `delivery_audit_logs`, +> `drivers`, `driver_credentials`, `driver_documents`, `driver_lifecycle_events`, `outbox_events`. +> experience_db پیاده‌سازی شده — Phase 11.0–11.4: +> `experience_workspaces`, `experience_locale_profiles`, `external_provider_configs`, +> `render_engine_registrations`, `experience_configurations`, `experience_settings`, +> `experience_roles`, `experience_permissions`, `experience_audit_logs`, +> `experience_sites`, `experience_pages`, `experience_site_domains`, +> `experience_components`, `experience_component_versions`, `experience_page_component_placements`, +> `experience_themes`, `experience_layouts`, `experience_site_theme_assignments`, +> `experience_page_layout_assignments`, `experience_templates`, +> `experience_template_versions`, `experience_template_instantiations`. +- **ecommerce_db:** products، categories، inventory، orders، order_items، + payments، shipments، discounts، carts. +- **experience_db:** sites، pages، page_types، components، component_versions، + themes، layouts، templates، locales، media_refs، forms، surveys، + appointment_pages، publish_states، domain_bindings، seo_meta، bundles، + feature_toggles، widgets، audit/config shells (از Phase 11.0). +- **website_builder_db:** (historical scaffold) sites، pages، blocks، forms، menus، media، content — prefer `experience_db`. +- **live_chat_db:** widgets، conversations، messages، agents، routing_rules. +- **ai_assistant_db:** assistants، knowledge_bases، documents، chat_sessions، + messages، handoffs. +- **smart_messenger_db:** channels، accounts، threads، messages، automations. +- **sms_panel_db:** providers، templates، campaigns، messages، send_queue. +- **link_shortener_db:** links، domains، clicks، campaigns. +- **notification_db:** notifications، channels، templates، delivery_logs. +- **file_storage_db:** files، buckets، access_policies. +- **restaurant_db:** menus، menu_items، tables، orders، kitchen_tickets، + payments (loyalty via `loyalty_db` / Loyalty service). + +هر سرویس دسترسی قابلیت‌ها را از Core (`check_feature_access`) استعلام می‌کند و +هرگز مستقیماً به `core_platform_db` متصل نمی‌شود. + +## بخش ۳ — دیتابیس Identity & Access (`identity_access_db`) — فاز ۲ + +### جدول `user_profiles` +| ستون | نوع | توضیح | +| --- | --- | --- | +| id | UUID (PK) | | +| keycloak_sub | varchar unique | شناسه کاربر در Keycloak | +| email | varchar | | +| username | varchar null | | +| display_name | varchar null | | +| mobile | varchar(15) null unique | شماره موبایل — **الزامی برای کاربران جدید** | +| mobile_verified | bool | تأیید OTP | +| mobile_verified_at | timestamptz null | زمان تأیید | +| core_user_id | UUID null | لینک به `core_platform_db.users` | +| status | enum | active / inactive / suspended | +| created_at, updated_at | timestamptz | | + +ایندکس‌ها: `keycloak_sub` (unique)، `email`، `mobile` (unique). + +**قانون:** کاربر تازه‌ثبت‌نام‌شده تا پیش از `mobile_verified=true` کامل محسوب نمی‌شود. +کاربران SSO قدیمی بدون موبایل باید از `/auth/verify-mobile` تکمیل کنند. + +### جدول `tenant_memberships` + +> **توجه:** این جدول مربوط به `identity_access_db` (لایهٔ هویت SSO) است و +> **جدول متفاوتی** نسبت به `tenant_memberships` در `core_platform_db` است که +> در بخش ۱ (فاز ۴ — Tenant Onboarding) مستند شده. این دو جدول در دو سرویس/ +> دیتابیس جدا زندگی می‌کنند و sync خودکار بین آن‌ها وجود ندارد؛ منبع حقیقت +> (source of truth) برای نقش عملیاتی کاربر در یک workspace، جدول Core است. + +| ستون | نوع | توضیح | +| --- | --- | --- | +| id | UUID (PK) | | +| tenant_id | UUID | مرجع به tenant در Core (بدون FK بین DB) | +| user_id | UUID (FK→user_profiles) | | +| role | enum | tenant_admin / tenant_member | +| is_active | bool | | +| joined_at | timestamptz | | + +محدودیت یکتا: (tenant_id, user_id). ایندکس‌ها: `tenant_id`, `user_id`. + diff --git a/docs/service-snapshots/README.md b/docs/service-snapshots/README.md new file mode 100644 index 0000000..1802e2a --- /dev/null +++ b/docs/service-snapshots/README.md @@ -0,0 +1,10 @@ +# Service Snapshots + +One compact YAML snapshot per service — current implementation state for AI-assisted phases. + +**Policy:** [service-snapshot-policy.md](../ai-framework/service-snapshot-policy.md) +**Template:** [_template.yaml](_template.yaml) + +Snapshots are generated automatically after each completed phase. Agents load the latest snapshot before reconstructing state from historical phase documents. + +Example paths: `accounting.yaml`, `crm.yaml`, `delivery.yaml`, `experience.yaml`, `hospitality.yaml`, `loyalty.yaml`, `communication.yaml`, `sports-center.yaml`. diff --git a/docs/service-snapshots/_template.yaml b/docs/service-snapshots/_template.yaml new file mode 100644 index 0000000..53b7913 --- /dev/null +++ b/docs/service-snapshots/_template.yaml @@ -0,0 +1,33 @@ +# Service Snapshot Template — copy to <service>.yaml and regenerate after each completed phase. +# Normative spec: docs/ai-framework/service-snapshot-policy.md + +schema_version: 1 +snapshot_version: 0 + +service_name: "" +commercial_product: "" +current_version: "" +current_phase: "" +last_completed_phase: "" +next_phase: "" + +completed_phases: [] +remaining_phases: [] + +registered_modules: [] +enabled_capabilities: [] +enabled_bundles: [] + +public_apis: [] +published_events: [] + +permission_prefix: "" + +active_adrs: [] +integration_contracts: [] + +known_limitations: [] +open_todos: [] + +last_handover_reference: "" +last_updated: "" diff --git a/docs/service-snapshots/healthcare.yaml b/docs/service-snapshots/healthcare.yaml new file mode 100644 index 0000000..8c8bfa9 --- /dev/null +++ b/docs/service-snapshots/healthcare.yaml @@ -0,0 +1,115 @@ +# Healthcare service snapshot — phases 13.0–13.7 complete +# Spec: docs/ai-framework/service-snapshot-policy.md + +schema_version: 1 +snapshot_version: 1 + +service_name: healthcare +commercial_product: Torbat Healthcare +current_version: "0.13.7.0" +current_phase: healthcare-13-7 +last_completed_phase: healthcare-13-7 +next_phase: null + +completed_phases: + - healthcare-13-0 + - healthcare-13-1 + - healthcare-13-2 + - healthcare-13-3 + - healthcare-13-4 + - healthcare-13-5 + - healthcare-13-6 + - healthcare-13-7 +remaining_phases: [] + +registered_modules: + - foundation + - profiles + - appointments + - doctor_panel + - visit_sessions + - visit_notes + - clinic_services + - staff_assignments + - clinic_policies + - operating_hours + - patient_portal + - medical_records + - encounters + - clinical_lists + - pharmacies + - prescription_orders + - delivery_integration + +enabled_capabilities: + - foundation + - appointments + - doctor_panel + - clinic_management + - patient_portal + - medical_records + - pharmacy + - delivery_integration + +enabled_bundles: + - torbat_healthcare + +public_apis: + - { method: GET, path: /health, permission: public } + - { method: GET, path: /capabilities, permission: public } + - { method: GET, path: /metrics, permission: public } + - { method: POST, path: /api/v1/clinics, permission: healthcare.clinics.create } + - { method: GET, path: /api/v1/clinics, permission: healthcare.clinics.view } + - { method: POST, path: /api/v1/appointments, permission: healthcare.appointments.manage } + - { method: GET, path: /api/v1/appointments, permission: healthcare.appointments.view } + - { method: GET, path: /api/v1/doctor-panel/dashboard, permission: healthcare.doctor_panel.view } + - { method: POST, path: /api/v1/visit-sessions, permission: healthcare.visit_sessions.manage } + - { method: POST, path: "/api/v1/visit-sessions/{id}/finish", permission: healthcare.visit_sessions.manage } + - { method: POST, path: /api/v1/clinic-services, permission: healthcare.clinic_services.manage } + - { method: GET, path: /api/v1/patient-portal/me, permission: healthcare.patient_portal.view } + - { method: POST, path: /api/v1/patient-portal/appointments, permission: healthcare.patient_portal.manage } + - { method: POST, path: /api/v1/medical-records, permission: healthcare.medical_records.manage } + - { method: POST, path: "/api/v1/encounters/{id}/finalize", permission: healthcare.encounters.manage } + - { method: POST, path: /api/v1/pharmacies, permission: healthcare.pharmacies.manage } + - { method: POST, path: /api/v1/prescription-orders, permission: healthcare.prescriptions.submit } + - { method: POST, path: /api/v1/delivery-integrations, permission: healthcare.delivery_integration.manage } + - { method: POST, path: "/api/v1/delivery-job-intents/prescription-orders/{id}", permission: healthcare.delivery_integration.submit } + - { method: POST, path: /api/v1/webhooks/delivery-status, permission: webhook } + +published_events: + - { event_type: healthcare.appointment.booked, version: "1" } + - { event_type: healthcare.appointment.completed, version: "1" } + - { event_type: healthcare.visit_session.finished, version: "1" } + - { event_type: healthcare.clinic_service.created, version: "1" } + - { event_type: healthcare.patient_portal.profile_created, version: "1" } + - { event_type: healthcare.encounter.finalized, version: "1" } + - { event_type: healthcare.prescription_order.submitted, version: "1" } + - { event_type: healthcare.delivery_job_intent.created, version: "1" } + +permission_prefix: healthcare. + +active_adrs: + - ADR-013 + - ADR-018 + - ADR-019 + +integration_contracts: + - CommunicationProvider + - DeliveryProvider + - StorageProvider + - AccountingProvider + - CRMProvider + - LoyaltyProvider + - IdentityProvider + - AIProvider + +known_limitations: + - No frontend doctor/patient UI in Healthcare service + - Delivery dispatch owned by Delivery platform; mock provider in tests + - Medical records are shells; no national EHR exchange + - Binary document storage via Storage refs only + +open_todos: [] + +last_handover_reference: docs/phase-handover/phase-13-7.md +last_updated: "2026-07-26" diff --git a/docs/service-snapshots/hospitality.yaml b/docs/service-snapshots/hospitality.yaml new file mode 100644 index 0000000..5bccf8b --- /dev/null +++ b/docs/service-snapshots/hospitality.yaml @@ -0,0 +1,239 @@ +# Hospitality Platform service snapshot — regenerated after each Complete phase. +# Spec: docs/ai-framework/service-snapshot-policy.md + +schema_version: 1 +snapshot_version: 8 + +service_name: hospitality +commercial_product: Torbat Food +current_version: "0.12.8.0" +current_phase: hospitality-12.8 +last_completed_phase: hospitality-12.8 +next_phase: hospitality-12.9 + +completed_phases: + - hospitality-12.0 + - hospitality-12.1 + - hospitality-12.2 + - hospitality-12.3 + - hospitality-12.4 + - hospitality-12.5 + - hospitality-12.6 + - hospitality-12.7 + - hospitality-12.8 +remaining_phases: + - hospitality-12.9 + - hospitality-12.10 + +registered_modules: + - venues + - branches + - dining_areas + - tables + - menus + - menu_categories + - menu_items + - allergens + - modifier_groups + - modifier_options + - availability_windows + - menu_media_refs + - menu_localizations + - qr_codes + - qr_menu_sessions + - qr_ordering_sessions + - carts + - cart_lines + - reservations + - table_assignments + - service_requests + - waitlist_entries + - pos_registers + - pos_shifts + - pos_tickets + - pos_ticket_lines + - pos_payments + - pos_discounts + - pos_tax_lines + - pos_floor_plans + - pos_stations + - kitchen_stations + - kitchen_tickets + - kitchen_ticket_items + - connector_registrations + - connector_dispatches + - analytics_report_definitions + - analytics_snapshots + - bundles + - feature_toggles + - roles + - permissions + - configurations + - settings + - audit + +enabled_capabilities: + - foundation + - feature_based_architecture + - bundle_based_licensing + - capability_discovery + - feature_toggle + - digital_menu_shell + - digital_menu_catalog + - table_service_shell + - qr_menu + - qr_ordering + - table_service + - reservation + - pos_lite + - pos_pro + - kitchen + - delivery_integration + - accounting_integration + - crm_integration + - loyalty_integration + - communication_integration + - website_integration + - analytics + +enabled_bundles: + - digital_menu + - qr_menu + - qr_ordering + - pos_lite + - pos_pro + - kitchen + - reservation + - table_service + - delivery_connector + - accounting_connector + - crm_connector + - loyalty_connector + - communication_connector + - website_connector + - analytics + - ai_assistant + +public_apis: + - { method: GET, path: /health, permission: public } + - { method: GET, path: /capabilities, permission: public } + - { method: POST, path: /api/v1/venues, permission: hospitality.venues.create } + - { method: POST, path: /api/v1/menus, permission: hospitality.menus.create } + - { method: POST, path: /api/v1/allergens, permission: hospitality.allergens.manage } + - { method: POST, path: /api/v1/modifier-groups, permission: hospitality.modifier_groups.manage } + - { method: POST, path: /api/v1/availability-windows, permission: hospitality.availability_windows.manage } + - { method: POST, path: /api/v1/menu-media-refs, permission: hospitality.menu_media.manage } + - { method: POST, path: /api/v1/menu-localizations/upsert, permission: hospitality.menu_localizations.manage } + - { method: POST, path: /api/v1/qr-codes, permission: hospitality.qr_codes.manage } + - { method: GET, path: "/api/v1/qr-codes/by-token/{token}", permission: hospitality.qr_codes.view } + - { method: POST, path: /api/v1/qr-menu-sessions, permission: hospitality.qr_menu_sessions.manage } + - { method: POST, path: /api/v1/qr-ordering-sessions, permission: hospitality.qr_ordering_sessions.manage } + - { method: POST, path: "/api/v1/qr-ordering-sessions/{id}/submit", permission: hospitality.qr_ordering_sessions.manage } + - { method: POST, path: /api/v1/carts, permission: hospitality.carts.manage } + - { method: POST, path: /api/v1/cart-lines, permission: hospitality.carts.manage } + - { method: POST, path: /api/v1/reservations, permission: hospitality.reservations.manage } + - { method: POST, path: "/api/v1/reservations/{id}/status", permission: hospitality.reservations.manage } + - { method: POST, path: /api/v1/table-assignments, permission: hospitality.table_assignments.manage } + - { method: POST, path: "/api/v1/table-assignments/{id}/release", permission: hospitality.table_assignments.manage } + - { method: POST, path: /api/v1/service-requests, permission: hospitality.service_requests.manage } + - { method: POST, path: "/api/v1/service-requests/{id}/status", permission: hospitality.service_requests.manage } + - { method: POST, path: /api/v1/waitlist, permission: hospitality.waitlist.manage } + - { method: POST, path: "/api/v1/waitlist/{id}/status", permission: hospitality.waitlist.manage } + - { method: POST, path: /api/v1/pos-registers, permission: hospitality.pos_registers.manage } + - { method: POST, path: /api/v1/pos-shifts, permission: hospitality.pos_shifts.manage } + - { method: POST, path: "/api/v1/pos-shifts/{id}/close", permission: hospitality.pos_shifts.manage } + - { method: POST, path: /api/v1/pos-tickets, permission: hospitality.pos_tickets.manage } + - { method: POST, path: "/api/v1/pos-tickets/{id}/void", permission: hospitality.pos_tickets.manage } + - { method: POST, path: /api/v1/pos-ticket-lines, permission: hospitality.pos_tickets.manage } + - { method: POST, path: /api/v1/pos-payments, permission: hospitality.pos_payments.manage } + - { method: POST, path: /api/v1/pos-discounts, permission: hospitality.pos_discounts.manage } + - { method: POST, path: /api/v1/pos-tax-lines, permission: hospitality.pos_tax_lines.manage } + - { method: POST, path: /api/v1/pos-floor-plans, permission: hospitality.pos_floor_plans.manage } + - { method: POST, path: /api/v1/pos-stations, permission: hospitality.pos_stations.manage } + - { method: POST, path: /api/v1/kitchen-stations, permission: hospitality.kitchen_stations.manage } + - { method: POST, path: /api/v1/kitchen-tickets, permission: hospitality.kitchen_tickets.manage } + - { method: POST, path: "/api/v1/kitchen-tickets/{id}/status", permission: hospitality.kitchen_tickets.manage } + - { method: POST, path: /api/v1/kitchen-ticket-items, permission: hospitality.kitchen_tickets.manage } + - { method: POST, path: "/api/v1/kitchen-ticket-items/{id}/status", permission: hospitality.kitchen_tickets.manage } + - { method: POST, path: /api/v1/connector-registrations, permission: hospitality.connector_registrations.manage } + - { method: POST, path: /api/v1/connector-dispatches, permission: hospitality.connector_dispatches.manage } + - { method: POST, path: /api/v1/analytics-reports, permission: hospitality.analytics_reports.manage } + - { method: POST, path: /api/v1/analytics-snapshots/refresh, permission: hospitality.analytics_snapshots.manage } + - { method: POST, path: /api/v1/bundle-definitions, permission: hospitality.bundles.manage } + - { method: POST, path: /api/v1/tenant-bundles/activate, permission: hospitality.tenant_bundles.manage } + - { method: POST, path: /api/v1/feature-toggles/upsert, permission: hospitality.feature_toggles.manage } + +published_events: + - { event_type: hospitality.venue.created, version: "1" } + - { event_type: hospitality.menu.created, version: "1" } + - { event_type: hospitality.allergen.created, version: "1" } + - { event_type: hospitality.modifier_group.created, version: "1" } + - { event_type: hospitality.availability_window.created, version: "1" } + - { event_type: hospitality.menu_media_ref.created, version: "1" } + - { event_type: hospitality.menu_localization.upserted, version: "1" } + - { event_type: hospitality.qr_code.created, version: "1" } + - { event_type: hospitality.qr_menu_session.created, version: "1" } + - { event_type: hospitality.qr_ordering_session.created, version: "1" } + - { event_type: hospitality.qr_ordering_session.submitted, version: "1" } + - { event_type: hospitality.cart.created, version: "1" } + - { event_type: hospitality.cart_line.added, version: "1" } + - { event_type: hospitality.reservation.created, version: "1" } + - { event_type: hospitality.reservation.status_changed, version: "1" } + - { event_type: hospitality.table_assignment.created, version: "1" } + - { event_type: hospitality.service_request.created, version: "1" } + - { event_type: hospitality.waitlist_entry.created, version: "1" } + - { event_type: hospitality.pos_register.created, version: "1" } + - { event_type: hospitality.pos_shift.opened, version: "1" } + - { event_type: hospitality.pos_shift.closed, version: "1" } + - { event_type: hospitality.pos_ticket.created, version: "1" } + - { event_type: hospitality.pos_ticket.voided, version: "1" } + - { event_type: hospitality.pos_ticket_line.added, version: "1" } + - { event_type: hospitality.pos_payment.recorded, version: "1" } + - { event_type: hospitality.pos_discount.applied, version: "1" } + - { event_type: hospitality.pos_tax_line.added, version: "1" } + - { event_type: hospitality.pos_floor_plan.created, version: "1" } + - { event_type: hospitality.pos_station.created, version: "1" } + - { event_type: hospitality.kitchen_station.created, version: "1" } + - { event_type: hospitality.kitchen_ticket.created, version: "1" } + - { event_type: hospitality.kitchen_ticket.status_changed, version: "1" } + - { event_type: hospitality.kitchen_ticket_item.added, version: "1" } + - { event_type: hospitality.connector_registration.created, version: "1" } + - { event_type: hospitality.connector_dispatch.created, version: "1" } + - { event_type: hospitality.analytics_report_definition.created, version: "1" } + - { event_type: hospitality.analytics_snapshot.refreshed, version: "1" } + - { event_type: hospitality.tenant_bundle.activated, version: "1" } + - { event_type: hospitality.feature_toggle.upserted, version: "1" } + +permission_prefix: hospitality.* + +active_adrs: + - ADR-001 + - ADR-003 + - ADR-006 + - ADR-017 + +integration_contracts: + - AccountingProvider (mock implementation for local dispatch simulation) + - CRMProvider (mock implementation for local dispatch simulation) + - LoyaltyProvider (mock implementation for local dispatch simulation) + - CommunicationProvider (mock implementation for local dispatch simulation) + - DeliveryProvider (mock implementation for local dispatch simulation) + - WebsiteBuilderProvider (mock implementation for local dispatch simulation) + - StorageProvider (contract only) + - AIProvider (contract only) + +known_limitations: + - No accounting journal posting engines + - No real payment gateway integration (local records only) + - No real KDS vendor integration; no automatic POS/QR-to-kitchen routing engine + - No automatic table-to-reservation matching engine + - Media is Storage file_ref only + - Bundle gating of catalog/QR/table-service/POS/kitchen APIs not enforced at middleware yet + - POS Lite one-open-shift-per-register rule enforced at service layer only + - Connector dispatch is simulated locally via no-op mocks; no real connector SDKs, retries, or webhook acks yet + - Analytics limited to simple local COUNT(*) metrics; no scheduled refresh or cross-service warehouse joins + +open_todos: [] + +last_handover_reference: docs/phase-handover/phase-12-8.md +last_updated: "2026-07-26" diff --git a/docs/service-snapshots/sports-center.yaml b/docs/service-snapshots/sports-center.yaml new file mode 100644 index 0000000..866c046 --- /dev/null +++ b/docs/service-snapshots/sports-center.yaml @@ -0,0 +1,105 @@ +schema_version: 1 +snapshot_version: 2 + +service_name: sports_center +commercial_product: Sports Center +current_version: "0.9.7.0" +current_phase: sports-center-9.7 +last_completed_phase: sports-center-9.7 +next_phase: sports-center-9.8 + +completed_phases: + - sports-center-9.0 + - sports-center-9.1 + - sports-center-9.2 + - sports-center-9.3 + - sports-center-9.4 + - sports-center-9.5 + - sports-center-9.6 + - sports-center-9.7 + +remaining_phases: + - sports-center-9.8 + - sports-center-9.9 + - sports-center-9.10 + +registered_modules: + - foundation + - membership_catalog + - members + - coaches + - staff + - booking + - attendance + - training + - competitions + +enabled_capabilities: + - sports_center.foundation + - sports_center.membership_catalog + - sports_center.member_management + - sports_center.coach_staff_management + - sports_center.booking_engine + - sports_center.attendance_engine + - sports_center.training_management + - sports_center.competition_management + +enabled_bundles: + - Sports Center + +public_apis: + - method: GET + path: /health + permission: public + - method: GET + path: /capabilities + permission: public + - method: POST + path: /api/v1/competitions + permission: sports_center.competitions.manage + - method: POST + path: /api/v1/competition-registrations + permission: sports_center.competition_registrations.manage + - method: POST + path: /api/v1/competition-matches + permission: sports_center.competition_matches.manage + - method: POST + path: /api/v1/competition-results + permission: sports_center.competition_results.manage + +published_events: + - event_type: sports_center.competition.created + version: "1" + - event_type: sports_center.competition.result.recorded + version: "1" + - event_type: sports_center.competition.medal.awarded + version: "1" + +permission_prefix: sports_center.* + +active_adrs: + - ADR-001 + - ADR-003 + - ADR-006 + - ADR-014 + +integration_contracts: + - service: accounting + mode: api_and_events + - service: crm + mode: api_and_events + - service: loyalty + mode: api_and_events + - service: communication + mode: api_and_events + +known_limitations: + - Route-level permission enforcement deferred (JWT + X-Tenant-ID) + - Events in-memory only (no durable outbox yet) + - No financial integration (Phase 9.8) + - Foundation sports_events shell unchanged; competition module is separate + +open_todos: [] + +last_handover_reference: docs/phase-handover/phase-9-7.md +last_updated: "2026-07-26" diff --git a/docs/sports-center-phase-9-3.md b/docs/sports-center-phase-9-3.md new file mode 100644 index 0000000..0201808 --- /dev/null +++ b/docs/sports-center-phase-9-3.md @@ -0,0 +1,38 @@ +# Phase 9.3 — Sports Center Coach & Staff Management + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | sports-center | +| Version | 0.9.3.0 | +| Database | `sports_center_db` | +| API Port | 8006 | + +## Goal + +Deliver **Coach & Staff Management**: certificates, skills, working hours, availability, assignments, and extended coach profile fields — without attendance devices or booking engine. + +## Scope Completed + +- Extended `Coach` with `staff_kind`, bio, hire_date, payroll refs, sports role ref +- Staff child aggregates: certificates, skills, working hours, availability, assignments +- APIs, validators, permissions, events, migration `0004_phase_93_coach_staff` + +## APIs + +| Resource | Prefix | +| --- | --- | +| Staff certificates | `/api/v1/staff-certificates` | +| Staff skills | `/api/v1/staff-skills` | +| Working hours | `/api/v1/staff-working-hours` | +| Availability | `/api/v1/staff-availability` | +| Assignments | `/api/v1/staff-assignments` | + +## Events + +`sports_center.staff_certificate.created|updated`, `staff_skill.created`, `staff_working_hours.created`, `staff_availability.changed`, `staff_assignment.created` + +## Related Documents + +- [Handover 9.3](phase-handover/phase-9-3.md) +- [Phase 9.2](sports-center-phase-9-2.md) diff --git a/docs/sports-center-phase-9-4.md b/docs/sports-center-phase-9-4.md new file mode 100644 index 0000000..19811db --- /dev/null +++ b/docs/sports-center-phase-9-4.md @@ -0,0 +1,30 @@ +# Phase 9.4 — Sports Center Scheduling & Booking + +| Field | Value | +| --- | --- | +| Status | Complete | +| Version | 0.9.4.0 | + +## Goal + +Scheduling & booking engine: sessions, bookings, capacity, waiting lists, equipment, resource reservations with conflict detection. + +## Scope Completed + +- Equipment, SportsSession, Booking, WaitingListEntry, ResourceReservation aggregates +- Capacity checks, session/resource conflict rules, confirm/cancel booking flows +- Migration `0005_phase_94_booking`; `/capabilities` sets `booking_engine: true` + +## APIs + +| Resource | Prefix | +| --- | --- | +| Equipment | `/api/v1/equipment` | +| Sessions | `/api/v1/sessions` | +| Bookings | `/api/v1/bookings` (+ confirm/cancel) | +| Waiting list | `/api/v1/waiting-list` | +| Resource reservations | `/api/v1/resource-reservations` | + +## Related Documents + +- [Handover 9.4](phase-handover/phase-9-4.md) diff --git a/docs/sports-center-phase-9-5.md b/docs/sports-center-phase-9-5.md new file mode 100644 index 0000000..3ef5574 --- /dev/null +++ b/docs/sports-center-phase-9-5.md @@ -0,0 +1,29 @@ +# Phase 9.5 — Sports Center Attendance & Access Control + +| Field | Value | +| --- | --- | +| Status | Complete | +| Version | 0.9.5.0 | + +## Goal + +Attendance recording, manual check-in/out, access logs, membership validation at gate — vendor adapters remain in connector framework only. + +## Scope Completed + +- AttendanceRecord, AccessLog aggregates +- Check-in/check-out APIs with membership validation +- Migration `0006_phase_95_attendance`; `/capabilities` sets `attendance_engine: true` + +## APIs + +| Resource | Prefix | +| --- | --- | +| Check-in | `/api/v1/check-in` | +| Check-out | `/api/v1/check-out` | +| Attendance records | `/api/v1/attendance-records` | +| Access logs | `/api/v1/access-logs` | + +## Related Documents + +- [Handover 9.5](phase-handover/phase-9-5.md) diff --git a/docs/sports-center-phase-9-6.md b/docs/sports-center-phase-9-6.md new file mode 100644 index 0000000..9356f3f --- /dev/null +++ b/docs/sports-center-phase-9-6.md @@ -0,0 +1,24 @@ +# Phase 9.6 — Sports Center Training Management + +| Field | Value | +| --- | --- | +| Status | Complete | +| Version | 0.9.6.0 | + +## Goal + +Training programs, exercises, workout plans/assignments, goals, measurements, progress, nutrition plans, assessments, performance and body composition records — no competitions module yet. + +## Scope Completed + +- Eleven training aggregates with APIs, permissions, events +- Migration `0007_phase_96_training` +- `/capabilities` sets `training_management: true`, phase `9.6` + +## APIs + +Programs, exercises, workout plans/assignments, goals, measurements, progress records, nutrition plans, assessments, performance records, body compositions under `/api/v1/*`. + +## Related Documents + +- [Handover 9.6](phase-handover/phase-9-6.md) diff --git a/docs/sports-center-phase-9-7.md b/docs/sports-center-phase-9-7.md new file mode 100644 index 0000000..826fdd0 --- /dev/null +++ b/docs/sports-center-phase-9-7.md @@ -0,0 +1,40 @@ +# Phase 9.7 — Sports Center Competition & Event Management + +| Field | Value | +| --- | --- | +| Status | Complete | +| Version | 0.9.7.0 | + +## Goal + +Deliver competitions, tournaments, teams, groups, registrations, matches, results, rankings, medals, judges, and certificates — without payment posting ownership. + +## Scope Completed + +- Competition, teams, groups, registrations, matches, results, rankings, medals, judges, certificates +- Registration confirm workflow; match scheduling; result recording with ranking updates +- Alembic `0008_phase_97_competitions`; `/capabilities` sets `competition_management: true` + +## APIs + +| Resource | Prefix | +| --- | --- | +| Competitions | `/api/v1/competitions` | +| Teams | `/api/v1/competition-teams` | +| Groups | `/api/v1/competition-groups` | +| Registrations | `/api/v1/competition-registrations` (+ confirm) | +| Matches | `/api/v1/competition-matches` | +| Results | `/api/v1/competition-results` | +| Rankings | `/api/v1/competition-rankings` | +| Medals | `/api/v1/competition-medals` | +| Judges | `/api/v1/competition-judges` | +| Certificates | `/api/v1/competition-certificates` | + +## Events + +`sports_center.competition.created|updated`, `competition_team.created`, `competition.registration.created|confirmed`, `competition.match.scheduled`, `competition.result.recorded`, `competition.medal.awarded`, `competition.certificate.issued` + +## Related Documents + +- [Handover 9.7](phase-handover/phase-9-7.md) +- [Phase 9.6](sports-center-phase-9-6.md) diff --git a/docs/sports-center-roadmap.md b/docs/sports-center-roadmap.md index 6c8a37e..a6eb22a 100644 --- a/docs/sports-center-roadmap.md +++ b/docs/sports-center-roadmap.md @@ -9,7 +9,7 @@ | Database | `sports_center_db` | | Permission Prefix | `sports_center.*` | | Phases | 9.0 – 9.10 | -| Status | Phase 9.2 Member Management complete; next 9.3 Coach & Staff Management | +| Status | Phase 9.7 Competition & Event Management complete; next 9.8 Financial Integration | ## Vision @@ -91,11 +91,11 @@ Forbidden: cross-DB queries, embedding providers inside Sports Center for SMS, c | 9.0 | `sports-center-9.0` | Foundation | Complete | | 9.1 | `sports-center-9.1` | Membership Catalog | Complete | | 9.2 | `sports-center-9.2` | Member Management | Complete | -| 9.3 | `sports-center-9.3` | Coach & Staff Management | Planned | -| 9.4 | `sports-center-9.4` | Scheduling & Booking | Planned | -| 9.5 | `sports-center-9.5` | Attendance & Access Control | Planned | -| 9.6 | `sports-center-9.6` | Training Management | Planned | -| 9.7 | `sports-center-9.7` | Competition & Event Management | Planned | +| 9.3 | `sports-center-9.3` | Coach & Staff Management | Complete | +| 9.4 | `sports-center-9.4` | Scheduling & Booking | Complete | +| 9.5 | `sports-center-9.5` | Attendance & Access Control | Complete | +| 9.6 | `sports-center-9.6` | Training Management | Complete | +| 9.7 | `sports-center-9.7` | Competition & Event Management | Complete | | 9.8 | `sports-center-9.8` | Financial Integration | Planned | | 9.9 | `sports-center-9.9` | AI & Analytics | Planned | | 9.10 | `sports-center-9.10` | Enterprise Validation | Planned | diff --git a/frontend/lib/apps-catalog.ts b/frontend/lib/apps-catalog.ts index 4c13430..0a1fda2 100644 --- a/frontend/lib/apps-catalog.ts +++ b/frontend/lib/apps-catalog.ts @@ -76,6 +76,24 @@ export const PLATFORM_APPS: PlatformApp[] = [ status: "available", href: "/delivery/hub", }, + { + id: "communication", + name: "ارتباطات و پیامک", + description: "SMS، کمپین، اتوماسیون", + icon: "📨", + gradient: "from-lime-500 to-green-600", + status: "available", + href: "/communication", + }, + { + id: "hospitality", + name: "تربت فود", + description: "رستوران، POS، آشپزخانه", + icon: "🍽️", + gradient: "from-orange-500 to-amber-600", + status: "available", + href: "/hospitality/hub", + }, { id: "ecommerce", name: "فروشگاه آنلاین", diff --git a/infrastructure/deploy/.env.production.example b/infrastructure/deploy/.env.production.example index 5f04cf5..506a31e 100644 --- a/infrastructure/deploy/.env.production.example +++ b/infrastructure/deploy/.env.production.example @@ -99,6 +99,36 @@ LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:54 LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db LOYALTY_SERVICE_NAME=loyalty-service +COMMUNICATION_SERVICE_URL=http://communication-service:8005 +COMMUNICATION_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db +COMMUNICATION_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db +COMMUNICATION_SERVICE_NAME=communication-service + +SPORTS_CENTER_SERVICE_URL=http://sports-center-service:8006 +SPORTS_CENTER_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/sports_center_db +SPORTS_CENTER_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db +SPORTS_CENTER_SERVICE_NAME=sports-center-service + +DELIVERY_SERVICE_URL=http://delivery-service:8007 +DELIVERY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/delivery_db +DELIVERY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/delivery_db +DELIVERY_SERVICE_NAME=delivery-service + +EXPERIENCE_SERVICE_URL=http://experience-service:8008 +EXPERIENCE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/experience_db +EXPERIENCE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/experience_db +EXPERIENCE_SERVICE_NAME=experience-service + +HOSPITALITY_SERVICE_URL=http://hospitality-service:8009 +HOSPITALITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/hospitality_db +HOSPITALITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/hospitality_db +HOSPITALITY_SERVICE_NAME=hospitality-service + +BEAUTY_BUSINESS_SERVICE_URL=http://beauty-business-service:8011 +BEAUTY_BUSINESS_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/beauty_business_db +BEAUTY_BUSINESS_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/beauty_business_db +BEAUTY_BUSINESS_SERVICE_NAME=beauty-business-service + HEALTHCARE_SERVICE_URL=http://healthcare-service:8010 HEALTHCARE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/healthcare_db HEALTHCARE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/healthcare_db diff --git a/infrastructure/nginx/torbatyar.ir.conf b/infrastructure/nginx/torbatyar.ir.conf index 74e060d..db737f2 100644 --- a/infrastructure/nginx/torbatyar.ir.conf +++ b/infrastructure/nginx/torbatyar.ir.conf @@ -33,6 +33,22 @@ upstream torbatyar_loyalty { server 192.168.10.162:8004; keepalive 16; } +upstream torbatyar_communication { + server 192.168.10.162:8005; + keepalive 16; +} +upstream torbatyar_sports_center { + server 192.168.10.162:8006; + keepalive 16; +} +upstream torbatyar_delivery { + server 192.168.10.162:8007; + keepalive 16; +} +upstream torbatyar_experience { + server 192.168.10.162:8008; + keepalive 16; +} upstream torbatyar_healthcare { server 192.168.10.162:8010; keepalive 16; @@ -49,7 +65,7 @@ upstream torbatyar_hospitality { # Force HTTPS for platform hosts server { listen 192.168.10.156:80; - server_name torbatyar.ir www.torbatyar.ir api.torbatyar.ir identity.torbatyar.ir auth.torbatyar.ir accounting.torbatyar.ir crm.torbatyar.ir loyalty.torbatyar.ir healthcare.torbatyar.ir beauty.torbatyar.ir hospitality.torbatyar.ir; + server_name torbatyar.ir www.torbatyar.ir api.torbatyar.ir identity.torbatyar.ir auth.torbatyar.ir accounting.torbatyar.ir crm.torbatyar.ir loyalty.torbatyar.ir communication.torbatyar.ir sports-center.torbatyar.ir delivery.torbatyar.ir experience.torbatyar.ir healthcare.torbatyar.ir beauty.torbatyar.ir hospitality.torbatyar.ir; location /.well-known/acme-challenge/ { root /var/www/html; @@ -241,6 +257,90 @@ server { } } +server { + listen 192.168.10.156:443 ssl; + server_name communication.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_communication; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name sports-center.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_sports_center; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name delivery.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_delivery; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name experience.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_experience; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} + server { listen 192.168.10.156:443 ssl; server_name healthcare.torbatyar.ir; diff --git a/infrastructure/postgres/init-dbs.sql b/infrastructure/postgres/init-dbs.sql index 4b9b844..5faa2dd 100644 --- a/infrastructure/postgres/init-dbs.sql +++ b/infrastructure/postgres/init-dbs.sql @@ -7,3 +7,5 @@ CREATE DATABASE sports_center_db; CREATE DATABASE hospitality_db; CREATE DATABASE experience_db; CREATE DATABASE healthcare_db; +CREATE DATABASE delivery_db; +CREATE DATABASE beauty_business_db; diff --git a/scripts/deploy_platform_full.py b/scripts/deploy_platform_full.py new file mode 100644 index 0000000..59721f5 --- /dev/null +++ b/scripts/deploy_platform_full.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Full platform deploy: all backend services, frontend, env, DBs, catalog seed.""" +from __future__ import annotations + +import base64 +import os +import sys +import tarfile +from pathlib import Path + +import paramiko + +ROOT = Path(__file__).resolve().parents[1] +APP_HOST = "192.168.10.162" +APP_USER = "morteza" +APP_PASS = "Moli@5404" +REMOTE_DIR = "/home/morteza/torbatyar" + +EXCLUDE_DIRS = { + ".git", + "node_modules", + ".next", + ".venv", + "venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".idea", + ".vscode", + ".cursor", + "agent-transcripts", + "scripts/.gitea-migration-staging", +} +EXCLUDE_FILES = {".DS_Store", "Thumbs.db", ".env"} + +ENV_UPSERT = { + "NEXT_PUBLIC_BACKEND_URL": "https://api.torbatyar.ir", + "NEXT_PUBLIC_API_BASE_URL": "https://api.torbatyar.ir", + "NEXT_PUBLIC_IDENTITY_API_URL": "https://identity.torbatyar.ir", + "NEXT_PUBLIC_KEYCLOAK_URL": "https://auth.torbatyar.ir", + "NEXT_PUBLIC_ACCOUNTING_API_URL": "https://accounting.torbatyar.ir", + "NEXT_PUBLIC_CRM_API_URL": "https://crm.torbatyar.ir", + "NEXT_PUBLIC_HEALTHCARE_API_URL": "https://healthcare.torbatyar.ir", + "NEXT_PUBLIC_HOSPITALITY_API_URL": "https://hospitality.torbatyar.ir", + "COMMUNICATION_SERVICE_URL": "http://communication-service:8005", + "COMMUNICATION_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db", + "COMMUNICATION_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db", + "COMMUNICATION_SERVICE_NAME": "communication-service", + "SPORTS_CENTER_SERVICE_URL": "http://sports-center-service:8006", + "SPORTS_CENTER_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/sports_center_db", + "SPORTS_CENTER_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db", + "SPORTS_CENTER_SERVICE_NAME": "sports-center-service", + "DELIVERY_SERVICE_URL": "http://delivery-service:8007", + "DELIVERY_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/delivery_db", + "DELIVERY_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/delivery_db", + "DELIVERY_SERVICE_NAME": "delivery-service", + "EXPERIENCE_SERVICE_URL": "http://experience-service:8008", + "EXPERIENCE_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/experience_db", + "EXPERIENCE_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/experience_db", + "EXPERIENCE_SERVICE_NAME": "experience-service", + "HOSPITALITY_SERVICE_URL": "http://hospitality-service:8009", + "HOSPITALITY_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/hospitality_db", + "HOSPITALITY_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/hospitality_db", + "HOSPITALITY_SERVICE_NAME": "hospitality-service", + "BEAUTY_BUSINESS_SERVICE_URL": "http://beauty-business-service:8011", + "BEAUTY_BUSINESS_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/beauty_business_db", + "BEAUTY_BUSINESS_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/beauty_business_db", + "BEAUTY_BUSINESS_SERVICE_NAME": "beauty-business-service", + "KEYCLOAK_SERVER_URL": "http://keycloak:8080", + "KEYCLOAK_REALM": "superapp", +} + +ALL_SERVICES = ( + "core-service identity-access-service frontend " + "accounting-service crm-service loyalty-service communication-service " + "sports-center-service delivery-service experience-service " + "healthcare-service beauty-business-service hospitality-service " + "celery-worker celery-beat" +) + +EXTRA_DBS = ( + "communication_db", + "sports_center_db", + "delivery_db", + "experience_db", + "beauty_business_db", +) + + +def connect() -> paramiko.SSHClient: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect( + APP_HOST, + username=APP_USER, + password=APP_PASS, + timeout=30, + allow_agent=False, + look_for_keys=False, + ) + return c + + +def run(c: paramiko.SSHClient, cmd: str, *, sudo: bool = False, timeout: int = 900) -> tuple[int, str]: + cmd = cmd.replace("\r\n", "\n").replace("\r", "\n").strip() + "\n" + b64 = base64.b64encode(cmd.encode()).decode() + if sudo: + full = f"echo {APP_PASS!r} | sudo -S -p '' bash -c 'echo {b64} | base64 -d | bash'" + else: + full = f"bash -lc 'echo {b64} | base64 -d | bash'" + print("\n>>>", cmd[:220].replace("\n", " | ")) + _, stdout, stderr = c.exec_command(full, timeout=timeout, get_pty=True) + out = stdout.read().decode("utf-8", "replace") + err = stderr.read().decode("utf-8", "replace") + code = stdout.channel.recv_exit_status() + text = out + (("\n" + err) if err.strip() else "") + print(text[-6000:].encode("ascii", "replace").decode("ascii")) + return code, text + + +def should_exclude(path: Path) -> bool: + if path.name in EXCLUDE_FILES or path.suffix in {".pyc", ".log", ".tar.gz", ".zip"}: + return True + if any(part in EXCLUDE_DIRS for part in path.parts): + return True + if path.as_posix().startswith("scripts/_"): + return True + return False + + +def make_tarball() -> Path: + out = ROOT / "scripts" / "_deploy_platform_full.tar.gz" + print(f"Creating {out} ...") + with tarfile.open(out, "w:gz") as tar: + for dirpath, dirnames, filenames in os.walk(ROOT): + p = Path(dirpath) + dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS] + rel_dir = p.relative_to(ROOT) + if should_exclude(rel_dir) and rel_dir != Path("."): + continue + for name in filenames: + fp = p / name + rel = fp.relative_to(ROOT) + if should_exclude(rel): + continue + tar.add(fp, arcname=str(rel).replace("\\", "/")) + print(f"Size: {out.stat().st_size / 1024 / 1024:.1f} MB") + return out + + +def merge_env_remote(c: paramiko.SSHClient) -> None: + py = f""" +from pathlib import Path +p = Path({REMOTE_DIR!r}) / '.env' +text = p.read_text(encoding='utf-8') if p.exists() else '' +lines = text.splitlines() +keys = {ENV_UPSERT!r} +seen = set() +out = [] +for line in lines: + if not line.strip() or line.lstrip().startswith('#') or '=' not in line: + out.append(line) + continue + k = line.split('=', 1)[0].strip() + if k in keys: + out.append(f"{{k}}={{keys[k]}}") + seen.add(k) + else: + out.append(line) +for k, v in keys.items(): + if k not in seen: + out.append(f"{{k}}={{v}}") +p.write_text('\\n'.join(out).rstrip() + '\\n', encoding='utf-8') +print('ENV_MERGED', len(keys)) +""" + b64 = base64.b64encode(py.encode()).decode() + code, _ = run(c, f"python3 -c \"import base64; exec(base64.b64decode('{b64}').decode())\"") + if code != 0: + raise RuntimeError("env merge failed") + + +def main() -> int: + tarball = make_tarball() + c = connect() + try: + sftp = c.open_sftp() + print("Uploading bundle...") + sftp.put(str(tarball), "/tmp/torbatyar_platform_full.tar.gz") + sftp.close() + + code, _ = run( + c, + f""" +set -e +cd {REMOTE_DIR} +cp -a .env /tmp/torbatyar.env.bak +tar -xzf /tmp/torbatyar_platform_full.tar.gz -C {REMOTE_DIR} +cp /tmp/torbatyar.env.bak .env +rm -f /tmp/torbatyar_platform_full.tar.gz +""", + sudo=True, + timeout=300, + ) + if code != 0: + raise RuntimeError("extract failed") + + merge_env_remote(c) + + db_cmds = " ".join( + f"docker compose exec -T postgres psql -U superapp -d postgres -tc " + f"\"SELECT 1 FROM pg_database WHERE datname='{db}'\" | grep -q 1 || " + f"docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE {db}';" + for db in EXTRA_DBS + ) + code, _ = run( + c, + f""" +set -e +cd {REMOTE_DIR} +{db_cmds} +echo DBS_READY +""", + sudo=True, + timeout=300, + ) + if code != 0: + raise RuntimeError("database create failed") + + code, _ = run( + c, + f""" +set -e +cd {REMOTE_DIR} +docker ps -a --format '{{{{.Names}}}}' | grep -E '_superapp_' | xargs -r docker rm -f || true +docker compose down --remove-orphans || true +docker compose up -d --build {ALL_SERVICES} +sleep 20 +sg docker -c 'docker compose exec -T frontend npm install' || docker compose exec -T frontend npm install +sg docker -c 'docker compose restart frontend core-service' || docker compose restart frontend core-service +sleep 25 +sg docker -c 'docker compose exec -T core-service python scripts/seed_platform_catalog.py' || docker compose exec -T core-service python scripts/seed_platform_catalog.py +""", + sudo=True, + timeout=3600, + ) + if code != 0: + raise RuntimeError("compose/seed failed") + + verify = """ +set -e +cd /home/morteza/torbatyar +docker compose ps --format '{{.Service}} {{.State}}' | sort +echo '--- registry ---' +docker compose exec -T postgres psql -U superapp -d core_platform_db -t -A -c 'SELECT count(*) FROM service_registry;' +echo '--- features ---' +docker compose exec -T postgres psql -U superapp -d core_platform_db -t -A -c 'SELECT count(*) FROM features;' +for path in / /delivery/hub /communication /loyalty/hub /hospitality/hub /admin/services; do + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000$path) + echo "fe$path=$code" +done +for port in 8005 8006 8007 8008; do + curl -sf http://127.0.0.1:$port/health && echo " health:$port=ok" || echo " health:$port=fail" +done +""" + run(c, verify, sudo=True, timeout=300) + finally: + c.close() + + print("\n=== PLATFORM DEPLOY DONE ===") + print("https://torbatyar.ir") + print("https://torbatyar.ir/admin/services") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"FATAL: {exc}", file=sys.stderr) + raise