diff --git a/backend/core-service/app/commercial/recommendation.py b/backend/core-service/app/commercial/recommendation.py index add2535..ca73805 100644 --- a/backend/core-service/app/commercial/recommendation.py +++ b/backend/core-service/app/commercial/recommendation.py @@ -228,7 +228,8 @@ class AssessmentEngine: "options": [ {"value": "online_sales", "label": "فروش آنلاین"}, {"value": "delivery", "label": "پیک"}, - {"value": "crm", "label": "CRM"}, + {"value": "crm", "label": "مدیریت ارتباط با مشتری"}, + {"value": "workspace", "label": "فضای کاری"}, {"value": "loyalty", "label": "وفاداری"}, {"value": "website", "label": "وب‌سایت"}, ], diff --git a/backend/core-service/app/commercial/resolvers.py b/backend/core-service/app/commercial/resolvers.py index b4ede4c..91e0ace 100644 --- a/backend/core-service/app/commercial/resolvers.py +++ b/backend/core-service/app/commercial/resolvers.py @@ -301,10 +301,10 @@ class LicenseResolver: state = (await self.db.execute(stmt)).scalar_one_or_none() if state is None: steps = [ - {"step_key": "workspace", "label": "Workspace", "status": "done"}, - {"step_key": "bundle", "label": "Bundle", "status": "done"}, - {"step_key": "trial", "label": "Trial", "status": "done"}, - {"step_key": "dashboard", "label": "Dashboard", "status": "pending", "href": "/dashboard"}, + {"step_key": "workspace", "label": "فضای کاری", "status": "done"}, + {"step_key": "bundle", "label": "بسته خدمات", "status": "done"}, + {"step_key": "trial", "label": "دوره آزمایشی", "status": "done"}, + {"step_key": "dashboard", "label": "داشبورد", "status": "pending", "href": "/dashboard"}, ] state = CommercialActivationState( tenant_id=tenant_id, @@ -312,9 +312,9 @@ class LicenseResolver: completion_percent=75, steps=steps, checklist=[ - {"id": "workspace", "label": "Workspace", "done": True}, - {"id": "bundle", "label": "انتخاب باندل", "done": True}, - {"id": "billing", "label": "Billing", "done": False, "href": "/billing"}, + {"id": "workspace", "label": "فضای کاری", "done": True}, + {"id": "bundle", "label": "انتخاب بسته", "done": True}, + {"id": "billing", "label": "صورتحساب", "done": False, "href": "/billing"}, {"id": "domain", "label": "دامنه", "done": False, "href": "/domains"}, ], ) diff --git a/backend/core-service/app/tests/test_commercial_runtime.py b/backend/core-service/app/tests/test_commercial_runtime.py index 59f6f48..2d847f2 100644 --- a/backend/core-service/app/tests/test_commercial_runtime.py +++ b/backend/core-service/app/tests/test_commercial_runtime.py @@ -341,3 +341,11 @@ async def test_policy_evaluate(client): ) assert resp.status_code == 200 assert resp.json()["custom_domain_allowed"] is True + + +def test_workspace_commercial_registry_catalog(): + from pathlib import Path + import runpy + + script = Path(__file__).resolve().parents[2] / "scripts" / "validate_commercial_workspace_registry.py" + runpy.run_path(str(script), run_name="__validate_workspace_registry__") diff --git a/backend/core-service/scripts/data/business-bundles.catalog.yaml b/backend/core-service/scripts/data/business-bundles.catalog.yaml new file mode 100644 index 0000000..0e6f580 --- /dev/null +++ b/backend/core-service/scripts/data/business-bundles.catalog.yaml @@ -0,0 +1,324 @@ +# Canonical machine-readable Business Bundle catalog. +# Source of truth with docs/reference/business-bundles.md +# Frontend MUST be generated from this file in a future phase — do not treat React as authoritative. +schema_version: 1 +contract_version: business-bundle.v1 +updated: "2026-07-28" +owner: core-platform + +bundles: + - bundle_code: bundle.restaurant.starter + display_name: Restaurant Starter + status: active + version_code: "2026.07.1" + business_types: [restaurant] + services: + - { service_identifier: hospitality, required: true } + - { service_identifier: payment, required: true } + - { service_identifier: experience, required: true } + - { service_identifier: delivery, required: false } + - { service_identifier: loyalty, required: false } + - { service_identifier: communication, required: false } + - { service_identifier: accounting, required: false } + pricing_refs: + - { pricing_item_code: price.restaurant.starter.monthly, role: primary } + - { pricing_item_code: price.restaurant.starter.yearly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.restaurant.growth + display_name: Restaurant Growth + status: active + version_code: "2026.07.1" + business_types: [restaurant] + services: + - { service_identifier: hospitality, required: true } + - { service_identifier: payment, required: true } + - { service_identifier: experience, required: true } + - { service_identifier: delivery, required: true } + - { service_identifier: loyalty, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: accounting, required: false } + - { service_identifier: crm, required: false } + pricing_refs: + - { pricing_item_code: price.restaurant.growth.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.cafe.starter + display_name: Cafe Starter + status: active + version_code: "2026.07.1" + business_types: [cafe] + services: + - { service_identifier: hospitality, required: true } + - { service_identifier: payment, required: true } + - { service_identifier: experience, required: true } + - { service_identifier: loyalty, required: false } + - { service_identifier: communication, required: false } + pricing_refs: + - { pricing_item_code: price.cafe.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.clinic.starter + display_name: Clinic Starter + status: active + version_code: "2026.07.1" + business_types: [clinic] + services: + - { service_identifier: experience, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: accounting, required: true } + - { service_identifier: payment, required: false } + - { service_identifier: crm, required: false } + pricing_refs: + - { pricing_item_code: price.clinic.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.beauty.starter + display_name: Beauty Salon Starter + status: active + version_code: "2026.07.1" + business_types: [beauty_salon] + services: + - { service_identifier: experience, required: true } + - { service_identifier: crm, required: true } + - { service_identifier: payment, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: loyalty, required: false } + pricing_refs: + - { pricing_item_code: price.beauty.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.sports.starter + display_name: Sports Center Starter + status: active + version_code: "2026.07.1" + business_types: [sports_center] + services: + - { service_identifier: sports-center, required: true } + - { service_identifier: payment, required: true } + - { service_identifier: loyalty, required: true } + - { service_identifier: communication, required: false } + - { service_identifier: accounting, required: false } + pricing_refs: + - { pricing_item_code: price.sports.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.retail.starter + display_name: Retail Starter + status: active + version_code: "2026.07.1" + business_types: [retail_shop] + services: + - { service_identifier: experience, required: true } + - { service_identifier: payment, required: true } + - { service_identifier: delivery, required: false } + - { service_identifier: loyalty, required: false } + - { service_identifier: accounting, required: false } + pricing_refs: + - { pricing_item_code: price.retail.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.marketplace.seller + display_name: Marketplace Seller + status: draft + version_code: "2026.07.1" + business_types: [marketplace_seller] + services: + - { service_identifier: payment, required: true } + - { service_identifier: accounting, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: marketplace, required: false } + pricing_refs: + - { pricing_item_code: price.marketplace.seller.monthly, role: primary } + visibility: { public_catalog: false, admin_only: true, hidden: false } + lifecycle: { sellable: false, renewable: false, grandfathered: false } + notes: Marketplace product not implemented in commercial-foundation wave. + + - bundle_code: bundle.education.starter + display_name: Education Starter + status: active + version_code: "2026.07.1" + business_types: [educational_center] + services: + - { service_identifier: experience, required: true } + - { service_identifier: crm, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: payment, required: false } + - { service_identifier: accounting, required: false } + pricing_refs: + - { pricing_item_code: price.education.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.corporate.crm + display_name: Corporate CRM + status: active + version_code: "2026.07.1" + business_types: [corporate_company] + services: + - { service_identifier: crm, required: true } + - { service_identifier: accounting, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: identity-access, required: false } + - { service_identifier: experience, required: false } + pricing_refs: + - { pricing_item_code: price.corporate.crm.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.professional.starter + display_name: Professional Services + status: active + version_code: "2026.07.1" + business_types: [professional_services] + services: + - { service_identifier: crm, required: true } + - { service_identifier: accounting, required: true } + - { service_identifier: experience, required: true } + - { service_identifier: communication, required: false } + - { service_identifier: payment, required: false } + pricing_refs: + - { pricing_item_code: price.professional.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.freelancer.starter + display_name: Freelancer + status: active + version_code: "2026.07.1" + business_types: [freelancer] + services: + - { service_identifier: experience, required: true } + - { service_identifier: payment, required: true } + - { service_identifier: accounting, required: false } + - { service_identifier: communication, required: false } + pricing_refs: + - { pricing_item_code: price.freelancer.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.manufacturer.starter + display_name: Manufacturer + status: active + version_code: "2026.07.1" + business_types: [manufacturer] + services: + - { service_identifier: crm, required: true } + - { service_identifier: accounting, required: true } + - { service_identifier: communication, required: false } + pricing_refs: + - { pricing_item_code: price.manufacturer.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.wholesale.starter + display_name: Wholesale + status: active + version_code: "2026.07.1" + business_types: [wholesale] + services: + - { service_identifier: crm, required: true } + - { service_identifier: accounting, required: true } + - { service_identifier: payment, required: true } + - { service_identifier: delivery, required: false } + pricing_refs: + - { pricing_item_code: price.wholesale.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.real_estate.starter + display_name: Real Estate + status: active + version_code: "2026.07.1" + business_types: [real_estate] + services: + - { service_identifier: experience, required: true } + - { service_identifier: crm, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: payment, required: false } + pricing_refs: + - { pricing_item_code: price.real_estate.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.government.starter + display_name: Government + status: active + version_code: "2026.07.1" + business_types: [government] + services: + - { service_identifier: experience, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: accounting, required: true } + pricing_refs: + - { pricing_item_code: price.enterprise.custom, role: enterprise_quote } + visibility: { public_catalog: false, admin_only: true, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.ngo.starter + display_name: NGO + status: active + version_code: "2026.07.1" + business_types: [ngo] + services: + - { service_identifier: experience, required: true } + - { service_identifier: crm, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: accounting, required: false } + pricing_refs: + - { pricing_item_code: price.ngo.starter.monthly, role: primary } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } + + - bundle_code: bundle.platform.trial + display_name: Platform Trial + status: active + version_code: "2026.07.1" + business_types: [custom] + services: + - { service_identifier: experience, required: true } + - { service_identifier: payment, required: false } + - { service_identifier: communication, required: false } + pricing_refs: + - { pricing_item_code: price.platform.trial.14d, role: trial } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: false, grandfathered: false } + + - bundle_code: bundle.workspace.starter + display_name: فضای کاری — نسخه شروع + status: active + version_code: "2026.07.1" + business_types: + - advertising_agency + - content_team + - software_company + - marketing_company + - team_freelancer + services: + - { service_identifier: workspace, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: crm, required: true } + - { service_identifier: accounting, required: true } + capability_codes: + - workspace.projects + - workspace.tasks + - workspace.kanban + - workspace.calendar + - workspace.content + - workspace.approvals + - workspace.comments + - workspace.mentions + - workspace.analytics + pricing_refs: + - { pricing_item_code: price.workspace.starter.monthly, role: primary } + - { pricing_item_code: price.workspace.business.monthly, role: upgrade } + - { pricing_item_code: price.workspace.enterprise, role: enterprise } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } diff --git a/backend/core-service/scripts/seed_commercial_runtime.py b/backend/core-service/scripts/seed_commercial_runtime.py index 06dee42..0cf0a73 100644 --- a/backend/core-service/scripts/seed_commercial_runtime.py +++ b/backend/core-service/scripts/seed_commercial_runtime.py @@ -1,16 +1,23 @@ #!/usr/bin/env python3 -"""Seed Commercial Runtime registries from docs/reference catalogs (idempotent).""" +"""Seed Commercial Runtime registries from bundled catalogs (idempotent, Docker-safe). + +Canonical QA seed for every builtin registry kind. Bundle/pricing catalog is +loaded from scripts/data/ (mounted with core-service) with docs/ as optional +fallback — docs/ is NOT mounted into the container by default. +""" from __future__ import annotations import asyncio +import re import sys +import uuid +from datetime import datetime, timezone from pathlib import Path import yaml from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -# Allow running from the repo checkout or from the mounted /app directory in Docker. SCRIPT_PATH = Path(__file__).resolve() CORE_SERVICE_ROOT = SCRIPT_PATH.parents[1] ROOT = next((parent for parent in SCRIPT_PATH.parents if (parent / "docs").exists()), CORE_SERVICE_ROOT) @@ -21,6 +28,9 @@ from app.commercial.service import CommercialRegistryService from app.core.config import settings from app.models.commercial import CommercialRegistryKind, CommercialRegistryObject +VERSION = "2026.07.1" +SEED_DATA_DIR = SCRIPT_PATH.parent / "data" + async def ensure_kinds(session: AsyncSession) -> None: svc = CommercialRegistryService(session) @@ -49,7 +59,7 @@ async def upsert_object( display_name: str, payload: dict, description: str | None = None, - version_code: str = "2026.07.1", + version_code: str = VERSION, ) -> bool: existing = ( await session.execute( @@ -70,9 +80,6 @@ async def upsert_object( existing.is_deleted = False await session.flush() return False - from datetime import datetime, timezone - import re - import uuid slug = re.sub(r"[^a-z0-9]+", "-", code.lower()).strip("-") session.add( @@ -96,73 +103,256 @@ async def upsert_object( return True +def _load_bundles_catalog() -> list[dict]: + candidates = [ + SEED_DATA_DIR / "business-bundles.catalog.yaml", + ROOT / "docs" / "reference" / "business-bundles.catalog.yaml", + CORE_SERVICE_ROOT / "docs" / "reference" / "business-bundles.catalog.yaml", + ] + for path in candidates: + if path.exists(): + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + bundles = data.get("bundles") or [] + print(f"loaded bundles catalog: {path} ({len(bundles)} bundles)") + return bundles + print("WARNING: business-bundles.catalog.yaml not found; bundles/pricing will be empty") + return [] + + +def _product_code(service_identifier: str) -> str: + return { + "sports-center": "sports_center", + "identity-access": "identity-access", + }.get(service_identifier, service_identifier) + + +def _capability_codes_for_products(product_codes: list[str]) -> list[str]: + mapping = { + "hospitality": [ + "hospitality.digital_menu", + "hospitality.pos_lite", + "hospitality.kitchen", + "hospitality.reservation", + "hospitality.analytics", + ], + "payment": ["payments", "wallet"], + "experience": ["forms", "surveys", "qr", "short-links"], + "delivery": ["delivery"], + "loyalty": ["loyalty"], + "communication": ["sms", "email", "notifications"], + "crm": ["crm"], + "accounting": ["analytics"], + "sports_center": ["appointments", "tickets"], + "workspace": [ + "workspace.projects", + "workspace.tasks", + "workspace.kanban", + "workspace.calendar", + "workspace.content", + "workspace.approvals", + "workspace.comments", + "workspace.mentions", + "workspace.analytics", + ], + } + out: list[str] = [] + for p in product_codes: + for c in mapping.get(p, []): + if c not in out: + out.append(c) + return out + + +def _pricing_amount(pcode: str) -> int | None: + if "enterprise" in pcode or "custom" in pcode: + return None + if "trial" in pcode: + return 0 + if "workspace.business" in pcode: + return 1_990_000 + if "yearly" in pcode: + return 9_900_000 + if "growth" in pcode: + return 1_490_000 + return 990_000 + + +WORKSPACE_PRICING_NAMES = { + "price.workspace.starter.monthly": "پلن فضای کاری — شروع", + "price.workspace.business.monthly": "پلن فضای کاری — کسب‌وکار", + "price.workspace.enterprise": "پلن فضای کاری — سازمانی", +} + + async def seed(session: AsyncSession) -> dict[str, int]: await ensure_kinds(session) - counts = {"kinds": len(BUILTIN_REGISTRY_KINDS), "created": 0} + counts: dict[str, int] = {"kinds": len(BUILTIN_REGISTRY_KINDS), "created": 0, "updated": 0} - catalog_path = ROOT / "docs" / "reference" / "business-bundles.catalog.yaml" - data = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) if catalog_path.exists() else {} - bundles = data.get("bundles") or [] + async def put(**kwargs) -> None: + created = await upsert_object(session, **kwargs) + counts["created" if created else "updated"] += 1 - # Products derived from services referenced by bundles (open-ended) + # ── Languages / Currencies / Regions / Industries ───────────────────── + for code, name in [("fa-IR", "فارسی"), ("en-US", "English")]: + await put( + kind="languages", + code=code, + display_name=name, + payload={"language_code": code, "display_name": name, "dir": "rtl" if code.startswith("fa") else "ltr"}, + ) + await put( + kind="currencies", + code="IRR", + display_name="ریال ایران", + payload={"currency_code": "IRR", "symbol": "﷼", "decimals": 0}, + ) + await put( + kind="regions", + code="IR", + display_name="ایران", + payload={"region_code": "IR", "country_code": "IR", "default_currency": "IRR", "default_locale": "fa-IR"}, + ) + for code, name in [ + ("food_beverage", "غذا و نوشیدنی"), + ("healthcare", "سلامت"), + ("beauty", "زیبایی"), + ("sports", "ورزش"), + ("retail", "خرده\u200cفروشی"), + ("professional", "خدمات حرفه\u200cای"), + ("education", "آموزش"), + ("government", "دولت و سازمان"), + ]: + await put( + kind="industries", + code=code, + display_name=name, + payload={"industry_code": code, "display_name": name, "active": True}, + ) + + # ── Products ────────────────────────────────────────────────────────── products = { - "hospitality": ("Torbat Food", "/hospitality", "hospitality"), - "payment": ("Torbat Pay", "/payment/hub", "payment"), - "experience": ("Torbat Pages", "/experience/hub", "experience"), - "delivery": ("Torbat Driver", "/delivery/hub", "delivery"), - "loyalty": ("Torbat Loyalty", "/loyalty/hub", "loyalty"), - "communication": ("Torbat Communication", "/communication", "communication"), - "accounting": ("Torbat Accounting", "/accounting", "accounting"), - "crm": ("Torbat CRM", "/crm/hub", "crm"), - "sports_center": ("Torbat Sports", "/sports", "sports_center"), - "identity-access": ("Torbat Identity", "/dashboard", "identity"), + "hospitality": ("Torbat Food", "/hospitality", "hospitality", "food_beverage"), + "payment": ("Torbat Pay", "/payment/hub", "payment", "retail"), + "experience": ("Torbat Pages", "/experience/hub", "experience", "professional"), + "delivery": ("Torbat Driver", "/delivery/hub", "delivery", "retail"), + "loyalty": ("Torbat Loyalty", "/loyalty/hub", "loyalty", "retail"), + "communication": ("Torbat Communication", "/communication", "communication", "professional"), + "accounting": ("Torbat Accounting", "/accounting", "accounting", "professional"), + "crm": ("Torbat CRM", "/crm/hub", "crm", "professional"), + "sports_center": ("Torbat Sports", "/sports", "sports_center", "sports"), + "identity-access": ("Torbat Identity", "/dashboard", "identity", "professional"), + # Planned shell so draft bundle.marketplace.seller refs resolve (zero-code registry only). + "marketplace": ("Torbat Marketplace", "/marketplace", "marketplace", "retail"), + "workspace": ("فضای کاری", "/workspace", "workspace", "professional"), } - for code, (name, route, service_key) in products.items(): - if await upsert_object( - session, + for code, (name, route, service_key, industry) in products.items(): + planned = code == "marketplace" + is_workspace = code == "workspace" + await put( kind="products", code=code, display_name=name, - description=f"Platform product {name}", + description=( + "مدیریت کار، تیم، پروژه و تولید محتوا" + if is_workspace + else f"Platform product {name}" + ), payload={ "product_code": code, "default_route": route, "service_key": service_key, "category": "platform", - "trial_eligible": True, - "visibility": "public", + "category_code": "platform", + "industry": industry, + "trial_eligible": not planned, + "visibility": "public" if not planned else "admin", + "commercial_status": "planned" if planned else "ga", + "maturity": "concept" if planned else "mature", + "future_product": planned, + "version_code": VERSION, + **( + { + "name_fa": "فضای کاری", + "name_en": "Torbat Workspace", + "icon": "workspace", + "color": "#4F46E5", + "href": "/workspace", + } + if is_workspace + else {} + ), }, - ): - counts["created"] += 1 + ) + # ── Capabilities ────────────────────────────────────────────────────── + capabilities = [ + ("hospitality.digital_menu", "Digital Menu", "hospitality", "content"), + ("hospitality.pos_lite", "POS Lite", "hospitality", "commerce"), + ("hospitality.kitchen", "Kitchen Display", "hospitality", "ops"), + ("hospitality.reservation", "Reservations", "hospitality", "ops"), + ("hospitality.analytics", "Hospitality Analytics", "hospitality", "content"), + ("payments", "Payments", "payment", "commerce"), + ("wallet", "Wallet", "payment", "commerce"), + ("forms", "Forms", "experience", "content"), + ("surveys", "Surveys", "experience", "content"), + ("qr", "QR", "experience", "channel"), + ("short-links", "Short Links", "experience", "channel"), + ("delivery", "Delivery / Fleet", "delivery", "ops"), + ("loyalty", "Loyalty", "loyalty", "engage"), + ("sms", "SMS", "communication", "engage"), + ("email", "Email", "communication", "engage"), + ("notifications", "Notifications", "communication", "engage"), + ("crm", "CRM", "crm", "engage"), + ("analytics", "Analytics", "accounting", "content"), + ("appointments", "Appointments", "sports_center", "ops"), + ("tickets", "Ticketing", "sports_center", "ops"), + ("workspace.projects", "پروژه‌ها", "workspace", "ops"), + ("workspace.tasks", "وظایف", "workspace", "ops"), + ("workspace.kanban", "کانبان", "workspace", "ops"), + ("workspace.calendar", "تقویم محتوا", "workspace", "content"), + ("workspace.content", "مدیریت محتوا", "workspace", "content"), + ("workspace.approvals", "تأییدیه‌ها", "workspace", "ops"), + ("workspace.comments", "نظرات", "workspace", "engage"), + ("workspace.mentions", "منشن", "workspace", "engage"), + ("workspace.analytics", "گزارش‌های فضای کاری", "workspace", "content"), + ] + for code, name, service_key, category in capabilities: + await put( + kind="capabilities", + code=code, + display_name=name, + payload={ + "capability_code": code, + "display_name": name, + "service_key": service_key, + "category": category, + "status": "active", + }, + ) + + # ── Bundles + Pricing + Plans (from catalog) ────────────────────────── + bundles = _load_bundles_catalog() for b in bundles: code = b["bundle_code"] services = b.get("services") or [] - product_codes = [s["service_identifier"] for s in services if s.get("required")] - product_codes += [ - s["service_identifier"] - for s in services - if not s.get("required") and s["service_identifier"] not in product_codes - ] - product_codes = [p.replace("sports-center", "sports_center").replace("identity-access", "identity-access") for p in product_codes] - # Map identity-access product code used in products dict - product_codes = [("identity-access" if p == "identity-access" else p) for p in product_codes] + product_codes: list[str] = [] + for s in services: + pc = _product_code(s["service_identifier"]) + if pc not in product_codes: + product_codes.append(pc) + # Prefer required first + required = [_product_code(s["service_identifier"]) for s in services if s.get("required")] + optional = [p for p in product_codes if p not in required] + product_codes = required + optional pricing_refs = [p.get("pricing_item_code") for p in (b.get("pricing_refs") or []) if isinstance(p, dict)] - capability_codes: list[str] = [] - if "hospitality" in product_codes: - capability_codes = [ - "hospitality.digital_menu", - "hospitality.pos_lite", - "hospitality.kitchen", - "hospitality.reservation", - "hospitality.analytics", - ] - if await upsert_object( - session, + capability_codes = list(b.get("capability_codes") or []) or _capability_codes_for_products( + product_codes + ) + await put( kind="bundles", code=code, display_name=b.get("display_name") or code, - version_code=str(b.get("version_code") or "2026.07.1"), + version_code=str(b.get("version_code") or VERSION), payload={ "bundle_code": code, "business_types": b.get("business_types") or [], @@ -173,36 +363,44 @@ async def seed(session: AsyncSession) -> dict[str, int]: "visibility": b.get("visibility") or {}, "lifecycle": b.get("lifecycle") or {}, "trial_eligible": True, + "status": b.get("status") or "active", }, - ): - counts["created"] += 1 - + ) for pref in b.get("pricing_refs") or []: pcode = pref.get("pricing_item_code") if isinstance(pref, dict) else None if not pcode: continue - interval = "yearly" if "yearly" in pcode else "monthly" - if await upsert_object( - session, + role = pref.get("role") if isinstance(pref, dict) else "primary" + if "trial" in pcode: + model, interval, trial_days = "trial", "once", 14 + elif "enterprise" in pcode or "custom" in pcode: + model, interval, trial_days = "enterprise_quote", "custom", None + elif "yearly" in pcode: + model, interval, trial_days = "subscription", "yearly", 14 + else: + model, interval, trial_days = "subscription", "monthly", 14 + amount = _pricing_amount(pcode) + await put( kind="pricing", code=pcode, - display_name=pcode, + display_name=WORKSPACE_PRICING_NAMES.get(pcode, pcode), payload={ "pricing_item_code": pcode, - "pricing_model": "subscription", + "pricing_model": model, "interval": interval, "currency": "IRR", - "amount_minor": None, + "amount_minor": amount, "bundle_code": code, + "role": role, "trial_eligible": True, - "trial_days": 14, + "trial_days": trial_days, + "tax_class_code": "vat.standard", + "region_code": "IR", + "status": "active", }, - ): - counts["created"] += 1 - # Mirror pricing as plan shell for plan resolver + ) plan_code = pcode.replace("price.", "plan.", 1) if pcode.startswith("price.") else f"plan.{pcode}" - if await upsert_object( - session, + await put( kind="plans", code=plan_code, display_name=plan_code, @@ -211,28 +409,43 @@ async def seed(session: AsyncSession) -> dict[str, int]: "pricing_item_code": pcode, "bundle_code": code, "trial_eligible": True, - "trial_days": 14, + "trial_days": trial_days or 14, "interval": interval, "currency": "IRR", + "amount_minor": amount, "limits": {}, "renewal": {"interval": interval}, + "status": "active", }, - ): - counts["created"] += 1 + ) + # ── Business types ──────────────────────────────────────────────────── business_types = [ - ("restaurant", "Restaurant", "رستوران", ["bundle.restaurant.starter"]), - ("cafe", "Cafe", "کافه", ["bundle.cafe.starter"]), - ("clinic", "Clinic", "کلینیک", ["bundle.clinic.starter"]), - ("beauty_salon", "Beauty Salon", "سالن زیبایی", ["bundle.beauty.starter"]), - ("sports_center", "Sports Center", "مرکز ورزشی", ["bundle.sports.starter"]), - ("retail_shop", "Retail Shop", "فروشگاه", ["bundle.retail.starter"]), - ("corporate_company", "Corporate", "شرکت سازمانی", ["bundle.corporate.crm"]), - ("freelancer", "Freelancer", "فریلنسر", ["bundle.freelancer.starter"]), + ("restaurant", "Restaurant", "رستوران", ["bundle.restaurant.starter"], "food_beverage"), + ("cafe", "Cafe", "کافه", ["bundle.cafe.starter"], "food_beverage"), + ("clinic", "Clinic", "کلینیک", ["bundle.clinic.starter"], "healthcare"), + ("beauty_salon", "Beauty Salon", "سالن زیبایی", ["bundle.beauty.starter"], "beauty"), + ("sports_center", "Sports Center", "مرکز ورزشی", ["bundle.sports.starter"], "sports"), + ("retail_shop", "Retail Shop", "فروشگاه", ["bundle.retail.starter"], "retail"), + ("marketplace_seller", "Marketplace Seller", "فروشنده مارکت‌پلیس", ["bundle.marketplace.seller"], "retail"), + ("educational_center", "Education", "مرکز آموزشی", ["bundle.education.starter"], "education"), + ("corporate_company", "Corporate", "شرکت سازمانی", ["bundle.corporate.crm"], "professional"), + ("professional_services", "Professional Services", "خدمات حرفه‌ای", ["bundle.professional.starter"], "professional"), + ("freelancer", "Freelancer", "فریلنسر", ["bundle.freelancer.starter"], "professional"), + ("manufacturer", "Manufacturer", "تولیدکننده", ["bundle.manufacturer.starter"], "retail"), + ("wholesale", "Wholesale", "عمده‌فروشی", ["bundle.wholesale.starter"], "retail"), + ("real_estate", "Real Estate", "املاک", ["bundle.real_estate.starter"], "professional"), + ("government", "Government", "دولتی", ["bundle.government.starter"], "government"), + ("ngo", "NGO", "NGO", ["bundle.ngo.starter"], "government"), + ("custom", "Custom", "سفارشی", ["bundle.platform.trial"], "professional"), + ("advertising_agency", "Advertising Agency", "آژانس تبلیغاتی", ["bundle.workspace.starter"], "professional"), + ("content_team", "Content Team", "تیم تولید محتوا", ["bundle.workspace.starter"], "professional"), + ("software_company", "Software Company", "شرکت نرم‌افزاری", ["bundle.workspace.starter"], "professional"), + ("marketing_company", "Marketing Company", "شرکت بازاریابی", ["bundle.workspace.starter"], "professional"), + ("team_freelancer", "Team Freelancer", "فریلنسر تیمی", ["bundle.workspace.starter"], "professional"), ] - for code, en, fa, rec in business_types: - if await upsert_object( - session, + for code, en, fa, rec, industry in business_types: + await put( kind="business_types", code=code, display_name=fa, @@ -241,24 +454,90 @@ async def seed(session: AsyncSession) -> dict[str, int]: "display_name": en, "display_name_fa": fa, "recommended_bundle_codes": rec, + "industry_code": industry, "active": True, }, - ): - counts["created"] += 1 + ) - # Recommendation rules + # ── Trial rules ─────────────────────────────────────────────────────── + trials = [ + ( + "trial.platform.14d", + "Platform 14-day trial", + { + "trial_code": "trial.platform.14d", + "duration_days": 14, + "bundle_codes": ["bundle.platform.trial", "bundle.restaurant.starter", "bundle.cafe.starter"], + "pricing_item_code": "price.platform.trial.14d", + "auto_convert": False, + "requires_payment_method": False, + "active": True, + }, + ), + ( + "trial.workspace.14d", + "دوره آزمایشی فضای کاری", + { + "trial_code": "trial.workspace.14d", + "duration_days": 14, + "bundle_codes": ["bundle.workspace.starter"], + "pricing_item_code": "price.workspace.starter.monthly", + "auto_convert": False, + "requires_payment_method": False, + "active": True, + }, + ), + ( + "trial.restaurant.14d", + "Restaurant starter trial", + { + "trial_code": "trial.restaurant.14d", + "duration_days": 14, + "bundle_codes": ["bundle.restaurant.starter"], + "pricing_item_code": "price.restaurant.starter.monthly", + "auto_convert": False, + "requires_payment_method": False, + "active": True, + }, + ), + ] + for code, name, payload in trials: + await put(kind="trials", code=code, display_name=name, payload=payload) + + # ── Recommendation rules ────────────────────────────────────────────── rules = [ ( "r.restaurant.base", "Restaurant base", {"business_type_code": "restaurant"}, - {"bundle_code": "bundle.restaurant.starter", "score": 100, "reason": "نوع رستوران", "products": ["hospitality", "payment", "experience"]}, + { + "bundle_code": "bundle.restaurant.starter", + "score": 100, + "reason": "نوع رستوران", + "products": ["hospitality", "payment", "experience"], + }, ), ( "r.cafe.base", "Cafe base", {"business_type_code": "cafe"}, - {"bundle_code": "bundle.cafe.starter", "score": 100, "reason": "نوع کافه", "products": ["hospitality", "payment"]}, + { + "bundle_code": "bundle.cafe.starter", + "score": 100, + "reason": "نوع کافه", + "products": ["hospitality", "payment"], + }, + ), + ( + "r.clinic.base", + "Clinic base", + {"business_type_code": "clinic"}, + { + "bundle_code": "bundle.clinic.starter", + "score": 100, + "reason": "نوع کلینیک", + "products": ["experience", "communication", "accounting"], + }, ), ( "r.delivery.need", @@ -272,24 +551,82 @@ async def seed(session: AsyncSession) -> dict[str, int]: {"crm": True}, {"score": 80, "reason": "نیاز به CRM", "services": ["crm"], "products": ["crm"]}, ), + ( + "r.workspace.advertising_agency", + "آژانس تبلیغاتی — فضای کاری ضروری", + {"business_type_code": "advertising_agency"}, + { + "bundle_code": "bundle.workspace.starter", + "score": 100, + "level": "required", + "reason": "آژانس تبلیغاتی به فضای کاری نیاز دارد", + "products": ["workspace", "communication", "crm", "accounting"], + }, + ), + ( + "r.workspace.content_team", + "تیم تولید محتوا — فضای کاری ضروری", + {"business_type_code": "content_team"}, + { + "bundle_code": "bundle.workspace.starter", + "score": 100, + "level": "required", + "reason": "تیم تولید محتوا به فضای کاری نیاز دارد", + "products": ["workspace", "communication", "crm"], + }, + ), + ( + "r.workspace.software_company", + "شرکت نرم‌افزاری — فضای کاری پیشنهادی", + {"business_type_code": "software_company"}, + { + "bundle_code": "bundle.workspace.starter", + "score": 70, + "level": "recommended", + "reason": "شرکت نرم‌افزاری از فضای کاری بهره می‌برد", + "products": ["workspace", "crm"], + }, + ), + ( + "r.workspace.marketing_company", + "شرکت بازاریابی — فضای کاری ضروری", + {"business_type_code": "marketing_company"}, + { + "bundle_code": "bundle.workspace.starter", + "score": 100, + "level": "required", + "reason": "شرکت بازاریابی به فضای کاری نیاز دارد", + "products": ["workspace", "communication", "crm", "accounting"], + }, + ), + ( + "r.workspace.team_freelancer", + "فریلنسر تیمی — فضای کاری پیشنهادی", + {"business_type_code": "team_freelancer"}, + { + "bundle_code": "bundle.workspace.starter", + "score": 70, + "level": "recommended", + "reason": "فریلنسر تیمی از فضای کاری بهره می‌برد", + "products": ["workspace"], + }, + ), ] for code, name, match, emit in rules: - if await upsert_object( - session, + await put( kind="recommendation_rules", code=code, display_name=name, payload={"rule_id": code, "priority": 100, "active": True, "match": match, "emit": emit}, - ): - counts["created"] += 1 + ) - # Assessment schema - if await upsert_object( - session, + # ── Assessment schema ───────────────────────────────────────────────── + await put( kind="assessment_schemas", code="default.business.assessment", display_name="ارزیابی کسب‌وکار", payload={ + "schema_code": "default.business.assessment", "questions": [ { "question_id": "business_type_code", @@ -311,60 +648,181 @@ async def seed(session: AsyncSession) -> dict[str, int]: {"value": "enterprise", "label": "سازمانی"}, ], }, - { - "question_id": "delivery", - "type": "boolean", - "label": "نیاز به پیک؟", - "required": False, - }, - { - "question_id": "crm", - "type": "boolean", - "label": "نیاز به CRM؟", - "required": False, - }, - ] + {"question_id": "delivery", "type": "boolean", "label": "نیاز به پیک؟", "required": False}, + {"question_id": "crm", "type": "boolean", "label": "نیاز به CRM؟", "required": False}, + ], }, - ): - counts["created"] += 1 + ) - # Capabilities sample (open-ended) - for cap in ["digital_menu", "pos_lite", "kitchen", "reservation", "analytics"]: - if await upsert_object( - session, - kind="capabilities", - code=f"hospitality.{cap}", - display_name=cap, - payload={"capability_code": f"hospitality.{cap}", "service_key": "hospitality"}, - ): - counts["created"] += 1 + # ── Policies ────────────────────────────────────────────────────────── + policies = [ + ( + "domain.custom", + "Custom domain policy", + { + "policy_code": "domain.custom", + "target": "domain", + "custom_domain_allowed": True, + "subdomain_only": False, + "reason": "طبق سیاست دامنه تجاری", + "dns_instructions": ["CNAME → edge.torbatyar.ir"], + "ssl_ready": True, + }, + ), + ( + "trial.eligibility", + "Trial eligibility policy", + { + "policy_code": "trial.eligibility", + "target": "trial", + "max_trials_per_tenant": 1, + "default_trial_days": 14, + "blocked_business_types": [], + }, + ), + ( + "feature.lock.default", + "Default feature lock policy", + { + "policy_code": "feature.lock.default", + "target": "capability", + "lock_when_unlicensed": True, + "reason": "قابلیت بدون لایسنس قفل است", + }, + ), + ] + for code, name, payload in policies: + await put(kind="policies", code=code, display_name=name, payload=payload) - # Domain policy - if await upsert_object( - session, - kind="policies", - code="domain.custom", - display_name="Custom domain policy", - payload={ - "custom_domain_allowed": True, - "subdomain_only": False, - "target": "domain", - "reason": "طبق سیاست دامنه تجاری", - "dns_instructions": ["CNAME → edge.torbatyar.ir"], - "ssl_ready": True, - }, - ): - counts["created"] += 1 + # ── Assets ──────────────────────────────────────────────────────────── + assets = [ + ("asset.theme.restaurant.v1", "Restaurant Theme", "restaurant_theme", "theme", "hospitality"), + ("asset.theme.clinic.v1", "Clinic Theme", "clinic_theme", "theme", "experience"), + ("asset.template.landing.v1", "Landing Template", "landing_template", "template", "experience"), + ("asset.template.menu.v1", "Digital Menu Template", "website_template", "template", "hospitality"), + ("asset.template.qr.v1", "QR Menu Template", "qr_template", "template", "experience"), + ("asset.template.report.v1", "Ops Report Template", "report_template", "template", "accounting"), + ] + for code, name, type_code, category, publisher in assets: + await put( + kind="assets", + code=code, + display_name=name, + payload={ + "asset_code": code, + "asset_type_code": type_code, + "category": category, + "display_name": name, + "publisher_product_code": publisher, + "owner_service_identifier": publisher, + "lifecycle_state": "published", + "version_code": VERSION, + "marketplace": {"listable": True, "installable": True}, + }, + ) - # Notification templates (email / sms / push / in_app) + # ── Extensions ──────────────────────────────────────────────────────── + extensions = [ + ("ext.pay.checkout_widget", "widget", "payment", "payment", ["payments"]), + ("ext.crm.pipeline_block", "block", "crm", "crm", ["crm"]), + ("ext.pages.form_action", "action", "experience", "experience", ["forms"]), + ("ext.loyalty.card_embed", "embed", "loyalty", "loyalty", ["loyalty"]), + ("ext.driver.dispatch_connector", "connector", "delivery", "delivery", ["delivery"]), + ("ext.accounting.invoice_template", "template", "accounting", "accounting", ["analytics"]), + ("ext.food.menu_widget", "widget", "hospitality", "hospitality", ["hospitality.digital_menu"]), + ] + for code, kind_ext, publisher, owner, caps in extensions: + await put( + kind="extensions", + code=code, + display_name=code, + payload={ + "extension_code": code, + "extension_kind": kind_ext, + "display_name": code, + "publisher_product_code": publisher, + "owner_service_identifier": owner, + "required_capability_codes": caps, + "supported_surfaces": ["admin", "tenant", "public"], + "status": "active", + }, + ) + + # ── Automation packs ────────────────────────────────────────────────── + packs = [ + ( + "auto.pack.welcome.sms", + "Welcome SMS Pack", + ["sms", "internal_event"], + ["sms"], + ["communication"], + ), + ( + "auto.pack.trial.expiry", + "Trial Expiry Notify Pack", + ["email", "push", "cron"], + ["email", "notifications"], + ["communication"], + ), + ( + "auto.pack.order.notify", + "Order Notify Pack", + ["sms", "email", "internal_event"], + ["sms", "email"], + ["hospitality", "communication"], + ), + ] + for code, name, types, caps, products_ref in packs: + await put( + kind="automation_packs", + code=code, + display_name=name, + payload={ + "automation_pack_code": code, + "display_name": name, + "automation_type_codes": types, + "required_capability_codes": caps, + "required_product_codes": products_ref, + "status": "active", + }, + ) + + # ── Metadata keys ───────────────────────────────────────────────────── + metadata_keys = [ + ("meta.product.tags", "product", ["tags", "industry", "release_channel", "icon"]), + ("meta.bundle.marketing", "bundle", ["slug", "tags", "marketing", "visibility", "industry"]), + ("meta.capability.docs", "capability", ["documentation", "support_level"]), + ("meta.asset.theme", "asset", ["color", "icon", "industry", "locale"]), + ("meta.extension.surfaces", "extension", ["supported_surfaces", "beta"]), + ("meta.automation.locale", "automation_pack", ["tags", "locale", "language"]), + ("meta.pricing.channel", "pricing_item", ["channel_code", "region_code", "currency"]), + ("meta.plan.limits", "subscription_plan", ["limits", "renewal"]), + ] + for code, target, attrs in metadata_keys: + await put( + kind="metadata", + code=code, + display_name=code, + payload={ + "metadata_key": code, + "target_kind": target, + "attributes": {a: True for a in attrs}, + "version_code": VERSION, + "locale": "fa-IR", + "currency": "IRR", + "region": "IR", + }, + ) + + # ── Notifications ───────────────────────────────────────────────────── for channel, code, subject in [ ("email", "notify.welcome.email", "خوش آمدید"), ("sms", "notify.welcome.sms", "خوش آمدید"), ("push", "notify.trial.expiring.push", "پایان trial"), ("in_app", "notify.activation.in_app", "فعال‌سازی"), + ("email", "notify.subscription.active.email", "اشتراک فعال شد"), ]: - if await upsert_object( - session, + await put( kind="notifications", code=code, display_name=subject, @@ -376,26 +834,99 @@ async def seed(session: AsyncSession) -> dict[str, int]: "locale": "fa-IR", "provider": "platform", }, - ): - counts["created"] += 1 + ) - if await upsert_object( - session, - kind="currencies", - code="IRR", - display_name="ریال ایران", - payload={"currency_code": "IRR", "symbol": "﷼", "decimals": 0}, - ): - counts["created"] += 1 + # ── Usage plans (templates) ─────────────────────────────────────────── + usage_plans = [ + ( + "usage.sms.starter", + "SMS Starter Quota", + "sms", + 1000, + "price.restaurant.starter.monthly", + ), + ( + "usage.sms.growth", + "SMS Growth Quota", + "sms", + 10000, + "price.restaurant.growth.monthly", + ), + ( + "usage.storage.starter", + "Storage Starter", + "storage_gb", + 5, + "price.platform.trial.14d", + ), + ] + for code, name, unit, quota, pricing_ref in usage_plans: + await put( + kind="usage_plans", + code=code, + display_name=name, + payload={ + "usage_plan_code": code, + "display_name": name, + "usage_unit": unit, + "quota": quota, + "pricing_item_code": pricing_ref, + "overage_policy": "block", + "active": True, + }, + ) - if await upsert_object( - session, - kind="regions", - code="IR", - display_name="ایران", - payload={"region_code": "IR", "country_code": "IR"}, - ): - counts["created"] += 1 + # ── Coupons / Promotions / Taxes / Announcements ────────────────────── + await put( + kind="coupons", + code="coupon.qa.welcome10", + display_name="Welcome 10%", + payload={ + "coupon_code": "coupon.qa.welcome10", + "percent_off": 10, + "currency": "IRR", + "eligible_pricing_item_codes": ["price.restaurant.starter.monthly", "price.cafe.starter.monthly"], + "active": True, + }, + ) + await put( + kind="promotions", + code="promo.qa.summer2026", + display_name="Summer 2026", + payload={ + "promotion_code": "promo.qa.summer2026", + "display_name": "Summer 2026", + "bundle_codes": ["bundle.restaurant.starter", "bundle.cafe.starter"], + "coupon_code": "coupon.qa.welcome10", + "window": {"starts_at": "2026-06-01", "ends_at": "2026-09-30"}, + "active": True, + }, + ) + await put( + kind="taxes", + code="vat.standard", + display_name="VAT Standard", + payload={ + "tax_code": "vat.standard", + "display_name": "VAT Standard", + "rate_bps": 900, + "inclusive": False, + "region_code": "IR", + "active": True, + }, + ) + await put( + kind="announcements", + code="announce.commercial.runtime.ready", + display_name="Commercial Runtime Ready", + payload={ + "announcement_code": "announce.commercial.runtime.ready", + "title": "موتور تجاری آماده است", + "body": "کاتالوگ محصولات، بسته‌ها و قیمت‌ها از Commercial Runtime خوانده می‌شوند.", + "locale": "fa-IR", + "active": True, + }, + ) await session.commit() return counts diff --git a/backend/core-service/scripts/validate_commercial_workspace_registry.py b/backend/core-service/scripts/validate_commercial_workspace_registry.py new file mode 100644 index 0000000..ef78024 --- /dev/null +++ b/backend/core-service/scripts/validate_commercial_workspace_registry.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Validate Workspace Commercial Runtime registration (catalog + seed, no extra services).""" +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CATALOG = ROOT / "scripts" / "data" / "business-bundles.catalog.yaml" +SEED = ROOT / "scripts" / "seed_commercial_runtime.py" + +REQUIRED_PRODUCTS = {"workspace", "communication", "crm", "accounting"} +REQUIRED_PRICES = { + "price.workspace.starter.monthly", + "price.workspace.business.monthly", + "price.workspace.enterprise", +} +REQUIRED_CAPS = { + "workspace.projects", + "workspace.tasks", + "workspace.kanban", + "workspace.calendar", + "workspace.content", + "workspace.approvals", + "workspace.comments", + "workspace.mentions", + "workspace.analytics", +} +REQUIRED_TYPES = { + "advertising_agency", + "content_team", + "software_company", + "marketing_company", + "team_freelancer", +} +REQUIRED_RULES = { + "r.workspace.advertising_agency", + "r.workspace.content_team", + "r.workspace.software_company", + "r.workspace.marketing_company", + "r.workspace.team_freelancer", +} + + +def fail(msg: str) -> None: + print(f"FAIL {msg}") + raise SystemExit(1) + + +def main() -> int: + catalog = yaml.safe_load(CATALOG.read_text(encoding="utf-8")) or {} + bundles = {b["bundle_code"]: b for b in catalog.get("bundles") or []} + bundle = bundles.get("bundle.workspace.starter") + if not bundle: + fail("bundle.workspace.starter missing from catalog") + print("Registry Validation: bundle.workspace.starter PASS") + + services = {s["service_identifier"] for s in bundle.get("services") or []} + if not REQUIRED_PRODUCTS.issubset(services): + fail(f"bundle products {services} missing {REQUIRED_PRODUCTS - services}") + prices = { + p.get("pricing_item_code") + for p in bundle.get("pricing_refs") or [] + if isinstance(p, dict) + } + if prices != REQUIRED_PRICES: + fail(f"pricing refs {prices} != {REQUIRED_PRICES}") + print("Pricing Validation: 3 workspace prices PASS") + + caps = set(bundle.get("capability_codes") or []) + if not REQUIRED_CAPS.issubset(caps): + fail(f"capabilities missing {REQUIRED_CAPS - caps}") + print("Cross Reference: workspace capabilities PASS") + + seed = SEED.read_text(encoding="utf-8") + if '"workspace": ("فضای کاری"' not in seed and '"workspace": ("فضای کاری", "/workspace"' not in seed: + if '"workspace":' not in seed or "فضای کاری" not in seed: + fail("workspace product not in seed") + print("Registry Validation: workspace product PASS") + + for item in REQUIRED_TYPES | REQUIRED_RULES | REQUIRED_PRICES: + if item not in seed: + fail(f"{item} missing from seed") + print("Recommendation Validation: 5 workspace rules + 5 business types PASS") + + tree = ast.parse(seed) + print("Validation PASS") + print(f"ast_ok={bool(tree)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/reference/business-bundles.catalog.yaml b/docs/reference/business-bundles.catalog.yaml index bfb3d91..0e6f580 100644 --- a/docs/reference/business-bundles.catalog.yaml +++ b/docs/reference/business-bundles.catalog.yaml @@ -290,3 +290,35 @@ bundles: - { pricing_item_code: price.platform.trial.14d, role: trial } visibility: { public_catalog: true, admin_only: false, hidden: false } lifecycle: { sellable: true, renewable: false, grandfathered: false } + + - bundle_code: bundle.workspace.starter + display_name: فضای کاری — نسخه شروع + status: active + version_code: "2026.07.1" + business_types: + - advertising_agency + - content_team + - software_company + - marketing_company + - team_freelancer + services: + - { service_identifier: workspace, required: true } + - { service_identifier: communication, required: true } + - { service_identifier: crm, required: true } + - { service_identifier: accounting, required: true } + capability_codes: + - workspace.projects + - workspace.tasks + - workspace.kanban + - workspace.calendar + - workspace.content + - workspace.approvals + - workspace.comments + - workspace.mentions + - workspace.analytics + pricing_refs: + - { pricing_item_code: price.workspace.starter.monthly, role: primary } + - { pricing_item_code: price.workspace.business.monthly, role: upgrade } + - { pricing_item_code: price.workspace.enterprise, role: enterprise } + visibility: { public_catalog: true, admin_only: false, hidden: false } + lifecycle: { sellable: true, renewable: true, grandfathered: false } diff --git a/frontend/modules/commercial/adapters/workspace.ts b/frontend/modules/commercial/adapters/workspace.ts index 914ca23..03498fe 100644 --- a/frontend/modules/commercial/adapters/workspace.ts +++ b/frontend/modules/commercial/adapters/workspace.ts @@ -187,8 +187,8 @@ export function buildSetupChecklist( const workspaceActions = products.length === 0 ? [ - { id: "workspace", label: "Workspace", href: "/dashboard", required: true }, - { id: "billing", label: "Billing", href: "/billing", required: false }, + { id: "workspace", label: "فضای کاری", href: "/workspace", required: true }, + { id: "billing", label: "صورتحساب", href: "/billing", required: false }, ] : []; diff --git a/frontend/modules/commercial/presentation.ts b/frontend/modules/commercial/presentation.ts index 081979e..db08d8f 100644 --- a/frontend/modules/commercial/presentation.ts +++ b/frontend/modules/commercial/presentation.ts @@ -445,6 +445,17 @@ const TOKEN_LABELS: Readonly> = { website: "وب‌سایت", experience: "وب‌سایت و تجربه دیجیتال", crm: "مدیریت ارتباط با مشتری", + workspace: "فضای کاری", + projects: "پروژه‌ها", + tasks: "وظایف", + kanban: "کانبان", + calendar: "تقویم", + content: "محتوا", + approvals: "تأییدیه‌ها", + comments: "نظرات", + mentions: "منشن", + analytics: "گزارش‌ها", + business: "کسب‌وکار", inventory: "انبارداری", pos: "صندوق فروش", communication: "ارتباط با مشتریان", @@ -810,7 +821,7 @@ export function looksTechnical(value: string): boolean { export function formatMoney(amount?: number | null, currency?: string | null): string { if (amount == null) return "استعلام قیمت"; - const unit = !currency || /irr|rial/i.test(currency) ? "ریال" : enumLabel(currency); + const unit = !currency || /irr|rial|toman/i.test(currency) ? "تومان" : enumLabel(currency); return `${new Intl.NumberFormat("fa-IR").format(amount)} ${unit}`; }