diff --git a/apps/backend/chatballs/ai/agent_card.py b/apps/backend/chatballs/ai/agent_card.py index 5e81f55..6192593 100644 --- a/apps/backend/chatballs/ai/agent_card.py +++ b/apps/backend/chatballs/ai/agent_card.py @@ -128,7 +128,6 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) "model": agent.model, "providerIntegrationId": agent.provider_integration_id, "modelParams": agent.model_params, - "limits": agent.limits, "answerLanguage": agent.answer_language, "persona": agent.persona, "tone": agent.tone, @@ -232,7 +231,6 @@ def update_agent_card( ai_fields = { "providerIntegrationId", "modelParams", - "limits", "persona", "tone", "instructions", @@ -249,11 +247,8 @@ def update_agent_card( ): raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")}) model_params = body.get("modelParams", agent.model_params) - limits = body.get("limits", agent.limits) if not isinstance(model_params, dict): raise ValidationError({"modelParams": t("api.object_required")}) - if not isinstance(limits, dict): - raise ValidationError({"limits": t("api.object_required")}) provider_integration_id = body.get( "providerIntegrationId", agent.provider_integration_id ) @@ -270,7 +265,6 @@ def update_agent_card( provider_integration_id=provider_integration_id, model_params=model_params, allowed_tools=agent.allowed_tools, - limits=limits, persona=str(body.get("persona", agent.persona)), tone=str(body.get("tone", agent.tone)), instructions=str(body.get("instructions", agent.instructions)), diff --git a/apps/backend/chatballs/ai/invocation.py b/apps/backend/chatballs/ai/invocation.py index f9d5e98..846da94 100644 --- a/apps/backend/chatballs/ai/invocation.py +++ b/apps/backend/chatballs/ai/invocation.py @@ -55,7 +55,7 @@ def invoke_chat( fallback_model = model or channel.ai_agent.model try: provider, model = _prepare_invocation(channel=channel, requested_model=model) - limits.assert_within_limits(channel, channel.ai_agent) + limits.assert_within_limits() except limits.LimitExceeded as error: _record_blocked( channel=channel, diff --git a/apps/backend/chatballs/ai/limits.py b/apps/backend/chatballs/ai/limits.py index 7853692..82c19c6 100644 --- a/apps/backend/chatballs/ai/limits.py +++ b/apps/backend/chatballs/ai/limits.py @@ -21,12 +21,14 @@ def daily_cost_micros(channel=None) -> int: return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0 -def assert_within_limits(channel, agent) -> None: +def assert_within_limits() -> None: + """Единственный лимит расхода — общий по установке, из переменной окружения. + + Дневного бюджета на агенте нет: он считался по прайс-таблице, где всего две + модели, и на любой другой расход оставался нулевым — лимит не срабатывал + никогда и давал ложное чувство защиты. + """ + global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS if global_limit and daily_cost_micros() >= global_limit: raise LimitExceeded("Global daily AI cost limit reached") - # Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается - # в micro-USD. 1 цент = 10 000 micro-USD. - channel_limit = (agent.limits or {}).get("dailyCostUsd") - if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000: - raise LimitExceeded("Channel daily AI cost limit reached") diff --git a/apps/backend/chatballs/ai/migrations/0018_remove_agent_limits.py b/apps/backend/chatballs/ai/migrations/0018_remove_agent_limits.py new file mode 100644 index 0000000..0108ac6 --- /dev/null +++ b/apps/backend/chatballs/ai/migrations/0018_remove_agent_limits.py @@ -0,0 +1,18 @@ +# Дневной бюджет агента снят вместе с полем `limits`: расход считался по +# прайс-таблице из двух моделей, а для всех остальных оставался нулевым — лимит +# не срабатывал никогда. Единственный оставшийся предохранитель — общий лимит +# установки из переменной окружения (CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS). +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("ai", "0017_agent_answer_language"), + ] + + operations = [ + migrations.RemoveField( + model_name="aiagent", + name="limits", + ), + ] diff --git a/apps/backend/chatballs/ai/models.py b/apps/backend/chatballs/ai/models.py index c97598a..8efb503 100644 --- a/apps/backend/chatballs/ai/models.py +++ b/apps/backend/chatballs/ai/models.py @@ -417,10 +417,6 @@ class AIAgent(TenantRelationModel): allowed_tools = models.JSONField(default=list, blank=True) - # Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD). - - limits = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/backend/chatballs/ai/services.py b/apps/backend/chatballs/ai/services.py index 7f29767..0662b81 100644 --- a/apps/backend/chatballs/ai/services.py +++ b/apps/backend/chatballs/ai/services.py @@ -21,7 +21,6 @@ class AgentInput: provider_integration_id: int | None model_params: dict allowed_tools: list - limits: dict persona: str tone: str instructions: str @@ -39,20 +38,6 @@ class AgentCreateInput: knowledge_ids: list[int] -# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD -# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub, -# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются. -def _normalize_limits(raw: dict | None) -> dict: - if not isinstance(raw, dict): - return {} - value = raw.get("dailyCostUsd") - try: - cents = int(value) - except (TypeError, ValueError): - return {} - return {"dailyCostUsd": cents} if cents > 0 else {} - - def knowledge_for_agent_ids( *, context: TenantContext, channel: Channel, knowledge_ids: list[int] ) -> list[Knowledge]: @@ -146,7 +131,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> locked.provider_integration = selection.integration locked.model_params = data.model_params locked.allowed_tools = data.allowed_tools - locked.limits = _normalize_limits(data.limits) locked.persona = data.persona locked.tone = data.tone locked.instructions = data.instructions @@ -158,7 +142,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> "provider_integration", "model_params", "allowed_tools", - "limits", "persona", "tone", "instructions", diff --git a/apps/backend/chatballs/ai/tests.py b/apps/backend/chatballs/ai/tests.py index fbf41e0..f999e1c 100644 --- a/apps/backend/chatballs/ai/tests.py +++ b/apps/backend/chatballs/ai/tests.py @@ -378,22 +378,28 @@ class ChatInvocationTests(TestCase): self.assertNotIn("a@b.com", captured["messages"][0].content) - def test_limit_blocks_and_records(self) -> None: + def test_global_limit_blocks_and_records(self) -> None: + """Единственный лимит расхода — общий по установке, из окружения. + + Дневного бюджета на агенте больше нет: он опирался на прайс-таблицу из + двух моделей и на всех прочих не срабатывал. + """ + + from django.test import override_settings + from chatballs.ai import limits as ai_limits from chatballs.ai.invocation import invoke_chat from chatballs.ai.models import LlmInvocation, LlmInvocationStatus from chatballs.ai.provider.base import ChatMessage - self.agent.limits = {"dailyCostUsd": 1} - self.agent.save(update_fields=["limits"]) - # 1 цент = 10 000 micro-USD; лимит превышен расходом в 10_001 micros. LlmInvocation.objects.create( channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001, status=LlmInvocationStatus.SUCCESS, ) - with self.assertRaises(ai_limits.LimitExceeded): - invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat") + with override_settings(CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=10_000): + with self.assertRaises(ai_limits.LimitExceeded): + invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat") self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists()) def test_invocation_records_used_fragment_ids(self) -> None: diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py index b7a68aa..d4b91b3 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py @@ -82,7 +82,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ agent.persona = item.get("persona", "") agent.tone = item.get("tone", "") agent.instructions = item.get("instructions", "") - agent.limits = item.get("limits", {}) if agent.status in ("ACTIVE", "DISABLED"): agent.provider_integration = llm agent.model = (llm.config or {}).get("default_model", "demo") diff --git a/apps/backend/chatballs/identity/test_organization_creation.py b/apps/backend/chatballs/identity/test_organization_creation.py index 84223b9..a2be55f 100644 --- a/apps/backend/chatballs/identity/test_organization_creation.py +++ b/apps/backend/chatballs/identity/test_organization_creation.py @@ -60,6 +60,8 @@ class OrganizationCreationTests(TestCase): self.assertEqual(created.name, "Вторая компания") self.assertEqual(created.language, "en") self.assertEqual(created.status, "ACTIVE") + # Валюту интерфейс не спрашивает и не шлёт: сервер ставит её сам. + self.assertEqual(created.currency, "RUB") membership = OrganizationMembership.objects.get(organization=created, user=self.owner) self.assertEqual(membership.role, EmployeeRole.OWNER) self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists()) diff --git a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx index db8100a..0312a5a 100644 --- a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx +++ b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx @@ -71,13 +71,6 @@ export function OrganizationSettingsForm({ onChange={(timezone) => onChange({ ...organization, timezone })} options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} /> - onChange({ ...organization, currency })} - options={[["RUB", t("admin.russian_rouble_rub")]]} - /> {/* Язык — сегментом, как «Тема» в профиле и схема в «Платформе»: вариантов три, и выбор лучше видеть целиком, чем разворачивать список. В сетку diff --git a/apps/internal-ui/src/features/agents/AgentDetailPage.tsx b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx index b28fa29..956dc77 100644 --- a/apps/internal-ui/src/features/agents/AgentDetailPage.tsx +++ b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx @@ -22,8 +22,6 @@ import { connectionStatusMeta, connectionSubtitle, createdLabel, - dailyCostCents, - dailyCostInput, deleteAgent, fetchAgent, knowledgeLine, @@ -534,17 +532,9 @@ function ModelCard({ card, providers, canManage, busy, apply }: { busy: boolean; apply: (patch: AgentPatch) => Promise; }) { - const [limit, setLimit] = useState(dailyCostInput(card.limits)); - useEffect(() => setLimit(dailyCostInput(card.limits)), [card.limits]); const missingProvider = card.providerIntegrationId === null; const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? ""; - function saveLimit() { - const cents = dailyCostCents(limit); - if (cents === Number(card.limits.dailyCostUsd ?? 0)) return; - void apply({ limits: { ...card.limits, dailyCostUsd: cents } }); - } - return (

{t("common.model")}

@@ -567,15 +557,6 @@ function ModelCard({ card, providers, canManage, busy, apply }: { -
- {t("ai.daily_limit")} - - {canManage - ? setLimit(event.target.value)} onBlur={saveLimit} /> - : {limit || "0.00"}} - USD - -
); diff --git a/apps/internal-ui/src/features/agents/model.ts b/apps/internal-ui/src/features/agents/model.ts index 8783258..b6fee00 100644 --- a/apps/internal-ui/src/features/agents/model.ts +++ b/apps/internal-ui/src/features/agents/model.ts @@ -37,7 +37,6 @@ export type AgentCard = { model: string; providerIntegrationId: number | null; modelParams: Record; - limits: Record; // Режим языка ответов: MIRROR, ORGANIZATION или код языка. answerLanguage: string; persona: string; @@ -63,7 +62,6 @@ export type AgentPatch = Partial<{ tone: string; instructions: string; knowledgeIds: number[]; - limits: Record; }>; // Страница списка агентов (кадр G1): группа, поиск и страница — на сервере. @@ -255,15 +253,3 @@ export function knowledgeLine(card: AgentCard): string { return `${head} · ${parts.join(" + ")}`; } -// Дневной бюджет агента хранится в целых центах USD (limits.dailyCostUsd, -// ADR-CHATBALLS-0023); на экране — доллары с двумя знаками. -export function dailyCostInput(limits: Record): string { - const cents = Number(limits.dailyCostUsd ?? 0); - return Number.isFinite(cents) && cents > 0 ? (cents / 100).toFixed(2) : ""; -} - -export function dailyCostCents(value: string): number { - const amount = Number(value.replace(",", ".").trim()); - if (!Number.isFinite(amount) || amount <= 0) return 0; - return Math.round(amount * 100); -} diff --git a/apps/internal-ui/src/features/agents/styles.css b/apps/internal-ui/src/features/agents/styles.css index f9ee428..b398c9b 100644 --- a/apps/internal-ui/src/features/agents/styles.css +++ b/apps/internal-ui/src/features/agents/styles.css @@ -841,15 +841,14 @@ .agent-field textarea:focus, .agent-field input:focus, .agent-create-modal .form-field.is-editable input:focus, -.agent-create-modal .select-like select:focus { +.agent-create-modal .select-box:focus-visible { border-color: var(--primary); background: var(--surface-card); box-shadow: 0 0 0 3px var(--primary-bg); } -/* Тот же фокус для общего селекта карточки: с боксом (группа) и без (провайдер). */ -.agent-side-fields .select-like select:focus, -.agent-side-fields .select-box:focus-within { +/* Тот же фокус для общего селекта карточки. */ +.agent-side-fields .select-box:focus-visible { border-color: var(--primary); background: var(--surface-card); box-shadow: 0 0 0 3px var(--primary-bg); @@ -1045,54 +1044,6 @@ color: var(--n-5); } -.agent-limit { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding-top: 6px; -} - -.agent-limit > span:first-child { - color: var(--n-3); - font-size: 12.5px; - font-weight: 600; -} - -.agent-limit > span:last-child { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--n-1); - font-size: 13px; -} - -.agent-limit input, -.agent-limit-value { - display: inline-flex; - width: 56px; - height: 30px; - align-items: center; - padding: 0 10px; - border: 1px solid var(--n-7); - border-radius: 7px; - outline: none; - background: var(--n-10); - color: var(--n-1); - font-family: var(--font-mono); - font-size: 12.5px; -} - -.agent-limit input:focus { - border-color: var(--primary); - background: var(--surface-card); - box-shadow: 0 0 0 3px var(--primary-bg); -} - -.agent-limit small { - color: var(--n-4); -} - /* Подключения. */ .agent-connection { diff --git a/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx b/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx index bb9a146..b436654 100644 --- a/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx +++ b/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx @@ -33,7 +33,6 @@ export function OrganizationCreatePage({ user, onCreated, onBack }: { // Новая организация наследует региональные параметры текущей: чаще всего // человек заводит вторую компанию там же, где первую. timezone: "Europe/Moscow", - currency: "RUB", language: "", }); const [logo, setLogo] = useState(null); @@ -123,12 +122,6 @@ export function OrganizationCreatePage({ user, onCreated, onBack }: { onChange={(timezone) => setDraft({ ...draft, timezone })} options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} /> - setDraft({ ...draft, currency })} - options={[["RUB", t("admin.russian_rouble_rub")]]} - />
{t("settings.language")} diff --git a/apps/internal-ui/src/features/organizations/api.ts b/apps/internal-ui/src/features/organizations/api.ts index 01e4c99..e6ebcdc 100644 --- a/apps/internal-ui/src/features/organizations/api.ts +++ b/apps/internal-ui/src/features/organizations/api.ts @@ -8,13 +8,13 @@ const BASE = "/api/v1/organizations/"; export type OrganizationCreateOptions = { timezones: string[]; languages: Array<{ code: string; label: string }>; - currencies: string[]; }; +// Валюты в черновике нет: в продукте нет ни одной суммы в валюте организации, +// сервер проставляет её сам. export type OrganizationDraft = { name: string; timezone: string; - currency: string; // Пустая строка — «как в установке». language: string; }; diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index d6ceca5..7be2419 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -308,7 +308,6 @@ export const en: Record = { "ai.created_on": "created {date}", "ai.creating": "Creating…", "ai.customers_reach_agent_through_these": "Customers reach the agent through these. One connection, one agent.", - "ai.daily_limit": "Daily limit", "ai.delete_agent": "Delete the agent", "ai.delete_agent_2": "Delete the agent?", "ai.delete_attachment": "Delete the attachment", @@ -1063,24 +1062,24 @@ export const en: Record = { "onboarding.s2.name": "AI agent", "onboarding.s2.time": "4 min", "onboarding.s2.title": "Create an AI agent", - "onboarding.s2.lead": "The agent is who talks to the customer: who they are, how they answer, what rules they follow and how much they may spend per day.", + "onboarding.s2.lead": "The agent is who talks to the customer: who they are, how they answer and what rules they follow.", "onboarding.s2.path": "Agents → Create agent", "onboarding.s2.do_1": "Open Agents in the sidebar and click Create agent.", "onboarding.s2.do_2": "Describe the role and tone: who they are to the customer, what they may promise, what they must not do.", - "onboarding.s2.do_3": "Pick a model from the connected provider and a daily budget in dollars.", + "onboarding.s2.do_3": "Pick a model from the connected provider.", "onboarding.s2.do_4": "Attach knowledge base articles — the agent answers only from those.", "onboarding.s2.do_5": "Switch the status from Draft to Active, otherwise the agent stays silent.", "onboarding.s2.warning": "If there is nothing to answer from, the agent says so honestly and offers to call a human — it will not make things up.", "onboarding.s2.preview": "Agents → Consultant", "onboarding.s2.row1_label": "Model", "onboarding.s2.row1_value": "openai/gpt-4o-mini", - "onboarding.s2.row2_label": "Daily budget", - "onboarding.s2.row2_value": "$5.00", - "onboarding.s2.row3_label": "Knowledge", - "onboarding.s2.row3_value": "12 articles", + "onboarding.s2.row2_label": "Knowledge", + "onboarding.s2.row2_value": "12 articles", + "onboarding.s2.row3_label": "Answer language", + "onboarding.s2.row3_value": "Same as the customer", "onboarding.s2.chip": "Active", "onboarding.s2.tour_title": "The Agents section", - "onboarding.s2.tour_text": "The organization's agents. An agent card is its role, its model, its budget and the knowledge attached to it.", + "onboarding.s2.tour_text": "The organization's agents. An agent card is its role, its model and the knowledge attached to it.", "onboarding.s3.name": "Knowledge base", "onboarding.s3.time": "from 10 min", "onboarding.s3.title": "Fill the knowledge base", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index 9a1daf7..0f136b4 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -309,7 +309,6 @@ export const ru = { "ai.created_on": "создан {date}", "ai.creating": "Создание…", "ai.customers_reach_agent_through_these": "Через них клиенты попадают к агенту. Одно подключение — один агент.", - "ai.daily_limit": "Лимит в день", "ai.delete_agent": "Удалить агента", "ai.delete_agent_2": "Удалить агента?", "ai.delete_attachment": "Удалить вложение", @@ -1064,24 +1063,24 @@ export const ru = { "onboarding.s2.name": "AI-агент", "onboarding.s2.time": "4 мин", "onboarding.s2.title": "Создайте AI-агента", - "onboarding.s2.lead": "Агент — тот, кто говорит с клиентом: кто он, как отвечает, каким правилам подчиняется и сколько может потратить в день.", + "onboarding.s2.lead": "Агент — тот, кто говорит с клиентом: кто он, как отвечает и каким правилам подчиняется.", "onboarding.s2.path": "Агенты → Создать агента", "onboarding.s2.do_1": "Откройте «Агенты» в сайдбаре и нажмите «Создать агента».", "onboarding.s2.do_2": "Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя.", - "onboarding.s2.do_3": "Выберите модель подключённого провайдера и дневной бюджет в долларах.", + "onboarding.s2.do_3": "Выберите модель подключённого провайдера.", "onboarding.s2.do_4": "Прикрепите статьи базы знаний — агент отвечает только из них.", "onboarding.s2.do_5": "Переведите статус из «Черновик» в «Активен», иначе агент молчит.", "onboarding.s2.warning": "Если данных для ответа нет, агент честно это скажет и предложит позвать сотрудника — придумывать он не станет.", "onboarding.s2.preview": "Агенты → Консультант", "onboarding.s2.row1_label": "Модель", "onboarding.s2.row1_value": "openai/gpt-4o-mini", - "onboarding.s2.row2_label": "Дневной бюджет", - "onboarding.s2.row2_value": "$5.00", - "onboarding.s2.row3_label": "Знания", - "onboarding.s2.row3_value": "12 статей", + "onboarding.s2.row2_label": "Знания", + "onboarding.s2.row2_value": "12 статей", + "onboarding.s2.row3_label": "Язык ответов", + "onboarding.s2.row3_value": "Как у клиента", "onboarding.s2.chip": "Активен", "onboarding.s2.tour_title": "Раздел «Агенты»", - "onboarding.s2.tour_text": "Список агентов организации. Карточка агента — это его роль, модель, бюджет и прикреплённые знания.", + "onboarding.s2.tour_text": "Список агентов организации. Карточка агента — это его роль, модель и прикреплённые знания.", "onboarding.s3.name": "База знаний", "onboarding.s3.time": "от 10 мин", "onboarding.s3.title": "Наполните базу знаний", diff --git a/tests/e2e/internal-ui.spec.ts b/tests/e2e/internal-ui.spec.ts index 4962bd4..f8263b0 100644 --- a/tests/e2e/internal-ui.spec.ts +++ b/tests/e2e/internal-ui.spec.ts @@ -527,7 +527,8 @@ test("владелец добавляет организацию из перек // Сразу в новой организации: адрес и переключатель показывают её. await expect(page).toHaveURL(new RegExp(`/organizations/${NEW_ORGANIZATION_PUBLIC_ID}/chat`)); await expect(page.locator(".hub-brand-switch span")).toHaveText("Вторая компания"); - expect(created).toEqual({ name: "Вторая компания", timezone: "Europe/Moscow", currency: "RUB", language: "en" }); + // Валюты в теле нет: выбора её в интерфейсе больше нет, сервер ставит сам. + expect(created).toEqual({ name: "Вторая компания", timezone: "Europe/Moscow", language: "en" }); }); test("сотрудник без прав менеджера не видит «Добавить организацию»", async ({ page }) => {