feat(commercial): register workspace product and pricing
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d0c1d296b2
commit
0135c2137e
@ -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": "وبسایت"},
|
||||
],
|
||||
|
||||
@ -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"},
|
||||
],
|
||||
)
|
||||
|
||||
@ -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__")
|
||||
|
||||
324
backend/core-service/scripts/data/business-bundles.catalog.yaml
Normal file
324
backend/core-service/scripts/data/business-bundles.catalog.yaml
Normal file
@ -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 }
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
@ -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 }
|
||||
|
||||
@ -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 },
|
||||
]
|
||||
: [];
|
||||
|
||||
|
||||
@ -445,6 +445,17 @@ const TOKEN_LABELS: Readonly<Record<string, string>> = {
|
||||
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}`;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user