Bariki cashback, order edit, and current work in progress

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+3 -3
View File
@@ -72,7 +72,7 @@ export function WebRequests() {
<b>{r.number}</b>
<div className="bc-small bc-muted">{t('admin.webRequests.created', { date: dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}</div>
</td>
<td className="bc-small">{dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</td>
<td className="bc-small">{r.preferredAt ? dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : '—'}</td>
<td>
<b>{t(`params.cleaning.cleaningType.${p.cleaningType ?? 'regular'}`)}</b>
<div className="bc-small">
@@ -83,8 +83,8 @@ export function WebRequests() {
)}
</td>
<td className="bc-small">
{t(`districts.${r.district.code}`)}
<div>{r.addressText}</div>
{r.district ? t(`districts.${r.district.code}`) : '—'}
<div>{r.addressText ?? '—'}</div>
{r.note && <div className="bc-muted">{r.note}</div>}
</td>
<td>
+3 -1
View File
@@ -16,7 +16,9 @@
"dev:demo": "tsx scripts/dev-demo.ts",
"dev:bo-demo": "tsx scripts/dev-backoffice-demo.ts",
"dev:chats-demo": "tsx scripts/dev-chats-demo.ts",
"dev:crm-demo": "tsx scripts/dev-crm-demo.ts"
"dev:crm-demo": "tsx scripts/dev-crm-demo.ts",
"dev:partners-demo": "tsx scripts/dev-partners-demo.ts",
"dev:points-demo": "tsx scripts/dev-points-demo.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1152.0",
+81
View File
@@ -0,0 +1,81 @@
/**
* Локальная разработка: демо партнёра услуг (клининг) — заказы у партнёра, сотрудники, доход,
* и одноразовая ссылка на кабинет менеджера в браузере.
* pnpm --filter @barev/api dev:partners-demo
* Клиенты демо — telegramId 100003000+. Повторный запуск пересоздаёт их заказы.
*/
import { fromYerevan, toYerevan } from '@barev/core';
import { getDb } from '@barev/db';
import { createOrder, createProWebLogin, dispatcherAssign, giveConsent, markPaid, offerOrder, startClient, transitionOrder, type Ctx } from '@barev/services';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const ctx: Ctx = { db, notify: { emit: async () => {} }, now: () => new Date() };
const TG_FROM = 100003000n;
const PRO_APP = process.env.PRO_WEB_URL ?? 'http://localhost:5187';
// ── чистим прошлое демо ──
const old = await db.user.findMany({ where: { telegramId: { gte: TG_FROM, lt: TG_FROM + 100n } }, include: { client: true } });
const oldClients = old.map((u) => u.client?.id).filter((x): x is string => !!x);
const oldOrders = (await db.order.findMany({ where: { clientId: { in: oldClients } }, select: { id: true } })).map((o) => o.id);
await db.partnerEarning.deleteMany({ where: { orderId: { in: oldOrders } } });
await db.agencyCommission.deleteMany({ where: { orderId: { in: oldOrders } } });
await db.order.deleteMany({ where: { id: { in: oldOrders } } });
let company = await db.providerCompany.findFirst({ where: { active: true, roles: { has: 'CLEANER' } } });
if (!company) throw new Error('Нет компании-партнёра — запустите seed');
company = await db.providerCompany.update({ where: { id: company.id }, data: { deal: 'PROFIT_SHARE', profitSharePct: 40, fotPct: null, taxPct: null } });
const manager = await db.companyDispatcher.findFirstOrThrow({ where: { companyId: company.id } });
const staff = await db.provider.findMany({ where: { companyId: company.id, active: true } });
// график всем сотрудникам: каждый день 9–20, лимит 3 заказа в день у первого
for (const [i, p] of staff.entries()) {
await db.providerSchedule.deleteMany({ where: { providerId: p.id } });
await db.providerSchedule.createMany({ data: [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ providerId: p.id, weekday, startMinute: 9 * 60, endMinute: 20 * 60 })) });
await db.provider.update({ where: { id: p.id }, data: { maxOrdersPerDay: i === 0 ? 3 : null, maxHoursPerDay: i === 0 ? 8 : null } });
}
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const admin = await db.adminUser.findFirstOrThrow({ where: { email: 'owner@barev.local' } });
const at = (dayOffset: number, hour: number) => {
const p = toYerevan(new Date(Date.now() + dayOffset * 24 * 3600_000));
return fromYerevan(p.year, p.month, p.day, hour * 60);
};
const NAMES = ['Ольга', 'James', 'Ирина', 'Arpi', 'Лилит'];
async function demoOrder(i: number, when: Date, rooms: number) {
const s = await startClient(ctx, { id: Number(TG_FROM) + i, first_name: NAMES[i % NAMES.length]!, language_code: i % 2 ? 'en' : 'ru' });
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms, bathrooms: 1 }, scheduledAt: when, addressText: `Абовяна ${10 + i}`, districtId: kentron.id, allowBusySlot: true });
}
// 1) два заказа ждут назначения у партнёра
for (const [i, d] of [
[0, 2],
[1, 3],
] as const) {
const o = await demoOrder(i, at(d, 11), 2 + i);
await offerOrder(ctx, { orderId: o.id, adminId: admin.id, target: { kind: 'company', id: company.id } });
}
// 2) один назначен на сотрудника
const assigned = await demoOrder(2, at(2, 15), 1);
await offerOrder(ctx, { orderId: assigned.id, adminId: admin.id, target: { kind: 'company', id: company.id } });
const linked = staff.find((p) => p.userId) ?? staff[0]!;
await dispatcherAssign(ctx, { orderId: assigned.id, companyId: company.id, providerId: linked.id, dispatcherUserId: manager.userId });
// 3) один выполнен и оплачен — доход партнёра
const done = await demoOrder(3, at(1, 10), 3);
await offerOrder(ctx, { orderId: done.id, adminId: admin.id, target: { kind: 'company', id: company.id } });
await dispatcherAssign(ctx, { orderId: done.id, companyId: company.id, providerId: linked.id, dispatcherUserId: manager.userId });
for (const to of ['EN_ROUTE', 'CLEANING'] as const) await transitionOrder(ctx, { orderId: done.id, to, actor: 'ADMIN', actorId: admin.id, event: 'demo' });
await db.orderChecklistItem.updateMany({ where: { orderId: done.id }, data: { checkedAt: new Date() } });
await db.order.update({ where: { id: done.id }, data: { status: 'DONE', completedAt: new Date() } });
await markPaid(ctx, { orderId: done.id, by: { type: 'ADMIN', id: admin.id } });
// 4) новый заказ без исполнителя — оператор выбирает, кому отдать (видно, что выгоднее)
const fresh = await demoOrder(4, at(4, 12), 2);
console.log(`Заказ для назначения: #${fresh.number} (${fresh.id})`);
const code = await createProWebLogin(ctx, manager.userId);
console.log(`Партнёр: ${company.name}`);
console.log(`Кабинет менеджера в браузере (ссылка одноразовая, 15 минут):\n${PRO_APP}/?login=${code}#/company`);
process.exit(0);
+46
View File
@@ -0,0 +1,46 @@
/**
* Локальная разработка: демо бариков у клиента «Анна» (telegramId 100000001):
* начисления (доступные и в ожидании), бонус, списание, открытый запрос и предложение системы.
* pnpm --filter @barev/api dev:points-demo
*/
import { DAY } from '@barev/core';
import { getDb } from '@barev/db';
import { accruePoints, redeemPoints, requestRedemption, type Ctx } from '@barev/services';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const ctx: Ctx = { db, notify: { emit: async () => {} }, now: () => new Date() };
const OWNER = { adminId: null, owner: true };
const user = await db.user.findUniqueOrThrow({ where: { telegramId: 100000001n }, include: { client: true } });
const clientId = user.client!.id;
// чистим прошлое демо этого клиента
await db.pointsRedemptionItem.deleteMany({ where: { redemption: { clientId } } });
await db.pointsRedemption.deleteMany({ where: { clientId } });
await db.pointsReversal.deleteMany({ where: { clientId } });
await db.pointsSuggestion.deleteMany({ where: { clientId } });
await db.pointsAccrual.deleteMany({ where: { clientId } });
await db.client.update({ where: { id: clientId }, data: { pointsActive: 0, pointsPending: 0, pointsDebt: 0, streakCount: 0, streakLastAt: null, pointsActivityAt: null } });
const cat = (code: string) => db.pointsCategory.findUniqueOrThrow({ where: { code } });
const now = Date.now();
const ago = (d: number) => new Date(now - d * DAY);
const cleaning = await cat('cleaning');
for (const [i, d] of [50, 25, 3].entries()) {
await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: cleaning.id, orderAmount: 18_000 + i * 2000, serviceDate: ago(d), orderRef: `DEMO-C${i}`, channel: 'WEB' }, OWNER);
}
await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: (await cat('documents')).id, orderAmount: 45_000, excludedAmount: 15_000, serviceDate: ago(10), orderRef: 'DEMO-D1', channel: 'WEB' }, OWNER);
// ожидание 48 ч от даты услуги: старые уже доступны — активируем
const { activatePendingPoints } = await import('@barev/services');
await activatePendingPoints(ctx);
// свежая уборка — ещё в ожидании
await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: cleaning.id, orderAmount: 19_000, serviceDate: new Date(), orderRef: 'DEMO-C3', channel: 'AUTO' }, OWNER);
const bonus = await db.pointsBonusType.findUniqueOrThrow({ where: { code: 'housewarming' } });
await accruePoints(ctx, { clientId, kind: 'BONUS', bonusTypeId: bonus.id, amount: 5000, serviceDate: new Date(), channel: 'WEB', commentForClient: 'Yerevan Home' }, OWNER);
await redeemPoints(ctx, { clientId, amount: 2000, orderAmount: 20_000, orderRef: 'DEMO-R1', reason: 'оплата по звонку', channel: 'WEB' }, OWNER);
await requestRedemption(ctx, { clientId, amount: 1500, note: 'Уборка 30 октября, 18 000 ֏' });
await db.pointsSuggestion.create({ data: { clientId, mechanic: 'first_month_route', dedupeKey: `route:${clientId}`, payload: { categories: ['cleaning', 'documents', 'learning'] }, suggestedAmount: 3000 } });
const c = await db.client.findUniqueOrThrow({ where: { id: clientId } });
console.log(`Анна: доступно ${c.pointsActive}, в ожидании ${c.pointsPending}, серия ${c.streakCount}`);
process.exit(0);
+2
View File
@@ -7,6 +7,7 @@ import { boOrdersRouter } from './orders.ts';
import { boProvidersRouter } from './providers.ts';
import { boCrmRouter } from './crm.ts';
import { boPartnersRouter } from './partners.ts';
import { boPointsRouter } from './points.ts';
import { boProductsRouter } from './products.ts';
import { boMovesRouter, boRequestsRouter } from './moves.ts';
import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts';
@@ -25,6 +26,7 @@ export const backofficeRouter = router({
providers: boProvidersRouter,
crm: boCrmRouter,
partners: boPartnersRouter,
points: boPointsRouter,
products: boProductsRouter,
broadcasts: boBroadcastsRouter,
journal: boJournalRouter,
+22 -3
View File
@@ -9,6 +9,9 @@ import {
getServiceByCode,
getSettings,
markPaid,
previewOrderEdit,
editOrderComposition,
staffChecklistItem,
offerOrder,
orderPayout,
quoteForClient,
@@ -210,7 +213,7 @@ export const boOrdersRouter = router({
const pending = o.offers.find((x) => x.status === 'OFFERED');
const adminIds = [...new Set(o.offers.map((x) => x.createdById).filter((x): x is string => !!x))];
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: adminIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
const payout = ctx.isSuper && o.provider ? orderPayout(o, o.provider) : null;
const payout = ctx.isSuper && o.provider ? orderPayout(o, o.provider, { fotPct: (await getSettings(ctx.db))['partner.fotPct'] }) : null;
const sub = o.client.subscriptions.find((s) => s.startsAt <= now && s.endsAt > now);
return {
id: o.id,
@@ -246,7 +249,7 @@ export const boOrdersRouter = router({
respondedAt: x.respondedAt,
})),
history: await historyView(ctx.db, o),
checklist: o.checklist.map((c) => ({ code: c.code, done: !!c.checkedAt, requiresPhoto: c.requiresPhoto, fileId: c.fileId })),
checklist: o.checklist.map((c) => ({ code: c.code, done: !!c.checkedAt, requiresPhoto: c.requiresPhoto, fileId: c.fileId, waived: c.photoWaivedReason, byStaff: !!c.checkedByAdminId })),
photos: o.files.filter((f) => f.kind === 'PHOTO_REPORT').map((f) => f.id),
rating: o.rating ? { score: o.rating.score, comment: o.rating.comment } : null,
// деньги — только суперадмину; статус оплаты видят все (поддержка отмечает оплату наличными)
@@ -267,7 +270,7 @@ export const boOrdersRouter = router({
/** Подсказка: свободные кандидаты по рейтингу подбора и занятые */
candidates: ordersProcedure.input(idInput).query(async ({ ctx, input }) => {
const { ranked, busy } = await assignmentCandidates(ctx.svc, input.id);
const { ranked, busy, bestId } = await assignmentCandidates(ctx.svc, input.id);
const tried = new Set((await ctx.db.orderOffer.findMany({ where: { orderId: input.id }, select: { providerId: true, companyId: true, status: true } })).filter((o) => o.status !== 'OFFERED').map((o) => o.providerId ?? o.companyId));
const view = (c: (typeof ranked)[number] | (typeof busy)[number]) => ({
kind: c.kind,
@@ -279,6 +282,12 @@ export const boOrdersRouter = router({
onShift: c.onShift,
reasons: 'reasons' in c ? c.reasons : [],
tried: tried.has(c.id),
/** самый выгодный для нас из доступных — подсвечивается всем; суммы видит только суперадмин */
best: `${c.kind}:${c.id}` === bestId,
cost: ctx.isSuper ? c.cost : null,
margin: ctx.isSuper ? c.margin : null,
partnerFree: c.partnerFree ?? null,
partnerStaff: c.partnerStaff ?? null,
});
return { free: ranked.map(view), busy: busy.map(view) };
}),
@@ -320,6 +329,16 @@ export const boOrdersRouter = router({
// ───── новый заказ за клиента ─────
/** Услуги, которые можно заказать сейчас, с формой параметров */
/** Изменить состав заказа: предпросмотр и сохранение (пересчёт цены или своя цена с причиной) */
editPreview: ordersProcedure.input(z.object({ id: z.string(), params: z.record(z.string(), z.unknown()) })).query(({ ctx, input }) => previewOrderEdit(ctx.svc, { orderId: input.id, params: input.params })),
edit: ordersProcedure
.input(z.object({ id: z.string(), params: z.record(z.string(), z.unknown()), totalPrice: z.number().int().min(0).nullable().optional(), reason: z.string().trim().max(500).nullable().optional(), notify: z.boolean().default(false) }))
.mutation(({ ctx, input }) => editOrderComposition(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, params: input.params, totalPrice: input.totalPrice, reason: input.reason, notify: input.notify })),
/** Чек-лист за исполнителя */
checklist: ordersProcedure
.input(z.object({ id: z.string(), code: z.string().max(60), checked: z.boolean(), reason: z.string().trim().max(500).nullable().optional() }))
.mutation(({ ctx, input }) => staffChecklistItem(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, code: input.code, checked: input.checked, reason: input.reason })),
services: ordersProcedure.query(async ({ ctx }) => {
const list = await ctx.db.service.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { code: true } });
return Promise.all(
+124 -2
View File
@@ -1,5 +1,5 @@
import { DAY, normalizeAgencyCode } from '@barev/core';
import { agencyLink, createOwnerInvite, savePartnerOffer } from '@barev/services';
import { agencyLink, createOwnerInvite, getSettings, managerInviteCode, partnerMoney, savePartnerOffer, termsOf } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { router, superProcedure } from '../../trpc.ts';
@@ -39,6 +39,8 @@ export const boPartnersRouter = router({
code: a.code,
name: a.name,
commissionPct: a.commissionPct,
giftPlan: a.giftPlan,
giftMonths: a.giftMonths,
active: a.active,
sales: a.sales,
clients: a._count.clients,
@@ -72,6 +74,10 @@ export const boPartnersRouter = router({
code: a.code,
name: a.name,
commissionPct: a.commissionPct,
giftPlan: a.giftPlan,
giftMonths: a.giftMonths,
housewarmingPoints: a.housewarmingPoints,
housewarmingDays: a.housewarmingDays,
contact: a.contact,
note: a.note,
active: a.active,
@@ -92,7 +98,7 @@ export const boPartnersRouter = router({
}),
saveAgency: p
.input(z.object({ id: z.string().optional(), code: z.string().max(32), name: z.string().trim().min(1).max(100), commissionPct: z.number().int().min(0).max(100), contact: z.string().max(200).nullable(), note: z.string().max(2000).nullable(), active: z.boolean(), salesId: z.string().nullable() }))
.input(z.object({ id: z.string().optional(), code: z.string().max(32), name: z.string().trim().min(1).max(100), commissionPct: z.number().int().min(0).max(100), giftPlan: z.enum(['BAREV_PLUS', 'ELITE']).nullable(), giftMonths: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(6), z.literal(12)]), housewarmingPoints: z.number().int().min(0).max(1_000_000).default(0), housewarmingDays: z.number().int().min(1).max(1000).default(60), contact: z.string().max(200).nullable(), note: z.string().max(2000).nullable(), active: z.boolean(), salesId: z.string().nullable() }))
.mutation(async ({ ctx, input }) => {
const code = normalizeAgencyCode(input.code);
if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' });
@@ -110,6 +116,122 @@ export const boPartnersRouter = router({
await ctx.db.payout.create({ data: { agencyId: input.id, amount: input.amount, status: 'PAID', paidAt: now, note: input.note, createdById: ctx.admin.id, periodStart: last?.periodEnd ?? monthStart(now), periodEnd: now } });
}),
// ───── партнёры услуг (клининговые компании) ─────
servicePartners: p.query(async ({ ctx }) => {
const settings = await getSettings(ctx.db);
const list = await ctx.db.providerCompany.findMany({
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: { _count: { select: { employees: { where: { active: true } }, dispatchers: true } } },
});
const ids = list.map((c) => c.id);
const now = ctx.svc.now();
const [active, month, accrued, paid] = await Promise.all([
ctx.db.order.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED'] } }, _count: true }),
ctx.db.partnerEarning.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, period: monthKey(now) }, _sum: { amount: true } }),
ctx.db.partnerEarning.groupBy({ by: ['companyId'], where: { companyId: { in: ids } }, _sum: { amount: true } }),
ctx.db.payout.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, status: 'PAID' }, _sum: { amount: true } }),
]);
const m = <T extends { companyId: string | null }>(rows: T[], f: (r: T) => number) => new Map(rows.map((r) => [r.companyId, f(r)]));
const act = m(active, (r) => r._count);
const mon = m(month, (r) => r._sum.amount ?? 0);
const acc = m(accrued, (r) => r._sum.amount ?? 0);
const pd = m(paid, (r) => r._sum.amount ?? 0);
return list.map((c) => ({
id: c.id,
name: c.name,
active: c.active,
deal: c.deal,
terms: termsOf(c, settings),
autoAssign: c.autoAssign,
staff: c._count.employees,
managers: c._count.dispatchers,
activeOrders: act.get(c.id) ?? 0,
thisMonth: mon.get(c.id) ?? 0,
balance: (acc.get(c.id) ?? 0) - (pd.get(c.id) ?? 0),
}));
}),
servicePartner: p.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const c = await ctx.db.providerCompany.findUnique({
where: { id: input.id },
include: { employees: { orderBy: [{ active: 'desc' }, { name: 'asc' }] }, dispatchers: { include: { user: true }, orderBy: { createdAt: 'asc' } } },
});
if (!c) throw new TRPCError({ code: 'NOT_FOUND' });
const settings = await getSettings(ctx.db);
const [money, activeOrders] = await Promise.all([
partnerMoney(ctx.db, c.id),
ctx.db.order.findMany({
where: { companyId: c.id, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED'] } },
orderBy: { scheduledAt: 'asc' },
take: 30,
include: { service: { select: { code: true } }, provider: { select: { name: true } } },
}),
]);
return {
id: c.id,
name: c.name,
contactPhone: c.contactPhone,
note: c.note,
active: c.active,
deal: c.deal,
commissionPct: c.commissionPct,
profitSharePct: c.profitSharePct,
fotPct: c.fotPct,
taxPct: c.taxPct,
terms: termsOf(c, settings),
defaults: { fotPct: settings['partner.fotPct'], taxPct: settings['partner.taxPct'] },
autoAssign: c.autoAssign,
travelGapMinutes: c.travelGapMinutes,
createdAt: c.createdAt,
managerLink: proBotUrl(c.inviteCode),
managers: c.dispatchers.map((d) => ({ id: d.id, name: name(d.user), since: d.createdAt })),
staff: c.employees.map((e) => ({ id: e.id, name: e.name, phone: e.phone, active: e.active, linked: !!e.userId, maxOrdersPerDay: e.maxOrdersPerDay, maxHoursPerDay: e.maxHoursPerDay })),
orders: activeOrders.map((o) => ({ id: o.id, number: o.number, service: o.service.code, scheduledAt: o.scheduledAt, status: o.status, staff: o.provider?.name ?? null })),
money,
};
}),
saveServicePartner: p
.input(
z.object({
id: z.string().optional(),
name: z.string().trim().min(1).max(100),
contactPhone: z.string().trim().max(30).nullable(),
note: z.string().max(2000).nullable(),
active: z.boolean(),
deal: z.enum(['PROFIT_SHARE', 'COMMISSION']),
commissionPct: z.number().int().min(0).max(100),
profitSharePct: z.number().int().min(0).max(100),
fotPct: z.number().int().min(0).max(100).nullable(),
taxPct: z.number().int().min(0).max(100).nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
// пока партнёры услуг — только клининг
const c = id
? await ctx.db.providerCompany.update({ where: { id }, data })
: await ctx.db.providerCompany.create({ data: { ...data, roles: ['CLEANER'], inviteCode: managerInviteCode() } });
return { id: c.id };
}),
/** Новая ссылка для менеджеров — старая перестаёт работать (уже подключённые остаются) */
resetManagerLink: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const c = await ctx.db.providerCompany.update({ where: { id: input.id }, data: { inviteCode: managerInviteCode() } });
return { link: proBotUrl(c.inviteCode) };
}),
removeManager: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.companyDispatcher.delete({ where: { id: input.id } });
}),
servicePartnerPayout: p.input(z.object({ id: z.string(), amount: z.number().int().min(1), note: z.string().max(300).optional() })).mutation(async ({ ctx, input }) => {
const now = ctx.svc.now();
const last = await ctx.db.payout.findFirst({ where: { companyId: input.id }, orderBy: { periodEnd: 'desc' } });
await ctx.db.payout.create({ data: { companyId: input.id, amount: input.amount, status: 'PAID', paidAt: now, note: input.note, createdById: ctx.admin.id, periodStart: last?.periodEnd ?? monthStart(now), periodEnd: now } });
}),
// ───── заведения ─────
venues: p.query(async ({ ctx }) => {
+398
View File
@@ -0,0 +1,398 @@
import { DAY, membershipLevel } from '@barev/core';
import type { Db } from '@barev/db';
import {
acceptSuggestion,
accruePoints,
confirmRedemption,
getSettings,
pointsHistory,
previewAccrual,
redeemPoints,
rejectRedemption,
rejectSuggestion,
reverseAccrual,
reversePreview,
reverseRedemption,
type PointsActor,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/**
* Барики в новой админке. Управляет суперадмин; поддержка — только если включено в настройках,
* с лимитами на операцию и на сутки (выше — подтверждение суперадмина).
*/
const pointsProcedure = rolesProcedure('SUPPORT').use(async ({ ctx, next }) => {
if (!ctx.isSuper && !(await getSettings(ctx.db))['points.supportCanManage']) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return next({ ctx: { actor: { adminId: ctx.admin.id, owner: ctx.isSuper } satisfies PointsActor } });
});
const name = (u: { firstName: string | null; lastName: string | null; username: string | null }) => [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
const L5 = z.object({ ru: z.string().max(100), en: z.string().max(100), zh: z.string().max(100), hy: z.string().max(100), es: z.string().max(100) });
const idem = z.string().min(8).max(80).optional();
/** Клиент по телефону, @username, имени или ID */
async function findClients(db: Db, q: string) {
const s = q.trim().replace(/^@/, '');
const digits = s.replace(/[^\d]/g, '');
return db.client.findMany({
where: {
OR: [
{ id: s },
{ cardCode: s.toUpperCase() },
{ user: { username: { equals: s, mode: 'insensitive' } } },
{ user: { firstName: { contains: s, mode: 'insensitive' } } },
{ user: { lastName: { contains: s, mode: 'insensitive' } } },
...(digits.length >= 5 ? [{ user: { phone: { contains: digits } } }] : []),
],
},
include: { user: true, subscriptions: { where: { status: 'ACTIVE' } } },
take: 20,
orderBy: { createdAt: 'desc' },
});
}
export const boPointsRouter = router({
/** Справочники формы: категории, типы бонусов, правила */
dictionaries: pointsProcedure.query(async ({ ctx }) => {
const [categories, bonusTypes, s] = await Promise.all([
ctx.db.pointsCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
ctx.db.pointsBonusType.findMany({ orderBy: { sortOrder: 'asc' } }),
getSettings(ctx.db),
]);
return {
categories: categories.map((c) => ({ ...c, name: c.name as Record<string, string> })),
bonusTypes: bonusTypes.map((b) => ({ ...b, name: b.name as Record<string, string> })),
rules: { pendingHours: s['points.pendingHours'], sharePct: s['points.maxSharePct'], min: s['points.minRedeem'], burnDays: s['points.burnDays'], diffPct: s['points.diffHighlightPct'] },
owner: ctx.isSuper,
};
}),
search: pointsProcedure.input(z.object({ q: z.string().trim().min(1).max(100) })).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
return (await findClients(ctx.db, input.q)).map((c) => ({
id: c.id,
name: name(c.user),
username: c.user.username,
phone: c.user.phone,
tier: membershipLevel(c.subscriptions, now),
balance: c.pointsActive,
pending: c.pointsPending,
}));
}),
/** Карточка клиента: баланс, ожидание, сгорание, источник, флаг проверки, последние операции */
client: pointsProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const s = await getSettings(ctx.db);
const now = ctx.svc.now();
const c = await ctx.db.client.findUnique({ where: { id: input.id }, include: { user: true, agency: { select: { name: true } }, subscriptions: { where: { status: 'ACTIVE' } } } });
if (!c) throw new TRPCError({ code: 'NOT_FOUND' });
const [history, reversed30, pendingNext] = await Promise.all([
pointsHistory(ctx.db, c.id, { take: 10 }),
ctx.db.pointsReversal.count({ where: { clientId: c.id, targetType: 'ACCRUAL', createdAt: { gte: new Date(now.getTime() - 30 * DAY) } } }),
ctx.db.pointsAccrual.findFirst({ where: { clientId: c.id, status: 'PENDING' }, orderBy: { availableAt: 'asc' } }),
]);
return {
id: c.id,
name: name(c.user),
phone: c.user.phone,
username: c.user.username,
tier: membershipLevel(c.subscriptions, now),
agency: c.agency?.name ?? null,
balance: c.pointsActive,
pending: c.pointsPending,
pendingNextAt: pendingNext?.availableAt ?? null,
debt: c.pointsDebt,
burnAt: c.pointsActive > 0 && c.pointsActivityAt ? new Date(c.pointsActivityAt.getTime() + s['points.burnDays'] * DAY) : null,
streak: c.streakCount,
flagged: reversed30 > 2,
history,
};
}),
preview: pointsProcedure
.input(z.object({ clientId: z.string(), categoryId: z.string(), orderAmount: z.number().int().min(0), excludedAmount: z.number().int().min(0).default(0), paidWithPoints: z.number().int().min(0).default(0), serviceDate: z.coerce.date() }))
.query(async ({ ctx, input }) => previewAccrual(ctx.db, input, await getSettings(ctx.db), ctx.svc.now())),
/** Начисление по заказу или бонус */
accrue: pointsProcedure
.input(
z.object({
clientId: z.string(),
kind: z.enum(['ORDER', 'BONUS']),
categoryId: z.string().optional(),
bonusTypeId: z.string().optional(),
orderRef: z.string().trim().max(60).nullable().optional(),
orderAmount: z.number().int().min(0).optional(),
excludedAmount: z.number().int().min(0).optional(),
paidWithPoints: z.number().int().min(0).optional(),
serviceDate: z.coerce.date(),
amount: z.number().int().min(1).max(10_000_000).optional(),
reason: z.string().trim().max(500).nullable().optional(),
commentForClient: z.string().trim().max(300).nullable().optional(),
noWait: z.boolean().optional(),
ttlDays: z.number().int().min(1).max(1000).nullable().optional(),
idempotencyKey: idem,
}),
)
.mutation(({ ctx, input }) => accruePoints(ctx.svc, { ...input, channel: 'WEB' }, ctx.actor)),
/** Списание командой: по заказу вне системы или по звонку (с причиной) */
redeem: pointsProcedure
.input(z.object({ clientId: z.string(), amount: z.number().int().min(1), orderRef: z.string().trim().max(60).nullable().optional(), orderAmount: z.number().int().min(1).nullable().optional(), reason: z.string().trim().min(1).max(500), idempotencyKey: idem }))
.mutation(({ ctx, input }) => redeemPoints(ctx.svc, { ...input, channel: 'WEB' }, ctx.actor)),
/** Запросы клиентов «Оплатить бариками» */
requests: pointsProcedure.query(async ({ ctx }) => {
const list = await ctx.db.pointsRedemption.findMany({ where: { status: 'REQUESTED' }, orderBy: { requestedAt: 'asc' }, include: { client: { include: { user: true } }, order: { select: { id: true, number: true, totalPrice: true, pointsUsed: true } } } });
return list.map((r) => ({ id: r.id, clientId: r.clientId, name: name(r.client.user), balance: r.client.pointsActive, amount: r.amount, note: r.note, order: r.order, requestedAt: r.requestedAt, expiresAt: r.expiresAt }));
}),
confirm: pointsProcedure
.input(z.object({ id: z.string(), orderAmount: z.number().int().min(1), amount: z.number().int().min(1).optional() }))
.mutation(({ ctx, input }) => confirmRedemption(ctx.svc, { redemptionId: input.id, orderAmount: input.orderAmount, amount: input.amount }, ctx.actor)),
reject: pointsProcedure.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500) })).mutation(({ ctx, input }) => rejectRedemption(ctx.svc, { redemptionId: input.id, reason: input.reason }, ctx.actor)),
reversePreview: pointsProcedure.input(z.object({ type: z.enum(['ACCRUAL', 'REDEMPTION']), id: z.string() })).query(({ ctx, input }) => reversePreview(ctx.db, input)),
reverse: pointsProcedure
.input(z.object({ type: z.enum(['ACCRUAL', 'REDEMPTION']), id: z.string(), reason: z.string().trim().min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
if (input.type === 'ACCRUAL') await reverseAccrual(ctx.svc, { accrualId: input.id, reason: input.reason, channel: 'WEB' }, ctx.actor);
else await reverseRedemption(ctx.svc, { redemptionId: input.id, reason: input.reason, channel: 'WEB' }, ctx.actor);
}),
/** Журнал: все операции с автором и каналом; фильтры; выгрузка — на клиенте */
ledger: pointsProcedure
.input(
z.object({
clientId: z.string().optional(),
adminId: z.string().optional(),
type: z.enum(['accrual', 'bonus', 'redemption', 'reversal']).optional(),
categoryId: z.string().optional(),
channel: z.enum(['WEB', 'BOT', 'CSV', 'AUTO', 'CLIENT']).optional(),
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
take: z.number().int().min(1).max(2000).default(200),
}),
)
.query(async ({ ctx, input }) => {
const s = await getSettings(ctx.db);
const at = input.from || input.to ? { gte: input.from, lt: input.to } : undefined;
const want = (t: string) => !input.type || input.type === t;
const [acc, red, rev, admins] = await Promise.all([
want('accrual') || want('bonus')
? ctx.db.pointsAccrual.findMany({
where: { clientId: input.clientId, createdById: input.adminId, categoryId: input.categoryId, channel: input.channel, createdAt: at, ...(input.type === 'accrual' ? { kind: 'ORDER' } : input.type === 'bonus' ? { kind: 'BONUS' } : {}) },
include: { client: { include: { user: true } }, category: true, bonusType: true },
orderBy: { createdAt: 'desc' },
take: input.take,
})
: [],
want('redemption') && !input.categoryId
? ctx.db.pointsRedemption.findMany({ where: { clientId: input.clientId, confirmedById: input.adminId, channel: input.channel, status: { in: ['CONFIRMED', 'REVERSED', 'REJECTED'] }, requestedAt: at }, include: { client: { include: { user: true } }, order: { select: { number: true } } }, orderBy: { requestedAt: 'desc' }, take: input.take })
: [],
want('reversal') && !input.categoryId ? ctx.db.pointsReversal.findMany({ where: { clientId: input.clientId, createdById: input.adminId, channel: input.channel, createdAt: at }, orderBy: { createdAt: 'desc' }, take: input.take }) : [],
ctx.db.adminUser.findMany({ select: { id: true, name: true } }),
]);
const adminName = new Map(admins.map((a) => [a.id, a.name]));
const revClients = rev.length ? new Map((await ctx.db.client.findMany({ where: { id: { in: [...new Set(rev.map((r) => r.clientId))] } }, include: { user: true } })).map((c) => [c.id, name(c.user)])) : new Map<string, string>();
const rows = [
...acc.map((a) => {
const diff = a.suggestedAmount ? Math.abs(a.amount - a.suggestedAmount) / Math.max(1, a.suggestedAmount) : 0;
return {
id: a.id,
at: a.createdAt,
type: a.kind === 'BONUS' ? ('bonus' as const) : ('accrual' as const),
clientId: a.clientId,
client: name(a.client.user),
amount: a.amount,
suggested: a.suggestedAmount,
highlight: a.kind === 'ORDER' && diff * 100 > s['points.diffHighlightPct'],
status: a.status as string,
what: a.kind === 'BONUS' ? ((a.bonusType?.name as Record<string, string>) ?? null) : ((a.category?.name as Record<string, string>) ?? null),
orderRef: a.orderRef,
orderAmount: a.orderAmount,
tier: a.tier,
ratePct: a.ratePct,
author: a.createdById ? (adminName.get(a.createdById) ?? '—') : null,
channel: a.channel as string,
reason: a.reason,
comment: a.commentForClient,
canReverse: a.status === 'PENDING' || a.status === 'ACTIVE',
};
}),
...red.map((r) => ({
id: r.id,
at: r.confirmedAt ?? r.requestedAt,
type: 'redemption' as const,
clientId: r.clientId,
client: name(r.client.user),
amount: -r.amount,
suggested: null,
highlight: false,
status: r.status as string,
what: null,
orderRef: r.order ? `#${r.order.number}` : r.orderRef,
orderAmount: r.orderAmount,
tier: null,
ratePct: null,
author: r.confirmedById ? (adminName.get(r.confirmedById) ?? '—') : null,
channel: r.channel as string,
reason: r.reason,
comment: r.note,
canReverse: r.status === 'CONFIRMED',
})),
...rev.map((r) => ({
id: r.id,
at: r.createdAt,
type: 'reversal' as const,
clientId: r.clientId,
client: revClients.get(r.clientId) ?? '—',
amount: r.targetType === 'ACCRUAL' ? -r.amount : r.amount,
suggested: null,
highlight: r.debt > 0,
status: r.targetType,
what: null,
orderRef: null,
orderAmount: null,
tier: null,
ratePct: null,
author: r.createdById ? (adminName.get(r.createdById) ?? '—') : null,
channel: r.channel as string,
reason: r.reason,
comment: r.debt > 0 ? `debt:${r.debt}` : null,
canReverse: false,
})),
];
return rows.sort((a, b) => b.at.getTime() - a.at.getTime()).slice(0, input.take);
}),
/** CSV: телефон, категория, сумма заказа, дата, номер заказа. dryRun — предпросмотр с ошибками */
import: pointsProcedure
.input(z.object({ dryRun: z.boolean(), rows: z.array(z.object({ phone: z.string().max(40), category: z.string().max(40), orderAmount: z.number().int().min(0), date: z.string().max(20), orderRef: z.string().max(60) })).max(1000) }))
.mutation(async ({ ctx, input }) => {
const s = await getSettings(ctx.db);
const now = ctx.svc.now();
const cats = await ctx.db.pointsCategory.findMany();
const seen = new Set<string>();
const out: { line: number; clientId?: string; client?: string; amount?: number; error?: string }[] = [];
for (const [i, r] of input.rows.entries()) {
const line = i + 1;
const digits = r.phone.replace(/[^\d]/g, '');
const cat = cats.find((c) => c.code === r.category.trim().toLowerCase() || Object.values(c.name as Record<string, string>).some((n) => n.toLowerCase() === r.category.trim().toLowerCase()));
const date = new Date(`${r.date.trim()}T12:00:00+04:00`);
const ref = r.orderRef.trim();
if (!cat) {
out.push({ line, error: 'category' });
continue;
}
if (Number.isNaN(date.getTime())) {
out.push({ line, error: 'date' });
continue;
}
const users = digits.length >= 8 ? await ctx.db.user.findMany({ where: { phone: { endsWith: digits.slice(-8) } }, include: { client: true } }) : [];
const cl = users.find((u) => u.client)?.client;
if (!cl) {
out.push({ line, error: 'client' });
continue;
}
if (!ref || seen.has(ref) || (await ctx.db.pointsAccrual.findFirst({ where: { orderRef: ref, status: { not: 'REVERSED' } } }))) {
out.push({ line, error: 'duplicate' });
continue;
}
seen.add(ref);
const p = await previewAccrual(ctx.db, { clientId: cl.id, categoryId: cat.id, orderAmount: r.orderAmount, serviceDate: date }, s, now);
const u = users.find((x) => x.client)!;
if (!input.dryRun) {
await accruePoints(ctx.svc, { clientId: cl.id, kind: 'ORDER', categoryId: cat.id, orderAmount: r.orderAmount, orderRef: ref, serviceDate: date, channel: 'CSV', idempotencyKey: `csv:${ref}` }, ctx.actor);
}
out.push({ line, clientId: cl.id, client: name(u), amount: p.suggested });
}
return { rows: out, total: out.reduce((sum, r) => sum + (r.amount ?? 0), 0), errors: out.filter((r) => r.error).length };
}),
/** Очередь «Предложено системой» */
suggestions: pointsProcedure.query(async ({ ctx }) => {
const list = await ctx.db.pointsSuggestion.findMany({ where: { status: 'OPEN' }, orderBy: { createdAt: 'asc' }, include: { client: { include: { user: true } } }, take: 200 });
const admins = new Map((await ctx.db.adminUser.findMany({ select: { id: true, name: true } })).map((a) => [a.id, a.name]));
return list.map((x) => {
const p = x.payload as Record<string, unknown>;
return { id: x.id, clientId: x.clientId, name: name(x.client.user), mechanic: x.mechanic, amount: x.suggestedAmount, payload: p, by: typeof p.adminId === 'string' ? (admins.get(p.adminId) ?? null) : null, createdAt: x.createdAt };
});
}),
acceptSuggestion: superProcedure
.input(z.object({ id: z.string(), amount: z.number().int().min(1).optional(), reason: z.string().trim().max(500).nullable().optional() }))
.mutation(({ ctx, input }) => acceptSuggestion(ctx.svc, input, { adminId: ctx.admin.id, owner: true })),
rejectSuggestion: superProcedure.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500) })).mutation(({ ctx, input }) => rejectSuggestion(ctx.svc, input, { adminId: ctx.admin.id, owner: true })),
/** Справочники — только суперадмин */
saveCategory: superProcedure
.input(z.object({ id: z.string().optional(), code: z.string().trim().regex(/^[a-z_]{2,30}$/), name: L5, rateBarev: z.number().int().min(0).max(100), ratePlus: z.number().int().min(0).max(100), rateElite: z.number().int().min(0).max(100), active: z.boolean(), sortOrder: z.number().int().default(0) }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return id ? ctx.db.pointsCategory.update({ where: { id }, data }) : ctx.db.pointsCategory.create({ data });
}),
saveBonusType: superProcedure
.input(z.object({ id: z.string().optional(), code: z.string().trim().regex(/^[a-z_]{2,30}$/), name: L5, defaultAmount: z.number().int().min(0), ttlDays: z.number().int().min(1).max(1000).nullable(), active: z.boolean(), sortOrder: z.number().int().default(0) }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return id ? ctx.db.pointsBonusType.update({ where: { id }, data }) : ctx.db.pointsBonusType.create({ data });
}),
/** Отчёт за период: начислено, потрачено, сгорело, отменено, обязательства, redemption rate, повторные заказы, топы */
report: superProcedure
.input(z.object({ from: z.coerce.date(), to: z.coerce.date(), categoryId: z.string().optional(), agencyId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const at = { gte: input.from, lt: input.to };
const clientWhere = input.agencyId ? { agencyId: input.agencyId } : {};
const accWhere = { createdAt: at, categoryId: input.categoryId, client: clientWhere };
const [accrued, spent, expired, reversed, liabilities, byCategory, topClients, topAgencies, payers] = await Promise.all([
ctx.db.pointsAccrual.aggregate({ where: accWhere, _sum: { amount: true } }),
ctx.db.pointsRedemption.aggregate({ where: { status: 'CONFIRMED', confirmedAt: at, client: clientWhere }, _sum: { amount: true } }),
// сгоревшее — по начислениям этого периода (дату сгорания журнал хранит в статусе)
ctx.db.pointsAccrual.aggregate({ where: { ...accWhere, expiredAmount: { gt: 0 } }, _sum: { expiredAmount: true } }),
ctx.db.pointsReversal.aggregate({ where: { targetType: 'ACCRUAL', createdAt: at }, _sum: { amount: true } }),
ctx.db.pointsAccrual.aggregate({ where: { status: { in: ['ACTIVE', 'PENDING'] }, categoryId: input.categoryId, client: clientWhere }, _sum: { remaining: true } }),
ctx.db.$queryRaw<{ code: string; name: Record<string, string>; points: number; revenue: number }[]>`
SELECT pc.code, pc.name, COALESCE(SUM(a.amount),0)::int AS points, COALESCE(SUM(COALESCE(a."orderAmount",0) - a."excludedAmount" - a."paidWithPoints"),0)::int AS revenue
FROM "PointsAccrual" a JOIN "PointsCategory" pc ON pc.id = a."categoryId"
WHERE a.kind = 'ORDER' AND a.status <> 'REVERSED' AND a."createdAt" >= ${input.from} AND a."createdAt" < ${input.to}
GROUP BY pc.code, pc.name`,
ctx.db.client.findMany({ where: { pointsActive: { gt: 0 }, ...clientWhere }, orderBy: { pointsActive: 'desc' }, take: 10, include: { user: true } }),
ctx.db.$queryRaw<{ name: string; n: number }[]>`
SELECT ag.name, COUNT(*)::int AS n FROM "PointsAccrual" a
JOIN "PointsBonusType" bt ON bt.id = a."bonusTypeId" AND bt.code = 'housewarming'
JOIN "Client" c ON c.id = a."clientId" JOIN "Agency" ag ON ag.id = c."agencyId"
WHERE a."createdAt" >= ${input.from} AND a."createdAt" < ${input.to}
GROUP BY ag.name ORDER BY n DESC LIMIT 10`,
// повторный заказ за 30 дней: у клиентов с бариками и без
ctx.db.$queryRaw<{ with_points: boolean; clients: number; repeat: number; multi: number }[]>`
WITH firsts AS (
SELECT o."clientId", MIN(o."createdAt") AS first_at, COUNT(DISTINCT s."pointsCategoryId") AS cats
FROM "Order" o JOIN "Service" s ON s.id = o."serviceId"
WHERE o.status <> 'CANCELLED' AND o."createdAt" >= ${input.from} AND o."createdAt" < ${input.to}
GROUP BY o."clientId"
)
SELECT (c."pointsActive" + c."pointsPending" > 0 OR EXISTS (SELECT 1 FROM "PointsAccrual" a WHERE a."clientId" = f."clientId")) AS with_points,
COUNT(*)::int AS clients,
COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Order" o2 WHERE o2."clientId" = f."clientId" AND o2.status <> 'CANCELLED' AND o2."createdAt" > f.first_at AND o2."createdAt" <= f.first_at + interval '30 days'))::int AS repeat,
COUNT(*) FILTER (WHERE f.cats >= 2)::int AS multi
FROM firsts f JOIN "Client" c ON c.id = f."clientId"
GROUP BY 1`,
]);
const a = accrued._sum.amount ?? 0;
const sp = spent._sum.amount ?? 0;
return {
accrued: a,
spent: sp,
expired: expired._sum.expiredAmount ?? 0,
reversed: reversed._sum.amount ?? 0,
liabilities: liabilities._sum.remaining ?? 0,
redemptionRate: a ? Math.round((sp / a) * 1000) / 10 : 0,
byCategory: byCategory.map((c) => ({ ...c, costPct: c.revenue ? Math.round((c.points / c.revenue) * 1000) / 10 : 0 })),
topClients: topClients.map((c) => ({ id: c.id, name: name(c.user), balance: c.pointsActive })),
topAgencies,
retention: payers.map((p) => ({ withPoints: p.with_points, clients: p.clients, repeatPct: p.clients ? Math.round((p.repeat / p.clients) * 1000) / 10 : 0, multi: p.multi })),
};
}),
});
+6 -5
View File
@@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto';
import { averageRating, DAY, LOCALES, PROVIDER_ROLES, startOfYerevanDay } from '@barev/core';
import type { Prisma } from '@barev/db';
import { orderPayout, rateLabel } from '@barev/services';
import { getSettings, orderPayout, rateLabel } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env.ts';
@@ -30,7 +30,7 @@ const profileInput = z.object({
rate: z.object({ rateType: z.enum(['FIXED', 'PERCENT']), rateValue: z.number().int().min(0), payoutPresetId: z.string().nullable() }).optional(),
});
const payoutInclude = { company: { select: { rateType: true, rateValue: true } }, payoutPreset: { select: { name: true, rules: true } } } as const;
const payoutInclude = { company: { select: { deal: true, fotPct: true } }, payoutPreset: { select: { name: true, rules: true } } } as const;
export const boProvidersRouter = router({
list: providersProcedure
@@ -97,7 +97,7 @@ export const boProvidersRouter = router({
where: { id: input.id },
include: {
...payoutInclude,
company: { select: { id: true, name: true, rateType: true, rateValue: true } },
company: { select: { id: true, name: true, deal: true, fotPct: true } },
user: { select: { username: true, firstName: true } },
districts: true,
schedule: { orderBy: [{ weekday: 'asc' }, { startMinute: 'asc' }] },
@@ -127,19 +127,20 @@ export const boProvidersRouter = router({
// деньги: заработано (по ставке исполнителя) минус выплачено = к выплате
let money = null;
if (ctx.isSuper) {
const fot = { fotPct: (await getSettings(ctx.db))['partner.fotPct'] };
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
const [doneAll, payouts] = await Promise.all([
ctx.db.order.findMany({ where: { providerId: p.id, ...DONE }, select: { completedAt: true, totalPrice: true, basePrice: true, params: true, priceBreakdown: true, service: { select: { pricing: true, paramsSchema: true } } } }),
ctx.db.payout.findMany({ where: { providerId: p.id }, orderBy: { createdAt: 'desc' } }),
]);
const earn = doneAll.map((o) => ({ at: o.completedAt!, sum: orderPayout(o, p) }));
const earn = doneAll.map((o) => ({ at: o.completedAt!, sum: orderPayout(o, p, fot) }));
const sum = (f: (x: (typeof earn)[number]) => boolean) => earn.filter(f).reduce((s, x) => s + x.sum, 0);
const earnedTotal = sum(() => true);
const paidTotal = payouts.filter((x) => x.status === 'PAID').reduce((s, x) => s + x.amount, 0);
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: payouts.map((x) => x.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
money = {
rate: rateLabel(p),
rate: rateLabel(p, fot),
thisMonth: sum((x) => x.at >= monthStart),
prevMonth: sum((x) => x.at >= prevMonthStart && x.at < monthStart),
earnedTotal,
+23 -1
View File
@@ -1,5 +1,9 @@
import { defaultParams, discountFor, LOCALES } from '@barev/core';
import { rateOrder,
clientPoints,
pointsHistory,
redeemQuote,
requestRedemption,
applyAgencyCode,
availableSlots,
cancelByClient,
@@ -54,6 +58,8 @@ export const clientRouter = router({
membership,
agencyName: agency?.name ?? null,
giftUsed: !!ctx.client.giftUsedAt,
/** барики: доступный баланс */
points: ctx.client.pointsActive,
cardCode: ctx.client.cardCode,
barevPlusPrice: settings['barevPlus.monthlyPrice'],
freeCancelHours: settings['cancel.freeHours'],
@@ -185,7 +191,9 @@ export const clientRouter = router({
.input(z.object({ serviceCode: z.string(), params: paramsInput, promoCode: z.string().trim().max(40).optional() }))
.query(async ({ ctx, input }) => {
const r = await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params, input.promoCode);
return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null };
// барики: сколько можно списать в этом заказе (с промокодом — нельзя)
const points = await redeemQuote(ctx.db, ctx.client.id, r.quote.totalPrice, await getSettings(ctx.db));
return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null, points };
}),
/** ORD-3 */
@@ -212,6 +220,8 @@ export const clientRouter = router({
repeatOfId: z.string().optional(),
/** промокод: применится, если выгоднее скидки подписки */
promoCode: z.string().trim().max(40).optional(),
/** оплатить часть бариками (с промокодом нельзя) */
usePoints: z.number().int().min(0).max(10_000_000).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -219,6 +229,18 @@ export const clientRouter = router({
return { id: order.id, number: order.number };
}),
/** Барики: экран, история, запрос на оплату заказа вне приложения или уже созданного заказа */
points: router({
summary: clientProcedure.query(({ ctx }) => clientPoints(ctx.svc, ctx.client.id)),
history: clientProcedure.input(z.object({ before: z.date().optional() }).optional()).query(({ ctx, input }) => pointsHistory(ctx.db, ctx.client.id, { take: 30, before: input?.before })),
request: consentedClientProcedure
.input(z.object({ amount: z.number().int().min(1).max(10_000_000), orderId: z.string().optional(), note: z.string().trim().max(300).optional() }))
.mutation(async ({ ctx, input }) => {
const r = await requestRedemption(ctx.svc, { clientId: ctx.client.id, ...input });
return { id: r.id };
}),
}),
orders: router({
list: clientProcedure.query(({ ctx }) => listClientOrders(ctx.db, ctx.client.id)),
get: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => clientOrder(ctx.db, ctx.client.id, input.id)),
+66 -3
View File
@@ -16,13 +16,38 @@ import {
partnerCabinet,
redeemOffer,
removePartnerMember,
companyStaffCandidates,
exchangeProWebLogin,
getSettings,
partnerAddStaff,
partnerCabinetData,
partnerDeclineOrder,
partnerRemoveManager,
partnerResetStaffInvite,
partnerSaveSettings,
partnerUnassign,
partnerUpdateStaff,
revokeProWebSession,
} from '@barev/services';
import { LOCALES } from '@barev/core';
import { env } from '../env.ts';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { signedUrl } from '../infra.ts';
import { proUserProcedure, providerProcedure, router } from '../trpc.ts';
import { proUserProcedure, providerProcedure, publicProcedure, router } from '../trpc.ts';
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const botLink = (code: string) => `https://t.me/${env.PRO_BOT_USERNAME}?start=${code}`;
const staffInput = z.object({
name: z.string().trim().min(1).max(100),
phone: z.string().trim().max(30).nullable().optional(),
languages: z.array(z.enum(LOCALES)).min(1),
districtIds: z.array(z.string()).max(30),
schedule: z.array(z.object({ weekday: z.number().int().min(0).max(6), startMinute: z.number().int().min(0).max(1440), endMinute: z.number().int().min(0).max(1440) })).max(21),
maxOrdersPerDay: z.number().int().min(0).max(20).nullable().optional(),
maxHoursPerDay: z.number().int().min(0).max(24).nullable().optional(),
});
const co = z.object({ companyId: z.string() });
/** Кабинет партнёра клуба: собственник и сотрудники */
const partnerRouter = router({
@@ -50,6 +75,7 @@ export const proRouter = router({
: null,
companies: ctx.identity.companies.map((c) => ({ id: c.id, name: c.name })),
partners: ctx.identity.partners,
web: ctx.web,
};
}),
@@ -98,15 +124,52 @@ export const proRouter = router({
return { url: await signedUrl(file.storageKey) };
}),
/** PRO-7: диспетчер компании */
/** Вход в кабинет из браузера: одноразовый код из бота → токен на 30 дней */
webLogin: publicProcedure.input(z.object({ code: z.string().min(10).max(100) })).mutation(async ({ ctx, input }) => ({ token: await exchangeProWebLogin(ctx.svc, input.code) })),
webLogout: publicProcedure.mutation(async ({ ctx }) => {
const auth = ctx.req.headers.authorization;
if (auth?.startsWith('pro ')) await revokeProWebSession(ctx.db, auth.slice(4));
}),
/** PRO-7: менеджер партнёра (диспетчер компании) */
dispatcher: router({
board: proUserProcedure.input(z.object({ companyId: z.string() })).query(({ ctx, input }) => companyBoard(ctx.svc, input.companyId, ctx.identity.user.id)),
/** Кабинет: заказы, сотрудники, доход, настройки */
cabinet: proUserProcedure.input(co).query(async ({ ctx, input }) => {
const d = await partnerCabinetData(ctx.svc, input.companyId, ctx.identity.user.id);
return {
...d,
company: { ...d.company, managerInvite: undefined, managerLink: botLink(d.company.managerInvite) },
staff: d.staff.map((p) => ({ ...p, inviteCode: undefined, inviteLink: p.inviteCode ? botLink(p.inviteCode) : null })),
};
}),
staffForOrder: proUserProcedure.input(z.object({ companyId: z.string(), orderId: z.string() })).query(async ({ ctx, input }) => {
if (!ctx.identity.companies.some((c) => c.id === input.companyId)) throw new TRPCError({ code: 'FORBIDDEN' });
return companyStaffForOrder(ctx.svc, input.companyId, input.orderId);
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, select: { companyId: true } });
if (order?.companyId !== input.companyId) throw new TRPCError({ code: 'NOT_FOUND' });
return companyStaffCandidates(ctx.db, input.companyId, input.orderId, await getSettings(ctx.db));
}),
assign: proUserProcedure
.input(z.object({ companyId: z.string(), orderId: z.string(), providerId: z.string() }))
.mutation(({ ctx, input }) => dispatcherAssign(ctx.svc, { ...input, dispatcherUserId: ctx.identity.user.id })),
/** Отказаться от заказа — он вернётся оператору */
decline: proUserProcedure.input(co.extend({ orderId: z.string() })).mutation(({ ctx, input }) => partnerDeclineOrder(ctx.svc, { ...input, userId: ctx.identity.user.id })),
/** Снять сотрудника с заказа (переназначить) */
unassign: proUserProcedure.input(co.extend({ orderId: z.string() })).mutation(({ ctx, input }) => partnerUnassign(ctx.svc, { ...input, userId: ctx.identity.user.id })),
addStaff: proUserProcedure.input(co.extend({ staff: staffInput })).mutation(async ({ ctx, input }) => {
const r = await partnerAddStaff(ctx.svc, { companyId: input.companyId, userId: ctx.identity.user.id, staff: input.staff });
return { id: r.id, inviteLink: botLink(r.inviteCode) };
}),
updateStaff: proUserProcedure
.input(co.extend({ providerId: z.string(), staff: staffInput, active: z.boolean().optional() }))
.mutation(({ ctx, input }) => partnerUpdateStaff(ctx.svc, { ...input, userId: ctx.identity.user.id })),
resetStaffInvite: proUserProcedure.input(co.extend({ providerId: z.string() })).mutation(async ({ ctx, input }) => {
const r = await partnerResetStaffInvite(ctx.svc, { ...input, userId: ctx.identity.user.id });
return { inviteLink: botLink(r.inviteCode) };
}),
saveSettings: proUserProcedure
.input(co.extend({ autoAssign: z.boolean(), travelGapMinutes: z.number().int().min(0).max(240) }))
.mutation(({ ctx, input }) => partnerSaveSettings(ctx.svc, { ...input, userId: ctx.identity.user.id })),
removeManager: proUserProcedure.input(co.extend({ managerId: z.string() })).mutation(({ ctx, input }) => partnerRemoveManager(ctx.svc, { ...input, userId: ctx.identity.user.id })),
}),
});
+4 -3
View File
@@ -83,9 +83,10 @@ export const publicRouter = router({
.input(
z.object({
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))])),
preferredAt: z.iso.datetime().transform((v) => new Date(v)),
districtCode: z.string().max(40),
addressText: z.string().max(300),
// обязательны только параметры уборки и телефон; остальное бот доспросит или уточнит оператор
preferredAt: z.iso.datetime().transform((v) => new Date(v)).optional(),
districtCode: z.string().max(40).optional(),
addressText: z.string().max(300).optional(),
note: z.string().max(1000).optional(),
phone: z.string().max(30),
name: z.string().max(80).optional(),
+24 -1
View File
@@ -4,7 +4,7 @@ import multipart, { type MultipartFile } from '@fastify/multipart';
import * as Sentry from '@sentry/node';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { getFlow } from '@barev/core';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend, startTextOverrides } from '@barev/services';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffChecklistItem, staffSend, startTextOverrides } from '@barev/services';
import { getFile, putFile } from '@barev/services/storage';
import { TelegramSender } from '@barev/services/telegram';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
@@ -187,6 +187,29 @@ app.post<{ Params: { providerId: string } }>('/files/bo/provider-doc/:providerId
return { id: doc.id };
});
/** Команда загружает фото к пункту чек-листа за исполнителя и отмечает пункт */
app.post<{ Params: { orderId: string } }>('/files/bo/checklist-photo/:orderId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(staff ? 403 : 401).send({ error: staff ? 'ROLE_FORBIDDEN' : 'ADMIN_LOGIN_REQUIRED' });
const order = await db.order.findUnique({ where: { id: req.params.orderId } });
if (!order) return reply.code(404).send({ error: 'NOT_FOUND' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
const code = multipartField(part, 'code');
if (!code) return reply.code(400).send({ error: 'BAD_INPUT' });
if (!IMAGE_TYPES.has(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const key = await putObject(`orders/${order.id}`, body, part.mimetype);
const file = await db.orderFile.create({ data: { orderId: order.id, kind: 'PHOTO_REPORT', storageKey: key, mime: part.mimetype, size: body.length, uploadedBy: 'ADMIN', uploadedById: staff.admin.id } });
try {
await staffChecklistItem(svc, { orderId: order.id, adminId: staff.admin.id, code, checked: true, fileId: file.id });
} catch (e) {
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
throw e;
}
return { fileId: file.id };
});
app.get<{ Params: { docId: string } }>('/files/bo/provider-doc-file/:docId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
+11 -2
View File
@@ -2,7 +2,7 @@
import type {} from '@fastify/cookie';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { initTRPC, TRPCError } from '@trpc/server';
import { DomainError, getClientByTelegramId, proIdentity, startClient } from '@barev/services';
import { DomainError, getClientByTelegramId, proIdentity, proWebSessionUser, startClient } from '@barev/services';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
@@ -86,12 +86,21 @@ export const consentedClientProcedure = clientProcedure.use(async ({ ctx, next }
/** Mini App исполнителя: initData бота исполнителей; пользователь должен быть привязан инвайтом */
export const proUserProcedure = publicProcedure.use(async ({ ctx, next }) => {
// кабинет менеджера партнёра в браузере: токен вместо initData (вход по одноразовой ссылке из бота)
const auth = ctx.req.headers.authorization;
if (auth?.startsWith('pro ')) {
const telegramId = await proWebSessionUser(ctx.db, auth.slice(4), ctx.svc.now());
if (!telegramId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'WEB_SESSION_EXPIRED' });
const identity = await proIdentity(ctx.db, telegramId);
if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' });
return next({ ctx: { tgUser: { id: Number(telegramId), first_name: identity.user.firstName ?? '' }, identity, web: true } });
}
const init = checkInitData(ctx.req, env.proBotToken);
const identity = await proIdentity(ctx.db, BigInt(init.user.id));
if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' });
}
return next({ ctx: { tgUser: init.user, identity } });
return next({ ctx: { tgUser: init.user, identity, web: false } });
});
export const providerProcedure = proUserProcedure.use(async ({ ctx, next }) => {
+8
View File
@@ -19,6 +19,8 @@ import { Crm } from './pages/Crm.tsx';
import { LeadCard } from './pages/LeadCard.tsx';
import { AgencyCard, Agencies } from './pages/Agencies.tsx';
import { VenueCard, Venues } from './pages/Venues.tsx';
import { ServicePartnerCard, ServicePartners } from './pages/ServicePartners.tsx';
import { Points } from './pages/Points.tsx';
import { Products, ServiceEditor } from './pages/Products.tsx';
import { Broadcasts, Journal, Settings } from './pages/Admin5.tsx';
import { MoveCard, Moves, Requests } from './pages/Moves.tsx';
@@ -57,7 +59,9 @@ const NAV: NavItem[] = [
{ path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
{ path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT', 'CONCIERGE'], count: 'orders' },
{ path: '/providers', key: 'nav.providers', icon: 'providers', roles: ['SUPPORT'], group: 'work' },
{ path: '/points', key: 'nav.points', icon: 'gift', roles: [], group: 'work' },
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', roles: [], group: 'partners' },
{ path: '/service-partners', key: 'nav.servicePartners', icon: 'providers', roles: [], group: 'partners' },
{ path: '/venues', key: 'nav.venues', icon: 'venues', roles: [], group: 'partners' },
{ path: '/crm', key: 'nav.crm', icon: 'crm', roles: ['SALES'], group: 'partners' },
{ path: '/products', key: 'nav.products', icon: 'products', roles: ['SUPPORT'], group: 'setup' },
@@ -95,6 +99,7 @@ function Shell({ me }: { me: Me }) {
const leadId = match('/crm/:id', path)?.id;
const agencyId = match('/agencies/:id', path)?.id;
const venueId = match('/venues/:id', path)?.id;
const servicePartnerId = match('/service-partners/:id', path)?.id;
const canCrm = items.some((n) => n.path === '/crm');
const productId = match('/products/:id', path)?.id;
const canProducts = items.some((n) => n.path === '/products');
@@ -112,6 +117,9 @@ function Shell({ me }: { me: Me }) {
else if (leadId && canCrm) page = <LeadCard id={leadId} me={me} />;
else if (path === '/agencies' && isSuper) page = <Agencies />;
else if (agencyId && isSuper) page = <AgencyCard id={agencyId} />;
else if (path === '/points' && isSuper) page = <Points />;
else if (path === '/service-partners' && isSuper) page = <ServicePartners />;
else if (servicePartnerId && isSuper) page = <ServicePartnerCard id={servicePartnerId} />;
else if (path === '/venues' && isSuper) page = <Venues />;
else if (venueId && isSuper) page = <VenueCard id={venueId} />;
else if (path === '/products' && canProducts) page = <Products me={me} />;
+37
View File
@@ -123,3 +123,40 @@
border-radius: 16px;
background: var(--bo-soft);
}
/* ───── барики ───── */
.bo-pt-tabs {
margin-bottom: 16px;
overflow-x: auto;
}
.bo-pt-tabs button {
flex: none;
padding: 0 14px;
}
.bo-pt-pick {
cursor: pointer;
border-radius: 14px;
}
.bo-pt-pick[data-on] {
background: var(--bo-soft);
}
.bo-pt-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 8px;
}
.bo-pt-filters .bo-select,
.bo-pt-filters .bo-input {
width: auto;
min-width: 140px;
}
.bo-table tr[data-hl] td {
background: color-mix(in srgb, var(--bc-perks) 45%, transparent);
}
.bo-alert--warn {
background: var(--bc-perks);
color: var(--bc-perks-ink);
cursor: default;
}
+851 -32
View File
@@ -43,8 +43,8 @@ const ru = {
'nav.chats': 'Чаты',
'nav.orders': 'Заказы',
'nav.providers': 'Исполнители',
'nav.agencies': 'Агентства',
'nav.venues': 'Заведения',
'nav.agencies': 'Риэлторские агентства',
'nav.venues': 'Партнёры клуба',
'nav.crm': 'CRM',
'nav.products': 'Продукты',
'nav.journal': 'Журнал',
@@ -656,7 +656,7 @@ const ru = {
'st.saveN': 'Сохранить ({n})',
'st.group.orders': 'Заказы',
'st.group.subs': 'Подписка',
'st.group.agencies': 'Агентства',
'st.group.agencies': 'Риэлторские агентства',
'st.group.sales': 'Бонус продажников',
'st.group.site': 'Цены на сайте',
'st.group.other': 'Прочее',
@@ -809,22 +809,22 @@ const ru = {
'venue.cat.shop': 'Магазин',
'venue.cat.beauty': 'Красота',
'venue.cat.other': 'Другое',
'ag.title': 'Агентства',
'ag.add': 'Новое агентство',
'ag.editTitle': 'Агентство',
'ag.empty': 'Агентств пока нет.',
'ag.col.name': 'Агентство',
'ag.title': 'Риэлторские агентства',
'ag.add': 'Новое риэлторское агентство',
'ag.editTitle': 'Риэлторское агентство',
'ag.empty': 'Риэлторских агентств пока нет.',
'ag.col.name': 'Риэлторское агентство',
'ag.col.clients': 'Клиенты',
'ag.col.gifts': 'Подарки',
'ag.col.month': 'Вознаграждение, месяц',
'ag.col.balance': 'К выплате',
'ag.hint': 'Вознаграждение — % от оплаченных заказов клиентов агентства, пока они закреплены за ним.',
'ag.inactive': 'выключено',
'ag.back': 'Все агентства',
'ag.back': 'Все риэлторские агентства',
'ag.pct': '{n}% с заказов клиентов',
'ag.byS': 'подключил(а) {name}',
'ag.link': 'Ссылка для клиентов',
'ag.linkHint': 'По ссылке клиент открывает бота, получает подарочный месяц и закрепляется за агентством. Или вводит код {code} командой /code.',
'ag.linkHint': 'По ссылке клиент открывает бота, получает подарок агентства и закрепляется за ним. Или вводит код {code} командой /code.',
'ag.clients': 'Клиенты',
'ag.giftsN': 'подарков: {n}',
'ag.noClients': 'Клиентов пока нет.',
@@ -838,7 +838,7 @@ const ru = {
'ag.sales': 'Подключил продажник',
'ag.contactPh': 'Имя, телефон, Telegram',
'ag.active': 'Агентство работает (код принимается)',
'vn.title': 'Заведения',
'vn.title': 'Партнёры клуба',
'vn.add': 'Новое заведение',
'vn.editTitle': 'Заведение',
'vn.empty': 'Заведений пока нет.',
@@ -1169,6 +1169,279 @@ const ru = {
'audit.bo.chats.openFor': 'открыл(а) чат с клиентом',
'audit.bo.chats.templates.save': 'изменил(а) шаблон ответа',
'audit.bo.chats.templates.remove': 'удалил(а) шаблон ответа',
'nav.servicePartners': 'Партнёры услуг',
'st.group.partners': 'Партнёры услуг',
'ag.giftPlan': 'Подарок новым клиентам',
'ag.giftMonths': 'На сколько',
'ag.giftHint': 'Действует для новых клиентов; у тех, кто уже получил подарок, ничего не меняется.',
'ag.noGift': 'без подарка',
'ag.giftValue': '{plan} на {n} мес.',
'ag.monthsN': '{n} мес.',
'ord.partner': 'партнёр',
'ord.best': 'выгоднее всего',
'ord.partnerFree': 'свободно {n} из {of}',
'ord.candMoney': 'Платим {cost} · нам остаётся {margin}',
'prv.ratePartnerPays': 'Зарплату платит партнёр',
'rq.missing': 'не хватает данных',
'rq.noTime': 'не указано',
'rq.noDistrict': 'район не указан',
'rq.noAddress': 'адрес не указан',
'sp.title': 'Партнёры услуг',
'sp.add': 'Новый партнёр',
'sp.editTitle': 'Партнёр услуг',
'sp.lead': 'Клининговые компании со своими сотрудниками. Менеджер партнёра получает ссылку, сам добавляет сотрудников и распределяет заказы. Заказ партнёру назначает оператор в карточке заказа.',
'sp.empty': 'Партнёров пока нет.',
'sp.col.name': 'Партнёр',
'sp.col.staff': 'Сотрудники',
'sp.col.active': 'Заказы в работе',
'sp.col.month': 'Доход партнёра, месяц',
'sp.col.balance': 'К выплате',
'sp.noManager': 'нет менеджера',
'sp.auto': 'автораспределение',
'sp.back': 'Все партнёры услуг',
'sp.terms.COMMISSION': 'как Uber: наши {pct}% с заказа',
'sp.terms.PROFIT_SHARE': 'расходы наши: ФОТ {fot}%, налог {tax}%, партнёру {pct}% прибыли',
'sp.managerLink': 'Ссылка для менеджера',
'sp.managerLinkHint': 'Отдайте ссылку менеджеру партнёра: он откроет рабочий бот и получит кабинет — заказы, сотрудники, автораспределение, доход. Кабинет открывается и на компьютере (кнопка в боте).',
'sp.resetLink': 'Новая ссылка',
'sp.resetConfirm': 'Сделать новую ссылку? Старая перестанет работать, подключённые менеджеры останутся.',
'sp.linkReset': 'Ссылка обновлена',
'sp.managers': 'Менеджеры',
'sp.noManagers': 'Менеджер ещё не подключился по ссылке.',
'sp.managerSince': 'с {date}',
'sp.remove': 'Убрать',
'sp.removeManagerConfirm': 'Убрать {name} из менеджеров партнёра?',
'sp.staff': 'Сотрудники',
'sp.staffHint': 'добавляет менеджер партнёра',
'sp.noStaff': 'Сотрудников пока нет.',
'sp.limitOrders': 'до {n} заказов в день',
'sp.limitHours': 'до {n} ч в день',
'sp.notLinked': 'не вошёл в бот',
'sp.off': 'выключен',
'sp.orders': 'Заказы в работе',
'sp.noOrders': 'Сейчас заказов у партнёра нет.',
'sp.waitingStaff': 'ждёт сотрудника',
'sp.toPay': 'Доход партнёра к выплате',
'sp.byOrder': 'Доход по заказам',
'sp.noIncome': 'Доход появится, когда клиент оплатит выполненный заказ.',
'sp.paid': 'оплачено {sum}',
'sp.fotTax': 'ФОТ {fot}, налог {tax}',
'sp.payoutTitle': 'Выплата партнёру',
'sp.phone': 'Телефон',
'sp.service': 'Услуга',
'sp.cleaningOnly': 'Клининг (пока только он)',
'sp.deal': 'Модель сотрудничества',
'sp.deal.COMMISSION': 'Как Uber: наш % с заказа',
'sp.deal.PROFIT_SHARE': 'Расходы наши: % партнёру',
'sp.dealHint.COMMISSION': 'Мы забираем свой % с оплаченного заказа, остальное — партнёру. Зарплаты сотрудникам и расходы — его.',
'sp.dealHint.PROFIT_SHARE': 'ФОТ клинеру и налог платим мы; партнёру — его % от того, что осталось: оплачено − ФОТ − налог.',
'sp.commissionPct': 'Наш %, с оплаченного заказа',
'sp.profitSharePct': '% партнёра от прибыли',
'sp.fotPct': 'ФОТ клинера, % от заказа',
'sp.taxPct': 'Налог, %',
'sp.defaultHint': 'пусто — из настроек ({n}%)',
'sp.exampleCOMMISSION': 'Пример: заказ оплачен на {sum} → нам {ours}, партнёру {partner}.',
'sp.examplePROFIT_SHARE': 'Пример: оплачено {sum} − ФОТ {fot} − налог {tax} = {profit}; партнёру {partner}, нам {ours}.',
'sp.activeLabel': 'Партнёр работает (можно назначать заказы)',
'nav.points': 'Барики',
'st.group.points': 'Барики (кэшбек)',
'ag.hwPoints': 'Барики на новоселье',
'ag.hwDays': 'Срок, дней',
'ag.hwHint': '0 — не дарим. Начисляются один раз, сразу доступны.',
'ag.hwValue': '{n} бариков на {days} дн.',
'pt.title': 'Барики',
'pt.tab.accrue': 'Начислить',
'pt.tab.requests': 'Запросы',
'pt.tab.suggestions': 'Предложено системой',
'pt.tab.ledger': 'Журнал',
'pt.tab.report': 'Отчёт',
'pt.tab.import': 'Импорт CSV',
'pt.tab.setup': 'Ставки и бонусы',
'pt.search': 'Клиент',
'pt.searchPh': 'Телефон, @username, имя или ID',
'pt.notFound': 'Никого не нашли',
'pt.pickClient': 'Найдите клиента слева — здесь появится форма начисления.',
'pt.flagged': 'проверить: много отмен',
'pt.balance': 'Доступно',
'pt.pending': 'В ожидании',
'pt.availableFrom': 'с {date}',
'pt.tier': 'Уровень',
'pt.burnAt': 'Сгорят',
'pt.agency': 'Агентство',
'pt.streak': 'Серия уборок',
'pt.debt': 'Долг',
'pt.redeem': 'Списать',
'pt.redeemBtn': 'Списать',
'pt.redeemed': 'Списано',
'pt.allOps': 'Все операции',
'pt.noOps': 'Операций пока нет.',
'pt.reverse': 'Отменить',
'pt.accrueTitle': 'Начисление',
'pt.kind.ORDER': 'За заказ',
'pt.kind.BONUS': 'Бонус',
'pt.f.category': 'Категория',
'pt.f.orderAmount': 'Сумма заказа, ֏',
'pt.f.excluded': 'Не облагается, ֏',
'pt.f.excludedHint': 'пошлины, материалы, чаевые',
'pt.f.paidWithPoints': 'Оплачено бариками',
'pt.f.serviceDate': 'Дата услуги',
'pt.f.orderRef': 'Номер заказа',
'pt.f.orderRefHint': 'второй раз по тому же номеру начислить нельзя',
'pt.f.bonusType': 'Тип бонуса',
'pt.f.ttl': 'Срок жизни, дней',
'pt.f.ttlDefault': 'по умолчанию {n}',
'pt.f.ttlNone': 'пусто — общее правило сгорания',
'pt.f.amount': 'Сумма бариков',
'pt.f.amountHint': 'подсказка: {n}',
'pt.f.reason': 'Причина',
'pt.f.reasonRequired': 'Причина (обязательно)',
'pt.f.comment': 'Комментарий для клиента',
'pt.f.commentPh': 'Увидит в истории',
'pt.f.noWait': 'Без ожидания — сразу доступны',
'pt.f.noWaitReason': 'Без ожидания (нужна причина)',
'pt.f.redeemAmount': 'Сколько списать',
'pt.f.defaultAmount': 'Сумма по умолчанию',
'pt.formula': '{tier}: {rate}%{extra} → {n} бариков',
'pt.accrueBtn': 'Начислить',
'pt.confirmTitle': 'Проверьте',
'pt.edit': 'Изменить',
'pt.confirmOrder': 'Начислить {n} бариков {name} за {what} {sum} ֏ ({tier}, {rate}%). Станут доступны {date}.',
'pt.confirmBonus': 'Начислить {n} бариков {name}: {what}, {until}.',
'pt.now': 'сразу',
'pt.forDays': 'на {n} дн.',
'pt.noTtl': 'без своего срока',
'pt.changedNote': 'Сумма изменена против подсказки ({n}) — попадёт в журнал с вашей причиной.',
'pt.accrued': 'Начислено {n}',
'pt.needsApproval': 'Выше лимита — отправлено суперадмину на подтверждение',
'pt.zero': 'Сумма 0 — начислять нечего',
'pt.reverseAccrual': 'Отменить начисление',
'pt.reverseRedemption': 'Отменить списание',
'pt.reverseAccrualText': 'Начисление {n} бариков будет отменено. Исходная запись останется в журнале.',
'pt.reverseRedemptionText': '{n} бариков вернутся в исходные начисления с их сроками.',
'pt.debtWarn': 'Из начисления уже потрачено {spent} — баланс уйдёт в минус: {after}. Минус погасится следующими начислениями.',
'pt.reasonPh': 'Возврат денег, ошибка…',
'pt.reverseHint': 'Поддержка отменяет только свои операции и только в течение 24 часов.',
'pt.reversed': 'Отменено',
'pt.maxHint': 'максимум {max} ({share}% заказа и не больше баланса), минимум {min}',
'pt.redeemReasonPh': 'По звонку, заказ вне приложения…',
'pt.toPay': 'К доплате: {sum} ֏',
'pt.noRequests': 'Запросов нет.',
'pt.wants': 'хочет списать {n}',
'pt.forOrder': 'заказ №{n}',
'pt.noOrder': 'заказ вне приложения',
'pt.balanceN': 'баланс {n}',
'pt.confirm': 'Подтвердить',
'pt.confirmN': 'Списать {n}',
'pt.confirmed': 'Списано {n}',
'pt.reject': 'Отклонить',
'pt.rejectReason': 'Причина — её увидит клиент',
'pt.noSuggestions': 'Предложений нет.',
'pt.suggestionsHint': 'Система нашла, кому положен бонус. Начислите, измените сумму или отклоните.',
'pt.mech.first_month_route': 'Маршрут первого месяца',
'pt.mech.photo_review': 'Отзыв с фото',
'pt.mech.over_limit': 'Выше лимита поддержки',
'pt.why.first_month_route': '3 разные категории за первые 30 дней',
'pt.why.photo_review': 'прислал фото после заказа №{n}',
'pt.why.over_limit': '{by}: {op}',
'pt.op.accrual': 'начисление',
'pt.op.redemption': 'списание',
'pt.approve': 'Подтвердить',
'pt.changeAmount': 'Изменить сумму',
'pt.allTypes': 'Все типы',
'pt.allCategories': 'Все категории',
'pt.allChannels': 'Все каналы',
'pt.allClients': 'Все клиенты',
'pt.type.accrual': 'Начисление',
'pt.type.bonus': 'Бонус',
'pt.type.redemption': 'Списание',
'pt.type.request': 'Запрос клиента',
'pt.type.reversal': 'Отмена',
'pt.channel.WEB': 'админка',
'pt.channel.BOT': 'бот',
'pt.channel.CSV': 'CSV',
'pt.channel.AUTO': 'авто',
'pt.channel.CLIENT': 'клиент',
'pt.status.PENDING': 'в ожидании',
'pt.status.ACTIVE': 'активно',
'pt.status.SPENT': 'потрачено',
'pt.status.EXPIRED': 'сгорело',
'pt.status.REVERSED': 'отменено',
'pt.status.REQUESTED': 'ждёт',
'pt.status.CONFIRMED': 'списано',
'pt.status.REJECTED': 'отклонено',
'pt.status.ACCRUAL': 'начисления',
'pt.status.REDEMPTION': 'списания',
'pt.col.date': 'Когда',
'pt.col.client': 'Клиент',
'pt.col.what': 'Что',
'pt.col.amount': 'Барики',
'pt.col.status': 'Статус',
'pt.col.author': 'Кто',
'pt.system': 'система',
'pt.suggestedN': 'подсказка {n}',
'pt.debtN': 'в долг {n}',
'pt.rep.accrued': 'Начислено',
'pt.rep.spent': 'Потрачено',
'pt.rep.expired': 'Сгорело',
'pt.rep.reversed': 'Отменено',
'pt.rep.liabilities': 'Обязательства сейчас',
'pt.rep.rate': 'Доля потраченных',
'pt.rep.byCategory': 'Стоимость программы по категориям',
'pt.rep.cost': '{pct}% от выручки {revenue} ֏',
'pt.rep.retention': 'Повторный заказ за 30 дней',
'pt.rep.with': 'С бариками',
'pt.rep.without': 'Без бариков',
'pt.rep.clientsN': 'клиентов {n}, с 2+ категориями {multi}',
'pt.rep.topClients': 'Топ по балансу',
'pt.rep.topAgencies': 'Агентства: активации новоселья',
'pt.importHint': 'CSV: телефон, категория (cleaning, documents, learning, repair, transfer), сумма заказа, дата (ГГГГ-ММ-ДД), номер заказа. Сначала — предпросмотр, начисление только после подтверждения.',
'pt.importRows': 'Строк',
'pt.importTotal': 'Бариков всего',
'pt.importErr.client': 'клиент не найден',
'pt.importErr.category': 'нет такой категории',
'pt.importErr.date': 'неверная дата',
'pt.importErr.duplicate': 'дубль заказа',
'pt.importCommit': 'Начислить {n} строк',
'pt.importDone': 'Начислено по {n} строкам',
'pt.categories': 'Категории и ставки',
'pt.bonusTypes': 'Типы бонусов',
'pt.code': 'Код',
'pt.activeLabel': 'Работает',
'pt.rulesHint': 'Доступны через {hours} ч после услуги · оплатить можно до {share}% заказа, минимум {min} · сгорают через {burn} дней без активности. Меняется в «Настройках».',
'err.POINTS_DUPLICATE_ORDER': 'За этот заказ барики уже начислены.',
'err.POINTS_REASON_REQUIRED': 'Укажите причину.',
'err.POINTS_BELOW_MIN': 'Меньше минимальной суммы списания.',
'err.POINTS_NOT_ENOUGH': 'Не хватает бариков.',
'err.POINTS_OVER_SHARE': 'Больше допустимой доли заказа.',
'err.POINTS_OVER_LIMIT': 'Выше вашего лимита — нужно подтверждение суперадмина.',
'err.POINTS_FINAL': 'Эту операцию уже нельзя отменить.',
'err.POINTS_REQUEST_CLOSED': 'Запрос уже обработан или истёк.',
'err.POINTS_WITH_PROMO': 'Барики нельзя вместе с промокодом.',
'pt.formulaStreak': ' + серия уборок {streak}: +{bonus}%',
'ord.editComposition': 'Изменить состав',
'ord.editTitle': 'Состав заказа №{n}',
'ord.editPrice': 'Цена: было {was} → станет {now} (скидка {pct}%, как при оформлении)',
'ord.editDuration': 'Длительность: {was} → {now} мин',
'ord.editBusy': 'Заказ стал дольше, а у исполнителя в это время другой заказ — проверьте график.',
'ord.editPaid': 'Заказ уже оплачен: пересчитаем вознаграждение агентства, доход партнёра и барики. Разницу в деньгах решите с клиентом.',
'ord.editOwnPrice': 'Своя итоговая цена, ֏',
'ord.editOwnPriceHint': 'пусто — по прайсу: {n}',
'ord.editReason': 'Причина',
'ord.editReasonRequired': 'Причина (обязательно для своей цены)',
'ord.editReasonPh': 'На месте добавили окна…',
'ord.editNotify': 'Сообщить клиенту и исполнителю',
'ord.edited': 'Состав заказа изменён',
'ord.editedBusy': 'Изменено. Внимание: исполнитель может не успеть — проверьте график',
'ord.uncheck': 'Снять отметку',
'ord.checkForProvider': 'Отметить за исполнителя',
'ord.noPhotoReason': 'без фото: {reason}',
'ord.byStaff': 'отметила команда',
'ord.checkNoPhoto': 'Отметить без фото',
'ord.photoHint': 'Для этого пункта нужно фото. Загрузите его или отметьте без фото с причиной.',
'ord.uploadPhoto': 'Загрузить фото',
'ord.noPhotoField': 'Или причина, почему без фото',
'ord.noPhotoPh': 'Клинер прислал фото в чат…',
'err.PHOTO_REQUIRED': 'Нужно фото или причина, почему без него.',
};
export type BoKey = keyof typeof ru;
@@ -1211,8 +1484,8 @@ const en: Dict = {
'nav.chats': 'Chats',
'nav.orders': 'Orders',
'nav.providers': 'Providers',
'nav.agencies': 'Agencies',
'nav.venues': 'Venues',
'nav.agencies': 'Real estate agencies',
'nav.venues': 'Club partners',
'nav.crm': 'CRM',
'nav.products': 'Products',
'nav.journal': 'Activity log',
@@ -1824,7 +2097,7 @@ const en: Dict = {
'st.saveN': 'Save ({n})',
'st.group.orders': 'Orders',
'st.group.subs': 'Membership',
'st.group.agencies': 'Agencies',
'st.group.agencies': 'Real estate agencies',
'st.group.sales': 'Sales bonus',
'st.group.site': 'Website prices',
'st.group.other': 'Other',
@@ -1977,22 +2250,22 @@ const en: Dict = {
'venue.cat.shop': 'Shop',
'venue.cat.beauty': 'Beauty',
'venue.cat.other': 'Other',
'ag.title': 'Agencies',
'ag.add': 'New agency',
'ag.editTitle': 'Agency',
'ag.empty': 'No agencies yet.',
'ag.col.name': 'Agency',
'ag.title': 'Real estate agencies',
'ag.add': 'New real estate agency',
'ag.editTitle': 'Real estate agency',
'ag.empty': 'No real estate agencies yet.',
'ag.col.name': 'Real estate agency',
'ag.col.clients': 'Clients',
'ag.col.gifts': 'Gifts',
'ag.col.month': 'Reward, month',
'ag.col.balance': 'To pay',
'ag.hint': 'Reward = % of paid orders of the agency’s clients while they’re attached.',
'ag.inactive': 'inactive',
'ag.back': 'All agencies',
'ag.back': 'All real estate agencies',
'ag.pct': '{n}% of client orders',
'ag.byS': 'connected by {name}',
'ag.link': 'Client link',
'ag.linkHint': 'The client opens the bot, gets a gift month and is attached to the agency. Or types code {code} via /code.',
'ag.linkHint': 'The client opens the bot, gets the agency gift and is attached to the agency. Or types code {code} via /code.',
'ag.clients': 'Clients',
'ag.giftsN': 'gifts: {n}',
'ag.noClients': 'No clients yet.',
@@ -2006,7 +2279,7 @@ const en: Dict = {
'ag.sales': 'Connected by',
'ag.contactPh': 'Name, phone, Telegram',
'ag.active': 'Agency active (code accepted)',
'vn.title': 'Venues',
'vn.title': 'Club partners',
'vn.add': 'New venue',
'vn.editTitle': 'Venue',
'vn.empty': 'No venues yet.',
@@ -2337,6 +2610,279 @@ const en: Dict = {
'audit.bo.chats.openFor': 'opened a chat with a client',
'audit.bo.chats.templates.save': 'edited a reply template',
'audit.bo.chats.templates.remove': 'deleted a reply template',
'nav.servicePartners': 'Service partners',
'st.group.partners': 'Service partners',
'ag.giftPlan': 'Gift for new clients',
'ag.giftMonths': 'For how long',
'ag.giftHint': 'Applies to new clients; those who already got a gift keep theirs.',
'ag.noGift': 'no gift',
'ag.giftValue': '{plan} for {n} mo',
'ag.monthsN': '{n} mo',
'ord.partner': 'partner',
'ord.best': 'best for us',
'ord.partnerFree': '{n} of {of} free',
'ord.candMoney': 'We pay {cost} · we keep {margin}',
'prv.ratePartnerPays': 'Paid by the partner',
'rq.missing': 'missing details',
'rq.noTime': 'not given',
'rq.noDistrict': 'no district',
'rq.noAddress': 'no address',
'sp.title': 'Service partners',
'sp.add': 'New partner',
'sp.editTitle': 'Service partner',
'sp.lead': 'Cleaning companies with their own staff. The partner’s manager gets a link, adds staff and assigns orders. An operator gives an order to a partner from the order card.',
'sp.empty': 'No partners yet.',
'sp.col.name': 'Partner',
'sp.col.staff': 'Staff',
'sp.col.active': 'Active orders',
'sp.col.month': 'Partner income, month',
'sp.col.balance': 'To pay',
'sp.noManager': 'no manager',
'sp.auto': 'auto-assign',
'sp.back': 'All service partners',
'sp.terms.COMMISSION': 'Uber-style: we take {pct}%',
'sp.terms.PROFIT_SHARE': 'our costs: payroll {fot}%, tax {tax}%, partner gets {pct}% of profit',
'sp.managerLink': 'Manager link',
'sp.managerLinkHint': 'Give this link to the partner’s manager: they open the work bot and get the cabinet — orders, staff, auto-assignment, income. It also opens on a computer (button in the bot).',
'sp.resetLink': 'New link',
'sp.resetConfirm': 'Make a new link? The old one stops working; connected managers stay.',
'sp.linkReset': 'Link updated',
'sp.managers': 'Managers',
'sp.noManagers': 'No manager has joined via the link yet.',
'sp.managerSince': 'since {date}',
'sp.remove': 'Remove',
'sp.removeManagerConfirm': 'Remove {name} from the partner’s managers?',
'sp.staff': 'Staff',
'sp.staffHint': 'added by the partner’s manager',
'sp.noStaff': 'No staff yet.',
'sp.limitOrders': 'up to {n} orders a day',
'sp.limitHours': 'up to {n} h a day',
'sp.notLinked': 'not in the bot',
'sp.off': 'off',
'sp.orders': 'Active orders',
'sp.noOrders': 'The partner has no orders right now.',
'sp.waitingStaff': 'waiting for staff',
'sp.toPay': 'Partner income to pay',
'sp.byOrder': 'Income by order',
'sp.noIncome': 'Income appears once a client pays for a completed order.',
'sp.paid': 'paid {sum}',
'sp.fotTax': 'payroll {fot}, tax {tax}',
'sp.payoutTitle': 'Payout to partner',
'sp.phone': 'Phone',
'sp.service': 'Service',
'sp.cleaningOnly': 'Cleaning (only this for now)',
'sp.deal': 'Deal model',
'sp.deal.COMMISSION': 'Uber-style: our % of order',
'sp.deal.PROFIT_SHARE': 'Our costs: % to partner',
'sp.dealHint.COMMISSION': 'We keep our % of each paid order, the rest goes to the partner. Wages and costs are theirs.',
'sp.dealHint.PROFIT_SHARE': 'We pay the cleaner and the tax; the partner gets their % of what’s left: paid − payroll − tax.',
'sp.commissionPct': 'Our %, of paid order',
'sp.profitSharePct': 'Partner’s % of profit',
'sp.fotPct': 'Cleaner payroll, % of order',
'sp.taxPct': 'Tax, %',
'sp.defaultHint': 'empty — from settings ({n}%)',
'sp.exampleCOMMISSION': 'Example: order paid {sum} → we keep {ours}, partner gets {partner}.',
'sp.examplePROFIT_SHARE': 'Example: paid {sum} − payroll {fot} − tax {tax} = {profit}; partner {partner}, us {ours}.',
'sp.activeLabel': 'Partner active (orders can be assigned)',
'nav.points': 'Bariki',
'st.group.points': 'Bariki (cashback)',
'ag.hwPoints': 'Housewarming bariki',
'ag.hwDays': 'Valid, days',
'ag.hwHint': '0 — none. Credited once, available right away.',
'ag.hwValue': '{n} bariki for {days} days',
'pt.title': 'Bariki',
'pt.tab.accrue': 'Credit',
'pt.tab.requests': 'Requests',
'pt.tab.suggestions': 'Suggested',
'pt.tab.ledger': 'Log',
'pt.tab.report': 'Report',
'pt.tab.import': 'CSV import',
'pt.tab.setup': 'Rates & bonuses',
'pt.search': 'Client',
'pt.searchPh': 'Phone, @username, name or ID',
'pt.notFound': 'No one found',
'pt.pickClient': 'Find a client on the left — the credit form appears here.',
'pt.flagged': 'check: many reversals',
'pt.balance': 'Available',
'pt.pending': 'Pending',
'pt.availableFrom': 'from {date}',
'pt.tier': 'Level',
'pt.burnAt': 'Expire on',
'pt.agency': 'Agency',
'pt.streak': 'Cleaning streak',
'pt.debt': 'Debt',
'pt.redeem': 'Redeem',
'pt.redeemBtn': 'Redeem',
'pt.redeemed': 'Redeemed',
'pt.allOps': 'All operations',
'pt.noOps': 'No operations yet.',
'pt.reverse': 'Reverse',
'pt.accrueTitle': 'Credit',
'pt.kind.ORDER': 'For an order',
'pt.kind.BONUS': 'Bonus',
'pt.f.category': 'Category',
'pt.f.orderAmount': 'Order amount, ֏',
'pt.f.excluded': 'Not eligible, ֏',
'pt.f.excludedHint': 'fees, materials, tips',
'pt.f.paidWithPoints': 'Paid with bariki',
'pt.f.serviceDate': 'Service date',
'pt.f.orderRef': 'Order number',
'pt.f.orderRefHint': 'the same number can’t be credited twice',
'pt.f.bonusType': 'Bonus type',
'pt.f.ttl': 'Valid for, days',
'pt.f.ttlDefault': 'default {n}',
'pt.f.ttlNone': 'empty — general expiry rule',
'pt.f.amount': 'Bariki amount',
'pt.f.amountHint': 'suggested: {n}',
'pt.f.reason': 'Reason',
'pt.f.reasonRequired': 'Reason (required)',
'pt.f.comment': 'Comment for the client',
'pt.f.commentPh': 'Shown in their history',
'pt.f.noWait': 'No waiting — available now',
'pt.f.noWaitReason': 'No waiting (reason required)',
'pt.f.redeemAmount': 'How much to redeem',
'pt.f.defaultAmount': 'Default amount',
'pt.formula': '{tier}: {rate}%{extra} → {n} bariki',
'pt.accrueBtn': 'Credit',
'pt.confirmTitle': 'Please check',
'pt.edit': 'Edit',
'pt.confirmOrder': 'Credit {n} bariki to {name} for {what} {sum} ֏ ({tier}, {rate}%). Available {date}.',
'pt.confirmBonus': 'Credit {n} bariki to {name}: {what}, {until}.',
'pt.now': 'right away',
'pt.forDays': 'for {n} days',
'pt.noTtl': 'no own expiry',
'pt.changedNote': 'Amount differs from the suggestion ({n}) — logged with your reason.',
'pt.accrued': 'Credited {n}',
'pt.needsApproval': 'Above the limit — sent to a super admin',
'pt.zero': 'Amount is 0 — nothing to credit',
'pt.reverseAccrual': 'Reverse credit',
'pt.reverseRedemption': 'Reverse redemption',
'pt.reverseAccrualText': 'The credit of {n} bariki will be reversed. The original stays in the log.',
'pt.reverseRedemptionText': '{n} bariki return to the original credits with their expiry.',
'pt.debtWarn': '{spent} of this credit is already spent — balance goes negative: {after}. Future credits will cover it.',
'pt.reasonPh': 'Refund, mistake…',
'pt.reverseHint': 'Support can reverse only their own operations within 24 hours.',
'pt.reversed': 'Reversed',
'pt.maxHint': 'max {max} ({share}% of the order, within balance), min {min}',
'pt.redeemReasonPh': 'By phone, order outside the app…',
'pt.toPay': 'Left to pay: {sum} ֏',
'pt.noRequests': 'No requests.',
'pt.wants': 'wants to use {n}',
'pt.forOrder': 'order #{n}',
'pt.noOrder': 'order outside the app',
'pt.balanceN': 'balance {n}',
'pt.confirm': 'Confirm',
'pt.confirmN': 'Redeem {n}',
'pt.confirmed': 'Redeemed {n}',
'pt.reject': 'Decline',
'pt.rejectReason': 'Reason — the client will see it',
'pt.noSuggestions': 'No suggestions.',
'pt.suggestionsHint': 'The system found who qualifies for a bonus. Credit, change the amount or decline.',
'pt.mech.first_month_route': 'First-month route',
'pt.mech.photo_review': 'Photo review',
'pt.mech.over_limit': 'Above support limit',
'pt.why.first_month_route': '3 different categories in the first 30 days',
'pt.why.photo_review': 'sent a photo after order #{n}',
'pt.why.over_limit': '{by}: {op}',
'pt.op.accrual': 'credit',
'pt.op.redemption': 'redemption',
'pt.approve': 'Approve',
'pt.changeAmount': 'Change amount',
'pt.allTypes': 'All types',
'pt.allCategories': 'All categories',
'pt.allChannels': 'All channels',
'pt.allClients': 'All clients',
'pt.type.accrual': 'Credit',
'pt.type.bonus': 'Bonus',
'pt.type.redemption': 'Redemption',
'pt.type.request': 'Client request',
'pt.type.reversal': 'Reversal',
'pt.channel.WEB': 'admin',
'pt.channel.BOT': 'bot',
'pt.channel.CSV': 'CSV',
'pt.channel.AUTO': 'auto',
'pt.channel.CLIENT': 'client',
'pt.status.PENDING': 'pending',
'pt.status.ACTIVE': 'active',
'pt.status.SPENT': 'spent',
'pt.status.EXPIRED': 'expired',
'pt.status.REVERSED': 'reversed',
'pt.status.REQUESTED': 'waiting',
'pt.status.CONFIRMED': 'redeemed',
'pt.status.REJECTED': 'declined',
'pt.status.ACCRUAL': 'of a credit',
'pt.status.REDEMPTION': 'of a redemption',
'pt.col.date': 'When',
'pt.col.client': 'Client',
'pt.col.what': 'What',
'pt.col.amount': 'Bariki',
'pt.col.status': 'Status',
'pt.col.author': 'Who',
'pt.system': 'system',
'pt.suggestedN': 'suggested {n}',
'pt.debtN': 'debt {n}',
'pt.rep.accrued': 'Credited',
'pt.rep.spent': 'Spent',
'pt.rep.expired': 'Expired',
'pt.rep.reversed': 'Reversed',
'pt.rep.liabilities': 'Outstanding now',
'pt.rep.rate': 'Redemption rate',
'pt.rep.byCategory': 'Programme cost by category',
'pt.rep.cost': '{pct}% of {revenue} ֏ revenue',
'pt.rep.retention': 'Repeat order within 30 days',
'pt.rep.with': 'With bariki',
'pt.rep.without': 'Without bariki',
'pt.rep.clientsN': '{n} clients, {multi} with 2+ categories',
'pt.rep.topClients': 'Top by balance',
'pt.rep.topAgencies': 'Agencies: housewarming activations',
'pt.importHint': 'CSV: phone, category (cleaning, documents, learning, repair, transfer), order amount, date (YYYY-MM-DD), order number. Preview first; credited only after you confirm.',
'pt.importRows': 'Rows',
'pt.importTotal': 'Total bariki',
'pt.importErr.client': 'client not found',
'pt.importErr.category': 'unknown category',
'pt.importErr.date': 'bad date',
'pt.importErr.duplicate': 'duplicate order',
'pt.importCommit': 'Credit {n} rows',
'pt.importDone': 'Credited {n} rows',
'pt.categories': 'Categories and rates',
'pt.bonusTypes': 'Bonus types',
'pt.code': 'Code',
'pt.activeLabel': 'Active',
'pt.rulesHint': 'Available {hours} h after the service · up to {share}% of an order, min {min} · expire after {burn} days of inactivity. Change in Settings.',
'err.POINTS_DUPLICATE_ORDER': 'Bariki for this order were already credited.',
'err.POINTS_REASON_REQUIRED': 'Please give a reason.',
'err.POINTS_BELOW_MIN': 'Below the minimum redemption.',
'err.POINTS_NOT_ENOUGH': 'Not enough bariki.',
'err.POINTS_OVER_SHARE': 'More than the allowed share of the order.',
'err.POINTS_OVER_LIMIT': 'Above your limit — needs a super admin.',
'err.POINTS_FINAL': 'This can no longer be reversed.',
'err.POINTS_REQUEST_CLOSED': 'Request already handled or expired.',
'err.POINTS_WITH_PROMO': 'Bariki can’t be combined with a promo code.',
'pt.formulaStreak': ' + cleaning streak {streak}: +{bonus}%',
'ord.editComposition': 'Edit composition',
'ord.editTitle': 'Order #{n} composition',
'ord.editPrice': 'Price: was {was} → now {now} ({pct}% discount, as when booked)',
'ord.editDuration': 'Duration: {was} → {now} min',
'ord.editBusy': 'The order got longer and the provider has another job then — check the schedule.',
'ord.editPaid': 'Already paid: agency reward, partner income and bariki will be recalculated. Settle the money difference with the client.',
'ord.editOwnPrice': 'Custom total, ֏',
'ord.editOwnPriceHint': 'empty — price list: {n}',
'ord.editReason': 'Reason',
'ord.editReasonRequired': 'Reason (required for a custom price)',
'ord.editReasonPh': 'Added windows on site…',
'ord.editNotify': 'Notify the client and the provider',
'ord.edited': 'Order updated',
'ord.editedBusy': 'Updated. The provider may not have time — check the schedule',
'ord.uncheck': 'Uncheck',
'ord.checkForProvider': 'Check for the provider',
'ord.noPhotoReason': 'no photo: {reason}',
'ord.byStaff': 'checked by the team',
'ord.checkNoPhoto': 'Check without photo',
'ord.photoHint': 'This item needs a photo. Upload one or check it without a photo and a reason.',
'ord.uploadPhoto': 'Upload photo',
'ord.noPhotoField': 'Or why there is no photo',
'ord.noPhotoPh': 'The cleaner sent it in the chat…',
'err.PHOTO_REQUIRED': 'A photo or a reason is required.',
};
const hy: Dict = {
@@ -2376,8 +2922,8 @@ const hy: Dict = {
'nav.chats': 'Չատեր',
'nav.orders': 'Պատվերներ',
'nav.providers': 'Կատարողներ',
'nav.agencies': 'Գործակալություններ',
'nav.venues': 'Հաստատություններ',
'nav.agencies': 'Անշարժ գույքի գործակալություններ',
'nav.venues': 'Ակումբի գործընկերներ',
'nav.crm': 'CRM',
'nav.products': 'Ապրանքներ',
'nav.journal': 'Մատյան',
@@ -2989,7 +3535,7 @@ const hy: Dict = {
'st.saveN': 'Պահպանել ({n})',
'st.group.orders': 'Պատվերներ',
'st.group.subs': 'Բաժանորդագրություն',
'st.group.agencies': 'Գործակալություններ',
'st.group.agencies': 'Անշարժ գույքի գործակալություններ',
'st.group.sales': 'Վաճառողների բոնուս',
'st.group.site': 'Կայքի գներ',
'st.group.other': 'Այլ',
@@ -3142,10 +3688,10 @@ const hy: Dict = {
'venue.cat.shop': 'Խանութ',
'venue.cat.beauty': 'Գեղեցկություն',
'venue.cat.other': 'Այլ',
'ag.title': 'Գործակալություններ',
'ag.add': 'Նոր գործակալություն',
'ag.editTitle': 'Գործակալություն',
'ag.empty': 'Գործակալություններ դեռ չկան։',
'ag.title': 'Անշարժ գույքի գործակալություններ',
'ag.add': 'Նոր անշարժ գույքի գործակալություն',
'ag.editTitle': 'Անշարժ գույքի գործակալություն',
'ag.empty': 'Անշարժ գույքի գործակալություններ դեռ չկան։',
'ag.col.name': 'Գործակալություն',
'ag.col.clients': 'Հաճախորդներ',
'ag.col.gifts': 'Նվերներ',
@@ -3157,7 +3703,7 @@ const hy: Dict = {
'ag.pct': '{n}% հաճախորդների պատվերներից',
'ag.byS': 'միացրել է {name}',
'ag.link': 'Հղում հաճախորդների համար',
'ag.linkHint': 'Հաճախորդը բացում է բոտը և ստանում նվեր ամիս։ Կամ մուտքագրում է {code} կոդը։',
'ag.linkHint': 'Հաճախորդը բացում է բոտը, ստանում գործակալության նվերը և կցվում նրան։ Կամ մուտքագրում է {code} կոդը /code հրամանով։',
'ag.clients': 'Հաճախորդներ',
'ag.giftsN': 'նվերներ՝ {n}',
'ag.noClients': 'Հաճախորդներ դեռ չկան։',
@@ -3170,8 +3716,8 @@ const hy: Dict = {
'ag.pctLabel': 'Պարգև, %',
'ag.sales': 'Միացրել է',
'ag.contactPh': 'Անուն, հեռախոս, Telegram',
'ag.active': 'Գործակալությունն աշխատում է',
'vn.title': 'Հաստատություններ',
'ag.active': 'Գործակալությունն աշխատում է (կոդն ընդունվում է)',
'vn.title': 'Ակումբի գործընկերներ',
'vn.add': 'Նոր հաստատություն',
'vn.editTitle': 'Հաստատություն',
'vn.empty': 'Հաստատություններ դեռ չկան։',
@@ -3502,6 +4048,279 @@ const hy: Dict = {
'audit.bo.chats.openFor': 'բացեց զրույց հաճախորդի հետ',
'audit.bo.chats.templates.save': 'փոխեց պատասխանի ձևանմուշը',
'audit.bo.chats.templates.remove': 'ջնջեց պատասխանի ձևանմուշը',
'nav.servicePartners': 'Ծառայությունների գործընկերներ',
'st.group.partners': 'Ծառայությունների գործընկերներ',
'ag.giftPlan': 'Նվեր նոր հաճախորդներին',
'ag.giftMonths': 'Որքան ժամանակով',
'ag.giftHint': 'Գործում է նոր հաճախորդների համար․ արդեն նվեր ստացածների մոտ ոչինչ չի փոխվում։',
'ag.noGift': 'առանց նվերի',
'ag.giftValue': '{plan}՝ {n} ամիս',
'ag.monthsN': '{n} ամիս',
'ord.partner': 'գործընկեր',
'ord.best': 'ամենաշահավետը',
'ord.partnerFree': 'ազատ է {n}-ը {of}-ից',
'ord.candMoney': 'Վճարում ենք {cost} · մեզ մնում է {margin}',
'prv.ratePartnerPays': 'Աշխատավարձը վճարում է գործընկերը',
'rq.missing': 'տվյալներ չեն բավարարում',
'rq.noTime': 'նշված չէ',
'rq.noDistrict': 'շրջանը նշված չէ',
'rq.noAddress': 'հասցեն նշված չէ',
'sp.title': 'Ծառայությունների գործընկերներ',
'sp.add': 'Նոր գործընկեր',
'sp.editTitle': 'Ծառայությունների գործընկեր',
'sp.lead': 'Մաքրման ընկերություններ՝ իրենց աշխատակիցներով։ Գործընկերոջ մենեջերը ստանում է հղում, ինքն է ավելացնում աշխատակիցներին և բաշխում պատվերները։ Պատվերը գործընկերոջը նշանակում է օպերատորը։',
'sp.empty': 'Գործընկերներ դեռ չկան։',
'sp.col.name': 'Գործընկեր',
'sp.col.staff': 'Աշխատակիցներ',
'sp.col.active': 'Ընթացիկ պատվերներ',
'sp.col.month': 'Գործընկերոջ եկամուտը, ամիս',
'sp.col.balance': 'Վճարման ենթակա',
'sp.noManager': 'մենեջեր չկա',
'sp.auto': 'ավտոբաշխում',
'sp.back': 'Բոլոր գործընկերները',
'sp.terms.COMMISSION': 'Uber-ի պես՝ մեր {pct}%-ը',
'sp.terms.PROFIT_SHARE': 'ծախսերը մերն են՝ աշխ. {fot}%, հարկ {tax}%, գործընկերոջը՝ շահույթի {pct}%',
'sp.managerLink': 'Մենեջերի հղումը',
'sp.managerLinkHint': 'Տվեք հղումը գործընկերոջ մենեջերին․ նա կբացի աշխատանքային բոտը և կստանա կաբինետ՝ պատվերներ, աշխատակիցներ, ավտոբաշխում, եկամուտ։ Կաբինետը բացվում է նաև համակարգչում (կոճակը բոտում)։',
'sp.resetLink': 'Նոր հղում',
'sp.resetConfirm': 'Ստեղծե՞լ նոր հղում։ Հինը կդադարի աշխատել, միացված մենեջերները կմնան։',
'sp.linkReset': 'Հղումը թարմացված է',
'sp.managers': 'Մենեջերներ',
'sp.noManagers': 'Մենեջերը դեռ չի միացել հղումով։',
'sp.managerSince': '{date}-ից',
'sp.remove': 'Հեռացնել',
'sp.removeManagerConfirm': 'Հեռացնե՞լ {name}-ին մենեջերներից։',
'sp.staff': 'Աշխատակիցներ',
'sp.staffHint': 'ավելացնում է գործընկերոջ մենեջերը',
'sp.noStaff': 'Աշխատակիցներ դեռ չկան։',
'sp.limitOrders': 'օրական մինչև {n} պատվեր',
'sp.limitHours': 'օրական մինչև {n} ժ',
'sp.notLinked': 'բոտ չի մտել',
'sp.off': 'անջատված',
'sp.orders': 'Ընթացիկ պատվերներ',
'sp.noOrders': 'Գործընկերը հիմա պատվեր չունի։',
'sp.waitingStaff': 'սպասում է աշխատակցի',
'sp.toPay': 'Գործընկերոջ եկամուտը վճարման',
'sp.byOrder': 'Եկամուտն ըստ պատվերների',
'sp.noIncome': 'Եկամուտը կհայտնվի, երբ հաճախորդը վճարի կատարված պատվերի համար։',
'sp.paid': 'վճարված՝ {sum}',
'sp.fotTax': 'աշխ. {fot}, հարկ {tax}',
'sp.payoutTitle': 'Վճարում գործընկերոջը',
'sp.phone': 'Հեռախոս',
'sp.service': 'Ծառայություն',
'sp.cleaningOnly': 'Մաքրում (առայժմ միայն այն)',
'sp.deal': 'Համագործակցության մոդել',
'sp.deal.COMMISSION': 'Uber-ի պես՝ մեր %-ը',
'sp.deal.PROFIT_SHARE': 'Ծախսերը մերն են՝ % գործընկերոջը',
'sp.dealHint.COMMISSION': 'Վճարված պատվերից վերցնում ենք մեր %-ը, մնացածը՝ գործընկերոջը։ Աշխատավարձն ու ծախսերը նրանն են։',
'sp.dealHint.PROFIT_SHARE': 'Մաքրողի աշխատավարձը և հարկը վճարում ենք մենք․ գործընկերոջը՝ մնացորդի իր %-ը՝ վճարված − աշխ. − հարկ։',
'sp.commissionPct': 'Մեր %-ը՝ վճարված պատվերից',
'sp.profitSharePct': 'Գործընկերոջ %-ը շահույթից',
'sp.fotPct': 'Մաքրողի աշխ., պատվերի %',
'sp.taxPct': 'Հարկ, %',
'sp.defaultHint': 'դատարկ՝ կարգավորումներից ({n}%)',
'sp.exampleCOMMISSION': 'Օրինակ՝ վճարվել է {sum} → մեզ {ours}, գործընկերոջը {partner}։',
'sp.examplePROFIT_SHARE': 'Օրինակ՝ վճարված {sum} − աշխ. {fot} − հարկ {tax} = {profit}․ գործընկերոջը {partner}, մեզ {ours}։',
'sp.activeLabel': 'Գործընկերն աշխատում է (կարելի է նշանակել պատվերներ)',
'nav.points': 'Բարիկներ',
'st.group.points': 'Բարիկներ (քեշբեք)',
'ag.hwPoints': 'Բարիկներ բնակարանամուտին',
'ag.hwDays': 'Ժամկետ, օր',
'ag.hwHint': '0՝ չենք նվիրում։ Մեկ անգամ, անմիջապես հասանելի։',
'ag.hwValue': '{n} բարիկ {days} օրով',
'pt.title': 'Բարիկներ',
'pt.tab.accrue': 'Հաշվեգրել',
'pt.tab.requests': 'Հայտեր',
'pt.tab.suggestions': 'Համակարգի առաջարկներ',
'pt.tab.ledger': 'Մատյան',
'pt.tab.report': 'Հաշվետվություն',
'pt.tab.import': 'CSV ներմուծում',
'pt.tab.setup': 'Տոկոսներ և բոնուսներ',
'pt.search': 'Հաճախորդ',
'pt.searchPh': 'Հեռախոս, @username, անուն կամ ID',
'pt.notFound': 'Ոչ ոք չգտնվեց',
'pt.pickClient': 'Գտեք հաճախորդին ձախից․ այստեղ կհայտնվի ձևը։',
'pt.flagged': 'ստուգել՝ շատ չեղարկումներ',
'pt.balance': 'Հասանելի',
'pt.pending': 'Սպասման մեջ',
'pt.availableFrom': '{date}-ից',
'pt.tier': 'Մակարդակ',
'pt.burnAt': 'Կայրվեն',
'pt.agency': 'Գործակալություն',
'pt.streak': 'Մաքրումների շարք',
'pt.debt': 'Պարտք',
'pt.redeem': 'Դուրս գրել',
'pt.redeemBtn': 'Դուրս գրել',
'pt.redeemed': 'Դուրս է գրված',
'pt.allOps': 'Բոլոր գործողությունները',
'pt.noOps': 'Գործողություններ դեռ չկան։',
'pt.reverse': 'Չեղարկել',
'pt.accrueTitle': 'Հաշվեգրում',
'pt.kind.ORDER': 'Պատվերի համար',
'pt.kind.BONUS': 'Բոնուս',
'pt.f.category': 'Կատեգորիա',
'pt.f.orderAmount': 'Պատվերի գումար, ֏',
'pt.f.excluded': 'Չի հաշվարկվում, ֏',
'pt.f.excludedHint': 'տուրքեր, նյութեր, թեյավճար',
'pt.f.paidWithPoints': 'Վճարված բարիկներով',
'pt.f.serviceDate': 'Ծառայության ամսաթիվ',
'pt.f.orderRef': 'Պատվերի համար',
'pt.f.orderRefHint': 'նույն համարով երկրորդ անգամ չի կարելի',
'pt.f.bonusType': 'Բոնուսի տեսակ',
'pt.f.ttl': 'Ժամկետ, օր',
'pt.f.ttlDefault': 'լռելյայն՝ {n}',
'pt.f.ttlNone': 'դատարկ՝ ընդհանուր կանոն',
'pt.f.amount': 'Բարիկների գումար',
'pt.f.amountHint': 'առաջարկ՝ {n}',
'pt.f.reason': 'Պատճառ',
'pt.f.reasonRequired': 'Պատճառ (պարտադիր)',
'pt.f.comment': 'Մեկնաբանություն հաճախորդի համար',
'pt.f.commentPh': 'Կտեսնի պատմության մեջ',
'pt.f.noWait': 'Առանց սպասման՝ անմիջապես',
'pt.f.noWaitReason': 'Առանց սպասման (պատճառ պետք է)',
'pt.f.redeemAmount': 'Որքան դուրս գրել',
'pt.f.defaultAmount': 'Լռելյայն գումար',
'pt.formula': '{tier}՝ {rate}%{extra} → {n} բարիկ',
'pt.accrueBtn': 'Հաշվեգրել',
'pt.confirmTitle': 'Ստուգեք',
'pt.edit': 'Փոխել',
'pt.confirmOrder': 'Հաշվեգրել {n} բարիկ {name}-ին՝ {what} {sum} ֏ ({tier}, {rate}%)։ Հասանելի՝ {date}։',
'pt.confirmBonus': 'Հաշվեգրել {n} բարիկ {name}-ին՝ {what}, {until}։',
'pt.now': 'անմիջապես',
'pt.forDays': '{n} օրով',
'pt.noTtl': 'առանց ժամկետի',
'pt.changedNote': 'Գումարը տարբերվում է առաջարկից ({n})՝ կգրանցվի ձեր պատճառով։',
'pt.accrued': 'Հաշվեգրված է {n}',
'pt.needsApproval': 'Գերազանցում է սահմանաչափը՝ ուղարկված է սուպերադմինին',
'pt.zero': 'Գումարը 0 է',
'pt.reverseAccrual': 'Չեղարկել հաշվեգրումը',
'pt.reverseRedemption': 'Չեղարկել դուրսգրումը',
'pt.reverseAccrualText': '{n} բարիկի հաշվեգրումը կչեղարկվի։ Սկզբնական գրառումը կմնա մատյանում։',
'pt.reverseRedemptionText': '{n} բարիկը կվերադառնա սկզբնական հաշվեգրումներին։',
'pt.debtWarn': 'Արդեն ծախսված է {spent}՝ մնացորդը կդառնա բացասական՝ {after}։ Կծածկվի հաջորդ հաշվեգրումներով։',
'pt.reasonPh': 'Վերադարձ, սխալ…',
'pt.reverseHint': 'Աջակցությունը չեղարկում է միայն իր գործողությունները 24 ժամում։',
'pt.reversed': 'Չեղարկված է',
'pt.maxHint': 'առավելագույնը {max} ({share}%), նվազագույնը {min}',
'pt.redeemReasonPh': 'Զանգով, պատվեր հավելվածից դուրս…',
'pt.toPay': 'Մնաց վճարել՝ {sum} ֏',
'pt.noRequests': 'Հայտեր չկան։',
'pt.wants': 'ուզում է դուրս գրել {n}',
'pt.forOrder': 'պատվեր №{n}',
'pt.noOrder': 'պատվեր հավելվածից դուրս',
'pt.balanceN': 'մնացորդ {n}',
'pt.confirm': 'Հաստատել',
'pt.confirmN': 'Դուրս գրել {n}',
'pt.confirmed': 'Դուրս է գրված {n}',
'pt.reject': 'Մերժել',
'pt.rejectReason': 'Պատճառը՝ կտեսնի հաճախորդը',
'pt.noSuggestions': 'Առաջարկներ չկան։',
'pt.suggestionsHint': 'Համակարգը գտել է, ում է հասնում բոնուս։ Հաշվեգրեք, փոխեք գումարը կամ մերժեք։',
'pt.mech.first_month_route': 'Առաջին ամսվա երթուղի',
'pt.mech.photo_review': 'Կարծիք լուսանկարով',
'pt.mech.over_limit': 'Աջակցության սահմանաչափից բարձր',
'pt.why.first_month_route': 'առաջին 30 օրում 3 տարբեր կատեգորիա',
'pt.why.photo_review': 'լուսանկար է ուղարկել №{n}-ից հետո',
'pt.why.over_limit': '{by}՝ {op}',
'pt.op.accrual': 'հաշվեգրում',
'pt.op.redemption': 'դուրսգրում',
'pt.approve': 'Հաստատել',
'pt.changeAmount': 'Փոխել գումարը',
'pt.allTypes': 'Բոլոր տեսակները',
'pt.allCategories': 'Բոլոր կատեգորիաները',
'pt.allChannels': 'Բոլոր ալիքները',
'pt.allClients': 'Բոլոր հաճախորդները',
'pt.type.accrual': 'Հաշվեգրում',
'pt.type.bonus': 'Բոնուս',
'pt.type.redemption': 'Դուրսգրում',
'pt.type.request': 'Հաճախորդի հայտ',
'pt.type.reversal': 'Չեղարկում',
'pt.channel.WEB': 'ադմին',
'pt.channel.BOT': 'բոտ',
'pt.channel.CSV': 'CSV',
'pt.channel.AUTO': 'ավտո',
'pt.channel.CLIENT': 'հաճախորդ',
'pt.status.PENDING': 'սպասման մեջ',
'pt.status.ACTIVE': 'ակտիվ',
'pt.status.SPENT': 'ծախսված',
'pt.status.EXPIRED': 'այրված',
'pt.status.REVERSED': 'չեղարկված',
'pt.status.REQUESTED': 'սպասում է',
'pt.status.CONFIRMED': 'դուրս գրված',
'pt.status.REJECTED': 'մերժված',
'pt.status.ACCRUAL': 'հաշվեգրման',
'pt.status.REDEMPTION': 'դուրսգրման',
'pt.col.date': 'Երբ',
'pt.col.client': 'Հաճախորդ',
'pt.col.what': 'Ինչ',
'pt.col.amount': 'Բարիկներ',
'pt.col.status': 'Կարգավիճակ',
'pt.col.author': 'Ով',
'pt.system': 'համակարգ',
'pt.suggestedN': 'առաջարկ {n}',
'pt.debtN': 'պարտք {n}',
'pt.rep.accrued': 'Հաշվեգրված',
'pt.rep.spent': 'Ծախսված',
'pt.rep.expired': 'Այրված',
'pt.rep.reversed': 'Չեղարկված',
'pt.rep.liabilities': 'Պարտավորություններ',
'pt.rep.rate': 'Ծախսման տոկոս',
'pt.rep.byCategory': 'Ծրագրի արժեքը ըստ կատեգորիաների',
'pt.rep.cost': 'հասույթի {pct}%՝ {revenue} ֏',
'pt.rep.retention': 'Կրկնակի պատվեր 30 օրում',
'pt.rep.with': 'Բարիկներով',
'pt.rep.without': 'Առանց բարիկների',
'pt.rep.clientsN': '{n} հաճախորդ, 2+ կատեգորիայով՝ {multi}',
'pt.rep.topClients': 'Թոփ ըստ մնացորդի',
'pt.rep.topAgencies': 'Գործակալություններ՝ բնակարանամուտի ակտիվացումներ',
'pt.importHint': 'CSV՝ հեռախոս, կատեգորիա, պատվերի գումար, ամսաթիվ (ՏՏՏՏ-ԱԱ-ՕՕ), պատվերի համար։ Նախ՝ նախադիտում։',
'pt.importRows': 'Տողեր',
'pt.importTotal': 'Ընդամենը բարիկ',
'pt.importErr.client': 'հաճախորդը չգտնվեց',
'pt.importErr.category': 'անհայտ կատեգորիա',
'pt.importErr.date': 'սխալ ամսաթիվ',
'pt.importErr.duplicate': 'կրկնվող պատվեր',
'pt.importCommit': 'Հաշվեգրել {n} տող',
'pt.importDone': 'Հաշվեգրված է {n} տող',
'pt.categories': 'Կատեգորիաներ և տոկոսներ',
'pt.bonusTypes': 'Բոնուսների տեսակներ',
'pt.code': 'Կոդ',
'pt.activeLabel': 'Ակտիվ',
'pt.rulesHint': 'Հասանելի՝ {hours} ժ հետո · մինչև {share}%, նվազագույնը {min} · այրվում են {burn} օր անգործությունից հետո։',
'err.POINTS_DUPLICATE_ORDER': 'Այս պատվերի համար արդեն հաշվեգրվել է։',
'err.POINTS_REASON_REQUIRED': 'Նշեք պատճառը։',
'err.POINTS_BELOW_MIN': 'Նվազագույնից քիչ է։',
'err.POINTS_NOT_ENOUGH': 'Բարիկները չեն բավարարում։',
'err.POINTS_OVER_SHARE': 'Ավելի է թույլատրելի մասից։',
'err.POINTS_OVER_LIMIT': 'Սահմանաչափից բարձր է։',
'err.POINTS_FINAL': 'Այլևս հնարավոր չէ չեղարկել։',
'err.POINTS_REQUEST_CLOSED': 'Հայտն արդեն մշակված է։',
'err.POINTS_WITH_PROMO': 'Բարիկները չեն համատեղվում պրոմոկոդի հետ։',
'pt.formulaStreak': ' + շարք {streak}՝ +{bonus}%',
'ord.editComposition': 'Փոխել կազմը',
'ord.editTitle': '№{n} պատվերի կազմը',
'ord.editPrice': 'Գինը՝ {was} → {now} (զեղչ {pct}%)',
'ord.editDuration': 'Տևողություն՝ {was} → {now} րոպե',
'ord.editBusy': 'Պատվերը երկարեց, իսկ կատարողն այդ ժամին զբաղված է։',
'ord.editPaid': 'Արդեն վճարված է․ կվերահաշվարկվեն պարգևը, գործընկերոջ եկամուտը և բարիկները։',
'ord.editOwnPrice': 'Սեփական գին, ֏',
'ord.editOwnPriceHint': 'դատարկ՝ գնացուցակով՝ {n}',
'ord.editReason': 'Պատճառ',
'ord.editReasonRequired': 'Պատճառ (պարտադիր)',
'ord.editReasonPh': 'Տեղում ավելացրին պատուհաններ…',
'ord.editNotify': 'Տեղեկացնել հաճախորդին և կատարողին',
'ord.edited': 'Պատվերը փոխված է',
'ord.editedBusy': 'Փոխված է։ Ստուգեք կատարողի գրաֆիկը',
'ord.uncheck': 'Հանել նշումը',
'ord.checkForProvider': 'Նշել կատարողի փոխարեն',
'ord.noPhotoReason': 'առանց լուսանկարի՝ {reason}',
'ord.byStaff': 'նշել է թիմը',
'ord.checkNoPhoto': 'Նշել առանց լուսանկարի',
'ord.photoHint': 'Այս կետի համար պետք է լուսանկար։',
'ord.uploadPhoto': 'Վերբեռնել լուսանկար',
'ord.noPhotoField': 'Կամ պատճառը, թե ինչու առանց լուսանկարի',
'ord.noPhotoPh': 'Մաքրողն ուղարկել է զրույցում…',
'err.PHOTO_REQUIRED': 'Պետք է լուսանկար կամ պատճառ։',
};
const DICTS: Record<BoLocale, Dict> = { ru, en, hy };
+33
View File
@@ -379,8 +379,12 @@
margin: 0;
padding: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.bo-cands .bo-small {
overflow-wrap: anywhere;
}
.bo-cands li {
display: flex;
align-items: center;
@@ -393,6 +397,15 @@
background: var(--bc-home);
color: var(--bc-home-ink);
}
/* самый выгодный для нас из доступных — обводка, чтобы оператор видел его и среди первых по подбору */
.bo-cands li[data-best] {
outline: 2px solid var(--bc-plum);
outline-offset: -2px;
}
.bo-cands li[data-best] .bo-chip--ok {
background: var(--bc-plum);
color: #fff;
}
.bo-cands__rank {
width: 26px;
height: 26px;
@@ -698,3 +711,23 @@
display: none;
}
}
/* чек-лист: команда отмечает за исполнителя */
.bo-checks__toggle {
display: inline-flex;
align-items: center;
gap: 10px;
border: 0;
background: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.bo-checks__toggle:disabled {
cursor: default;
}
.bo-checks__toggle:hover:not(:disabled) .bo-checks__box {
border-color: var(--bc-accent);
}
+5 -2
View File
@@ -261,8 +261,11 @@ export function Journal() {
const GROUPS: { key: string; keys: string[] }[] = [
{ key: 'orders', keys: ['cancel.freeHours', 'offer.acceptTimeoutMinutes', 'assign.auto', 'assign.maxAutoOffers'] },
{ key: 'subs', keys: ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'barevPlus.giftDays', 'elite.monthlyPrice', 'elite.maxDiscountPct'] },
{ key: 'agencies', keys: ['agency.attributionMonths', 'agency.defaultCommissionPct'] },
{ key: 'subs', keys: ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'elite.monthlyPrice', 'elite.maxDiscountPct'] },
// подарок новым клиентам задаётся у каждого агентства; giftDays — только число в текстах лендинга
{ key: 'agencies', keys: ['agency.attributionMonths', 'agency.defaultCommissionPct', 'barevPlus.giftDays'] },
{ key: 'points', keys: ['points.pendingHours', 'points.maxSharePct', 'points.minRedeem', 'points.burnDays', 'points.warnDays1', 'points.warnDays2', 'points.requestTtlHours', 'points.streakGapDays', 'points.diffHighlightPct', 'points.supportCanManage', 'points.opMaxPerOperation', 'points.opMaxPerDay'] },
{ key: 'partners', keys: ['partner.fotPct', 'partner.taxPct', 'partner.minTravelGapMinutes', 'partner.acceptTimeoutMinutes'] },
{ key: 'sales', keys: ['sales.bonusPerVenue', 'sales.bonusPerAgency', 'sales.bonusAgencyPct', 'sales.bonusAgencyMonths'] },
{ key: 'site', keys: ['relocation.hotelWithPlus', 'relocation.hotelBase', 'relocation.consultation', 'docs.fromPrice', 'docs.socialCardPrice', 'residence.escortFrom', 'residence.stateFee', 'transfer.standardPrice', 'transfer.premiumPrice'] },
{ key: 'other', keys: ['chat.closeAfterHours', 'files.retentionDays', 'consent.version'] },
+44 -3
View File
@@ -26,6 +26,15 @@ function CopyLink({ url }: { url: string }) {
}
export { CopyLink };
const GIFT_MONTHS = [1, 2, 3, 6, 12] as const;
type GiftMonths = (typeof GIFT_MONTHS)[number];
const PLAN_NAME = { BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' } as const;
/** «Barev+ на 3 мес.» или «без подарка» */
function giftText(t: ReturnType<typeof useT>['t'], a: { giftPlan: 'BAREV_PLUS' | 'ELITE' | null; giftMonths: number }) {
return a.giftPlan ? t('ag.giftValue', { plan: PLAN_NAME[a.giftPlan], n: a.giftMonths }) : t('ag.noGift');
}
export function Agencies() {
const { t, amd } = useT();
const trpc = useTRPC();
@@ -64,7 +73,7 @@ export function Agencies() {
<Avatar name={a.name} id={a.id} size={38} />
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{a.name}</span>
<span className="bo-who__meta">{[a.code, `${a.commissionPct}%`, a.sales?.name].filter(Boolean).join(' · ')}</span>
<span className="bo-who__meta">{[a.code, `${a.commissionPct}%`, giftText(t, a), a.sales?.name].filter(Boolean).join(' · ')}</span>
{!a.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
</span>
</a>
@@ -125,6 +134,8 @@ export function AgencyCard({ id }: { id: string }) {
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
<span className="bo-num">{d.code}</span>
<span>{t('ag.pct', { n: d.commissionPct })}</span>
<span>🎁 {giftText(t, d)}</span>
{d.housewarmingPoints > 0 && <span>🪙 {t('ag.hwValue', { n: new Intl.NumberFormat('ru-RU').format(d.housewarmingPoints), days: d.housewarmingDays })}</span>}
{d.sales && <span>{t('ag.byS', { name: d.sales.name })}</span>}
<span>{t('prv.since', { date: date(d.createdAt) })}</span>
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
@@ -227,7 +238,7 @@ export function AgencyCard({ id }: { id: string }) {
);
}
function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code: string; name: string; commissionPct: number; contact: string | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) {
function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code: string; name: string; commissionPct: number; giftPlan: 'BAREV_PLUS' | 'ELITE' | null; giftMonths: number; housewarmingPoints: number; housewarmingDays: number; contact: string | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
@@ -237,6 +248,10 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
code: agency?.code ?? '',
name: agency?.name ?? '',
commissionPct: String(agency?.commissionPct ?? 10),
giftPlan: (agency ? (agency.giftPlan ?? 'NONE') : 'BAREV_PLUS') as 'BAREV_PLUS' | 'ELITE' | 'NONE',
giftMonths: (GIFT_MONTHS.includes(agency?.giftMonths as GiftMonths) ? agency!.giftMonths : 1) as GiftMonths,
hwPoints: String(agency?.housewarmingPoints ?? 0),
hwDays: String(agency?.housewarmingDays ?? 60),
contact: agency?.contact ?? '',
note: agency?.note ?? '',
active: agency?.active ?? true,
@@ -267,7 +282,7 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
type="button"
className="bo-btn"
disabled={!f.name.trim() || !f.code.trim() || save.isPending}
onClick={() => save.mutate({ id: agency?.id, code: f.code, name: f.name, commissionPct: Math.min(100, Number(f.commissionPct) || 0), contact: f.contact || null, note: f.note || null, active: f.active, salesId: f.salesId || null })}
onClick={() => save.mutate({ id: agency?.id, code: f.code, name: f.name, commissionPct: Math.min(100, Number(f.commissionPct) || 0), giftPlan: f.giftPlan === 'NONE' ? null : f.giftPlan, giftMonths: f.giftMonths, housewarmingPoints: Number(f.hwPoints) || 0, housewarmingDays: Number(f.hwDays) || 60, contact: f.contact || null, note: f.note || null, active: f.active, salesId: f.salesId || null })}
>
{t('ui.save')}
</button>
@@ -297,6 +312,32 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
</select>
</Field>
</div>
<div className="bo-row2">
<Field label={t('ag.giftPlan')} hint={t('ag.giftHint')}>
<select className="bo-select" value={f.giftPlan} onChange={(e) => setF({ ...f, giftPlan: e.target.value as typeof f.giftPlan })}>
<option value="BAREV_PLUS">Barev+</option>
<option value="ELITE">Barev Elite</option>
<option value="NONE">{t('ag.noGift')}</option>
</select>
</Field>
<Field label={t('ag.giftMonths')}>
<select className="bo-select" value={f.giftMonths} disabled={f.giftPlan === 'NONE'} onChange={(e) => setF({ ...f, giftMonths: Number(e.target.value) as GiftMonths })}>
{GIFT_MONTHS.map((m) => (
<option key={m} value={m}>
{t('ag.monthsN', { n: m })}
</option>
))}
</select>
</Field>
</div>
<div className="bo-row2">
<Field label={t('ag.hwPoints')} hint={t('ag.hwHint')}>
<input className="bo-input" inputMode="numeric" value={f.hwPoints} onChange={(e) => setF({ ...f, hwPoints: e.target.value.replace(/\D/g, '').slice(0, 7) })} />
</Field>
<Field label={t('ag.hwDays')}>
<input className="bo-input" inputMode="numeric" value={f.hwDays} disabled={!Number(f.hwPoints)} onChange={(e) => setF({ ...f, hwDays: e.target.value.replace(/\D/g, '').slice(0, 4) })} />
</Field>
</div>
<Field label={t('crm.contacts')}>
<input className="bo-input" value={f.contact} maxLength={200} onChange={(e) => setF({ ...f, contact: e.target.value })} placeholder={t('ag.contactPh')} />
</Field>
+2
View File
@@ -1,3 +1,4 @@
import { ClientPoints } from './Points.tsx';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web';
import { myChannels } from './Chats.tsx';
@@ -234,6 +235,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
</div>
<div className="bo-col">
{isSuper && <ClientPoints id={d.id} compact />}
{d.money && (
<section className="bo-panel bo-money-card">
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
+6 -3
View File
@@ -519,14 +519,17 @@ export function Requests({ me }: { me: Me }) {
<b>
<span className="bo-num bo-faint">№{r.number}</span> {shared(`services.${r.service.code}.name`)}
</b>
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
<span className="bo-tags">
{r.status !== 'ORDERED' && r.status !== 'CANCELLED' && (!r.preferredAt || !r.district || !r.addressText) && <span className="bo-chip bo-chip--warn">{t('rq.missing')}</span>}
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
</span>
</div>
<div className="bo-small">{paramsSummary(sharedT, r.service.code, r.params as Record<string, unknown>)}</div>
<div className="bo-small">
{t('rq.wish')}: <b>{dateTime(r.preferredAt)}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
{t('rq.wish')}: <b>{r.preferredAt ? dateTime(r.preferredAt) : t('rq.noTime')}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
</div>
<div className="bo-small bo-faint">
{shared(`districts.${r.district.code}`)}, {r.addressText}
{[r.district ? shared(`districts.${r.district.code}`) : t('rq.noDistrict'), r.addressText ?? t('rq.noAddress')].join(', ')}
</div>
<div className="bo-rq__who">
<span>
+2 -2
View File
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge } from '../components/kit.tsx';
type ParamField =
export type ParamField =
| { key: string; type: 'select'; options: string[]; default?: string }
| { key: string; type: 'int'; min: number; max: number; default?: number }
| { key: string; type: 'text'; maxLength: number; required?: boolean }
@@ -262,7 +262,7 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
);
}
function ParamsForm({ service, fields, value, onChange }: { service: string; fields: ParamField[]; value: Params; onChange: (v: Params) => void }) {
export function ParamsForm({ service, fields, value, onChange }: { service: string; fields: ParamField[]; value: Params; onChange: (v: Params) => void }) {
const { t, shared } = useT();
const label = (k: string) => {
const key = `params.${service}.${k}.label`;
+170 -9
View File
@@ -5,7 +5,7 @@ import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge, Skeleton } from '../components/kit.tsx';
import { GROUP_DOT, statusLabel } from './Orders.tsx';
import { SlotPicker } from './NewOrder.tsx';
import { ParamsForm, SlotPicker, type ParamField } from './NewOrder.tsx';
const MIN = 60_000;
@@ -15,7 +15,8 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
const qc = useQueryClient();
const toast = useToast();
const o = useQuery({ ...trpc.bo.orders.get.queryOptions({ id }), refetchInterval: 15_000 });
const [dlg, setDlg] = useState<'reschedule' | 'cancel' | null>(null);
const [dlg, setDlg] = useState<'reschedule' | 'cancel' | 'edit' | null>(null);
const [check, setCheck] = useState<{ code: string; requiresPhoto: boolean } | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.orders.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.orders.candidates.queryKey({ id }) });
@@ -26,6 +27,7 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
const onError = (e: unknown) => toast(errText(t, e), 'error');
const transition = useMutation(trpc.bo.orders.transition.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.statusChanged'))), onError }));
const markPaid = useMutation(trpc.bo.orders.markPaid.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.paidDone'))), onError }));
const setChecklist = useMutation(trpc.bo.orders.checklist.mutationOptions({ onSuccess: refresh, onError }));
const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError }));
const canChat = me.roles.some((r) => r === 'SUPERADMIN' || r === 'SUPPORT' || r === 'CONCIERGE');
@@ -92,6 +94,11 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.details')}</h2>
{d.status !== 'CANCELLED' && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => setDlg('edit')}>
{t('ord.editComposition')}
</button>
)}
</div>
<dl className="bo-kv">
<dt>{t('ord.when')}</dt>
@@ -143,9 +150,19 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
<ul className="bo-checks">
{d.checklist.map((c) => (
<li key={c.code} data-done={c.done || undefined}>
<span className="bo-checks__box">{c.done && <I name="check" size={14} stroke={3} />}</span>
{shared(`checklist.${c.code}`)}
{c.requiresPhoto && !c.fileId && <span className="bo-faint bo-small"> · {t('ord.needsPhoto')}</span>}
<button
type="button"
className="bo-checks__toggle"
disabled={setChecklist.isPending || d.status === 'CANCELLED'}
title={c.done ? t('ord.uncheck') : t('ord.checkForProvider')}
onClick={() => (c.done ? setChecklist.mutate({ id: d.id, code: c.code, checked: false }) : c.requiresPhoto && !c.fileId ? setCheck(c) : setChecklist.mutate({ id: d.id, code: c.code, checked: true }))}
>
<span className="bo-checks__box">{c.done && <I name="check" size={14} stroke={3} />}</span>
{shared(`checklist.${c.code}`)}
</button>
{c.requiresPhoto && !c.fileId && !c.waived && <span className="bo-faint bo-small"> · {t('ord.needsPhoto')}</span>}
{c.waived && <span className="bo-faint bo-small"> · {t('ord.noPhotoReason', { reason: c.waived })}</span>}
{c.byStaff && <span className="bo-faint bo-small"> · {t('ord.byStaff')}</span>}
</li>
))}
</ul>
@@ -332,6 +349,8 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
</div>
{dlg === 'reschedule' && <RescheduleDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'edit' && <EditDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
{check && <ChecklistPhotoDialog orderId={d.id} code={check.code} onClose={() => setCheck(null)} onDone={refresh} />}
{dlg === 'cancel' && <CancelDialog id={id} showFee={!!d.money} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
@@ -379,7 +398,7 @@ function useOrderData(id: string) {
}
function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onChanged: () => void; canChat: boolean }) {
const { t, shared } = useT();
const { t, shared, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const [showBusy, setShowBusy] = useState(false);
@@ -464,15 +483,28 @@ function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onC
) : (
<ul className="bo-cands">
{cands.data.free.map((c, i) => (
<li key={`${c.kind}:${c.id}`}>
<li key={`${c.kind}:${c.id}`} data-best={c.best || undefined}>
<span className="bo-cands__rank">{i + 1}</span>
<span className="bo-grow" style={{ minWidth: 0 }}>
<b>{c.name}</b>
{c.kind === 'company' && <span className="bo-chip" style={{ marginLeft: 6 }}>{t('ord.company')}</span>}
{c.kind === 'company' && <span className="bo-chip" style={{ marginLeft: 6 }}>{t('ord.partner')}</span>}
{c.best && <span className="bo-chip bo-chip--ok" style={{ marginLeft: 6 }}>{t('ord.best')}</span>}
{c.tried && <span className="bo-chip bo-chip--warn" style={{ marginLeft: 6 }}>{t('ord.tried')}</span>}
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[c.rating != null ? `★ ${c.rating}` : null, c.languages.join(', ').toUpperCase(), ...c.reasons.map((r) => shared(`admin.reasons.${r}`)), t('ord.load', { n: c.load })].filter(Boolean).join(' · ')}
{[
c.rating != null ? `★ ${c.rating}` : null,
c.languages.join(', ').toUpperCase(),
...c.reasons.map((r) => shared(`admin.reasons.${r}`)),
c.kind === 'company' && c.partnerFree != null ? t('ord.partnerFree', { n: c.partnerFree, of: c.partnerStaff ?? 0 }) : t('ord.load', { n: c.load }),
]
.filter(Boolean)
.join(' · ')}
</span>
{c.margin != null && (
<span className="bo-small" style={{ display: 'block' }}>
{t('ord.candMoney', { cost: amd(c.cost ?? 0), margin: amd(c.margin) })}
</span>
)}
</span>
<button type="button" className="bo-btn bo-btn--sm" disabled={assign.isPending} onClick={() => assign.mutate({ id: d.id, target: { kind: c.kind, id: c.id } })}>
{t('ord.offer')}
@@ -610,3 +642,132 @@ function CancelDialog({ id, showFee, onClose, onDone }: { id: string; showFee: b
</Dialog>
);
}
/** Изменить состав заказа: пересчёт по прайсу с прежней скидкой или своя цена с причиной */
function EditDialog({ order, onClose, onDone }: { order: { id: string; number: number; service: string; params: Record<string, unknown> }; onClose: () => void; onDone: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const services = useQuery(trpc.bo.orders.services.queryOptions());
const fields = (services.data?.find((s) => s.code === order.service)?.paramsSchema.fields ?? []) as ParamField[];
const [params, setParams] = useState<Record<string, unknown>>(() => ({ ...order.params }));
const [price, setPrice] = useState('');
const [reason, setReason] = useState('');
const [notify, setNotify] = useState(true);
const p = useQuery({ ...trpc.bo.orders.editPreview.queryOptions({ id: order.id, params }), placeholderData: (prev) => prev });
const manual = price !== '' && p.data && Number(price) !== p.data.totalPrice;
const save = useMutation(
trpc.bo.orders.edit.mutationOptions({
onSuccess: (r) => {
toast(r.providerBusy ? t('ord.editedBusy') : t('ord.edited'));
onDone();
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
wide
onClose={onClose}
title={t('ord.editTitle', { n: order.number })}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!p.data || save.isPending || (!!manual && !reason.trim())} onClick={() => save.mutate({ id: order.id, params, totalPrice: price === '' ? null : Number(price), reason: reason.trim() || null, notify })}>
{t('ui.save')}
</button>
</>
}
>
{fields.length > 0 && <ParamsForm service={order.service} fields={fields} value={params} onChange={setParams} />}
{p.isError && <p className="bo-hint" style={{ color: 'var(--bc-danger)' }}>{errText(t, p.error)}</p>}
{p.data && (
<div className="bo-side-panel__note" style={{ margin: '14px 0' }}>
{t('ord.editPrice', { was: amd(p.data.was.totalPrice), now: amd(p.data.totalPrice), pct: p.data.discountPct })}
<br />
{t('ord.editDuration', { was: p.data.was.durationMinutes, now: p.data.durationMinutes })}
{p.data.providerBusy && (
<>
<br />⚠️ {t('ord.editBusy')}
</>
)}
{p.data.paid && (
<>
<br />💳 {t('ord.editPaid')}
</>
)}
</div>
)}
<div className="bo-row2">
<Field label={t('ord.editOwnPrice')} hint={p.data ? t('ord.editOwnPriceHint', { n: amd(p.data.totalPrice) }) : undefined}>
<input className="bo-input" inputMode="numeric" value={price} placeholder={p.data ? String(p.data.totalPrice) : ''} onChange={(e) => setPrice(e.target.value.replace(/\D/g, '').slice(0, 8))} />
</Field>
<Field label={manual ? t('ord.editReasonRequired') : t('ord.editReason')}>
<input className="bo-input" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.editReasonPh')} />
</Field>
</div>
<label className="bo-check">
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} />
{t('ord.editNotify')}
</label>
</Dialog>
);
}
/** Пункт с фото: загрузить фото или отметить без фото с причиной */
function ChecklistPhotoDialog({ orderId, code, onClose, onDone }: { orderId: string; code: string; onClose: () => void; onDone: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const mark = useMutation(trpc.bo.orders.checklist.mutationOptions({ onSuccess: () => (onDone(), onClose()), onError: (e) => toast(errText(t, e), 'error') }));
const upload = async (file: File) => {
setBusy(true);
const fd = new FormData();
fd.append('code', code);
fd.append('file', file);
const res = await fetch(`/files/bo/checklist-photo/${orderId}`, { method: 'POST', body: fd, credentials: 'include' }).catch(() => null);
setBusy(false);
if (!res?.ok) {
const err = (await res?.json().catch(() => null)) as { error?: string } | null;
toast(errText(t, { message: err?.error ?? 'NETWORK' }), 'error');
return;
}
onDone();
onClose();
};
return (
<Dialog
open
onClose={onClose}
title={shared(`checklist.${code}`)}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!reason.trim() || mark.isPending} onClick={() => mark.mutate({ id: orderId, code, checked: true, reason })}>
{t('ord.checkNoPhoto')}
</button>
</>
}
>
<p className="bo-hint" style={{ marginTop: 0 }}>
{t('ord.photoHint')}
</p>
<label className="bo-btn bo-btn--accent" style={{ display: 'inline-flex', marginBottom: 14, opacity: busy ? 0.6 : 1 }}>
<I name="clip" size={16} />
{busy ? t('ui.loading') : t('ord.uploadPhoto')}
<input type="file" accept="image/*" hidden disabled={busy} onChange={(e) => e.target.files?.[0] && void upload(e.target.files[0])} />
</label>
<Field label={t('ord.noPhotoField')}>
<input className="bo-input" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.noPhotoPh')} />
</Field>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -299,8 +299,10 @@ export function ProviderCard({ id, me }: { id: string; me: Me }) {
);
}
function rateText(t: ReturnType<typeof useT>['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number; name?: string }) {
function rateText(t: ReturnType<typeof useT>['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number | null; name?: string }) {
if (r.kind === 'preset') return t('prv.ratePreset', { name: r.name ?? '' });
// сотрудник партнёра «как Uber» — зарплату ему платит партнёр
if (r.kind === 'company' && r.value == null) return t('prv.ratePartnerPays');
const v = r.type === 'PERCENT' ? `${r.value}%` : `${r.value} ֏`;
return r.kind === 'company' ? t('prv.rateCompany', { v }) : t('prv.rateOwn', { v });
}
@@ -0,0 +1,485 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { CopyLink } from './Agencies.tsx';
type Deal = 'PROFIT_SHARE' | 'COMMISSION';
type Terms = { deal: Deal; commissionPct: number; profitSharePct: number; fotPct: number; taxPct: number };
function useTermsText() {
const { t } = useT();
return (x: Terms) => (x.deal === 'COMMISSION' ? t('sp.terms.COMMISSION', { pct: x.commissionPct }) : t('sp.terms.PROFIT_SHARE', { pct: x.profitSharePct, fot: x.fotPct, tax: x.taxPct }));
}
/** Партнёры услуг: клининговые компании со своими сотрудниками и менеджером */
export function ServicePartners() {
const { t, amd } = useT();
const trpc = useTRPC();
const terms = useTermsText();
const list = useQuery(trpc.bo.partners.servicePartners.queryOptions());
const [creating, setCreating] = useState(false);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('sp.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('sp.add')}
</button>
</div>
<p className="bo-hint" style={{ marginTop: -6, marginBottom: 14 }}>
{t('sp.lead')}
</p>
{!list.data ? (
<Skeleton h={240} />
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('sp.empty')}</div>
) : (
<div className="bo-panel" style={{ padding: 8 }}>
<table className="bo-table bo-ptable">
<thead>
<tr>
<th>{t('sp.col.name')}</th>
<th className="bo-r">{t('sp.col.staff')}</th>
<th className="bo-r">{t('sp.col.active')}</th>
<th className="bo-r">{t('sp.col.month')}</th>
<th className="bo-r">{t('sp.col.balance')}</th>
</tr>
</thead>
<tbody>
{list.data.map((c) => (
<tr key={c.id} onClick={() => navigate(`/service-partners/${c.id}`)} data-off={!c.active || undefined}>
<td>
<a href={`#/service-partners/${c.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
<Avatar name={c.name} id={c.id} size={38} />
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{c.name}</span>
<span className="bo-who__meta">{terms(c.terms)}</span>
<span className="bo-tags" style={{ marginTop: 4 }}>
{!c.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
{c.managers === 0 && <span className="bo-chip bo-chip--warn">{t('sp.noManager')}</span>}
{c.autoAssign && <span className="bo-chip">{t('sp.auto')}</span>}
</span>
</span>
</a>
</td>
<td className="bo-r bo-num">{c.staff}</td>
<td className="bo-r bo-num">{c.activeOrders}</td>
<td className="bo-r bo-num">{amd(c.thisMonth)}</td>
<td className="bo-r">
<b className="bo-num">{amd(c.balance)}</b>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{creating && <PartnerDialog onClose={() => setCreating(false)} />}
</>
);
}
export function ServicePartnerCard({ id }: { id: string }) {
const { t, amd, date, dateTime, locale, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const terms = useTermsText();
const c = useQuery(trpc.bo.partners.servicePartner.queryOptions({ id }));
const [dlg, setDlg] = useState<'edit' | 'payout' | null>(null);
const refresh = () => {
void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartner.queryKey({ id }) });
void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartners.queryKey() });
};
const reset = useMutation(trpc.bo.partners.resetManagerLink.mutationOptions({ onSuccess: () => (refresh(), toast(t('sp.linkReset'))), onError: (e) => toast(errText(t, e), 'error') }));
const removeManager = useMutation(trpc.bo.partners.removeManager.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
const back = (
<a className="bo-back" href="#/service-partners">
<I name="back" size={16} />
{t('sp.back')}
</a>
);
if (!c.data)
return (
<>
{back}
{c.isError ? <div className="bo-panel bo-empty">{errText(t, c.error)}</div> : <Skeleton h={300} />}
</>
);
const d = c.data;
return (
<>
{back}
<header className="bo-profile">
<Avatar name={d.name} id={d.id} size={64} />
<div style={{ flex: 1, minWidth: 220 }}>
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
{d.name}
</h1>
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
<span>{terms(d.terms)}</span>
{d.contactPhone && <span>{d.contactPhone}</span>}
<span>{t('prv.since', { date: date(d.createdAt) })}</span>
{d.autoAssign && <span className="bo-chip">{t('sp.auto')}</span>}
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
</div>
</div>
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
{t('prv.edit')}
</button>
</header>
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.managerLink')}</h2>
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" disabled={reset.isPending} onClick={() => confirm(t('sp.resetConfirm')) && reset.mutate({ id })}>
{t('sp.resetLink')}
</button>
</div>
<p className="bo-hint" style={{ marginBottom: 10 }}>
{t('sp.managerLinkHint')}
</p>
<CopyLink url={d.managerLink} />
<h3 className="bo-label" style={{ marginTop: 16 }}>
{t('sp.managers')}
</h3>
{d.managers.length === 0 ? (
<p className="bo-hint">{t('sp.noManagers')}</p>
) : (
<ul className="bo-list">
{d.managers.map((m) => (
<li key={m.id}>
<span className="bo-grow">
<b>{m.name}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{t('sp.managerSince', { date: date(m.since) })}
</span>
</span>
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => confirm(t('sp.removeManagerConfirm', { name: m.name })) && removeManager.mutate({ id: m.id })}>
{t('sp.remove')}
</button>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.staff')}</h2>
<span className="bo-faint bo-small">{t('sp.staffHint')}</span>
</div>
{d.staff.length === 0 ? (
<p className="bo-hint">{t('sp.noStaff')}</p>
) : (
<ul className="bo-list">
{d.staff.map((s) => (
<li key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
<a href={`#/providers/${s.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>{s.name}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{[s.phone, s.maxOrdersPerDay ? t('sp.limitOrders', { n: s.maxOrdersPerDay }) : null, s.maxHoursPerDay ? t('sp.limitHours', { n: s.maxHoursPerDay }) : null].filter(Boolean).join(' · ') || '—'}
</span>
</a>
{!s.linked && <span className="bo-chip bo-chip--warn">{t('sp.notLinked')}</span>}
{!s.active && <span className="bo-chip">{t('sp.off')}</span>}
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.orders')}</h2>
</div>
{d.orders.length === 0 ? (
<p className="bo-hint">{t('sp.noOrders')}</p>
) : (
<ul className="bo-list">
{d.orders.map((o) => (
<li key={o.id}>
<a href={`#/orders/${o.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>
#{o.number} · {shared(`services.${o.service}.name`)}
</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{dateTime(o.scheduledAt)} · {o.staff ?? t('sp.waitingStaff')}
</span>
</a>
</li>
))}
</ul>
)}
</section>
</div>
<div className="bo-col">
<section className="bo-panel bo-money-card">
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
{t('sp.toPay')}
</span>
<div className="bo-bignum" style={{ margin: '6px 0 8px' }}>
{amd(d.money.balance)}
</div>
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
<span>
{t('ag.accrued')} <b className="bo-num">{amd(d.money.accrued)}</b>
</span>
<span>
{t('prv.paidTotal')} <b className="bo-num">{amd(d.money.paid)}</b>
</span>
</div>
<button type="button" className="bo-btn bo-btn--sm" disabled={d.money.balance <= 0} onClick={() => setDlg('payout')}>
{t('prv.payout')}
</button>
{d.money.months.length > 0 && (
<ul className="bo-list" style={{ marginTop: 12 }}>
{d.money.months.map((m) => (
<li key={m.period}>
<span className="bo-grow" style={{ textTransform: 'capitalize' }}>
{formatDateTime(locale as Locale, new Date(`${m.period}-15T12:00:00+04:00`), { month: 'long', year: 'numeric' })}
</span>
<span className="bo-small" style={{ opacity: 0.75 }}>
{t('ag.orders', { n: m.orders })} · {amd(m.revenue)}
</span>
<b className="bo-num">{amd(m.amount)}</b>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.byOrder')}</h2>
</div>
{d.money.recent.length === 0 ? (
<p className="bo-hint">{t('sp.noIncome')}</p>
) : (
<ul className="bo-list">
{d.money.recent.slice(0, 20).map((e) => (
<li key={e.orderId}>
<a href={`#/orders/${e.orderId}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>#{e.number}</b> <span className="bo-small bo-faint">{date(e.at)}</span>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{t('sp.paid', { sum: amd(e.paid) })}
{e.deal === 'PROFIT_SHARE' ? ` · ${t('sp.fotTax', { fot: amd(e.fot), tax: amd(e.tax) })}` : ` · −${e.pct}%`}
</span>
</a>
<b className="bo-num">{amd(e.amount)}</b>
</li>
))}
</ul>
)}
</section>
{d.note && (
<section className="bo-panel">
<p className="bo-side-panel__note" style={{ margin: 0 }}>
{d.note}
</p>
</section>
)}
</div>
</div>
{dlg === 'edit' && <PartnerDialog partner={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'payout' && <PartnerPayout id={d.id} balance={d.money.balance} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
type PartnerRow = {
id: string;
name: string;
contactPhone: string | null;
note: string | null;
active: boolean;
deal: Deal;
commissionPct: number;
profitSharePct: number;
fotPct: number | null;
taxPct: number | null;
defaults: { fotPct: number; taxPct: number };
};
function PartnerDialog({ partner, onClose, onDone }: { partner?: PartnerRow; onClose: () => void; onDone?: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const settings = useQuery({ ...trpc.bo.settings.get.queryOptions(), enabled: !partner });
const defaults = partner?.defaults ?? { fotPct: settings.data?.['partner.fotPct'] ?? 50, taxPct: settings.data?.['partner.taxPct'] ?? 10 };
const [f, setF] = useState({
name: partner?.name ?? '',
contactPhone: partner?.contactPhone ?? '',
note: partner?.note ?? '',
active: partner?.active ?? true,
deal: partner?.deal ?? ('COMMISSION' as Deal),
commissionPct: String(partner?.commissionPct ?? 30),
profitSharePct: String(partner?.profitSharePct ?? 50),
fotPct: partner?.fotPct == null ? '' : String(partner.fotPct),
taxPct: partner?.taxPct == null ? '' : String(partner.taxPct),
});
const num = (s: string) => Math.min(100, Number(s) || 0);
const opt = (s: string) => (s === '' ? null : num(s));
// пример на 20 000 ֏, чтобы было видно, как считается
const ex = 20_000;
const fot = opt(f.fotPct) ?? defaults.fotPct;
const tax = opt(f.taxPct) ?? defaults.taxPct;
const example =
f.deal === 'COMMISSION'
? t('sp.exampleCOMMISSION', { sum: amd(ex), ours: amd(Math.floor((ex * num(f.commissionPct)) / 100)), partner: amd(ex - Math.floor((ex * num(f.commissionPct)) / 100)) })
: (() => {
const fotS = Math.floor((ex * fot) / 100);
const taxS = Math.floor((ex * tax) / 100);
const profit = Math.max(0, ex - fotS - taxS);
const p = Math.floor((profit * num(f.profitSharePct)) / 100);
return t('sp.examplePROFIT_SHARE', { sum: amd(ex), fot: amd(fotS), tax: amd(taxS), profit: amd(profit), partner: amd(p), ours: amd(profit - p) });
})();
const save = useMutation(
trpc.bo.partners.saveServicePartner.mutationOptions({
onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartners.queryKey() });
onDone?.();
onClose();
if (!partner) navigate(`/service-partners/${r.id}`);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={partner ? t('sp.editTitle') : t('sp.add')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn"
disabled={!f.name.trim() || save.isPending}
onClick={() =>
save.mutate({
id: partner?.id,
name: f.name,
contactPhone: f.contactPhone || null,
note: f.note || null,
active: f.active,
deal: f.deal,
commissionPct: num(f.commissionPct),
profitSharePct: num(f.profitSharePct),
fotPct: opt(f.fotPct),
taxPct: opt(f.taxPct),
})
}
>
{t('ui.save')}
</button>
</>
}
>
<div className="bo-row2">
<Field label={t('crm.name')}>
<input className="bo-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
</Field>
<Field label={t('sp.phone')}>
<input className="bo-input" value={f.contactPhone} maxLength={30} onChange={(e) => setF({ ...f, contactPhone: e.target.value })} />
</Field>
</div>
<Field label={t('sp.service')}>
<input className="bo-input" value={t('sp.cleaningOnly')} disabled />
</Field>
<Field label={t('sp.deal')}>
<div className="bo-chtabs">
{(['COMMISSION', 'PROFIT_SHARE'] as const).map((d) => (
<button key={d} type="button" aria-pressed={f.deal === d} onClick={() => setF({ ...f, deal: d })}>
{t(`sp.deal.${d}`)}
</button>
))}
</div>
</Field>
<p className="bo-hint" style={{ marginTop: -4 }}>
{t(`sp.dealHint.${f.deal}`)}
</p>
{f.deal === 'COMMISSION' ? (
<Field label={t('sp.commissionPct')}>
<input className="bo-input" inputMode="numeric" value={f.commissionPct} onChange={(e) => setF({ ...f, commissionPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
) : (
<>
<Field label={t('sp.profitSharePct')}>
<input className="bo-input" inputMode="numeric" value={f.profitSharePct} onChange={(e) => setF({ ...f, profitSharePct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
<div className="bo-row2">
<Field label={t('sp.fotPct')} hint={t('sp.defaultHint', { n: defaults.fotPct })}>
<input className="bo-input" inputMode="numeric" placeholder={String(defaults.fotPct)} value={f.fotPct} onChange={(e) => setF({ ...f, fotPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
<Field label={t('sp.taxPct')} hint={t('sp.defaultHint', { n: defaults.taxPct })}>
<input className="bo-input" inputMode="numeric" placeholder={String(defaults.taxPct)} value={f.taxPct} onChange={(e) => setF({ ...f, taxPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
</div>
</>
)}
<p className="bo-side-panel__note" style={{ margin: '0 0 12px' }}>
{example}
</p>
<Field label={t('crm.note')}>
<textarea className="bo-textarea" style={{ minHeight: 56 }} value={f.note} maxLength={2000} onChange={(e) => setF({ ...f, note: e.target.value })} />
</Field>
<label className="bo-check">
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
{t('sp.activeLabel')}
</label>
</Dialog>
);
}
function PartnerPayout({ id, balance, onClose, onDone }: { id: string; balance: number; onClose: () => void; onDone: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const [amount, setAmount] = useState(String(balance));
const [note, setNote] = useState('');
const save = useMutation(
trpc.bo.partners.servicePartnerPayout.mutationOptions({
onSuccess: () => {
onDone();
toast(t('prv.payoutDone'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('sp.payoutTitle')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!Number(amount) || save.isPending} onClick={() => save.mutate({ id, amount: Number(amount), note: note || undefined })}>
{t('prv.payoutConfirm')}
</button>
</>
}
>
<Field label={t('prv.amount')} hint={t('ag.balanceHint', { sum: amd(balance) })}>
<input className="bo-input" inputMode="numeric" value={amount} onChange={(e) => setAmount(e.target.value.replace(/\D/g, ''))} />
</Field>
<Field label={t('prv.payoutNote')}>
<input className="bo-input" value={note} maxLength={300} onChange={(e) => setNote(e.target.value)} placeholder={t('prv.payoutNotePh')} />
</Field>
</Dialog>
);
}
+148 -8
View File
@@ -14,6 +14,11 @@ import {
giveConsent,
rateOrder,
linkWebRequest,
clientPoints,
pointsHistory,
completeWebRequest,
webRequestSlots,
type LinkResult,
setClientChannel,
setLocale,
startClient,
@@ -41,6 +46,8 @@ const bot = new Bot(token || 'disabled:token');
/** Ожидаем ввод кода агентства от этих чатов */
const awaitingCode = new Set<number>();
/** Заявка с сайта без адреса: следующий текст — адрес (chatId → заявка) */
const awaitingAddress = new Map<number, string>();
/** Низкая оценка: следующий текст — комментарий к ней (chatId → заказ и оценка) */
const awaitingRatingComment = new Map<number, { orderId: string; score: number }>();
@@ -75,9 +82,11 @@ async function askConsent(c: Context, locale: Locale) {
await c.reply(t('bot.client.consentText', { url: `${LANDING}/${locale}/privacy` }), { reply_markup: kb, parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
}
async function showMain(c: Context, locale: Locale, gift?: { endsAt: Date } | null) {
const planName = (plan: string) => (plan === 'ELITE' ? 'Barev Elite' : 'Barev+');
async function showMain(c: Context, locale: Locale, gift?: { endsAt: Date; plan: string } | null) {
const t = createT(locale);
if (gift) await c.reply(t('bot.client.giftActivated', { date: formatDateTime(locale, gift.endsAt, { dateStyle: 'long' }) }));
if (gift) await c.reply(t('bot.client.giftActivated', { plan: planName(gift.plan), date: formatDateTime(locale, gift.endsAt, { dateStyle: 'long' }) }));
await c.reply(t('bot.client.welcome'), { reply_markup: mainKeyboard(locale) });
}
@@ -87,19 +96,107 @@ async function continueOnboarding(c: Context, locale: Locale) {
return showMain(c, locale);
}
// ───── заявка с сайта: доспросить район, адрес и время ─────
async function webRequestDone(c: Context, locale: Locale, link: LinkResult) {
const t = createT(locale);
if (link.kind === 'ordered') {
const date = formatDateTime(locale, link.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
await c.reply(t('bot.client.webRequestOrdered', { request: link.requestNumber, order: link.orderNumber, date }));
} else if (link.kind === 'linked' || link.kind === 'incomplete') {
await c.reply(t('bot.client.webRequestLinked', { request: link.requestNumber }));
}
}
/** Следующий вопрос по заявке: сначала район, потом адрес, потом день и время */
async function webRequestStep(c: Context, locale: Locale, clientId: string, link: Extract<LinkResult, { kind: 'incomplete' }>, first = false) {
const t = createT(locale);
const chatId = c.chat!.id;
if (first) await c.reply(t('bot.client.wr.intro', { request: link.requestNumber }));
const next = link.missing[0];
if (next === 'district') {
const kb = new InlineKeyboard();
const districts = await db.district.findMany({ orderBy: { sortOrder: 'asc' } });
districts.forEach((d, i) => {
kb.text(t(`districts.${d.code}`), `wrd:${link.requestId}:${d.code}`);
if (i % 2 === 1) kb.row();
});
await c.reply(t('bot.client.wr.askDistrict'), { reply_markup: kb });
return;
}
if (next === 'address') {
awaitingAddress.set(chatId, link.requestId);
await c.reply(t('bot.client.wr.askAddress'));
return;
}
const days = await webRequestSlots(ctx, { requestId: link.requestId, clientId });
if (!days.length) {
await c.reply(t('bot.client.wr.noSlots'));
return showMain(c, locale);
}
const kb = new InlineKeyboard();
days.slice(0, 8).forEach((d, i) => {
kb.text(formatDateTime(locale, d.starts[0]!, { weekday: 'short', day: 'numeric', month: 'short' }), `wrday:${link.requestId}:${d.dateKey}`);
if (i % 2 === 1) kb.row();
});
await c.reply(t('bot.client.wr.askDay'), { reply_markup: kb });
}
async function webRequestAnswer(c: Context, input: { requestId: string; districtCode?: string; addressText?: string; preferredAt?: Date }) {
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
if (!client) return;
const locale = client.user.locale as Locale;
try {
const res = await completeWebRequest(ctx, { ...input, clientId: client.id });
if (res.kind === 'incomplete') return webRequestStep(c, locale, client.id, res);
await webRequestDone(c, locale, res);
return showMain(c, locale);
} catch (e) {
if (e instanceof DomainError) return void (await c.reply(createT(locale)(`errors.${e.code}`)));
throw e;
}
}
bot.callbackQuery(/^wrd:(\w+):(\w+)$/, async (c) => {
await c.answerCallbackQuery();
await c.editMessageReplyMarkup().catch(() => undefined);
await webRequestAnswer(c, { requestId: c.match[1]!, districtCode: c.match[2]! });
});
bot.callbackQuery(/^wrday:(\w+):(\d{4}-\d{2}-\d{2})$/, async (c) => {
await c.answerCallbackQuery();
const client = await getClientByTelegramId(db, BigInt(c.from.id));
if (!client) return;
const locale = client.user.locale as Locale;
const t = createT(locale);
const [day] = await webRequestSlots(ctx, { requestId: c.match[1]!, clientId: client.id, dateKey: c.match[2]! }).catch(() => []);
if (!day) return void (await c.reply(t('bot.client.wr.dayFull')));
const kb = new InlineKeyboard();
day.starts.slice(0, 24).forEach((s, i) => {
kb.text(formatDateTime(locale, s, { hour: '2-digit', minute: '2-digit' }), `wrt:${c.match[1]}:${Math.round(s.getTime() / 60_000).toString(36)}`);
if (i % 4 === 3) kb.row();
});
await c.editMessageText(t('bot.client.wr.askTime', { day: formatDateTime(locale, day.starts[0]!, { weekday: 'long', day: 'numeric', month: 'long' }) }), { reply_markup: kb }).catch(() => c.reply(t('bot.client.wr.askTime'), { reply_markup: kb }));
});
bot.callbackQuery(/^wrt:(\w+):([0-9a-z]+)$/, async (c) => {
await c.answerCallbackQuery();
await c.editMessageReplyMarkup().catch(() => undefined);
await webRequestAnswer(c, { requestId: c.match[1]!, preferredAt: new Date(parseInt(c.match[2]!, 36) * 60_000) });
});
bot.command('start', async (c) => {
const res = await startClient(ctx, c.from!, c.match || undefined);
// пришёл с сайта после заявки: язык и согласие уже есть — сразу говорим, что с заявкой
const token = decodeStartPayload(c.match).token;
const link = token ? await linkWebRequest(ctx, token, res.clientId) : null;
if (link && link.kind !== 'none') {
const t = createT(res.locale);
if (link.kind === 'ordered') {
const date = formatDateTime(res.locale, link.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
await c.reply(t('bot.client.webRequestOrdered', { request: link.requestNumber, order: link.orderNumber, date }));
} else {
await c.reply(t('bot.client.webRequestLinked', { request: link.requestNumber }));
if (link.kind === 'incomplete') {
const gift = link.gift ?? res.gift;
if (gift) await c.reply(createT(res.locale)('bot.client.giftActivated', { plan: planName(gift.plan), date: formatDateTime(res.locale, gift.endsAt, { dateStyle: 'long' }) }));
return webRequestStep(c, res.locale, res.clientId, link, true);
}
await webRequestDone(c, res.locale, link);
return showMain(c, res.locale, link.gift ?? res.gift);
}
if (res.needsLocale) {
@@ -158,6 +255,41 @@ bot.command('app', async (c) => {
bot.command('help', async (c) => c.reply(createT(await localeOf(c.from))('bot.client.help')));
// ───── барики ─────
const fmtPts = (locale: Locale, n: number) => new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale).format(n);
const pointsKb = (locale: Locale) => (APP_URL.startsWith('https://') ? new InlineKeyboard().webApp(createT(locale)('bot.client.points.open'), `${APP_URL}?r=/points`) : undefined);
bot.command('balance', async (c) => {
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
const locale = await localeOf(c.from);
if (!client) return;
const t = createT(locale);
const p = await clientPoints(ctx, client.id);
const lines = [t('bot.client.points.balance', { balance: fmtPts(locale, p.balance) })];
if (p.pending) lines.push(t('bot.client.points.pending', { pending: fmtPts(locale, p.pending), date: p.pendingNextAt ? formatDateTime(locale, p.pendingNextAt, { day: 'numeric', month: 'long' }) : '—' }));
if (p.burnAt) lines.push(t('bot.client.points.burn', { date: formatDateTime(locale, p.burnAt, { day: 'numeric', month: 'long' }) }));
await c.reply(lines.join('\n'), { reply_markup: pointsKb(locale) });
});
bot.command('history', async (c) => {
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
const locale = await localeOf(c.from);
if (!client) return;
const t = createT(locale);
const rows = await pointsHistory(db, client.id, { take: 10 });
if (!rows.length) return void (await c.reply(t('bot.client.points.empty')));
const name = (m: Record<string, string> | null) => (m ? m[locale] || m.ru || '' : '');
const text = rows
.map((r) => {
const sign = r.amount > 0 ? '+' : '−';
const what = r.type === 'bonus' ? name(r.bonus) : r.type === 'accrual' ? name(r.category) : t(`bot.client.points.type.${r.type}`);
return `${formatDateTime(locale, r.at, { day: '2-digit', month: '2-digit' })} ${sign}${fmtPts(locale, Math.abs(r.amount))} · ${what}${r.orderRef ? ` ${r.orderRef}` : ''} · ${t(`bot.client.points.status.${r.status}`)}`;
})
.join('\n');
await c.reply(text, { reply_markup: pointsKb(locale) });
});
/** Сообщение в чат команды: подписчик пишет консьержу, остальные — поддержке. Ответ придёт сюда же */
async function toTeam(c: Context, text: string, file?: ChatFile) {
const locale = await localeOf(c.from);
@@ -228,6 +360,12 @@ bot.on('message:text', async (c) => {
await rateOrder(ctx, { orderId: pending.orderId, clientId: client.id, score: pending.score, comment: c.message.text.slice(0, 1000) }).catch(() => undefined);
return toTeam(c, c.message.text);
}
// адрес для заявки с сайта
const requestId = awaitingAddress.get(c.chat.id);
if (requestId && client) {
awaitingAddress.delete(c.chat.id);
return webRequestAnswer(c, { requestId, addressText: c.message.text });
}
if (!awaitingCode.has(c.chat.id)) return toTeam(c, c.message.text);
if (!client) return;
try {
@@ -256,6 +394,8 @@ async function main() {
{ command: 'start', description: t('bot.client.cmdStart') },
{ command: 'app', description: t('bot.client.cmdApp') },
{ command: 'code', description: t('bot.client.cmdCode') },
{ command: 'balance', description: t('bot.client.cmdBalance') },
{ command: 'history', description: t('bot.client.cmdHistory') },
{ command: 'support', description: t('bot.client.cmdSupport') },
{ command: 'concierge', description: t('bot.client.cmdConcierge') },
{ command: 'language', description: t('bot.client.cmdLanguage') },
+105 -3
View File
@@ -9,6 +9,12 @@ import {
acceptPartnerInvite,
addProviderMessage,
checkCard,
accruePoints,
confirmRedemption,
getSettings,
previewAccrual,
rejectRedemption,
createProWebLogin,
DomainError,
linkByInvite,
peerName,
@@ -42,12 +48,25 @@ async function identity(c: Context) {
}
const localeOf = (id: Awaited<ReturnType<typeof identity>>): Locale => (id?.user.locale as Locale) ?? 'ru';
function cabinetKeyboard(locale: Locale) {
function cabinetKeyboard(locale: Locale, manager = false) {
const kb = new InlineKeyboard();
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL);
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL).row();
// менеджер партнёра может открыть кабинет и на компьютере
if (manager) kb.text(createT(locale)('bot.pro.openWeb'), 'web');
return kb;
}
/** Одноразовая ссылка на кабинет в браузере (15 минут) */
async function sendWebLink(c: Context) {
const id = await identity(c);
const t = createT(localeOf(id));
if (!id || id.companies.length === 0) return void (await c.reply(t('bot.pro.notLinked')));
const code = await createProWebLogin(ctx, id.user.id);
const url = `${APP_URL || 'http://localhost:5175'}/?login=${code}#/company`;
const kb = url.startsWith('https://') ? new InlineKeyboard().url(t('bot.pro.openWeb'), url) : undefined;
await c.reply(t('bot.pro.webLink', { url }), { reply_markup: kb, link_preview_options: { is_disabled: true } });
}
async function greet(c: Context) {
const id = await identity(c);
const locale = localeOf(id);
@@ -61,7 +80,7 @@ async function greet(c: Context) {
...id.companies.map((co) => t('bot.pro.youAreDispatcher', { company: co.name })),
...id.partners.map((p) => t(p.role === 'OWNER' ? 'bot.partner.youAreOwner' : 'bot.partner.youAreStaff', { partner: p.name })),
].filter(Boolean);
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale) });
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale, id.companies.length > 0) });
}
/** Оператор админки, привязавший этот Telegram */
@@ -173,6 +192,11 @@ bot.command('start', async (c) => {
});
bot.command('app', greet);
bot.command('web', sendWebLink);
bot.callbackQuery('web', async (c) => {
await c.answerCallbackQuery();
await sendWebLink(c);
});
/** PRO-5: принять / отклонить из уведомления */
bot.callbackQuery(/^offer:(accept|decline):(\w+)$/, async (c) => {
@@ -214,6 +238,84 @@ bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
});
/** «Ответить» под уведомлением о сообщении */
// ───── барики: быстрые действия команды ─────
/** Кто может управлять бариками из бота: суперадмин или поддержка, если разрешено в настройках */
async function pointsActor(c: Context) {
const admin = await adminOf(c);
if (!admin) return null;
const owner = admin.roles.includes('SUPERADMIN');
if (!owner && !(admin.roles.includes('SUPPORT') && (await getSettings(db))['points.supportCanManage'])) return null;
return { adminId: admin.id, owner };
}
const fmt = (n: number) => new Intl.NumberFormat('ru-RU').format(n);
async function pointsError(c: Context, e: unknown) {
if (!(e instanceof DomainError)) throw e;
await c.reply(adminT(`errors.${e.code}`));
}
bot.callbackQuery(/^pr:(ok|no):(\w+)$/, async (c) => {
await c.answerCallbackQuery();
const actor = await pointsActor(c);
if (!actor) return void (await c.reply(adminT('bot.admin.notAdmin')));
const r = await db.pointsRedemption.findUnique({ where: { id: c.match[2]! }, include: { order: true } });
if (!r) return;
try {
if (c.match[1] === 'ok') {
if (!r.order) return;
const res = await confirmRedemption(ctx, { redemptionId: r.id, orderAmount: r.order.totalPrice }, actor);
await c.editMessageReplyMarkup().catch(() => undefined);
await c.reply(adminT('bot.admin.points.confirmed', { amount: fmt(res.amount) }));
} else {
await rejectRedemption(ctx, { redemptionId: r.id, reason: 'declined' }, actor);
await c.editMessageReplyMarkup().catch(() => undefined);
await c.reply(adminT('bot.admin.points.rejected'));
}
} catch (e) {
await pointsError(c, e);
}
});
/** /barik <телефон или @username> <сумма заказа> [категория] — подсказка и кнопка «Начислить» */
bot.command('barik', async (c) => {
const actor = await pointsActor(c);
if (!actor) return void (await c.reply(adminT('bot.admin.notAdmin')));
const parts = (c.match ?? '').trim().split(/\s+/).filter(Boolean);
if (parts.length < 2) return void (await c.reply(adminT('bot.admin.points.help')));
const [who, sumRaw, catRaw] = parts;
const sum = Number(sumRaw!.replace(/[^\d]/g, ''));
const digits = who!.replace(/[^\d]/g, '');
const user = who!.startsWith('@')
? await db.user.findFirst({ where: { username: { equals: who!.slice(1), mode: 'insensitive' } }, include: { client: true } })
: digits.length >= 8
? await db.user.findFirst({ where: { phone: { endsWith: digits.slice(-8) } }, include: { client: true } })
: null;
if (!user?.client) return void (await c.reply(adminT('bot.admin.points.noClient')));
const cat = await db.pointsCategory.findUnique({ where: { code: (catRaw ?? 'cleaning').toLowerCase() } });
if (!cat || !sum) return void (await c.reply(adminT('bot.admin.points.help')));
const p = await previewAccrual(db, { clientId: user.client.id, categoryId: cat.id, orderAmount: sum, serviceDate: new Date() }, await getSettings(db), new Date());
const name = [user.firstName, user.lastName].filter(Boolean).join(' ') || `@${user.username}`;
const catName = (cat.name as Record<string, string>).ru ?? cat.code;
const tier = p.tier === 'ELITE' ? 'Elite' : p.tier === 'BAREV_PLUS' ? 'Barev+' : 'Barev';
const kb = new InlineKeyboard().text(adminT('bot.admin.points.accrue'), `pa:${user.client.id}:${cat.code}:${sum}`);
await c.reply(adminT('bot.admin.points.preview', { amount: fmt(p.suggested), name, what: catName, sum: fmt(sum), tier, rate: p.ratePct, date: formatDateTime('ru', p.availableAt, { day: 'numeric', month: 'long' }) }), { reply_markup: kb });
});
bot.callbackQuery(/^pa:(\w+):([a-z_]+):(\d+)$/, async (c) => {
await c.answerCallbackQuery();
const actor = await pointsActor(c);
if (!actor) return;
const cat = await db.pointsCategory.findUnique({ where: { code: c.match[2]! } });
if (!cat) return;
try {
const r = await accruePoints(ctx, { clientId: c.match[1]!, kind: 'ORDER', categoryId: cat.id, orderAmount: Number(c.match[3]), serviceDate: new Date(), channel: 'BOT', idempotencyKey: `bot:${c.callbackQuery.message?.message_id}:${c.match[1]}` }, actor);
await c.editMessageReplyMarkup().catch(() => undefined);
await c.reply(r.status === 'created' ? adminT('bot.admin.points.done', { amount: fmt(r.amount) }) : r.status === 'needs_approval' ? adminT('bot.admin.points.needsApproval') : adminT('bot.admin.points.zero'));
} catch (e) {
await pointsError(c, e);
}
});
bot.callbackQuery(/^ar:(c|p|t):(\w+)$/, async (c) => {
const admin = await adminOf(c);
if (!admin) {
+2 -1
View File
@@ -5,6 +5,7 @@ import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CleanForm } from '../../../components/clean';
import { FooterContacts, LegalLine } from '../../../components/seo';
import { LegalLinks } from '../../../components/legal';
import { BotLink, BotLinkProvider, LangMenu, LangSwitch } from '../../../components/client';
import { getCleaning } from '../../../lib/data';
@@ -113,7 +114,7 @@ export default async function CleanPage({ params }: { params: Promise<{ locale:
<div className="footer__links">
<a href={`/${locale}`}>{t('landing.clean.home')}</a>
<FooterContacts />
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
<LegalLinks locale={locale} />
</div>
</footer>
</div>
+117
View File
@@ -0,0 +1,117 @@
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
import { Icon } from '@barev/ui';
import { notFound } from 'next/navigation';
import { LegalShell, legalMetadata } from '../../../components/legal';
import { getCatalog } from '../../../lib/data';
export const revalidate = 300;
export const dynamicParams = false;
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
return legalMetadata(params, 'pricing');
}
interface Row {
id: string;
price: string;
vars?: Record<string, string>;
href?: string;
}
/**
* Цены: подписки клуба и услуги, которые сейчас продаются. Все суммы — из настроек админки
* (как на главной и страницах услуг), поэтому страница не расходится с ботом.
*/
export default async function Pricing({ params }: { params: Promise<{ locale: string }> }) {
const { locale: raw } = await params;
if (!isLocale(raw)) notFound();
const locale: Locale = raw;
const t = createT(locale);
const amd = (n: number) => formatAmd(locale, n);
const { offer, barevPlus, categories } = await getCatalog();
const cleaning = categories.find((c) => c.code === 'HOME')?.services.find((s) => s.code === 'cleaning')?.fromPrice ?? 0;
const from = (n: number) => t('pricing.from', { price: amd(n) });
const plans = [
{ id: 'free', name: 'Barev', price: t('landing.club.free'), pct: 0 },
{ id: 'plus', name: 'Barev+', price: t('landing.club.perMonth', { price: amd(barevPlus.monthlyPrice) }), pct: offer.plusMaxPct },
{ id: 'elite', name: 'Barev Elite', price: t('landing.club.perMonth', { price: amd(offer.elitePrice) }), pct: offer.eliteMaxPct },
] as const;
const groups: { id: 'HOME' | 'DOCS'; rows: Row[] }[] = [
{
id: 'HOME',
rows: [
{ id: 'cleaning', price: from(cleaning), href: '/clean' },
{ id: 'transferStandard', price: amd(offer.transferStandard), href: '/airport' },
{ id: 'transferPremium', price: amd(offer.transferPremium), href: '/airport' },
{ id: 'hotel', price: t('pricing.perDay', { price: from(offer.hotelWithPlus) }), vars: { base: amd(offer.hotelBase) } },
],
},
{
id: 'DOCS',
rows: [
{ id: 'socialCard', price: amd(offer.socialCard), href: '/social-card' },
{ id: 'consultation', price: amd(offer.consultation), href: '/residence-permit' },
{ id: 'residence', price: from(offer.residenceEscortFrom), vars: { fee: amd(offer.residenceStateFee) }, href: '/residence-permit' },
],
},
];
return (
<LegalShell locale={locale} page="pricing">
<section className="policy__section" id="plans">
<h2>{t('pricing.plansTitle')}</h2>
<p>{t('pricing.plansNote')}</p>
<div className="pr-plans">
{plans.map((p) => (
<article key={p.id} className="pr-plan" data-plan={p.id}>
<h3>{p.name}</h3>
<span className="pr-plan__price">{p.price}</span>
<ul>
{[1, 2, 3].map((n) => (
<li key={n}>
<Icon name="check" size={16} strokeWidth={2.6} />
{t(`landing.club.${p.id}F${n}`, { pct: p.pct })}
</li>
))}
</ul>
<small>{t(`pricing.planNote.${p.id}`, { days: barevPlus.giftDays })}</small>
</article>
))}
</div>
</section>
<section className="policy__section" id="services">
<h2>{t('pricing.servicesTitle')}</h2>
<p>{t('pricing.servicesNote')}</p>
{groups.map((g) => (
<div key={g.id} className="pr-group">
<h3>{t(`categories.${g.id}.name`)}</h3>
<dl className="pr-list">
{g.rows.map((r) => (
<div key={r.id} className="pr-row">
<dt>
{r.href ? <a href={`/${locale}${r.href}`}>{t(`pricing.rows.${r.id}.name`)}</a> : t(`pricing.rows.${r.id}.name`)}
<small>{t(`pricing.rows.${r.id}.note`, r.vars)}</small>
</dt>
<dd>{r.price}</dd>
</div>
))}
</dl>
</div>
))}
</section>
<section className="policy__section" id="payment">
<h2>{t('pricing.payTitle')}</h2>
<p>{t('pricing.payBody')}</p>
<p>
<a href={`/${locale}/terms`}>{t('landing.footer.terms')}</a> · <a href={`/${locale}/refund`}>{t('landing.footer.refund')}</a>
</p>
</section>
</LegalShell>
);
}
+10 -68
View File
@@ -1,80 +1,22 @@
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
import { Logo } from '@barev/ui';
import type { Metadata } from 'next';
import { isLocale, LOCALES } from '@barev/i18n';
import { notFound } from 'next/navigation';
import { RichText } from '../../../components/rich-text';
import { FooterContacts, LegalLine } from '../../../components/seo';
import { CONTACTS, languageAlternates } from '../../../lib/seo';
import { LegalDocument, legalMetadata } from '../../../components/legal';
export const dynamicParams = false;
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
/** Разделы политики по порядку; тексты — privacy.<id>.title / privacy.<id>.body */
/** Разделы по порядку; тексты — privacy.<id>.title / privacy.<id>.body */
const SECTIONS = ['operator', 'data', 'purposes', 'basis', 'recipients', 'location', 'retention', 'security', 'rights', 'marketing', 'cookies', 'age', 'changes'] as const;
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const t = createT(locale);
return {
title: `${t('privacy.title')} — Barev Club`,
description: t('privacy.intro'),
alternates: { canonical: `/${locale}/privacy`, languages: languageAlternates('/privacy') },
// своё превью ссылки, иначе подставляется старый слоган из общего layout
openGraph: { title: `${t('privacy.title')} — Barev Club`, description: t('privacy.intro'), locale, type: 'article', siteName: 'Barev Club' },
twitter: { card: 'summary_large_image', title: `${t('privacy.title')} — Barev Club`, description: t('privacy.intro') },
};
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
return legalMetadata(params, 'privacy');
}
/** ONB-3: политика конфиденциальности. Оператор — ИП владельца; основной текст на русском, переводы needs_review */
export default async function Privacy({ params }: { params: Promise<{ locale: string }> }) {
const { locale: raw } = await params;
if (!isLocale(raw)) notFound();
const locale: Locale = raw;
const t = createT(locale);
const vars = { email: CONTACTS.email, phone: CONTACTS.phoneDisplay, tin: CONTACTS.tin };
return (
<main className="prose policy">
<div className="policy__top">
<a href={`/${locale}`} style={{ textDecoration: 'none' }}>
<Logo />
</a>
<nav className="policy__langs" aria-label="Language">
{LOCALES.map((l) => (
<a key={l} href={`/${l}/privacy`} hrefLang={l} aria-current={l === locale ? 'page' : undefined}>
{l.toUpperCase()}
</a>
))}
</nav>
</div>
<h1 className="bc-h2" style={{ marginTop: 24 }}>
{t('privacy.title')}
</h1>
<p className="policy__updated">{t('privacy.updated')}</p>
<p>{t('privacy.intro')}</p>
<ol className="policy__toc">
{SECTIONS.map((s) => (
<li key={s}>
<a href={`#${s}`}>{t(`privacy.${s}.title`)}</a>
</li>
))}
</ol>
{SECTIONS.map((s, i) => (
<section key={s} id={s} className="policy__section">
<h2>
{i + 1}. {t(`privacy.${s}.title`)}
</h2>
<RichText text={t(`privacy.${s}.body`, vars)} />
</section>
))}
<footer className="policy__footer">
<LegalLine locale={locale} />
<span className="footer__links">
<FooterContacts />
</span>
</footer>
</main>
);
/** ONB-3: политика конфиденциальности. Оператор — ИП владельца. Основной текст на русском, переводы needs_review */
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return <LegalDocument locale={locale} ns="privacy" sections={SECTIONS} />;
}
+22
View File
@@ -0,0 +1,22 @@
import { isLocale, LOCALES } from '@barev/i18n';
import { notFound } from 'next/navigation';
import { LegalDocument, legalMetadata } from '../../../components/legal';
export const dynamicParams = false;
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
/** Разделы по порядку; тексты — refund.<id>.title / refund.<id>.body */
const SECTIONS = ['cancelClient', 'cancelUs', 'quality', 'subscription', 'nonRefundable', 'howTo', 'timing'] as const;
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
return legalMetadata(params, 'refund');
}
/** Правила отмены и возврата денег. Основной текст на русском, переводы needs_review */
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return <LegalDocument locale={locale} ns="refund" sections={SECTIONS} />;
}
+22
View File
@@ -0,0 +1,22 @@
import { isLocale, LOCALES } from '@barev/i18n';
import { notFound } from 'next/navigation';
import { LegalDocument, legalMetadata } from '../../../components/legal';
export const dynamicParams = false;
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
/** Разделы по порядку; тексты — terms.<id>.title / terms.<id>.body */
const SECTIONS = ['about', 'acceptance', 'orders', 'prices', 'payment', 'cancel', 'subscription', 'perks', 'docs', 'client', 'quality', 'liability', 'content', 'changes', 'law'] as const;
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
return legalMetadata(params, 'terms');
}
/** Условия использования сервиса (публичная оферта). Основной текст на русском, переводы needs_review */
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return <LegalDocument locale={locale} ns="terms" sections={SECTIONS} />;
}
+33
View File
@@ -455,6 +455,33 @@ button.cl-extra {
color: #e9c9d5;
}
/* телефон сразу под комнатами и санузлами — единственное обязательное поле, кроме параметров */
.cl-hero-phone {
max-width: 420px;
margin-top: 4px;
}
.cl-hero-phone .bc-input {
background: #fff;
}
.cl-hero-phone[data-error='true'] .bc-input {
border-color: var(--bc-danger);
}
/* согласие на обработку данных — нажатием кнопки */
.cl-consent-note {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: #e9c9d5;
}
.cl-consent-note a {
color: inherit;
}
.cl-consent-note--mobile {
display: none;
color: var(--bc-ink-2, #6b5d57);
padding: 0 4px;
}
/* нижняя панель — только на телефоне */
.cl-bar {
display: none;
@@ -659,6 +686,12 @@ button.cl-extra {
.cl-receipt .cl-btn-pink {
display: none;
}
.cl-receipt .cl-consent-note {
display: none;
}
.cl-consent-note--mobile {
display: block;
}
.cl-bar {
position: fixed;
left: 12px;
+123
View File
@@ -419,6 +419,129 @@ a:hover {
.policy__footer a {
color: inherit;
}
.policy__legal {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
flex-basis: 100%;
}
/* страница цен */
.pr-plans {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.pr-plan {
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px;
border-radius: 20px;
background: #fff;
border: 1.5px solid rgba(74, 16, 40, 0.12);
}
.pr-plan[data-plan='plus'] {
background: var(--bc-pink);
border-color: var(--bc-pink);
}
.pr-plan[data-plan='elite'] {
background: var(--bc-perks);
border-color: var(--bc-perks);
}
.pr-plan h3 {
margin: 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-style: italic;
font-size: 26px;
line-height: 1.1;
color: var(--bc-plum);
}
.pr-plan__price {
font-family: var(--bc-font-head);
font-weight: 600;
font-size: 18px;
color: var(--bc-plum);
}
.pr-plan ul {
display: flex;
flex-direction: column;
gap: 6px;
margin: 4px 0 0;
padding: 0;
list-style: none;
font-size: 15px;
line-height: 1.4;
}
.pr-plan li {
display: flex;
gap: 8px;
align-items: flex-start;
}
.pr-plan li svg {
flex: none;
margin-top: 2px;
color: var(--bc-plum);
}
.pr-plan small {
margin-top: auto;
padding-top: 8px;
font-size: 13px;
line-height: 1.4;
color: var(--bc-ink-2);
}
.pr-group h3 {
margin: 24px 0 4px;
font-family: var(--bc-font-head);
font-size: 18px;
color: var(--bc-plum);
}
.pr-list {
margin: 0;
border-radius: 20px;
background: #fff;
padding: 4px 18px;
}
.pr-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 12px 0;
}
.pr-row + .pr-row {
border-top: 1px solid rgba(74, 16, 40, 0.1);
}
.pr-row dt {
display: flex;
flex-direction: column;
gap: 2px;
font-weight: 600;
}
.pr-row dt a {
color: inherit;
}
.pr-row dt small {
font-weight: 400;
font-size: 13px;
line-height: 1.4;
color: var(--bc-ink-2);
}
.pr-row dd {
margin: 0;
flex: none;
font-family: var(--bc-font-head);
font-weight: 600;
white-space: nowrap;
color: var(--bc-plum);
}
@media (max-width: 720px) {
.pr-plans {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 720px) {
.wrap {
padding: 12px 16px 24px;
+53 -47
View File
@@ -19,10 +19,11 @@ const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
const AGENCY_KEY = 'barev.agency';
const TYPES = ['regular', 'general', 'renovation'] as const;
type Errors = Partial<Record<'time' | 'district' | 'street' | 'phone' | 'consent' | 'submit', string>>;
type Errors = Partial<Record<'phone' | 'submit', string>>;
/**
* Страница /clean (модель qlean.ru): параметры → доп. услуги → время → адрес → контакты.
* Страница /clean (модель qlean.ru): параметры и телефон → доп. услуги → время → адрес → имя.
* Обязательны только тип уборки, комнаты, санузлы и телефон; время и адрес бот доспросит, а оператор уточнит.
* Цена считается в браузере той же функцией quote, что и на сервере. Заявка сразу видна в админке,
* а человек уходит в бота по ссылке, которая привязывает заявку к его Telegram.
*/
@@ -81,7 +82,6 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
const [note, setNote] = useState('');
const [phone, setPhone] = useState('');
const [name, setName] = useState('');
const [consent, setConsent] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [sending, setSending] = useState(false);
const [done, setDone] = useState<{ number: number; url: string } | null>(null);
@@ -96,11 +96,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
function validate(): Errors {
const e: Errors = {};
if (!time) e.time = t('landing.clean.err.time');
if (!district) e.district = t('landing.clean.err.district');
if (street.trim().length < 3) e.street = t('landing.clean.err.street');
if (!/^\+?\d{8,15}$/.test(phone.replace(/[\s()\-.]/g, ''))) e.phone = t('landing.clean.err.phone');
if (!consent) e.consent = t('landing.clean.err.consent');
return e;
}
@@ -127,9 +123,10 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
body: JSON.stringify({
json: {
params,
preferredAt: time!.toISOString(),
districtCode: district,
addressText: street.trim(),
preferredAt: time?.toISOString(),
districtCode: district || undefined,
// короткий адрес не отправляем — бот спросит его заново
addressText: street.trim().length >= 3 ? street.trim() : undefined,
note: note.trim() || undefined,
phone,
name: name.trim() || undefined,
@@ -211,6 +208,30 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
<Stepper value={baths} min={bathsF.min ?? 1} max={bathsF.max ?? 4} onChange={setBaths} decLabel={t('common.decrease')} incLabel={t('common.increase')} />
</div>
</div>
<label className="bc-field cl-hero-phone" data-error={!!errors.phone}>
<span className="bc-label">{t('landing.clean.phone')}</span>
<input
className="bc-input"
type="tel"
inputMode="tel"
autoComplete="tel"
value={phone}
maxLength={30}
placeholder="+374 91 123456"
aria-invalid={!!errors.phone}
aria-describedby="cl-phone-hint"
onChange={(e) => setPhone(e.target.value)}
/>
{errors.phone ? (
<span className="bc-error" role="alert">
{errors.phone}
</span>
) : (
<span id="cl-phone-hint" className="cl-muted cl-small">
{t('landing.clean.phoneHint')}
</span>
)}
</label>
</div>
<div className="cl-hero__price" aria-live="polite">
<PriceTicker value={q ? amd(q.totalPrice) : '…'} />
@@ -254,7 +275,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
</Block>
)}
<Block title={t('landing.clean.whenTitle')} hint={t('landing.clean.whenHint')} error={errors.time}>
<Block title={t('landing.clean.whenTitle')} hint={t('landing.clean.whenHintOptional')}>
<div className="cl-days" role="listbox" aria-label={t('landing.clean.whenTitle')}>
{days.map((d) => (
<button
@@ -284,11 +305,11 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
)}
</Block>
<Block title={t('landing.clean.addressTitle')} error={errors.district ?? errors.street}>
<Block title={t('landing.clean.addressTitle')} hint={t('landing.clean.addressHintOptional')}>
<div className="cl-grid2">
<label className="bc-field">
<span className="bc-label">{t('landing.clean.district')}</span>
<select className="bc-input" value={district} onChange={(e) => setDistrict(e.target.value)} aria-invalid={!!errors.district}>
<select className="bc-input" value={district} onChange={(e) => setDistrict(e.target.value)}>
<option value="">{t('landing.clean.districtPlaceholder')}</option>
{config.districts.map((d) => (
<option key={d} value={d}>
@@ -305,7 +326,6 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
maxLength={300}
placeholder={t('landing.clean.streetPlaceholder')}
autoComplete="street-address"
aria-invalid={!!errors.street}
onChange={(e) => setStreet(e.target.value)}
/>
</label>
@@ -316,41 +336,14 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
</label>
</Block>
<Block title={t('landing.clean.contactTitle')} error={errors.phone ?? errors.consent}>
<div className="cl-grid2">
<label className="bc-field">
<span className="bc-label">{t('landing.clean.phone')}</span>
<input
className="bc-input"
type="tel"
inputMode="tel"
autoComplete="tel"
value={phone}
maxLength={30}
placeholder="+374 91 123456"
aria-invalid={!!errors.phone}
aria-describedby="cl-phone-hint"
onChange={(e) => setPhone(e.target.value)}
/>
<span id="cl-phone-hint" className="cl-muted cl-small">
{t('landing.clean.phoneHint')}
</span>
</label>
<label className="bc-field">
<span className="bc-label">{t('landing.clean.name')}</span>
<input className="bc-input" value={name} maxLength={80} autoComplete="given-name" onChange={(e) => setName(e.target.value)} />
</label>
</div>
<label className="cl-consent">
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} aria-invalid={!!errors.consent} />
<span>
{t('landing.clean.consent')}.{' '}
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
{t('landing.clean.privacy')}
</a>
</span>
<Block title={t('landing.clean.nameTitle')}>
<label className="bc-field">
<span className="bc-label">{t('landing.clean.name')}</span>
<input className="bc-input" value={name} maxLength={80} autoComplete="given-name" onChange={(e) => setName(e.target.value)} />
</label>
</Block>
{/* на телефоне кнопка в нижней панели — текст о согласии под формой */}
<ConsentNote locale={locale} t={t} className="cl-consent-note cl-consent-note--mobile" />
</div>
<aside className="cl-receipt" aria-label={t('landing.clean.summaryTitle')}>
@@ -386,6 +379,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
</div>
)}
<p className="cl-receipt__after">{t('landing.clean.after')}</p>
<ConsentNote locale={locale} t={t} />
</aside>
</div>
@@ -403,6 +397,18 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
);
}
/** Согласие на обработку данных — нажатием кнопки, без отдельной галочки */
function ConsentNote({ locale, t, className = 'cl-consent-note' }: { locale: Locale; t: (k: string) => string; className?: string }) {
return (
<p className={className}>
{t('landing.clean.consentByClick')}{' '}
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
{t('landing.clean.privacy')}
</a>
</p>
);
}
function Block({ title, hint, error, children }: { title: string; hint?: string; error?: string; children: ReactNode }) {
return (
<section className="cl-block" data-error={!!error}>
+2 -1
View File
@@ -4,6 +4,7 @@ import type { Metadata } from 'next';
import { getCatalog, type LandingOffer } from '../lib/data';
import { BotLink } from './client';
import { SERVICE_PAGES } from '../lib/services';
import { LegalLinks } from './legal';
import { FooterContacts, LegalLine } from './seo';
/** Главная (/) и «Уже живу» (/live): индексируются, у каждой свой canonical и hreflang */
@@ -102,7 +103,7 @@ export function HomeFooter({ locale }: { locale: Locale }) {
<span className="h-footer__links">
<FooterContacts />
<BotLink>{t('landing.footer.partner')}</BotLink>
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
<LegalLinks locale={locale} />
</span>
</footer>
);
+107
View File
@@ -0,0 +1,107 @@
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
import { Logo } from '@barev/ui';
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { CONTACTS, languageAlternates } from '../lib/seo';
import { RichText } from './rich-text';
import { FooterContacts, LegalLine } from './seo';
/** Страницы с ценами и правилами: их требуют платёжные провайдеры при проверке сайта */
export const LEGAL_PAGES = ['pricing', 'terms', 'refund', 'privacy'] as const;
export type LegalPageId = (typeof LEGAL_PAGES)[number];
/** Ссылки на цены и правила — в подвал любой страницы */
export function LegalLinks({ locale }: { locale: Locale }) {
const t = createT(locale);
return (
<>
{LEGAL_PAGES.map((p) => (
<a key={p} href={`/${locale}/${p}`}>
{t(`landing.footer.${p}`)}
</a>
))}
</>
);
}
/** Мета страницы: заголовок и описание из <ns>.title / <ns>.intro */
export async function legalMetadata(params: Promise<{ locale: string }>, ns: LegalPageId): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const t = createT(locale);
const title = `${t(`${ns}.title`)} — Barev Club`;
const description = t(`${ns}.intro`);
return {
title,
description,
alternates: { canonical: `/${locale}/${ns}`, languages: languageAlternates(`/${ns}`) },
// своё превью ссылки, иначе подставляется слоган из общего layout
openGraph: { title, description, locale, type: 'article', siteName: 'Barev Club' },
twitter: { card: 'summary_large_image', title, description },
};
}
/** Каркас: логотип, языки, заголовок, подвал с реквизитами и ссылками на остальные правила */
export function LegalShell({ locale, page, children }: { locale: Locale; page: LegalPageId; children: ReactNode }) {
const t = createT(locale);
return (
<main className="prose policy">
<div className="policy__top">
<a href={`/${locale}`} style={{ textDecoration: 'none' }}>
<Logo />
</a>
<nav className="policy__langs" aria-label="Language">
{LOCALES.map((l) => (
<a key={l} href={`/${l}/${page}`} hrefLang={l} aria-current={l === locale ? 'page' : undefined}>
{l.toUpperCase()}
</a>
))}
</nav>
</div>
<h1 className="bc-h2" style={{ marginTop: 24 }}>
{t(`${page}.title`)}
</h1>
<p className="policy__updated">{t(`${page}.updated`)}</p>
<p>{t(`${page}.intro`)}</p>
{children}
<footer className="policy__footer">
<LegalLine locale={locale} />
<span className="footer__links">
<FooterContacts />
</span>
<nav className="policy__legal" aria-label={t('landing.footer.rules')}>
{LEGAL_PAGES.filter((p) => p !== page).map((p) => (
<a key={p} href={`/${locale}/${p}`}>
{t(`landing.footer.${p}`)}
</a>
))}
</nav>
</footer>
</main>
);
}
/** Текст правил: оглавление и нумерованные разделы <ns>.<id>.title / <ns>.<id>.body */
export function LegalDocument({ locale, ns, sections }: { locale: Locale; ns: Exclude<LegalPageId, 'pricing'>; sections: readonly string[] }) {
const t = createT(locale);
const vars = { email: CONTACTS.email, phone: CONTACTS.phoneDisplay, tin: CONTACTS.tin };
return (
<LegalShell locale={locale} page={ns}>
<ol className="policy__toc">
{sections.map((s) => (
<li key={s}>
<a href={`#${s}`}>{t(`${ns}.${s}.title`)}</a>
</li>
))}
</ol>
{sections.map((s, i) => (
<section key={s} id={s} className="policy__section">
<h2>
{i + 1}. {t(`${ns}.${s}.title`)}
</h2>
<RichText text={t(`${ns}.${s}.body`, vars)} />
</section>
))}
</LegalShell>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { LOCALES } from '@barev/i18n';
export const SITE = (process.env.LANDING_URL ?? 'http://localhost:3000').replace(/\/$/, '');
/** Страницы, которые должны быть в поиске; экспериментальные /landN закрыты noindex и сюда не входят */
export const INDEXED_PATHS = ['', '/live', '/clean', '/social-card', '/residence-permit', '/airport', '/privacy'] as const;
export const INDEXED_PATHS = ['', '/live', '/clean', '/social-card', '/residence-permit', '/airport', '/pricing', '/terms', '/refund', '/privacy'] as const;
/** hreflang для пути: все языки + x-default на русскую версию (абсолютные адреса для sitemap) */
export function languageAlternates(path: string, absolute = false): Record<string, string> {
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
View File
@@ -0,0 +1 @@
{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/libLine truncated
+2
View File
@@ -16,6 +16,7 @@ import { Perks } from './screens/Perks.tsx';
import { Profile } from './screens/Profile.tsx';
import { Services } from './screens/Services.tsx';
import { Chat } from './screens/Chat.tsx';
import { Points } from './screens/Points.tsx';
const ROOTS = ['/', '/orders', '/services', '/perks', '/profile', '/docs'];
@@ -65,6 +66,7 @@ function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
else if (path === '/profile') screen = <Profile />;
else if (path === '/membership') screen = <Membership />;
else if (path === '/chat') screen = <Chat />;
else if (path === '/points') screen = <Points />;
else screen = segment === 'MOVING' ? <Move /> : <Home />;
const withNav = ROOTS.includes(path);
return (
+102
View File
@@ -933,3 +933,105 @@
.chat-compose__send:disabled {
opacity: 0.4;
}
/* барики в форме заказа */
.points-toggle {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px 14px;
border-radius: 18px;
background: var(--bc-perks, #f6e3b4);
cursor: pointer;
}
.points-toggle[data-off] {
opacity: 0.6;
cursor: default;
}
.points-toggle input {
width: 22px;
height: 22px;
margin-top: 1px;
accent-color: var(--bc-plum);
flex: none;
}
.points-toggle small {
display: block;
font-size: 13px;
opacity: 0.8;
margin-top: 2px;
}
/* ───── барики ───── */
.points-hero {
gap: 6px;
}
.points-hero__num {
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 46px;
line-height: 1;
letter-spacing: -0.03em;
}
.points-hero__ghost {
color: #fff;
border-color: rgba(255, 255, 255, 0.5);
}
.points-rates {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.points-rates th,
.points-rates td {
padding: 6px 4px;
text-align: right;
}
.points-rates th:first-child,
.points-rates td:first-child {
text-align: left;
}
.points-rates th {
font-weight: 600;
opacity: 0.6;
}
.points-rates [data-on] {
font-weight: 800;
opacity: 1;
color: var(--bc-plum);
}
.points-dots {
display: flex;
gap: 8px;
}
.points-dots span {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bc-bg);
border: 2px solid rgba(74, 16, 40, 0.15);
}
.points-dots span[data-mark] {
border-color: var(--bc-plum);
}
.points-dots span[data-on] {
background: var(--bc-plum);
border-color: var(--bc-plum);
}
.points-row {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 4px;
border-bottom: 1px solid rgba(74, 16, 40, 0.08);
}
.points-row:last-child {
border-bottom: 0;
}
.points-plus {
color: var(--bc-home-ink, #2f5b1f);
white-space: nowrap;
}
.points-minus {
white-space: nowrap;
}
@@ -112,9 +112,15 @@ export function OrderDetail({ id }: { id: string }) {
<span>−{amd(o.discountAmount)}</span>
</div>
)}
{o.pointsUsed > 0 && (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.points.paidLine')}</span>
<span>−{amd(o.pointsUsed)}</span>
</div>
)}
<div className="bc-between total">
<span>{t('client.order.total')}</span>
<span>{amd(o.totalPrice)}</span>
<span>{amd(o.totalPrice - o.pointsUsed)}</span>
</div>
{o.cancelFee > 0 && (
<div className="bc-between">
+27 -1
View File
@@ -31,6 +31,8 @@ export function OrderForm({ code }: { code: string }) {
const [promoInput, setPromoInput] = useState('');
const [promo, setPromo] = useState('');
const [promoError, setPromoError] = useState<string | null>(null);
// барики: списать в этом заказе (с промокодом нельзя)
const [usePts, setUsePts] = useState(false);
const [touched, setTouched] = useState(false);
// дефолты услуги, затем — параметры повторяемого заказа (ORD-7)
@@ -132,10 +134,13 @@ export function OrderForm({ code }: { code: string }) {
paymentMethodId: methodId || undefined,
repeatOfId: repeatId ?? undefined,
promoCode: q?.promo ? promo : undefined,
usePoints: ptsUsed || undefined,
});
}
const q = quote.data;
const ptsAvailable = !!q?.points.allowed && !q.promo;
const ptsUsed = usePts && ptsAvailable ? q!.points.max : 0;
return (
<main className="app app--plain">
<PageHead title={t(`services.${code}.name`)} />
@@ -294,6 +299,27 @@ export function OrderForm({ code }: { code: string }) {
) : (
<Spinner />
)}
{q && q.points.balance > 0 && (
<label className="points-toggle" data-off={!ptsAvailable || undefined}>
<input type="checkbox" checked={usePts && ptsAvailable} disabled={!ptsAvailable} onChange={(e) => setUsePts(e.target.checked)} />
<span>
<b>{t('client.points.useInOrder', { amount: amd(q.points.max).replace(/\s?֏$/, '') })}</b>
<small>
{q.promo
? t('client.points.notWithPromo')
: !q.points.allowed
? t('client.points.belowMin', { min: amd(q.points.min).replace(/\s?֏$/, ''), balance: amd(q.points.balance).replace(/\s?֏$/, '') })
: t('client.points.useHint', { pct: q.points.sharePct, balance: amd(q.points.balance).replace(/\s?֏$/, '') })}
</small>
</span>
</label>
)}
{ptsUsed > 0 && (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.points.paidLine')}</span>
<span>−{amd(ptsUsed)}</span>
</div>
)}
<PromoField
value={promoInput}
onChange={(v) => {
@@ -325,7 +351,7 @@ export function OrderForm({ code }: { code: string }) {
{t('client.order.total')}
</span>
<span className="bc-num" style={{ fontSize: 22 }}>
{q ? amd(q.totalPrice) : '…'}
{q ? amd(q.totalPrice - ptsUsed) : '…'}
</span>
</div>
{slot && (
+213
View File
@@ -0,0 +1,213 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Badge, Button, Card, Center, Field, Icon, Sheet, Spinner } from '@barev/ui';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { LEVEL_NAME, PageHead, type Level } from '../lib.tsx';
/** Экран «Барики»: баланс, ожидание, сгорание, ставки по уровню, серия уборок, маршрут, история */
export function Points() {
const { t, dt, locale } = useI18n();
const trpc = useTRPC();
const p = useQuery(trpc.client.points.summary.queryOptions());
const history = useQuery(trpc.client.points.history.queryOptions());
const [pay, setPay] = useState(false);
const n = (v: number) => new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale).format(v);
const date = (d: Date) => dt(d, { day: 'numeric', month: 'long' });
const name = (m: Record<string, string> | null | undefined) => (m ? m[locale] || m.ru || m.en || '' : '');
if (!p.data)
return (
<Center>
<Spinner />
</Center>
);
const d = p.data;
const tier = d.tier as Level;
return (
<main className="app app--plain">
<PageHead title={t('client.points.title')} />
<Card tone="dark" className="bc-stack points-hero">
<span className="bc-small" style={{ opacity: 0.75 }}>
{t('client.points.balance')}
</span>
<div className="points-hero__num">{n(d.balance)}</div>
<span className="bc-small" style={{ opacity: 0.85 }}>
{t('client.points.equals', { amount: n(Math.max(0, d.balance)) })}
</span>
{d.pending > 0 && <span className="bc-small">⏳ {t('client.points.pending', { amount: n(d.pending), date: d.pendingNextAt ? date(d.pendingNextAt) : '—' })}</span>}
{d.burnAt && <span className="bc-small">🔥 {t('client.points.burn', { amount: n(d.burnAmount), date: date(d.burnAt) })}</span>}
<div className="bc-row" style={{ gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
<Button size="sm" variant="light" disabled={d.balance < d.redeem.min} onClick={() => setPay(true)}>
{t('client.points.pay')}
</Button>
<a className="bc-btn bc-btn--outline bc-btn--sm points-hero__ghost" href="#/profile?code=1">
{t('client.points.enterCode')}
</a>
</div>
{d.openRequests > 0 && <span className="bc-small">{t('client.points.requestPending')}</span>}
</Card>
<Card className="bc-stack">
<div className="bc-between">
<h2 className="bc-h3">{t('client.points.rates')}</h2>
<Badge>{LEVEL_NAME[tier]}</Badge>
</div>
<table className="points-rates">
<thead>
<tr>
<th />
<th data-on={tier === 'BAREV' || undefined}>Barev</th>
<th data-on={tier === 'BAREV_PLUS' || undefined}>Barev+</th>
<th data-on={tier === 'ELITE' || undefined}>Elite</th>
</tr>
</thead>
<tbody>
{d.rates.map((r) => (
<tr key={r.code}>
<td>{name(r.name)}</td>
<td data-on={tier === 'BAREV' || undefined}>{r.barev}%</td>
<td data-on={tier === 'BAREV_PLUS' || undefined}>{r.plus}%</td>
<td data-on={tier === 'ELITE' || undefined}>{r.elite}%</td>
</tr>
))}
</tbody>
</table>
{tier === 'BAREV' && d.plusWouldGet > 0 && (
<a href="#/membership" className="bc-small" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
{t('client.points.plusWouldGet', { amount: n(d.plusWouldGet) })}
</a>
)}
</Card>
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.points.streak')}</h2>
<div className="points-dots" aria-hidden="true">
{[1, 2, 3, 4, 5, 6].map((i) => (
<span key={i} data-on={d.streak.count >= i || undefined} data-mark={i === 3 || i === 6 || undefined} />
))}
</div>
<p className="bc-small" style={{ margin: 0 }}>
{d.streak.next
? t('client.points.streakLeft', { left: d.streak.next - d.streak.count, bonus: d.streak.next === 3 ? 2 : 3, days: d.streak.gapDays })
: t('client.points.streakMax', { bonus: d.streak.bonusPct })}
</p>
</Card>
{d.route && (
<Card tone="home" className="bc-stack">
<h2 className="bc-h3">{t('client.points.route')}</h2>
<div className="points-dots" aria-hidden="true">
{[1, 2, 3].map((i) => (
<span key={i} data-on={d.route!.done >= i || undefined} />
))}
</div>
<p className="bc-small" style={{ margin: 0 }}>
{t('client.points.routeText', { done: d.route.done, amount: n(d.route.amount), date: date(d.route.until) })}
</p>
</Card>
)}
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('client.points.history')}</h2>
{!history.data ? (
<Spinner />
) : history.data.length === 0 ? (
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.points.empty')}
</p>
) : (
<Card tight className="bc-stack" style={{ gap: 0 }}>
{history.data.map((r) => (
<div key={`${r.type}:${r.id}`} className="points-row">
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 600 }}>
{r.type === 'bonus' ? name(r.bonus) : r.type === 'accrual' ? name(r.category) : t(`client.points.type.${r.type}`)}
{r.orderRef ? <span className="bc-muted"> {r.orderRef}</span> : null}
</div>
<div className="bc-small bc-muted">
{dt(r.at, { day: 'numeric', month: 'short' })} · {t(`client.points.status.${r.status}`)}
{r.status === 'PENDING' && r.availableAt ? ` · ${t('client.points.availableFrom', { date: date(r.availableAt) })}` : ''}
{r.expiresAt && r.status === 'ACTIVE' ? ` · ${t('client.points.until', { date: date(r.expiresAt) })}` : ''}
</div>
{r.comment && <div className="bc-small">💬 {r.comment}</div>}
</div>
<b className={r.amount > 0 ? 'points-plus' : 'points-minus'}>
{r.amount > 0 ? '+' : '−'}
{n(Math.abs(r.amount))}
</b>
</div>
))}
</Card>
)}
</section>
{pay && <PaySheet balance={d.balance} sharePct={d.redeem.sharePct} min={d.redeem.min} onClose={() => setPay(false)} />}
</main>
);
}
/** Оплатить бариками: выбрать заказ или ввести сумму — запрос уходит команде */
function PaySheet({ balance, sharePct, min, onClose }: { balance: number; sharePct: number; min: number; onClose: () => void }) {
const { t, amd, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const orders = useQuery(trpc.client.orders.list.queryOptions());
const [orderId, setOrderId] = useState<string>('');
const [amount, setAmount] = useState('');
const [note, setNote] = useState('');
const order = orders.data?.active.find((o) => o.id === orderId);
const max = order ? Math.min(balance, Math.floor(((order.totalPrice - order.pointsUsed) * sharePct) / 100)) : balance;
const value = Number(amount) || 0;
const send = useMutation(
trpc.client.points.request.mutationOptions({
onSuccess: () => {
haptic.ok();
toast(t('client.points.requested'));
void qc.invalidateQueries();
onClose();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const payable = orders.data?.active.filter((o) => o.paymentStatus === 'UNPAID') ?? [];
return (
<Sheet open onClose={onClose} label={t('client.points.pay')}>
<div className="bc-stack">
<div className="bc-between">
<h2 className="bc-h2">{t('client.points.pay')}</h2>
<button className="back" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" />
</button>
</div>
<p className="bc-small" style={{ margin: 0 }}>
{t('client.points.payHint', { pct: sharePct, min: amd(min).replace(/\s?֏$/, '') })}
</p>
<Field label={t('client.points.forOrder')}>
<select className="bc-input" value={orderId} onChange={(e) => setOrderId(e.target.value)}>
<option value="">{t('client.points.otherOrder')}</option>
{payable.map((o) => (
<option key={o.id} value={o.id}>
#{o.number} · {t(`services.${o.serviceCode}.name`)} · {dt(o.scheduledAt, { day: 'numeric', month: 'short' })}
</option>
))}
</select>
</Field>
<Field label={t('client.points.amount')}>
<input className="bc-input" inputMode="numeric" value={amount} placeholder={String(max)} onChange={(e) => setAmount(e.target.value.replace(/\D/g, '').slice(0, 7))} />
</Field>
<p className="bc-small bc-muted" style={{ margin: '-6px 0 0' }}>
{t('client.points.max', { max: amd(max).replace(/\s?֏$/, '') })}
</p>
{!order && (
<Field label={t('client.points.note')}>
<input className="bc-input" value={note} maxLength={300} placeholder={t('client.points.notePh')} onChange={(e) => setNote(e.target.value)} />
</Field>
)}
<Button block disabled={send.isPending || value < min || value > max} onClick={() => send.mutate({ amount: value, orderId: orderId || undefined, note: note.trim() || undefined })}>
{t('client.points.send')}
</Button>
</div>
</Sheet>
);
}
@@ -42,6 +42,14 @@ export function Profile() {
<LanguagePicker />
<nav className="links">
<a href="#/points">
<Icon name="star" size={20} />
<span>
{t('client.points.title')}
{me.data ? <b style={{ marginLeft: 8 }}>{new Intl.NumberFormat('ru-RU').format(me.data.points)}</b> : null}
</span>
<Icon name="arrow" size={18} />
</a>
<a href="#/orders">
<Icon name="clock" size={20} />
<span>{t('client.orders.title')}</span>
+6 -1
View File
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { Center, Icon, Spinner } from '@barev/ui';
import { I18nProvider, match, navigate, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
import { useEffect, type ReactNode } from 'react';
import { forgetWebLogin, isWeb } from './auth.ts';
import { Company } from './screens/Company.tsx';
import { History } from './screens/History.tsx';
import { Job } from './screens/Job.tsx';
@@ -15,10 +16,14 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
const me = useQuery({ ...trpc.pro.me.queryOptions(), enabled: hasInitData });
const locale = pickLocale(me.data?.locale, telegramLanguage(), navigator.language);
const status = me.error?.data?.httpStatus;
useEffect(() => {
if (status === 401 && isWeb()) forgetWebLogin();
}, [status]);
return (
<I18nProvider initial={locale}>
{!hasInitData || status === 401 ? (
<Message titleKey="pro.openInTelegram.title" textKey="pro.openInTelegram.text" />
// в браузере без входа или с устаревшим входом — подсказка, как получить ссылку из бота
<Message titleKey="pro.openInTelegram.title" textKey={isWeb() ? 'pro.web.loginHint' : 'pro.openInTelegram.text'} />
) : status === 403 ? (
<Message titleKey="pro.notLinked.title" textKey="pro.notLinked.text" />
) : me.isPending ? (
+57
View File
@@ -203,3 +203,60 @@
.invite .bc-field {
width: 100%;
}
/* кабинет менеджера партнёра: в браузере на компьютере — шире, карточки в две колонки */
.app--wide {
max-width: 1040px;
padding-bottom: 40px;
}
.cab-grid {
display: grid;
gap: 8px;
}
@media (min-width: 820px) {
.app--wide .cab-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.cab-row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.cab-link {
border: none;
background: none;
padding: 6px 0;
color: var(--bc-plum);
font-weight: 700;
cursor: pointer;
font: inherit;
}
.cab-copy {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 8px 8px 12px;
border-radius: 16px;
background: var(--bc-bg);
}
.cab-copy code {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.cab-switch {
display: flex;
gap: 12px;
align-items: flex-start;
cursor: pointer;
}
.cab-switch input {
width: 22px;
height: 22px;
margin-top: 2px;
accent-color: var(--bc-plum);
}
+51 -2
View File
@@ -1,5 +1,54 @@
import { getInitData } from '@barev/web';
/**
* Внутри Telegram запросы подписаны initData. В браузере (кабинет менеджера партнёра на компьютере)
* — токен: бот выдаёт одноразовую ссылку ?login=<код>, страница меняет её на токен и хранит в localStorage.
*/
const initData = getInitData();
export const hasInitData = !!initData;
export const authHeader = () => ({ authorization: `tma ${initData}` });
const TOKEN_KEY = 'barev.pro.web';
function readToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
let webToken = initData ? null : readToken();
export const hasAuth = () => !!initData || !!webToken;
export const isWeb = () => !initData;
export const authHeader = (): Record<string, string> => (initData ? { authorization: `tma ${initData}` } : webToken ? { authorization: `pro ${webToken}` } : {});
export function forgetWebLogin() {
webToken = null;
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* приватный режим */
}
}
/** Ссылка из бота: обменять одноразовый код на токен до первого запроса */
export async function consumeLoginLink(): Promise<void> {
if (initData) return;
const url = new URL(window.location.href);
const code = url.searchParams.get('login');
if (!code) return;
url.searchParams.delete('login');
window.history.replaceState(null, '', url.toString());
try {
const res = await fetch('/trpc/pro.webLogin', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ json: { code } }) });
const body = (await res.json()) as { result?: { data?: { json?: { token: string } } } };
const token = body.result?.data?.json?.token;
if (!token) return;
webToken = token;
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* без localStorage вход действует до закрытия вкладки */
}
} catch {
/* сеть — покажем экран «откройте ссылку из бота» */
}
}
+13 -10
View File
@@ -4,18 +4,21 @@ import { ApiProvider, loadTextOverrides, initTelegram, ToastProvider } from '@ba
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import { authHeader, hasInitData } from './auth.ts';
import { authHeader, consumeLoginLink, hasAuth } from './auth.ts';
initTelegram();
// названия и описания услуг, изменённые в админке
void loadTextOverrides();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApiProvider headers={authHeader}>
<ToastProvider>
<App hasInitData={hasInitData} />
</ToastProvider>
</ApiProvider>
</StrictMode>,
);
// в браузере: ссылка из бота ?login=… меняется на токен до первого запроса
void consumeLoginLink().then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApiProvider headers={authHeader}>
<ToastProvider>
<App hasInitData={hasAuth()} />
</ToastProvider>
</ApiProvider>
</StrictMode>,
);
});
+565 -85
View File
@@ -1,20 +1,24 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Badge, Button, Card, Center, Chip, Icon, Sheet, Spinner } from '@barev/ui';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { LOCALES, type Locale } from '@barev/i18n';
import { Badge, Button, Card, Center, Chip, Field, Icon, Segmented, Sheet, Spinner } from '@barev/ui';
import { confirmDialog, errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState, type ReactNode } from 'react';
import { forgetWebLogin, isWeb } from '../auth.ts';
import { JobRow, PageHead } from '../lib.tsx';
/** PRO-7: диспетчер компании распределяет заказы, назначенные на компанию */
type Tab = 'orders' | 'staff' | 'money' | 'settings';
type StaffCheck = 'ok' | 'busy' | 'gap' | 'limit' | 'notLinked';
/** Кабинет менеджера партнёра: заказы и распределение, сотрудники, доход, настройки */
export function Company({ companies }: { companies: { id: string; name: string }[] }) {
const { t } = useI18n();
const trpc = useTRPC();
const [companyId, setCompanyId] = useState(companies[0]!.id);
const board = useQuery({ ...trpc.pro.dispatcher.board.queryOptions({ companyId }), refetchInterval: 15_000 });
const [picking, setPicking] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>('orders');
const cab = useCabinet(companyId);
return (
<main className="app">
<PageHead title={board.data?.company.name ?? t('pro.company.title')} back={false} />
<main className={isWeb() ? 'app app--wide' : 'app'}>
<PageHead title={cab.data?.company.name ?? t('pro.company.title')} back={false} />
{companies.length > 1 && (
<div className="bc-chips">
{companies.map((c) => (
@@ -24,111 +28,587 @@ export function Company({ companies }: { companies: { id: string; name: string }
))}
</div>
)}
{!board.data ? (
<Segmented
value={tab}
onChange={setTab}
label={t('pro.company.title')}
options={[
{ value: 'orders', label: t('pro.cab.tab.orders') + (cab.data?.pending.length ? ` · ${cab.data.pending.length}` : '') },
{ value: 'staff', label: t('pro.cab.tab.staff') },
{ value: 'money', label: t('pro.cab.tab.money') },
{ value: 'settings', label: t('pro.cab.tab.settings') },
]}
/>
{!cab.data ? (
<Center>
<Spinner />
</Center>
) : tab === 'orders' ? (
<Orders companyId={companyId} data={cab.data} />
) : tab === 'staff' ? (
<Staff companyId={companyId} data={cab.data} />
) : tab === 'money' ? (
<Money data={cab.data} />
) : (
<>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">
{t('pro.company.pending')} · {board.data.pending.length}
</h2>
{board.data.pending.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noPending')}</p>}
{board.data.pending.map((o) => (
<Card key={o.id} tone="pink" tight className="bc-stack" style={{ gap: 8 }}>
<JobRow j={o} />
<Button size="sm" onClick={() => setPicking(o.id)}>
{t('pro.company.assign')}
</Button>
</Card>
))}
</section>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.company.assigned')}</h2>
{board.data.assigned.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noAssigned')}</p>}
{board.data.assigned.map((o) => (
<JobRow key={o.id} j={o} right={<span className="bc-small" style={{ fontWeight: 600 }}>{o.providerName}</span>} />
))}
</section>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.company.staff')}</h2>
<Card tight className="bc-stack" style={{ gap: 6 }}>
{board.data.staff.map((p) => (
<div key={p.id} className="bc-between" style={{ minHeight: 40 }}>
<span style={{ fontWeight: 600 }}>{p.name}</span>
<span className="bc-row bc-small">
{p.languages.map((l) => l.toUpperCase()).join(' ')}
<Badge style={{ background: p.onShift ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(p.onShift ? 'pro.today.onShift' : 'pro.today.offShift')}</Badge>
{!p.linked && <Badge style={{ background: 'var(--bc-docs)' }}>{t('pro.company.notLinked')}</Badge>}
</span>
</div>
))}
</Card>
</section>
</>
<Settings companyId={companyId} data={cab.data} />
)}
{picking && <StaffSheet companyId={companyId} orderId={picking} onClose={() => setPicking(null)} />}
</main>
);
}
function StaffSheet({ companyId, orderId, onClose }: { companyId: string; orderId: string; onClose: () => void }) {
const { t } = useI18n();
function useCabinet(companyId: string) {
const trpc = useTRPC();
return useQuery({ ...trpc.pro.dispatcher.cabinet.queryOptions({ companyId }), refetchInterval: 15_000 });
}
type Cabinet = NonNullable<ReturnType<typeof useCabinet>['data']>;
function useAction() {
const { t } = useI18n();
const qc = useQueryClient();
const toast = useToast();
return {
onSuccess: (msg?: string) => () => {
haptic.ok();
if (msg) toast(msg);
void qc.invalidateQueries();
},
onError: (e: unknown) => toast(errorText(t, e), 'error'),
};
}
// ───── заказы ─────
function Orders({ companyId, data }: { companyId: string; data: Cabinet }) {
const { t, dt, amd } = useI18n();
const trpc = useTRPC();
const act = useAction();
const [picking, setPicking] = useState<string | null>(null);
const decline = useMutation(trpc.pro.dispatcher.decline.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.declined')), onError: act.onError }));
const unassign = useMutation(trpc.pro.dispatcher.unassign.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.unassigned')), onError: act.onError }));
return (
<>
{data.company.autoAssign && (
<Card tight tone="blue" className="bc-row" style={{ gap: 10 }}>
<Icon name="repeat" size={20} />
<span className="bc-small">{t('pro.cab.autoOn')}</span>
</Card>
)}
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">
{t('pro.company.pending')} · {data.pending.length}
</h2>
{data.pending.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noPending')}</p>}
<div className="cab-grid">
{data.pending.map((o) => (
<Card key={o.id} tone="pink" tight className="bc-stack" style={{ gap: 8 }}>
<JobRow j={o} right={<b className="bc-small">{dt(o.scheduledAt, { day: 'numeric', month: 'short' })}</b>} />
<div className="bc-small" style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
<span>
{t('pro.cab.income')}: <b>{amd(o.income)}</b>
</span>
{o.deadline && !o.offeredTo && <span>{t('pro.cab.deadline', { time: dt(o.deadline, { hour: '2-digit', minute: '2-digit' }) })}</span>}
{o.offeredTo && <span>{t('pro.cab.offeredTo', { name: o.offeredTo })}</span>}
</div>
<div className="bc-row" style={{ gap: 8 }}>
<Button size="sm" onClick={() => setPicking(o.id)}>
{t('pro.company.assign')}
</Button>
<Button
size="sm"
variant="ghost"
disabled={decline.isPending}
onClick={async () => {
if (await confirmDialog(t('pro.cab.declineConfirm'))) decline.mutate({ companyId, orderId: o.id });
}}
>
{t('pro.cab.decline')}
</Button>
</div>
</Card>
))}
</div>
</section>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.company.assigned')}</h2>
{data.assigned.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noAssigned')}</p>}
<div className="cab-grid">
{data.assigned.map((o) => (
<Card key={o.id} tight className="bc-stack" style={{ gap: 6 }}>
<JobRow j={o} right={<b className="bc-small">{dt(o.scheduledAt, { day: 'numeric', month: 'short' })}</b>} />
<div className="bc-between bc-small">
<span>
<Icon name="user" size={16} /> <b>{o.providerName}</b> · {t(`status.${o.flow}.${o.status}`)}
</span>
{o.canUnassign && (
<button
type="button"
className="cab-link"
disabled={unassign.isPending}
onClick={async () => {
if (await confirmDialog(t('pro.cab.unassignConfirm', { name: o.providerName ?? '' }))) unassign.mutate({ companyId, orderId: o.id });
}}
>
{t('pro.cab.reassign')}
</button>
)}
</div>
</Card>
))}
</div>
</section>
{picking && <AssignSheet companyId={companyId} orderId={picking} onClose={() => setPicking(null)} />}
</>
);
}
const CHECK_TONE: Record<StaffCheck, string> = { ok: 'var(--bc-home)', notLinked: 'var(--bc-docs)', gap: 'var(--bc-docs)', limit: 'var(--bc-docs)', busy: 'var(--bc-bg)' };
function AssignSheet({ companyId, orderId, onClose }: { companyId: string; orderId: string; onClose: () => void }) {
const { t } = useI18n();
const trpc = useTRPC();
const act = useAction();
const staff = useQuery(trpc.pro.dispatcher.staffForOrder.queryOptions({ companyId, orderId }));
const assign = useMutation(
trpc.pro.dispatcher.assign.mutationOptions({
onSuccess: () => {
haptic.ok();
toast(t('pro.company.assigned_ok'));
void qc.invalidateQueries();
act.onSuccess(t('pro.company.assigned_ok'))();
onClose();
},
onError: (e) => toast(errorText(t, e), 'error'),
onError: act.onError,
}),
);
// свободные и говорящие на языке клиента — выше
const sorted = [...(staff.data ?? [])].sort((a, b) => Number(b.free) - Number(a.free) || Number(b.speaksClientLanguage) - Number(a.speaksClientLanguage) || a.load - b.load);
return (
<Sheet open onClose={onClose} label={t('pro.company.assign')}>
<div className="bc-stack">
<div className="bc-between">
<h2 className="bc-h2">{t('pro.company.chooseStaff')}</h2>
<button className="back" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" />
</button>
</div>
<SheetHead title={t('pro.company.chooseStaff')} onClose={onClose} />
{staff.data && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('pro.cab.gapHint', { min: staff.data.gap })}</p>}
{!staff.data ? (
<Spinner />
) : staff.data.rows.length === 0 ? (
<p className="bc-muted">{t('pro.cab.noStaff')}</p>
) : (
sorted.map((p) => (
<button
key={p.id}
type="button"
className="job"
style={{ border: 'none', textAlign: 'left', cursor: p.free ? 'pointer' : 'not-allowed', opacity: p.free ? 1 : 0.5 }}
disabled={!p.free || assign.isPending}
onClick={() => assign.mutate({ companyId, orderId, providerId: p.id })}
>
<div className="job__main">
<div style={{ fontWeight: 700 }}>{p.name}</div>
<div className="bc-small bc-muted">
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.company.load', { count: p.load })}
staff.data.rows.map((p) => {
const can = p.check !== 'busy';
return (
<button
key={p.id}
type="button"
className="job"
style={{ border: 'none', textAlign: 'left', cursor: can ? 'pointer' : 'not-allowed', opacity: can ? 1 : 0.5 }}
disabled={!can || assign.isPending}
onClick={async () => {
if (p.check !== 'ok' && !(await confirmDialog(t(`pro.cab.check.${p.check}_confirm`)))) return;
assign.mutate({ companyId, orderId, providerId: p.id });
}}
>
<div className="job__main">
<div style={{ fontWeight: 700 }}>{p.name}</div>
<div className="bc-small bc-muted">
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.cab.dayLoad', { n: p.dayOrders, max: p.maxOrdersPerDay ?? '∞' })} · {t('pro.cab.weekLoad', { n: p.weekOrders })}
{p.rating != null && ` · ★ ${p.rating}`}
</div>
</div>
</div>
<span className="bc-row">
{p.speaksClientLanguage && <Badge style={{ background: 'var(--bc-home)' }}>{t('pro.company.speaksLanguage')}</Badge>}
<Badge style={{ background: p.free ? 'var(--bc-blue)' : 'var(--bc-bg)' }}>{t(p.free ? 'pro.company.free' : 'pro.company.busy')}</Badge>
</span>
</button>
))
<span className="bc-row" style={{ flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{p.language && <Badge style={{ background: 'var(--bc-home)' }}>{t('pro.company.speaksLanguage')}</Badge>}
{p.district && <Badge style={{ background: 'var(--bc-blue)' }}>{t('pro.cab.district')}</Badge>}
<Badge style={{ background: CHECK_TONE[p.check] }}>{t(`pro.cab.check.${p.check}`)}</Badge>
</span>
</button>
);
})
)}
</div>
</Sheet>
);
}
function SheetHead({ title, onClose }: { title: ReactNode; onClose: () => void }) {
const { t } = useI18n();
return (
<div className="bc-between">
<h2 className="bc-h2">{title}</h2>
<button className="back" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" />
</button>
</div>
);
}
// ───── сотрудники ─────
type StaffRow = Cabinet['staff'][number];
function Staff({ companyId, data }: { companyId: string; data: Cabinet }) {
const { t } = useI18n();
const [edit, setEdit] = useState<StaffRow | 'new' | null>(null);
return (
<>
<div className="bc-between">
<h2 className="bc-h3">
{t('pro.company.staff')} · {data.staff.filter((s) => s.active).length}
</h2>
<Button size="sm" onClick={() => setEdit('new')}>
<Icon name="plus" size={18} /> {t('pro.cab.addStaff')}
</Button>
</div>
{data.staff.length === 0 && <p className="bc-muted">{t('pro.cab.noStaff')}</p>}
<div className="cab-grid">
{data.staff.map((p) => (
<button key={p.id} type="button" className="job" style={{ border: 'none', textAlign: 'left', cursor: 'pointer', opacity: p.active ? 1 : 0.5 }} onClick={() => setEdit(p)}>
<div className="job__main">
<div style={{ fontWeight: 700 }}>{p.name}</div>
<div className="bc-small bc-muted">
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.cab.todayLoad', { n: p.today, max: p.maxOrdersPerDay ?? '∞' })}
{p.maxHoursPerDay ? ` · ${t('pro.cab.hoursMax', { n: p.maxHoursPerDay })}` : ''}
</div>
</div>
<span className="bc-row">
{!p.active ? (
<Badge style={{ background: 'var(--bc-bg)' }}>{t('pro.cab.off')}</Badge>
) : !p.linked ? (
<Badge style={{ background: 'var(--bc-docs)' }}>{t('pro.company.notLinked')}</Badge>
) : (
<Badge style={{ background: p.onShift ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(p.onShift ? 'pro.today.onShift' : 'pro.today.offShift')}</Badge>
)}
</span>
</button>
))}
</div>
{edit && <StaffSheet companyId={companyId} districts={data.districts} staff={edit === 'new' ? null : edit} onClose={() => setEdit(null)} />}
</>
);
}
const DAYS = [0, 1, 2, 3, 4, 5, 6];
const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const toMin = (s: string) => {
const [h, m] = s.split(':').map(Number);
return (h ?? 0) * 60 + (m ?? 0);
};
function StaffSheet({ companyId, districts, staff, onClose }: { companyId: string; districts: { id: string; code: string }[]; staff: StaffRow | null; onClose: () => void }) {
const { t } = useI18n();
const trpc = useTRPC();
const act = useAction();
const first = staff?.schedule[0];
const [f, setF] = useState({
name: staff?.name ?? '',
phone: staff?.phone ?? '',
languages: (staff?.languages ?? ['ru']) as Locale[],
districtIds: staff?.districtIds ?? [],
days: staff ? [...new Set(staff.schedule.map((s) => s.weekday))] : [0, 1, 2, 3, 4, 5],
from: hhmm(first?.startMinute ?? 9 * 60),
to: hhmm(first?.endMinute ?? 19 * 60),
maxOrders: staff?.maxOrdersPerDay ? String(staff.maxOrdersPerDay) : '',
maxHours: staff?.maxHoursPerDay ? String(staff.maxHoursPerDay) : '',
});
const [link, setLink] = useState<string | null>(staff?.inviteLink ?? null);
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
const payload = () => ({
name: f.name,
phone: f.phone || null,
languages: f.languages,
districtIds: f.districtIds,
schedule: f.days.map((weekday) => ({ weekday, startMinute: toMin(f.from), endMinute: toMin(f.to) })),
maxOrdersPerDay: Number(f.maxOrders) || null,
maxHoursPerDay: Number(f.maxHours) || null,
});
const add = useMutation(
trpc.pro.dispatcher.addStaff.mutationOptions({
onSuccess: (r) => {
act.onSuccess(t('pro.cab.staffAdded'))();
setLink(r.inviteLink);
},
onError: act.onError,
}),
);
const save = useMutation(
trpc.pro.dispatcher.updateStaff.mutationOptions({
onSuccess: () => {
act.onSuccess(t('pro.cab.saved'))();
onClose();
},
onError: act.onError,
}),
);
const reset = useMutation(trpc.pro.dispatcher.resetStaffInvite.mutationOptions({ onSuccess: (r) => setLink(r.inviteLink), onError: act.onError }));
const created = !staff && !!link;
return (
<Sheet open onClose={onClose} label={staff ? staff.name : t('pro.cab.addStaff')}>
<div className="bc-stack">
<SheetHead title={staff ? staff.name : t('pro.cab.addStaff')} onClose={onClose} />
{created ? (
<>
<p style={{ margin: 0 }}>{t('pro.cab.inviteText')}</p>
<CopyBox url={link!} />
<Button block onClick={onClose}>
{t('pro.cab.done')}
</Button>
</>
) : (
<>
<Field label={t('pro.cab.f.name')}>
<input className="bc-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
</Field>
<Field label={t('pro.cab.f.phone')}>
<input className="bc-input" type="tel" value={f.phone} maxLength={30} onChange={(e) => setF({ ...f, phone: e.target.value })} />
</Field>
<Field label={t('pro.cab.f.languages')}>
<div className="bc-chips">
{LOCALES.map((l) => (
<Chip key={l} pressed={f.languages.includes(l)} onClick={() => setF({ ...f, languages: toggle(f.languages, l) })}>
{l.toUpperCase()}
</Chip>
))}
</div>
</Field>
<Field label={t('pro.cab.f.districts')}>
<div className="bc-chips">
{districts.map((d) => (
<Chip key={d.id} pressed={f.districtIds.includes(d.id)} onClick={() => setF({ ...f, districtIds: toggle(f.districtIds, d.id) })}>
{t(`districts.${d.code}`)}
</Chip>
))}
</div>
</Field>
<Field label={t('pro.cab.f.days')}>
<div className="bc-chips">
{DAYS.map((d) => (
<Chip key={d} pressed={f.days.includes(d)} onClick={() => setF({ ...f, days: toggle(f.days, d) })}>
{t(`pro.cab.wd.${d}`)}
</Chip>
))}
</div>
</Field>
<div className="cab-row2">
<Field label={t('pro.cab.f.from')}>
<input className="bc-input" type="time" step={1800} value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
</Field>
<Field label={t('pro.cab.f.to')}>
<input className="bc-input" type="time" step={1800} value={f.to} onChange={(e) => setF({ ...f, to: e.target.value })} />
</Field>
</div>
<div className="cab-row2">
<Field label={t('pro.cab.f.maxOrders')}>
<input className="bc-input" inputMode="numeric" placeholder="∞" value={f.maxOrders} onChange={(e) => setF({ ...f, maxOrders: e.target.value.replace(/\D/g, '').slice(0, 2) })} />
</Field>
<Field label={t('pro.cab.f.maxHours')}>
<input className="bc-input" inputMode="numeric" placeholder="∞" value={f.maxHours} onChange={(e) => setF({ ...f, maxHours: e.target.value.replace(/\D/g, '').slice(0, 2) })} />
</Field>
</div>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('pro.cab.f.loadHint')}
</p>
{staff && link && (
<>
<p className="bc-small" style={{ margin: 0 }}>
{t('pro.cab.inviteText')}
</p>
<CopyBox url={link} />
</>
)}
<Button
block
disabled={!f.name.trim() || !f.languages.length || toMin(f.to) <= toMin(f.from) || add.isPending || save.isPending}
onClick={() => (staff ? save.mutate({ companyId, providerId: staff.id, staff: payload() }) : add.mutate({ companyId, staff: payload() }))}
>
{staff ? t('pro.cab.save') : t('pro.cab.addStaff')}
</Button>
{staff && (
<div className="bc-row" style={{ gap: 8, flexWrap: 'wrap' }}>
{staff.linked && (
<Button size="sm" variant="ghost" disabled={reset.isPending} onClick={async () => (await confirmDialog(t('pro.cab.resetConfirm'))) && reset.mutate({ companyId, providerId: staff.id })}>
{t('pro.cab.resetInvite')}
</Button>
)}
<Button
size="sm"
variant={staff.active ? 'danger' : 'outline'}
disabled={save.isPending}
onClick={async () => {
if (!staff.active || (await confirmDialog(t('pro.cab.offConfirm', { name: staff.name })))) save.mutate({ companyId, providerId: staff.id, staff: payload(), active: !staff.active });
}}
>
{staff.active ? t('pro.cab.turnOff') : t('pro.cab.turnOn')}
</Button>
</div>
)}
</>
)}
</div>
</Sheet>
);
}
function CopyBox({ url }: { url: string }) {
const { t } = useI18n();
const toast = useToast();
return (
<div className="cab-copy">
<code>{url}</code>
<Button
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard?.writeText(url);
toast(t('pro.cab.copied'));
}}
>
{t('pro.cab.copy')}
</Button>
</div>
);
}
// ───── доход ─────
function Money({ data }: { data: Cabinet }) {
const { t, amd, dt, locale } = useI18n();
const m = data.money;
const terms = data.company.terms;
const month = (p: string) => new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(`${p}-15T12:00:00+04:00`));
return (
<>
<Card tone="dark" className="bc-stack" style={{ gap: 6 }}>
<span className="bc-small" style={{ opacity: 0.75 }}>
{t('pro.cab.balance')}
</span>
<div className="stat__value" style={{ fontSize: 30 }}>
{amd(m.balance)}
</div>
<span className="bc-small" style={{ opacity: 0.85 }}>
{t('pro.cab.accrued')}: {amd(m.accrued)} · {t('pro.cab.paidOut')}: {amd(m.paid)}
</span>
</Card>
<Card tight className="bc-small">
{terms.deal === 'COMMISSION' ? t('pro.cab.terms.COMMISSION', { pct: terms.commissionPct }) : t('pro.cab.terms.PROFIT_SHARE', { pct: terms.profitSharePct, fot: terms.fotPct, tax: terms.taxPct })}
</Card>
{m.months.length > 0 && (
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.cab.byMonth')}</h2>
<Card tight className="bc-stack" style={{ gap: 6 }}>
{m.months.map((x) => (
<div key={x.period} className="bc-between">
<span style={{ textTransform: 'capitalize' }}>{month(x.period)}</span>
<span className="bc-small bc-muted">{t('pro.cab.ordersN', { n: x.orders })}</span>
<b>{amd(x.amount)}</b>
</div>
))}
</Card>
</section>
)}
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.cab.byOrder')}</h2>
{m.recent.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.cab.noIncome')}</p>}
<div className="cab-grid">
{m.recent.map((e) => (
<Card key={e.orderId} tight className="bc-stack" style={{ gap: 2 }}>
<div className="bc-between">
<b>
#{e.number} · {dt(e.at, { day: 'numeric', month: 'short' })}
</b>
<b>{amd(e.amount)}</b>
</div>
<span className="bc-small bc-muted">
{e.staff ? `${e.staff} · ` : ''}
{t('pro.cab.paidBy', { sum: amd(e.paid) })}
{e.deal === 'PROFIT_SHARE' ? ` · ${t('pro.cab.minus', { fot: amd(e.fot), tax: amd(e.tax), pct: e.pct })}` : ` · ${t('pro.cab.minusCommission', { pct: e.pct })}`}
</span>
</Card>
))}
</div>
</section>
{m.payouts.length > 0 && (
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.cab.payouts')}</h2>
<Card tight className="bc-stack" style={{ gap: 6 }}>
{m.payouts.map((p) => (
<div key={p.id} className="bc-between">
<span>{p.paidAt ? dt(p.paidAt, { day: 'numeric', month: 'long' }) : '—'}</span>
<Badge style={{ background: p.status === 'PAID' ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(`pro.cab.payout.${p.status === 'PAID' ? 'paid' : 'pending'}`)}</Badge>
<b>{amd(p.amount)}</b>
</div>
))}
</Card>
</section>
)}
</>
);
}
// ───── настройки ─────
function Settings({ companyId, data }: { companyId: string; data: Cabinet }) {
const { t } = useI18n();
const trpc = useTRPC();
const act = useAction();
const c = data.company;
const [auto, setAuto] = useState(c.autoAssign);
const [gap, setGap] = useState(String(c.travelGapMinutes));
const save = useMutation(trpc.pro.dispatcher.saveSettings.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.saved')), onError: act.onError }));
const remove = useMutation(trpc.pro.dispatcher.removeManager.mutationOptions({ onSuccess: act.onSuccess(), onError: act.onError }));
const logout = useMutation(
trpc.pro.webLogout.mutationOptions({
onSettled: () => {
forgetWebLogin();
window.location.reload();
},
}),
);
const gapN = Number(gap) || 0;
return (
<>
<Card className="bc-stack">
<label className="cab-switch">
<input type="checkbox" checked={auto} onChange={(e) => setAuto(e.target.checked)} />
<span>
<b>{t('pro.cab.auto')}</b>
<span className="bc-small bc-muted" style={{ display: 'block' }}>
{t('pro.cab.autoHint')}
</span>
</span>
</label>
<Field label={t('pro.cab.gap')} error={gapN < c.minTravelGap ? t('pro.cab.gapMin', { min: c.minTravelGap }) : undefined}>
<input className="bc-input" inputMode="numeric" value={gap} onChange={(e) => setGap(e.target.value.replace(/\D/g, '').slice(0, 3))} />
</Field>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('pro.cab.timeoutHint', { min: c.acceptTimeoutMinutes })}
</p>
<Button disabled={save.isPending || gapN < c.minTravelGap} onClick={() => save.mutate({ companyId, autoAssign: auto, travelGapMinutes: gapN })}>
{t('pro.cab.save')}
</Button>
</Card>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.cab.managers')}</h2>
<Card tight className="bc-stack" style={{ gap: 6 }}>
{data.managers.map((m) => (
<div key={m.id} className="bc-between" style={{ minHeight: 36 }}>
<span>
{m.name}
{m.me && <span className="bc-muted"> · {t('pro.cab.you')}</span>}
</span>
{!m.me && (
<button type="button" className="cab-link" onClick={async () => (await confirmDialog(t('pro.cab.removeManagerConfirm', { name: m.name }))) && remove.mutate({ companyId, managerId: m.id })}>
{t('pro.cab.remove')}
</button>
)}
</div>
))}
</Card>
<p className="bc-small" style={{ margin: 0 }}>
{t('pro.cab.managerInvite')}
</p>
<CopyBox url={c.managerLink} />
</section>
{isWeb() && (
<Button variant="ghost" onClick={() => logout.mutate()}>
{t('pro.cab.logout')}
</Button>
)}
</>
);
}
+5 -3
View File
@@ -64,9 +64,11 @@ export function History() {
<div className="bc-small bc-muted">
{history.data.rate.kind === 'preset'
? t('pro.history.ratePreset', { name: history.data.rate.name })
: history.data.rate.type === 'PERCENT'
? t('pro.history.ratePercent', { value: history.data.rate.value })
: t('pro.history.rateFixed', { value: amd(history.data.rate.value) })}
: history.data.rate.value == null
? t('pro.history.ratePartner')
: history.data.rate.type === 'PERCENT'
? t('pro.history.ratePercent', { value: history.data.rate.value })
: t('pro.history.rateFixed', { value: amd(history.data.rate.value) })}
</div>
{history.data.orders.length === 0 ? (
<p className="bc-muted">{t('pro.history.empty')}</p>
+2 -2
View File
@@ -81,7 +81,7 @@ export function Job({ id }: { id: string }) {
</dd>
<dt>{t('pro.job.price')}</dt>
<dd>
{amd(j.totalPrice)} · {t(`payment.${j.paymentStatus}`)}
{amd(j.dueAmount)} · {t(`payment.${j.paymentStatus}`)}
</dd>
</dl>
{paramsExtras(t, j.params).length > 0 && (
@@ -141,7 +141,7 @@ export function Job({ id }: { id: string }) {
</p>
)}
{j.paymentStatus === 'UNPAID' && j.status !== 'NEW' && (
<Button variant="outline" block disabled={paid.isPending} onClick={async () => (await confirmDialog(t('pro.job.confirmPaid', { amount: amd(j.totalPrice) }))) && paid.mutate({ orderId: j.id })}>
<Button variant="outline" block disabled={paid.isPending} onClick={async () => (await confirmDialog(t('pro.job.confirmPaid', { amount: amd(j.dueAmount) }))) && paid.mutate({ orderId: j.id })}>
{t('pro.job.markPaid')}
</Button>
)}
+3 -1
View File
@@ -2,12 +2,14 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
// API_PROXY — другой локальный API (например, второй экземпляр на соседнем порту)
const api = process.env.API_PROXY ?? 'http://localhost:4000';
export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: true,
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
proxy: { '/trpc': api, '/files': api, '/client-log': api },
},
build: { target: 'es2022', sourcemap: true },
});
+163 -4
View File
@@ -2,10 +2,10 @@
* Воркер: превращает доменные события в сообщения Telegram на языке получателя (MSG-2, MSG-3)
* и раз в минуту запускает фоновые задачи — напоминания и истечение предложений.
*/
import { getFlow, type ServiceFlow } from '@barev/core';
import { getFlow, toYerevan, type ServiceFlow } from '@barev/core';
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { autoAssign, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { autoAssign, retryCompanyAutoAssign, activatePendingPoints, burnPoints, buildPointSuggestions, expirePointRequests, reconcilePoints, warnExpiringPoints, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { getFile } from '@barev/services/storage';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
@@ -119,6 +119,7 @@ async function loadOrder(id: string) {
district: true,
client: { include: { user: true } },
provider: { include: { user: true } },
company: { select: { autoAssign: true } },
},
});
}
@@ -166,7 +167,108 @@ async function dispatchers(companyId: string) {
return db.companyDispatcher.findMany({ where: { companyId }, include: { user: true } });
}
// ───── барики ─────
const pts = (locale: Locale, n: number) => formatAmd(locale, n).replace(/\s?֏$/, '');
const pickName = (v: unknown, locale: Locale) => {
const m = (v ?? {}) as Record<string, string>;
return m[locale] || m.ru || m.en || '';
};
const pointsButton = (locale: Locale) => compactRows([[webAppButton(createT(locale)('notify.points.open'), `${CLIENT_APP}/?r=/points`)]]);
async function toClientUser(clientId: string, build: (t: ReturnType<typeof createT>, locale: Locale, c: { pointsActive: number }) => string | null) {
const c = await db.client.findUnique({ where: { id: clientId }, include: { user: true } });
if (!c) return;
const locale = c.user.locale as Locale;
const text = build(createT(locale), locale, c);
if (text) await clientBot.send({ chatId: c.user.telegramId, text, buttons: pointsButton(locale) });
}
const dateL = (locale: Locale, d: Date) => formatDateTime(locale, d, { day: 'numeric', month: 'long' });
async function handlePoints(event: DomainEvent): Promise<boolean> {
switch (event.type) {
case 'points.accrued':
case 'points.activated': {
const a = await db.pointsAccrual.findUnique({ where: { id: event.accrualId }, include: { category: true, bonusType: true } });
if (!a) return true;
await toClientUser(a.clientId, (t, l, c) => {
const comment = a.commentForClient && a.bonusType?.code !== 'housewarming' ? `\n💬 ${escapeHtml(a.commentForClient)}` : '';
if (event.type === 'points.activated') return t('notify.points.activated', { amount: pts(l, a.amount), balance: pts(l, c.pointsActive) });
if (a.kind === 'BONUS') {
const what = a.bonusType?.code === 'housewarming' && a.commentForClient ? t('notify.points.housewarming', { agency: a.commentForClient }) : pickName(a.bonusType?.name, l);
return (a.expiresAt ? t('notify.points.bonus', { amount: pts(l, a.amount), what, until: dateL(l, a.expiresAt) }) : t('notify.points.bonusNoTtl', { amount: pts(l, a.amount), what })) + comment;
}
const what = pickName(a.category?.name, l);
return (a.status === 'PENDING'
? t('notify.points.pending', { amount: pts(l, a.amount), what, date: dateL(l, a.serviceDate), available: dateL(l, a.availableAt) })
: t('notify.points.active', { amount: pts(l, a.amount), what, balance: pts(l, c.pointsActive) })) + comment;
});
return true;
}
case 'points.redeemed': {
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId } });
if (!r) return true;
await toClientUser(r.clientId, (t, l, c) =>
r.orderAmount != null
? t('notify.points.redeemed', { amount: pts(l, r.amount), due: formatAmd(l, Math.max(0, r.orderAmount - r.amount)), balance: pts(l, c.pointsActive) })
: t('notify.points.redeemedNoOrder', { amount: pts(l, r.amount), balance: pts(l, c.pointsActive) }),
);
return true;
}
case 'points.rejected': {
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId } });
if (!r) return true;
await toClientUser(r.clientId, (t) => (r.reason === 'timeout' ? t('notify.points.requestExpired') : t('notify.points.rejected', { reason: escapeHtml(r.reason === 'declined' ? t('notify.points.declinedGeneric') : (r.reason ?? '')) })));
return true;
}
case 'points.reversed': {
const rv = await db.pointsReversal.findUnique({ where: { id: event.reversalId } });
if (!rv) return true;
const reasonText = (t: ReturnType<typeof createT>) => escapeHtml(rv.reason === 'order_cancelled' ? t('notify.points.orderCancelled') : rv.reason === 'order_edited' ? t('notify.points.orderEdited') : rv.reason);
if (rv.targetType === 'REDEMPTION') {
await toClientUser(rv.clientId, (t, l, c) => t('notify.points.returned', { amount: pts(l, rv.amount), balance: pts(l, c.pointsActive) }));
} else {
const a = await db.pointsAccrual.findUnique({ where: { id: rv.targetId }, include: { bonusType: true } });
await toClientUser(rv.clientId, (t, l, c) => t('notify.points.reversed', { what: a?.orderRef ?? pickName(a?.bonusType?.name, l), reason: reasonText(t), balance: pts(l, c.pointsActive) }));
}
return true;
}
case 'points.expiring':
await toClientUser(event.clientId, (t, l) => t('notify.points.expiring', { days: event.days, amount: pts(l, event.amount) }));
return true;
case 'points.expired':
await toClientUser(event.clientId, (t, l) => t('notify.points.expired', { amount: pts(l, event.amount) }));
return true;
case 'points.requested': {
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId }, include: { client: { include: { user: true } }, order: true } });
if (!r) return true;
const order = r.order ? `\n${adminT('bot.admin.points.forOrder', { number: r.order.number, sum: formatAmd('ru', r.order.totalPrice) })}` : r.note ? `\n💬 ${escapeHtml(r.note)}` : '';
const buttons: InlineButton[][] = [[...(r.order ? [{ text: adminT('bot.admin.points.confirm'), callback_data: `pr:ok:${r.id}` }] : []), { text: adminT('bot.admin.points.reject'), callback_data: `pr:no:${r.id}` }]];
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=requests` }]] : [];
await toAdmins(`🪙 <b>${adminT('bot.admin.points.request', { amount: pts('ru', r.amount) })}</b>\n${who(r.client.user)} · ${adminT('bot.admin.points.balance', { balance: pts('ru', r.client.pointsActive) })}${order}`, compactRows([...buttons, ...link]));
return true;
}
case 'points.suggestions': {
const open = await db.pointsSuggestion.count({ where: { status: 'OPEN' } });
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=suggestions` }]] : [];
await toAdmins(`💡 ${adminT('bot.admin.points.suggestions', { count: event.count, open })}`, compactRows(link));
return true;
}
case 'points.overLimit': {
const s = await db.pointsSuggestion.findUnique({ where: { id: event.suggestionId }, include: { client: { include: { user: true } } } });
if (!s) return true;
const by = typeof (s.payload as Record<string, unknown>).adminId === 'string' ? await db.adminUser.findUnique({ where: { id: (s.payload as Record<string, string>).adminId } }) : null;
const owners = await db.adminUser.findMany({ where: { active: true, telegramId: { not: null }, roles: { has: 'SUPERADMIN' } } });
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=suggestions` }]] : [];
for (const o of owners) await proBot.send({ chatId: o.telegramId!, text: `⚠️ ${adminT('bot.admin.points.overLimit', { by: escapeHtml(by?.name ?? '—'), amount: pts('ru', s.suggestedAmount) })}\n${who(s.client.user)}`, buttons: compactRows(link) });
return true;
}
default:
return false;
}
}
async function handle(event: DomainEvent) {
if (event.type.startsWith('points.') && (await handlePoints(event))) return;
switch (event.type) {
case 'order.created': {
const o = await loadOrder(event.orderId);
@@ -207,6 +309,14 @@ async function handle(event: DomainEvent) {
if (o) await toClient(o, `notify.event.${event.code}`);
return;
}
case 'order.edited': {
const o = await loadOrder(event.orderId);
if (!o) return;
const locale = o.client.user.locale as Locale;
await toClient(o, 'notify.orderEdited', { total: formatAmd(locale, o.totalPrice - o.pointsUsed) });
if (o.provider?.user) await toProvider(o, o.provider.user, 'notify.pro.orderEdited');
return;
}
case 'order.rescheduled': {
const o = await loadOrder(event.orderId);
if (!o) return;
@@ -261,9 +371,28 @@ async function handle(event: DomainEvent) {
await reportAuto(o.id, await autoAssign(ctx, o.id));
return;
}
// при автораспределении партнёра заказ сам уйдёт следующему; менеджеру пишем, если сотрудников не осталось (company.noStaff)
if (o.company?.autoAssign) return;
for (const d of await dispatchers(companyId)) await toProvider(o, d.user, 'notify.dispatcher.returned');
return;
}
case 'company.noStaff': {
const o = await loadOrder(event.orderId);
if (!o) return;
for (const d of await dispatchers(event.companyId)) await toProvider(o, d.user, 'notify.dispatcher.noStaff', proDetails(o, d.user.locale as Locale));
return;
}
case 'partner.returned': {
const o = await loadOrder(event.orderId);
const company = await db.providerCompany.findUnique({ where: { id: event.companyId } });
if (!o || !company) return;
await toAdmins(
`↩️ ${escapeHtml(adminT(event.reason === 'declined' ? 'bot.admin.partnerDeclined' : 'bot.admin.partnerExpired', { number: o.number, partner: company.name }))}\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}`,
compactRows([[orderButton(o.id)]]),
);
if (event.reason === 'expired') for (const d of await dispatchers(event.companyId)) await toProvider(o, d.user, 'notify.dispatcher.expired');
return;
}
case 'company.order': {
const o = await loadOrder(event.orderId);
if (!o) return;
@@ -413,9 +542,10 @@ async function handle(event: DomainEvent) {
case 'webRequest.created': {
const r = await db.webRequest.findUnique({ where: { id: event.requestId }, include: { service: true, district: true } });
if (!r) return;
const date = formatDateTime('ru', r.preferredAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
const date = r.preferredAt ? formatDateTime('ru', r.preferredAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }) : adminT('bot.admin.noTime');
const place = r.district ? adminT(`districts.${r.district.code}`) : adminT('bot.admin.noAddress');
await toAdmins(
`🧹 <b>${adminT('bot.admin.newWebRequest', { number: r.number })}</b>\n${escapeHtml(adminT(`services.${r.service.code}.name`))} · ${date}\n${escapeHtml(r.name ?? '')} ${escapeHtml(r.phone)} · ${escapeHtml(adminT(`districts.${r.district.code}`))} · ${formatAmd('ru', r.totalPrice)}`,
`🧹 <b>${adminT('bot.admin.newWebRequest', { number: r.number })}</b>\n${escapeHtml(adminT(`services.${r.service.code}.name`))} · ${date}\n${escapeHtml(r.name ?? '')} ${escapeHtml(r.phone)} · ${escapeHtml(place)} · ${formatAmd('ru', r.totalPrice)}`,
compactRows([[adminButton('/requests')]]),
);
return;
@@ -448,6 +578,9 @@ async function tick() {
if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`);
// заказы, которые ещё ждут исполнителя, — пробуем распределить снова
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
// заказы партнёров с автораспределением, которые ждут сотрудника
await retryCompanyAutoAssign(ctx);
await pointsJobs();
// задачи продажников, у которых наступил срок
await dueSalesTasks(ctx);
// рассылки, прерванные перезапуском, — продолжить
@@ -458,6 +591,32 @@ async function tick() {
ticking = false;
}
}
/**
* Барики: ожидающие → доступные и просроченные запросы — каждую минуту;
* раз в час — предложения системы; раз в сутки — сгорание (03:00), сверка (04:00), предупреждения (11:00).
* Задачи идемпотентны: повторный запуск после рестарта ничего не задваивает.
*/
const lastRun = new Map<string, string>();
async function once(key: string, period: string, fn: () => Promise<unknown>) {
if (lastRun.get(key) === period) return;
lastRun.set(key, period);
await fn();
}
async function pointsJobs() {
await activatePendingPoints(ctx);
await expirePointRequests(ctx);
const y = toYerevan(new Date());
const day = `${y.year}-${y.month}-${y.day}`;
await once('suggestions', `${day} ${y.hour}`, () => buildPointSuggestions(ctx));
if (y.hour >= 3) await once('burn', day, () => burnPoints(ctx));
if (y.hour >= 4)
await once('reconcile', day, async () => {
const diff = await reconcilePoints(db);
if (diff.length) await toAdmins(`⚠️ ${adminT('bot.admin.points.mismatch', { count: diff.length })}`);
});
if (y.hour >= 11) await once('warn', day, () => warnExpiringPoints(ctx));
}
setInterval(tick, 60_000);
void tick();
+11 -5
View File
@@ -230,15 +230,21 @@ describe('Barev+ и агентства (ONB-4, SUB-3, ADM-8)', () => {
const now = new Date('2026-10-17T10:00:00Z');
it('подарочный месяц — один раз', () => {
const ok = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: null });
expect(ok).toEqual({ ok: true, startsAt: now, endsAt: new Date('2026-11-16T10:00:00Z') });
expect(decideGift({ now, giftUsedAt: now, agencyActive: true, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'ALREADY_USED' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: false, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'AGENCY_INACTIVE' });
const ok = decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 1, currentEndsAt: null });
expect(ok).toEqual({ ok: true, startsAt: now, endsAt: new Date('2026-11-17T10:00:00Z') });
expect(decideGift({ now, giftUsedAt: now, agencyActive: true, giftMonths: 1, currentEndsAt: null })).toEqual({ ok: false, reason: 'ALREADY_USED' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: false, giftMonths: 1, currentEndsAt: null })).toEqual({ ok: false, reason: 'AGENCY_INACTIVE' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 0, currentEndsAt: null })).toEqual({ ok: false, reason: 'NO_GIFT' });
});
it('подарок на несколько месяцев', () => {
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 3, currentEndsAt: null });
expect(r.ok && r.endsAt).toEqual(new Date('2027-01-17T10:00:00Z'));
});
it('подарок продлевает действующую подписку', () => {
const endsAt = new Date('2026-10-20T00:00:00Z');
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: endsAt });
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 1, currentEndsAt: endsAt });
expect(r.ok && r.startsAt).toEqual(endsAt);
});
+2
View File
@@ -13,3 +13,5 @@ export * from './settings.ts';
export * from './payments.ts';
export * from './catalog.ts';
export * from './payout.ts';
export * from './partners.ts';
export * from './points.ts';
+8 -6
View File
@@ -1,4 +1,4 @@
import { addMonths, DAY } from './time.ts';
import { addMonths } from './time.ts';
export interface SubscriptionLike {
startsAt: Date;
@@ -33,23 +33,25 @@ export function discountFor(level: MembershipLevel, plusPct: number, elitePct =
export type GiftDecision =
| { ok: true; startsAt: Date; endsAt: Date }
| { ok: false; reason: 'ALREADY_USED' | 'AGENCY_INACTIVE' };
| { ok: false; reason: 'ALREADY_USED' | 'AGENCY_INACTIVE' | 'NO_GIFT' };
/**
* SUB-3 / ONB-4: подарочный месяц по коду агентства, один раз на клиента.
* Если у клиента уже есть активный Barev+, подарок продлевает его с даты окончания.
* SUB-3 / ONB-4: подарок по коду агентства (уровень и срок задаются у агентства), один раз на клиента.
* Если у клиента уже есть активная подписка, подарок начинается с даты её окончания.
*/
export function decideGift(input: {
now: Date;
giftUsedAt: Date | null;
agencyActive: boolean;
giftDays: number;
/** срок подарка агентства в месяцах; 0 — агентство ничего не дарит */
giftMonths: number;
currentEndsAt: Date | null;
}): GiftDecision {
if (input.giftUsedAt) return { ok: false, reason: 'ALREADY_USED' };
if (!input.agencyActive) return { ok: false, reason: 'AGENCY_INACTIVE' };
if (input.giftMonths <= 0) return { ok: false, reason: 'NO_GIFT' };
const startsAt = input.currentEndsAt && input.currentEndsAt > input.now ? input.currentEndsAt : input.now;
return { ok: true, startsAt, endsAt: new Date(startsAt.getTime() + input.giftDays * DAY) };
return { ok: true, startsAt, endsAt: addMonths(startsAt, input.giftMonths) };
}
/** Клиент «принадлежит» агентству N месяцев с первого /start (решение владельца) */
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { partnerSplit, partnerTerms, withinDailyLimits } from './partners.ts';
import { isProviderFreeWithGap } from './slots.ts';
import { fromYerevan } from './time.ts';
describe('партнёры услуг: деньги', () => {
it('как Uber: наш % с оплаченного, остальное партнёру', () => {
const r = partnerSplit(20_000, { deal: 'COMMISSION', commissionPct: 30, profitSharePct: 0, fotPct: 0, taxPct: 0 });
expect(r).toMatchObject({ partner: 14_000, margin: 6_000, fot: 0, tax: 0, pct: 30 });
});
it('расходы наши: % партнёра от (оплачено − ФОТ − налог)', () => {
const r = partnerSplit(20_000, { deal: 'PROFIT_SHARE', commissionPct: 0, profitSharePct: 30, fotPct: 60, taxPct: 5 });
// ФОТ 12 000, налог 1 000, прибыль 7 000 → партнёру 2 100
expect(r).toMatchObject({ fot: 12_000, tax: 1_000, partner: 2_100, cost: 14_100, margin: 5_900 });
});
it('ФОТ и налог по умолчанию из настроек, свои — у партнёра', () => {
const base = { deal: 'PROFIT_SHARE' as const, commissionPct: 0, profitSharePct: 50 };
expect(partnerTerms({ ...base, fotPct: null, taxPct: null }, { fotPct: 50, taxPct: 10 })).toMatchObject({ fotPct: 50, taxPct: 10 });
expect(partnerTerms({ ...base, fotPct: 40, taxPct: 0 }, { fotPct: 50, taxPct: 10 })).toMatchObject({ fotPct: 40, taxPct: 0 });
});
});
describe('партнёры услуг: нагрузка сотрудника', () => {
it('лимит заказов и часов в день', () => {
const day = [{ durationMinutes: 180 }, { durationMinutes: 120 }];
expect(withinDailyLimits({ maxOrdersPerDay: 3, maxHoursPerDay: null }, day, 60)).toBe(true);
expect(withinDailyLimits({ maxOrdersPerDay: 2, maxHoursPerDay: null }, day, 60)).toBe(false);
expect(withinDailyLimits({ maxOrdersPerDay: null, maxHoursPerDay: 6 }, day, 60)).toBe(true);
expect(withinDailyLimits({ maxOrdersPerDay: null, maxHoursPerDay: 6 }, day, 61)).toBe(false);
});
it('между заказами остаётся время на дорогу', () => {
// суббота 24.10.2026, график 9:00–21:00 (0 — понедельник), занят 10:00–12:00
const p = { id: 'p', schedule: [{ weekday: 5, startMinute: 9 * 60, endMinute: 21 * 60 }], busy: [{ start: fromYerevan(2026, 10, 24, 600), end: fromYerevan(2026, 10, 24, 720) }] };
const at = (m: number, len = 120) => ({ start: fromYerevan(2026, 10, 24, m), end: fromYerevan(2026, 10, 24, m + len) });
expect(isProviderFreeWithGap(p, at(12 * 60), 0)).toBe(true);
expect(isProviderFreeWithGap(p, at(12 * 60 + 30), 60)).toBe(false);
expect(isProviderFreeWithGap(p, at(13 * 60), 60)).toBe(true);
expect(isProviderFreeWithGap(p, at(7 * 60 + 30, 90), 60)).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
/** Условия партнёра услуг (клининговой компании) */
export interface PartnerTerms {
deal: 'PROFIT_SHARE' | 'COMMISSION';
/** COMMISSION: наш % с заказа */
commissionPct: number;
/** PROFIT_SHARE: % партнёра от прибыли */
profitSharePct: number;
/** PROFIT_SHARE: ФОТ клинера и налог, % от оплаченного (уже с учётом настроек по умолчанию) */
fotPct: number;
taxPct: number;
}
export interface PartnerSplit {
/** фактически оплачено клиентом */
paid: number;
/** PROFIT_SHARE: ФОТ клинера (платим мы) и налог */
fot: number;
tax: number;
/** доход партнёра */
partner: number;
/** всё, что мы платим исполнителям по заказу: ФОТ + доля партнёра (без налога) — для сравнения с фрилансерами */
cost: number;
/** нам остаётся до налога: paid − cost */
margin: number;
/** % партнёра (PROFIT_SHARE) или наша комиссия (COMMISSION) — то, что запоминаем в начислении */
pct: number;
}
const pctOf = (sum: number, pct: number) => Math.floor((sum * pct) / 100);
/**
* Деньги по заказу партнёра.
* - «Расходы наши» (PROFIT_SHARE): партнёр получает X% от (оплачено − ФОТ − налог); ФОТ клинеру платим мы.
* - «Как Uber» (COMMISSION): мы забираем свой %, остальное — партнёру; его расходы — его.
*/
export function partnerSplit(paid: number, t: PartnerTerms): PartnerSplit {
if (t.deal === 'COMMISSION') {
const ours = pctOf(paid, t.commissionPct);
const partner = paid - ours;
return { paid, fot: 0, tax: 0, partner, cost: partner, margin: ours, pct: t.commissionPct };
}
const fot = pctOf(paid, t.fotPct);
const tax = pctOf(paid, t.taxPct);
const profit = Math.max(0, paid - fot - tax);
const partner = pctOf(profit, t.profitSharePct);
const cost = fot + partner;
return { paid, fot, tax, partner, cost, margin: paid - cost, pct: t.profitSharePct };
}
/** Условия партнёра с подставленными общими ФОТ и налогом из настроек */
export function partnerTerms(
c: { deal: 'PROFIT_SHARE' | 'COMMISSION'; commissionPct: number; profitSharePct: number; fotPct: number | null; taxPct: number | null },
defaults: { fotPct: number; taxPct: number },
): PartnerTerms {
return { deal: c.deal, commissionPct: c.commissionPct, profitSharePct: c.profitSharePct, fotPct: c.fotPct ?? defaults.fotPct, taxPct: c.taxPct ?? defaults.taxPct };
}
/** Лимиты нагрузки сотрудника на день: заказов и часов (пусто — без ограничения) */
export function withinDailyLimits(
limits: { maxOrdersPerDay: number | null; maxHoursPerDay: number | null },
dayOrders: { durationMinutes: number }[],
newDurationMinutes: number,
): boolean {
if (limits.maxOrdersPerDay != null && dayOrders.length + 1 > limits.maxOrdersPerDay) return false;
if (limits.maxHoursPerDay != null) {
const minutes = dayOrders.reduce((s, o) => s + o.durationMinutes, 0) + newDurationMinutes;
if (minutes > limits.maxHoursPerDay * 60) return false;
}
return true;
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { fifoPlan, nextStreak, redeemLimit, streakBonusPct, suggestAccrual, tierRate } from './points.ts';
const d = (s: string) => new Date(`${s}T10:00:00Z`);
describe('барики', () => {
it('подсказка: (сумма − не облагается − оплачено бариками) × ставка, вниз', () => {
expect(suggestAccrual({ orderAmount: 18_000, ratePct: 5 })).toBe(900);
expect(suggestAccrual({ orderAmount: 18_000, excludedAmount: 3_000, paidWithPoints: 5_000, ratePct: 8 })).toBe(800);
expect(suggestAccrual({ orderAmount: 999, ratePct: 5 })).toBe(49);
expect(suggestAccrual({ orderAmount: 1_000, excludedAmount: 5_000, ratePct: 5 })).toBe(0);
});
it('ставка по уровню', () => {
const c = { rateBarev: 5, ratePlus: 8, rateElite: 10 };
expect([tierRate(c, 'BAREV'), tierRate(c, 'BAREV_PLUS'), tierRate(c, 'ELITE')]).toEqual([5, 8, 10]);
});
it('серия уборок: перерыв ≤ 35 дней — +1, больше — заново; надбавка с 3-й и 6-й', () => {
expect(nextStreak({ count: 0, lastAt: null }, d('2026-10-01'), 35)).toBe(1);
expect(nextStreak({ count: 2, lastAt: d('2026-10-01') }, d('2026-11-05'), 35)).toBe(3);
expect(nextStreak({ count: 2, lastAt: d('2026-10-01') }, d('2026-11-06'), 35)).toBe(1);
expect([streakBonusPct(2), streakBonusPct(3), streakBonusPct(5), streakBonusPct(6)]).toEqual([0, 2, 2, 3]);
});
it('лимит списания: 30% заказа, не больше баланса, минимум 1000', () => {
expect(redeemLimit({ orderAmount: 18_000, balance: 10_000, sharePct: 30, min: 1000 })).toEqual({ max: 5_400, allowed: true });
expect(redeemLimit({ orderAmount: 18_000, balance: 4_150, sharePct: 30, min: 1000 })).toEqual({ max: 4_150, allowed: true });
expect(redeemLimit({ orderAmount: 18_000, balance: 800, sharePct: 30, min: 1000 })).toEqual({ max: 800, allowed: false });
});
it('FIFO: сначала со своим сроком, потом самые старые', () => {
const list = [
{ id: 'new', remaining: 900, availableAt: d('2026-10-20'), expiresAt: null },
{ id: 'old', remaining: 500, availableAt: d('2026-09-01'), expiresAt: null },
{ id: 'bonus', remaining: 300, availableAt: d('2026-10-10'), expiresAt: d('2026-11-10') },
];
expect(fifoPlan(list, 1000)).toEqual([
{ id: 'bonus', amount: 300 },
{ id: 'old', amount: 500 },
{ id: 'new', amount: 200 },
]);
expect(fifoPlan(list, 5000)).toBeNull();
});
});
+68
View File
@@ -0,0 +1,68 @@
/** Барики — кэшбек клуба: 1 барик = 1 ֏, только целые числа */
export type Tier = 'BAREV' | 'BAREV_PLUS' | 'ELITE';
export interface RateRow {
rateBarev: number;
ratePlus: number;
rateElite: number;
}
export function tierRate(c: RateRow, tier: Tier): number {
return tier === 'ELITE' ? c.rateElite : tier === 'BAREV_PLUS' ? c.ratePlus : c.rateBarev;
}
/** Надбавка за серию уборок: с 3-й подряд +2 п.п., с 6-й +3 п.п. */
export function streakBonusPct(count: number): number {
return count >= 6 ? 3 : count >= 3 ? 2 : 0;
}
/** Какой будет серия после уборки в serviceDate: перерыв больше gapDays — серия заново */
export function nextStreak(prev: { count: number; lastAt: Date | null }, serviceDate: Date, gapDays: number): number {
if (!prev.lastAt || prev.count <= 0) return 1;
const gap = (serviceDate.getTime() - prev.lastAt.getTime()) / 86_400_000;
if (gap < 0) return prev.count; // начисление задним числом не ломает серию
return gap <= gapDays ? prev.count + 1 : 1;
}
/** Подсказка суммы: ⌊(сумма − не облагается − оплачено бариками) × ставка⌋ */
export function suggestAccrual(input: { orderAmount: number; excludedAmount?: number; paidWithPoints?: number; ratePct: number }): number {
const base = Math.max(0, input.orderAmount - (input.excludedAmount ?? 0) - (input.paidWithPoints ?? 0));
return Math.floor((base * input.ratePct) / 100);
}
/** Сколько можно списать: не больше N% заказа и баланса; меньше минимума — нельзя */
export function redeemLimit(input: { orderAmount: number; balance: number; sharePct: number; min: number }): { max: number; allowed: boolean } {
const max = Math.max(0, Math.min(Math.floor((input.orderAmount * input.sharePct) / 100), input.balance));
return { max, allowed: max >= input.min && max > 0 };
}
export interface Spendable {
id: string;
remaining: number;
availableAt: Date;
expiresAt: Date | null;
}
/** Порядок трат: сначала то, что сгорит раньше (свой срок), потом самые старые */
export function fifoOrder<T extends Spendable>(list: T[]): T[] {
return [...list].sort((a, b) => {
const ea = a.expiresAt?.getTime() ?? Infinity;
const eb = b.expiresAt?.getTime() ?? Infinity;
return ea - eb || a.availableAt.getTime() - b.availableAt.getTime() || a.id.localeCompare(b.id);
});
}
/** Из каких начислений списать amount */
export function fifoPlan<T extends Spendable>(list: T[], amount: number): { id: string; amount: number }[] | null {
const out: { id: string; amount: number }[] = [];
let left = amount;
for (const a of fifoOrder(list)) {
if (left <= 0) break;
if (a.remaining <= 0) continue;
const take = Math.min(a.remaining, left);
out.push({ id: a.id, amount: take });
left -= take;
}
return left > 0 ? null : out;
}
+31 -1
View File
@@ -11,7 +11,7 @@ export const settingsSchema = z.object({
'agency.attributionMonths': z.number().int().min(1).max(60).default(12),
/** % агентства по умолчанию для новых агентств */
'agency.defaultCommissionPct': z.number().int().min(0).max(100).default(10),
/** SUB-3: длительность подарочного Barev+ */
/** лендинг: «первые N дней в подарок» в текстах; сам подарок задаётся у каждого агентства */
'barevPlus.giftDays': z.number().int().min(1).max(365).default(30),
/** цена подписки в месяц, ֏ (заглушка до решения владельца) */
'barevPlus.monthlyPrice': z.number().int().min(0).default(4990),
@@ -45,6 +45,36 @@ export const settingsSchema = z.object({
'assign.auto': z.boolean().default(true),
/** сколько исполнителей подряд система пробует сама, дальше заказ ждёт диспетчера */
'assign.maxAutoOffers': z.number().int().min(1).max(20).default(5),
/** партнёры услуг (модель «расходы наши»): ФОТ клинера, % от оплаченного заказа; у партнёра можно задать свой */
'partner.fotPct': z.number().int().min(0).max(100).default(50),
/** …налог, % от оплаченного заказа; у партнёра можно задать свой */
'partner.taxPct': z.number().int().min(0).max(100).default(10),
/** минимальный перерыв между заказами сотрудника партнёра на дорогу, мин (менеджер может поставить больше) */
'partner.minTravelGapMinutes': z.number().int().min(0).max(240).default(30),
/** сколько минут партнёр может не брать заказ — дальше заказ возвращается оператору */
'partner.acceptTimeoutMinutes': z.number().int().min(5).max(1440).default(60),
/** барики: через сколько часов после даты услуги начисление становится доступным */
'points.pendingHours': z.number().int().min(0).max(720).default(48),
/** барики: сколько % заказа можно оплатить бариками */
'points.maxSharePct': z.number().int().min(1).max(100).default(30),
/** барики: минимум за одно списание */
'points.minRedeem': z.number().int().min(0).default(1000),
/** барики: сгорают через N дней без начислений и списаний */
'points.burnDays': z.number().int().min(7).max(1000).default(120),
/** барики: предупреждения за N и M дней до сгорания */
'points.warnDays1': z.number().int().min(1).max(60).default(14),
'points.warnDays2': z.number().int().min(1).max(60).default(3),
/** барики: запрос клиента на оплату бариками ждёт подтверждения N часов */
'points.requestTtlHours': z.number().int().min(1).max(240).default(24),
/** барики: серия уборок — максимальный перерыв, дней */
'points.streakGapDays': z.number().int().min(1).max(120).default(35),
/** барики: поддержка тоже может начислять и списывать (в пределах лимитов) */
'points.supportCanManage': z.boolean().default(false),
/** барики: лимит поддержки на одну операцию и на сутки; выше — подтверждение суперадмина */
'points.opMaxPerOperation': z.number().int().min(0).default(20000),
'points.opMaxPerDay': z.number().int().min(0).default(100000),
/** барики: ручная сумма отличается от подсказки больше чем на N% — подсветить в журнале */
'points.diffHighlightPct': z.number().int().min(0).max(100).default(20),
/** бонус продажника: за каждое подключённое заведение, ֏ (0 — не начисляется; задаёт владелец) */
'sales.bonusPerVenue': z.number().int().min(0).default(0),
/** бонус продажника: за каждое подключённое агентство, ֏ */
+8
View File
@@ -44,6 +44,14 @@ export function isProviderFree(p: ProviderAvailability, slot: Interval): boolean
return inSchedule && !p.busy.some((b) => overlaps(b, slot));
}
/** Свободен и между соседними заказами остаётся не меньше gapMinutes на дорогу */
export function isProviderFreeWithGap(p: ProviderAvailability, slot: Interval, gapMinutes: number): boolean {
if (!isProviderFree(p, slot)) return false;
const g = gapMinutes * 60_000;
const padded = { start: new Date(slot.start.getTime() - g), end: new Date(slot.end.getTime() + g) };
return !p.busy.some((b) => overlaps(b, padded));
}
/**
* Слоты на день (решение владельца: окно услуги + хотя бы один свободный исполнитель по графику).
* Неназначенные заказы на пересекающееся время тоже занимают мощность, чтобы не продать одно время дважды.
@@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "PartnerDeal" AS ENUM ('PROFIT_SHARE', 'COMMISSION');
-- DropForeignKey
ALTER TABLE "WebRequest" DROP CONSTRAINT "WebRequest_districtId_fkey";
-- AlterTable
ALTER TABLE "Agency" ADD COLUMN "giftMonths" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "giftPlan" "SubscriptionPlan" DEFAULT 'BAREV_PLUS';
-- AlterTable
ALTER TABLE "Provider" ADD COLUMN "maxHoursPerDay" INTEGER,
ADD COLUMN "maxOrdersPerDay" INTEGER;
-- AlterTable
ALTER TABLE "ProviderCompany" ADD COLUMN "autoAssign" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "commissionPct" INTEGER NOT NULL DEFAULT 30,
ADD COLUMN "deal" "PartnerDeal" NOT NULL DEFAULT 'COMMISSION',
ADD COLUMN "fotPct" INTEGER,
ADD COLUMN "note" TEXT,
ADD COLUMN "profitSharePct" INTEGER NOT NULL DEFAULT 50,
ADD COLUMN "taxPct" INTEGER,
ADD COLUMN "travelGapMinutes" INTEGER NOT NULL DEFAULT 60;
-- AlterTable
ALTER TABLE "WebRequest" ALTER COLUMN "preferredAt" DROP NOT NULL,
ALTER COLUMN "districtId" DROP NOT NULL,
ALTER COLUMN "addressText" DROP NOT NULL;
-- CreateTable
CREATE TABLE "PartnerEarning" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"deal" "PartnerDeal" NOT NULL,
"paid" INTEGER NOT NULL,
"fot" INTEGER NOT NULL DEFAULT 0,
"tax" INTEGER NOT NULL DEFAULT 0,
"pct" INTEGER NOT NULL,
"amount" INTEGER NOT NULL,
"period" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartnerEarning_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PartnerEarning_orderId_key" ON "PartnerEarning"("orderId");
-- CreateIndex
CREATE INDEX "PartnerEarning_companyId_period_idx" ON "PartnerEarning"("companyId", "period");
-- AddForeignKey
ALTER TABLE "PartnerEarning" ADD CONSTRAINT "PartnerEarning_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerEarning" ADD CONSTRAINT "PartnerEarning_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Действующие компании: их ставка «% компании от заказа» превращается в нашу комиссию
UPDATE "ProviderCompany" SET "commissionPct" = GREATEST(0, LEAST(100, 100 - "rateValue")) WHERE "rateType" = 'PERCENT';
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "ProWebSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"codeHash" TEXT,
"codeExpiresAt" TIMESTAMP(3),
"tokenHash" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProWebSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProWebSession_codeHash_key" ON "ProWebSession"("codeHash");
-- CreateIndex
CREATE UNIQUE INDEX "ProWebSession_tokenHash_key" ON "ProWebSession"("tokenHash");
-- CreateIndex
CREATE INDEX "ProWebSession_userId_idx" ON "ProWebSession"("userId");
-- AddForeignKey
ALTER TABLE "ProWebSession" ADD CONSTRAINT "ProWebSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,267 @@
-- CreateEnum
CREATE TYPE "PointsKind" AS ENUM ('ORDER', 'BONUS');
-- CreateEnum
CREATE TYPE "PointsStatus" AS ENUM ('PENDING', 'ACTIVE', 'SPENT', 'EXPIRED', 'REVERSED');
-- CreateEnum
CREATE TYPE "PointsChannel" AS ENUM ('WEB', 'BOT', 'CSV', 'AUTO', 'CLIENT');
-- CreateEnum
CREATE TYPE "RedemptionStatus" AS ENUM ('REQUESTED', 'CONFIRMED', 'REJECTED', 'REVERSED');
-- CreateEnum
CREATE TYPE "SuggestionStatus" AS ENUM ('OPEN', 'ACCEPTED', 'REJECTED');
-- AlterTable
ALTER TABLE "Agency" ADD COLUMN "housewarmingDays" INTEGER NOT NULL DEFAULT 60,
ADD COLUMN "housewarmingPoints" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Client" ADD COLUMN "pointsActive" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "pointsActivityAt" TIMESTAMP(3),
ADD COLUMN "pointsDebt" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "pointsPending" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "pointsWarn14At" TIMESTAMP(3),
ADD COLUMN "pointsWarn3At" TIMESTAMP(3),
ADD COLUMN "streakCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "streakLastAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "pointsUsed" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "pointsCategoryId" TEXT;
-- CreateTable
CREATE TABLE "PointsCategory" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" JSONB NOT NULL,
"rateBarev" INTEGER NOT NULL,
"ratePlus" INTEGER NOT NULL,
"rateElite" INTEGER NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PointsCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PointsBonusType" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" JSONB NOT NULL,
"defaultAmount" INTEGER NOT NULL,
"ttlDays" INTEGER,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PointsBonusType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PointsAccrual" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"kind" "PointsKind" NOT NULL,
"categoryId" TEXT,
"bonusTypeId" TEXT,
"orderId" TEXT,
"orderRef" TEXT,
"orderAmount" INTEGER,
"excludedAmount" INTEGER NOT NULL DEFAULT 0,
"paidWithPoints" INTEGER NOT NULL DEFAULT 0,
"tier" TEXT,
"ratePct" INTEGER,
"streakBonusPct" INTEGER NOT NULL DEFAULT 0,
"suggestedAmount" INTEGER,
"amount" INTEGER NOT NULL,
"remaining" INTEGER NOT NULL,
"expiredAmount" INTEGER NOT NULL DEFAULT 0,
"status" "PointsStatus" NOT NULL,
"serviceDate" TIMESTAMP(3) NOT NULL,
"availableAt" TIMESTAMP(3) NOT NULL,
"expiresAt" TIMESTAMP(3),
"reason" TEXT,
"commentForClient" TEXT,
"createdById" TEXT,
"channel" "PointsChannel" NOT NULL,
"idempotencyKey" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PointsAccrual_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PointsRedemption" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"orderId" TEXT,
"orderRef" TEXT,
"orderAmount" INTEGER,
"amount" INTEGER NOT NULL,
"status" "RedemptionStatus" NOT NULL,
"channel" "PointsChannel" NOT NULL,
"note" TEXT,
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
"confirmedById" TEXT,
"confirmedAt" TIMESTAMP(3),
"reason" TEXT,
"idempotencyKey" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PointsRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PointsRedemptionItem" (
"id" TEXT NOT NULL,
"redemptionId" TEXT NOT NULL,
"accrualId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
CONSTRAINT "PointsRedemptionItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PointsReversal" (
"id" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"debt" INTEGER NOT NULL DEFAULT 0,
"createdById" TEXT,
"channel" "PointsChannel" NOT NULL,
"reason" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PointsReversal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PointsSuggestion" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"mechanic" TEXT NOT NULL,
"dedupeKey" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"suggestedAmount" INTEGER NOT NULL,
"status" "SuggestionStatus" NOT NULL DEFAULT 'OPEN',
"resolvedById" TEXT,
"resolvedAt" TIMESTAMP(3),
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PointsSuggestion_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PointsCategory_code_key" ON "PointsCategory"("code");
-- CreateIndex
CREATE UNIQUE INDEX "PointsBonusType_code_key" ON "PointsBonusType"("code");
-- CreateIndex
CREATE UNIQUE INDEX "PointsAccrual_idempotencyKey_key" ON "PointsAccrual"("idempotencyKey");
-- CreateIndex
CREATE INDEX "PointsAccrual_clientId_status_idx" ON "PointsAccrual"("clientId", "status");
-- CreateIndex
CREATE INDEX "PointsAccrual_status_availableAt_idx" ON "PointsAccrual"("status", "availableAt");
-- CreateIndex
CREATE INDEX "PointsAccrual_orderId_idx" ON "PointsAccrual"("orderId");
-- CreateIndex
CREATE INDEX "PointsAccrual_createdAt_idx" ON "PointsAccrual"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "PointsRedemption_idempotencyKey_key" ON "PointsRedemption"("idempotencyKey");
-- CreateIndex
CREATE INDEX "PointsRedemption_clientId_status_idx" ON "PointsRedemption"("clientId", "status");
-- CreateIndex
CREATE INDEX "PointsRedemption_status_requestedAt_idx" ON "PointsRedemption"("status", "requestedAt");
-- CreateIndex
CREATE INDEX "PointsRedemptionItem_accrualId_idx" ON "PointsRedemptionItem"("accrualId");
-- CreateIndex
CREATE INDEX "PointsReversal_targetType_targetId_idx" ON "PointsReversal"("targetType", "targetId");
-- CreateIndex
CREATE INDEX "PointsReversal_clientId_idx" ON "PointsReversal"("clientId");
-- CreateIndex
CREATE INDEX "PointsReversal_createdAt_idx" ON "PointsReversal"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "PointsSuggestion_dedupeKey_key" ON "PointsSuggestion"("dedupeKey");
-- CreateIndex
CREATE INDEX "PointsSuggestion_status_createdAt_idx" ON "PointsSuggestion"("status", "createdAt");
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_pointsCategoryId_fkey" FOREIGN KEY ("pointsCategoryId") REFERENCES "PointsCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PointsAccrual" ADD CONSTRAINT "PointsAccrual_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PointsAccrual" ADD CONSTRAINT "PointsAccrual_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "PointsCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PointsAccrual" ADD CONSTRAINT "PointsAccrual_bonusTypeId_fkey" FOREIGN KEY ("bonusTypeId") REFERENCES "PointsBonusType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PointsAccrual" ADD CONSTRAINT "PointsAccrual_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PointsRedemption" ADD CONSTRAINT "PointsRedemption_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PointsRedemption" ADD CONSTRAINT "PointsRedemption_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PointsRedemptionItem" ADD CONSTRAINT "PointsRedemptionItem_redemptionId_fkey" FOREIGN KEY ("redemptionId") REFERENCES "PointsRedemption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PointsRedemptionItem" ADD CONSTRAINT "PointsRedemptionItem_accrualId_fkey" FOREIGN KEY ("accrualId") REFERENCES "PointsAccrual"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PointsSuggestion" ADD CONSTRAINT "PointsSuggestion_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- номер заказа уникален среди неотменённых начислений; автоначисление — один раз на заказ
CREATE UNIQUE INDEX "PointsAccrual_orderRef_live_key" ON "PointsAccrual"("orderRef") WHERE "orderRef" IS NOT NULL AND "status" <> 'REVERSED';
CREATE UNIQUE INDEX "PointsAccrual_orderId_live_key" ON "PointsAccrual"("orderId") WHERE "orderId" IS NOT NULL AND "status" <> 'REVERSED';
-- категории кэшбека (ставки по ТЗ; Elite = Barev+ +2)
INSERT INTO "PointsCategory" ("id", "code", "name", "rateBarev", "ratePlus", "rateElite", "sortOrder") VALUES
('pc_cleaning', 'cleaning', '{"ru":"Клининг","en":"Cleaning","zh":"保洁","hy":"Մաքրում","es":"Limpieza"}', 5, 8, 10, 1),
('pc_documents', 'documents', '{"ru":"Документы","en":"Documents","zh":"证件","hy":"Փաստաթղթեր","es":"Documentos"}', 5, 10, 12, 2),
('pc_learning', 'learning', '{"ru":"Обучение и карьера","en":"Learning and career","zh":"学习与职业","hy":"Ուսում և կարիերա","es":"Formación y carrera"}', 3, 4, 6, 3),
('pc_repair', 'repair', '{"ru":"Ремонт","en":"Repairs","zh":"维修","hy":"Վերանորոգում","es":"Reparaciones"}', 2, 4, 6, 4),
('pc_transfer', 'transfer', '{"ru":"Трансфер","en":"Transfer","zh":"接送","hy":"Տրանսֆեր","es":"Traslado"}', 2, 4, 6, 5);
UPDATE "Service" SET "pointsCategoryId" = 'pc_cleaning' WHERE "code" = 'cleaning';
UPDATE "Service" SET "pointsCategoryId" = 'pc_repair' WHERE "code" = 'repair';
UPDATE "Service" SET "pointsCategoryId" = 'pc_transfer' WHERE "code" = 'transfer';
UPDATE "Service" s SET "pointsCategoryId" = 'pc_documents' FROM "Category" c WHERE c."id" = s."categoryId" AND c."code" = 'DOCS';
UPDATE "Service" s SET "pointsCategoryId" = 'pc_learning' FROM "Category" c WHERE c."id" = s."categoryId" AND c."code" = 'LEARNING';
-- типы бонусов
INSERT INTO "PointsBonusType" ("id", "code", "name", "defaultAmount", "ttlDays", "sortOrder") VALUES
('pb_housewarming', 'housewarming', '{"ru":"Новоселье","en":"Housewarming","zh":"乔迁之喜","hy":"Բնակարանամուտ","es":"Mudanza"}', 5000, 60, 1),
('pb_route', 'first_month_route', '{"ru":"Маршрут первого месяца","en":"First-month route","zh":"首月路线","hy":"Առաջին ամսվա երթուղի","es":"Ruta del primer mes"}', 3000, NULL, 2),
('pb_ref_inviter', 'referral_inviter', '{"ru":"Пригласил друга","en":"Invited a friend","zh":"邀请好友","hy":"Հրավիրել է ընկերոջ","es":"Invitó a un amigo"}', 3000, NULL, 3),
('pb_ref_invitee', 'referral_invitee', '{"ru":"По приглашению друга","en":"Invited by a friend","zh":"好友邀请","hy":"Ընկերոջ հրավերով","es":"Invitado por un amigo"}', 3000, NULL, 4),
('pb_photo', 'photo_review', '{"ru":"Отзыв с фото","en":"Review with a photo","zh":"带图评价","hy":"Կարծիք լուսանկարով","es":"Reseña con foto"}', 500, NULL, 5),
('pb_lease', 'lease_renewal', '{"ru":"Продление аренды","en":"Lease renewal","zh":"续租","hy":"Վարձակալության երկարաձգում","es":"Renovación del alquiler"}', 2000, 60, 6),
('pb_complaint', 'complaint', '{"ru":"Компенсация","en":"Compensation","zh":"补偿","hy":"Փոխհատուցում","es":"Compensación"}', 1000, NULL, 7),
('pb_promo', 'promo', '{"ru":"Промо","en":"Promo","zh":"促销","hy":"Պրոմո","es":"Promoción"}', 1000, 30, 8);
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "OrderChecklistItem" ADD COLUMN "checkedByAdminId" TEXT,
ADD COLUMN "photoWaivedReason" TEXT;
+309 -4
View File
@@ -97,6 +97,14 @@ enum PaymentStatus {
REFUNDED
}
/// Модель сотрудничества с партнёром услуг
enum PartnerDeal {
/// расходы наши: платим ФОТ и налог, партнёру — % от прибыли
PROFIT_SHARE
/// как Uber: забираем свой % с заказа, остальное партнёру, расходы — его
COMMISSION
}
enum RateType {
FIXED
PERCENT
@@ -190,6 +198,7 @@ model User {
dispatcher CompanyDispatcher[]
partnerMembers PartnerMember[]
messages Message[]
proWebSessions ProWebSession[]
}
model Client {
@@ -231,6 +240,25 @@ model Client {
/// null — по умолчанию: подписчик → консьерж, остальные → поддержка
chatChannel ChatChannel?
// ── барики (кэшбек) — кэш баланса; источник правды — журнал PointsAccrual / PointsRedemption ──
/// доступно: остатки активных начислений минус долг
pointsActive Int @default(0)
/// в ожидании (станут доступны позже)
pointsPending Int @default(0)
/// долг после отмены уже потраченного начисления; гасится следующими начислениями
pointsDebt Int @default(0)
/// последнее начисление или списание — от неё считается сгорание
pointsActivityAt DateTime?
/// предупреждения о сгорании уже отправлены (сбрасываются при активности)
pointsWarn14At DateTime?
pointsWarn3At DateTime?
/// серия уборок: сколько подряд с перерывами не больше N дней
streakCount Int @default(0)
streakLastAt DateTime?
pointsAccruals PointsAccrual[]
pointsRedemptions PointsRedemption[]
pointsSuggestions PointsSuggestion[]
@@index([agencyId])
}
@@ -245,11 +273,28 @@ model ProviderCompany {
rateType RateType @default(PERCENT)
rateValue Int @default(70)
contactPhone String?
/// ссылка менеджера: t.me/<рабочий бот>?start=<inviteCode>
inviteCode String @unique
active Boolean @default(true)
/// модель сотрудничества (rateType/rateValue — устаревшая ставка, не используется)
deal PartnerDeal @default(COMMISSION)
/// COMMISSION: наш % с оплаченной суммы заказа
commissionPct Int @default(30)
/// PROFIT_SHARE: % партнёра от (оплачено − ФОТ − налог)
profitSharePct Int @default(50)
/// PROFIT_SHARE: свой ФОТ клинера, % от заказа; пусто — из настроек
fotPct Int?
/// PROFIT_SHARE: свой налог, %; пусто — из настроек
taxPct Int?
/// менеджер включил автораспределение по сотрудникам
autoAssign Boolean @default(false)
/// перерыв между заказами сотрудника на дорогу, мин (не меньше минимума из настроек)
travelGapMinutes Int @default(60)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
earnings PartnerEarning[]
employees Provider[]
dispatchers CompanyDispatcher[]
orders Order[]
@@ -295,6 +340,9 @@ model Provider {
blockedAt DateTime?
/// заметка команды об исполнителе (он не видит)
adminNote String?
/// нагрузка (задаёт менеджер партнёра): максимум заказов и часов в день; пусто — без ограничения
maxOrdersPerDay Int?
maxHoursPerDay Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -391,6 +439,9 @@ model Service {
code String @unique
categoryId String
category Category @relation(fields: [categoryId], references: [id])
/// категория кэшбека «Барики» (ставки Barev / Barev+ / Elite)
pointsCategoryId String?
pointsCategory PointsCategory? @relation(fields: [pointsCategoryId], references: [id])
flow ServiceFlow
providerRole ProviderRole
/// правила цены (см. packages/core/src/pricing)
@@ -462,6 +513,8 @@ model Order {
discountPct Int @default(0)
discountAmount Int @default(0)
totalPrice Int
/// оплачено бариками (к оплате деньгами: totalPrice − pointsUsed)
pointsUsed Int @default(0)
status OrderStatus @default(NEW)
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
@@ -493,6 +546,9 @@ model Order {
lessons LessonPackage?
payments Payment[]
commission AgencyCommission?
partnerEarning PartnerEarning?
pointsAccruals PointsAccrual[]
pointsRedemptions PointsRedemption[]
tasks OperatorTask[]
webRequest WebRequest?
@@ -548,6 +604,10 @@ model OrderChecklistItem {
checkedAt DateTime?
fileId String? @unique
file OrderFile? @relation(fields: [fileId], references: [id])
/// пункт отметила команда за исполнителя (id сотрудника админки)
checkedByAdminId String?
/// отмечено без фото — причина (команда)
photoWaivedReason String?
@@unique([orderId, code])
}
@@ -662,6 +722,12 @@ model Agency {
name String
/// вознаграждение, % от оплаченных заказов клиентов агентства
commissionPct Int @default(10)
/// подарок новым клиентам: уровень (пусто — без подарка) и срок в месяцах
giftPlan SubscriptionPlan? @default(BAREV_PLUS)
giftMonths Int @default(1)
/// барики на новоселье: сумма (0 — нет) и срок жизни в днях
housewarmingPoints Int @default(0)
housewarmingDays Int @default(60)
contact String?
active Boolean @default(true)
/// продажник, который подключил агентство (бонус в CRM)
@@ -694,6 +760,30 @@ model AgencyCommission {
@@index([agencyId, period])
}
/// Доход партнёра услуг с выполненного заказа — фиксируется при завершении, чтобы смена условий не меняла прошлое
model PartnerEarning {
id String @id @default(cuid())
companyId String
company ProviderCompany @relation(fields: [companyId], references: [id])
orderId String @unique
order Order @relation(fields: [orderId], references: [id])
deal PartnerDeal
/// фактически оплачено клиентом
paid Int
/// PROFIT_SHARE: ФОТ и налог, которые несём мы
fot Int @default(0)
tax Int @default(0)
/// % партнёра (PROFIT_SHARE) или наша комиссия (COMMISSION)
pct Int
/// доход партнёра
amount Int
/// YYYY-MM
period String
createdAt DateTime @default(now())
@@index([companyId, period])
}
model PromoCode {
id String @id @default(cuid())
code String @unique
@@ -998,11 +1088,11 @@ model WebRequest {
service Service @relation(fields: [serviceId], references: [id])
params Json
/// желаемое время, оператор подтверждает
preferredAt DateTime
preferredAt DateTime?
durationMinutes Int
districtId String
district District @relation(fields: [districtId], references: [id])
addressText String
districtId String?
district District? @relation(fields: [districtId], references: [id])
addressText String?
note String?
phone String
name String?
@@ -1435,3 +1525,218 @@ model BroadcastRecipient {
@@unique([broadcastId, clientId])
@@index([broadcastId, status])
}
/// Вход в кабинет менеджера партнёра с компьютера: бот выдаёт одноразовую ссылку (code), браузер меняет её на токен
model ProWebSession {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// sha256 одноразового кода из ссылки; после входа обнуляется
codeHash String? @unique
codeExpiresAt DateTime?
/// sha256 токена браузера
tokenHash String? @unique
expiresAt DateTime
lastUsedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
// ───────────── Барики: кэшбек клуба (1 барик = 1 ֏) ─────────────
enum PointsKind {
ORDER
BONUS
}
enum PointsStatus {
/// создано, тратить нельзя до availableAt
PENDING
ACTIVE
/// израсходовано полностью
SPENT
/// сгорело
EXPIRED
/// отменено
REVERSED
}
enum PointsChannel {
WEB
BOT
CSV
AUTO
CLIENT
}
enum RedemptionStatus {
REQUESTED
CONFIRMED
REJECTED
REVERSED
}
enum SuggestionStatus {
OPEN
ACCEPTED
REJECTED
}
/// Категория кэшбека со ставками по уровням, %
model PointsCategory {
id String @id @default(cuid())
code String @unique
/// { ru, en, zh, hy, es }
name Json
rateBarev Int
ratePlus Int
rateElite Int
active Boolean @default(true)
sortOrder Int @default(0)
services Service[]
accruals PointsAccrual[]
}
/// Тип бонуса: новоселье, отзыв, компенсация…
model PointsBonusType {
id String @id @default(cuid())
code String @unique
name Json
defaultAmount Int
/// срок жизни бонуса в днях; пусто — общее правило сгорания
ttlDays Int?
active Boolean @default(true)
sortOrder Int @default(0)
accruals PointsAccrual[]
}
/// Начисление бариков (журнал: не удаляется и не правится, кроме остатка и статуса)
model PointsAccrual {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
kind PointsKind
categoryId String?
category PointsCategory? @relation(fields: [categoryId], references: [id])
bonusTypeId String?
bonusType PointsBonusType? @relation(fields: [bonusTypeId], references: [id])
/// заказ в системе (автоначисление)
orderId String?
order Order? @relation(fields: [orderId], references: [id])
/// номер заказа для сверки; среди неотменённых начислений уникален (индекс в миграции)
orderRef String?
orderAmount Int?
excludedAmount Int @default(0)
paidWithPoints Int @default(0)
/// BAREV | BAREV_PLUS | ELITE — на дату услуги
tier String?
ratePct Int?
/// надбавка за серию уборок, п.п.
streakBonusPct Int @default(0)
suggestedAmount Int?
amount Int
remaining Int
/// сколько сгорело
expiredAmount Int @default(0)
status PointsStatus
serviceDate DateTime
availableAt DateTime
/// свой срок жизни (бонусы); пусто — общее правило сгорания
expiresAt DateTime?
reason String?
commentForClient String?
createdById String?
channel PointsChannel
idempotencyKey String? @unique
createdAt DateTime @default(now())
items PointsRedemptionItem[]
@@index([clientId, status])
@@index([status, availableAt])
@@index([orderId])
@@index([createdAt])
}
/// Списание (оплата части заказа бариками)
model PointsRedemption {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
orderId String?
order Order? @relation(fields: [orderId], references: [id])
orderRef String?
orderAmount Int?
amount Int
status RedemptionStatus
channel PointsChannel
/// комментарий клиента к запросу
note String?
requestedAt DateTime @default(now())
expiresAt DateTime?
confirmedById String?
confirmedAt DateTime?
reason String?
idempotencyKey String? @unique
createdAt DateTime @default(now())
items PointsRedemptionItem[]
@@index([clientId, status])
@@index([status, requestedAt])
}
/// Из каких начислений списано (FIFO)
model PointsRedemptionItem {
id String @id @default(cuid())
redemptionId String
redemption PointsRedemption @relation(fields: [redemptionId], references: [id])
accrualId String
accrual PointsAccrual @relation(fields: [accrualId], references: [id])
amount Int
@@index([accrualId])
}
/// Отмена операции — обратная запись со ссылкой на исходную
model PointsReversal {
id String @id @default(cuid())
/// ACCRUAL | REDEMPTION
targetType String
targetId String
clientId String
amount Int
/// сколько ушло в долг (отмена уже потраченного начисления)
debt Int @default(0)
createdById String?
channel PointsChannel
reason String
createdAt DateTime @default(now())
@@index([targetType, targetId])
@@index([clientId])
@@index([createdAt])
}
/// Предложено системой: начислить бонус или подтвердить операцию оператора сверх лимита
model PointsSuggestion {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
/// route | photo_review | over_limit | …
mechanic String
/// чтобы одна и та же причина не создавала карточку дважды
dedupeKey String @unique
payload Json
suggestedAmount Int
status SuggestionStatus @default(OPEN)
resolvedById String?
resolvedAt DateTime?
reason String?
createdAt DateTime @default(now())
@@index([status, createdAt])
}
+471 -43
View File
@@ -412,7 +412,7 @@
"CANCEL_VIA_OPERATOR": "The service has already started — only an operator can cancel it now.",
"RESCHEDULE_VIA_OPERATOR": "Only an operator can reschedule this order.",
"AGENCY_CODE_INVALID": "That code doesn't exist. Check the code in your lease.",
"AGENCY_ALREADY_SET": "You're already linked to another agency.",
"AGENCY_ALREADY_SET": "You're already linked to another real estate agency.",
"OFFER_NOT_ACTIVE": "This offer is no longer valid.",
"PROVIDER_BUSY": "The specialist is busy at that time.",
"ALREADY_ASSIGNED": "The order is already assigned.",
@@ -448,7 +448,17 @@
"PROMO_INVALID": "This promo code doesn't exist",
"PROMO_EXPIRED": "This promo code is no longer valid",
"PROMO_USED": "This promo code has already been used",
"PROMO_NOT_FOR_SERVICE": "This promo code doesn't apply to this service"
"PROMO_NOT_FOR_SERVICE": "This promo code doesn't apply to this service",
"POINTS_DUPLICATE_ORDER": "Bariki for this order have already been credited.",
"POINTS_REASON_REQUIRED": "Please give a reason.",
"POINTS_BELOW_MIN": "The amount is below the minimum.",
"POINTS_NOT_ENOUGH": "Not enough bariki.",
"POINTS_OVER_SHARE": "Bariki can cover only part of the order.",
"POINTS_OVER_LIMIT": "Above your limit — a super admin must approve.",
"POINTS_FINAL": "This operation can no longer be reversed.",
"POINTS_REQUEST_CLOSED": "The request is already handled or expired.",
"POINTS_TOO_MANY_REQUESTS": "You already have pending requests — please wait for a reply.",
"POINTS_WITH_PROMO": "Bariki can’t be combined with a promo code."
},
"client": {
"nav": {
@@ -473,7 +483,7 @@
"text": "To place orders we need your consent to process personal data: your name, address and order details. They go to the specialist doing your order and the partners listed in the policy, and are stored on a server in Germany.",
"policyLink": "Privacy policy",
"accept": "I agree, continue",
"giftToast": "Gift activated: one month of Barev+"
"giftToast": "Your real estate agency gift is active"
},
"home": {
"hello": "Barev!",
@@ -482,7 +492,7 @@
"allOrders": "All",
"noOrders": "No orders yet. Book your first service — it takes 2 minutes.",
"repeatLast": "Repeat",
"enterCode": "Agency code",
"enterCode": "Real estate agency code",
"plusUntil": "Barev+ active until {{date}}",
"discount": "−{{pct}}% with Barev+",
"upsellTitle": "Up to −{{pct}}% with Barev Elite",
@@ -582,20 +592,20 @@
},
"profile": {
"title": "Profile",
"fromAgency": "Client of {{agency}}",
"noAgency": "No agency",
"fromAgency": "Client of real estate agency {{agency}}",
"noAgency": "No real estate agency",
"membership": "Barev / Barev+ membership",
"codeTitle": "Agency code",
"codeText": "Rented through a partner agency? Enter the code from your lease and get a month of Barev+.",
"codeTitle": "Real estate agency code",
"codeText": "Rented through a partner real estate agency? Enter the code from your lease and get a gift from the agency.",
"codePlaceholder": "e.g. DOMIK",
"codeAccepted": "Code accepted: {{agency}}",
"codeGift": "Code accepted: {{agency}}. A month of Barev+ is yours!",
"codeGift": "Code accepted: {{agency}}. Your gift is active!",
"language": "Language"
},
"membership": {
"title": "Membership",
"current": "Your plan",
"howToGet": "You can get Barev+ as a gift with an agency code. Online subscription is coming very soon.",
"howToGet": "You can get Barev+ as a gift with a real estate agency code. Online subscription is coming very soon.",
"request": "Get {{plan}}",
"requested": "Request sent — an operator will be in touch",
"requestHint": "An operator will message you on Telegram, arrange payment and switch the subscription on.",
@@ -712,6 +722,60 @@
"title": "Club card",
"partners": "Club partners",
"plans": "Levels and discounts"
},
"points": {
"title": "Bariki",
"balance": "Available",
"equals": "bariki ≈ {{amount}} ֏ · 1 bariki = 1 ֏",
"pending": "pending: {{amount}}, available from {{date}}",
"burn": "{{amount}} expire on {{date}} — any order keeps them",
"pay": "Pay with bariki",
"enterCode": "Enter a code",
"requestPending": "Your bariki payment request is waiting for approval.",
"rates": "Cashback by category",
"plusWouldGet": "With Barev+ you’d have +{{amount}} more bariki over the last 30 days →",
"streak": "Cleaning streak",
"streakLeft": "{{left}} more and cleaning cashback goes +{{bonus}}%. Keep gaps under {{days}} days.",
"streakMax": "Top streak: +{{bonus}}% cashback on every cleaning.",
"route": "First-month route",
"routeText": "Book services from 3 different categories by {{date}} and get {{amount}} bariki. Now: {{done}} of 3.",
"history": "History",
"empty": "You earn bariki on every paid order — cashback at your level’s rate.",
"availableFrom": "available from {{date}}",
"until": "until {{date}}",
"payHint": "Bariki can cover up to {{pct}}% of an order, minimum {{min}}. The team will confirm and send the amount left to pay.",
"forOrder": "Order",
"otherOrder": "Another order (not in the app)",
"amount": "How many bariki to use",
"max": "Maximum: {{max}}",
"note": "Comment",
"notePh": "Which order and for how much",
"send": "Send request",
"requested": "Request sent — we’ll reply in the chat.",
"useInOrder": "Use {{amount}} bariki",
"useHint": "up to {{pct}}% of the order · balance {{balance}}",
"notWithPromo": "Not together with a promo code",
"belowMin": "Minimum {{min}} · balance {{balance}}",
"paidLine": "Paid with bariki",
"type": {
"accrual": "Cashback",
"bonus": "Bonus",
"redemption": "Paid with bariki",
"request": "Payment request",
"reversal": "Reversal"
},
"status": {
"PENDING": "pending",
"ACTIVE": "available",
"SPENT": "spent",
"EXPIRED": "expired",
"REVERSED": "reversed",
"REQUESTED": "awaiting approval",
"CONFIRMED": "redeemed",
"REJECTED": "declined",
"ACCRUAL": "credit reversed",
"REDEMPTION": "bariki returned"
}
}
},
"pro": {
@@ -793,7 +857,8 @@
"empty": "No orders in this period.",
"ratePercent": "Rate: {{value}}% of the order",
"rateFixed": "Rate: {{value}} per order",
"ratePreset": "Rate: “{{name}}” preset"
"ratePreset": "Rate: “{{name}}” preset",
"ratePartner": "Your company sets your pay for orders"
},
"company": {
"title": "Company",
@@ -849,26 +914,130 @@
"recent": "Recent checks",
"noRecent": "No checks yet.",
"checked": "card check"
},
"web": {
"loginHint": "Open the work bot and tap “Open on a computer” (or send /web) — you’ll get a login link."
},
"cab": {
"tab": {
"orders": "Orders",
"staff": "Staff",
"money": "Income",
"settings": "Settings"
},
"autoOn": "Auto-assignment is on: new orders go to free staff automatically.",
"income": "Your income",
"deadline": "assign by {{time}}",
"offeredTo": "waiting for {{name}}",
"decline": "Decline",
"declineConfirm": "Decline this order? It will go back to Barev Club.",
"declined": "Order returned",
"reassign": "Reassign",
"unassignConfirm": "Remove {{name}} from the order? It will wait for assignment again.",
"unassigned": "Staff member removed",
"gapHint": "Travel gap between orders: {{min}} min.",
"noStaff": "No staff yet — add them in the “Staff” tab.",
"dayLoad": "that day {{n}}/{{max}}",
"weekLoad": "this week {{n}}",
"district": "district",
"check": {
"ok": "Free",
"busy": "Busy",
"gap": "No time to travel",
"limit": "Daily limit",
"notLinked": "Not in the bot",
"gap_confirm": "There’s less travel time between orders than set. Assign anyway?",
"limit_confirm": "This person has reached their daily order or hour limit. Assign anyway?",
"notLinked_confirm": "This person hasn’t joined the work bot yet and won’t be notified. Assign?",
"busy_confirm": "This person is busy at that time."
},
"addStaff": "Add staff member",
"todayLoad": "today {{n}}/{{max}}",
"hoursMax": "up to {{n}} h a day",
"off": "off",
"staffAdded": "Staff member added",
"saved": "Saved",
"inviteText": "Send this link to the staff member: they’ll open the work bot and can take orders.",
"done": "Done",
"f": {
"name": "Name",
"phone": "Phone",
"languages": "Languages",
"districts": "Districts (empty — any)",
"days": "Working days",
"from": "From",
"to": "To",
"maxOrders": "Max orders a day",
"maxHours": "Max hours a day",
"loadHint": "Empty means no limit. Auto-assignment won’t give the person more."
},
"save": "Save",
"resetInvite": "New login link",
"resetConfirm": "Make a new link? The current bot login will be disconnected.",
"turnOff": "Turn off",
"turnOn": "Turn on",
"offConfirm": "Turn off {{name}}? They won’t get new orders.",
"copy": "Copy",
"copied": "Copied",
"balance": "To be paid",
"accrued": "Accrued",
"paidOut": "Paid out",
"terms": {
"COMMISSION": "Terms: Barev Club takes {{pct}}% of each paid order, the rest is yours. Wages and costs are yours.",
"PROFIT_SHARE": "Terms: Barev Club pays the cleaner ({{fot}}%) and tax ({{tax}}%); you get {{pct}}% of what’s left."
},
"byMonth": "By month",
"ordersN": "orders: {{n}}",
"byOrder": "By order",
"noIncome": "Income appears once a client pays for a completed order.",
"paidBy": "paid {{sum}}",
"minus": "−payroll {{fot}}, −tax {{tax}}, your {{pct}}%",
"minusCommission": "−{{pct}}% Barev Club",
"payouts": "Payouts",
"payout": {
"paid": "paid",
"pending": "pending"
},
"auto": "Auto-assignment",
"autoHint": "A new order is offered to the best free staff member: free by schedule, has time to travel, within limits; preferred — speaks the client’s language, works in the district, fewer orders this week, higher rating. If they decline or don’t answer, it goes to the next.",
"gap": "Travel gap between orders, min",
"gapMin": "At least {{min}} min",
"timeoutHint": "If an order isn’t assigned within {{min}} min, it goes back to Barev Club.",
"managers": "Managers",
"you": "you",
"remove": "Remove",
"removeManagerConfirm": "Remove {{name}} from managers?",
"managerInvite": "Link for another manager — they open the work bot and get access to the cabinet:",
"logout": "Log out on this computer",
"wd": {
"0": "Mon",
"1": "Tue",
"2": "Wed",
"3": "Thu",
"4": "Fri",
"5": "Sat",
"6": "Sun"
}
}
},
"bot": {
"client": {
"chooseLanguage": "Выбери язык · Choose your language · 选择语言 · Ընտրեք լեզուն · Elige tu idioma",
"languageSet": "Language: {{language}}",
"fromAgency": "Hi! You came from {{agency}} — we have a gift for you.",
"fromAgency": "Hi! You came from real estate agency {{agency}} — we have a gift for you.",
"consentText": "To use Barev Club we need your consent to process personal data. It goes to the specialist doing your order and the partners listed in the policy, and is stored on a server in Germany.\n\n<a href=\"{{url}}\">Privacy policy</a>",
"consentAccept": "I agree",
"giftActivated": "🎁 A gift from your agency: Barev+ is active until {{date}}. Service discounts already apply.",
"giftActivated": "🎁 A gift from your real estate agency: {{plan}} is active until {{date}}. Service discounts already apply.",
"welcome": "Yerevan feels like home. From day one.\n\nBook cleaning and other services, track the status and enjoy partner discounts — all in the app.",
"openApp": "Open app",
"enterCode": "Enter agency code",
"enterCode": "Enter real estate agency code",
"changeLanguage": "Change language",
"codePrompt": "Send the agency code from your lease.",
"codePrompt": "Send the real estate agency code from your lease.",
"codeAccepted": "Code accepted: {{agency}}.",
"help": "Tap “Open app” to book a service. Commands: /app — the app, /code — agency code, /language — language.",
"help": "Tap “Open app” to book a service. Commands: /app — the app, /code — real estate agency code, /language — language.",
"cmdStart": "Start",
"cmdApp": "Open app",
"cmdCode": "Enter agency code",
"cmdCode": "Enter real estate agency code",
"cmdLanguage": "Change language",
"cmdHelp": "Help",
"webRequestOrdered": "Request No. {{request}} received and booked as order No. {{order}} for {{date}}. Track the status in the app.",
@@ -880,7 +1049,44 @@
"cmdSupport": "Write to support",
"cmdConcierge": "Write to the concierge (Barev+)",
"rated": "Thanks for your rating! {{stars}}",
"ratedLow": "Sorry it didn’t go well. Tell us here what happened — the team will sort it out."
"ratedLow": "Sorry it didn’t go well. Tell us here what happened — the team will sort it out.",
"wr": {
"intro": "We have your request #{{request}}. Just a couple of questions to book it.",
"askDistrict": "Which district is the apartment in?",
"askAddress": "Send the address: street, building, apartment.",
"askDay": "When suits you? Pick a day.",
"askTime": "Pick a time on {{day}}:",
"dayFull": "That day is fully booked. Please pick another day.",
"noSlots": "No free time in the coming days — an operator will contact you to find a slot."
},
"cmdBalance": "Bariki balance",
"cmdHistory": "Bariki history",
"points": {
"balance": "🪙 Bariki: {{balance}} (≈ {{balance}} ֏)",
"pending": "⏳ Pending: {{pending}}, available from {{date}}",
"burn": "🔥 Expire on {{date}} if there are no orders",
"empty": "No bariki activity yet.",
"open": "Open bariki",
"type": {
"accrual": "Cashback",
"bonus": "Bonus",
"redemption": "Paid with bariki",
"request": "Payment request",
"reversal": "Reversal"
},
"status": {
"PENDING": "pending",
"ACTIVE": "available",
"SPENT": "spent",
"EXPIRED": "expired",
"REVERSED": "reversed",
"REQUESTED": "awaiting approval",
"CONFIRMED": "redeemed",
"REJECTED": "declined",
"ACCRUAL": "credit reversed",
"REDEMPTION": "bariki returned"
}
}
},
"pro": {
"notLinked": "This bot is for Barev Club specialists. Ask the operator for an invite link.",
@@ -890,7 +1096,9 @@
"youAreDispatcher": "Dispatcher for “{{company}}”",
"welcome": "New orders arrive here. Accept them with the buttons in the message and manage your day in the cabinet.",
"openCabinet": "Open cabinet",
"messageReceived": "Passed to the operator — the reply will come here."
"messageReceived": "Passed to the operator — the reply will come here.",
"openWeb": "💻 Open on a computer",
"webLink": "Link to the cabinet for your browser (single use, valid for 15 minutes):\n{{url}}"
},
"admin": {
"hello": "{{name}}, Barev Club admin notifications arrive here.",
@@ -916,7 +1124,30 @@
"chatAssigned": "{{by}} passed you a conversation: {{name}}",
"order": "Order #{{number}}",
"noOrder": "No current order",
"autoFailed": "No free provider found for order #{{number}} — please assign manually."
"autoFailed": "No free provider found for order #{{number}} — please assign manually.",
"partnerDeclined": "Partner “{{partner}}” declined order #{{number}} — reassign it",
"partnerExpired": "Partner “{{partner}}” didn’t assign staff to order #{{number}} in time — reassign it",
"noTime": "no time given",
"noAddress": "no address given",
"points": {
"forOrder": "Order #{{number}} for {{sum}}",
"confirm": "✅ Confirm",
"reject": "Decline",
"request": "Client asks to pay with bariki: {{amount}}",
"balance": "balance {{balance}}",
"suggestions": "New bariki suggestions: {{count}} ({{open}} in the queue)",
"overLimit": "{{by}} wants to process {{amount}} bariki — above the limit, needs your approval",
"mismatch": "Bariki reconciliation: {{count}} clients had a mismatched balance — fixed, check the log",
"confirmed": "Redeemed {{amount}} bariki",
"rejected": "Request declined",
"help": "Credit for an order: /barik <phone or @username> <order amount> [category]\nCategories: cleaning, documents, learning, repair, transfer",
"noClient": "Client not found",
"preview": "Credit {{amount}} bariki to {{name}} for {{what}} {{sum}} ֏ ({{tier}}, {{rate}}%). Available from {{date}}.",
"accrue": "🪙 Credit",
"done": "Credited {{amount}} bariki",
"needsApproval": "Above the limit — sent to a super admin for approval",
"zero": "The amount is 0 — nothing to credit"
}
},
"partner": {
"valid": "{{name}} — {{level}}, card is valid",
@@ -1011,34 +1242,58 @@
"rescheduled": "Order moved to {{when}}.",
"unassigned": "The order for {{when}} was taken off you.",
"reminder24": "Order tomorrow: {{when}}.",
"reminder1": "Order in one hour: {{when}}."
"reminder1": "Order in one hour: {{when}}.",
"orderEdited": "The order on {{when}} was updated — please open it."
},
"dispatcher": {
"newOrder": "New order for the company for {{when}}. Assign a team member in the cabinet.",
"returned": "A team member declined the order for {{when}}. Assign someone else."
"returned": "A team member declined the order for {{when}}. Assign someone else.",
"noStaff": "No free staff for the order on {{when}} — assign manually in the cabinet.",
"expired": "The order on {{when}} went back to Barev Club: no one was assigned in time."
},
"openPlan": "Open the app",
"concierge": "Barev Club concierge",
"subscriptionActivated": "{{plan}} is on until {{date}}. Discounts are already in service prices.",
"operator": "Barev Club operator",
"support": "Barev Club support",
"rateAsk": "How did it go? Please rate — it takes a second:"
"rateAsk": "How did it go? Please rate — it takes a second:",
"points": {
"open": "My bariki",
"pending": "🪙 +{{amount}} bariki for {{what}} on {{date}}. Available from {{available}}.",
"active": "🪙 +{{amount}} bariki for {{what}}. Balance: {{balance}}.",
"bonus": "🎁 +{{amount}} bariki as a gift: {{what}}. Valid until {{until}}.",
"bonusNoTtl": "🎁 +{{amount}} bariki as a gift: {{what}}.",
"housewarming": "a housewarming gift from {{agency}}",
"activated": "✅ {{amount}} bariki are now available. Balance: {{balance}}.",
"redeemed": "Paid with bariki: {{amount}}. Left to pay: {{due}}. Balance: {{balance}}.",
"redeemedNoOrder": "Bariki redeemed: {{amount}}. Balance: {{balance}}.",
"requestExpired": "Your bariki payment request expired. Send a new one if needed.",
"rejected": "Your bariki payment request was declined: {{reason}}",
"declinedGeneric": "message us in the chat and we’ll sort it out.",
"orderCancelled": "the order was cancelled",
"returned": "↩️ {{amount}} bariki are back on your balance. Balance: {{balance}}.",
"reversed": "The credit {{what}} was reversed: {{reason}}. Balance: {{balance}}.",
"expiring": "⏰ {{amount}} bariki expire in {{days}} days. Any order keeps them.",
"expired": "{{amount}} bariki expired after a long time without orders.",
"orderEdited": "the order changed — we’ll credit the new amount"
},
"orderEdited": "Your order was updated. Total to pay: {{total}}."
},
"admin": {
"nav": {
"orders": "Orders",
"clients": "Clients",
"providers": "Specialists",
"companies": "Companies",
"companies": "Service partners",
"services": "Services & prices",
"agencies": "Agencies",
"agencies": "Real estate agencies",
"settings": "Settings",
"presets": "Rates",
"webRequests": "Website requests",
"leads": "Relocation",
"concierge": "Concierge",
"requests": "App requests",
"partners": "Partners",
"partners": "Club partners",
"telegram": "Notifications",
"templates": "Moving templates"
},
@@ -1177,7 +1432,10 @@
"finishLesson": "Lesson finished",
"openMeeting": "Meeting opened",
"startMeeting": "Meeting started",
"finishMeeting": "Meeting finished"
"finishMeeting": "Meeting finished",
"editedByStaff": "Composition changed by the team",
"checklistByStaff": "Checklist item checked by the team",
"checklistUncheckedByStaff": "Checklist item unchecked by the team"
},
"weekdays": {
"0": "Mon",
@@ -1256,7 +1514,7 @@
"eliteDiscount": "Elite discount"
},
"agencies": {
"add": "Add agency",
"add": "Add real estate agency",
"name": "Name",
"code": "Code",
"commission": "Reward, %",
@@ -1289,7 +1547,7 @@
"cancel_freeHours": "Free cancellation up to, h before",
"agency_attributionMonths": "Client attached to agency, months",
"agency_defaultCommissionPct": "Default agency reward, %",
"barevPlus_giftDays": "Gift Barev+, days",
"barevPlus_giftDays": "Landing: “first N days free” (the gift itself is set per agency)",
"barevPlus_monthlyPrice": "Barev+ monthly price, ֏",
"offer_acceptTimeoutMinutes": "Specialist reply time, min",
"chat_closeAfterHours": "Chat closes after the order, h",
@@ -1321,7 +1579,23 @@
"sales_bonusPerVenue": "Sales bonus per venue, ֏",
"sales_bonusPerAgency": "Sales bonus per agency, ֏",
"sales_bonusAgencyPct": "Sales bonus: % of revenue from their agencies’ clients",
"sales_bonusAgencyMonths": "…for how many months after the agency joins"
"sales_bonusAgencyMonths": "…for how many months after the agency joins",
"partner_fotPct": "Partners (our costs): cleaner payroll, % of order",
"partner_taxPct": "Partners (our costs): tax, %",
"partner_minTravelGapMinutes": "Partners: minimum gap between a staff member’s orders, min",
"partner_acceptTimeoutMinutes": "Partners: minutes to assign staff before the order comes back",
"points_pendingHours": "Bariki: available N hours after the service",
"points_maxSharePct": "Bariki: up to N% of an order",
"points_minRedeem": "Bariki: minimum per redemption",
"points_burnDays": "Bariki: expire after N days of inactivity",
"points_warnDays1": "Bariki: first warning N days before",
"points_warnDays2": "Bariki: second warning N days before",
"points_requestTtlHours": "Bariki: client request waits N hours",
"points_streakGapDays": "Bariki: cleaning streak — gap of up to N days",
"points_diffHighlightPct": "Bariki: highlight manual amounts off by more than N%",
"points_supportCanManage": "Support can also credit and redeem bariki (with limits)",
"points_opMaxPerOperation": "Support limit per operation, bariki",
"points_opMaxPerDay": "Support daily limit, bariki"
},
"presets": {
"add": "Add preset",
@@ -1549,7 +1823,7 @@
"step4_sub": "Chat with your specialist inside"
},
"agency": {
"title": "Rented through a partner agency?",
"title": "Rented through a partner real estate agency?",
"text": "Your code for a month of Barev+ is already in your lease.",
"gift": "A gift!",
"giftSub": "1 month of Barev+"
@@ -1571,7 +1845,7 @@
"a": "Discounts of up to {{pct}}% on documents, home services, learning and career coaching."
},
"gift": {
"q": "How do I get the agency gift?",
"q": "How do I get the real estate agency gift?",
"a": "The code is in your lease. Enter it in the bot and get {{days}} days of Barev+."
}
},
@@ -1580,7 +1854,11 @@
"partner": "Become a partner",
"privacy": "Privacy policy",
"legal": "Sole proprietor Yuri Zabudko · TIN (ՀՎՀՀ) {{tin}}",
"services": "Services"
"services": "Services",
"pricing": "Pricing",
"terms": "Terms of service",
"refund": "Refund policy",
"rules": "Service rules"
},
"clean": {
"meta": {
@@ -1646,7 +1924,11 @@
"how2": "We confirm in Telegram",
"how2Sub": "The bot shows your order and its status",
"how3": "A cleaner arrives",
"how3Sub": "Works by a checklist and sends a photo report"
"how3Sub": "Works by a checklist and sends a photo report",
"whenHintOptional": "Optional: if you skip it, the bot will ask in Telegram.",
"addressHintOptional": "Optional: you can send the address to the bot later.",
"nameTitle": "Your name",
"consentByClick": "By tapping the button, you agree to the processing of your personal data."
},
"v1": {
"title": "Everything for life in Yerevan — in one chat",
@@ -1687,7 +1969,7 @@
"airportTitle": "We meet you at the airport and check you into a hotel",
"airportText": "A driver with a sign at Zvartnots and a transfer to the hotel — carefree first days.",
"flatTitle": "We find you an apartment",
"flatText": "We find a place through partner agencies and clean it before you move in.",
"flatText": "We find a place through partner real estate agencies and clean it before you move in.",
"docsTitle": "We sort out registration and your social card",
"docsText": "We prepare the documents, book the appointment and walk you through to the finished card.",
"permitTitle": "We help with residence permit documents",
@@ -1700,9 +1982,9 @@
"chatCta": "Write to the chat"
},
"v3": {
"tag": "For tenants of partner agencies",
"tag": "For tenants of partner real estate agencies",
"title": "Welcome home",
"sub": "The agency you rented through gives you a month of Barev+ — discounts on cleaning, documents and lessons.",
"sub": "The real estate agency you rented through gives you a Barev+ subscription — discounts on cleaning, documents and lessons.",
"giftTitle": "1 month of Barev+",
"giftCode": "Code {{code}} applies automatically",
"giftNoCode": "The code is in your lease",
@@ -1756,14 +2038,14 @@
"perYear": "Per year — {{amount}}",
"other": "The subscription also covers other categories",
"cta": "Try Barev+",
"gift": "Rented through a partner agency? Your first month is a gift."
"gift": "Rented through a partner real estate agency? Your subscription is a gift."
},
"v6": {
"tag": "For real estate agencies",
"title": "An agreement on how your tenants settle into Yerevan",
"sub": "You give clients a month of Barev+, we help them with everyday life, and you earn a share of their orders.",
"docDate": "Yerevan",
"s1": "What the agency gets",
"s1": "What the real estate agency gets",
"s1i1": "{{pct}}% of every client order for {{months}} months after their first visit to the bot.",
"s1i2": "Your own code and QR — add them to the lease or hand them over with the keys.",
"s1i3": "A report on clients and orders: how many came, how many ordered and how much you earned.",
@@ -1779,7 +2061,7 @@
"exampleText": "{{clients}} clients × {{orders}} cleanings a month × {{price}}",
"exampleResult": "{{amount}} to the agency per month",
"exampleNote": "Based on a regular cleaning of a two-room apartment.",
"signAgency": "Agency",
"signAgency": "Real estate agency",
"signUs": "Barev Club",
"cta": "Become a partner"
},
@@ -2315,7 +2597,7 @@
},
"privacy": {
"title": "Privacy policy",
"updated": "Version of 20 October 2026",
"updated": "Version of 22 October 2026",
"intro": "This policy explains what personal data Barev Club collects, why, who we share it with and how you can control it. It applies to barevclub.com, the Barev Club Telegram bots and mini apps.",
"operator": {
"title": "Who processes your data",
@@ -2323,7 +2605,7 @@
},
"data": {
"title": "What data we receive",
"body": "• From Telegram: your ID, username, first and last name from your profile, language. Your phone number only if you choose to share it.\n• Website requests: name, phone or Telegram username, your answers in the relocation planner (dates, services needed, budget). For cleaning: address, district, preferred time and comment.\n• Orders: address and district, map location (if you set one), service options, date and time, price, discount, payment status, comments.\n• Documents: scans of your passport and other documents you upload for Documents services.\n• Messages with the specialist via the bot, photo reports, ratings and reviews.\n• Membership: tier (Barev, Barev+, Elite), subscription dates, club card code and records of its use at partners.\n• Source: the agency or promo code you came with and campaign tags (UTM).\n• Technical data: a cookie with the site language, the agency code in browser storage and server logs (IP address, time and requested address) to protect against failures and attacks.\nWe do not receive bank card details. When online payment launches, card details will be entered on the payment provider’s side."
"body": "• From Telegram: your ID, username, first and last name from your profile, language. Your phone number only if you choose to share it.\n• Website requests: name, phone or Telegram username, your answers in the relocation planner (dates, services needed, budget). For cleaning: address, district, preferred time and comment.\n• Orders: address and district, map location (if you set one), service options, date and time, price, discount, payment status, comments.\n• Documents: scans of your passport and other documents you upload for Documents services.\n• Messages with the specialist via the bot, photo reports, ratings and reviews.\n• Membership: tier (Barev, Barev+, Elite), subscription dates, club card code and records of its use at partners.\n• Source: the agency or promo code you came with and campaign tags (UTM).\n• Technical data: a cookie with the site language, the agency code in browser storage and server logs (IP address, time and requested address) to protect against failures and attacks.\nWe do not receive bank card details: for online payments they are entered on the page of Paddle, our online reseller."
},
"purposes": {
"title": "Why we use it",
@@ -2335,7 +2617,7 @@
},
"recipients": {
"title": "Who we share data with",
"body": "We do not sell personal data and share only what a specific task needs:\n• The specialist doing your order and, if a partner company does it, the company’s dispatcher: name, language, address and order details. Your phone number is not shown to the specialist; you talk via the bot.\n• The real estate agency that referred you: your name, whether you use the club and the amounts of paid orders, to calculate the agency’s reward.\n• A club partner when you show your club card: your name and membership tier.\n• Telegram, which runs the bots and mini apps and processes data under its own policy.\n• Hostinger International, whose servers store the database and files.\n• A payment provider, once online payment launches.\n• Government bodies, when you order a documents service and we file on your behalf, or when the law requires it."
"body": "We do not sell personal data and share only what a specific task needs:\n• The specialist doing your order and, if a partner company does it, the company’s dispatcher: name, language, address and order details. Your phone number is not shown to the specialist; you talk via the bot.\n• The real estate agency that referred you: your name, whether you use the club and the amounts of paid orders, to calculate the agency’s reward.\n• A club partner when you show your club card: your name and membership tier.\n• Telegram, which runs the bots and mini apps and processes data under its own policy.\n• Hostinger International, whose servers store the database and files.\n• Paddle.com, our online reseller that processes online payments: name, email, country and payment details. Paddle processes this data as an independent controller under its own privacy policy.\n• Government bodies, when you order a documents service and we file on your behalf, or when the law requires it."
},
"location": {
"title": "Where data is stored",
@@ -2369,5 +2651,151 @@
"title": "Changes to this policy",
"body": "We may update this policy. The current version is always on this page, with its date at the top. We will tell you about significant changes in the bot.\nThe Russian text is the main version; translations are provided for convenience."
}
},
"pricing": {
"title": "Pricing",
"updated": "Effective from 22 October 2026",
"intro": "What the Barev Club membership and club services cost. You can order services without a membership — it only adds discounts and a concierge. All prices are in Armenian dram (֏).",
"from": "from {{price}}",
"perDay": "{{price}} per night",
"plansTitle": "Club membership",
"plansNote": "The membership is billed monthly and renews until you cancel it. You can cancel at any time — it stays active until the end of the paid month.",
"planNote": {
"free": "Sign up in the Telegram bot. No payment, no time limit.",
"plus": "Subscribe in the bot. If you came through a partner real estate agency, the subscription is a gift (usually {{days}} days or more — each agency sets its own terms).",
"elite": "Arranged through a club operator in the bot chat."
},
"servicesTitle": "Services",
"servicesNote": "These are base prices. Before you confirm an order, the bot shows its exact price, including your options and membership discount. Once confirmed, the price does not change.",
"rows": {
"cleaning": {
"name": "Apartment cleaning",
"note": "Depends on the type of cleaning and the number of rooms"
},
"transferStandard": {
"name": "Zvartnots airport transfer, Standard",
"note": "Up to 3 passengers, name sign, 60 minutes of waiting"
},
"transferPremium": {
"name": "Zvartnots airport transfer, Premium",
"note": "Business class, water, SIM card"
},
"hotel": {
"name": "Hotel for the first days",
"note": "Price with Barev+; without a membership — from {{base}} per night"
},
"socialCard": {
"name": "Social card, end to end",
"note": "Passport translation, notary, appointment, escort"
},
"consultation": {
"name": "Residence permit consultation",
"note": "Credited towards the support fee"
},
"residence": {
"name": "Residence permit support",
"note": "Grounds, documents, application, visit. The state fee of {{fee}} for a 1-year permit is paid to the state separately"
}
},
"payTitle": "Payment and cancellation",
"payBody": "The Barev+ membership can be paid online by card — payments are processed by our online reseller Paddle.com, and membership payments are refundable on request within 14 days. Services are paid in cash or by transfer; you choose the method in the order. You can cancel or reschedule an order for free up to 24 hours before the service starts. See the terms of service and the refund policy for details."
},
"terms": {
"title": "Terms of service",
"updated": "Version of 22 October 2026",
"intro": "These are the terms on which Barev Club provides services through barevclub.com, its Telegram bots and mini apps. By placing an order or subscribing, you accept these terms.",
"about": {
"title": "Who we are and what we do",
"body": "Barev Club is a service for people moving to Armenia or already living here: household services (cleaning, airport transfer, a hotel for the first days), help with documents (social card, residence permit), learning, and discounts from club partners.\nServices are provided by sole proprietor Yuri Konstantinovich Zabudko (Ա/Ձ Զաբուդկո Յուրի Կոնստանտինի), Republic of Armenia, TIN (ՀՎՀՀ) {{tin}}. Contacts: {{email}}, {{phone}}.\nOrders are carried out by vetted providers — our staff or partner companies. Barev Club is responsible to you for the result."
},
"acceptance": {
"title": "Accepting the terms",
"body": "These terms apply from the moment you place an order, subscribe or send a request on the website or in the bot. You must be 18 or older to use the service.\nIf you do not agree with the terms, please do not place an order."
},
"orders": {
"title": "How orders work",
"body": "• You choose a service in the bot or send a request on the website and specify the details: address, date, time and so on.\n• Before you confirm, the bot shows the total price, discount and cancellation terms.\n• An order is accepted once you have confirmed it and we have assigned a provider. You get a notification about this and every later status.\n• You talk to the provider through the chat in the bot.\nIf we cannot fulfil the order at the chosen time, we will offer another time or cancel the order with no charge."
},
"prices": {
"title": "Prices",
"body": "Prices are listed in Armenian dram on the Pricing page and in the bot. We may change prices, but the price of an order you have already confirmed does not change.\nState fees and duties, such as the residence permit fee, are not included in the service price unless stated explicitly. We tell you the amount in advance."
},
"payment": {
"title": "Payment",
"body": "The Barev+ membership can be paid online by card. Our order process is conducted by our online reseller Paddle.com. Paddle.com is the Merchant of Record for all our online orders. Paddle provides all customer service inquiries about payments and handles returns. Card details are entered on Paddle’s page; we do not receive or store them.\nServices (cleaning, transfers, documents and others) are not sold online: they are paid in cash or by transfer to the provider, or by bank transfer to Barev Club — you choose the method in the order.\nWe issue a payment receipt on request."
},
"cancel": {
"title": "Cancellation and rescheduling",
"body": "You can cancel or reschedule an order for free up to 24 hours before the service starts — in the bot or in the chat with an operator.\nFor later cancellations, part of the price is withheld. The amount is shown on the service card before you confirm the order; by default it is 30%.\nIf we cancel the order or the provider does not show up, you get a full refund. See the refund policy for details."
},
"subscription": {
"title": "Barev+ and Barev Elite membership",
"body": "• The membership gives discounts on club services, gifts from partners and a concierge in chat. Tiers and prices are on the Pricing page.\n• The membership is billed monthly and renews automatically until you cancel it. We remind you 3 days before renewal.\n• The discount applies to orders placed while the membership is active.\n• The free period with a partner agency code is given once per client.\n• You can cancel at any time in the bot or in the chat with an operator. The membership stays active until the end of the paid period."
},
"perks": {
"title": "Partner discounts and gifts",
"body": "Discounts and gifts with the club card are provided by club partners — cafés, restaurants and other businesses. The terms of each offer are shown in the partner catalogue.\nThe partner is responsible for its own goods and services. Partner offers may change or end."
},
"docs": {
"title": "Document services",
"body": "We help you gather documents, translate them, book appointments and accompany you to state offices. The decision to issue a document or residence permit is made by the state authority, and we cannot guarantee it.\nYou are responsible for the documents and information you give us being genuine and accurate. If a refusal is caused by our mistake, we will fix it at our own cost."
},
"client": {
"title": "Your obligations",
"body": "• Provide correct details: address, contacts, order options.\n• Give access to the apartment at the agreed time and safe conditions for the provider's work.\n• Treat providers and operators with respect.\n• Do not use the service for unlawful purposes.\nIf the provider cannot start work because of you (for example, nobody is at the address), this counts as a late cancellation."
},
"quality": {
"title": "Quality and complaints",
"body": "If you are not happy with the result, write in the order chat within 24 hours of completion and attach photos if you can. We will fix the issues for free or refund part or all of the price.\nIf a provider damages your property, tell us within 24 hours — we will look into it and compensate confirmed damage."
},
"liability": {
"title": "Liability",
"body": "Our liability for an order is limited to its price, except for confirmed damage to property and cases where the law does not allow such a limit.\nWe are not liable for delays and cancellations caused by circumstances beyond our control: decisions of state authorities, flight delays, natural disasters, Telegram outages."
},
"content": {
"title": "Website content",
"body": "The texts, design, logo and the Barev Club name belong to the owner of the service. Do not copy them without permission."
},
"changes": {
"title": "Changes to the terms",
"body": "We may update these terms. The current version is always on this page, with its date at the top. Orders already confirmed are carried out on the previous terms. We will notify you in the bot in advance about significant changes to the membership."
},
"law": {
"title": "Governing law and disputes",
"body": "These terms are governed by the laws of the Republic of Armenia. We first try to settle disputes by negotiation — write to {{email}} and we will reply within 3 business days. If we cannot agree, the dispute is heard by a court of the Republic of Armenia.\nThese terms do not limit your statutory consumer rights.\nThe original text is in Russian; translations are provided for convenience."
}
},
"refund": {
"title": "Refund policy",
"updated": "Version of 22 October 2026",
"intro": "When and how much Barev Club refunds for services and membership, and how to request a refund.",
"cancelClient": {
"title": "If you cancel",
"body": "• 24 hours or more before the service starts — cancelling and rescheduling are free, and you get a full refund.\n• Less than 24 hours before — part of the price is withheld and the rest is refunded. The amount is shown on the service card before you confirm the order; by default it is 30%.\n• Once the service has started, the part already done is not refunded."
},
"cancelUs": {
"title": "If we cancel",
"body": "If we cancel the order, cannot find a provider, or the provider does not show up, you choose: reschedule to a convenient time or get a full refund."
},
"quality": {
"title": "If you are not happy with the result",
"body": "Write in the order chat within 24 hours of completion and attach photos if you can. We will fix the issues for free, and if that is not possible, refund part or all of the price depending on what went wrong."
},
"subscription": {
"title": "Barev+ and Barev Elite membership",
"body": "• Any membership payment is refunded in full if you ask within 14 days of the charge.\n• You can cancel at any time — the next payment will not be charged, and the membership stays active until the end of the paid month.\n• The free period costs nothing, so there is nothing to refund."
},
"nonRefundable": {
"title": "What is not refundable",
"body": "• State fees and duties we have already paid on your behalf.\n• Costs we have already paid to third parties for your order: for example, a notarised translation or a hotel booking under the hotel's rules.\n• Goods and services of club partners — the partner's own rules apply."
},
"howTo": {
"title": "How to request a refund",
"body": "Write in the bot chat or to {{email}} with your order or membership number and the reason. We will reply within 3 business days.\nIf you paid for the membership online, you can also request a refund directly from Paddle at paddle.net, where you can find your payment and receipt."
},
"timing": {
"title": "Timing and method",
"body": "Refunds are made by the same method you used to pay, within 10 business days after the refund is approved. A card refund may take a few more days depending on your bank. If you paid in cash, we refund by transfer or in cash, whichever you prefer.\nRefund questions: {{email}}, {{phone}}."
}
}
}
+471 -43
View File
@@ -412,7 +412,7 @@
"CANCEL_VIA_OPERATOR": "El servicio ya empezó: solo un operador puede cancelarlo.",
"RESCHEDULE_VIA_OPERATOR": "Solo un operador puede cambiar la fecha.",
"AGENCY_CODE_INVALID": "Ese código no existe. Revisa el código de tu contrato.",
"AGENCY_ALREADY_SET": "Ya estás vinculado a otra agencia.",
"AGENCY_ALREADY_SET": "Ya estás vinculado a otra agencia inmobiliaria.",
"OFFER_NOT_ACTIVE": "Esta oferta ya no es válida.",
"PROVIDER_BUSY": "El especialista está ocupado a esa hora.",
"ALREADY_ASSIGNED": "El pedido ya está asignado.",
@@ -448,7 +448,17 @@
"PROMO_INVALID": "Este código no existe",
"PROMO_EXPIRED": "El código ya no es válido",
"PROMO_USED": "Este código ya se ha usado",
"PROMO_NOT_FOR_SERVICE": "El código no es válido para este servicio"
"PROMO_NOT_FOR_SERVICE": "El código no es válido para este servicio",
"POINTS_DUPLICATE_ORDER": "Ya se acreditaron bariki por este pedido.",
"POINTS_REASON_REQUIRED": "Indica el motivo.",
"POINTS_BELOW_MIN": "La cantidad es inferior al mínimo.",
"POINTS_NOT_ENOUGH": "No tienes suficientes bariki.",
"POINTS_OVER_SHARE": "Con bariki solo se puede pagar una parte del pedido.",
"POINTS_OVER_LIMIT": "Supera tu límite: debe aprobarlo un superadministrador.",
"POINTS_FINAL": "Esta operación ya no se puede anular.",
"POINTS_REQUEST_CLOSED": "La solicitud ya se gestionó o caducó.",
"POINTS_TOO_MANY_REQUESTS": "Ya tienes solicitudes pendientes: espera la respuesta.",
"POINTS_WITH_PROMO": "Los bariki no se combinan con un código promocional."
},
"client": {
"nav": {
@@ -473,7 +483,7 @@
"text": "Para hacer pedidos necesitamos tu consentimiento para tratar datos personales: nombre, dirección y detalles del pedido. Los reciben el especialista de tu pedido y los socios indicados en la política, y se guardan en un servidor en Alemania.",
"policyLink": "Política de privacidad",
"accept": "Acepto, continuar",
"giftToast": "Regalo activado: un mes de Barev+"
"giftToast": "El regalo de tu agencia inmobiliaria está activado"
},
"home": {
"hello": "¡Barev!",
@@ -482,7 +492,7 @@
"allOrders": "Todos",
"noOrders": "Aún no tienes pedidos. Reserva tu primer servicio: son 2 minutos.",
"repeatLast": "Repetir",
"enterCode": "Código de agencia",
"enterCode": "Código de la agencia inmobiliaria",
"plusUntil": "Barev+ activo hasta el {{date}}",
"discount": "−{{pct}}% con Barev+",
"upsellTitle": "Hasta −{{pct}}% con Barev Elite",
@@ -582,20 +592,20 @@
},
"profile": {
"title": "Perfil",
"fromAgency": "Cliente de {{agency}}",
"noAgency": "Sin agencia",
"fromAgency": "Cliente de la agencia inmobiliaria {{agency}}",
"noAgency": "Sin agencia inmobiliaria",
"membership": "Membresía Barev / Barev+",
"codeTitle": "Código de agencia",
"codeText": "¿Alquilaste con una agencia asociada? Introduce el código del contrato y te regalamos un mes de Barev+.",
"codeTitle": "Código de la agencia inmobiliaria",
"codeText": "¿Alquilaste con una agencia inmobiliaria asociada? Introduce el código del contrato y recibe el regalo de la agencia.",
"codePlaceholder": "p. ej., DOMIK",
"codeAccepted": "Código aceptado: {{agency}}",
"codeGift": "Código aceptado: {{agency}}. ¡Un mes de Barev+ para ti!",
"codeGift": "Código aceptado: {{agency}}. ¡Tu regalo está activado!",
"language": "Idioma"
},
"membership": {
"title": "Membresía",
"current": "Tu plan",
"howToGet": "Puedes recibir Barev+ de regalo con un código de agencia. La suscripción en línea llegará muy pronto.",
"howToGet": "Puedes recibir Barev+ de regalo con el código de una agencia inmobiliaria. La suscripción en línea llegará muy pronto.",
"request": "Contratar {{plan}}",
"requested": "Solicitud enviada: un operador te contactará",
"requestHint": "Un operador te escribirá por Telegram, acordará el pago y activará la suscripción.",
@@ -712,6 +722,60 @@
"title": "Tarjeta del club",
"partners": "Socios del club",
"plans": "Niveles y descuentos"
},
"points": {
"title": "Bariki",
"balance": "Disponible",
"equals": "bariki ≈ {{amount}} ֏ · 1 bariki = 1 ֏",
"pending": "pendientes: {{amount}}, disponibles desde el {{date}}",
"burn": "{{amount}} caducan el {{date}}: cualquier pedido los conserva",
"pay": "Pagar con bariki",
"enterCode": "Introducir código",
"requestPending": "Tu solicitud de pago con bariki espera confirmación.",
"rates": "Cashback por categoría",
"plusWouldGet": "Con Barev+ tendrías +{{amount}} bariki más en los últimos 30 días →",
"streak": "Racha de limpiezas",
"streakLeft": "{{left}} más y el cashback de limpieza sube +{{bonus}}%. Pausas de hasta {{days}} días.",
"streakMax": "Racha máxima: +{{bonus}}% de cashback en cada limpieza.",
"route": "Ruta del primer mes",
"routeText": "Reserva servicios de 3 categorías distintas antes del {{date}} y recibe {{amount}} bariki. Ahora: {{done}} de 3.",
"history": "Historial",
"empty": "Ganas bariki con cada pedido pagado: cashback según tu nivel.",
"availableFrom": "disponibles desde el {{date}}",
"until": "hasta el {{date}}",
"payHint": "Con bariki pagas hasta el {{pct}}% del pedido, mínimo {{min}}. El equipo confirmará y te enviará el importe restante.",
"forOrder": "Pedido",
"otherOrder": "Otro pedido (fuera de la app)",
"amount": "Cuántos bariki usar",
"max": "Máximo: {{max}}",
"note": "Comentario",
"notePh": "Qué pedido y por qué importe",
"send": "Enviar solicitud",
"requested": "Solicitud enviada: te responderemos en el chat.",
"useInOrder": "Usar {{amount}} bariki",
"useHint": "hasta el {{pct}}% del pedido · saldo {{balance}}",
"notWithPromo": "No junto con un código promocional",
"belowMin": "Mínimo {{min}} · saldo {{balance}}",
"paidLine": "Pagado con bariki",
"type": {
"accrual": "Cashback",
"bonus": "Bono",
"redemption": "Pago con bariki",
"request": "Solicitud de pago",
"reversal": "Anulación"
},
"status": {
"PENDING": "pendiente",
"ACTIVE": "disponible",
"SPENT": "gastado",
"EXPIRED": "caducado",
"REVERSED": "anulado",
"REQUESTED": "pendiente de confirmación",
"CONFIRMED": "canjeado",
"REJECTED": "rechazado",
"ACCRUAL": "abono anulado",
"REDEMPTION": "bariki devueltos"
}
}
},
"pro": {
@@ -793,7 +857,8 @@
"empty": "No hay pedidos en este periodo.",
"ratePercent": "Tarifa: {{value}}% del pedido",
"rateFixed": "Tarifa: {{value}} por pedido",
"ratePreset": "Tarifa: plantilla «{{name}}»"
"ratePreset": "Tarifa: plantilla «{{name}}»",
"ratePartner": "Tu empresa fija tu pago por pedido"
},
"company": {
"title": "Empresa",
@@ -849,26 +914,130 @@
"recent": "Últimas comprobaciones",
"noRecent": "Aún no hay comprobaciones.",
"checked": "comprobación"
},
"web": {
"loginHint": "Abre el bot de trabajo y pulsa «Abrir en el ordenador» (o envía /web): recibirás un enlace de acceso."
},
"cab": {
"tab": {
"orders": "Pedidos",
"staff": "Personal",
"money": "Ingresos",
"settings": "Ajustes"
},
"autoOn": "La asignación automática está activada: los pedidos nuevos van solos al personal libre.",
"income": "Tu ingreso",
"deadline": "asigna antes de las {{time}}",
"offeredTo": "esperando a {{name}}",
"decline": "Rechazar",
"declineConfirm": "¿Rechazar el pedido? Volverá a Barev Club.",
"declined": "Pedido devuelto",
"reassign": "Reasignar",
"unassignConfirm": "¿Quitar a {{name}} del pedido? Volverá a esperar asignación.",
"unassigned": "Empleado retirado del pedido",
"gapHint": "Tiempo de traslado entre pedidos: {{min}} min.",
"noStaff": "Aún no hay personal: añádelo en la pestaña «Personal».",
"dayLoad": "ese día {{n}}/{{max}}",
"weekLoad": "esta semana {{n}}",
"district": "distrito",
"check": {
"ok": "Libre",
"busy": "Ocupado",
"gap": "No llega a tiempo",
"limit": "Límite diario",
"notLinked": "No está en el bot",
"gap_confirm": "Hay menos tiempo de traslado del establecido. ¿Asignar igualmente?",
"limit_confirm": "Ya alcanzó su límite de pedidos u horas de ese día. ¿Asignar igualmente?",
"notLinked_confirm": "Aún no entró en el bot de trabajo y no recibirá aviso. ¿Asignar?",
"busy_confirm": "Está ocupado a esa hora."
},
"addStaff": "Añadir empleado",
"todayLoad": "hoy {{n}}/{{max}}",
"hoursMax": "hasta {{n}} h al día",
"off": "desactivado",
"staffAdded": "Empleado añadido",
"saved": "Guardado",
"inviteText": "Envía este enlace al empleado: abrirá el bot de trabajo y podrá aceptar pedidos.",
"done": "Listo",
"f": {
"name": "Nombre",
"phone": "Teléfono",
"languages": "Idiomas",
"districts": "Distritos (vacío: cualquiera)",
"days": "Días laborables",
"from": "Desde",
"to": "Hasta",
"maxOrders": "Máx. pedidos al día",
"maxHours": "Máx. horas al día",
"loadHint": "Vacío: sin límite. La asignación automática no le dará más."
},
"save": "Guardar",
"resetInvite": "Nuevo enlace de acceso",
"resetConfirm": "¿Crear un enlace nuevo? Se desconectará el acceso actual al bot.",
"turnOff": "Desactivar",
"turnOn": "Activar",
"offConfirm": "¿Desactivar a {{name}}? No recibirá pedidos nuevos.",
"copy": "Copiar",
"copied": "Copiado",
"balance": "Pendiente de pago",
"accrued": "Acumulado",
"paidOut": "Pagado",
"terms": {
"COMMISSION": "Condiciones: Barev Club se queda con el {{pct}}% de cada pedido pagado y el resto es tuyo. Salarios y gastos corren de tu cuenta.",
"PROFIT_SHARE": "Condiciones: Barev Club paga al limpiador ({{fot}}%) y el impuesto ({{tax}}%); tú recibes el {{pct}}% del resto."
},
"byMonth": "Por meses",
"ordersN": "pedidos: {{n}}",
"byOrder": "Por pedido",
"noIncome": "Los ingresos aparecen cuando el cliente paga un pedido realizado.",
"paidBy": "pagado {{sum}}",
"minus": "−nómina {{fot}}, −impuesto {{tax}}, tu {{pct}}%",
"minusCommission": "−{{pct}}% Barev Club",
"payouts": "Pagos",
"payout": {
"paid": "pagado",
"pending": "pendiente"
},
"auto": "Asignación automática",
"autoHint": "Un pedido nuevo se ofrece al mejor empleado libre: libre según su horario, con tiempo de traslado y dentro de sus límites; tienen prioridad quienes hablan el idioma del cliente, trabajan en el distrito, tienen menos pedidos esta semana y mejor valoración. Si lo rechaza o no responde, pasa al siguiente.",
"gap": "Tiempo de traslado entre pedidos, min",
"gapMin": "Mínimo {{min}} min",
"timeoutHint": "Si un pedido no se asigna en {{min}} min, vuelve a Barev Club.",
"managers": "Gestores",
"you": "tú",
"remove": "Quitar",
"removeManagerConfirm": "¿Quitar a {{name}} de gestores?",
"managerInvite": "Enlace para otro gestor: abrirá el bot de trabajo y tendrá acceso al panel:",
"logout": "Cerrar sesión en este ordenador",
"wd": {
"0": "Lu",
"1": "Ma",
"2": "Mi",
"3": "Ju",
"4": "Vi",
"5": "Sá",
"6": "Do"
}
}
},
"bot": {
"client": {
"chooseLanguage": "Выбери язык · Choose your language · 选择语言 · Ընտրեք լեզուն · Elige tu idioma",
"languageSet": "Idioma: {{language}}",
"fromAgency": "¡Hola! Vienes de {{agency}}: tenemos un regalo para ti.",
"fromAgency": "¡Hola! Vienes de la agencia inmobiliaria {{agency}}: tenemos un regalo para ti.",
"consentText": "Para usar Barev Club necesitamos tu consentimiento para tratar datos personales. Los reciben el especialista de tu pedido y los socios indicados en la política, y se guardan en un servidor en Alemania.\n\n<a href=\"{{url}}\">Política de privacidad</a>",
"consentAccept": "Acepto",
"giftActivated": "🎁 Regalo de tu agencia: Barev+ activo hasta el {{date}}. Los descuentos ya se aplican.",
"giftActivated": "🎁 Regalo de tu agencia inmobiliaria: {{plan}} activo hasta el {{date}}. Los descuentos ya se aplican.",
"welcome": "Ereván como en casa. Desde el primer día.\n\nReserva limpieza y otros servicios, sigue su estado y disfruta de descuentos de socios, todo en la app.",
"openApp": "Abrir la app",
"enterCode": "Introducir código de agencia",
"enterCode": "Introducir código de la agencia inmobiliaria",
"changeLanguage": "Cambiar idioma",
"codePrompt": "Envía el código de agencia de tu contrato de alquiler.",
"codePrompt": "Envía el código de la agencia inmobiliaria de tu contrato de alquiler.",
"codeAccepted": "Código aceptado: {{agency}}.",
"help": "Pulsa «Abrir la app» para reservar un servicio. Comandos: /app — la app, /code — código de agencia, /language — idioma.",
"help": "Pulsa «Abrir la app» para reservar un servicio. Comandos: /app — la app, /code — código de la agencia inmobiliaria, /language — idioma.",
"cmdStart": "Empezar",
"cmdApp": "Abrir la app",
"cmdCode": "Introducir código de agencia",
"cmdCode": "Introducir código de la agencia inmobiliaria",
"cmdLanguage": "Cambiar idioma",
"cmdHelp": "Ayuda",
"webRequestOrdered": "Solicitud n.º {{request}} recibida y registrada como pedido n.º {{order}} para el {{date}}. El estado está en la app.",
@@ -880,7 +1049,44 @@
"cmdSupport": "Escribir a soporte",
"cmdConcierge": "Escribir al conserje (Barev+)",
"rated": "¡Gracias por tu valoración! {{stars}}",
"ratedLow": "Lamentamos que no fuera bien. Cuéntanos aquí qué pasó y el equipo lo resolverá."
"ratedLow": "Lamentamos que no fuera bien. Cuéntanos aquí qué pasó y el equipo lo resolverá.",
"wr": {
"intro": "Tenemos tu solicitud n.º {{request}}. Solo un par de preguntas para reservar.",
"askDistrict": "¿En qué distrito está el piso?",
"askAddress": "Escribe la dirección: calle, número y piso.",
"askDay": "¿Cuándo te viene bien? Elige un día.",
"askTime": "Elige la hora el {{day}}:",
"dayFull": "Ese día ya está completo. Elige otro día.",
"noSlots": "No hay horas libres en los próximos días: un operador te contactará para encontrar una."
},
"cmdBalance": "Saldo de bariki",
"cmdHistory": "Historial de bariki",
"points": {
"balance": "🪙 Bariki: {{balance}} (≈ {{balance}} ֏)",
"pending": "⏳ Pendientes: {{pending}}, disponibles desde el {{date}}",
"burn": "🔥 Caducan el {{date}} si no hay pedidos",
"empty": "Aún no hay movimientos de bariki.",
"open": "Abrir bariki",
"type": {
"accrual": "Cashback",
"bonus": "Bono",
"redemption": "Pago con bariki",
"request": "Solicitud de pago",
"reversal": "Anulación"
},
"status": {
"PENDING": "pendiente",
"ACTIVE": "disponible",
"SPENT": "gastado",
"EXPIRED": "caducado",
"REVERSED": "anulado",
"REQUESTED": "pendiente de confirmación",
"CONFIRMED": "canjeado",
"REJECTED": "rechazado",
"ACCRUAL": "abono anulado",
"REDEMPTION": "bariki devueltos"
}
}
},
"pro": {
"notLinked": "Este bot es para los especialistas de Barev Club. Pide al operador un enlace de invitación.",
@@ -890,7 +1096,9 @@
"youAreDispatcher": "Despachador de «{{company}}»",
"welcome": "Aquí llegan los pedidos nuevos. Acéptalos con los botones del mensaje y gestiona tu día en el panel.",
"openCabinet": "Abrir panel",
"messageReceived": "Lo pasamos al operador: la respuesta llegará aquí."
"messageReceived": "Lo pasamos al operador: la respuesta llegará aquí.",
"openWeb": "💻 Abrir en el ordenador",
"webLink": "Enlace al panel para el navegador (un solo uso, válido 15 minutos):\n{{url}}"
},
"admin": {
"hello": "{{name}}, aquí llegan las notificaciones del panel de Barev Club.",
@@ -916,7 +1124,30 @@
"chatAssigned": "{{by}} te ha pasado una conversación: {{name}}",
"order": "Pedido n.º {{number}}",
"noOrder": "Sin pedido en curso",
"autoFailed": "No hay profesional libre para el pedido n.º {{number}}: asígnalo manualmente."
"autoFailed": "No hay profesional libre para el pedido n.º {{number}}: asígnalo manualmente.",
"partnerDeclined": "El socio «{{partner}}» rechazó el pedido n.º {{number}}: reasígnalo",
"partnerExpired": "El socio «{{partner}}» no asignó personal al pedido n.º {{number}} a tiempo: reasígnalo",
"noTime": "sin hora",
"noAddress": "sin dirección",
"points": {
"forOrder": "Order #{{number}} for {{sum}}",
"confirm": "✅ Confirm",
"reject": "Decline",
"request": "Client asks to pay with bariki: {{amount}}",
"balance": "balance {{balance}}",
"suggestions": "New bariki suggestions: {{count}} ({{open}} in the queue)",
"overLimit": "{{by}} wants to process {{amount}} bariki — above the limit, needs your approval",
"mismatch": "Bariki reconciliation: {{count}} clients had a mismatched balance — fixed, check the log",
"confirmed": "Redeemed {{amount}} bariki",
"rejected": "Request declined",
"help": "Credit for an order: /barik <phone or @username> <order amount> [category]\nCategories: cleaning, documents, learning, repair, transfer",
"noClient": "Client not found",
"preview": "Credit {{amount}} bariki to {{name}} for {{what}} {{sum}} ֏ ({{tier}}, {{rate}}%). Available from {{date}}.",
"accrue": "🪙 Credit",
"done": "Credited {{amount}} bariki",
"needsApproval": "Above the limit — sent to a super admin for approval",
"zero": "The amount is 0 — nothing to credit"
}
},
"partner": {
"valid": "{{name}} — {{level}}, tarjeta válida",
@@ -1011,34 +1242,58 @@
"rescheduled": "Pedido cambiado al {{when}}.",
"unassigned": "Se te retiró el pedido del {{when}}.",
"reminder24": "Pedido mañana: {{when}}.",
"reminder1": "Pedido dentro de una hora: {{when}}."
"reminder1": "Pedido dentro de una hora: {{when}}.",
"orderEdited": "El pedido del {{when}} se actualizó: ábrelo."
},
"dispatcher": {
"newOrder": "Nuevo pedido para la empresa el {{when}}. Asigna a alguien en el panel.",
"returned": "Alguien del equipo rechazó el pedido del {{when}}. Asigna a otra persona."
"returned": "Alguien del equipo rechazó el pedido del {{when}}. Asigna a otra persona.",
"noStaff": "No hay personal libre para el pedido del {{when}}: asígnalo a mano en el panel.",
"expired": "El pedido del {{when}} volvió a Barev Club: no se asignó a nadie a tiempo."
},
"openPlan": "Abrir la app",
"concierge": "Conserje de Barev Club",
"subscriptionActivated": "{{plan}} activo hasta el {{date}}. Los descuentos ya están en los precios.",
"operator": "Operador de Barev Club",
"support": "Soporte de Barev Club",
"rateAsk": "¿Qué tal fue? Valóralo, por favor, es un segundo:"
"rateAsk": "¿Qué tal fue? Valóralo, por favor, es un segundo:",
"points": {
"open": "Mis bariki",
"pending": "🪙 +{{amount}} bariki por {{what}} del {{date}}. Disponibles desde el {{available}}.",
"active": "🪙 +{{amount}} bariki por {{what}}. Saldo: {{balance}}.",
"bonus": "🎁 +{{amount}} bariki de regalo: {{what}}. Válidos hasta el {{until}}.",
"bonusNoTtl": "🎁 +{{amount}} bariki de regalo: {{what}}.",
"housewarming": "por la mudanza, de {{agency}}",
"activated": "✅ {{amount}} bariki ya disponibles. Saldo: {{balance}}.",
"redeemed": "Pagado con bariki: {{amount}}. A pagar: {{due}}. Saldo: {{balance}}.",
"redeemedNoOrder": "Bariki canjeados: {{amount}}. Saldo: {{balance}}.",
"requestExpired": "Tu solicitud de pago con bariki caducó. Envía otra si la necesitas.",
"rejected": "Tu solicitud de pago con bariki fue rechazada: {{reason}}",
"declinedGeneric": "escríbenos en el chat y lo vemos.",
"orderCancelled": "el pedido se canceló",
"returned": "↩️ {{amount}} bariki han vuelto a tu saldo. Saldo: {{balance}}.",
"reversed": "El abono {{what}} se anuló: {{reason}}. Saldo: {{balance}}.",
"expiring": "⏰ {{amount}} bariki caducan en {{days}} días. Cualquier pedido los conserva.",
"expired": "{{amount}} bariki caducaron por no tener pedidos.",
"orderEdited": "el pedido cambió: abonaremos el nuevo importe"
},
"orderEdited": "Tu pedido se actualizó. Total a pagar: {{total}}."
},
"admin": {
"nav": {
"orders": "Pedidos",
"clients": "Clientes",
"providers": "Especialistas",
"companies": "Empresas",
"companies": "Socios de servicios",
"services": "Servicios y precios",
"agencies": "Agencias",
"agencies": "Agencias inmobiliarias",
"settings": "Ajustes",
"presets": "Tarifas",
"webRequests": "Solicitudes web",
"leads": "Mudanzas",
"concierge": "Conserje",
"requests": "Solicitudes de la app",
"partners": "Socios",
"partners": "Socios del club",
"telegram": "Notificaciones",
"templates": "Plantillas de mudanza"
},
@@ -1177,7 +1432,10 @@
"finishLesson": "Clase terminada",
"openMeeting": "Reunión abierta",
"startMeeting": "Reunión empezada",
"finishMeeting": "Reunión terminada"
"finishMeeting": "Reunión terminada",
"editedByStaff": "El equipo cambió el pedido",
"checklistByStaff": "El equipo marcó un punto",
"checklistUncheckedByStaff": "El equipo desmarcó un punto"
},
"weekdays": {
"0": "Lun",
@@ -1256,7 +1514,7 @@
"eliteDiscount": "Descuento Elite"
},
"agencies": {
"add": "Añadir agencia",
"add": "Añadir agencia inmobiliaria",
"name": "Nombre",
"code": "Código",
"commission": "Comisión, %",
@@ -1289,7 +1547,7 @@
"cancel_freeHours": "Cancelación gratis hasta, h antes",
"agency_attributionMonths": "Cliente vinculado a la agencia, meses",
"agency_defaultCommissionPct": "Comisión de agencia por defecto, %",
"barevPlus_giftDays": "Barev+ de regalo, días",
"barevPlus_giftDays": "Landing: «primeros N días gratis» (el regalo se fija en cada agencia)",
"barevPlus_monthlyPrice": "Precio mensual de Barev+, ֏",
"offer_acceptTimeoutMinutes": "Tiempo de respuesta del especialista, min",
"chat_closeAfterHours": "El chat se cierra tras el pedido, h",
@@ -1321,7 +1579,23 @@
"sales_bonusPerVenue": "Bono de ventas por local, ֏",
"sales_bonusPerAgency": "Bono de ventas por agencia, ֏",
"sales_bonusAgencyPct": "Bono de ventas: % de ingresos de clientes de sus agencias",
"sales_bonusAgencyMonths": "…durante cuántos meses tras unirse la agencia"
"sales_bonusAgencyMonths": "…durante cuántos meses tras unirse la agencia",
"partner_fotPct": "Socios (gastos nuestros): nómina del limpiador, % del pedido",
"partner_taxPct": "Socios (gastos nuestros): impuesto, %",
"partner_minTravelGapMinutes": "Socios: pausa mínima entre pedidos de un empleado, min",
"partner_acceptTimeoutMinutes": "Socios: minutos para asignar personal antes de que el pedido vuelva",
"points_pendingHours": "Bariki: disponibles N horas después del servicio",
"points_maxSharePct": "Bariki: hasta el N% del pedido",
"points_minRedeem": "Bariki: mínimo por canje",
"points_burnDays": "Bariki: caducan tras N días sin actividad",
"points_warnDays1": "Bariki: primer aviso N días antes",
"points_warnDays2": "Bariki: segundo aviso N días antes",
"points_requestTtlHours": "Bariki: la solicitud espera N horas",
"points_streakGapDays": "Bariki: racha de limpiezas — pausa de hasta N días",
"points_diffHighlightPct": "Bariki: resaltar importes manuales con más de N% de diferencia",
"points_supportCanManage": "Soporte también puede abonar y canjear bariki (con límites)",
"points_opMaxPerOperation": "Límite de soporte por operación, bariki",
"points_opMaxPerDay": "Límite diario de soporte, bariki"
},
"presets": {
"add": "Añadir plantilla",
@@ -1549,7 +1823,7 @@
"step4_sub": "Chat con el especialista incluido"
},
"agency": {
"title": "¿Alquilaste con una agencia asociada?",
"title": "¿Alquilaste con una agencia inmobiliaria asociada?",
"text": "El código para un mes de Barev+ ya está en tu contrato.",
"gift": "¡Regalo!",
"giftSub": "1 mes de Barev+"
@@ -1571,7 +1845,7 @@
"a": "Descuentos de hasta el {{pct}}% en documentos, hogar, formación y asesoría de carrera."
},
"gift": {
"q": "¿Cómo consigo el regalo de la agencia?",
"q": "¿Cómo consigo el regalo de la agencia inmobiliaria?",
"a": "El código está en tu contrato de alquiler. Introdúcelo en el bot y tendrás {{days}} días de Barev+."
}
},
@@ -1580,7 +1854,11 @@
"partner": "Hazte socio",
"privacy": "Política de privacidad",
"legal": "Empresario individual Yuri Zabudko · NIF (ՀՎՀՀ) {{tin}}",
"services": "Servicios"
"services": "Servicios",
"pricing": "Precios",
"terms": "Condiciones de uso",
"refund": "Cancelación y reembolso",
"rules": "Normas del servicio"
},
"clean": {
"meta": {
@@ -1646,7 +1924,11 @@
"how2": "Confirmamos en Telegram",
"how2Sub": "El bot muestra el pedido y su estado",
"how3": "Llega la limpiadora",
"how3Sub": "Trabaja con una lista y envía un informe con fotos"
"how3Sub": "Trabaja con una lista y envía un informe con fotos",
"whenHintOptional": "Opcional: si lo omites, el bot te lo preguntará en Telegram.",
"addressHintOptional": "Opcional: puedes enviar la dirección al bot después.",
"nameTitle": "Cómo te llamas",
"consentByClick": "Al pulsar el botón, aceptas el tratamiento de tus datos personales."
},
"v1": {
"title": "Todo para vivir en Ereván, en un solo chat",
@@ -1687,7 +1969,7 @@
"airportTitle": "Te recibimos en el aeropuerto y te alojamos en un hotel",
"airportText": "Un conductor con un cartel en Zvartnots y traslado al hotel: los primeros días sin preocupaciones.",
"flatTitle": "Encontramos tu piso",
"flatText": "Buscamos vivienda con agencias asociadas y la limpiamos antes de que entres.",
"flatText": "Buscamos vivienda con agencias inmobiliarias asociadas y la limpiamos antes de que entres.",
"docsTitle": "Hacemos el registro y la tarjeta social",
"docsText": "Preparamos los documentos, pedimos la cita y te acompañamos hasta la tarjeta lista.",
"permitTitle": "Te ayudamos con los documentos de residencia",
@@ -1700,9 +1982,9 @@
"chatCta": "Escribir al chat"
},
"v3": {
"tag": "Para inquilinos de agencias asociadas",
"tag": "Para inquilinos de agencias inmobiliarias asociadas",
"title": "Bienvenido a casa",
"sub": "La agencia con la que alquilaste te regala un mes de Barev+: descuentos en limpieza, documentos y clases.",
"sub": "La agencia inmobiliaria con la que alquilaste te regala una suscripción a Barev+: descuentos en limpieza, documentos y clases.",
"giftTitle": "1 mes de Barev+",
"giftCode": "El código {{code}} se aplica solo",
"giftNoCode": "El código está en tu contrato de alquiler",
@@ -1756,14 +2038,14 @@
"perYear": "Al año: {{amount}}",
"other": "La suscripción también sirve para otras categorías",
"cta": "Probar Barev+",
"gift": "¿Alquilaste con una agencia asociada? El primer mes es un regalo."
"gift": "¿Alquilaste con una agencia inmobiliaria asociada? La suscripción es un regalo."
},
"v6": {
"tag": "Para agencias inmobiliarias",
"title": "Un acuerdo sobre cómo tus inquilinos se instalan en Ereván",
"sub": "Regalas a tus clientes un mes de Barev+, nosotros les ayudamos con el día a día y tú recibes un porcentaje de sus pedidos.",
"docDate": "Ereván",
"s1": "Qué recibe la agencia",
"s1": "Qué recibe la agencia inmobiliaria",
"s1i1": "El {{pct}}% de cada pedido del cliente durante {{months}} meses desde su primera visita al bot.",
"s1i2": "Tu propio código y QR: añádelos al contrato o entrégalos con las llaves.",
"s1i3": "Un informe de clientes y pedidos: cuántos llegaron, cuántos pidieron y cuánto ganaste.",
@@ -1779,7 +2061,7 @@
"exampleText": "{{clients}} clientes × {{orders}} limpiezas al mes × {{price}}",
"exampleResult": "{{amount}} para la agencia al mes",
"exampleNote": "Cálculo con una limpieza regular de un piso de dos habitaciones.",
"signAgency": "Agencia",
"signAgency": "Agencia inmobiliaria",
"signUs": "Barev Club",
"cta": "Hacerme socio"
},
@@ -2315,7 +2597,7 @@
},
"privacy": {
"title": "Política de privacidad",
"updated": "Versión del 20 de octubre de 2026",
"updated": "Versión del 22 de octubre de 2026",
"intro": "Esta política explica qué datos personales recoge Barev Club, para qué, con quién los compartimos y cómo puedes controlarlos. Se aplica al sitio barevclub.com, a los bots de Telegram y a las mini apps de Barev Club.",
"operator": {
"title": "Quién trata tus datos",
@@ -2323,7 +2605,7 @@
},
"data": {
"title": "Qué datos recibimos",
"body": "• De Telegram: ID, nombre de usuario, nombre y apellido del perfil, idioma. El número de teléfono solo si decides compartirlo.\n• Solicitudes del sitio: nombre, teléfono o usuario de Telegram, tus respuestas en el planificador de mudanza (fechas, servicios, presupuesto). Para la limpieza: dirección, distrito, hora deseada y comentario.\n• Pedidos: dirección y distrito, ubicación en el mapa (si la indicas), opciones del servicio, fecha y hora, precio, descuento, estado del pago, comentarios.\n• Documentos: copias escaneadas del pasaporte y otros documentos que subes para los servicios de Documentos.\n• Mensajes con el especialista a través del bot, informes fotográficos, valoraciones y reseñas.\n• Membresía: nivel (Barev, Barev+, Elite), fechas de suscripción, código de la tarjeta del club y registros de su uso en socios.\n• Origen: la agencia o código promocional con el que llegaste y etiquetas de campañas (UTM).\n• Datos técnicos: una cookie con el idioma del sitio, el código de agencia en el almacenamiento del navegador y registros del servidor (dirección IP, hora y dirección solicitada) para protegernos de fallos y ataques.\nNo recibimos datos de tarjetas bancarias. Cuando haya pago en línea, los datos de la tarjeta se introducirán en el sistema del proveedor de pagos."
"body": "• De Telegram: ID, nombre de usuario, nombre y apellido del perfil, idioma. El número de teléfono solo si decides compartirlo.\n• Solicitudes del sitio: nombre, teléfono o usuario de Telegram, tus respuestas en el planificador de mudanza (fechas, servicios, presupuesto). Para la limpieza: dirección, distrito, hora deseada y comentario.\n• Pedidos: dirección y distrito, ubicación en el mapa (si la indicas), opciones del servicio, fecha y hora, precio, descuento, estado del pago, comentarios.\n• Documentos: copias escaneadas del pasaporte y otros documentos que subes para los servicios de Documentos.\n• Mensajes con el especialista a través del bot, informes fotográficos, valoraciones y reseñas.\n• Membresía: nivel (Barev, Barev+, Elite), fechas de suscripción, código de la tarjeta del club y registros de su uso en socios.\n• Origen: la agencia o código promocional con el que llegaste y etiquetas de campañas (UTM).\n• Datos técnicos: una cookie con el idioma del sitio, el código de agencia en el almacenamiento del navegador y registros del servidor (dirección IP, hora y dirección solicitada) para protegernos de fallos y ataques.\nNo recibimos datos de tarjetas bancarias: en los pagos en línea se introducen en la página de Paddle, nuestro revendedor en línea."
},
"purposes": {
"title": "Para qué los usamos",
@@ -2335,7 +2617,7 @@
},
"recipients": {
"title": "Con quién compartimos los datos",
"body": "No vendemos datos personales y solo compartimos lo que necesita cada tarea:\n• Con el especialista de tu pedido y, si lo realiza una empresa asociada, con su coordinador: nombre, idioma, dirección y detalles del pedido. Tu número de teléfono no se muestra al especialista; la comunicación es a través del bot.\n• Con la agencia inmobiliaria que te recomendó: tu nombre, si usas el club y los importes de los pedidos pagados, para calcular su comisión.\n• Con un socio del club cuando muestras tu tarjeta: tu nombre y nivel de membresía.\n• Con Telegram, donde funcionan los bots y mini apps; Telegram trata los datos según su propia política.\n• Con Hostinger International, en cuyos servidores se guardan la base de datos y los archivos.\n• Con un proveedor de pagos, cuando haya pago en línea.\n• Con organismos públicos, cuando pides un servicio de documentos y los presentamos en tu nombre, o cuando la ley lo exige."
"body": "No vendemos datos personales y solo compartimos lo que necesita cada tarea:\n• Con el especialista de tu pedido y, si lo realiza una empresa asociada, con su coordinador: nombre, idioma, dirección y detalles del pedido. Tu número de teléfono no se muestra al especialista; la comunicación es a través del bot.\n• Con la agencia inmobiliaria que te recomendó: tu nombre, si usas el club y los importes de los pedidos pagados, para calcular su comisión.\n• Con un socio del club cuando muestras tu tarjeta: tu nombre y nivel de membresía.\n• Con Telegram, donde funcionan los bots y mini apps; Telegram trata los datos según su propia política.\n• Con Hostinger International, en cuyos servidores se guardan la base de datos y los archivos.\n• Con Paddle.com, nuestro revendedor en línea que procesa los pagos en línea: nombre, email, país y datos del pago. Paddle trata estos datos como responsable independiente según su propia política de privacidad.\n• Con organismos públicos, cuando pides un servicio de documentos y los presentamos en tu nombre, o cuando la ley lo exige."
},
"location": {
"title": "Dónde se guardan los datos",
@@ -2369,5 +2651,151 @@
"title": "Cambios en la política",
"body": "Podemos actualizar esta política. La versión vigente está siempre en esta página, con su fecha al principio. Te avisaremos en el bot de los cambios importantes.\nEl texto en ruso es la versión principal; las traducciones se ofrecen por comodidad."
}
},
"pricing": {
"title": "Precios",
"updated": "Vigentes desde el 22 de octubre de 2026",
"intro": "Cuánto cuestan la suscripción a Barev Club y los servicios del club. Puedes pedir servicios sin suscripción: esta solo añade descuentos y un conserje. Todos los precios están en drams armenios (֏).",
"from": "desde {{price}}",
"perDay": "{{price}} por noche",
"plansTitle": "Suscripción al club",
"plansNote": "La suscripción se paga mensualmente y se renueva hasta que la canceles. Puedes cancelarla en cualquier momento: seguirá activa hasta el final del mes pagado.",
"planNote": {
"free": "Registro en el bot de Telegram, sin pago y sin plazo.",
"plus": "Se contrata en el bot. Si llegas a través de una agencia inmobiliaria asociada, la suscripción es un regalo (normalmente {{days}} días o más; cada agencia fija sus condiciones).",
"elite": "Se contrata con un operador del club en el chat del bot."
},
"servicesTitle": "Servicios",
"servicesNote": "Estos son los precios base. Antes de que confirmes el pedido, el bot muestra el precio exacto con tus opciones y el descuento de la suscripción. Una vez confirmado, el precio no cambia.",
"rows": {
"cleaning": {
"name": "Limpieza del piso",
"note": "Depende del tipo de limpieza y del número de habitaciones"
},
"transferStandard": {
"name": "Traslado desde el aeropuerto de Zvartnots, Estándar",
"note": "Hasta 3 pasajeros, cartel con tu nombre, 60 minutos de espera"
},
"transferPremium": {
"name": "Traslado desde el aeropuerto de Zvartnots, Premium",
"note": "Clase business, agua, tarjeta SIM"
},
"hotel": {
"name": "Hotel para los primeros días",
"note": "Precio con Barev+; sin suscripción, desde {{base}} por noche"
},
"socialCard": {
"name": "Tarjeta social, de principio a fin",
"note": "Traducción del pasaporte, notario, cita, acompañamiento"
},
"consultation": {
"name": "Consulta sobre el permiso de residencia",
"note": "Se descuenta del precio del acompañamiento"
},
"residence": {
"name": "Acompañamiento del permiso de residencia",
"note": "Base legal, documentos, solicitud, visita. La tasa estatal de {{fee}} por el permiso de 1 año se paga al Estado por separado"
}
},
"payTitle": "Pago y cancelación",
"payBody": "La suscripción Barev+ se puede pagar con tarjeta en línea: el pago lo procesa nuestro revendedor en línea Paddle.com, y los pagos de la suscripción se reembolsan a petición en un plazo de 14 días. Los servicios se pagan en efectivo o por transferencia; el método se elige en el pedido. Puedes cancelar o cambiar la fecha gratis hasta 24 horas antes del inicio del servicio. Los detalles están en las condiciones de uso y en la política de reembolso."
},
"terms": {
"title": "Condiciones de uso",
"updated": "Versión del 22 de octubre de 2026",
"intro": "Estas son las condiciones en las que Barev Club presta sus servicios a través de barevclub.com, sus bots de Telegram y sus mini apps. Al hacer un pedido o contratar una suscripción, aceptas estas condiciones.",
"about": {
"title": "Quiénes somos y qué hacemos",
"body": "Barev Club es un servicio para quienes se mudan a Armenia o ya viven aquí: servicios del hogar (limpieza, traslado desde el aeropuerto, hotel para los primeros días), ayuda con documentos (tarjeta social, permiso de residencia), formación y descuentos de los socios del club.\nLos servicios los presta el empresario individual Yuri Konstantinovich Zabudko (Ա/Ձ Զաբուդկո Յուրի Կոնստանտինի), República de Armenia, NIF (ՀՎՀՀ) {{tin}}. Contacto: {{email}}, {{phone}}.\nLos pedidos los realizan proveedores verificados: nuestro personal o empresas socias. Barev Club responde ante ti por el resultado."
},
"acceptance": {
"title": "Aceptación de las condiciones",
"body": "Las condiciones se aplican desde el momento en que haces un pedido, contratas una suscripción o envías una solicitud en la web o en el bot. Para usar el servicio debes tener 18 años o más.\nSi no estás de acuerdo con las condiciones, no hagas el pedido."
},
"orders": {
"title": "Cómo funciona un pedido",
"body": "• Eliges un servicio en el bot o envías una solicitud en la web e indicas los detalles: dirección, fecha, hora y demás.\n• Antes de confirmar, el bot muestra el precio total, el descuento y las condiciones de cancelación.\n• El pedido queda aceptado cuando lo confirmas y asignamos un proveedor. Recibes un aviso de esto y de cada estado posterior.\n• Hablas con el proveedor a través del chat del bot.\nSi no podemos realizar el pedido a la hora elegida, te propondremos otra o cancelaremos el pedido sin coste."
},
"prices": {
"title": "Precios",
"body": "Los precios figuran en drams armenios en la página «Precios» y en el bot. Podemos cambiar los precios, pero el de un pedido ya confirmado no cambia.\nLas tasas y derechos estatales, como la tasa del permiso de residencia, no están incluidos en el precio del servicio salvo que se indique expresamente. Te decimos el importe por adelantado."
},
"payment": {
"title": "Pago",
"body": "La suscripción Barev+ se puede pagar con tarjeta en línea. Los pedidos en línea los gestiona nuestro revendedor en línea Paddle.com. Paddle.com es el vendedor (Merchant of Record) de todos nuestros pedidos en línea: cobra el pago, emite la factura, atiende las consultas sobre pagos y gestiona los reembolsos. Los datos de la tarjeta se introducen en la página de Paddle; no los recibimos ni los guardamos.\nLos servicios (limpieza, traslados, documentos y otros) no se venden en línea: se pagan en efectivo o por transferencia al proveedor, o por transferencia a la cuenta de Barev Club; el método se elige en el pedido.\nEmitimos un justificante de pago a petición."
},
"cancel": {
"title": "Cancelación y cambio de fecha",
"body": "Puedes cancelar o cambiar la fecha de un pedido gratis hasta 24 horas antes del inicio del servicio, en el bot o en el chat con un operador.\nSi cancelas más tarde, se retiene una parte del precio. El importe se indica en la ficha del servicio antes de confirmar el pedido; por defecto es el 30 %.\nSi cancelamos nosotros o el proveedor no se presenta, te devolvemos todo el importe. Más detalles en la política de reembolso."
},
"subscription": {
"title": "Suscripción Barev+ y Barev Elite",
"body": "• La suscripción da descuentos en los servicios del club, regalos de los socios y un conserje en el chat. Niveles y precios en la página «Precios».\n• La suscripción se paga mensualmente y se renueva automáticamente hasta que la canceles. Te avisamos 3 días antes de la renovación.\n• El descuento se aplica a los pedidos hechos mientras la suscripción está activa.\n• El periodo gratuito con el código de una agencia socia se concede una sola vez por cliente.\n• Puedes cancelarla en cualquier momento en el bot o en el chat con un operador. Seguirá activa hasta el final del periodo pagado."
},
"perks": {
"title": "Descuentos y regalos de los socios",
"body": "Los descuentos y regalos con la tarjeta del club los ofrecen los socios del club: cafeterías, restaurantes y otras empresas. Las condiciones de cada oferta figuran en el catálogo de socios.\nCada socio responde de sus propios productos y servicios. Las ofertas de los socios pueden cambiar o terminar."
},
"docs": {
"title": "Servicios de documentación",
"body": "Te ayudamos a reunir los documentos, los traducimos, pedimos cita y te acompañamos a los organismos públicos. La decisión de expedir un documento o un permiso de residencia la toma el organismo público y no podemos garantizarla.\nEres responsable de que los documentos y datos que nos das sean auténticos y exactos. Si una denegación se debe a un error nuestro, lo corregiremos a nuestra costa."
},
"client": {
"title": "Tus obligaciones",
"body": "• Dar datos correctos: dirección, contacto, opciones del pedido.\n• Dar acceso al piso a la hora acordada y condiciones seguras para el trabajo del proveedor.\n• Tratar con respeto a proveedores y operadores.\n• No usar el servicio con fines ilícitos.\nSi el proveedor no puede empezar por causa tuya (por ejemplo, no hay nadie en la dirección), se considera una cancelación tardía."
},
"quality": {
"title": "Calidad y reclamaciones",
"body": "Si no quedas satisfecho con el resultado, escribe en el chat del pedido en las 24 horas siguientes y, si puedes, adjunta fotos. Corregiremos los defectos gratis o devolveremos parte o todo el precio.\nSi un proveedor daña tus bienes, avísanos en 24 horas: lo revisaremos y compensaremos el daño confirmado."
},
"liability": {
"title": "Responsabilidad",
"body": "Nuestra responsabilidad por un pedido se limita a su precio, salvo en caso de daños confirmados a bienes y cuando la ley no permite ese límite.\nNo respondemos de retrasos ni cancelaciones por causas ajenas a nuestro control: decisiones de organismos públicos, retrasos de vuelos, desastres naturales, caídas de Telegram."
},
"content": {
"title": "Contenido de la web",
"body": "Los textos, el diseño, el logotipo y el nombre Barev Club pertenecen al titular del servicio. No los copies sin permiso."
},
"changes": {
"title": "Cambios en las condiciones",
"body": "Podemos actualizar estas condiciones. La versión vigente está siempre en esta página, con su fecha al principio. Los pedidos ya confirmados se realizan con las condiciones anteriores. Te avisaremos en el bot con antelación de los cambios importantes en la suscripción."
},
"law": {
"title": "Ley aplicable y disputas",
"body": "Estas condiciones se rigen por la legislación de la República de Armenia. Primero intentamos resolver las disputas negociando: escribe a {{email}} y responderemos en 3 días hábiles. Si no llegamos a un acuerdo, la disputa la resuelve un tribunal de la República de Armenia.\nEstas condiciones no limitan tus derechos legales como consumidor.\nEl texto original está en ruso; las traducciones se ofrecen por comodidad."
}
},
"refund": {
"title": "Cancelación y reembolso",
"updated": "Versión del 22 de octubre de 2026",
"intro": "Cuándo y cuánto reembolsa Barev Club por los servicios y la suscripción, y cómo solicitar un reembolso.",
"cancelClient": {
"title": "Si cancelas tú",
"body": "• Con 24 horas o más de antelación al inicio del servicio, cancelar y cambiar la fecha es gratis y te devolvemos todo el importe.\n• Con menos de 24 horas, se retiene una parte del precio y se devuelve el resto. El importe se indica en la ficha del servicio antes de confirmar el pedido; por defecto es el 30 %.\n• Una vez iniciado el servicio, no se devuelve la parte ya realizada."
},
"cancelUs": {
"title": "Si cancelamos nosotros",
"body": "Si cancelamos el pedido, no encontramos proveedor o el proveedor no se presenta, tú eliges: cambiar a otra hora que te venga bien o recibir el reembolso completo."
},
"quality": {
"title": "Si no quedas satisfecho",
"body": "Escribe en el chat del pedido en las 24 horas siguientes a su realización y, si puedes, adjunta fotos. Corregiremos los defectos gratis y, si no es posible, devolveremos parte o todo el precio según lo que haya fallado."
},
"subscription": {
"title": "Suscripción Barev+ y Barev Elite",
"body": "• Cualquier pago de la suscripción se devuelve íntegro si lo pides en los 14 días siguientes al cobro.\n• Puedes cancelar en cualquier momento: no se cobrará el siguiente pago y la suscripción seguirá activa hasta el final del mes pagado.\n• El periodo gratuito no cuesta nada, así que no hay nada que reembolsar."
},
"nonRefundable": {
"title": "Qué no se reembolsa",
"body": "• Las tasas y derechos estatales que ya hemos pagado en tu nombre.\n• Los gastos que ya hemos pagado a terceros por tu pedido: por ejemplo, una traducción notarial o una reserva de hotel según las normas del hotel.\n• Los productos y servicios de los socios del club: se aplican las normas del socio."
},
"howTo": {
"title": "Cómo solicitar un reembolso",
"body": "Escribe en el chat del bot o a {{email}} indicando el número de pedido o de suscripción y el motivo. Responderemos en 3 días hábiles.\nSi pagaste la suscripción en línea, también puedes pedir el reembolso directamente a Paddle en paddle.net, donde encontrarás tu pago y tu recibo."
},
"timing": {
"title": "Plazos y forma de reembolso",
"body": "El dinero se devuelve por el mismo medio con el que pagaste, en un plazo de 10 días hábiles desde que se aprueba el reembolso. En tarjeta puede tardar unos días más, según tu banco. Si pagaste en efectivo, te lo devolvemos por transferencia o en efectivo, como prefieras.\nDudas sobre reembolsos: {{email}}, {{phone}}."
}
}
}
+469 -41
View File
@@ -412,7 +412,7 @@
"CANCEL_VIA_OPERATOR": "Ծառայությունն արդեն սկսվել է․ չեղարկել կարելի է միայն օպերատորի միջոցով։",
"RESCHEDULE_VIA_OPERATOR": "Տեղափոխել կարելի է միայն օպերատորի միջոցով։",
"AGENCY_CODE_INVALID": "Այդպիսի կոդ չկա։ Ստուգեք պայմանագրի կոդը։",
"AGENCY_ALREADY_SET": "Դուք արդեն կապված եք այլ գործակալության հետ։",
"AGENCY_ALREADY_SET": "Դուք արդեն կապված եք այլ անշարժ գույքի գործակալության հետ։",
"OFFER_NOT_ACTIVE": "Առաջարկն այլևս արդիական չէ։",
"PROVIDER_BUSY": "Մասնագետն այդ ժամին զբաղված է։",
"ALREADY_ASSIGNED": "Պատվերն արդեն նշանակված է։",
@@ -448,7 +448,17 @@
"PROMO_INVALID": "Նման պրոմոկոդ չկա",
"PROMO_EXPIRED": "Պրոմոկոդն այլևս չի գործում",
"PROMO_USED": "Այս պրոմոկոդն արդեն օգտագործված է",
"PROMO_NOT_FOR_SERVICE": "Պրոմոկոդը չի գործում այս ծառայության համար"
"PROMO_NOT_FOR_SERVICE": "Պրոմոկոդը չի գործում այս ծառայության համար",
"POINTS_DUPLICATE_ORDER": "Այս պատվերի համար բարիկներն արդեն հաշվեգրվել են։",
"POINTS_REASON_REQUIRED": "Նշեք պատճառը։",
"POINTS_BELOW_MIN": "Գումարը նվազագույնից քիչ է։",
"POINTS_NOT_ENOUGH": "Բարիկները չեն բավարարում։",
"POINTS_OVER_SHARE": "Բարիկներով կարելի է վճարել պատվերի միայն մի մասը։",
"POINTS_OVER_LIMIT": "Գերազանցում է ձեր սահմանաչափը․ պետք է սուպերադմինի հաստատում։",
"POINTS_FINAL": "Այս գործողությունն այլևս հնարավոր չէ չեղարկել։",
"POINTS_REQUEST_CLOSED": "Հայտն արդեն մշակված է կամ ժամկետանց։",
"POINTS_TOO_MANY_REQUESTS": "Դուք արդեն ունեք սպասող հայտեր․ սպասեք պատասխանի։",
"POINTS_WITH_PROMO": "Բարիկները չեն համատեղվում պրոմոկոդի հետ։"
},
"client": {
"nav": {
@@ -473,7 +483,7 @@
"text": "Պատվերներ ձևակերպելու համար անհրաժեշտ է ձեր համաձայնությունը անձնական տվյալների մշակմանը՝ անուն, հասցե և պատվերի մանրամասներ։ Դրանք ստանում են ձեր պատվերի կատարողը և քաղաքականությունում նշված գործընկերները, իսկ պահվում են Գերմանիայում գտնվող սերվերում։",
"policyLink": "Գաղտնիության քաղաքականություն",
"accept": "Համաձայն եմ, շարունակել",
"giftToast": "Նվերն ակտիվացված է՝ Barev+-ի մեկ ամիս"
"giftToast": "Անշարժ գույքի գործակալության նվերն ակտիվացված է"
},
"home": {
"hello": "Բարև՛",
@@ -482,7 +492,7 @@
"allOrders": "Բոլորը",
"noOrders": "Դեռ հայտեր չկան։ Պատվիրեք առաջին ծառայությունը՝ ընդամենը 2 րոպե։",
"repeatLast": "Կրկնել",
"enterCode": "Գործակալության կոդ",
"enterCode": "Անշարժ գույքի գործակալության կոդ",
"plusUntil": "Barev+-ն ակտիվ է մինչև {{date}}",
"discount": "−{{pct}}% Barev+-ով",
"upsellTitle": "Մինչև −{{pct}}% Barev Elite-ով",
@@ -582,20 +592,20 @@
},
"profile": {
"title": "Պրոֆիլ",
"fromAgency": "{{agency}} գործակալության հաճախորդ",
"noAgency": "Առանց գործակալության",
"fromAgency": "{{agency}} անշարժ գույքի գործակալության հաճախորդ",
"noAgency": "Առանց անշարժ գույքի գործակալության",
"membership": "Barev / Barev+ անդամակցություն",
"codeTitle": "Գործակալության կոդ",
"codeText": "Բնակարանը վարձե՞լ եք գործընկեր գործակալության միջոցով։ Մուտքագրեք պայմանագրի կոդը և ստացեք Barev+-ի մեկ ամիս։",
"codeTitle": "Անշարժ գույքի գործակալության կոդ",
"codeText": "Բնակարանը վարձե՞լ եք գործընկեր անշարժ գույքի գործակալության միջոցով։ Մուտքագրեք պայմանագրի կոդը և ստացեք գործակալության նվերը։",
"codePlaceholder": "Օրինակ՝ DOMIK",
"codeAccepted": "Կոդն ընդունված է՝ {{agency}}",
"codeGift": "Կոդն ընդունված է՝ {{agency}}։ Barev+-ի մեկ ամիսը ձերն է։",
"codeGift": "Կոդն ընդունված է՝ {{agency}}։ Նվերն ակտիվացված է։",
"language": "Լեզու"
},
"membership": {
"title": "Անդամակցություն",
"current": "Ձեր սակագինը",
"howToGet": "Barev+-ը կարելի է ստանալ նվեր գործակալության կոդով։ Առցանց բաժանորդագրությունը շուտով կհայտնվի։",
"howToGet": "Barev+-ը կարելի է ստանալ նվեր անշարժ գույքի գործակալության կոդով։ Առցանց բաժանորդագրությունը շուտով կհայտնվի։",
"request": "Ձևակերպել {{plan}}",
"requested": "Հայտն ուղարկված է, օպերատորը կկապվի",
"requestHint": "Օպերատորը կգրի Telegram-ով, կհամաձայնեցնի վճարումը և կմիացնի բաժանորդագրությունը։",
@@ -712,6 +722,60 @@
"title": "Ակումբային քարտ",
"partners": "Ակումբի գործընկերներ",
"plans": "Մակարդակներ և զեղչեր"
},
"points": {
"title": "Բարիկներ",
"balance": "Հասանելի է",
"equals": "բարիկ ≈ {{amount}} ֏ · 1 բարիկ = 1 ֏",
"pending": "սպասման մեջ՝ {{amount}}, հասանելի կլինեն {{date}}",
"burn": "{{amount}}-ը կայրվի {{date}}․ ցանկացած պատվեր կպահի դրանք",
"pay": "Վճարել բարիկներով",
"enterCode": "Մուտքագրել կոդ",
"requestPending": "Բարիկներով վճարման հայտը սպասում է հաստատման։",
"rates": "Քեշբեք ըստ կատեգորիաների",
"plusWouldGet": "Barev+-ով վերջին 30 օրում կունենայիք ևս +{{amount}} բարիկ →",
"streak": "Մաքրումների շարք",
"streakLeft": "Եվս {{left}}՝ և մաքրման քեշբեքը +{{bonus}}%։ Ընդմիջումը՝ ոչ ավելի {{days}} օրից։",
"streakMax": "Առավելագույն շարք՝ +{{bonus}}% քեշբեք ամեն մաքրման համար։",
"route": "Առաջին ամսվա երթուղի",
"routeText": "Պատվիրեք 3 տարբեր կատեգորիայի ծառայություններ մինչև {{date}} և ստացեք {{amount}} բարիկ։ Հիմա՝ {{done}}/3։",
"history": "Պատմություն",
"empty": "Բարիկներ եք ստանում ամեն վճարված պատվերի համար՝ ձեր մակարդակի տոկոսով։",
"availableFrom": "հասանելի են {{date}}-ից",
"until": "մինչև {{date}}",
"payHint": "Բարիկներով կարելի է վճարել պատվերի մինչև {{pct}}%-ը, նվազագույնը՝ {{min}}։ Թիմը կհաստատի և կուղարկի մնացած գումարը։",
"forOrder": "Պատվեր",
"otherOrder": "Այլ պատվեր (հավելվածից դուրս)",
"amount": "Քանի բարիկ դուրս գրել",
"max": "Առավելագույնը՝ {{max}}",
"note": "Մեկնաբանություն",
"notePh": "Որ պատվերը և ինչ գումարով",
"send": "Ուղարկել հայտը",
"requested": "Հայտն ուղարկված է․ կպատասխանենք զրույցում։",
"useInOrder": "Դուրս գրել {{amount}} բարիկ",
"useHint": "մինչև պատվերի {{pct}}%-ը · հաշվին՝ {{balance}}",
"notWithPromo": "Ոչ պրոմոկոդի հետ միասին",
"belowMin": "Նվազագույնը՝ {{min}} · հաշվին՝ {{balance}}",
"paidLine": "Վճարված է բարիկներով",
"type": {
"accrual": "Քեշբեք",
"bonus": "Բոնուս",
"redemption": "Վճարում բարիկներով",
"request": "Վճարման հայտ",
"reversal": "Չեղարկում"
},
"status": {
"PENDING": "սպասման մեջ",
"ACTIVE": "հասանելի",
"SPENT": "ծախսված",
"EXPIRED": "այրված",
"REVERSED": "չեղարկված",
"REQUESTED": "սպասում է հաստատման",
"CONFIRMED": "դուրս է գրված",
"REJECTED": "մերժված",
"ACCRUAL": "հաշվեգրումը չեղարկված է",
"REDEMPTION": "բարիկները վերադարձվել են"
}
}
},
"pro": {
@@ -793,7 +857,8 @@
"empty": "Այս ժամանակահատվածում պատվերներ չկան։",
"ratePercent": "Դրույք՝ պատվերի {{value}}%",
"rateFixed": "Դրույք՝ {{value}} մեկ պատվերի համար",
"ratePreset": "Դրույք՝ «{{name}}» նախադրվածք"
"ratePreset": "Դրույք՝ «{{name}}» նախադրվածք",
"ratePartner": "Պատվերների վարձատրությունը սահմանում է ձեր ընկերությունը"
},
"company": {
"title": "Ընկերություն",
@@ -849,23 +914,127 @@
"recent": "Վերջին ստուգումները",
"noRecent": "Ստուգումներ դեռ չեն եղել։",
"checked": "քարտի ստուգում"
},
"web": {
"loginHint": "Բացեք աշխատանքային բոտը և սեղմեք «Բացել համակարգչում» (կամ ուղարկեք /web)՝ կստանաք մուտքի հղում։"
},
"cab": {
"tab": {
"orders": "Պատվերներ",
"staff": "Աշխատակիցներ",
"money": "Եկամուտ",
"settings": "Կարգավորումներ"
},
"autoOn": "Ավտոբաշխումը միացված է․ նոր պատվերներն ինքնաբերաբար գնում են ազատ աշխատակիցներին։",
"income": "Ձեր եկամուտը",
"deadline": "նշանակեք մինչև {{time}}",
"offeredTo": "սպասում ենք՝ {{name}}",
"decline": "Հրաժարվել",
"declineConfirm": "Հրաժարվե՞լ պատվերից։ Այն կվերադառնա Barev Club։",
"declined": "Պատվերը վերադարձված է",
"reassign": "Վերանշանակել",
"unassignConfirm": "Հանե՞լ {{name}}-ին պատվերից։ Պատվերը նորից կսպասի նշանակման։",
"unassigned": "Աշխատակիցը հանված է",
"gapHint": "Ճանապարհի ընդմիջում պատվերների միջև՝ {{min}} րոպե։",
"noStaff": "Աշխատակիցներ դեռ չկան․ ավելացրեք «Աշխատակիցներ» բաժնում։",
"dayLoad": "այդ օրը՝ {{n}}/{{max}}",
"weekLoad": "շաբաթում՝ {{n}}",
"district": "շրջան",
"check": {
"ok": "Ազատ",
"busy": "Զբաղված",
"gap": "Չի հասցնի",
"limit": "Օրական սահմանաչափ",
"notLinked": "Բոտ չի մտել",
"gap_confirm": "Պատվերների միջև ճանապարհի ժամանակը սահմանվածից քիչ է։ Միևնույն է նշանակե՞լ։",
"limit_confirm": "Աշխատակիցն այդ օրվա պատվերների կամ ժամերի սահմանաչափին է հասել։ Միևնույն է նշանակե՞լ։",
"notLinked_confirm": "Աշխատակիցը դեռ չի մտել աշխատանքային բոտ և ծանուցում չի ստանա։ Նշանակե՞լ։",
"busy_confirm": "Աշխատակիցն այդ ժամին զբաղված է։"
},
"addStaff": "Ավելացնել աշխատակից",
"todayLoad": "այսօր՝ {{n}}/{{max}}",
"hoursMax": "օրական մինչև {{n}} ժ",
"off": "անջատված",
"staffAdded": "Աշխատակիցն ավելացված է",
"saved": "Պահպանված է",
"inviteText": "Ուղարկեք այս հղումը աշխատակցին․ նա կբացի աշխատանքային բոտը և կկարողանա ընդունել պատվերներ։",
"done": "Պատրաստ է",
"f": {
"name": "Անուն",
"phone": "Հեռախոս",
"languages": "Լեզուներ",
"districts": "Շրջաններ (դատարկ՝ ցանկացած)",
"days": "Աշխատանքային օրեր",
"from": "Սկսած",
"to": "Մինչև",
"maxOrders": "Օրական պատվերներ, առավ.",
"maxHours": "Օրական ժամեր, առավ.",
"loadHint": "Դատարկ՝ առանց սահմանաչափի։ Ավտոբաշխումն ավելին չի տա։"
},
"save": "Պահպանել",
"resetInvite": "Մուտքի նոր հղում",
"resetConfirm": "Ստեղծե՞լ նոր հղում։ Ընթացիկ մուտքը բոտ կանջատվի։",
"turnOff": "Անջատել",
"turnOn": "Միացնել",
"offConfirm": "Անջատե՞լ {{name}}-ին։ Նոր պատվերներ չի ստանա։",
"copy": "Պատճենել",
"copied": "Պատճենված է",
"balance": "Վճարման ենթակա",
"accrued": "Հաշվեգրված",
"paidOut": "Վճարված",
"terms": {
"COMMISSION": "Պայմաններ․ Barev Club-ը վերցնում է վճարված պատվերի {{pct}}%-ը, մնացածը ձերն է։ Աշխատավարձն ու ծախսերը ձեզ վրա են։",
"PROFIT_SHARE": "Պայմաններ․ մաքրողի աշխատավարձը ({{fot}}%) և հարկը ({{tax}}%) վճարում է Barev Club-ը, ձեզ՝ մնացորդի {{pct}}%-ը։"
},
"byMonth": "Ըստ ամիսների",
"ordersN": "պատվերներ՝ {{n}}",
"byOrder": "Ըստ պատվերների",
"noIncome": "Եկամուտը կհայտնվի, երբ հաճախորդը վճարի կատարված պատվերի համար։",
"paidBy": "վճարված՝ {{sum}}",
"minus": "−աշխ. {{fot}}, −հարկ {{tax}}, ձեր {{pct}}%",
"minusCommission": "−{{pct}}% Barev Club",
"payouts": "Վճարումներ",
"payout": {
"paid": "վճարված",
"pending": "սպասում է"
},
"auto": "Ավտոբաշխում",
"autoHint": "Նոր պատվերն առաջարկվում է լավագույն ազատ աշխատակցին՝ ազատ է ըստ գրաֆիկի, հասցնում է տեղ հասնել, սահմանաչափը չի գերազանցված․ նախապատվությունը՝ ով գիտի հաճախորդի լեզուն, աշխատում է շրջանում, ունի քիչ պատվեր շաբաթում և բարձր վարկանիշ։ Հրաժարվեց կամ չպատասխանեց՝ հաջորդին։",
"gap": "Ճանապարհի ընդմիջում պատվերների միջև, րոպե",
"gapMin": "Առնվազն {{min}} րոպե",
"timeoutHint": "Եթե պատվերը {{min}} րոպեում չնշանակվի, այն կվերադառնա Barev Club։",
"managers": "Մենեջերներ",
"you": "դուք",
"remove": "Հեռացնել",
"removeManagerConfirm": "Հեռացնե՞լ {{name}}-ին մենեջերներից։",
"managerInvite": "Հղում ևս մեկ մենեջերի համար․ նա կբացի աշխատանքային բոտը և մուտք կստանա կաբինետ․",
"logout": "Ելք այս համակարգչում",
"wd": {
"0": "Երկ",
"1": "Երք",
"2": "Չրք",
"3": "Հնգ",
"4": "Ուրբ",
"5": "Շբթ",
"6": "Կիր"
}
}
},
"bot": {
"client": {
"chooseLanguage": "Выбери язык · Choose your language · 选择语言 · Ընտրեք լեզուն · Elige tu idioma",
"languageSet": "Լեզու՝ {{language}}",
"fromAgency": "Բարև՛։ Դուք եկել եք {{agency}} գործակալությունից՝ ձեզ համար նվեր ունենք։",
"fromAgency": "Բարև՛։ Դուք եկել եք {{agency}} անշարժ գույքի գործակալությունից՝ ձեզ համար նվեր ունենք։",
"consentText": "Barev Club-ից օգտվելու համար անհրաժեշտ է անձնական տվյալների մշակման համաձայնություն։ Տվյալները ստանում են ձեր պատվերի կատարողը և քաղաքականությունում նշված գործընկերները, իսկ պահվում են Գերմանիայում գտնվող սերվերում։\n\n<a href=\"{{url}}\">Գաղտնիության քաղաքականություն</a>",
"consentAccept": "Համաձայն եմ",
"giftActivated": "🎁 Նվեր գործակալությունից՝ Barev+-ն ակտիվ է մինչև {{date}}։ Զեղչերն արդեն կիրառվում են։",
"giftActivated": "🎁 Նվեր անշարժ գույքի գործակալությունից՝ {{plan}}-ն ակտիվ է մինչև {{date}}։ Զեղչերն արդեն կիրառվում են։",
"welcome": "Երևանը՝ ինչպես տանը։ Առաջին օրվանից։\n\nՊատվիրեք մաքրում և այլ ծառայություններ, հետևեք կարգավիճակին և օգտվեք գործընկերների զեղչերից՝ ամեն ինչ հավելվածում։",
"openApp": "Բացել հավելվածը",
"enterCode": "Մուտքագրել գործակալության կոդը",
"changeLanguage": "Փոխել լեզուն",
"codePrompt": "Ուղարկեք գործակալության կոդը վարձակալության պայմանագրից։",
"codePrompt": "Ուղարկեք անշարժ գույքի գործակալության կոդը վարձակալության պայմանագրից։",
"codeAccepted": "Կոդն ընդունված է՝ {{agency}}։",
"help": "Ծառայություն պատվիրելու համար սեղմեք «Բացել հավելվածը»։ Հրամաններ՝ /app — հավելված, /code — գործակալության կոդ, /language — լեզու։",
"help": "Ծառայություն պատվիրելու համար սեղմեք «Բացել հավելվածը»։ Հրամաններ՝ /app — հավելված, /code — անշարժ գույքի գործակալության կոդ, /language — լեզու։",
"cmdStart": "Սկսել",
"cmdApp": "Բացել հավելվածը",
"cmdCode": "Մուտքագրել գործակալության կոդը",
@@ -880,7 +1049,44 @@
"cmdSupport": "Գրել աջակցությանը",
"cmdConcierge": "Գրել կոնսիերժին (Barev+)",
"rated": "Շնորհակալություն գնահատականի համար։ {{stars}}",
"ratedLow": "Ափսոս, որ ամեն ինչ լավ չանցավ։ Գրիր այստեղ, թե ինչ է պատահել, թիմը կզբաղվի։"
"ratedLow": "Ափսոս, որ ամեն ինչ լավ չանցավ։ Գրիր այստեղ, թե ինչ է պատահել, թիմը կզբաղվի։",
"wr": {
"intro": "Ձեր №{{request}} հայտը ստացել ենք։ Մնաց մի քանի հարց՝ պատվերը ձևակերպելու համար։",
"askDistrict": "Ո՞ր վարչական շրջանում է բնակարանը։",
"askAddress": "Գրեք հասցեն՝ փողոց, շենք, բնակարան։",
"askDay": "Ե՞րբ է հարմար։ Ընտրեք օրը։",
"askTime": "Ընտրեք ժամը՝ {{day}}․",
"dayFull": "Այդ օրն արդեն զբաղված է։ Ընտրեք այլ օր։",
"noSlots": "Մոտակա օրերին ազատ ժամ չկա․ օպերատորը կկապվի ձեզ հետ և կընտրի ժամը։"
},
"cmdBalance": "Բարիկների մնացորդ",
"cmdHistory": "Բարիկների պատմություն",
"points": {
"balance": "🪙 Բարիկներ՝ {{balance}} (≈ {{balance}} ֏)",
"pending": "⏳ Սպասման մեջ՝ {{pending}}, հասանելի կլինեն {{date}}",
"burn": "🔥 Կայրվեն {{date}}, եթե պատվերներ չլինեն",
"empty": "Բարիկներով գործողություններ դեռ չկան։",
"open": "Բացել բարիկները",
"type": {
"accrual": "Քեշբեք",
"bonus": "Բոնուս",
"redemption": "Վճարում բարիկներով",
"request": "Վճարման հայտ",
"reversal": "Չեղարկում"
},
"status": {
"PENDING": "սպասման մեջ",
"ACTIVE": "հասանելի",
"SPENT": "ծախսված",
"EXPIRED": "այրված",
"REVERSED": "չեղարկված",
"REQUESTED": "սպասում է հաստատման",
"CONFIRMED": "դուրս է գրված",
"REJECTED": "մերժված",
"ACCRUAL": "հաշվեգրումը չեղարկված է",
"REDEMPTION": "բարիկները վերադարձվել են"
}
}
},
"pro": {
"notLinked": "Այս բոտը Barev Club-ի կատարողների համար է։ Խնդրեք օպերատորից հրավերի հղում։",
@@ -890,7 +1096,9 @@
"youAreDispatcher": "«{{company}}» ընկերության դիսպետչեր",
"welcome": "Այստեղ գալիս են նոր պատվերները։ Ընդունեք դրանք հաղորդագրության կոճակներով, իսկ օրվա պատվերները վարեք անձնական էջում։",
"openCabinet": "Բացել անձնական էջը",
"messageReceived": "Փոխանցեցինք օպերատորին՝ պատասխանը կգա այստեղ։"
"messageReceived": "Փոխանցեցինք օպերատորին՝ պատասխանը կգա այստեղ։",
"openWeb": "💻 Բացել համակարգչում",
"webLink": "Կաբինետի հղումը բրաուզերի համար (մեկանգամյա, գործում է 15 րոպե)․\n{{url}}"
},
"admin": {
"hello": "{{name}}, այստեղ գալիս են Barev Club ադմինկայի ծանուցումները։",
@@ -916,7 +1124,30 @@
"chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}",
"order": "Պատվեր №{{number}}",
"noOrder": "Այժմ պատվեր չկա",
"autoFailed": "№{{number}} պատվերի համար ազատ կատարող չգտնվեց․ նշանակեք ձեռքով։"
"autoFailed": "№{{number}} պատվերի համար ազատ կատարող չգտնվեց․ նշանակեք ձեռքով։",
"partnerDeclined": "«{{partner}}» գործընկերը հրաժարվեց №{{number}} պատվերից․ նշանակեք նորից",
"partnerExpired": "«{{partner}}» գործընկերը ժամանակին աշխատակից չնշանակեց №{{number}} պատվերին․ նշանակեք նորից",
"noTime": "ժամը նշված չէ",
"noAddress": "հասցեն նշված չէ",
"points": {
"forOrder": "Order #{{number}} for {{sum}}",
"confirm": "✅ Confirm",
"reject": "Decline",
"request": "Client asks to pay with bariki: {{amount}}",
"balance": "balance {{balance}}",
"suggestions": "New bariki suggestions: {{count}} ({{open}} in the queue)",
"overLimit": "{{by}} wants to process {{amount}} bariki — above the limit, needs your approval",
"mismatch": "Bariki reconciliation: {{count}} clients had a mismatched balance — fixed, check the log",
"confirmed": "Redeemed {{amount}} bariki",
"rejected": "Request declined",
"help": "Credit for an order: /barik <phone or @username> <order amount> [category]\nCategories: cleaning, documents, learning, repair, transfer",
"noClient": "Client not found",
"preview": "Credit {{amount}} bariki to {{name}} for {{what}} {{sum}} ֏ ({{tier}}, {{rate}}%). Available from {{date}}.",
"accrue": "🪙 Credit",
"done": "Credited {{amount}} bariki",
"needsApproval": "Above the limit — sent to a super admin for approval",
"zero": "The amount is 0 — nothing to credit"
}
},
"partner": {
"valid": "{{name}} — {{level}}, քարտը գործում է",
@@ -1011,34 +1242,58 @@
"rescheduled": "Պատվերը տեղափոխված է՝ {{when}}։",
"unassigned": "{{when}}-ի պատվերը հանվել է ձեզնից։",
"reminder24": "Վաղը պատվեր՝ {{when}}։",
"reminder1": "Մեկ ժամից պատվեր՝ {{when}}։"
"reminder1": "Մեկ ժամից պատվեր՝ {{when}}։",
"orderEdited": "{{when}} պատվերը փոխվել է․ բացեք այն։"
},
"dispatcher": {
"newOrder": "Նոր պատվեր ընկերության համար՝ {{when}}։ Նշանակեք աշխատակցի անձնական էջում։",
"returned": "Աշխատակիցը հրաժարվեց {{when}}-ի պատվերից։ Նշանակեք մեկ ուրիշին։"
"returned": "Աշխատակիցը հրաժարվեց {{when}}-ի պատվերից։ Նշանակեք մեկ ուրիշին։",
"noStaff": "{{when}} պատվերի համար ազատ աշխատակից չկա․ նշանակեք ձեռքով կաբինետում։",
"expired": "{{when}} պատվերը վերադարձավ Barev Club՝ աշխատակից ժամանակին չնշանակվեց։"
},
"openPlan": "Բացել հավելվածը",
"concierge": "Barev Club կոնսիերժ",
"subscriptionActivated": "{{plan}}-ը միացված է մինչև {{date}}։ Զեղչերն արդեն ծառայությունների գներում են։",
"operator": "Barev Club օպերատոր",
"support": "Barev Club աջակցություն",
"rateAsk": "Ինչպե՞ս անցավ։ Խնդրում ենք գնահատել՝ մեկ վայրկյան է։"
"rateAsk": "Ինչպե՞ս անցավ։ Խնդրում ենք գնահատել՝ մեկ վայրկյան է։",
"points": {
"open": "Իմ բարիկները",
"pending": "🪙 +{{amount}} բարիկ՝ {{what}}, {{date}}։ Հասանելի կլինեն {{available}}։",
"active": "🪙 +{{amount}} բարիկ՝ {{what}}։ Մնացորդ՝ {{balance}}։",
"bonus": "🎁 +{{amount}} բարիկ նվեր՝ {{what}}։ Գործում են մինչև {{until}}։",
"bonusNoTtl": "🎁 +{{amount}} բարիկ նվեր՝ {{what}}։",
"housewarming": "բնակարանամուտի առթիվ {{agency}}-ից",
"activated": "✅ {{amount}} բարիկ հասանելի է։ Մնացորդ՝ {{balance}}։",
"redeemed": "Վճարված է բարիկներով՝ {{amount}}։ Մնաց վճարել՝ {{due}}։ Մնացորդ՝ {{balance}}։",
"redeemedNoOrder": "Դուրս է գրվել՝ {{amount}} բարիկ։ Մնացորդ՝ {{balance}}։",
"requestExpired": "Բարիկներով վճարման հայտի ժամկետը լրացել է։ Անհրաժեշտության դեպքում ուղարկեք նորը։",
"rejected": "Բարիկներով վճարման հայտը մերժվել է․ {{reason}}",
"declinedGeneric": "գրեք մեզ զրույցում, կքննարկենք։",
"orderCancelled": "պատվերը չեղարկվել է",
"returned": "↩️ {{amount}} բարիկը վերադարձվել է հաշվին։ Մնացորդ՝ {{balance}}։",
"reversed": "{{what}} հաշվեգրումը չեղարկվել է․ {{reason}}։ Մնացորդ՝ {{balance}}։",
"expiring": "⏰ {{days}} օրից կայրվի {{amount}} բարիկ։ Ցանկացած պատվեր կպահի դրանք։",
"expired": "{{amount}} բարիկ այրվեց՝ երկար ժամանակ պատվերներ չկային։",
"orderEdited": "պատվերը փոխվել է․ կհաշվեգրենք նոր գումարով"
},
"orderEdited": "Պատվերը փոխվել է։ Վճարման ենթակա՝ {{total}}։"
},
"admin": {
"nav": {
"orders": "Պատվերներ",
"clients": "Հաճախորդներ",
"providers": "Կատարողներ",
"companies": "Ընկերություններ",
"companies": "Ծառայությունների գործընկերներ",
"services": "Ծառայություններ և գներ",
"agencies": "Գործակալություններ",
"agencies": "Անշարժ գույքի գործակալություններ",
"settings": "Կարգավորումներ",
"presets": "Դրույքներ",
"webRequests": "Կայքի հայտեր",
"leads": "Տեղափոխություն",
"concierge": "Կոնսիերժ",
"requests": "Հայտեր հավելվածից",
"partners": "Գործընկերներ",
"partners": "Ակումբի գործընկերներ",
"telegram": "Ծանուցումներ",
"templates": "Տեղափոխության ձևանմուշներ"
},
@@ -1177,7 +1432,10 @@
"finishLesson": "Դասն ավարտված է",
"openMeeting": "Հանդիպումը բացված է",
"startMeeting": "Հանդիպումը սկսված է",
"finishMeeting": "Հանդիպումն ավարտված է"
"finishMeeting": "Հանդիպումն ավարտված է",
"editedByStaff": "Թիմը փոխել է կազմը",
"checklistByStaff": "Թիմը նշել է կետը",
"checklistUncheckedByStaff": "Թիմը հանել է նշումը"
},
"weekdays": {
"0": "Երկ",
@@ -1256,7 +1514,7 @@
"eliteDiscount": "Elite զեղչ"
},
"agencies": {
"add": "Ավելացնել գործակալություն",
"add": "Ավելացնել անշարժ գույքի գործակալություն",
"name": "Անվանում",
"code": "Կոդ",
"commission": "Վարձատրություն, %",
@@ -1289,7 +1547,7 @@
"cancel_freeHours": "Անվճար չեղարկում ոչ ուշ, քան, ժ առաջ",
"agency_attributionMonths": "Հաճախորդը կապված է գործակալությանը, ամիս",
"agency_defaultCommissionPct": "Գործակալության վարձատրություն ըստ նախնականի, %",
"barevPlus_giftDays": "Նվեր Barev+, օր",
"barevPlus_giftDays": "Լենդինգ․ «առաջին N օրը նվեր» (նվերը սահմանվում է յուրաքանչյուր գործակալությունում)",
"barevPlus_monthlyPrice": "Barev+-ի ամսական գին, ֏",
"offer_acceptTimeoutMinutes": "Կատարողի պատասխանի ժամանակ, րոպե",
"chat_closeAfterHours": "Չատը փակվում է պատվերից հետո, ժ",
@@ -1321,7 +1579,23 @@
"sales_bonusPerVenue": "Վաճառողի բոնուս հաստատության համար, ֏",
"sales_bonusPerAgency": "Վաճառողի բոնուս գործակալության համար, ֏",
"sales_bonusAgencyPct": "Վաճառողի բոնուս՝ % գործակալությունների հաճախորդների եկամտից",
"sales_bonusAgencyMonths": "…քանի ամիս գործակալության միանալուց հետո"
"sales_bonusAgencyMonths": "…քանի ամիս գործակալության միանալուց հետո",
"partner_fotPct": "Գործընկերներ (ծախսերը մերն են)․ մաքրողի աշխատավարձ, պատվերի %",
"partner_taxPct": "Գործընկերներ (ծախսերը մերն են)․ հարկ, %",
"partner_minTravelGapMinutes": "Գործընկերներ․ աշխատակցի պատվերների միջև նվազագույն ընդմիջում, րոպե",
"partner_acceptTimeoutMinutes": "Գործընկերներ․ քանի րոպեում նշանակել աշխատակից, այլապես պատվերը կվերադառնա",
"points_pendingHours": "Բարիկներ․ հասանելի են ծառայությունից N ժամ հետո",
"points_maxSharePct": "Բարիկներ․ մինչև պատվերի N%-ը",
"points_minRedeem": "Բարիկներ․ նվազագույնը մեկ դուրսգրման համար",
"points_burnDays": "Բարիկներ․ այրվում են N օր անգործությունից հետո",
"points_warnDays1": "Բարիկներ․ առաջին զգուշացումը N օր առաջ",
"points_warnDays2": "Բարիկներ․ երկրորդ զգուշացումը N օր առաջ",
"points_requestTtlHours": "Բարիկներ․ հաճախորդի հայտը սպասում է N ժամ",
"points_streakGapDays": "Բարիկներ․ մաքրումների շարք՝ մինչև N օր ընդմիջում",
"points_diffHighlightPct": "Բարիկներ․ ընդգծել ձեռքով գումարը, եթե տարբերվում է N%-ից ավելի",
"points_supportCanManage": "Աջակցությունը նույնպես կարող է հաշվեգրել և դուրս գրել (սահմանաչափերով)",
"points_opMaxPerOperation": "Աջակցության սահմանաչափը մեկ գործողության համար",
"points_opMaxPerDay": "Աջակցության օրական սահմանաչափը"
},
"presets": {
"add": "Ավելացնել նախադրվածք",
@@ -1549,7 +1823,7 @@
"step4_sub": "Չատ կատարողի հետ ներսում"
},
"agency": {
"title": "Բնակարանը վարձե՞լ եք գործընկեր գործակալության միջոցով",
"title": "Բնակարանը վարձե՞լ եք գործընկեր անշարժ գույքի գործակալության միջոցով",
"text": "Barev+-ի մեկ ամսվա կոդն արդեն պայմանագրում է։",
"gift": "Նվեր։",
"giftSub": "Barev+-ի 1 ամիս"
@@ -1571,7 +1845,7 @@
"a": "Մինչև {{pct}}% զեղչեր փաստաթղթերի, կենցաղի, ուսման և կարիերայի խորհրդատվության համար։"
},
"gift": {
"q": "Ինչպե՞ս ստանալ գործակալության նվերը",
"q": "Ինչպե՞ս ստանալ անշարժ գույքի գործակալության նվերը",
"a": "Կոդը նշված է վարձակալության պայմանագրում։ Մուտքագրեք այն բոտում՝ և Barev+-ի {{days}} օրը ձերն է։"
}
},
@@ -1580,7 +1854,11 @@
"partner": "Դառնալ գործընկեր",
"privacy": "Գաղտնիության քաղաքականություն",
"legal": "Ա/Ձ Զաբուդկո Յուրի Կոնստանտինի · ՀՎՀՀ {{tin}}",
"services": "Ծառայություններ"
"services": "Ծառայություններ",
"pricing": "Գներ",
"terms": "Օգտագործման պայմաններ",
"refund": "Չեղարկում և վերադարձ",
"rules": "Ծառայության կանոններ"
},
"clean": {
"meta": {
@@ -1646,7 +1924,11 @@
"how2": "Հաստատում ենք Telegram-ում",
"how2Sub": "Բոտը ցույց կտա պատվերը և դրա կարգավիճակը",
"how3": "Գալիս է մաքրողը",
"how3Sub": "Աշխատում է ըստ ստուգաթերթի և ուղարկում լուսանկարներով հաշվետվություն"
"how3Sub": "Աշխատում է ըստ ստուգաթերթի և ուղարկում լուսանկարներով հաշվետվություն",
"whenHintOptional": "Ոչ պարտադիր․ եթե չընտրեք, բոտը կհարցնի Telegram-ում։",
"addressHintOptional": "Ոչ պարտադիր․ հասցեն կարող եք գրել բոտին ուղարկելուց հետո։",
"nameTitle": "Ինչպես դիմել ձեզ",
"consentByClick": "Սեղմելով կոճակը՝ համաձայնում եք անձնական տվյալների մշակմանը։"
},
"v1": {
"title": "Երևանում ապրելու համար ամեն ինչ՝ մեկ չատում",
@@ -1687,7 +1969,7 @@
"airportTitle": "Կդիմավորենք օդանավակայանում և կտեղավորենք հյուրանոցում",
"airportText": "Վարորդը ցուցանակով Զվարթնոցում և տրանսֆեր մինչև հյուրանոց՝ առաջին օրերն առանց հոգսերի։",
"flatTitle": "Կգտնենք բնակարան",
"flatText": "Կընտրենք բնակարան գործընկեր գործակալությունների միջոցով, իսկ տեղափոխվելուց առաջ կմաքրենք։",
"flatText": "Կընտրենք բնակարան գործընկեր անշարժ գույքի գործակալությունների միջոցով, իսկ տեղափոխվելուց առաջ կմաքրենք։",
"docsTitle": "Կանենք գրանցումը և սոցքարտը",
"docsText": "Կպատրաստենք փաստաթղթերը, կգրանցենք և կուղեկցենք մինչև պատրաստի քարտը։",
"permitTitle": "Կօգնենք ԿՔ-ի փաստաթղթերով",
@@ -1700,9 +1982,9 @@
"chatCta": "Գրել չատում"
},
"v3": {
"tag": "Գործընկեր գործակալությունների վարձակալների համար",
"tag": "Գործընկեր անշարժ գույքի գործակալությունների վարձակալների համար",
"title": "Բարի գալուստ տուն",
"sub": "Գործակալությունը, որի միջոցով վարձել ես բնակարանը, նվիրում է Barev+-ի մեկ ամիս՝ զեղչեր մաքրման, փաստաթղթերի և դասերի համար։",
"sub": "Անշարժ գույքի գործակալությունը, որի միջոցով վարձել ես բնակարանը, նվիրում է Barev+ բաժանորդագրություն՝ զեղչեր մաքրման, փաստաթղթերի և դասերի համար։",
"giftTitle": "Barev+-ի 1 ամիս",
"giftCode": "{{code}} կոդը կկիրառվի ինքնաբերաբար",
"giftNoCode": "Կոդը վարձակալության պայմանագրում է",
@@ -1756,14 +2038,14 @@
"perYear": "Տարեկան՝ {{amount}}",
"other": "Բաժանորդագրությունը գործում է նաև այլ ուղղությունների համար",
"cta": "Փորձել Barev+",
"gift": "Վարձե՞լ ես գործընկեր գործակալության միջոցով։ Առաջին ամիսը նվեր է։"
"gift": "Վարձե՞լ ես գործընկեր անշարժ գույքի գործակալության միջոցով։ Բաժանորդագրությունը նվեր է։"
},
"v6": {
"tag": "Անշարժ գույքի գործակալությունների համար",
"title": "Պայմանագիր այն մասին, թե ինչպես են ձեր վարձակալները տեղավորվում Երևանում",
"sub": "Դուք հաճախորդներին նվիրում եք Barev+-ի մեկ ամիս, մենք օգնում ենք նրանց կենցաղում, իսկ դուք ստանում եք տոկոս նրանց պատվերներից։",
"docDate": "Երևան",
"s1": "Ինչ է ստանում գործակալությունը",
"s1": "Ինչ է ստանում անշարժ գույքի գործակալությունը",
"s1i1": "Հաճախորդի յուրաքանչյուր պատվերի {{pct}}%-ը բոտ առաջին մուտքից հետո {{months}} ամսվա ընթացքում։",
"s1i2": "Ձեր կոդը և QR-ը՝ կարելի է ավելացնել վարձակալության պայմանագրում կամ փոխանցել բանալիների հետ։",
"s1i3": "Հաշվետվություն հաճախորդների և պատվերների մասին՝ քանիսն եկան, քանիսը պատվիրեցին և որքան վաստակեցիք։",
@@ -1779,7 +2061,7 @@
"exampleText": "{{clients}} հաճախորդ × ամսական {{orders}} մաքրում × {{price}}",
"exampleResult": "{{amount}} գործակալությանը ամսական",
"exampleNote": "Հաշվարկը՝ երկսենյականոց բնակարանի ընթացիկ մաքրման համար։",
"signAgency": "Գործակալություն",
"signAgency": "Անշարժ գույքի գործակալություն",
"signUs": "Barev Club",
"cta": "Դառնալ գործընկեր"
},
@@ -2315,7 +2597,7 @@
},
"privacy": {
"title": "Գաղտնիության քաղաքականություն",
"updated": "Խմբագրություն՝ 2026 թվականի հոկտեմբերի 20",
"updated": "2026 թ. հոկտեմբերի 22-ի խմբագրություն",
"intro": "Այստեղ գրված է, թե Barev Club-ը ինչ անձնական տվյալներ է հավաքում, ինչու, ում է փոխանցում և ինչպես կարող եք դրանք կառավարել։ Քաղաքականությունը գործում է barevclub.com կայքի, Barev Club-ի Telegram բոտերի և մինի հավելվածների համար։",
"operator": {
"title": "Ով է մշակում տվյալները",
@@ -2323,7 +2605,7 @@
},
"data": {
"title": "Ինչ տվյալներ ենք ստանում",
"body": "• Telegram-ից՝ ID, օգտանուն, պրոֆիլի անուն և ազգանուն, լեզու։ Հեռախոսահամարը՝ միայն եթե ինքներդ եք այն կիսել։\n• Կայքի հայտեր՝ անուն, հեռախոս կամ Telegram օգտանուն, տեղափոխության ընտրության պատասխաններ (ժամկետներ, անհրաժեշտ ծառայություններ, բյուջե)։ Մաքրման համար՝ հասցե, թաղամաս, ցանկալի ժամ և մեկնաբանություն։\n• Պատվերներ՝ հասցե և թաղամաս, քարտեզի կետ (եթե նշել եք), ծառայության պարամետրեր, ամսաթիվ և ժամ, գին, զեղչ, վճարման կարգավիճակ, մեկնաբանություններ։\n• Փաստաթղթեր՝ անձնագրի և այլ փաստաթղթերի պատճեններ, որոնք վերբեռնում եք «Փաստաթղթեր» ուղղության ծառայությունների համար։\n• Կատարողի հետ նամակագրություն բոտի միջոցով, լուսանկարային հաշվետվություններ, գնահատականներ և կարծիքներ։\n• Անդամակցություն՝ մակարդակ (Barev, Barev+, Elite), բաժանորդագրության ժամկետներ, ակումբային քարտի կոդ և գործընկերների մոտ դրա օգտագործման նշումներ։\n• Աղբյուր՝ գործակալություն կամ պրոմոկոդ, որով եկել եք, և գովազդային արշավների նշիչներ (UTM)։\n• Տեխնիկական տվյալներ՝ կայքի լեզվով cookie, գործակալության կոդը դիտարկչի հիշողությունում և սերվերի մատյաններ (IP հասցե, հարցման ժամ և հասցե)՝ խափանումներից և հարձակումներից պաշտպանվելու համար։\nԲանկային քարտերի տվյալներ չենք ստանում։ Առցանց վճարման դեպքում քարտի տվյալները մուտքագրվելու են վճարային մատակարարի կողմում։"
"body": "• Telegram-ից՝ ID, օգտանուն, պրոֆիլի անուն և ազգանուն, լեզու։ Հեռախոսահամարը՝ միայն եթե ինքներդ եք այն կիսել։\n• Կայքի հայտեր՝ անուն, հեռախոս կամ Telegram օգտանուն, տեղափոխության ընտրության պատասխաններ (ժամկետներ, անհրաժեշտ ծառայություններ, բյուջե)։ Մաքրման համար՝ հասցե, թաղամաս, ցանկալի ժամ և մեկնաբանություն։\n• Պատվերներ՝ հասցե և թաղամաս, քարտեզի կետ (եթե նշել եք), ծառայության պարամետրեր, ամսաթիվ և ժամ, գին, զեղչ, վճարման կարգավիճակ, մեկնաբանություններ։\n• Փաստաթղթեր՝ անձնագրի և այլ փաստաթղթերի պատճեններ, որոնք վերբեռնում եք «Փաստաթղթեր» ուղղության ծառայությունների համար։\n• Կատարողի հետ նամակագրություն բոտի միջոցով, լուսանկարային հաշվետվություններ, գնահատականներ և կարծիքներ։\n• Անդամակցություն՝ մակարդակ (Barev, Barev+, Elite), բաժանորդագրության ժամկետներ, ակումբային քարտի կոդ և գործընկերների մոտ դրա օգտագործման նշումներ։\n• Աղբյուր՝ գործակալություն կամ պրոմոկոդ, որով եկել եք, և գովազդային արշավների նշիչներ (UTM)։\n• Տեխնիկական տվյալներ՝ կայքի լեզվով cookie, գործակալության կոդը դիտարկչի հիշողությունում և սերվերի մատյաններ (IP հասցե, հարցման ժամ և հասցե)՝ խափանումներից և հարձակումներից պաշտպանվելու համար։\nԲանկային քարտերի տվյալներ չենք ստանում․ առցանց վճարման դեպքում դրանք մուտքագրվում են մեր առցանց վերավաճառող Paddle-ի էջում։"
},
"purposes": {
"title": "Ինչի համար ենք օգտագործում",
@@ -2335,7 +2617,7 @@
},
"recipients": {
"title": "Ում ենք փոխանցում տվյալները",
"body": "Մենք անձնական տվյալներ չենք վաճառում և փոխանցում ենք միայն այն, ինչ անհրաժեշտ է կոնկրետ խնդրի համար․\n• Ձեր պատվերի կատարողին, իսկ եթե պատվերը կատարում է գործընկեր ընկերությունը՝ նաև նրա դիսպետչերին՝ անուն, լեզու, հասցե և պատվերի մանրամասներ։ Ձեր հեռախոսահամարը կատարողին չի ցուցադրվում, շփումը բոտի միջոցով է։\n• Ձեզ բերած անշարժ գույքի գործակալությանը՝ անուն, արդյոք օգտվում եք ակումբից, և վճարված պատվերների գումարները՝ գործակալության պարգևատրումը հաշվելու համար։\n• Ակումբի գործընկերոջը, երբ ցույց եք տալիս ակումբային քարտը՝ անուն և անդամակցության մակարդակ։\n• Telegram-ին. նրա միջոցով են աշխատում բոտերն ու մինի հավելվածները, և Telegram-ը տվյալները մշակում է իր քաղաքականությամբ։\n• Hostinger International-ին. նրա սերվերներում են պահվում տվյալների բազան և ֆայլերը։\n• Վճարային մատակարարին, երբ հայտնվի առցանց վճարումը։\n• Պետական մարմիններին, երբ պատվիրում եք փաստաթղթային ծառայություն, և մենք դրանք ներկայացնում ենք ձեր անունից, կամ երբ դա պահանջում է օրենքը։"
"body": "Մենք անձնական տվյալներ չենք վաճառում և փոխանցում ենք միայն այն, ինչ անհրաժեշտ է կոնկրետ խնդրի համար․\n• Ձեր պատվերի կատարողին, իսկ եթե պատվերը կատարում է գործընկեր ընկերությունը՝ նաև նրա դիսպետչերին՝ անուն, լեզու, հասցե և պատվերի մանրամասներ։ Ձեր հեռախոսահամարը կատարողին չի ցուցադրվում, շփումը բոտի միջոցով է։\n• Ձեզ բերած անշարժ գույքի գործակալությանը՝ անուն, արդյոք օգտվում եք ակումբից, և վճարված պատվերների գումարները՝ գործակալության պարգևատրումը հաշվելու համար։\n• Ակումբի գործընկերոջը, երբ ցույց եք տալիս ակումբային քարտը՝ անուն և անդամակցության մակարդակ։\n• Telegram-ին. նրա միջոցով են աշխատում բոտերն ու մինի հավելվածները, և Telegram-ը տվյալները մշակում է իր քաղաքականությամբ։\n• Hostinger International-ին. նրա սերվերներում են պահվում տվյալների բազան և ֆայլերը։\n• Paddle.com-ին՝ մեր առցանց վերավաճառողին, որն ընդունում է առցանց վճարումները՝ անուն, email, երկիր և վճարման տվյալներ։ Paddle-ը մշակում է այդ տվյալները որպես ինքնուրույն օպերատոր՝ իր գաղտնիության քաղաքականությամբ։\n• Պետական մարմիններին, երբ պատվիրում եք փաստաթղթային ծառայություն, և մենք դրանք ներկայացնում ենք ձեր անունից, կամ երբ դա պահանջում է օրենքը։"
},
"location": {
"title": "Որտեղ են պահվում տվյալները",
@@ -2369,5 +2651,151 @@
"title": "Քաղաքականության փոփոխություններ",
"body": "Կարող ենք թարմացնել քաղաքականությունը։ Գործող խմբագրությունը միշտ այս էջում է, ամսաթիվը նշված է սկզբում։ Էական փոփոխությունների մասին կտեղեկացնենք բոտում։\nՀիմնական տեքստը ռուսերենն է, թարգմանությունները արված են հարմարության համար։"
}
},
"pricing": {
"title": "Գներ",
"updated": "Գործում են 2026 թ. հոկտեմբերի 22-ից",
"intro": "Որքան արժեն Barev Club-ի բաժանորդագրությունը և ակումբի ծառայությունները։ Ծառայություններ կարելի է պատվիրել նաև առանց բաժանորդագրության․ այն միայն ավելացնում է զեղչեր և կոնսիերժ։ Բոլոր գները հայկական դրամով են (֏)։",
"from": "{{price}}-ից",
"perDay": "{{price}} մեկ գիշերվա համար",
"plansTitle": "Ակումբի բաժանորդագրություն",
"plansNote": "Բաժանորդագրությունը վճարվում է ամսական և երկարաձգվում է, քանի դեռ չեք չեղարկել այն։ Կարող եք չեղարկել ցանկացած պահի․ այն կգործի մինչև վճարված ամսվա ավարտը։",
"planNote": {
"free": "Գրանցում Telegram բոտում, առանց վճարի և առանց ժամկետի։",
"plus": "Ձևակերպվում է բոտում։ Եթե եկել եք գործընկեր անշարժ գույքի գործակալության միջոցով, բաժանորդագրությունը նվեր է (սովորաբար {{days}} օր կամ ավելի, պայմանները սահմանում է գործակալությունը)։",
"elite": "Ձևակերպվում է ակումբի օպերատորի միջոցով՝ բոտի չատում։"
},
"servicesTitle": "Ծառայություններ",
"servicesNote": "Սրանք բազային գներն են։ Պատվերը հաստատելուց առաջ բոտը ցույց է տալիս ճշգրիտ գինը՝ հաշվի առնելով պարամետրերը և բաժանորդագրության զեղչը։ Հաստատելուց հետո գինը չի փոխվում։",
"rows": {
"cleaning": {
"name": "Բնակարանի մաքրում",
"note": "Կախված է մաքրման տեսակից և սենյակների քանակից"
},
"transferStandard": {
"name": "Տրանսֆեր «Զվարթնոց» օդանավակայանից, Ստանդարտ",
"note": "Մինչև 3 ուղևոր, դիմավորում ցուցանակով, 60 րոպե սպասում"
},
"transferPremium": {
"name": "Տրանսֆեր «Զվարթնոց» օդանավակայանից, Պրեմիում",
"note": "Բիզնես դաս, ջուր, SIM քարտ"
},
"hotel": {
"name": "Հյուրանոց առաջին օրերի համար",
"note": "Գինը Barev+-ով․ առանց բաժանորդագրության՝ {{base}}-ից մեկ գիշերվա համար"
},
"socialCard": {
"name": "Սոցիալական քարտ՝ ամբողջությամբ",
"note": "Անձնագրի թարգմանություն, նոտար, գրանցում, ուղեկցում"
},
"consultation": {
"name": "Խորհրդատվություն կացության քարտի վերաբերյալ",
"note": "Հաշվվում է ուղեկցման արժեքի մեջ"
},
"residence": {
"name": "Կացության քարտի ուղեկցում",
"note": "Հիմք, փաստաթղթեր, դիմում, այց։ 1 տարվա կացության քարտի {{fee}} պետական տուրքը վճարվում է պետությանը առանձին"
}
},
"payTitle": "Վճարում և չեղարկում",
"payBody": "Barev+ բաժանորդագրությունը կարելի է վճարել առցանց քարտով․ վճարումն ընդունում է մեր առցանց վերավաճառող Paddle.com-ը, իսկ բաժանորդագրության վճարը վերադարձվում է պահանջով՝ 14 օրվա ընթացքում։ Ծառայությունները վճարվում են կանխիկ կամ փոխանցումով, եղանակը ընտրվում է պատվերում։ Անվճար չեղարկել կամ տեղափոխել պատվերը կարելի է ծառայության սկսվելուց առնվազն 24 ժամ առաջ։ Մանրամասները՝ օգտագործման պայմաններում և վերադարձի կանոններում։"
},
"terms": {
"title": "Օգտագործման պայմաններ",
"updated": "2026 թ. հոկտեմբերի 22-ի խմբագրություն",
"intro": "Սրանք այն պայմաններն են, որոնցով Barev Club-ը մատուցում է ծառայություններ barevclub.com կայքի, Telegram բոտերի և մինի-հավելվածների միջոցով։ Պատվեր կամ բաժանորդագրություն ձևակերպելով՝ դուք ընդունում եք այս պայմանները։",
"about": {
"title": "Ովքեր ենք մենք և ինչ ենք անում",
"body": "Barev Club-ը ծառայություն է նրանց համար, ովքեր տեղափոխվում են Հայաստան կամ արդեն ապրում են այստեղ․ կենցաղային ծառայություններ (մաքրում, տրանսֆեր օդանավակայանից, հյուրանոց առաջին օրերի համար), օգնություն փաստաթղթերով (սոցիալական քարտ, կացության քարտ), ուսուցում և ակումբի գործընկերների զեղչեր։\nԾառայությունները մատուցում է անհատ ձեռնարկատեր Զաբուդկո Յուրի Կոնստանտինին (Ա/Ձ Զաբուդկո Յուրի Կոնստանտինի), Հայաստանի Հանրապետություն, ՀՎՀՀ {{tin}}։ Կոնտակտներ՝ {{email}}, {{phone}}։\nՊատվերները կատարում են ստուգված կատարողներ՝ մեր աշխատակիցները կամ գործընկեր ընկերությունները։ Արդյունքի համար ձեր առջև պատասխանատու է Barev Club-ը։"
},
"acceptance": {
"title": "Պայմանների ընդունում",
"body": "Պայմանները գործում են այն պահից, երբ դուք կայքում կամ բոտում ձևակերպել եք պատվեր, բաժանորդագրություն կամ հայտ։ Ծառայությունից կարելի է օգտվել 18 տարեկանից։\nԵթե համաձայն չեք պայմաններին, մի ձևակերպեք պատվեր։"
},
"orders": {
"title": "Ինչպես է ձևակերպվում պատվերը",
"body": "• Դուք ընտրում եք ծառայությունը բոտում կամ թողնում եք հայտ կայքում և նշում մանրամասները՝ հասցե, ամսաթիվ, ժամ և այլն։\n• Հաստատելուց առաջ բոտը ցույց է տալիս վերջնական գինը, զեղչը և չեղարկման պայմանները։\n• Պատվերը համարվում է ընդունված, երբ դուք այն հաստատել եք, իսկ մենք նշանակել ենք կատարող։ Այդ և հաջորդ յուրաքանչյուր կարգավիճակի մասին ծանուցում է գալիս։\n• Կատարողի հետ շփումը տեղի է ունենում բոտի չատում։\nԵթե ընտրված ժամին պատվերը կատարել չի հաջողվում, մենք կառաջարկենք այլ ժամ կամ կչեղարկենք պատվերն առանց պահումի։"
},
"prices": {
"title": "Գներ",
"body": "Գները նշված են հայկական դրամով «Գներ» էջում և բոտում։ Մենք կարող ենք փոխել գները, բայց արդեն հաստատված պատվերի գինը չի փոխվում։\nՊետական տուրքերը և վճարները, օրինակ՝ կացության քարտի տուրքը, ծառայության գնի մեջ ներառված չեն, եթե դա ուղղակիորեն նշված չէ։ Դրանց չափը մենք հայտնում ենք նախապես։"
},
"payment": {
"title": "Վճարում",
"body": "Barev+ բաժանորդագրությունը կարելի է վճարել առցանց քարտով։ Առցանց պատվերները մշակում է մեր առցանց վերավաճառող Paddle.com-ը։ Paddle.com-ը բոլոր առցանց պատվերների վաճառողն է (Merchant of Record)․ ընդունում է վճարումը, դուրս է գրում հաշիվը, պատասխանում է վճարումների վերաբերյալ հարցերին և ձևակերպում է վերադարձները։ Քարտի տվյալները մուտքագրվում են Paddle-ի էջում, մենք դրանք չենք ստանում և չենք պահում։\nԾառայությունները (մաքրում, տրանսֆեր, փաստաթղթեր և այլն) առցանց չեն վաճառվում․ դրանք վճարվում են կանխիկ կամ փոխանցումով կատարողին կամ փոխանցումով Barev Club-ի հաշվին՝ եղանակը ընտրվում է պատվերում։\nՊահանջի դեպքում տրամադրում ենք վճարման փաստաթուղթ։"
},
"cancel": {
"title": "Չեղարկում և տեղափոխում",
"body": "Անվճար չեղարկել կամ տեղափոխել պատվերը կարելի է ծառայության սկսվելուց առնվազն 24 ժամ առաջ՝ բոտում կամ օպերատորի հետ չատում։\nԱվելի ուշ չեղարկելու դեպքում պահվում է արժեքի մի մասը։ Դրա չափը նշված է ծառայության քարտում՝ մինչև պատվերի հաստատումը, լռելյայն՝ 30%։\nԵթե պատվերը չեղարկում ենք մենք կամ կատարողը չի եկել, գումարն ամբողջությամբ վերադարձվում է։ Մանրամասները՝ չեղարկման և վերադարձի կանոններում։"
},
"subscription": {
"title": "Barev+ և Barev Elite բաժանորդագրություն",
"body": "• Բաժանորդագրությունը տալիս է զեղչեր ակումբի ծառայությունների համար, գործընկերների նվերներ և կոնսիերժ չատում։ Մակարդակների բովանդակությունն ու գները՝ «Գներ» էջում։\n• Բաժանորդագրությունը վճարվում է ամսական և ինքնաշխատ երկարաձգվում է, քանի դեռ չեք չեղարկել այն։ Երկարաձգման մասին հիշեցնում ենք 3 օր առաջ։\n• Զեղչը կիրառվում է այն պատվերների նկատմամբ, որոնք ձևակերպվել են բաժանորդագրության գործողության ընթացքում։\n• Գործընկեր գործակալության կոդով նվեր ժամանակահատվածը տրվում է մեկ անգամ մեկ հաճախորդին։\n• Բաժանորդագրությունը կարելի է չեղարկել ցանկացած պահի՝ բոտում կամ օպերատորի հետ չատում։ Այն կգործի մինչև վճարված ժամանակահատվածի ավարտը։"
},
"perks": {
"title": "Գործընկերների զեղչեր և նվերներ",
"body": "Ակումբային քարտով զեղչերն ու նվերները տրամադրում են ակումբի գործընկերները՝ սրճարաններ, ռեստորաններ և այլ ընկերություններ։ Յուրաքանչյուր առաջարկի պայմանները նշված են գործընկերների կատալոգում։\nԳործընկերոջ ապրանքների և ծառայությունների համար պատասխանատու է գործընկերը։ Գործընկերների առաջարկները կարող են փոխվել և ավարտվել։"
},
"docs": {
"title": "Փաստաթղթային ծառայություններ",
"body": "Մենք օգնում ենք հավաքել փաստաթղթերը, թարգմանում ենք դրանք, գրանցում և ուղեկցում ենք ձեզ պետական մարմիններում։ Փաստաթղթի կամ կացության քարտի տրամադրման մասին որոշումը կայացնում է պետական մարմինը, և մենք չենք կարող այն երաշխավորել։\nԴուք պատասխանատու եք, որ մեզ փոխանցվող փաստաթղթերն ու տվյալները իսկական և ճշգրիտ են։ Եթե մերժումը կապված է մեր սխալի հետ, մենք այն կուղղենք մեր հաշվին։"
},
"client": {
"title": "Ձեր պարտավորությունները",
"body": "• Նշել ճիշտ տվյալներ՝ հասցե, կոնտակտներ, պատվերի պարամետրեր։\n• Նշանակված ժամին ապահովել մուտք բնակարան և անվտանգ պայմաններ կատարողի աշխատանքի համար։\n• Հարգալից շփվել կատարողների և օպերատորների հետ։\n• Չօգտագործել ծառայությունը ապօրինի նպատակներով։\nԵթե կատարողը ձեր մեղքով չի կարողացել սկսել աշխատանքը (օրինակ՝ հասցեում ոչ ոք չկա), դա համարվում է ուշ չեղարկում։"
},
"quality": {
"title": "Որակ և բողոքներ",
"body": "Եթե արդյունքը ձեզ չի գոհացրել, գրեք պատվերի չատում կատարումից հետո 24 ժամվա ընթացքում և, հնարավորության դեպքում, կցեք լուսանկարներ։ Մենք անվճար կուղղենք թերությունները կամ կվերադարձնենք արժեքի մի մասը կամ ամբողջը։\nԵթե կատարողը վնասել է ձեր գույքը, տեղեկացրեք 24 ժամվա ընթացքում․ մենք կուսումնասիրենք և կփոխհատուցենք հաստատված վնասը։"
},
"liability": {
"title": "Պատասխանատվություն",
"body": "Պատվերի համար մեր պատասխանատվությունը սահմանափակված է դրա արժեքով, բացառությամբ գույքին հաստատված վնասի և այն դեպքերի, երբ օրենքը նման սահմանափակում չի թույլատրում։\nՄենք պատասխանատու չենք այն հանգամանքներով պայմանավորված ուշացումների և չեղարկումների համար, որոնց վրա չենք կարող ազդել՝ պետական մարմինների որոշումներ, չվերթերի ուշացումներ, տարերային աղետներ, Telegram-ի խափանումներ։"
},
"content": {
"title": "Կայքի նյութեր",
"body": "Տեքստերը, դիզայնը, լոգոն և Barev Club անվանումը պատկանում են ծառայության սեփականատիրոջը։ Առանց թույլտվության դրանք պատճենել չի կարելի։"
},
"changes": {
"title": "Պայմանների փոփոխություններ",
"body": "Մենք կարող ենք թարմացնել պայմանները։ Գործող խմբագրությունը միշտ այս էջում է, խմբագրության ամսաթիվը նշված է սկզբում։ Արդեն հաստատված պատվերները կատարվում են նախկին պայմաններով։ Բաժանորդագրության էական փոփոխությունների մասին նախապես կտեղեկացնենք բոտում։"
},
"law": {
"title": "Կիրառելի իրավունք և վեճեր",
"body": "Պայմանների նկատմամբ կիրառվում է Հայաստանի Հանրապետության օրենսդրությունը։ Վեճերը նախ լուծում ենք բանակցությունների միջոցով․ գրեք {{email}} հասցեին, կպատասխանենք 3 աշխատանքային օրվա ընթացքում։ Եթե համաձայնության չգանք, վեճը քննում է Հայաստանի Հանրապետության դատարանը։\nԱյս պայմանները չեն սահմանափակում սպառողի՝ օրենքով սահմանված ձեր իրավունքները։\nՀիմնական տեքստը ռուսերեն է, թարգմանությունները արված են հարմարության համար։"
}
},
"refund": {
"title": "Չեղարկում և վերադարձ",
"updated": "2026 թ. հոկտեմբերի 22-ի խմբագրություն",
"intro": "Երբ և որքան գումար է Barev Club-ը վերադարձնում ծառայությունների և բաժանորդագրության համար, և ինչպես ներկայացնել վերադարձի հարցում։",
"cancelClient": {
"title": "Եթե չեղարկում եք դուք",
"body": "• Ծառայության սկսվելուց 24 ժամ առաջ և ավելի վաղ՝ չեղարկումն ու տեղափոխումն անվճար են, վճարն ամբողջությամբ վերադարձվում է։\n• 24 ժամից պակաս՝ պահվում է արժեքի մի մասը, մնացածը վերադարձվում է։ Պահումի չափը նշված է ծառայության քարտում՝ մինչև պատվերի հաստատումը, լռելյայն՝ 30%։\n• Ծառայությունը սկսվելուց հետո արդեն կատարված մասի արժեքը չի վերադարձվում։"
},
"cancelUs": {
"title": "Եթե չեղարկում ենք մենք",
"body": "Եթե մենք չեղարկել ենք պատվերը, չենք գտել կատարող կամ կատարողը չի եկել, ընտրում եք դուք՝ տեղափոխում հարմար ժամի կամ գումարի ամբողջական վերադարձ։"
},
"quality": {
"title": "Եթե արդյունքը չի գոհացրել",
"body": "Գրեք պատվերի չատում կատարումից հետո 24 ժամվա ընթացքում և, հնարավորության դեպքում, կցեք լուսանկարներ։ Մենք անվճար կուղղենք թերությունները, իսկ եթե դա հնարավոր չէ՝ կվերադարձնենք արժեքի մի մասը կամ ամբողջը՝ կախված նրանից, թե ինչն էր սխալ։"
},
"subscription": {
"title": "Barev+ և Barev Elite բաժանորդագրություն",
"body": "• Բաժանորդագրության ցանկացած վճար ամբողջությամբ վերադարձվում է, եթե խնդրեք դա գանձումից հետո 14 օրվա ընթացքում։\n• Բաժանորդագրությունը կարելի է չեղարկել ցանկացած պահի․ հաջորդ վճարը չի գանձվի, իսկ բաժանորդագրությունը կգործի մինչև վճարված ամսվա ավարտը։\n• Նվեր ժամանակահատվածն անվճար է, դրա համար վերադարձնելու բան չկա։"
},
"nonRefundable": {
"title": "Ինչը չի վերադարձվում",
"body": "• Պետական տուրքերն ու վճարները, որոնք մենք արդեն վճարել ենք ձեր անունից։\n• Ձեր պատվերի համար երրորդ անձանց մոտ մեր արդեն կատարած ծախսերը՝ օրինակ՝ նոտարական թարգմանություն կամ հյուրանոցի ամրագրում՝ հյուրանոցի կանոններով։\n• Ակումբի գործընկերների ապրանքներն ու ծառայությունները․ դրանց համար գործում են գործընկերոջ կանոնները։"
},
"howTo": {
"title": "Ինչպես պահանջել վերադարձ",
"body": "Գրեք բոտի չատում կամ {{email}} հասցեին և նշեք պատվերի կամ բաժանորդագրության համարը և պատճառը։ Կպատասխանենք 3 աշխատանքային օրվա ընթացքում։\nԵթե բաժանորդագրությունը վճարել եք առցանց, վերադարձ կարող եք պահանջել նաև անմիջապես Paddle-ից՝ paddle.net կայքում, որտեղ կգտնեք ձեր վճարումն ու անդորրագիրը։"
},
"timing": {
"title": "Վերադարձի ժամկետներ և եղանակ",
"body": "Գումարը վերադարձվում է նույն եղանակով, որով վճարել եք, վերադարձը հաստատելուց հետո 10 աշխատանքային օրվա ընթացքում։ Քարտին վերադարձը կարող է տևել ևս մի քանի օր՝ կախված բանկից։ Եթե վճարել եք կանխիկ, կվերադարձնենք փոխանցումով կամ կանխիկ՝ ինչպես ձեզ հարմար է։\nՎերադարձի հարցերով՝ {{email}}, {{phone}}։"
}
}
}
+472 -44
View File
@@ -412,7 +412,7 @@
"CANCEL_VIA_OPERATOR": "Услуга уже началась — отменить можно только через оператора.",
"RESCHEDULE_VIA_OPERATOR": "Перенести можно только через оператора.",
"AGENCY_CODE_INVALID": "Такого кода нет. Проверь код в договоре.",
"AGENCY_ALREADY_SET": "Ты уже привязан к другому агентству.",
"AGENCY_ALREADY_SET": "Ты уже привязан к другому риэлторскому агентству.",
"OFFER_NOT_ACTIVE": "Предложение уже неактуально.",
"PROVIDER_BUSY": "Исполнитель занят в это время.",
"ALREADY_ASSIGNED": "Заказ уже назначен.",
@@ -448,7 +448,17 @@
"PROMO_INVALID": "Такого промокода нет",
"PROMO_EXPIRED": "Промокод больше не действует",
"PROMO_USED": "Этот промокод уже использован",
"PROMO_NOT_FOR_SERVICE": "Промокод не действует на эту услугу"
"PROMO_NOT_FOR_SERVICE": "Промокод не действует на эту услугу",
"POINTS_DUPLICATE_ORDER": "За этот заказ барики уже начислены.",
"POINTS_REASON_REQUIRED": "Укажите причину.",
"POINTS_BELOW_MIN": "Списать можно не меньше минимальной суммы.",
"POINTS_NOT_ENOUGH": "Не хватает бариков.",
"POINTS_OVER_SHARE": "Бариками можно оплатить только часть заказа.",
"POINTS_OVER_LIMIT": "Сумма выше вашего лимита — нужно подтверждение суперадмина.",
"POINTS_FINAL": "Эту операцию уже нельзя отменить.",
"POINTS_REQUEST_CLOSED": "Запрос уже обработан или истёк.",
"POINTS_TOO_MANY_REQUESTS": "У вас уже есть запросы на оплату бариками — дождитесь ответа.",
"POINTS_WITH_PROMO": "Барики нельзя использовать вместе с промокодом."
},
"client": {
"nav": {
@@ -473,7 +483,7 @@
"text": "Чтобы оформлять заказы, нам нужно твоё согласие на обработку персональных данных: имени, адреса и деталей заказа. Их получает исполнитель твоего заказа и партнёры, перечисленные в политике, а хранятся они на сервере в Германии.",
"policyLink": "Политика конфиденциальности",
"accept": "Согласен, продолжить",
"giftToast": "Подарок активирован: месяц Barev+"
"giftToast": "Подарок от риэлторского агентства активирован"
},
"home": {
"hello": "Барев!",
@@ -482,7 +492,7 @@
"allOrders": "Все",
"noOrders": "Заявок пока нет. Закажи первую услугу — это 2 минуты.",
"repeatLast": "Повторить",
"enterCode": "Код агентства",
"enterCode": "Код риэлторского агентства",
"plusUntil": "Barev+ активен до {{date}}",
"discount": "−{{pct}}% с Barev+",
"upsellTitle": "Скидки до −{{pct}}% с Barev Elite",
@@ -582,20 +592,20 @@
},
"profile": {
"title": "Профиль",
"fromAgency": "Клиент агентства {{agency}}",
"noAgency": "Без агентства",
"fromAgency": "Клиент риэлторского агентства {{agency}}",
"noAgency": "Без риэлторского агентства",
"membership": "Членство Barev / Barev+",
"codeTitle": "Код агентства",
"codeText": "Снял квартиру через агентство-партнёра? Введи код из договора — подарим месяц Barev+.",
"codeTitle": "Код риэлторского агентства",
"codeText": "Снял квартиру через риэлторское агентство-партнёр? Введи код из договора — получишь подарок от агентства.",
"codePlaceholder": "Например, DOMIK",
"codeAccepted": "Код принят: {{agency}}",
"codeGift": "Код принят: {{agency}}. Barev+ на месяц — твой!",
"codeGift": "Код принят: {{agency}}. Подарок активирован!",
"language": "Язык"
},
"membership": {
"title": "Членство",
"current": "Твой тариф",
"howToGet": "Barev+ можно получить в подарок по коду агентства. Онлайн-подписка появится совсем скоро.",
"howToGet": "Barev+ можно получить в подарок по коду риэлторского агентства. Онлайн-подписка появится совсем скоро.",
"request": "Оформить {{plan}}",
"requested": "Заявка отправлена — оператор свяжется",
"requestHint": "Оператор напишет в Telegram, договорится об оплате и включит подписку.",
@@ -712,6 +722,60 @@
"title": "Клубная карта",
"partners": "Партнёры клуба",
"plans": "Уровни и скидки"
},
"points": {
"title": "Барики",
"balance": "Доступно",
"equals": "бариков ≈ {{amount}} ֏ · 1 барик = 1 ֏",
"pending": "в ожидании: {{amount}}, станут доступны {{date}}",
"burn": "{{amount}} сгорят {{date}} — любой заказ сохранит их",
"pay": "Оплатить бариками",
"enterCode": "Ввести код",
"requestPending": "Запрос на оплату бариками ждёт подтверждения.",
"rates": "Кэшбек по категориям",
"plusWouldGet": "С Barev+ за последние 30 дней было бы ещё +{{amount}} бариков →",
"streak": "Серия уборок",
"streakLeft": "Ещё {{left}} — и кэшбек за уборку +{{bonus}}%. Перерыв между уборками — не больше {{days}} дней.",
"streakMax": "Максимальная серия: +{{bonus}}% к кэшбеку за каждую уборку.",
"route": "Маршрут первого месяца",
"routeText": "Закажите услуги из 3 разных категорий до {{date}} — получите {{amount}} бариков. Сейчас: {{done}} из 3.",
"history": "История",
"empty": "Барики начисляются за каждый оплаченный заказ — кэшбек по ставке вашего уровня.",
"availableFrom": "доступны с {{date}}",
"until": "до {{date}}",
"payHint": "Бариками можно оплатить до {{pct}}% заказа, минимум {{min}}. Команда подтвердит и пришлёт сумму к доплате.",
"forOrder": "Заказ",
"otherOrder": "Другой заказ (не в приложении)",
"amount": "Сколько бариков списать",
"max": "Максимум: {{max}}",
"note": "Комментарий",
"notePh": "Какой заказ и на какую сумму",
"send": "Отправить запрос",
"requested": "Запрос отправлен — ответим в чате.",
"useInOrder": "Списать {{amount}} бариков",
"useHint": "до {{pct}}% заказа · на счёте {{balance}}",
"notWithPromo": "Не вместе с промокодом",
"belowMin": "Списать можно от {{min}} · на счёте {{balance}}",
"paidLine": "Оплачено бариками",
"type": {
"accrual": "Кэшбек",
"bonus": "Бонус",
"redemption": "Оплата бариками",
"request": "Запрос на оплату",
"reversal": "Отмена"
},
"status": {
"PENDING": "в ожидании",
"ACTIVE": "доступно",
"SPENT": "потрачено",
"EXPIRED": "сгорело",
"REVERSED": "отменено",
"REQUESTED": "ждёт подтверждения",
"CONFIRMED": "списано",
"REJECTED": "отклонено",
"ACCRUAL": "начисление отменено",
"REDEMPTION": "барики вернулись"
}
}
},
"pro": {
@@ -793,7 +857,8 @@
"empty": "За этот период заказов нет.",
"ratePercent": "Ставка: {{value}}% от заказа",
"rateFixed": "Ставка: {{value}} за заказ",
"ratePreset": "Ставка: пресет «{{name}}»"
"ratePreset": "Ставка: пресет «{{name}}»",
"ratePartner": "Оплату за заказы назначает ваша компания"
},
"company": {
"title": "Компания",
@@ -849,26 +914,130 @@
"recent": "Последние проверки",
"noRecent": "Проверок ещё не было.",
"checked": "проверка карты"
},
"web": {
"loginHint": "Откройте рабочий бот и нажмите «Открыть на компьютере» (или отправьте /web) — придёт ссылка для входа."
},
"cab": {
"tab": {
"orders": "Заказы",
"staff": "Сотрудники",
"money": "Доход",
"settings": "Настройки"
},
"autoOn": "Автораспределение включено: новые заказы сами уходят свободным сотрудникам.",
"income": "Ваш доход",
"deadline": "назначьте до {{time}}",
"offeredTo": "ждём ответа: {{name}}",
"decline": "Отказаться",
"declineConfirm": "Отказаться от заказа? Он вернётся в Barev Club.",
"declined": "Заказ возвращён",
"reassign": "Переназначить",
"unassignConfirm": "Снять {{name}} с заказа? Заказ снова будет ждать назначения.",
"unassigned": "Сотрудник снят с заказа",
"gapHint": "Перерыв на дорогу между заказами: {{min}} мин.",
"noStaff": "Сотрудников пока нет — добавьте их во вкладке «Сотрудники».",
"dayLoad": "в этот день {{n}}/{{max}}",
"weekLoad": "за неделю {{n}}",
"district": "район",
"check": {
"ok": "Свободен",
"busy": "Занят",
"gap": "Не успеет доехать",
"limit": "Лимит на день",
"notLinked": "Не вошёл в бот",
"gap_confirm": "Между заказами меньше времени на дорогу, чем задано. Всё равно назначить?",
"limit_confirm": "У сотрудника уже лимит заказов или часов на этот день. Всё равно назначить?",
"notLinked_confirm": "Сотрудник ещё не вошёл в рабочий бот и не получит уведомление. Назначить?",
"busy_confirm": "Сотрудник занят в это время."
},
"addStaff": "Добавить сотрудника",
"todayLoad": "сегодня {{n}}/{{max}}",
"hoursMax": "до {{n}} ч в день",
"off": "выключен",
"staffAdded": "Сотрудник добавлен",
"saved": "Сохранено",
"inviteText": "Отправьте сотруднику эту ссылку: он откроет рабочий бот и сможет принимать заказы.",
"done": "Готово",
"f": {
"name": "Имя",
"phone": "Телефон",
"languages": "Языки",
"districts": "Районы (пусто — любые)",
"days": "Рабочие дни",
"from": "С",
"to": "До",
"maxOrders": "Заказов в день, макс.",
"maxHours": "Часов в день, макс.",
"loadHint": "Пусто — без ограничения. Автораспределение не даст сотруднику больше."
},
"save": "Сохранить",
"resetInvite": "Новая ссылка для входа",
"resetConfirm": "Сделать новую ссылку? Текущий вход сотрудника в бот отключится.",
"turnOff": "Выключить",
"turnOn": "Включить",
"offConfirm": "Выключить {{name}}? Новые заказы ему не будут приходить.",
"copy": "Скопировать",
"copied": "Скопировано",
"balance": "К выплате",
"accrued": "Начислено",
"paidOut": "Выплачено",
"terms": {
"COMMISSION": "Условия: Barev Club берёт {{pct}}% с оплаченного заказа, остальное — ваше. Зарплаты и расходы — на вас.",
"PROFIT_SHARE": "Условия: зарплату клинеру ({{fot}}%) и налог ({{tax}}%) платит Barev Club, вам — {{pct}}% от остатка."
},
"byMonth": "По месяцам",
"ordersN": "заказов: {{n}}",
"byOrder": "По заказам",
"noIncome": "Доход появится, когда клиент оплатит выполненный заказ.",
"paidBy": "оплачено {{sum}}",
"minus": "−ФОТ {{fot}}, −налог {{tax}}, ваши {{pct}}%",
"minusCommission": "−{{pct}}% Barev Club",
"payouts": "Выплаты",
"payout": {
"paid": "выплачено",
"pending": "ожидает"
},
"auto": "Автораспределение",
"autoHint": "Новый заказ сам предлагается лучшему свободному сотруднику: свободен по графику, есть время доехать, не превышен лимит; выше — кто знает язык клиента и работает в районе, у кого меньше заказов за неделю и выше рейтинг. Отказался или молчит — следующему.",
"gap": "Перерыв на дорогу между заказами, мин",
"gapMin": "Не меньше {{min}} мин",
"timeoutHint": "Если заказ не назначен за {{min}} мин, он вернётся в Barev Club.",
"managers": "Менеджеры",
"you": "это вы",
"remove": "Убрать",
"removeManagerConfirm": "Убрать {{name}} из менеджеров?",
"managerInvite": "Ссылка для ещё одного менеджера — по ней он откроет рабочий бот и получит доступ к кабинету:",
"logout": "Выйти на этом компьютере",
"wd": {
"0": "Пн",
"1": "Вт",
"2": "Ср",
"3": "Чт",
"4": "Пт",
"5": "Сб",
"6": "Вс"
}
}
},
"bot": {
"client": {
"chooseLanguage": "Выбери язык · Choose your language · 选择语言 · Ընտրեք լեզուն · Elige tu idioma",
"languageSet": "Язык: {{language}}",
"fromAgency": "Привет! Ты пришёл от агентства {{agency}} — у нас для тебя подарок.",
"fromAgency": "Привет! Ты пришёл от риэлторского агентства {{agency}} — у нас для тебя подарок.",
"consentText": "Чтобы пользоваться Barev Club, нужно согласие на обработку персональных данных. Их получает исполнитель твоего заказа и партнёры, перечисленные в политике, а хранятся они на сервере в Германии.\n\n<a href=\"{{url}}\">Политика конфиденциальности</a>",
"consentAccept": "Согласен",
"giftActivated": "🎁 Подарок от агентства: Barev+ активен до {{date}}. Скидки на услуги уже применяются.",
"giftActivated": "🎁 Подарок от риэлторского агентства: {{plan}} активен до {{date}}. Скидки на услуги уже применяются.",
"welcome": "Ереван — как дома. С первого дня.\n\nЗаказывай клининг и другие услуги, следи за статусом и пользуйся скидками партнёров — всё в приложении.",
"openApp": "Открыть приложение",
"enterCode": "Ввести код агентства",
"enterCode": "Ввести код риэлторского агентства",
"changeLanguage": "Сменить язык",
"codePrompt": "Отправь код агентства из договора аренды.",
"codePrompt": "Отправь код риэлторского агентства из договора аренды.",
"codeAccepted": "Код принят: {{agency}}.",
"help": "Нажми «Открыть приложение», чтобы заказать услугу. Команды: /app — приложение, /code — код агентства, /language — язык.",
"help": "Нажми «Открыть приложение», чтобы заказать услугу. Команды: /app — приложение, /code — код риэлторского агентства, /language — язык.",
"cmdStart": "Начать",
"cmdApp": "Открыть приложение",
"cmdCode": "Ввести код агентства",
"cmdCode": "Ввести код риэлторского агентства",
"cmdLanguage": "Сменить язык",
"cmdHelp": "Помощь",
"webRequestOrdered": "Заявка №{{request}} принята и оформлена как заказ №{{order}} на {{date}}. Статус — в приложении.",
@@ -880,7 +1049,44 @@
"cmdSupport": "Написать в поддержку",
"cmdConcierge": "Написать консьержу (Barev+)",
"rated": "Спасибо за оценку! {{stars}}",
"ratedLow": "Жаль, что не всё прошло хорошо. Напиши здесь, что случилось, — команда разберётся."
"ratedLow": "Жаль, что не всё прошло хорошо. Напиши здесь, что случилось, — команда разберётся.",
"wr": {
"intro": "Заявка №{{request}} у нас. Осталось пару вопросов, чтобы оформить заказ.",
"askDistrict": "В каком районе квартира?",
"askAddress": "Напиши адрес: улица, дом, квартира.",
"askDay": "Когда удобно? Выбери день.",
"askTime": "Выбери время {{day}}:",
"dayFull": "На этот день время уже разобрали. Выбери другой день.",
"noSlots": "Свободного времени в ближайшие дни нет — оператор свяжется с тобой и подберёт время."
},
"cmdBalance": "Баланс бариков",
"cmdHistory": "История бариков",
"points": {
"balance": "🪙 Барики: {{balance}} (≈ {{balance}} ֏)",
"pending": "⏳ В ожидании: {{pending}}, станут доступны {{date}}",
"burn": "🔥 Сгорят {{date}}, если не будет заказов",
"empty": "Операций с бариками пока нет.",
"open": "Открыть барики",
"type": {
"accrual": "Кэшбек",
"bonus": "Бонус",
"redemption": "Оплата бариками",
"request": "Запрос на оплату",
"reversal": "Отмена"
},
"status": {
"PENDING": "в ожидании",
"ACTIVE": "доступно",
"SPENT": "потрачено",
"EXPIRED": "сгорело",
"REVERSED": "отменено",
"REQUESTED": "ждёт подтверждения",
"CONFIRMED": "списано",
"REJECTED": "отклонено",
"ACCRUAL": "начисление отменено",
"REDEMPTION": "барики вернулись"
}
}
},
"pro": {
"notLinked": "Этот бот — для исполнителей Barev Club. Попроси у оператора ссылку-приглашение.",
@@ -890,7 +1096,9 @@
"youAreDispatcher": "Диспетчер компании «{{company}}»",
"welcome": "Здесь приходят новые заказы. Принимайте их кнопками в сообщении, а заказы на день ведите в кабинете.",
"openCabinet": "Открыть кабинет",
"messageReceived": "Передали оператору — ответ придёт сюда."
"messageReceived": "Передали оператору — ответ придёт сюда.",
"openWeb": "💻 Открыть на компьютере",
"webLink": "Ссылка на кабинет для браузера (действует 15 минут, одноразовая):\n{{url}}"
},
"admin": {
"hello": "{{name}}, сюда приходят уведомления из админки Barev Club.",
@@ -916,7 +1124,30 @@
"chatAssigned": "{{by}} передал(а) вам диалог: {{name}}",
"order": "Заказ №{{number}}",
"noOrder": "Сейчас без заказа",
"autoFailed": "Не нашли свободного исполнителя для заказа №{{number}} — назначьте вручную."
"autoFailed": "Не нашли свободного исполнителя для заказа №{{number}} — назначьте вручную.",
"partnerDeclined": "Партнёр «{{partner}}» отказался от заказа №{{number}} — назначьте заново",
"partnerExpired": "Партнёр «{{partner}}» не назначил сотрудника на заказ №{{number}} вовремя — назначьте заново",
"noTime": "время не указано",
"noAddress": "адрес не указан",
"points": {
"forOrder": "Заказ №{{number}} на {{sum}}",
"confirm": "✅ Подтвердить",
"reject": "Отклонить",
"request": "Клиент просит оплатить бариками: {{amount}}",
"balance": "баланс {{balance}}",
"suggestions": "Новые предложения бариков: {{count}} (всего в очереди {{open}})",
"overLimit": "{{by}} хочет провести {{amount}} бариков — выше лимита, нужно ваше подтверждение",
"mismatch": "Сверка бариков: у {{count}} клиентов баланс расходился с журналом — исправлено, проверьте журнал",
"confirmed": "Списано {{amount}} бариков",
"rejected": "Запрос отклонён",
"help": "Начислить за заказ: /barik <телефон или @username> <сумма заказа> [категория]\nКатегории: cleaning, documents, learning, repair, transfer",
"noClient": "Клиент не найден",
"preview": "Начислить {{amount}} бариков {{name}} за {{what}} {{sum}} ֏ ({{tier}}, {{rate}}%). Станут доступны {{date}}.",
"accrue": "🪙 Начислить",
"done": "Начислено {{amount}} бариков",
"needsApproval": "Сумма выше лимита — отправлено суперадмину на подтверждение",
"zero": "Сумма получилась 0 — начислять нечего"
}
},
"partner": {
"valid": "{{name}} — {{level}}, карта действует",
@@ -1011,34 +1242,58 @@
"rescheduled": "Заказ перенесён на {{when}}.",
"unassigned": "Заказ на {{when}} снят с вас.",
"reminder24": "Завтра заказ: {{when}}.",
"reminder1": "Через час заказ: {{when}}."
"reminder1": "Через час заказ: {{when}}.",
"orderEdited": "Состав заказа на {{when}} изменён — откройте заказ."
},
"dispatcher": {
"newOrder": "Новый заказ для компании на {{when}}. Назначьте сотрудника в кабинете.",
"returned": "Сотрудник отказался от заказа на {{when}}. Назначьте другого."
"returned": "Сотрудник отказался от заказа на {{when}}. Назначьте другого.",
"noStaff": "Нет свободного сотрудника на заказ {{when}} — назначьте вручную в кабинете.",
"expired": "Заказ на {{when}} вернулся в Barev Club: сотрудника не назначили вовремя."
},
"openPlan": "Открыть приложение",
"concierge": "Консьерж Barev Club",
"subscriptionActivated": "{{plan}} включён до {{date}}. Скидки уже в ценах услуг.",
"operator": "Оператор Barev Club",
"support": "Поддержка Barev Club",
"rateAsk": "Как всё прошло? Оцени, пожалуйста, — это займёт секунду:"
"rateAsk": "Как всё прошло? Оцени, пожалуйста, — это займёт секунду:",
"points": {
"open": "Мои барики",
"pending": "🪙 +{{amount}} бариков за {{what}} {{date}}. Станут доступны {{available}}.",
"active": "🪙 +{{amount}} бариков за {{what}}. Баланс: {{balance}}.",
"bonus": "🎁 +{{amount}} бариков в подарок: {{what}}. Действуют до {{until}}.",
"bonusNoTtl": "🎁 +{{amount}} бариков в подарок: {{what}}.",
"housewarming": "на новоселье от {{agency}}",
"activated": "✅ {{amount}} бариков доступны. Баланс: {{balance}}.",
"redeemed": "Оплачено бариками: {{amount}}. К доплате: {{due}}. Остаток: {{balance}}.",
"redeemedNoOrder": "Списано бариков: {{amount}}. Остаток: {{balance}}.",
"requestExpired": "Запрос на оплату бариками истёк. Если нужно — отправьте новый.",
"rejected": "Запрос на оплату бариками отклонён: {{reason}}",
"declinedGeneric": "напишите нам в чат, обсудим.",
"orderCancelled": "заказ отменён",
"returned": "↩️ {{amount}} бариков вернулись на счёт. Баланс: {{balance}}.",
"reversed": "Начисление {{what}} отменено: {{reason}}. Баланс: {{balance}}.",
"expiring": "⏰ Через {{days}} дн. сгорят {{amount}} бариков. Любой заказ сохранит их.",
"expired": "{{amount}} бариков сгорели — давно не было заказов.",
"orderEdited": "заказ изменён — начислим по новой сумме"
},
"orderEdited": "Состав заказа изменён. Итого к оплате: {{total}}."
},
"admin": {
"nav": {
"orders": "Заказы",
"clients": "Клиенты",
"providers": "Исполнители",
"companies": "Компании",
"companies": "Партнёры услуг",
"services": "Услуги и цены",
"agencies": "Агентства",
"agencies": "Риэлторские агентства",
"settings": "Настройки",
"presets": "Ставки",
"webRequests": "Заявки с сайта",
"leads": "Переезд",
"concierge": "Консьерж",
"requests": "Заявки из приложения",
"partners": "Партнёры",
"partners": "Партнёры клуба",
"telegram": "Уведомления",
"templates": "Шаблоны переезда"
},
@@ -1177,7 +1432,10 @@
"finishLesson": "Урок завершён",
"openMeeting": "Встреча открыта",
"startMeeting": "Встреча начата",
"finishMeeting": "Встреча завершена"
"finishMeeting": "Встреча завершена",
"editedByStaff": "Команда изменила состав",
"checklistByStaff": "Команда отметила пункт чек-листа",
"checklistUncheckedByStaff": "Команда сняла отметку чек-листа"
},
"weekdays": {
"0": "Пн",
@@ -1256,7 +1514,7 @@
"eliteDiscount": "Скидка Elite"
},
"agencies": {
"add": "Добавить агентство",
"add": "Добавить риэлторское агентство",
"name": "Название",
"code": "Код",
"commission": "Вознаграждение, %",
@@ -1289,7 +1547,7 @@
"cancel_freeHours": "Бесплатная отмена не позднее чем за, ч",
"agency_attributionMonths": "Клиент закреплён за агентством, мес.",
"agency_defaultCommissionPct": "Вознаграждение агентства по умолчанию, %",
"barevPlus_giftDays": "Подарочный Barev+, дней",
"barevPlus_giftDays": "Лендинг: «первые N дней в подарок» (сам подарок задаётся у агентства)",
"barevPlus_monthlyPrice": "Цена Barev+ в месяц, ֏",
"offer_acceptTimeoutMinutes": "Время на ответ исполнителя, мин",
"chat_closeAfterHours": "Чат закрывается через, ч после заказа",
@@ -1321,7 +1579,23 @@
"sales_bonusPerVenue": "Бонус продажника за заведение, ֏",
"sales_bonusPerAgency": "Бонус продажника за агентство, ֏",
"sales_bonusAgencyPct": "Бонус продажника: % выручки клиентов его агентств",
"sales_bonusAgencyMonths": "…сколько месяцев после подключения агентства"
"sales_bonusAgencyMonths": "…сколько месяцев после подключения агентства",
"partner_fotPct": "Партнёры «расходы наши»: ФОТ клинера, % от заказа",
"partner_taxPct": "Партнёры «расходы наши»: налог, %",
"partner_minTravelGapMinutes": "Партнёры: минимальный перерыв между заказами сотрудника, мин",
"partner_acceptTimeoutMinutes": "Партнёры: за сколько минут назначить сотрудника, иначе заказ вернётся",
"points_pendingHours": "Барики: доступны через N часов после услуги",
"points_maxSharePct": "Барики: можно оплатить до N% заказа",
"points_minRedeem": "Барики: минимум за одно списание",
"points_burnDays": "Барики: сгорают через N дней без активности",
"points_warnDays1": "Барики: первое предупреждение за N дней",
"points_warnDays2": "Барики: второе предупреждение за N дней",
"points_requestTtlHours": "Барики: запрос клиента ждёт N часов",
"points_streakGapDays": "Барики: серия уборок — перерыв не больше N дней",
"points_diffHighlightPct": "Барики: подсветить ручную сумму, если отличается больше чем на N%",
"points_supportCanManage": "Поддержка тоже может начислять и списывать барики (с лимитами)",
"points_opMaxPerOperation": "Лимит поддержки на одну операцию, барики",
"points_opMaxPerDay": "Лимит поддержки в сутки, барики"
},
"presets": {
"add": "Добавить пресет",
@@ -1549,7 +1823,7 @@
"step4_sub": "Чат с исполнителем внутри"
},
"agency": {
"title": "Сняли квартиру через агентство-партнёра?",
"title": "Сняли квартиру через риэлторское агентство-партнёр?",
"text": "Код на месяц Barev+ уже в договоре.",
"gift": "Подарок!",
"giftSub": "1 месяц Barev+"
@@ -1571,7 +1845,7 @@
"a": "Скидки до {{pct}}% на документы, быт, обучение и карьерные консультации."
},
"gift": {
"q": "Как получить подарок от агентства?",
"q": "Как получить подарок от риэлторского агентства?",
"a": "Код указан в договоре аренды. Введи его в боте — и {{days}} дней Barev+ твои."
}
},
@@ -1580,7 +1854,11 @@
"partner": "Стать партнёром",
"privacy": "Политика конфиденциальности",
"legal": "ИП Забудько Юрий Константинович · ՀՎՀՀ {{tin}}",
"services": "Услуги"
"services": "Услуги",
"pricing": "Цены",
"terms": "Условия использования",
"refund": "Отмена и возврат",
"rules": "Правила сервиса"
},
"clean": {
"meta": {
@@ -1646,7 +1924,11 @@
"how2": "Подтверждаем в Telegram",
"how2Sub": "Бот покажет заказ и его статус",
"how3": "Приезжает клинер",
"how3Sub": "Работает по чек-листу, присылает фотоотчёт"
"how3Sub": "Работает по чек-листу, присылает фотоотчёт",
"whenHintOptional": "Необязательно: если не выберете, бот спросит в Telegram.",
"addressHintOptional": "Необязательно: адрес можно написать боту после отправки.",
"nameTitle": "Как к вам обращаться",
"consentByClick": "Нажимая кнопку, вы соглашаетесь на обработку персональных данных."
},
"v1": {
"title": "Всё для жизни в Ереване — в одном чате",
@@ -1687,7 +1969,7 @@
"airportTitle": "Встретим в аэропорту и заселим в отель",
"airportText": "Водитель с табличкой в Звартноце и трансфер до отеля — первые дни без забот.",
"flatTitle": "Найдём квартиру",
"flatText": "Подберём жильё через агентства-партнёров, а перед заездом сделаем уборку.",
"flatText": "Подберём жильё через риэлторские агентства-партнёры, а перед заездом сделаем уборку.",
"docsTitle": "Сделаем регистрацию и соцкарту",
"docsText": "Подготовим документы, запишем и проводим до готовой карты.",
"permitTitle": "Поможем с документами на ВНЖ",
@@ -1700,9 +1982,9 @@
"chatCta": "Написать в чат"
},
"v3": {
"tag": "Для арендаторов агентств-партнёров",
"tag": "Для арендаторов риэлторских агентств-партнёров",
"title": "Добро пожаловать домой",
"sub": "Агентство, через которое ты снял квартиру, дарит месяц Barev+ — скидки на уборку, документы и уроки.",
"sub": "Риэлторское агентство, через которое ты снял квартиру, дарит подписку Barev+ — скидки на уборку, документы и уроки.",
"giftTitle": "1 месяц Barev+",
"giftCode": "Код {{code}} применится сам",
"giftNoCode": "Код — в договоре аренды",
@@ -1756,14 +2038,14 @@
"perYear": "За год — {{amount}}",
"other": "Подписка действует и на другие направления",
"cta": "Попробовать Barev+",
"gift": "Снял квартиру через агентство-партнёра? Первый месяц — в подарок."
"gift": "Снял квартиру через риэлторское агентство-партнёр? Подписка — в подарок."
},
"v6": {
"tag": "Для агентств недвижимости",
"tag": "Для риэлторских агентств",
"title": "Договор о том, как ваши арендаторы обживаются в Ереване",
"sub": "Вы дарите клиентам месяц Barev+, мы помогаем им с бытом, а вы получаете процент с их заказов.",
"docDate": "Ереван",
"s1": "Что получает агентство",
"s1": "Что получает риэлторское агентство",
"s1i1": "{{pct}}% с каждого заказа клиента в течение {{months}} месяцев после первого входа в бота.",
"s1i2": "Свой код и QR — их можно вставить в договор аренды или передать вместе с ключами.",
"s1i3": "Отчёт по клиентам и заказам: сколько пришло, сколько заказало и сколько вы заработали.",
@@ -1779,7 +2061,7 @@
"exampleText": "{{clients}} клиентов × {{orders}} уборки в месяц × {{price}}",
"exampleResult": "{{amount}} агентству в месяц",
"exampleNote": "Расчёт по обычной уборке квартиры с двумя комнатами.",
"signAgency": "Агентство",
"signAgency": "Риэлторское агентство",
"signUs": "Barev Club",
"cta": "Стать партнёром"
},
@@ -2315,7 +2597,7 @@
},
"privacy": {
"title": "Политика конфиденциальности",
"updated": "Редакция от 20 октября 2026 года",
"updated": "Редакция от 22 октября 2026 года",
"intro": "Здесь написано, какие персональные данные собирает Barev Club, зачем, кому их передаёт и как вы можете ими управлять. Политика действует для сайта barevclub.com, Telegram-ботов и мини-приложений Barev Club.",
"operator": {
"title": "Кто обрабатывает данные",
@@ -2323,7 +2605,7 @@
},
"data": {
"title": "Какие данные мы получаем",
"body": "• Из Telegram: ID, имя пользователя, имя и фамилию из профиля, язык. Номер телефона — только если вы сами им поделились.\n• Заявки с сайта: имя, телефон или ник в Telegram, ответы в подборе переезда (сроки, нужные услуги, бюджет). Для уборки — адрес, район, желаемое время и комментарий.\n• Заказы: адрес и район, точку на карте (если вы её указали), параметры услуги, дату и время, цену, скидку, статус оплаты, комментарии.\n• Документы: сканы паспорта и других документов, которые вы загружаете для услуг направления «Документы».\n• Переписку с исполнителем через бота, фотоотчёты, оценки и отзывы.\n• Членство: уровень (Barev, Barev+, Elite), сроки подписки, код клубной карты и отметки о её использовании у партнёров.\n• Источник: агентство или промокод, по которому вы пришли, и метки рекламных кампаний (UTM).\n• Технические данные: cookie с языком сайта, код агентства в памяти браузера и журналы сервера (IP-адрес, время и адрес запроса) для защиты от сбоев и атак.\nДанные банковских карт мы не получаем. Когда появится онлайн-оплата, реквизиты карты будут вводиться на стороне платёжного провайдера."
"body": "• Из Telegram: ID, имя пользователя, имя и фамилию из профиля, язык. Номер телефона — только если вы сами им поделились.\n• Заявки с сайта: имя, телефон или ник в Telegram, ответы в подборе переезда (сроки, нужные услуги, бюджет). Для уборки — адрес, район, желаемое время и комментарий.\n• Заказы: адрес и район, точку на карте (если вы её указали), параметры услуги, дату и время, цену, скидку, статус оплаты, комментарии.\n• Документы: сканы паспорта и других документов, которые вы загружаете для услуг направления «Документы».\n• Переписку с исполнителем через бота, фотоотчёты, оценки и отзывы.\n• Членство: уровень (Barev, Barev+, Elite), сроки подписки, код клубной карты и отметки о её использовании у партнёров.\n• Источник: агентство или промокод, по которому вы пришли, и метки рекламных кампаний (UTM).\n• Технические данные: cookie с языком сайта, код агентства в памяти браузера и журналы сервера (IP-адрес, время и адрес запроса) для защиты от сбоев и атак.\nДанные банковских карт мы не получаем: при онлайн-оплате их вводят на странице Paddle, нашего онлайн-реселлера."
},
"purposes": {
"title": "Зачем мы их используем",
@@ -2335,7 +2617,7 @@
},
"recipients": {
"title": "Кому мы передаём данные",
"body": "Мы не продаём персональные данные и передаём только то, что нужно для конкретной задачи:\n• Исполнителю вашего заказа, а если заказ выполняет компания-партнёр, то и её диспетчеру: имя, язык, адрес и детали заказа. Ваш номер телефона исполнителю не показывается, общение идёт через бота.\n• Агентству недвижимости, которое вас привело: имя, пользуетесь ли вы клубом и суммы оплаченных заказов — для расчёта вознаграждения агентства.\n• Партнёру клуба, когда вы показываете клубную карту: имя и уровень членства.\n• Telegram: через него работают боты и мини-приложения. Telegram обрабатывает данные по своей политике.\n• Hostinger International: на его серверах хранятся база данных и файлы.\n• Платёжному провайдеру, когда появится онлайн-оплата.\n• Государственным органам: когда вы заказываете услугу по документам и мы подаём их от вашего имени, или когда этого требует закон."
"body": "Мы не продаём персональные данные и передаём только то, что нужно для конкретной задачи:\n• Исполнителю вашего заказа, а если заказ выполняет компания-партнёр, то и её диспетчеру: имя, язык, адрес и детали заказа. Ваш номер телефона исполнителю не показывается, общение идёт через бота.\n• Агентству недвижимости, которое вас привело: имя, пользуетесь ли вы клубом и суммы оплаченных заказов — для расчёта вознаграждения агентства.\n• Партнёру клуба, когда вы показываете клубную карту: имя и уровень членства.\n• Telegram: через него работают боты и мини-приложения. Telegram обрабатывает данные по своей политике.\n• Hostinger International: на его серверах хранятся база данных и файлы.\n• Paddle.com — нашему онлайн-реселлеру, который принимает онлайн-платежи: имя, email, страну и данные платежа. Paddle обрабатывает данные как самостоятельный оператор по своей политике конфиденциальности.\n• Государственным органам: когда вы заказываете услугу по документам и мы подаём их от вашего имени, или когда этого требует закон."
},
"location": {
"title": "Где хранятся данные",
@@ -2369,5 +2651,151 @@
"title": "Изменения политики",
"body": "Мы можем обновлять политику. Действующая редакция всегда на этой странице, дата редакции указана в начале. О существенных изменениях сообщим в боте.\nОсновной текст — на русском языке, переводы сделаны для удобства."
}
},
"pricing": {
"title": "Цены",
"updated": "Действуют с 22 октября 2026 года",
"intro": "Сколько стоят подписка Barev Club и услуги клуба. Заказывать услуги можно и без подписки, она только даёт скидки и консьержа. Все цены — в армянских драмах (֏).",
"from": "от {{price}}",
"perDay": "{{price}} за сутки",
"plansTitle": "Подписка клуба",
"plansNote": "Подписка оплачивается помесячно и продлевается, пока вы её не отмените. Отменить можно в любой момент — она будет действовать до конца оплаченного месяца.",
"planNote": {
"free": "Регистрация в Telegram-боте, без оплаты и без срока.",
"plus": "Оформляется в боте. Если вы пришли от риэлторского агентства-партнёра — подписка в подарок (обычно {{days}} дней или больше, условия у каждого агентства свои).",
"elite": "Оформляется через оператора клуба в чате бота."
},
"servicesTitle": "Услуги",
"servicesNote": "Здесь — базовые цены. Точную стоимость заказа с учётом параметров и скидки по подписке бот показывает до того, как вы подтвердите заказ. Подтверждённая цена после этого не меняется.",
"rows": {
"cleaning": {
"name": "Уборка квартиры",
"note": "Зависит от вида уборки и числа комнат"
},
"transferStandard": {
"name": "Трансфер из аэропорта «Звартноц», Стандарт",
"note": "До 3 пассажиров, встреча с табличкой, 60 минут ожидания"
},
"transferPremium": {
"name": "Трансфер из аэропорта «Звартноц», Премиум",
"note": "Бизнес-класс, вода, SIM-карта"
},
"hotel": {
"name": "Отель на первые дни",
"note": "Цена с подпиской Barev+; без подписки — от {{base}} за сутки"
},
"socialCard": {
"name": "Соцкарта под ключ",
"note": "Перевод паспорта, нотариус, запись, сопровождение"
},
"consultation": {
"name": "Консультация по ВНЖ",
"note": "Засчитывается в стоимость сопровождения"
},
"residence": {
"name": "Сопровождение ВНЖ",
"note": "Основание, документы, подача, визит. Госпошлина {{fee}} за ВНЖ на 1 год платится государству отдельно"
}
},
"payTitle": "Оплата и отмена",
"payBody": "Подписку Barev+ можно оплатить онлайн картой — платёж принимает наш онлайн-реселлер Paddle.com, а платёж за подписку возвращается по запросу в течение 14 дней. Услуги оплачиваются наличными или переводом, способ выбирается в заказе. Бесплатно отменить или перенести заказ можно не позднее чем за 24 часа до начала услуги. Подробности — в условиях использования и правилах возврата."
},
"terms": {
"title": "Условия использования",
"updated": "Редакция от 22 октября 2026 года",
"intro": "Это условия, на которых Barev Club оказывает услуги через сайт barevclub.com, Telegram-ботов и мини-приложения. Оформляя заказ или подписку, вы принимаете эти условия.",
"about": {
"title": "Кто мы и что делаем",
"body": "Barev Club — сервис для тех, кто переезжает в Армению или уже живёт здесь: бытовые услуги (уборка, трансфер из аэропорта, отель на первые дни), помощь с документами (соцкарта, вид на жительство), обучение и скидки у партнёров клуба.\nУслуги оказывает индивидуальный предприниматель Забудько Юрий Константинович (Ա/Ձ Զաբուդկո Յուրի Կոնստանտինի), Республика Армения, ՀՎՀՀ {{tin}}. Контакты: {{email}}, {{phone}}.\nЗаказы выполняют проверенные исполнители — наши сотрудники или компании-партнёры. Перед вами за результат отвечает Barev Club."
},
"acceptance": {
"title": "Принятие условий",
"body": "Условия действуют с момента, когда вы оформили заказ, подписку или заявку на сайте или в боте. Пользоваться сервисом можно с 18 лет.\nЕсли вы не согласны с условиями, не оформляйте заказ."
},
"orders": {
"title": "Как оформляется заказ",
"body": "• Вы выбираете услугу в боте или оставляете заявку на сайте и указываете параметры: адрес, дату, время и другое.\n• До подтверждения бот показывает итоговую цену, скидку и условия отмены.\n• Заказ считается принятым, когда вы его подтвердили, а мы назначили исполнителя. Об этом и о каждом следующем статусе приходит уведомление.\n• Общение с исполнителем идёт через чат в боте.\nЕсли выполнить заказ в выбранное время не получается, мы предложим другое время или отменим заказ без удержания."
},
"prices": {
"title": "Цены",
"body": "Цены указаны в армянских драмах на странице «Цены» и в боте. Мы можем менять цены, но цена уже подтверждённого заказа не меняется.\nГосударственные пошлины и сборы, например пошлина за вид на жительство, в цену услуги не входят, если это не написано прямо. Их размер мы называем заранее."
},
"payment": {
"title": "Оплата",
"body": "Подписку Barev+ можно оплатить онлайн картой. Онлайн-заказы обрабатывает наш онлайн-реселлер Paddle.com. Paddle.com — продавец (Merchant of Record) по всем онлайн-заказам: он принимает оплату, выставляет счёт, отвечает на вопросы по платежам и оформляет возвраты. Данные карты вводятся на странице Paddle, мы их не получаем и не храним.\nУслуги (уборка, трансфер, документы и другие) онлайн не продаются: их оплачивают наличными или переводом исполнителю или переводом на счёт Barev Club — способ выбирается в заказе.\nПо запросу мы выдаём документ об оплате."
},
"cancel": {
"title": "Отмена и перенос",
"body": "Бесплатно отменить или перенести заказ можно не позднее чем за 24 часа до начала услуги — в боте или в чате с оператором.\nПри более поздней отмене удерживается часть стоимости. Её размер указан в карточке услуги до подтверждения заказа, по умолчанию — 30%.\nЕсли заказ отменяем мы или исполнитель не приехал, деньги возвращаются полностью. Подробнее — в правилах отмены и возврата."
},
"subscription": {
"title": "Подписка Barev+ и Barev Elite",
"body": "• Подписка даёт скидки на услуги клуба, подарки партнёров и консьержа в чате. Состав и цены уровней — на странице «Цены».\n• Подписка оплачивается помесячно и продлевается автоматически, пока вы её не отмените. О продлении мы напоминаем за 3 дня.\n• Скидка применяется к заказам, оформленным, пока подписка действует.\n• Подарочный период по коду агентства-партнёра даётся один раз на одного клиента.\n• Отменить подписку можно в любой момент в боте или в чате с оператором. Она будет действовать до конца оплаченного периода."
},
"perks": {
"title": "Скидки и подарки партнёров",
"body": "Скидки и подарки по клубной карте предоставляют партнёры клуба — кафе, рестораны и другие компании. Условия каждого предложения указаны в каталоге партнёров.\nЗа товары и услуги партнёров отвечает партнёр. Предложения партнёров могут меняться и заканчиваться."
},
"docs": {
"title": "Услуги по документам",
"body": "Мы помогаем собрать документы, переводим их, записываем и сопровождаем вас в государственных органах. Решение о выдаче документа или вида на жительство принимает государственный орган, и гарантировать его мы не можем.\nВы отвечаете за то, что документы и сведения, которые вы нам передаёте, подлинные и точные. Если отказ связан с нашей ошибкой, мы исправим её за свой счёт."
},
"client": {
"title": "Ваши обязанности",
"body": "• Указывать верные данные: адрес, контакты, параметры заказа.\n• Обеспечить доступ к квартире в назначенное время и безопасные условия для работы исполнителя.\n• Уважительно общаться с исполнителями и операторами.\n• Не использовать сервис для незаконных целей.\nЕсли исполнитель не смог начать работу по вашей вине (например, никого нет по адресу), это считается поздней отменой."
},
"quality": {
"title": "Качество и претензии",
"body": "Если результат вас не устроил, напишите в чат заказа в течение 24 часов после выполнения и, если можно, приложите фото. Мы бесплатно исправим недочёты или вернём часть или всю стоимость.\nЕсли исполнитель повредил ваше имущество, сообщите об этом в течение 24 часов — мы разберёмся и возместим подтверждённый ущерб."
},
"liability": {
"title": "Ответственность",
"body": "Наша ответственность по заказу ограничена его стоимостью, кроме случаев подтверждённого ущерба имуществу и случаев, когда закон не допускает такого ограничения.\nМы не отвечаем за задержки и отмены из-за обстоятельств, на которые не можем повлиять: решений государственных органов, задержек рейсов, стихийных бедствий, сбоев Telegram."
},
"content": {
"title": "Материалы сайта",
"body": "Тексты, дизайн, логотип и название Barev Club принадлежат владельцу сервиса. Копировать их без разрешения нельзя."
},
"changes": {
"title": "Изменения условий",
"body": "Мы можем обновлять условия. Действующая редакция всегда на этой странице, дата редакции указана в начале. Уже подтверждённые заказы выполняются на прежних условиях. О существенных изменениях подписки мы сообщим в боте заранее."
},
"law": {
"title": "Применимое право и споры",
"body": "К условиям применяется законодательство Республики Армения. Споры мы сначала решаем переговорами — напишите на {{email}}, мы ответим в течение 3 рабочих дней. Если договориться не удалось, спор рассматривает суд Республики Армения.\nВаши права потребителя по закону эти условия не ограничивают.\nОсновной текст — на русском языке, переводы сделаны для удобства."
}
},
"refund": {
"title": "Отмена и возврат",
"updated": "Редакция от 22 октября 2026 года",
"intro": "Когда и сколько денег Barev Club возвращает за услуги и подписку и как подать запрос на возврат.",
"cancelClient": {
"title": "Если отменяете вы",
"body": "• За 24 часа до начала услуги и раньше — отмена и перенос бесплатны, оплата возвращается полностью.\n• Меньше чем за 24 часа — удерживается часть стоимости, остальное возвращается. Размер удержания указан в карточке услуги до подтверждения заказа, по умолчанию — 30%.\n• После начала услуги стоимость уже выполненной части не возвращается."
},
"cancelUs": {
"title": "Если отменяем мы",
"body": "Если мы отменили заказ, не нашли исполнителя или исполнитель не приехал, вы выбираете: перенос на удобное время или полный возврат денег."
},
"quality": {
"title": "Если результат не устроил",
"body": "Напишите в чат заказа в течение 24 часов после выполнения и, если можно, приложите фото. Мы бесплатно исправим недочёты, а если это невозможно — вернём часть или всю стоимость, в зависимости от того, что было не так."
},
"subscription": {
"title": "Подписка Barev+ и Barev Elite",
"body": "• Любой платёж за подписку можно вернуть полностью, если попросить об этом в течение 14 дней после списания.\n• Отменить подписку можно в любой момент — тогда следующий платёж не спишется, а подписка будет действовать до конца оплаченного месяца.\n• Подарочный период бесплатный, возвращать за него нечего."
},
"nonRefundable": {
"title": "Что не возвращается",
"body": "• Государственные пошлины и сборы, которые мы уже заплатили от вашего имени.\n• Расходы, которые мы уже понесли по вашему заказу у третьих лиц: например, нотариальный перевод или бронь отеля по правилам отеля.\n• Товары и услуги партнёров клуба — по ним действуют правила партнёра."
},
"howTo": {
"title": "Как запросить возврат",
"body": "Напишите в чат бота или на {{email}} и укажите номер заказа или подписки и причину. Мы ответим в течение 3 рабочих дней.\nЕсли вы оплатили подписку онлайн, возврат можно также запросить напрямую у Paddle на paddle.net — там же можно найти свой платёж и чек."
},
"timing": {
"title": "Сроки и способ возврата",
"body": "Деньги возвращаются тем же способом, которым вы платили, в течение 10 рабочих дней после одобрения возврата. Возврат на карту может идти ещё несколько дней — это зависит от банка. Если вы платили наличными, вернём переводом или наличными, как вам удобнее.\nПо вопросам возврата: {{email}}, {{phone}}."
}
}
}
+471 -43
View File
@@ -412,7 +412,7 @@
"CANCEL_VIA_OPERATOR": "服务已开始,只能通过客服取消。",
"RESCHEDULE_VIA_OPERATOR": "只能通过客服改期。",
"AGENCY_CODE_INVALID": "该代码不存在,请核对租房合同中的代码。",
"AGENCY_ALREADY_SET": "您已绑定其他中介。",
"AGENCY_ALREADY_SET": "您已绑定其他房产中介。",
"OFFER_NOT_ACTIVE": "该派单已失效。",
"PROVIDER_BUSY": "该服务人员此时段已有安排。",
"ALREADY_ASSIGNED": "订单已指派。",
@@ -448,7 +448,17 @@
"PROMO_INVALID": "优惠码不存在",
"PROMO_EXPIRED": "优惠码已失效",
"PROMO_USED": "该优惠码已使用",
"PROMO_NOT_FOR_SERVICE": "该优惠码不适用于此服务"
"PROMO_NOT_FOR_SERVICE": "该优惠码不适用于此服务",
"POINTS_DUPLICATE_ORDER": "该订单已发放过 Bariki。",
"POINTS_REASON_REQUIRED": "请填写原因。",
"POINTS_BELOW_MIN": "金额低于最低限额。",
"POINTS_NOT_ENOUGH": "Bariki 不足。",
"POINTS_OVER_SHARE": "Bariki 只能支付订单的一部分。",
"POINTS_OVER_LIMIT": "超出您的限额——需超级管理员批准。",
"POINTS_FINAL": "该操作已无法撤销。",
"POINTS_REQUEST_CLOSED": "该申请已处理或已过期。",
"POINTS_TOO_MANY_REQUESTS": "您已有待处理的申请——请等待回复。",
"POINTS_WITH_PROMO": "Bariki 不能与优惠码同时使用。"
},
"client": {
"nav": {
@@ -473,7 +483,7 @@
"text": "下单前需要您同意我们处理个人数据:姓名、地址和订单信息。这些信息会提供给为您服务的人员和政策中列出的合作方,并存储在德国的服务器上。",
"policyLink": "隐私政策",
"accept": "同意并继续",
"giftToast": "礼品已激活:一个月 Barev+"
"giftToast": "房产中介赠送的礼品已激活"
},
"home": {
"hello": "Barev,你好!",
@@ -482,7 +492,7 @@
"allOrders": "全部",
"noOrders": "还没有订单。预订第一个服务只需 2 分钟。",
"repeatLast": "再来一单",
"enterCode": "中介代码",
"enterCode": "房产中介代码",
"plusUntil": "Barev+ 有效期至 {{date}}",
"discount": "Barev+ 享 −{{pct}}%",
"upsellTitle": "Barev Elite 最高 −{{pct}}%",
@@ -582,20 +592,20 @@
},
"profile": {
"title": "我的",
"fromAgency": "{{agency}} 的客户",
"noAgency": "未绑定中介",
"fromAgency": "房产中介 {{agency}} 的客户",
"noAgency": "未绑定房产中介",
"membership": "Barev / Barev+ 会员",
"codeTitle": "中介代码",
"codeText": "通过合作中介租房?输入合同上的代码,即送一个月 Barev+。",
"codeTitle": "房产中介代码",
"codeText": "通过合作房产中介租房?输入合同上的代码,即可领取中介赠送的礼品。",
"codePlaceholder": "例如 DOMIK",
"codeAccepted": "代码已生效:{{agency}}",
"codeGift": "代码已生效:{{agency}}。一个月 Barev+ 已到账!",
"codeGift": "代码已生效:{{agency}}。礼品已激活!",
"language": "语言"
},
"membership": {
"title": "会员",
"current": "当前方案",
"howToGet": "使用中介代码可免费获得 Barev+。在线订阅即将上线。",
"howToGet": "使用房产中介代码可免费获得 Barev+。在线订阅即将上线。",
"request": "订阅 {{plan}}",
"requested": "申请已发送,客服会联系你",
"requestHint": "客服会在 Telegram 联系你,商定付款并开通订阅。",
@@ -712,6 +722,60 @@
"title": "俱乐部会员卡",
"partners": "俱乐部合作伙伴",
"plans": "等级与折扣"
},
"points": {
"title": "Bariki",
"balance": "可用",
"equals": "Bariki ≈ {{amount}} ֏ · 1 Bariki = 1 ֏",
"pending": "待生效:{{amount}},{{date}} 起可用",
"burn": "{{amount}} 将于 {{date}} 过期——任意订单即可保留",
"pay": "用 Bariki 支付",
"enterCode": "输入代码",
"requestPending": "您的 Bariki 支付申请正在等待确认。",
"rates": "各类别返现",
"plusWouldGet": "若是 Barev+,过去 30 天可多得 +{{amount}} Bariki →",
"streak": "连续保洁",
"streakLeft": "再 {{left}} 次,保洁返现 +{{bonus}}%。两次间隔不超过 {{days}} 天。",
"streakMax": "最高连续:每次保洁返现 +{{bonus}}%。",
"route": "首月路线",
"routeText": "在 {{date}} 前预订 3 个不同类别的服务,即得 {{amount}} Bariki。当前:{{done}}/3。",
"history": "记录",
"empty": "每笔已付款订单都可获得 Bariki——按您等级的比例返现。",
"availableFrom": "{{date}} 起可用",
"until": "至 {{date}}",
"payHint": "Bariki 最多可支付订单的 {{pct}}%,最低 {{min}}。团队确认后会告知需补付金额。",
"forOrder": "订单",
"otherOrder": "其他订单(不在应用内)",
"amount": "使用多少 Bariki",
"max": "最多:{{max}}",
"note": "备注",
"notePh": "哪个订单、金额多少",
"send": "发送申请",
"requested": "申请已发送——我们会在聊天中回复。",
"useInOrder": "使用 {{amount}} Bariki",
"useHint": "最多订单的 {{pct}}% · 余额 {{balance}}",
"notWithPromo": "不能与优惠码同时使用",
"belowMin": "最低 {{min}} · 余额 {{balance}}",
"paidLine": "Bariki 支付",
"type": {
"accrual": "返现",
"bonus": "奖励",
"redemption": "Bariki 支付",
"request": "支付申请",
"reversal": "撤销"
},
"status": {
"PENDING": "待生效",
"ACTIVE": "可用",
"SPENT": "已用完",
"EXPIRED": "已过期",
"REVERSED": "已撤销",
"REQUESTED": "等待确认",
"CONFIRMED": "已扣除",
"REJECTED": "已拒绝",
"ACCRUAL": "发放已撤销",
"REDEMPTION": "Bariki 已退回"
}
}
},
"pro": {
@@ -793,7 +857,8 @@
"empty": "该时段没有订单。",
"ratePercent": "费率:订单金额的 {{value}}%",
"rateFixed": "费率:每单 {{value}}",
"ratePreset": "费率:「{{name}}」方案"
"ratePreset": "费率:「{{name}}」方案",
"ratePartner": "订单报酬由您的公司决定"
},
"company": {
"title": "公司",
@@ -849,26 +914,130 @@
"recent": "最近核验",
"noRecent": "暂无核验。",
"checked": "核验会员卡"
},
"web": {
"loginHint": "打开工作机器人,点击「在电脑上打开」(或发送 /web),即可收到登录链接。"
},
"cab": {
"tab": {
"orders": "订单",
"staff": "员工",
"money": "收入",
"settings": "设置"
},
"autoOn": "自动分配已开启:新订单会自动派给空闲员工。",
"income": "您的收入",
"deadline": "请在 {{time}} 前指派",
"offeredTo": "等待 {{name}} 回复",
"decline": "拒绝",
"declineConfirm": "拒绝此订单?订单将退回 Barev Club。",
"declined": "订单已退回",
"reassign": "重新指派",
"unassignConfirm": "将 {{name}} 从订单中移除?订单将重新等待指派。",
"unassigned": "已移除该员工",
"gapHint": "订单之间的路程间隔:{{min}} 分钟。",
"noStaff": "还没有员工——请在「员工」中添加。",
"dayLoad": "当天 {{n}}/{{max}}",
"weekLoad": "本周 {{n}}",
"district": "同区",
"check": {
"ok": "空闲",
"busy": "忙碌",
"gap": "来不及赶到",
"limit": "已达每日上限",
"notLinked": "未登录机器人",
"gap_confirm": "两单之间的路程时间少于设定值。仍要指派吗?",
"limit_confirm": "该员工当天已达订单或工时上限。仍要指派吗?",
"notLinked_confirm": "该员工尚未登录工作机器人,将收不到通知。仍要指派吗?",
"busy_confirm": "该员工此时忙碌。"
},
"addStaff": "添加员工",
"todayLoad": "今天 {{n}}/{{max}}",
"hoursMax": "每天最多 {{n}} 小时",
"off": "已停用",
"staffAdded": "员工已添加",
"saved": "已保存",
"inviteText": "把此链接发给员工:打开工作机器人后即可接单。",
"done": "完成",
"f": {
"name": "姓名",
"phone": "电话",
"languages": "语言",
"districts": "区域(留空表示不限)",
"days": "工作日",
"from": "从",
"to": "到",
"maxOrders": "每天最多订单",
"maxHours": "每天最多小时",
"loadHint": "留空表示不限。自动分配不会超出上限。"
},
"save": "保存",
"resetInvite": "新的登录链接",
"resetConfirm": "生成新链接?员工当前的机器人登录将失效。",
"turnOff": "停用",
"turnOn": "启用",
"offConfirm": "停用 {{name}}?其将不再收到新订单。",
"copy": "复制",
"copied": "已复制",
"balance": "待支付",
"accrued": "已计入",
"paidOut": "已支付",
"terms": {
"COMMISSION": "条款:Barev Club 收取已付订单的 {{pct}}%,其余归您。工资和费用由您承担。",
"PROFIT_SHARE": "条款:保洁员工资({{fot}}%)和税费({{tax}}%)由 Barev Club 支付,您获得剩余部分的 {{pct}}%。"
},
"byMonth": "按月",
"ordersN": "订单:{{n}}",
"byOrder": "按订单",
"noIncome": "客户为已完成订单付款后,收入将显示在这里。",
"paidBy": "已付 {{sum}}",
"minus": "−工资 {{fot}},−税费 {{tax}},您的 {{pct}}%",
"minusCommission": "−{{pct}}% Barev Club",
"payouts": "付款记录",
"payout": {
"paid": "已支付",
"pending": "待支付"
},
"auto": "自动分配",
"autoHint": "新订单会派给最合适的空闲员工:排班空闲、来得及赶到、未超上限;优先考虑会说客户语言、在该区工作、本周订单较少、评分较高的员工。拒绝或未回复则派给下一位。",
"gap": "订单之间的路程间隔,分钟",
"gapMin": "不少于 {{min}} 分钟",
"timeoutHint": "如 {{min}} 分钟内未指派,订单将退回 Barev Club。",
"managers": "管理员",
"you": "您",
"remove": "移除",
"removeManagerConfirm": "将 {{name}} 从管理员中移除?",
"managerInvite": "邀请其他管理员的链接——打开工作机器人即可访问后台:",
"logout": "在此电脑上退出",
"wd": {
"0": "周一",
"1": "周二",
"2": "周三",
"3": "周四",
"4": "周五",
"5": "周六",
"6": "周日"
}
}
},
"bot": {
"client": {
"chooseLanguage": "Выбери язык · Choose your language · 选择语言 · Ընտրեք լեզուն · Elige tu idioma",
"languageSet": "语言:{{language}}",
"fromAgency": "您好!您来自 {{agency}}——我们为您准备了礼品。",
"fromAgency": "您好!您来自房产中介 {{agency}}——我们为您准备了礼品。",
"consentText": "使用 Barev Club 需要您同意个人数据处理。数据会提供给为您服务的人员和政策中列出的合作方,并存储在德国的服务器上。\n\n<a href=\"{{url}}\">隐私政策</a>",
"consentAccept": "同意",
"giftActivated": "🎁 来自中介的礼品:Barev+ 有效期至 {{date}}。服务折扣已生效。",
"giftActivated": "🎁 来自房产中介的礼品:{{plan}} 有效期至 {{date}}。服务折扣已生效。",
"welcome": "在埃里温,从第一天起就像在家。\n\n预订保洁等服务、跟踪订单状态、享受合作伙伴折扣——尽在应用中。",
"openApp": "打开应用",
"enterCode": "输入中介代码",
"enterCode": "输入房产中介代码",
"changeLanguage": "切换语言",
"codePrompt": "请发送租房合同上的中介代码。",
"codePrompt": "请发送租房合同上的房产中介代码。",
"codeAccepted": "代码已生效:{{agency}}。",
"help": "点击「打开应用」预订服务。命令:/app — 应用,/code — 中介代码,/language — 语言。",
"help": "点击「打开应用」预订服务。命令:/app — 应用,/code — 房产中介代码,/language — 语言。",
"cmdStart": "开始",
"cmdApp": "打开应用",
"cmdCode": "输入中介代码",
"cmdCode": "输入房产中介代码",
"cmdLanguage": "切换语言",
"cmdHelp": "帮助",
"webRequestOrdered": "申请 {{request}} 号已收到,并已生成订单 {{order}} 号,时间:{{date}}。状态请在应用中查看。",
@@ -880,7 +1049,44 @@
"cmdSupport": "联系客服",
"cmdConcierge": "联系礼宾(Barev+)",
"rated": "感谢您的评价!{{stars}}",
"ratedLow": "很抱歉服务不够好。请在这里告诉我们发生了什么,团队会处理。"
"ratedLow": "很抱歉服务不够好。请在这里告诉我们发生了什么,团队会处理。",
"wr": {
"intro": "已收到您的申请 №{{request}}。再回答几个问题即可下单。",
"askDistrict": "房子在哪个区?",
"askAddress": "请发送地址:街道、楼号、门牌号。",
"askDay": "什么时候方便?请选择日期。",
"askTime": "请选择 {{day}} 的时间:",
"dayFull": "这一天已约满,请选择其他日期。",
"noSlots": "近几天没有空闲时间——客服会联系您安排时间。"
},
"cmdBalance": "Bariki 余额",
"cmdHistory": "Bariki 记录",
"points": {
"balance": "🪙 Bariki:{{balance}}(≈ {{balance}} ֏)",
"pending": "⏳ 待生效:{{pending}},{{date}} 起可用",
"burn": "🔥 若无订单,将于 {{date}} 过期",
"empty": "暂无 Bariki 记录。",
"open": "打开 Bariki",
"type": {
"accrual": "返现",
"bonus": "奖励",
"redemption": "Bariki 支付",
"request": "支付申请",
"reversal": "撤销"
},
"status": {
"PENDING": "待生效",
"ACTIVE": "可用",
"SPENT": "已用完",
"EXPIRED": "已过期",
"REVERSED": "已撤销",
"REQUESTED": "等待确认",
"CONFIRMED": "已扣除",
"REJECTED": "已拒绝",
"ACCRUAL": "发放已撤销",
"REDEMPTION": "Bariki 已退回"
}
}
},
"pro": {
"notLinked": "此机器人供 Barev Club 服务人员使用。请向客服索取邀请链接。",
@@ -890,7 +1096,9 @@
"youAreDispatcher": "「{{company}}」调度员",
"welcome": "新订单会发到这里。用消息中的按钮接单,并在工作台管理当天订单。",
"openCabinet": "打开工作台",
"messageReceived": "已转给客服,回复会发到这里。"
"messageReceived": "已转给客服,回复会发到这里。",
"openWeb": "💻 在电脑上打开",
"webLink": "浏览器后台链接(一次性,15 分钟内有效):\n{{url}}"
},
"admin": {
"hello": "{{name}},Barev Club 后台通知会发到这里。",
@@ -916,7 +1124,30 @@
"chatAssigned": "{{by}} 将对话转交给你:{{name}}",
"order": "订单 #{{number}}",
"noOrder": "当前没有订单",
"autoFailed": "订单 #{{number}} 没有找到空闲的服务人员,请手动分配。"
"autoFailed": "订单 #{{number}} 没有找到空闲的服务人员,请手动分配。",
"partnerDeclined": "合作方「{{partner}}」拒绝了订单 №{{number}}——请重新指派",
"partnerExpired": "合作方「{{partner}}」未及时为订单 №{{number}} 指派员工——请重新指派",
"noTime": "未指定时间",
"noAddress": "未指定地址",
"points": {
"forOrder": "Order #{{number}} for {{sum}}",
"confirm": "✅ Confirm",
"reject": "Decline",
"request": "Client asks to pay with bariki: {{amount}}",
"balance": "balance {{balance}}",
"suggestions": "New bariki suggestions: {{count}} ({{open}} in the queue)",
"overLimit": "{{by}} wants to process {{amount}} bariki — above the limit, needs your approval",
"mismatch": "Bariki reconciliation: {{count}} clients had a mismatched balance — fixed, check the log",
"confirmed": "Redeemed {{amount}} bariki",
"rejected": "Request declined",
"help": "Credit for an order: /barik <phone or @username> <order amount> [category]\nCategories: cleaning, documents, learning, repair, transfer",
"noClient": "Client not found",
"preview": "Credit {{amount}} bariki to {{name}} for {{what}} {{sum}} ֏ ({{tier}}, {{rate}}%). Available from {{date}}.",
"accrue": "🪙 Credit",
"done": "Credited {{amount}} bariki",
"needsApproval": "Above the limit — sent to a super admin for approval",
"zero": "The amount is 0 — nothing to credit"
}
},
"partner": {
"valid": "{{name}} — {{level}},会员卡有效",
@@ -1011,34 +1242,58 @@
"rescheduled": "订单已改到 {{when}}。",
"unassigned": "{{when}} 的订单已从您名下移除。",
"reminder24": "明天有订单:{{when}}。",
"reminder1": "一小时后有订单:{{when}}。"
"reminder1": "一小时后有订单:{{when}}。",
"orderEdited": "{{when}} 的订单已更新——请查看。"
},
"dispatcher": {
"newOrder": "公司有新订单:{{when}}。请在工作台分配员工。",
"returned": "员工拒绝了 {{when}} 的订单,请重新分配。"
"returned": "员工拒绝了 {{when}} 的订单,请重新分配。",
"noStaff": "{{when}} 的订单没有空闲员工——请在后台手动指派。",
"expired": "{{when}} 的订单已退回 Barev Club:未能及时指派员工。"
},
"openPlan": "打开应用",
"concierge": "Barev Club 礼宾",
"subscriptionActivated": "{{plan}} 已开通,有效期至 {{date}}。服务价格已含折扣。",
"operator": "Barev Club 客服",
"support": "Barev Club 客服支持",
"rateAsk": "服务体验如何?请花一秒钟评价一下:"
"rateAsk": "服务体验如何?请花一秒钟评价一下:",
"points": {
"open": "我的 Bariki",
"pending": "🪙 +{{amount}} Bariki({{what}},{{date}})。{{available}} 起可用。",
"active": "🪙 +{{amount}} Bariki({{what}})。余额:{{balance}}。",
"bonus": "🎁 赠送 +{{amount}} Bariki:{{what}}。有效期至 {{until}}。",
"bonusNoTtl": "🎁 赠送 +{{amount}} Bariki:{{what}}。",
"housewarming": "来自 {{agency}} 的乔迁礼",
"activated": "✅ {{amount}} Bariki 已可用。余额:{{balance}}。",
"redeemed": "Bariki 已支付:{{amount}}。需补付:{{due}}。余额:{{balance}}。",
"redeemedNoOrder": "已扣除 Bariki:{{amount}}。余额:{{balance}}。",
"requestExpired": "您的 Bariki 支付申请已过期。如需要请重新发送。",
"rejected": "您的 Bariki 支付申请被拒绝:{{reason}}",
"declinedGeneric": "请在聊天中联系我们。",
"orderCancelled": "订单已取消",
"returned": "↩️ {{amount}} Bariki 已退回。余额:{{balance}}。",
"reversed": "发放 {{what}} 已撤销:{{reason}}。余额:{{balance}}。",
"expiring": "⏰ {{amount}} Bariki 将在 {{days}} 天后过期。任意订单即可保留。",
"expired": "{{amount}} Bariki 因长期无订单已过期。",
"orderEdited": "订单已修改——将按新金额发放"
},
"orderEdited": "您的订单已更新。应付总额:{{total}}。"
},
"admin": {
"nav": {
"orders": "订单",
"clients": "客户",
"providers": "服务人员",
"companies": "合作公司",
"companies": "服务合作方",
"services": "服务与价格",
"agencies": "中介",
"agencies": "房产中介",
"settings": "设置",
"presets": "费率",
"webRequests": "网站申请",
"leads": "搬家",
"concierge": "礼宾",
"requests": "应用申请",
"partners": "合作伙伴",
"partners": "俱乐部合作商家",
"telegram": "通知",
"templates": "搬家模板"
},
@@ -1177,7 +1432,10 @@
"finishLesson": "课程结束",
"openMeeting": "咨询已打开",
"startMeeting": "咨询开始",
"finishMeeting": "咨询结束"
"finishMeeting": "咨询结束",
"editedByStaff": "团队修改了订单",
"checklistByStaff": "团队勾选了清单项",
"checklistUncheckedByStaff": "团队取消了清单项"
},
"weekdays": {
"0": "周一",
@@ -1256,7 +1514,7 @@
"eliteDiscount": "Elite 折扣"
},
"agencies": {
"add": "添加中介",
"add": "添加房产中介",
"name": "名称",
"code": "代码",
"commission": "佣金,%",
@@ -1289,7 +1547,7 @@
"cancel_freeHours": "免费取消截止时间,提前小时数",
"agency_attributionMonths": "客户归属中介,月",
"agency_defaultCommissionPct": "默认中介佣金,%",
"barevPlus_giftDays": "赠送 Barev+,天",
"barevPlus_giftDays": "落地页:「前 N 天免费」(礼品本身在各中介中设置)",
"barevPlus_monthlyPrice": "Barev+ 月费,֏",
"offer_acceptTimeoutMinutes": "服务人员回复时限,分钟",
"chat_closeAfterHours": "订单结束后聊天关闭,小时",
@@ -1321,7 +1579,23 @@
"sales_bonusPerVenue": "每个商户的销售奖金,֏",
"sales_bonusPerAgency": "每个代理的销售奖金,֏",
"sales_bonusAgencyPct": "销售奖金:其代理客户收入的百分比",
"sales_bonusAgencyMonths": "……代理加入后的月数"
"sales_bonusAgencyMonths": "……代理加入后的月数",
"partner_fotPct": "合作方(费用由我们承担):保洁员工资,订单的 %",
"partner_taxPct": "合作方(费用由我们承担):税费,%",
"partner_minTravelGapMinutes": "合作方:员工两单之间的最短间隔,分钟",
"partner_acceptTimeoutMinutes": "合作方:指派员工的时限(分钟),超时订单退回",
"points_pendingHours": "Bariki:服务后 N 小时可用",
"points_maxSharePct": "Bariki:最多支付订单的 N%",
"points_minRedeem": "Bariki:每次最低使用",
"points_burnDays": "Bariki:无活动 N 天后过期",
"points_warnDays1": "Bariki:提前 N 天首次提醒",
"points_warnDays2": "Bariki:提前 N 天再次提醒",
"points_requestTtlHours": "Bariki:客户申请等待 N 小时",
"points_streakGapDays": "Bariki:连续保洁——间隔不超过 N 天",
"points_diffHighlightPct": "Bariki:手动金额偏差超过 N% 时高亮",
"points_supportCanManage": "客服也可发放和扣除 Bariki(有限额)",
"points_opMaxPerOperation": "客服单次操作限额(Bariki)",
"points_opMaxPerDay": "客服每日限额(Bariki)"
},
"presets": {
"add": "添加方案",
@@ -1549,7 +1823,7 @@
"step4_sub": "内置与服务人员聊天"
},
"agency": {
"title": "通过合作中介租房?",
"title": "通过合作房产中介租房?",
"text": "一个月 Barev+ 的代码已写在合同里。",
"gift": "礼品!",
"giftSub": "1 个月 Barev+"
@@ -1571,7 +1845,7 @@
"a": "证件、生活服务、学习和职业咨询最高享 {{pct}}% 折扣。"
},
"gift": {
"q": "如何领取中介礼品?",
"q": "如何领取房产中介的礼品?",
"a": "代码在租房合同中。在机器人中输入代码,即可获得 {{days}} 天 Barev+。"
}
},
@@ -1580,7 +1854,11 @@
"partner": "成为合作伙伴",
"privacy": "隐私政策",
"legal": "个体经营者 Yuri Zabudko · 税号(ՀՎՀՀ){{tin}}",
"services": "服务"
"services": "服务",
"pricing": "价格",
"terms": "使用条款",
"refund": "取消与退款",
"rules": "服务规则"
},
"clean": {
"meta": {
@@ -1646,7 +1924,11 @@
"how2": "在 Telegram 确认",
"how2Sub": "机器人显示订单和状态",
"how3": "保洁员上门",
"how3Sub": "按清单工作,并发送照片报告"
"how3Sub": "按清单工作,并发送照片报告",
"whenHintOptional": "可选:如不选择,机器人会在 Telegram 中询问。",
"addressHintOptional": "可选:提交后也可以把地址发给机器人。",
"nameTitle": "怎么称呼您",
"consentByClick": "点击按钮即表示您同意处理个人数据。"
},
"v1": {
"title": "在埃里温生活所需,一个聊天全搞定",
@@ -1687,7 +1969,7 @@
"airportTitle": "机场接机并入住酒店",
"airportText": "司机在兹瓦尔特诺茨机场举牌接你,送到酒店,头几天无忧无虑。",
"flatTitle": "帮你找公寓",
"flatText": "通过合作中介找房,入住前帮你做好保洁。",
"flatText": "通过合作房产中介找房,入住前帮你做好保洁。",
"docsTitle": "办理登记和社会卡",
"docsText": "准备材料、预约,并陪你一直到拿到卡。",
"permitTitle": "协助准备居留许可文件",
@@ -1700,9 +1982,9 @@
"chatCta": "在聊天里留言"
},
"v3": {
"tag": "合作中介的租客专享",
"tag": "合作房产中介的租客专享",
"title": "欢迎回家",
"sub": "帮你租房的中介送你一个月 Barev+:保洁、证件和课程都有折扣。",
"sub": "帮你租房的房产中介送你 Barev+ 订阅:保洁、证件和课程都有折扣。",
"giftTitle": "Barev+ 1 个月",
"giftCode": "代码 {{code}} 会自动生效",
"giftNoCode": "代码在租房合同里",
@@ -1756,14 +2038,14 @@
"perYear": "一年:{{amount}}",
"other": "订阅同样适用于其他服务类别",
"cta": "试用 Barev+",
"gift": "通过合作中介租的房?第一个月免费赠送。"
"gift": "通过合作房产中介租的房?订阅免费赠送。"
},
"v6": {
"tag": "致房产中介",
"title": "一份关于你的租客如何在埃里温安顿下来的协议",
"sub": "你送客户一个月 Barev+,我们帮他们处理日常琐事,你从他们的订单中获得分成。",
"docDate": "埃里温",
"s1": "中介获得什么",
"s1": "房产中介获得什么",
"s1i1": "客户首次打开机器人后 {{months}} 个月内,每笔订单的 {{pct}}%。",
"s1i2": "专属代码和二维码,可放进租房合同或随钥匙一起交给客户。",
"s1i3": "客户和订单报告:来了多少人、下单多少、你赚了多少。",
@@ -1779,7 +2061,7 @@
"exampleText": "{{clients}} 位客户 × 每月 {{orders}} 次保洁 × {{price}}",
"exampleResult": "中介每月获得 {{amount}}",
"exampleNote": "按两居室公寓的日常保洁计算。",
"signAgency": "中介",
"signAgency": "房产中介",
"signUs": "Barev Club",
"cta": "成为合作伙伴"
},
@@ -2315,7 +2597,7 @@
},
"privacy": {
"title": "隐私政策",
"updated": "2026年10月20日版本",
"updated": "2026 年 10 月 22 日版本",
"intro": "本政策说明 Barev Club 收集哪些个人数据、用途、与谁共享,以及您如何管理这些数据。本政策适用于 barevclub.com 网站、Barev Club 的 Telegram 机器人和小程序。",
"operator": {
"title": "谁处理您的数据",
@@ -2323,7 +2605,7 @@
},
"data": {
"title": "我们获取哪些数据",
"body": "• 来自 Telegram:ID、用户名、个人资料中的姓名、语言。电话号码仅在您主动分享时获取。\n• 网站申请:姓名、电话或 Telegram 用户名、搬迁规划中的回答(时间、所需服务、预算)。保洁服务另需地址、区域、期望时间和备注。\n• 订单:地址和区域、地图位置(如您提供)、服务参数、日期和时间、价格、折扣、付款状态、备注。\n• 证件:您为“证件”类服务上传的护照及其他证件扫描件。\n• 通过机器人与服务人员的聊天记录、照片报告、评分和评价。\n• 会员信息:等级(Barev、Barev+、Elite)、订阅期限、会员卡代码及在合作商家处的使用记录。\n• 来源:您通过的中介机构或优惠码,以及广告活动标记(UTM)。\n• 技术数据:保存网站语言的 cookie、浏览器存储中的中介代码,以及用于防范故障和攻击的服务器日志(IP 地址、请求时间和地址)。\n我们不获取银行卡信息。上线在线支付后,卡信息将在支付服务商一侧填写。"
"body": "• 来自 Telegram:ID、用户名、个人资料中的姓名、语言。电话号码仅在您主动分享时获取。\n• 网站申请:姓名、电话或 Telegram 用户名、搬迁规划中的回答(时间、所需服务、预算)。保洁服务另需地址、区域、期望时间和备注。\n• 订单:地址和区域、地图位置(如您提供)、服务参数、日期和时间、价格、折扣、付款状态、备注。\n• 证件:您为“证件”类服务上传的护照及其他证件扫描件。\n• 通过机器人与服务人员的聊天记录、照片报告、评分和评价。\n• 会员信息:等级(Barev、Barev+、Elite)、订阅期限、会员卡代码及在合作商家处的使用记录。\n• 来源:您通过的中介机构或优惠码,以及广告活动标记(UTM)。\n• 技术数据:保存网站语言的 cookie、浏览器存储中的中介代码,以及用于防范故障和攻击的服务器日志(IP 地址、请求时间和地址)。\n我们不获取银行卡信息:在线支付时,卡信息在我们的在线经销商 Paddle 的页面上填写。"
},
"purposes": {
"title": "我们为什么使用这些数据",
@@ -2335,7 +2617,7 @@
},
"recipients": {
"title": "我们与谁共享数据",
"body": "我们不出售个人数据,只共享具体任务所需的信息:\n• 您订单的服务人员;如由合作公司完成,还包括该公司的调度员:姓名、语言、地址和订单详情。您的电话号码不会显示给服务人员,沟通通过机器人进行。\n• 推荐您的房产中介:姓名、是否使用俱乐部服务以及已付款订单金额,用于计算中介佣金。\n• 您出示会员卡时的俱乐部合作商家:姓名和会员等级。\n• Telegram:机器人和小程序在其上运行,Telegram 按其自身政策处理数据。\n• Hostinger International:数据库和文件存储在其服务器上。\n• 上线在线支付后的支付服务商。\n• 政府机关:当您订购证件服务、我们代您提交材料时,或法律要求时。"
"body": "我们不出售个人数据,只共享具体任务所需的信息:\n• 您订单的服务人员;如由合作公司完成,还包括该公司的调度员:姓名、语言、地址和订单详情。您的电话号码不会显示给服务人员,沟通通过机器人进行。\n• 推荐您的房产中介:姓名、是否使用俱乐部服务以及已付款订单金额,用于计算中介佣金。\n• 您出示会员卡时的俱乐部合作商家:姓名和会员等级。\n• Telegram:机器人和小程序在其上运行,Telegram 按其自身政策处理数据。\n• Hostinger International:数据库和文件存储在其服务器上。\n• Paddle.com——处理在线支付的在线经销商:姓名、电子邮箱、国家和付款信息。Paddle 作为独立的数据控制者,按其自身的隐私政策处理这些数据。\n• 政府机关:当您订购证件服务、我们代您提交材料时,或法律要求时。"
},
"location": {
"title": "数据存储在哪里",
@@ -2369,5 +2651,151 @@
"title": "政策变更",
"body": "我们可能会更新本政策。现行版本始终在本页面,版本日期标注在开头。重大变更我们会在机器人中通知您。\n俄语文本为正式版本,其他语言译文仅为方便阅读。"
}
},
"pricing": {
"title": "价格",
"updated": "自 2026 年 10 月 22 日起生效",
"intro": "Barev Club 会员和俱乐部服务的价格。不订阅会员也可以下单,会员只是额外提供折扣和礼宾服务。所有价格均以亚美尼亚德拉姆(֏)计。",
"from": "{{price}} 起",
"perDay": "每晚 {{price}}",
"plansTitle": "俱乐部会员",
"plansNote": "会员按月付费,在您取消之前自动续订。您可以随时取消,会员资格保留到已付费月份结束。",
"planNote": {
"free": "在 Telegram 机器人中注册,无需付费,没有期限。",
"plus": "在机器人中开通。如果您是通过合作房产中介来的,订阅免费赠送(通常为 {{days}} 天或更长,具体由各中介决定)。",
"elite": "通过机器人聊天中的俱乐部客服开通。"
},
"servicesTitle": "服务",
"servicesNote": "以下为基础价格。在您确认订单之前,机器人会显示包含您所选参数和会员折扣的准确价格。确认后价格不再变动。",
"rows": {
"cleaning": {
"name": "公寓清洁",
"note": "取决于清洁类型和房间数量"
},
"transferStandard": {
"name": "兹瓦尔特诺茨机场接机,标准",
"note": "最多 3 名乘客,举牌接机,等候 60 分钟"
},
"transferPremium": {
"name": "兹瓦尔特诺茨机场接机,高级",
"note": "商务车型,饮用水,SIM 卡"
},
"hotel": {
"name": "落地头几天的酒店",
"note": "Barev+ 会员价;非会员每晚 {{base}} 起"
},
"socialCard": {
"name": "社会卡全程办理",
"note": "护照翻译、公证、预约、陪同"
},
"consultation": {
"name": "居留许可咨询",
"note": "费用可抵扣陪同办理费"
},
"residence": {
"name": "居留许可陪同办理",
"note": "申请依据、材料、递交、到场办理。1 年期居留许可的国家规费 {{fee}} 另行向国家缴纳"
}
},
"payTitle": "付款与取消",
"payBody": "Barev+ 会员可以在线刷卡支付——付款由我们的在线经销商 Paddle.com 处理,会员费可在 14 天内申请退款。服务以现金或转账支付,付款方式在订单中选择。服务开始前 24 小时以上可以免费取消或改期。详情请见使用条款和退款政策。"
},
"terms": {
"title": "使用条款",
"updated": "2026 年 10 月 22 日版本",
"intro": "本条款规定 Barev Club 通过 barevclub.com 网站、Telegram 机器人和小程序提供服务的条件。下单或开通会员即表示您接受本条款。",
"about": {
"title": "我们是谁,做什么",
"body": "Barev Club 是为搬到亚美尼亚或已在这里生活的人提供的服务:生活服务(清洁、机场接机、落地头几天的酒店)、证件办理协助(社会卡、居留许可)、学习课程以及俱乐部合作伙伴的折扣。\n服务提供方为个体经营者 Yuri Konstantinovich Zabudko(Ա/Ձ Զաբուդկո Յուրի Կոնստանտինի),亚美尼亚共和国,税号(ՀՎՀՀ){{tin}}。联系方式:{{email}},{{phone}}。\n订单由经过审核的服务人员完成——我们的员工或合作公司。对您而言,由 Barev Club 对结果负责。"
},
"acceptance": {
"title": "接受条款",
"body": "自您在网站或机器人中下单、开通会员或提交申请之时起,本条款即适用。使用本服务须年满 18 岁。\n如果您不同意本条款,请不要下单。"
},
"orders": {
"title": "下单流程",
"body": "• 您在机器人中选择服务或在网站上提交申请,并填写地址、日期、时间等信息。\n• 在确认之前,机器人会显示总价、折扣和取消条件。\n• 您确认订单且我们指派服务人员后,订单即被接受。您会收到此次及之后每个状态的通知。\n• 您通过机器人中的聊天与服务人员沟通。\n如果无法在您选择的时间完成订单,我们会提供其他时间,或免费取消订单。"
},
"prices": {
"title": "价格",
"body": "价格以亚美尼亚德拉姆标示在「价格」页面和机器人中。我们可能调整价格,但已确认订单的价格不会改变。\n除非明确说明,国家规费和税费(例如居留许可规费)不包含在服务价格中。我们会提前告知金额。"
},
"payment": {
"title": "付款",
"body": "Barev+ 会员可以在线刷卡支付。在线订单由我们的在线经销商 Paddle.com 处理。Paddle.com 是我们所有在线订单的记录商户(Merchant of Record):负责收款、开具发票、处理付款相关咨询和退款。银行卡信息在 Paddle 页面上填写,我们不会接收或保存。\n服务(清洁、接机、证件办理等)不在线销售:可用现金或转账给服务人员,或转账至 Barev Club 账户支付,付款方式在订单中选择。\n如有需要,我们可以开具付款凭证。"
},
"cancel": {
"title": "取消与改期",
"body": "服务开始前 24 小时以上,您可以在机器人中或与客服聊天免费取消或改期。\n更晚取消将扣除部分费用。扣除比例在确认订单前显示在服务卡片上,默认为 30%。\n如果是我们取消订单或服务人员未到场,将全额退款。详情请见退款政策。"
},
"subscription": {
"title": "Barev+ 和 Barev Elite 会员",
"body": "• 会员可享受俱乐部服务折扣、合作伙伴礼品和聊天礼宾服务。各等级内容和价格见「价格」页面。\n• 会员按月付费,在您取消之前自动续订。我们会在续订前 3 天提醒您。\n• 折扣适用于会员有效期内下的订单。\n• 凭合作中介代码获得的免费期,每位客户仅限一次。\n• 您可以随时在机器人中或与客服聊天取消会员,会员资格保留到已付费期结束。"
},
"perks": {
"title": "合作伙伴折扣与礼品",
"body": "凭俱乐部卡享受的折扣和礼品由俱乐部合作伙伴提供——咖啡馆、餐厅和其他商家。每项优惠的条件见合作伙伴目录。\n合作伙伴对其商品和服务负责。合作伙伴的优惠可能变更或结束。"
},
"docs": {
"title": "证件服务",
"body": "我们协助您准备材料、翻译、预约并陪同您前往政府机构。是否签发证件或居留许可由政府机构决定,我们无法保证结果。\n您需保证提供给我们的文件和信息真实准确。如果因我们的失误被拒,我们将自费纠正。"
},
"client": {
"title": "您的义务",
"body": "• 提供正确信息:地址、联系方式、订单参数。\n• 在约定时间提供进入公寓的条件,并为服务人员提供安全的工作环境。\n• 尊重服务人员和客服。\n• 不将本服务用于违法目的。\n如果因您的原因服务人员无法开始工作(例如地址处无人),视为逾期取消。"
},
"quality": {
"title": "质量与投诉",
"body": "如果您对结果不满意,请在完成后 24 小时内在订单聊天中留言,可以的话附上照片。我们会免费返工,或退还部分或全部费用。\n如果服务人员损坏了您的财物,请在 24 小时内告知我们——我们会调查并赔偿经确认的损失。"
},
"liability": {
"title": "责任",
"body": "我们对订单的责任以订单金额为限,但经确认的财产损失以及法律不允许此类限制的情况除外。\n对于我们无法控制的情况造成的延误和取消,我们不承担责任:政府机构的决定、航班延误、自然灾害、Telegram 故障。"
},
"content": {
"title": "网站内容",
"body": "文字、设计、标志和 Barev Club 名称归服务所有者所有。未经许可不得复制。"
},
"changes": {
"title": "条款变更",
"body": "我们可能更新本条款。现行版本始终在本页面,版本日期标在开头。已确认的订单按原条款执行。会员的重大变更我们会提前在机器人中通知您。"
},
"law": {
"title": "适用法律与争议",
"body": "本条款适用亚美尼亚共和国法律。争议首先通过协商解决——请写信至 {{email}},我们会在 3 个工作日内回复。如果无法达成一致,争议由亚美尼亚共和国法院审理。\n本条款不限制您依法享有的消费者权利。\n原文为俄语,译文仅为方便阅读。"
}
},
"refund": {
"title": "取消与退款",
"updated": "2026 年 10 月 22 日版本",
"intro": "Barev Club 在什么情况下、退还多少服务费和会员费,以及如何申请退款。",
"cancelClient": {
"title": "如果您取消",
"body": "• 服务开始前 24 小时及以上——取消和改期免费,全额退款。\n• 不足 24 小时——扣除部分费用,其余退还。扣除比例在确认订单前显示在服务卡片上,默认为 30%。\n• 服务开始后,已完成部分的费用不予退还。"
},
"cancelUs": {
"title": "如果我们取消",
"body": "如果我们取消了订单、没有找到服务人员或服务人员未到场,由您选择:改到方便的时间,或全额退款。"
},
"quality": {
"title": "如果您对结果不满意",
"body": "请在完成后 24 小时内在订单聊天中留言,可以的话附上照片。我们会免费返工;如果无法返工,将根据问题情况退还部分或全部费用。"
},
"subscription": {
"title": "Barev+ 和 Barev Elite 会员",
"body": "• 任何一笔会员费,只要在扣款后 14 天内提出申请,均可全额退还。\n• 您可以随时取消——下一次将不再扣款,会员资格保留到已付费月份结束。\n• 免费期不收费,因此无需退款。"
},
"nonRefundable": {
"title": "不予退还的费用",
"body": "• 我们已代您缴纳的国家规费和税费。\n• 我们已为您的订单向第三方支付的费用:例如公证翻译,或按酒店规定处理的酒店预订。\n• 俱乐部合作伙伴的商品和服务——适用合作伙伴的规定。"
},
"howTo": {
"title": "如何申请退款",
"body": "在机器人聊天中或发送邮件至 {{email}},注明订单号或会员编号以及原因。我们会在 3 个工作日内回复。\n如果您在线支付了会员费,也可以直接在 paddle.net 向 Paddle 申请退款,并在那里查到您的付款和收据。"
},
"timing": {
"title": "退款时间与方式",
"body": "退款在批准后 10 个工作日内按您的原付款方式退回。退回银行卡可能还需几天,取决于您的银行。如果您以现金付款,我们会按您的意愿通过转账或现金退还。\n退款问题:{{email}},{{phone}}。"
}
}
}
+54 -25
View File
@@ -7,6 +7,8 @@ import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
import { transitionOrder } from './orders.ts';
import { orderPayout } from './payouts.ts';
import { companyAutoAssign, companyAvailability, companySplit, expireCompanyOffers } from './servicePartners.ts';
const DAY_MS = 24 * 3600_000;
@@ -20,6 +22,7 @@ function interval(o: { scheduledAt: Date; durationMinutes: number }) {
*/
export async function assignmentCandidates(ctx: Ctx, orderId: string) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } });
const settings = await getSettings(ctx.db);
const slot = interval(order);
const dayStart = startOfYerevanDay(order.scheduledAt);
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
@@ -27,7 +30,7 @@ export async function assignmentCandidates(ctx: Ctx, orderId: string) {
const [availability, providers, companies] = await Promise.all([
loadAvailability(ctx.db, role, dayStart, dayEnd, { excludeOrderId: order.id }),
ctx.db.provider.findMany({ where: { role, active: true }, include: { districts: true } }),
ctx.db.provider.findMany({ where: { role, active: true }, include: { districts: true, payoutPreset: true } }),
ctx.db.providerCompany.findMany({ where: { active: true, roles: { has: role } } }),
]);
const availById = new Map(availability.map((a) => [a.id, a]));
@@ -36,26 +39,37 @@ export async function assignmentCandidates(ctx: Ctx, orderId: string) {
const a = availById.get(id);
return !!a && isProviderFree(a, slot);
};
/** что мы платим исполнителю и что остаётся нам (до налога) — оператор выбирает самый выгодный вариант */
const money = new Map<string, { cost: number; margin: number; partnerFree?: number; partnerStaff?: number }>();
const candidates: Candidate[] = providers
.filter((p) => !p.companyId)
.map((p) => ({
kind: 'provider',
id: p.id,
name: p.name,
languages: p.languages as Locale[],
districtIds: p.districts.map((d) => d.districtId),
load: loadOf(p.id),
rating: averageRating(p.ratingSum, p.ratingCount),
free: freeOf(p.id),
onShift: p.onShift,
}));
.map((p) => {
const cost = orderPayout(order, { ...p, company: null }, { fotPct: settings['partner.fotPct'] });
money.set(`provider:${p.id}`, { cost, margin: order.totalPrice - cost });
return {
kind: 'provider' as const,
id: p.id,
name: p.name,
languages: p.languages as Locale[],
districtIds: p.districts.map((d) => d.districtId),
load: loadOf(p.id),
rating: averageRating(p.ratingSum, p.ratingCount),
free: freeOf(p.id),
onShift: p.onShift,
};
});
for (const c of companies) {
const staff = providers.filter((p) => p.companyId === c.id);
if (!staff.length) continue;
const free = staff.filter((p) => freeOf(p.id));
// партнёр «доступен», если у него есть сотрудник, свободный по графику, с перерывом на дорогу и в лимите нагрузки
const avail = await companyAvailability(ctx.db, c.id, order.id, settings);
const freeIds = new Set(avail.rows.filter((r) => r.check === 'ok' || r.check === 'notLinked').map((r) => r.id));
const free = staff.filter((p) => freeIds.has(p.id));
const ratings = staff.filter((p) => p.ratingCount > 0);
const split = companySplit(c, order.totalPrice, settings);
money.set(`company:${c.id}`, { cost: split.cost, margin: split.margin, partnerFree: free.length, partnerStaff: staff.length });
candidates.push({
kind: 'company',
id: c.id,
@@ -74,13 +88,16 @@ export async function assignmentCandidates(ctx: Ctx, orderId: string) {
});
}
const withMoney = <T extends Candidate>(c: T) => ({ ...c, ...(money.get(`${c.kind}:${c.id}`) ?? { cost: 0, margin: order.totalPrice }) });
const ranked = rankCandidates(candidates, {
locale: order.locale as Locale,
districtId: order.districtId,
isOnline: order.isOnline,
});
const busy = candidates.filter((c) => !c.free);
return { ranked, busy };
}).map(withMoney);
const busy = candidates.filter((c) => !c.free).map(withMoney);
// самый выгодный для нас из доступных
const best = ranked.reduce<(typeof ranked)[number] | null>((b, c) => (!b || c.margin > b.margin ? c : b), null);
return { ranked, busy, bestId: best ? `${best.kind}:${best.id}` : null, paid: order.totalPrice };
}
async function revokeActiveOffers(tx: Tx, orderId: string) {
@@ -136,9 +153,9 @@ export async function offerOrder(
if (!company || !company.active || !company.roles.includes(order.service.providerRole)) throw new DomainError('NOT_FOUND');
await revokeActiveOffers(tx, order.id);
await tx.order.update({ where: { id: order.id }, data: { companyId: company.id } });
// предложение компании без таймаута — заказ «Назначен на компанию», пока диспетчер не выберет сотрудника
// заказ «Назначен на партнёра», пока менеджер не выберет сотрудника; не успел за N минут — заказ вернётся оператору
const offer = await tx.orderOffer.create({
data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId },
data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId, expiresAt: new Date(ctx.now().getTime() + settings['partner.acceptTimeoutMinutes'] * 60_000) },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'assignedToCompany', actorType: input.adminId ? 'ADMIN' : 'SYSTEM', actorId: input.adminId, comment: company.name },
@@ -147,6 +164,7 @@ export async function offerOrder(
return offer;
});
for (const e of events) await ctx.notify.emit(e);
if (input.target.kind === 'company') await companyAutoAssign(ctx, input.orderId);
return offer;
}
@@ -182,6 +200,11 @@ export async function respondToOffer(ctx: Ctx, input: { offerId: string; provide
return declineOffer(ctx, tx, input.offerId, 'DECLINED', input.providerId);
});
if (event) await ctx.notify.emit(event);
// сотрудник партнёра отказался — автораспределение партнёра предложит следующему
if (!input.accept) {
const offer = await ctx.db.orderOffer.findUnique({ where: { id: input.offerId } });
if (offer?.companyId) await companyAutoAssign(ctx, offer.orderId);
}
}
async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED' | 'EXPIRED', providerId?: string): Promise<DomainEvent> {
@@ -190,8 +213,9 @@ async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED
await tx.orderOffer.update({ where: { id: offer.id }, data: { status, respondedAt: ctx.now() } });
const companyId = offer.provider?.companyId ?? null;
if (companyId) {
// сотрудник отказался — заказ снова у диспетчера компании
await tx.orderOffer.create({ data: { orderId: offer.orderId, companyId, status: 'OFFERED' } });
// сотрудник отказался — заказ снова у менеджера партнёра (срок на назначение — заново)
const settings = await getSettings(tx);
await tx.orderOffer.create({ data: { orderId: offer.orderId, companyId, status: 'OFFERED', expiresAt: new Date(ctx.now().getTime() + settings['partner.acceptTimeoutMinutes'] * 60_000) } });
} else {
await tx.order.update({ where: { id: offer.orderId }, data: { companyId: null } });
}
@@ -211,18 +235,21 @@ async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED
export async function expireOffers(ctx: Ctx): Promise<number> {
const expired = await ctx.db.orderOffer.findMany({
where: { status: 'OFFERED', expiresAt: { lt: ctx.now() }, providerId: { not: null } },
select: { id: true },
select: { id: true, orderId: true, companyId: true },
});
let n = 0;
for (const { id } of expired) {
for (const { id, orderId, companyId } of expired) {
try {
const event = await ctx.db.$transaction((tx) => declineOffer(ctx, tx, id, 'EXPIRED'));
await ctx.notify.emit(event);
n++;
if (companyId) await companyAutoAssign(ctx, orderId);
} catch (e) {
if (!(e instanceof DomainError)) throw e;
}
}
// партнёр не назначил сотрудника вовремя — заказ возвращается оператору
await expireCompanyOffers(ctx);
return n;
}
@@ -249,7 +276,9 @@ export async function dispatcherAssign(ctx: Ctx, input: { orderId: string; compa
(x) => x.id === provider.id,
);
if (!avail || !isProviderFree(avail, interval(order))) throw new DomainError('PROVIDER_BUSY');
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED' }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } });
// предложение партнёру принято; ожидающее предложение другому сотруднику (автораспределение) — отозвано
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED', providerId: null }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } });
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED', providerId: { not: null } }, data: { status: 'REVOKED', respondedAt: ctx.now() } });
if (flow.assigned) {
const r = await transitionOrder(
ctx,
@@ -334,9 +363,9 @@ export async function autoAssign(ctx: Ctx, orderId: string, opts: { force?: bool
}
const triedProviders = new Set(order.offers.map((o) => o.providerId).filter((x): x is string => !!x));
const triedCompanies = new Set(order.offers.filter((o) => !o.providerId).map((o) => o.companyId).filter((x): x is string => !!x));
const { ranked } = await assignmentCandidates(ctx, order.id);
const pick = ranked.find((c) => (c.kind === 'company' ? !triedCompanies.has(c.id) : !triedProviders.has(c.id)));
// партнёрам заказы назначает оператор (видит, что выгоднее); система предлагает только частным исполнителям
const pick = ranked.find((c) => c.kind === 'provider' && !triedProviders.has(c.id));
if (!pick) {
if (lastAuto === 'autoNoCandidate') return { result: 'none', first: false };
await mark('autoNoCandidate');
+16 -10
View File
@@ -9,9 +9,10 @@ import {
type Locale,
type MembershipLevel,
} from '@barev/core';
import type { Client, Db, Tx } from '@barev/db';
import type { Client, Db, SubscriptionPlan, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { getSettings } from './settings.ts';
import { grantHousewarming } from './points.ts';
export interface TelegramUserInput {
id: number | bigint;
@@ -58,7 +59,7 @@ export interface StartResult {
locale: Locale;
agencyName: string | null;
/** подарок активирован в этом /start */
gift: { endsAt: Date } | null;
gift: { endsAt: Date; plan: SubscriptionPlan } | null;
}
/**
@@ -136,12 +137,12 @@ export async function giveConsent(ctx: Ctx, clientId: string) {
}
/**
* ONB-4 / SUB-3: подарочный месяц Barev+ клиенту агентства, один раз.
* Возвращает дату окончания или null, если не положен.
* ONB-4 / SUB-3: подарок клиенту агентства, один раз. Уровень (Barev+ / Elite) и срок в месяцах задаются у агентства.
* Elite при действующем Barev+ начинается сразу (уровень выше), в остальных случаях — после текущей подписки.
* Возвращает уровень и дату окончания или null, если подарок не положен.
*/
export async function activateAgencyGift(ctx: Ctx, clientId: string): Promise<{ endsAt: Date } | null> {
export async function activateAgencyGift(ctx: Ctx, clientId: string): Promise<{ endsAt: Date; plan: SubscriptionPlan } | null> {
const { db } = ctx;
const settings = await getSettings(db);
const result = await db.$transaction(async (tx) => {
// блокируем строку клиента, чтобы параллельные /start не выдали подарок дважды
await tx.$queryRaw`SELECT id FROM "Client" WHERE id = ${clientId} FOR UPDATE`;
@@ -150,23 +151,28 @@ export async function activateAgencyGift(ctx: Ctx, clientId: string): Promise<{
include: { agency: true, subscriptions: true },
});
if (!client.agency || !client.consentAt) return null;
const plan = client.agency.giftPlan;
if (!plan) return null;
const now = ctx.now();
const current = activeSubscription(client.subscriptions, now);
const extends_ = current && (current.plan === plan || current.plan === 'ELITE');
const decision = decideGift({
now,
giftUsedAt: client.giftUsedAt,
agencyActive: client.agency.active,
giftDays: settings['barevPlus.giftDays'],
currentEndsAt: current?.endsAt ?? null,
giftMonths: client.agency.giftMonths,
currentEndsAt: extends_ ? current.endsAt : null,
});
if (!decision.ok) return null;
await tx.subscription.create({
data: { clientId, source: 'AGENCY_GIFT', startsAt: decision.startsAt, endsAt: decision.endsAt },
data: { clientId, plan, source: 'AGENCY_GIFT', agencyId: client.agency.id, startsAt: decision.startsAt, endsAt: decision.endsAt },
});
await tx.client.update({ where: { id: clientId }, data: { giftUsedAt: now } });
return { endsAt: decision.endsAt };
return { endsAt: decision.endsAt, plan };
});
if (result) await ctx.notify.emit({ type: 'gift.activated', clientId, endsAt: result.endsAt.toISOString() });
// барики на новоселье от агентства — тоже один раз
await grantHousewarming(ctx, clientId);
return result;
}
+30 -2
View File
@@ -8,11 +8,17 @@ export type DomainEvent =
/** событие без смены статуса, например «Я на месте» */
| { type: 'order.event'; orderId: string; code: string }
| { type: 'order.rescheduled'; orderId: string }
/** команда изменила состав заказа (и цену) — сообщить клиенту и исполнителю */
| { type: 'order.edited'; orderId: string }
| { type: 'order.unassigned'; orderId: string; providerId: string }
| { type: 'offer.created'; offerId: string }
| { type: 'offer.declined'; offerId: string }
| { type: 'offer.expired'; offerId: string }
| { type: 'company.order'; orderId: string; companyId: string }
/** автораспределение партнёра не нашло свободного сотрудника — решает менеджер */
| { type: 'company.noStaff'; orderId: string; companyId: string }
/** партнёр отказался от заказа или не взял его вовремя — заказ снова у оператора */
| { type: 'partner.returned'; orderId: string; companyId: string; reason: 'declined' | 'expired' }
| { type: 'order.reminder'; orderId: string; hours: 24 | 1 }
| { type: 'gift.activated'; clientId: string; endsAt: string }
/** оператор сменил статус шага переезда — клиенту сообщение в бота */
@@ -31,7 +37,19 @@ export type DomainEvent =
| { type: 'broadcast.start'; broadcastId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
| { type: 'webRequest.created'; requestId: string }
/** барики: клиенту — начислено, стали доступны, списано, запрос отклонён, отмена, скоро сгорят, сгорели */
| { type: 'points.accrued'; accrualId: string }
| { type: 'points.activated'; accrualId: string }
| { type: 'points.redeemed'; redemptionId: string }
| { type: 'points.rejected'; redemptionId: string }
| { type: 'points.reversed'; reversalId: string }
| { type: 'points.expiring'; clientId: string; amount: number; days: number }
| { type: 'points.expired'; clientId: string; amount: number }
/** барики: команде — запрос клиента, предложения системы, операция поддержки сверх лимита */
| { type: 'points.requested'; redemptionId: string }
| { type: 'points.suggestions'; count: number }
| { type: 'points.overLimit'; suggestionId: string };
export interface Notifier {
emit(event: DomainEvent): Promise<void> | void;
@@ -94,7 +112,17 @@ export type DomainErrorCode =
| 'PROMO_INVALID'
| 'PROMO_EXPIRED'
| 'PROMO_USED'
| 'PROMO_NOT_FOR_SERVICE';
| 'PROMO_NOT_FOR_SERVICE'
| 'POINTS_DUPLICATE_ORDER'
| 'POINTS_REASON_REQUIRED'
| 'POINTS_BELOW_MIN'
| 'POINTS_NOT_ENOUGH'
| 'POINTS_OVER_SHARE'
| 'POINTS_OVER_LIMIT'
| 'POINTS_FINAL'
| 'POINTS_REQUEST_CLOSED'
| 'POINTS_TOO_MANY_REQUESTS'
| 'POINTS_WITH_PROMO';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+3
View File
@@ -20,3 +20,6 @@ export * from './texts.ts';
export * from './promo.ts';
export * from './partners.ts';
export * from './ratings.ts';
export * from './servicePartners.ts';
export * from './points.ts';
export * from './orderEdit.ts';
+134
View File
@@ -0,0 +1,134 @@
/**
* Команда меняет заказ: состав услуги (тип уборки, комнаты, доп. услуги) с пересчётом цены и длительности
* и чек-лист за исполнителя. Менять можно всегда — и после оплаты: тогда пересчитываются начисления
* (вознаграждение агентства, доход партнёра, барики), а разницу в деньгах команда решает с клиентом сама.
*/
import { agencyCommission, isProviderFree, quote, validateParams } from '@barev/core';
import type { Prisma } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { getServiceByCode } from './catalog.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { accrueForOrder, reverseAccrual, takePointsEvents } from './points.ts';
import { companySplit } from './servicePartners.ts';
import { getSettings } from './settings.ts';
export interface OrderEditInput {
orderId: string;
adminId: string;
params: Record<string, unknown>;
/** своя итоговая цена (с причиной); пусто — по прайсу с прежней скидкой */
totalPrice?: number | null;
reason?: string | null;
/** сообщить клиенту и исполнителю */
notify?: boolean;
}
/** Что получится: новая цена, длительность, успевает ли исполнитель (если заказ стал дольше) */
export async function previewOrderEdit(ctx: Ctx, input: Pick<OrderEditInput, 'orderId' | 'params'>) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order) throw new DomainError('NOT_FOUND');
const service = await getServiceByCode(ctx.db, order.service.code);
const params = validateParams(service.paramsSchema, input.params);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
// скидка — та же, что была при оформлении (подписка или промокод)
const q = quote(service.pricing, params.values, order.discountPct);
let providerBusy = false;
if (order.providerId && q.durationMinutes > order.durationMinutes) {
const end = new Date(order.scheduledAt.getTime() + q.durationMinutes * 60_000);
const [p] = (await loadAvailability(ctx.db, service.providerRole, order.scheduledAt, end, { excludeOrderId: order.id })).filter((x) => x.id === order.providerId);
providerBusy = !p || !isProviderFree(p, { start: order.scheduledAt, end });
}
return {
params: params.values,
basePrice: q.basePrice,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
durationMinutes: q.durationMinutes,
breakdown: q.breakdown,
was: { totalPrice: order.totalPrice, durationMinutes: order.durationMinutes },
paid: order.paymentStatus === 'PAID',
pointsUsed: order.pointsUsed,
providerBusy,
};
}
export async function editOrderComposition(ctx: Ctx, input: OrderEditInput) {
const p = await previewOrderEdit(ctx, input);
const manual = input.totalPrice != null && input.totalPrice !== p.totalPrice;
const reason = input.reason?.trim() || null;
if (manual && !reason) throw new DomainError('REASON_REQUIRED');
const total = manual ? Math.max(0, Math.round(input.totalPrice!)) : p.totalPrice;
if (p.pointsUsed > total) throw new DomainError('POINTS_OVER_SHARE');
const settings = await getSettings(ctx.db);
const events: DomainEvent[] = [];
await ctx.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { client: { include: { agency: true } }, commission: true, partnerEarning: true } });
if (order.status === 'CANCELLED') throw new DomainError('TRANSITION_NOT_ALLOWED');
await tx.order.update({
where: { id: order.id },
data: {
params: p.params as Prisma.InputJsonValue,
priceBreakdown: p.breakdown as unknown as Prisma.InputJsonValue,
basePrice: p.basePrice,
discountAmount: manual ? Math.max(0, p.basePrice - total) : p.discountAmount,
totalPrice: total,
durationMinutes: p.durationMinutes,
},
});
const comment = [`${order.totalPrice} → ${total}`, reason].filter(Boolean).join(' · ');
await tx.orderStatusHistory.create({ data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'editedByStaff', actorType: 'ADMIN', actorId: input.adminId, comment } });
if (order.paymentStatus === 'PAID' && total !== order.totalPrice) {
const paidMoney = total - order.pointsUsed;
// начисления, зафиксированные при оплате, — по новой сумме
if (order.commission) await tx.agencyCommission.update({ where: { id: order.commission.id }, data: { amount: agencyCommission(paidMoney, order.commission.pct) } });
if (order.partnerEarning) {
const company = await tx.providerCompany.findUnique({ where: { id: order.partnerEarning.companyId } });
if (company) {
const s = companySplit(company, total, settings);
await tx.partnerEarning.update({ where: { id: order.partnerEarning.id }, data: { paid: s.paid, fot: s.fot, tax: s.tax, amount: s.partner } });
}
}
// кэшбек за заказ: отменяем прежнее начисление и начисляем по новой цене
const acc = await tx.pointsAccrual.findFirst({ where: { orderId: order.id, channel: 'AUTO', status: { in: ['PENDING', 'ACTIVE'] } } });
if (acc) {
await reverseAccrual(ctx, { accrualId: acc.id, reason: 'order_edited', channel: 'AUTO' }, { adminId: null, owner: true }, tx);
await accrueForOrder(ctx, tx, order.id);
}
events.push(...takePointsEvents(tx));
}
if (input.notify) events.push({ type: 'order.edited', orderId: order.id });
});
for (const e of events) await ctx.notify.emit(e);
return { totalPrice: total, durationMinutes: p.durationMinutes, providerBusy: p.providerBusy };
}
/** Команда отмечает пункт чек-листа за исполнителя: с фото или без (тогда — причина) */
export async function staffChecklistItem(ctx: Ctx, input: { orderId: string; adminId: string; code: string; checked: boolean; fileId?: string; reason?: string | null }) {
const item = await ctx.db.orderChecklistItem.findUnique({ where: { orderId_code: { orderId: input.orderId, code: input.code } } });
if (!item) throw new DomainError('NOT_FOUND');
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: input.orderId } });
if (order.status === 'CANCELLED') throw new DomainError('TRANSITION_NOT_ALLOWED');
if (input.fileId) {
const f = await ctx.db.orderFile.findUnique({ where: { id: input.fileId } });
if (!f || f.orderId !== input.orderId) throw new DomainError('NOT_FOUND');
}
const fileId = input.fileId ?? item.fileId;
const reason = input.reason?.trim() || null;
if (input.checked && item.requiresPhoto && !fileId && !reason) throw new DomainError('PHOTO_REQUIRED');
await ctx.db.$transaction([
ctx.db.orderChecklistItem.update({
where: { id: item.id },
data: input.checked
? { checkedAt: ctx.now(), fileId, checkedByAdminId: input.adminId, photoWaivedReason: item.requiresPhoto && !fileId ? reason : null }
: { checkedAt: null, checkedByAdminId: null, photoWaivedReason: null },
}),
ctx.db.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: input.checked ? 'checklistByStaff' : 'checklistUncheckedByStaff', actorType: 'ADMIN', actorId: input.adminId, comment: [input.code, reason].filter(Boolean).join(' · ') },
}),
]);
}
+31 -6
View File
@@ -25,7 +25,9 @@ import { getServiceByCode, type ParsedService } from './catalog.ts';
import { getMembership } from './clients.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
import { recordPartnerEarning } from './servicePartners.ts';
import { findPromo } from './promo.ts';
import { accrueForOrder, onOrderCancelled, redeemPoints, takePointsEvents } from './points.ts';
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>, promoCode?: string | null) {
const service = await getServiceByCode(ctx.db, serviceCode, { activeOnly: true });
@@ -85,6 +87,8 @@ export interface CreateOrderInput {
allowBusySlot?: boolean;
/** промокод на скидку (применяется, если выгоднее скидки подписки) */
promoCode?: string | null;
/** оплатить часть бариками: сумма (не больше N% и баланса); с промокодом нельзя */
usePoints?: number;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
@@ -94,6 +98,8 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
const { service, params, quote: q, promo } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params, input.promoCode);
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
const flow = getFlow(service.flow);
if (input.usePoints && promo) throw new DomainError('POINTS_WITH_PROMO');
const pointEvents: DomainEvent[] = [];
const order = await ctx.db.$transaction(
async (tx) => {
@@ -134,11 +140,17 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
await tx.promoCodeUse.create({ data: { promoId: promo.id, clientId: input.clientId, orderId: created.id } });
await tx.promoCode.update({ where: { id: promo.id }, data: { usedCount: { increment: 1 } } });
}
if (input.usePoints && input.usePoints > 0) {
await redeemPoints(ctx, { clientId: input.clientId, amount: input.usePoints, orderId: created.id, orderRef: `#${created.number}`, orderAmount: q.totalPrice, channel: input.createdBy ? 'WEB' : 'CLIENT' }, { adminId: input.createdBy?.id ?? null, owner: true }, tx);
pointEvents.push(...takePointsEvents(tx));
return tx.order.update({ where: { id: created.id }, data: { pointsUsed: input.usePoints } });
}
return created;
},
{ isolationLevel: 'ReadCommitted' },
);
await ctx.notify.emit({ type: 'order.created', orderId: order.id });
for (const e of pointEvents) await ctx.notify.emit(e);
return order;
}
@@ -150,7 +162,7 @@ export async function guardContext(db: Db | Tx, orderId: string): Promise<GuardC
db.lessonPackage.findUnique({ where: { orderId } }),
]);
return {
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId)),
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId || i.photoWaivedReason)),
estimateAccepted: estimate?.status === 'ACCEPTED',
lessonsTotal: lessons?.total,
lessonsCompleted: lessons?.completed,
@@ -211,12 +223,16 @@ export async function transitionOrder(ctx: Ctx, input: TransitionInput, txArg?:
comment: input.comment,
},
});
return { order: updated, from: order.status };
// барики: отмена возвращает списанные и отменяет начисление; выполненный и оплаченный заказ — кэшбек
if (input.to === 'CANCELLED') await onOrderCancelled(ctx, tx, order.id);
else if (flow.done.includes(input.to)) await accrueForOrder(ctx, tx, order.id);
return { order: updated, from: order.status, extra: takePointsEvents(tx) };
};
const result = txArg ? await run(txArg) : await ctx.db.$transaction(run);
const event: DomainEvent = { type: 'order.status', orderId: input.orderId, from: result.from, to: input.to };
if (txArg) return { ...result, event };
await ctx.notify.emit(event);
for (const e of result.extra) await ctx.notify.emit(e);
return { ...result, event };
}
@@ -304,6 +320,7 @@ export async function cancelByClient(ctx: Ctx, input: { orderId: string; clientI
return r;
});
await ctx.notify.emit(result.event);
for (const e of result.extra) await ctx.notify.emit(e);
return { order: result.order, fee: policy.fee };
}
@@ -426,18 +443,19 @@ export async function repeatDraft(db: Db, orderId: string, clientId: string) {
export async function markPaid(ctx: Ctx, input: { orderId: string; by: { type: 'ADMIN' | 'PROVIDER'; id: string }; paymentMethodId?: string }) {
const provider = new ManualPaymentProvider();
const settings = await getSettings(ctx.db);
return ctx.db.$transaction(async (tx) => {
const events: DomainEvent[] = [];
const res = await ctx.db.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { client: { include: { agency: true } } } });
if (!order) throw new DomainError('NOT_FOUND');
if (input.by.type === 'PROVIDER' && order.providerId !== input.by.id) throw new DomainError('NOT_FOUND');
if (order.paymentStatus === 'PAID' || order.status === 'CANCELLED') throw new DomainError('NOT_PAID_STATE');
const res = await provider.confirmPayment(
{ orderId: order.id, amount: order.totalPrice, currency: 'AMD', description: `#${order.number}` },
{ orderId: order.id, amount: order.totalPrice - order.pointsUsed, currency: 'AMD', description: `#${order.number}` },
{ markedBy: input.by.id },
);
const now = ctx.now();
await tx.payment.create({
data: { orderId: order.id, provider: res.provider, amount: order.totalPrice, status: 'PAID', markedById: input.by.id },
data: { orderId: order.id, provider: res.provider, amount: order.totalPrice - order.pointsUsed, status: 'PAID', markedById: input.by.id },
});
const updated = await tx.order.update({
where: { id: order.id },
@@ -450,13 +468,20 @@ export async function markPaid(ctx: Ctx, input: { orderId: string; by: { type: '
agencyId: agency.id,
orderId: order.id,
pct: agency.commissionPct,
amount: agencyCommission(order.totalPrice, agency.commissionPct),
amount: agencyCommission(order.totalPrice - order.pointsUsed, agency.commissionPct),
period: yerevanMonthKey(now),
},
});
}
// заказ выполнил сотрудник партнёра — фиксируем доход партнёра по его условиям
await recordPartnerEarning(tx, order, settings, now);
// уже выполнен — сразу кэшбек
await accrueForOrder(ctx, tx, order.id);
events.push(...takePointsEvents(tx));
return updated;
});
for (const e of events) await ctx.notify.emit(e);
return res;
}
export function isOrderActive(flowCode: Parameters<typeof isTerminal>[0], status: OrderStatus) {
+10 -7
View File
@@ -1,4 +1,5 @@
import {
DEFAULT_SETTINGS,
defaultParams,
paramsSchemaSchema,
payoutByRules,
@@ -16,7 +17,8 @@ type Rate = { type: RateType; value: number };
export interface PayoutProvider {
rateType: RateType;
rateValue: number;
company: { rateType: RateType; rateValue: number } | null;
/** сотрудник партнёра услуг: ФОТ платим мы только в модели «расходы наши» */
company: { deal: 'PROFIT_SHARE' | 'COMMISSION'; fotPct: number | null } | null;
payoutPreset: { rules: unknown } | null;
}
@@ -41,13 +43,13 @@ export function breakdownOf(o: PayoutOrder): PriceBreakdown | null {
}
/**
* PRO-8: выплата исполнителю за заказ.
* - сотрудник компании — договорная ставка компании (от итоговой цены);
* PRO-8: выплата исполнителю за заказ (от нас).
* - сотрудник партнёра: «расходы наши» — ФОТ, % от оплаченного; «как Uber» — 0, ему платит партнёр;
* - исполнитель с пресетом (джун / мидл / прайват) — фикс или % по каждой строке цены;
* - иначе — индивидуальная ставка (фикс за заказ или % от итога).
*/
export function orderPayout(o: PayoutOrder, p: PayoutProvider): number {
if (p.company) return payoutFor(o.totalPrice, { type: p.company.rateType, value: p.company.rateValue });
export function orderPayout(o: PayoutOrder, p: PayoutProvider, defaults: { fotPct: number } = { fotPct: DEFAULT_SETTINGS['partner.fotPct'] }): number {
if (p.company) return p.company.deal === 'PROFIT_SHARE' ? Math.floor((o.totalPrice * (p.company.fotPct ?? defaults.fotPct)) / 100) : 0;
if (p.payoutPreset) {
const rules = payoutRulesSchema.safeParse(p.payoutPreset.rules);
const b = breakdownOf(o);
@@ -58,8 +60,9 @@ export function orderPayout(o: PayoutOrder, p: PayoutProvider): number {
}
/** Описание ставки для экрана «История» исполнителя */
export function rateLabel(p: PayoutProvider & { payoutPreset: { name: string; rules: unknown } | null }) {
if (p.company) return { kind: 'company' as const, type: p.company.rateType, value: p.company.rateValue };
export function rateLabel(p: PayoutProvider & { payoutPreset: { name: string; rules: unknown } | null }, defaults: { fotPct: number } = { fotPct: DEFAULT_SETTINGS['partner.fotPct'] }) {
// «как Uber»: зарплату сотруднику платит партнёр — value пусто
if (p.company) return { kind: 'company' as const, type: 'PERCENT' as RateType, value: p.company.deal === 'PROFIT_SHARE' ? (p.company.fotPct ?? defaults.fotPct) : null };
if (p.payoutPreset) return { kind: 'preset' as const, name: p.payoutPreset.name };
return { kind: 'own' as const, type: p.rateType, value: p.rateValue };
}
+755
View File
@@ -0,0 +1,755 @@
/**
* Барики — кэшбек клуба. Журнал начислений (PointsAccrual) и списаний (PointsRedemption) — источник правды;
* баланс у клиента — кэш, который пересчитывается в той же транзакции, что и запись в журнал.
* Отмена ничего не удаляет: создаёт PointsReversal и меняет статус исходной записи.
*/
import {
DAY,
fifoPlan,
getFlow,
membershipLevel,
nextStreak,
redeemLimit,
streakBonusPct,
suggestAccrual,
tierRate,
type Settings,
type Tier,
} from '@barev/core';
import type { Db, PointsChannel, Prisma, Tx } from '@barev/db';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
const HOUR = 3600_000;
/** Кто выполняет операцию: суперадмин, сотрудник поддержки (с лимитами) или система */
export interface PointsActor {
adminId: string | null;
/** суперадмин: без лимитов, отменяет любые операции */
owner: boolean;
}
const SYSTEM: PointsActor = { adminId: null, owner: true };
type Out = { events: DomainEvent[] };
async function inTx<T>(ctx: Ctx, txArg: Tx | undefined, fn: (tx: Tx, out: Out) => Promise<T>): Promise<T> {
const out: Out = { events: [] };
if (txArg) {
const r = await fn(txArg, out);
// внутри чужой транзакции события отдаём вызывающему через pendingEvents
pendingEvents.get(txArg)?.push(...out.events) ?? pendingEvents.set(txArg, out.events);
return r;
}
const r = await ctx.db.$transaction((tx) => fn(tx, out));
for (const e of out.events) await ctx.notify.emit(e);
return r;
}
/** События бариков, созданные внутри транзакции вызывающего (заказы) — он отправит их после коммита */
const pendingEvents = new WeakMap<Tx, DomainEvent[]>();
export function takePointsEvents(tx: Tx): DomainEvent[] {
const e = pendingEvents.get(tx) ?? [];
pendingEvents.delete(tx);
return e;
}
async function lockClient(tx: Tx, clientId: string) {
await tx.$queryRaw`SELECT id FROM "Client" WHERE id = ${clientId} FOR UPDATE`;
return tx.client.findUniqueOrThrow({ where: { id: clientId } });
}
/** Пересчитать кэш баланса из журнала */
export async function recalcPoints(tx: Tx | Db, clientId: string) {
const [active, pending, client] = await Promise.all([
tx.pointsAccrual.aggregate({ where: { clientId, status: 'ACTIVE' }, _sum: { remaining: true } }),
tx.pointsAccrual.aggregate({ where: { clientId, status: 'PENDING' }, _sum: { remaining: true } }),
tx.client.findUniqueOrThrow({ where: { id: clientId }, select: { pointsDebt: true } }),
]);
const pointsActive = (active._sum.remaining ?? 0) - client.pointsDebt;
const pointsPending = pending._sum.remaining ?? 0;
await tx.client.update({ where: { id: clientId }, data: { pointsActive, pointsPending } });
return { active: pointsActive, pending: pointsPending };
}
const touch = (tx: Tx, clientId: string, now: Date) => tx.client.update({ where: { id: clientId }, data: { pointsActivityAt: now, pointsWarn14At: null, pointsWarn3At: null } });
/** Уровень клиента на дату услуги */
export async function tierAt(db: Db | Tx, clientId: string, at: Date): Promise<Tier> {
const subs = await db.subscription.findMany({ where: { clientId, startsAt: { lte: at }, endsAt: { gt: at } } });
return membershipLevel(subs, at);
}
/** Категория кэшбека услуги: из справочника, иначе — по коду услуги / направлению */
export async function pointsCategoryOf(db: Db | Tx, service: { code: string; pointsCategoryId: string | null; category?: { code: string } | null; categoryId: string }) {
if (service.pointsCategoryId) return db.pointsCategory.findUnique({ where: { id: service.pointsCategoryId } });
let code = ['cleaning', 'repair', 'transfer'].includes(service.code) ? service.code : null;
if (!code) {
const cat = service.category ?? (await db.category.findUnique({ where: { id: service.categoryId } }));
code = cat?.code === 'DOCS' ? 'documents' : cat?.code === 'LEARNING' ? 'learning' : null;
}
return code ? db.pointsCategory.findUnique({ where: { code } }) : null;
}
async function opLimits(tx: Tx, actor: PointsActor, amount: number, settings: Settings, now: Date) {
if (actor.owner || !actor.adminId) return { ok: true as const };
if (!settings['points.supportCanManage']) throw new DomainError('FORBIDDEN');
if (amount > settings['points.opMaxPerOperation']) return { ok: false as const };
const dayStart = new Date(now.getTime() - 24 * HOUR);
const [acc, red] = await Promise.all([
tx.pointsAccrual.aggregate({ where: { createdById: actor.adminId, createdAt: { gte: dayStart } }, _sum: { amount: true } }),
tx.pointsRedemption.aggregate({ where: { confirmedById: actor.adminId, confirmedAt: { gte: dayStart } }, _sum: { amount: true } }),
]);
if ((acc._sum.amount ?? 0) + (red._sum.amount ?? 0) + amount > settings['points.opMaxPerDay']) return { ok: false as const };
return { ok: true as const };
}
// ───── начисление ─────
export interface AccrualInput {
clientId: string;
kind: 'ORDER' | 'BONUS';
/** ORDER: категория кэшбека */
categoryId?: string;
/** BONUS: тип бонуса */
bonusTypeId?: string;
orderId?: string;
orderRef?: string | null;
orderAmount?: number;
excludedAmount?: number;
paidWithPoints?: number;
serviceDate: Date;
/** итоговая сумма; пусто — подсказка (ORDER) или сумма бонуса по умолчанию (BONUS) */
amount?: number;
/** причина: обязательна, если сумма изменена против подсказки, или для «без ожидания» у поддержки */
reason?: string | null;
commentForClient?: string | null;
/** сразу доступны, без ожидания */
noWait?: boolean;
/** свой срок жизни бонуса, дней (пусто — из типа бонуса) */
ttlDays?: number | null;
channel: PointsChannel;
idempotencyKey?: string | null;
}
/** Подсказка для формы начисления: уровень на дату услуги, ставка, надбавка за серию уборок, сумма, когда станут доступны */
export async function previewAccrual(db: Db | Tx, input: Pick<AccrualInput, 'clientId' | 'categoryId' | 'orderAmount' | 'excludedAmount' | 'paidWithPoints' | 'serviceDate'>, settings: Settings, now: Date) {
const [client, category] = await Promise.all([
db.client.findUniqueOrThrow({ where: { id: input.clientId } }),
input.categoryId ? db.pointsCategory.findUnique({ where: { id: input.categoryId } }) : null,
]);
if (!category) throw new DomainError('NOT_FOUND');
const tier = await tierAt(db, input.clientId, input.serviceDate);
const streak = category.code === 'cleaning' ? nextStreak({ count: client.streakCount, lastAt: client.streakLastAt }, input.serviceDate, settings['points.streakGapDays']) : 0;
const bonus = category.code === 'cleaning' ? streakBonusPct(streak) : 0;
const ratePct = tierRate(category, tier) + bonus;
const suggested = suggestAccrual({ orderAmount: input.orderAmount ?? 0, excludedAmount: input.excludedAmount, paidWithPoints: input.paidWithPoints, ratePct });
const availableAt = new Date(input.serviceDate.getTime() + settings['points.pendingHours'] * HOUR);
return { tier, baseRatePct: tierRate(category, tier), streak, streakBonusPct: bonus, ratePct, suggested, availableAt: availableAt > now ? availableAt : now, category: { id: category.id, code: category.code } };
}
export type AccrualResult = { status: 'created'; accrualId: string; amount: number } | { status: 'needs_approval'; suggestionId: string } | { status: 'skipped' };
/** Начислить (ORDER по заказу или BONUS). Блокирует строку клиента; номер заказа второй раз не начисляется */
export async function accruePoints(ctx: Ctx, input: AccrualInput, actor: PointsActor = SYSTEM, txArg?: Tx): Promise<AccrualResult> {
const settings = await getSettings(txArg ?? ctx.db);
return inTx(ctx, txArg, async (tx, out) => {
if (input.idempotencyKey) {
const prev = await tx.pointsAccrual.findUnique({ where: { idempotencyKey: input.idempotencyKey } });
if (prev) return { status: 'created', accrualId: prev.id, amount: prev.amount };
}
const client = await lockClient(tx, input.clientId);
const now = ctx.now();
const orderRef = input.orderRef?.trim() || null;
if (orderRef || input.orderId) {
const dup = await tx.pointsAccrual.findFirst({ where: { status: { not: 'REVERSED' }, OR: [orderRef ? { orderRef } : {}, input.orderId ? { orderId: input.orderId } : {}].filter((x) => Object.keys(x).length) } });
if (dup) throw new DomainError('POINTS_DUPLICATE_ORDER');
}
let amount: number;
let suggested: number | null = null;
let extra: Partial<Prisma.PointsAccrualUncheckedCreateInput> = {};
let availableAt = now;
let status: 'PENDING' | 'ACTIVE' = 'ACTIVE';
let expiresAt: Date | null = null;
let streak: number | null = null;
if (input.kind === 'ORDER') {
if (!input.categoryId || !input.orderAmount || input.orderAmount < 0) throw new DomainError('INVALID_PARAMS');
const p = await previewAccrual(tx, input, settings, now);
suggested = p.suggested;
amount = input.amount ?? p.suggested;
extra = { categoryId: p.category.id, tier: p.tier, ratePct: p.ratePct, streakBonusPct: p.streakBonusPct, orderAmount: input.orderAmount, excludedAmount: input.excludedAmount ?? 0, paidWithPoints: input.paidWithPoints ?? 0 };
availableAt = p.availableAt;
status = input.noWait || availableAt <= now ? 'ACTIVE' : 'PENDING';
if (input.noWait) availableAt = now;
if (p.category.code === 'cleaning') streak = p.streak;
} else {
const bt = input.bonusTypeId ? await tx.pointsBonusType.findUnique({ where: { id: input.bonusTypeId } }) : null;
if (!bt) throw new DomainError('NOT_FOUND');
suggested = bt.defaultAmount;
amount = input.amount ?? bt.defaultAmount;
const ttl = input.ttlDays === undefined ? bt.ttlDays : input.ttlDays;
if (ttl) expiresAt = new Date(now.getTime() + ttl * DAY);
extra = { bonusTypeId: bt.id };
}
if (!Number.isInteger(amount) || amount < 0) throw new DomainError('INVALID_PARAMS');
if (amount === 0) return { status: 'skipped' };
const reason = input.reason?.trim() || null;
if (input.kind === 'ORDER' && suggested !== null && amount !== suggested && !reason) throw new DomainError('POINTS_REASON_REQUIRED');
if (input.noWait && !actor.owner && !reason) throw new DomainError('POINTS_REASON_REQUIRED');
// поддержка сверх лимита — в очередь на подтверждение суперадмину
const lim = await opLimits(tx, actor, amount, settings, now);
if (!lim.ok) {
const s = await tx.pointsSuggestion.create({
data: { clientId: client.id, mechanic: 'over_limit', dedupeKey: `over_limit:${input.idempotencyKey ?? `${actor.adminId}:${now.getTime()}`}`, payload: { op: 'accrual', input: { ...input, serviceDate: input.serviceDate.toISOString() }, adminId: actor.adminId } as Prisma.InputJsonValue, suggestedAmount: amount },
});
out.events.push({ type: 'points.overLimit', suggestionId: s.id });
return { status: 'needs_approval', suggestionId: s.id };
}
// долг после отмены потраченного гасится новым доступным начислением
let remaining = amount;
let finalStatus: 'PENDING' | 'ACTIVE' | 'SPENT' = status;
if (status === 'ACTIVE' && client.pointsDebt > 0) {
const pay = Math.min(client.pointsDebt, remaining);
remaining -= pay;
await tx.client.update({ where: { id: client.id }, data: { pointsDebt: { decrement: pay } } });
if (remaining === 0) finalStatus = 'SPENT';
}
const a = await tx.pointsAccrual.create({
data: {
clientId: client.id,
kind: input.kind,
...extra,
orderId: input.orderId,
orderRef,
suggestedAmount: suggested,
amount,
remaining,
status: finalStatus,
serviceDate: input.serviceDate,
availableAt,
expiresAt,
reason,
commentForClient: input.commentForClient?.trim() || null,
createdById: actor.adminId,
channel: input.channel,
idempotencyKey: input.idempotencyKey || null,
},
});
if (streak !== null) await tx.client.update({ where: { id: client.id }, data: { streakCount: streak, streakLastAt: input.serviceDate } });
await touch(tx, client.id, now);
await recalcPoints(tx, client.id);
out.events.push({ type: 'points.accrued', accrualId: a.id });
return { status: 'created', accrualId: a.id, amount };
});
}
/** Воркер: ожидающие начисления, у которых наступил срок, становятся доступными */
export async function activatePendingPoints(ctx: Ctx) {
const due = await ctx.db.pointsAccrual.findMany({ where: { status: 'PENDING', availableAt: { lte: ctx.now() } }, select: { id: true, clientId: true }, take: 500 });
for (const { id, clientId } of due) {
await inTx(ctx, undefined, async (tx, out) => {
const client = await lockClient(tx, clientId);
const a = await tx.pointsAccrual.findUniqueOrThrow({ where: { id } });
if (a.status !== 'PENDING') return;
let remaining = a.remaining;
if (client.pointsDebt > 0) {
const pay = Math.min(client.pointsDebt, remaining);
remaining -= pay;
await tx.client.update({ where: { id: clientId }, data: { pointsDebt: { decrement: pay } } });
}
await tx.pointsAccrual.update({ where: { id }, data: { status: remaining > 0 ? 'ACTIVE' : 'SPENT', remaining } });
await recalcPoints(tx, clientId);
out.events.push({ type: 'points.activated', accrualId: id });
});
}
return due.length;
}
// ───── списание ─────
export interface RedeemInput {
clientId: string;
amount: number;
orderId?: string;
orderRef?: string | null;
/** сумма заказа — для проверки «не больше N%» */
orderAmount?: number | null;
channel: PointsChannel;
reason?: string | null;
idempotencyKey?: string | null;
}
/** Сколько клиент может потратить на заказ сейчас */
export async function redeemQuote(db: Db | Tx, clientId: string, orderAmount: number, settings: Settings) {
const c = await db.client.findUniqueOrThrow({ where: { id: clientId }, select: { pointsActive: true } });
return { balance: c.pointsActive, ...redeemLimit({ orderAmount, balance: c.pointsActive, sharePct: settings['points.maxSharePct'], min: settings['points.minRedeem'] }), min: settings['points.minRedeem'], sharePct: settings['points.maxSharePct'] };
}
/** Списать по FIFO в уже открытой транзакции, клиент заблокирован */
async function spend(tx: Tx, clientId: string, amount: number, settings: Settings, orderAmount: number | null | undefined) {
const [client, list] = await Promise.all([
tx.client.findUniqueOrThrow({ where: { id: clientId } }),
tx.pointsAccrual.findMany({ where: { clientId, status: 'ACTIVE', remaining: { gt: 0 } } }),
]);
const balance = list.reduce((s, a) => s + a.remaining, 0) - client.pointsDebt;
if (!Number.isInteger(amount) || amount <= 0) throw new DomainError('INVALID_PARAMS');
if (amount < settings['points.minRedeem']) throw new DomainError('POINTS_BELOW_MIN');
if (amount > balance) throw new DomainError('POINTS_NOT_ENOUGH');
if (orderAmount != null && amount > Math.floor((orderAmount * settings['points.maxSharePct']) / 100)) throw new DomainError('POINTS_OVER_SHARE');
const plan = fifoPlan(list, amount);
if (!plan) throw new DomainError('POINTS_NOT_ENOUGH');
for (const it of plan) {
const a = list.find((x) => x.id === it.id)!;
const left = a.remaining - it.amount;
await tx.pointsAccrual.update({ where: { id: a.id }, data: { remaining: left, status: left === 0 ? 'SPENT' : 'ACTIVE' } });
}
return plan;
}
/** Списание командой (по звонку, по заказу вне системы) или в заказе — сразу подтверждено */
export async function redeemPoints(ctx: Ctx, input: RedeemInput, actor: PointsActor = SYSTEM, txArg?: Tx) {
const settings = await getSettings(txArg ?? ctx.db);
return inTx(ctx, txArg, async (tx, out) => {
if (input.idempotencyKey) {
const prev = await tx.pointsRedemption.findUnique({ where: { idempotencyKey: input.idempotencyKey } });
if (prev) return { status: 'done' as const, redemptionId: prev.id };
}
await lockClient(tx, input.clientId);
if (actor.adminId && !input.orderId && !input.reason?.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
const lim = await opLimits(tx, actor, input.amount, settings, ctx.now());
if (!lim.ok) {
const s = await tx.pointsSuggestion.create({
data: { clientId: input.clientId, mechanic: 'over_limit', dedupeKey: `over_limit:${input.idempotencyKey ?? `${actor.adminId}:${ctx.now().getTime()}`}`, payload: { op: 'redemption', input, adminId: actor.adminId } as unknown as Prisma.InputJsonValue, suggestedAmount: input.amount },
});
out.events.push({ type: 'points.overLimit', suggestionId: s.id });
return { status: 'needs_approval' as const, suggestionId: s.id };
}
const plan = await spend(tx, input.clientId, input.amount, settings, input.orderAmount);
const r = await tx.pointsRedemption.create({
data: {
clientId: input.clientId,
orderId: input.orderId,
orderRef: input.orderRef?.trim() || null,
orderAmount: input.orderAmount ?? null,
amount: input.amount,
status: 'CONFIRMED',
channel: input.channel,
confirmedById: actor.adminId,
confirmedAt: ctx.now(),
reason: input.reason?.trim() || null,
idempotencyKey: input.idempotencyKey || null,
items: { create: plan.map((p) => ({ accrualId: p.id, amount: p.amount })) },
},
});
await touch(tx, input.clientId, ctx.now());
await recalcPoints(tx, input.clientId);
// в заказе клиент сам видит списание — сообщение только для списаний командой
if (!input.orderId) out.events.push({ type: 'points.redeemed', redemptionId: r.id });
return { status: 'done' as const, redemptionId: r.id };
});
}
/** Клиент просит оплатить бариками заказ вне системы — запрос ждёт подтверждения команды */
export async function requestRedemption(ctx: Ctx, input: { clientId: string; amount: number; orderId?: string | null; note?: string | null }) {
const settings = await getSettings(ctx.db);
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: input.clientId } });
if (!Number.isInteger(input.amount) || input.amount < settings['points.minRedeem']) throw new DomainError('POINTS_BELOW_MIN');
if (input.amount > client.pointsActive) throw new DomainError('POINTS_NOT_ENOUGH');
let orderAmount: number | null = null;
if (input.orderId) {
const o = await ctx.db.order.findUnique({ where: { id: input.orderId } });
if (!o || o.clientId !== client.id) throw new DomainError('NOT_FOUND');
orderAmount = o.totalPrice - o.pointsUsed;
if (input.amount > Math.floor((o.totalPrice * settings['points.maxSharePct']) / 100)) throw new DomainError('POINTS_OVER_SHARE');
}
const open = await ctx.db.pointsRedemption.count({ where: { clientId: client.id, status: 'REQUESTED' } });
if (open >= 3) throw new DomainError('POINTS_TOO_MANY_REQUESTS');
const r = await ctx.db.pointsRedemption.create({
data: { clientId: client.id, orderId: input.orderId ?? null, orderAmount, amount: input.amount, status: 'REQUESTED', channel: 'CLIENT', note: input.note?.trim() || null, expiresAt: new Date(ctx.now().getTime() + settings['points.requestTtlHours'] * HOUR) },
});
await ctx.notify.emit({ type: 'points.requested', redemptionId: r.id });
return r;
}
/** Команда подтверждает запрос клиента: вводит сумму заказа, списывается не больше лимита */
export async function confirmRedemption(ctx: Ctx, input: { redemptionId: string; orderAmount: number; amount?: number }, actor: PointsActor, txArg?: Tx) {
const settings = await getSettings(ctx.db);
return inTx(ctx, txArg, async (tx, out) => {
const r = await tx.pointsRedemption.findUnique({ where: { id: input.redemptionId } });
if (!r) throw new DomainError('NOT_FOUND');
await lockClient(tx, r.clientId);
const fresh = await tx.pointsRedemption.findUniqueOrThrow({ where: { id: r.id } });
if (fresh.status !== 'REQUESTED') throw new DomainError('POINTS_REQUEST_CLOSED');
if (fresh.expiresAt && fresh.expiresAt < ctx.now()) throw new DomainError('POINTS_REQUEST_CLOSED');
const amount = input.amount ?? Math.min(fresh.amount, Math.floor((input.orderAmount * settings['points.maxSharePct']) / 100));
const lim = await opLimits(tx, actor, amount, settings, ctx.now());
if (!lim.ok) throw new DomainError('POINTS_OVER_LIMIT');
const plan = await spend(tx, r.clientId, amount, settings, input.orderAmount);
await tx.pointsRedemption.update({
where: { id: r.id },
data: { status: 'CONFIRMED', amount, orderAmount: input.orderAmount, confirmedById: actor.adminId, confirmedAt: ctx.now(), items: { create: plan.map((p) => ({ accrualId: p.id, amount: p.amount })) } },
});
await touch(tx, r.clientId, ctx.now());
await recalcPoints(tx, r.clientId);
out.events.push({ type: 'points.redeemed', redemptionId: r.id });
return { amount };
});
}
export async function rejectRedemption(ctx: Ctx, input: { redemptionId: string; reason: string }, actor: PointsActor) {
if (!input.reason.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
const r = await ctx.db.pointsRedemption.updateMany({ where: { id: input.redemptionId, status: 'REQUESTED' }, data: { status: 'REJECTED', reason: input.reason.trim(), confirmedById: actor.adminId, confirmedAt: ctx.now() } });
if (!r.count) throw new DomainError('POINTS_REQUEST_CLOSED');
await ctx.notify.emit({ type: 'points.rejected', redemptionId: input.redemptionId });
}
/** Воркер: неподтверждённые запросы старше N часов закрываются */
export async function expirePointRequests(ctx: Ctx) {
const list = await ctx.db.pointsRedemption.findMany({ where: { status: 'REQUESTED', expiresAt: { lt: ctx.now() } }, select: { id: true } });
for (const { id } of list) {
const r = await ctx.db.pointsRedemption.updateMany({ where: { id, status: 'REQUESTED' }, data: { status: 'REJECTED', reason: 'timeout' } });
if (r.count) await ctx.notify.emit({ type: 'points.rejected', redemptionId: id });
}
}
// ───── отмена ─────
/** Что будет при отмене: сколько уже потрачено и уйдёт ли баланс в минус */
export async function reversePreview(db: Db | Tx, input: { type: 'ACCRUAL' | 'REDEMPTION'; id: string }) {
if (input.type === 'ACCRUAL') {
const a = await db.pointsAccrual.findUniqueOrThrow({ where: { id: input.id }, include: { client: { select: { pointsActive: true } } } });
const spent = a.amount - a.remaining - a.expiredAmount;
const lose = a.status === 'PENDING' ? 0 : a.remaining;
return { status: a.status, amount: a.amount, spent: a.status === 'PENDING' ? 0 : spent, balanceAfter: a.client.pointsActive - lose - (a.status === 'PENDING' ? 0 : spent), reversible: a.status === 'PENDING' || a.status === 'ACTIVE' };
}
const r = await db.pointsRedemption.findUniqueOrThrow({ where: { id: input.id }, include: { client: { select: { pointsActive: true } } } });
return { status: r.status, amount: r.amount, spent: 0, balanceAfter: r.client.pointsActive + r.amount, reversible: r.status === 'CONFIRMED' };
}
async function checkOwnership(actor: PointsActor, createdById: string | null, createdAt: Date, now: Date) {
if (actor.owner) return;
if (createdById !== actor.adminId || now.getTime() - createdAt.getTime() > 24 * HOUR) throw new DomainError('FORBIDDEN');
}
/** Отменить начисление. Уже потраченная часть становится долгом (минус гасится следующими начислениями) */
export async function reverseAccrual(ctx: Ctx, input: { accrualId: string; reason: string; channel: PointsChannel }, actor: PointsActor = SYSTEM, txArg?: Tx) {
if (!input.reason.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
return inTx(ctx, txArg, async (tx, out) => {
const a0 = await tx.pointsAccrual.findUnique({ where: { id: input.accrualId } });
if (!a0) throw new DomainError('NOT_FOUND');
await lockClient(tx, a0.clientId);
const a = await tx.pointsAccrual.findUniqueOrThrow({ where: { id: a0.id } });
if (a.status !== 'PENDING' && a.status !== 'ACTIVE') throw new DomainError('POINTS_FINAL');
await checkOwnership(actor, a.createdById, a.createdAt, ctx.now());
const debt = a.status === 'PENDING' ? 0 : a.amount - a.remaining - a.expiredAmount;
await tx.pointsAccrual.update({ where: { id: a.id }, data: { status: 'REVERSED', remaining: 0 } });
if (debt > 0) await tx.client.update({ where: { id: a.clientId }, data: { pointsDebt: { increment: debt } } });
const rv = await tx.pointsReversal.create({ data: { targetType: 'ACCRUAL', targetId: a.id, clientId: a.clientId, amount: a.amount, debt, createdById: actor.adminId, channel: input.channel, reason: input.reason.trim() } });
await recalcPoints(tx, a.clientId);
out.events.push({ type: 'points.reversed', reversalId: rv.id });
return { debt };
});
}
/** Отменить списание: барики возвращаются в исходные начисления с их сроками */
export async function reverseRedemption(ctx: Ctx, input: { redemptionId: string; reason: string; channel: PointsChannel }, actor: PointsActor = SYSTEM, txArg?: Tx) {
if (!input.reason.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
return inTx(ctx, txArg, async (tx, out) => {
const r0 = await tx.pointsRedemption.findUnique({ where: { id: input.redemptionId } });
if (!r0) throw new DomainError('NOT_FOUND');
await lockClient(tx, r0.clientId);
const r = await tx.pointsRedemption.findUniqueOrThrow({ where: { id: r0.id }, include: { items: { include: { accrual: true } } } });
if (r.status !== 'CONFIRMED') throw new DomainError('POINTS_FINAL');
await checkOwnership(actor, r.confirmedById, r.confirmedAt ?? r.createdAt, ctx.now());
let debtBack = 0;
for (const it of r.items) {
const a = it.accrual;
if (a.status === 'REVERSED') debtBack += it.amount; // начисление отменили после траты — возврат гасит долг
else if (a.status === 'EXPIRED') await tx.pointsAccrual.update({ where: { id: a.id }, data: { expiredAmount: { increment: it.amount } } });
else await tx.pointsAccrual.update({ where: { id: a.id }, data: { remaining: { increment: it.amount }, status: a.status === 'SPENT' ? 'ACTIVE' : a.status } });
}
if (debtBack > 0) {
const c = await tx.client.findUniqueOrThrow({ where: { id: r.clientId } });
await tx.client.update({ where: { id: r.clientId }, data: { pointsDebt: Math.max(0, c.pointsDebt - debtBack) } });
}
await tx.pointsRedemption.update({ where: { id: r.id }, data: { status: 'REVERSED' } });
if (r.orderId) await tx.order.update({ where: { id: r.orderId }, data: { pointsUsed: { decrement: r.amount } } });
const rv = await tx.pointsReversal.create({ data: { targetType: 'REDEMPTION', targetId: r.id, clientId: r.clientId, amount: r.amount, createdById: actor.adminId, channel: input.channel, reason: input.reason.trim() } });
await touch(tx, r.clientId, ctx.now());
await recalcPoints(tx, r.clientId);
out.events.push({ type: 'points.reversed', reversalId: rv.id });
});
}
// ───── заказы в системе ─────
/** Автоначисление: заказ выполнен и оплачен. Вызывается из смены статуса и отметки оплаты (в их транзакции) */
export async function accrueForOrder(ctx: Ctx, tx: Tx, orderId: string) {
const o = await tx.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: { include: { category: true } } } });
if (o.paymentStatus !== 'PAID' || o.status === 'CANCELLED') return;
if (!getFlow(o.service.flow).done.includes(o.status)) return;
if (await tx.pointsAccrual.findFirst({ where: { orderId: o.id, status: { not: 'REVERSED' } } })) return;
const category = await pointsCategoryOf(tx, o.service);
if (!category?.active) return;
await accruePoints(
ctx,
{ clientId: o.clientId, kind: 'ORDER', categoryId: category.id, orderId: o.id, orderRef: `#${o.number}`, orderAmount: o.totalPrice, paidWithPoints: o.pointsUsed, serviceDate: o.scheduledAt, channel: 'AUTO' },
SYSTEM,
tx,
);
}
/** Заказ отменён: барики, списанные в заказе, возвращаются; начисление за заказ отменяется */
export async function onOrderCancelled(ctx: Ctx, tx: Tx, orderId: string) {
const [reds, accs] = await Promise.all([
tx.pointsRedemption.findMany({ where: { orderId, status: 'CONFIRMED' } }),
tx.pointsAccrual.findMany({ where: { orderId, status: { in: ['PENDING', 'ACTIVE'] } } }),
]);
for (const r of reds) await reverseRedemption(ctx, { redemptionId: r.id, reason: 'order_cancelled', channel: 'AUTO' }, SYSTEM, tx);
for (const a of accs) await reverseAccrual(ctx, { accrualId: a.id, reason: 'order_cancelled', channel: 'AUTO' }, SYSTEM, tx);
}
// ───── новоселье ─────
/** Барики на новоселье от агентства — один раз на клиента, сразу доступны, со своим сроком */
export async function grantHousewarming(ctx: Ctx, clientId: string) {
const client = await ctx.db.client.findUnique({ where: { id: clientId }, include: { agency: true } });
const ag = client?.agency;
if (!client?.consentAt || !ag?.active || ag.housewarmingPoints <= 0) return null;
const bt = await ctx.db.pointsBonusType.findUnique({ where: { code: 'housewarming' } });
if (!bt) return null;
if (await ctx.db.pointsAccrual.findFirst({ where: { clientId, bonusTypeId: bt.id } })) return null;
try {
return await accruePoints(ctx, {
clientId,
kind: 'BONUS',
bonusTypeId: bt.id,
amount: ag.housewarmingPoints,
ttlDays: ag.housewarmingDays,
serviceDate: ctx.now(),
channel: 'AUTO',
commentForClient: ag.name,
idempotencyKey: `housewarming:${clientId}`,
});
} catch (e) {
if (e instanceof DomainError) return null;
throw e;
}
}
// ───── сгорание и предупреждения ─────
/** Воркер (раз в сутки): сгорают все барики без активности N дней и бонусы с истёкшим сроком */
export async function burnPoints(ctx: Ctx) {
const settings = await getSettings(ctx.db);
const now = ctx.now();
const cutoff = new Date(now.getTime() - settings['points.burnDays'] * DAY);
const [inactive, bonuses] = await Promise.all([
ctx.db.client.findMany({ where: { pointsActivityAt: { lt: cutoff }, pointsAccruals: { some: { status: 'ACTIVE', remaining: { gt: 0 } } } }, select: { id: true } }),
ctx.db.pointsAccrual.findMany({ where: { status: 'ACTIVE', remaining: { gt: 0 }, expiresAt: { lt: now } }, select: { clientId: true } }),
]);
const ids = new Set([...inactive.map((c) => c.id), ...bonuses.map((b) => b.clientId)]);
let burned = 0;
for (const clientId of ids) {
await inTx(ctx, undefined, async (tx, out) => {
const c = await lockClient(tx, clientId);
const all = c.pointsActivityAt && c.pointsActivityAt < cutoff;
const list = await tx.pointsAccrual.findMany({ where: { clientId, status: 'ACTIVE', remaining: { gt: 0 }, ...(all ? {} : { expiresAt: { lt: now } }) } });
let sum = 0;
for (const a of list) {
sum += a.remaining;
await tx.pointsAccrual.update({ where: { id: a.id }, data: { status: 'EXPIRED', expiredAmount: { increment: a.remaining }, remaining: 0 } });
}
if (!sum) return;
burned += sum;
await recalcPoints(tx, clientId);
out.events.push({ type: 'points.expired', clientId, amount: sum });
});
}
return burned;
}
/** Воркер (раз в сутки): предупредить за N и M дней до сгорания */
export async function warnExpiringPoints(ctx: Ctx) {
const s = await getSettings(ctx.db);
const now = ctx.now();
const burn = s['points.burnDays'] * DAY;
for (const [days, field] of [
[s['points.warnDays2'], 'pointsWarn3At'],
[s['points.warnDays1'], 'pointsWarn14At'],
] as const) {
const from = new Date(now.getTime() - burn); // уже сгорело бы
const to = new Date(now.getTime() - burn + days * DAY);
const list = await ctx.db.client.findMany({ where: { pointsActive: { gt: 0 }, pointsActivityAt: { gt: from, lte: to }, [field]: null }, select: { id: true, pointsActive: true, pointsActivityAt: true } });
for (const c of list) {
await ctx.db.client.update({ where: { id: c.id }, data: field === 'pointsWarn3At' ? { pointsWarn3At: now, pointsWarn14At: now } : { pointsWarn14At: now } });
const left = Math.max(1, Math.ceil((c.pointsActivityAt!.getTime() + burn - now.getTime()) / DAY));
await ctx.notify.emit({ type: 'points.expiring', clientId: c.id, amount: c.pointsActive, days: left });
}
}
}
/** Воркер (раз в сутки): кэш баланса против журнала. Возвращает расхождения (и чинит кэш) */
export async function reconcilePoints(db: Db) {
const rows = await db.$queryRaw<{ id: string; cached: number; pending_cached: number; actual: number; pending: number }[]>`
SELECT c.id, c."pointsActive" AS cached, c."pointsPending" AS pending_cached,
COALESCE(SUM(a.remaining) FILTER (WHERE a.status = 'ACTIVE'), 0)::int - c."pointsDebt" AS actual,
COALESCE(SUM(a.remaining) FILTER (WHERE a.status = 'PENDING'), 0)::int AS pending
FROM "Client" c LEFT JOIN "PointsAccrual" a ON a."clientId" = c.id
GROUP BY c.id HAVING c."pointsActive" <> COALESCE(SUM(a.remaining) FILTER (WHERE a.status = 'ACTIVE'), 0)::int - c."pointsDebt"
OR c."pointsPending" <> COALESCE(SUM(a.remaining) FILTER (WHERE a.status = 'PENDING'), 0)::int`;
for (const r of rows) await db.client.update({ where: { id: r.id }, data: { pointsActive: r.actual, pointsPending: r.pending } });
return rows;
}
// ───── предложения системы ─────
/** Воркер (раз в час): карточки «Предложено системой» — маршрут первого месяца и отзыв с фото */
export async function buildPointSuggestions(ctx: Ctx) {
const now = ctx.now();
const types = await ctx.db.pointsBonusType.findMany({ where: { code: { in: ['first_month_route', 'photo_review'] }, active: true } });
const route = types.find((t) => t.code === 'first_month_route');
const photo = types.find((t) => t.code === 'photo_review');
let created = 0;
const add = async (data: Prisma.PointsSuggestionUncheckedCreateInput) => {
const r = await ctx.db.pointsSuggestion.createMany({ data: [data], skipDuplicates: true });
created += r.count;
};
if (route) {
// 3 разные категории за 30 дней с регистрации
const fresh = await ctx.db.client.findMany({
where: { createdAt: { gte: new Date(now.getTime() - 45 * DAY) } },
select: { id: true, createdAt: true, orders: { where: { status: { not: 'CANCELLED' }, completedAt: { not: null } }, select: { createdAt: true, service: { select: { code: true, pointsCategoryId: true, categoryId: true } } } } },
});
for (const c of fresh) {
const until = c.createdAt.getTime() + 30 * DAY;
const cats = new Set(c.orders.filter((o) => o.createdAt.getTime() <= until).map((o) => o.service.pointsCategoryId ?? o.service.code));
if (cats.size >= 3) await add({ clientId: c.id, mechanic: 'first_month_route', dedupeKey: `route:${c.id}`, payload: { categories: [...cats] }, suggestedAmount: route.defaultAmount });
}
}
if (photo) {
// фото в чате в течение 7 дней после выполненного заказа
const msgs = await ctx.db.conciergeMessage.findMany({
where: { author: 'CLIENT', clientId: { not: null }, createdAt: { gte: new Date(now.getTime() - 3 * DAY) }, OR: [{ mime: { startsWith: 'image/' } }, { tgFileId: { not: null }, fileName: 'photo.jpg' }] },
select: { clientId: true, createdAt: true },
});
for (const m of msgs) {
const o = await ctx.db.order.findFirst({ where: { clientId: m.clientId!, completedAt: { lte: m.createdAt, gte: new Date(m.createdAt.getTime() - 7 * DAY) }, status: { not: 'CANCELLED' } }, orderBy: { completedAt: 'desc' } });
if (o) await add({ clientId: m.clientId!, mechanic: 'photo_review', dedupeKey: `photo:${o.id}`, payload: { orderId: o.id, orderNumber: o.number }, suggestedAmount: photo.defaultAmount });
}
}
if (created) await ctx.notify.emit({ type: 'points.suggestions', count: created });
return created;
}
/** Принять предложение: бонус — начислить; сверх лимита — выполнить операцию поддержки от имени суперадмина */
export async function acceptSuggestion(ctx: Ctx, input: { id: string; amount?: number; reason?: string | null }, actor: PointsActor) {
if (!actor.owner) throw new DomainError('FORBIDDEN');
const s = await ctx.db.pointsSuggestion.findUnique({ where: { id: input.id } });
if (!s || s.status !== 'OPEN') throw new DomainError('POINTS_REQUEST_CLOSED');
const p = s.payload as Record<string, unknown>;
if (s.mechanic === 'over_limit') {
const op = p.op as string;
const inp = p.input as Record<string, unknown>;
if (op === 'accrual') await accruePoints(ctx, { ...(inp as unknown as AccrualInput), serviceDate: new Date(inp.serviceDate as string), idempotencyKey: `sugg:${s.id}` }, actor);
else await redeemPoints(ctx, { ...(inp as unknown as RedeemInput), idempotencyKey: `sugg:${s.id}` }, actor);
} else {
const code = s.mechanic === 'first_month_route' ? 'first_month_route' : s.mechanic;
const bt = await ctx.db.pointsBonusType.findUnique({ where: { code } });
if (!bt) throw new DomainError('NOT_FOUND');
const amount = input.amount ?? s.suggestedAmount;
if (amount !== s.suggestedAmount && !input.reason?.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
await accruePoints(ctx, { clientId: s.clientId, kind: 'BONUS', bonusTypeId: bt.id, amount, reason: input.reason, serviceDate: ctx.now(), channel: 'WEB', idempotencyKey: `sugg:${s.id}` }, actor);
}
await ctx.db.pointsSuggestion.update({ where: { id: s.id }, data: { status: 'ACCEPTED', resolvedById: actor.adminId, resolvedAt: ctx.now(), reason: input.reason?.trim() || null } });
}
export async function rejectSuggestion(ctx: Ctx, input: { id: string; reason: string }, actor: PointsActor) {
if (!input.reason.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
const r = await ctx.db.pointsSuggestion.updateMany({ where: { id: input.id, status: 'OPEN' }, data: { status: 'REJECTED', resolvedById: actor.adminId, resolvedAt: ctx.now(), reason: input.reason.trim() } });
if (!r.count) throw new DomainError('POINTS_REQUEST_CLOSED');
}
// ───── клиент ─────
/** Экран «Барики»: баланс, ожидание, сгорание, ставки, серия, маршрут, сколько дал бы Barev+ */
export async function clientPoints(ctx: Ctx, clientId: string) {
const s = await getSettings(ctx.db);
const now = ctx.now();
const [client, pendingNext, cats, last30, tier, routeType] = await Promise.all([
ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { orders: { where: { status: { not: 'CANCELLED' }, completedAt: { not: null } }, select: { createdAt: true, service: { select: { code: true, pointsCategoryId: true } } } } } }),
ctx.db.pointsAccrual.findFirst({ where: { clientId, status: 'PENDING' }, orderBy: { availableAt: 'asc' } }),
ctx.db.pointsCategory.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' } }),
ctx.db.pointsAccrual.findMany({ where: { clientId, kind: 'ORDER', status: { not: 'REVERSED' }, serviceDate: { gte: new Date(now.getTime() - 30 * DAY) } }, include: { category: true } }),
tierAt(ctx.db, clientId, now),
ctx.db.pointsBonusType.findUnique({ where: { code: 'first_month_route' } }),
]);
const burnAt = client.pointsActive > 0 && client.pointsActivityAt ? new Date(client.pointsActivityAt.getTime() + s['points.burnDays'] * DAY) : null;
// на Barev+ за 30 дней получил бы
const plusWouldGet = tier === 'BAREV' ? last30.reduce((sum, a) => sum + suggestAccrual({ orderAmount: a.orderAmount ?? 0, excludedAmount: a.excludedAmount, paidWithPoints: a.paidWithPoints, ratePct: (a.category?.ratePlus ?? 0) + a.streakBonusPct }) - a.amount, 0) : 0;
const streakAlive = client.streakLastAt && now.getTime() - client.streakLastAt.getTime() <= s['points.streakGapDays'] * DAY;
const streak = streakAlive ? client.streakCount : 0;
const routeUntil = new Date(client.createdAt.getTime() + 30 * DAY);
const routeCats = new Set(client.orders.filter((o) => o.createdAt <= routeUntil).map((o) => o.service.pointsCategoryId ?? o.service.code));
return {
balance: client.pointsActive,
pending: client.pointsPending,
pendingNextAt: pendingNext?.availableAt ?? null,
burnAt,
burnAmount: burnAt ? client.pointsActive : 0,
tier,
rates: cats.map((c) => ({ code: c.code, name: c.name as Record<string, string>, barev: c.rateBarev, plus: c.ratePlus, elite: c.rateElite })),
plusWouldGet: Math.max(0, plusWouldGet),
streak: { count: streak, next: streak < 3 ? 3 : streak < 6 ? 6 : null, bonusPct: streakBonusPct(streak), gapDays: s['points.streakGapDays'] },
route: routeType && now < routeUntil ? { done: Math.min(3, routeCats.size), until: routeUntil, amount: routeType.defaultAmount } : null,
redeem: { sharePct: s['points.maxSharePct'], min: s['points.minRedeem'] },
openRequests: await ctx.db.pointsRedemption.count({ where: { clientId, status: 'REQUESTED' } }),
};
}
export type PointsHistoryRow = {
id: string;
at: Date;
type: 'accrual' | 'bonus' | 'redemption' | 'request' | 'reversal' | 'expired';
amount: number;
status: string;
category: Record<string, string> | null;
bonus: Record<string, string> | null;
orderRef: string | null;
comment: string | null;
availableAt?: Date | null;
expiresAt?: Date | null;
};
/** История клиента: начисления, списания, отмены — новые сверху */
export async function pointsHistory(db: Db | Tx, clientId: string, opts: { take?: number; before?: Date } = {}) {
const take = Math.min(opts.take ?? 30, 100);
const at = opts.before ? { lt: opts.before } : undefined;
const [acc, red, rev] = await Promise.all([
db.pointsAccrual.findMany({ where: { clientId, createdAt: at }, include: { category: true, bonusType: true }, orderBy: { createdAt: 'desc' }, take }),
db.pointsRedemption.findMany({ where: { clientId, requestedAt: at }, include: { order: { select: { number: true } } }, orderBy: { requestedAt: 'desc' }, take }),
db.pointsReversal.findMany({ where: { clientId, createdAt: at }, orderBy: { createdAt: 'desc' }, take }),
]);
const rows: PointsHistoryRow[] = [
...acc.map((a) => ({
id: a.id,
at: a.createdAt,
type: (a.kind === 'BONUS' ? 'bonus' : 'accrual') as PointsHistoryRow['type'],
amount: a.amount,
status: a.status,
category: (a.category?.name as Record<string, string>) ?? null,
bonus: (a.bonusType?.name as Record<string, string>) ?? null,
orderRef: a.orderRef,
comment: a.commentForClient,
availableAt: a.availableAt,
expiresAt: a.expiresAt,
})),
...red.map((r) => ({ id: r.id, at: r.requestedAt, type: (r.status === 'REQUESTED' || r.status === 'REJECTED' ? 'request' : 'redemption') as PointsHistoryRow['type'], amount: -r.amount, status: r.status, category: null, bonus: null, orderRef: r.order ? `#${r.order.number}` : r.orderRef, comment: r.status === 'REJECTED' && r.reason !== 'timeout' ? r.reason : null })),
...rev.map((r) => ({ id: r.id, at: r.createdAt, type: 'reversal' as const, amount: r.targetType === 'ACCRUAL' ? -r.amount : r.amount, status: r.targetType, category: null, bonus: null, orderRef: null, comment: r.reason === 'order_cancelled' ? null : r.reason })),
];
return rows.sort((a, b) => b.at.getTime() - a.at.getTime()).slice(0, take);
}
+10 -4
View File
@@ -2,6 +2,7 @@ import { averageRating, providerActions, startOfYerevanDay, getFlow, type Servic
import { orderPayout, rateLabel } from './payouts.ts';
import type { Db } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { getSettings } from './settings.ts';
import { upsertUser, type TelegramUserInput } from './clients.ts';
const DAY_MS = 24 * 3600_000;
@@ -55,6 +56,7 @@ export async function setShift(db: Db, providerId: string, onShift: boolean) {
/** PRO-1/PRO-2: шапка и заказы на сегодня */
export async function proToday(ctx: Ctx, providerId: string) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true, payoutPreset: true } });
const fot = { fotPct: (await getSettings(ctx.db))['partner.fotPct'] };
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [orders, offers] = await Promise.all([
@@ -89,7 +91,7 @@ export async function proToday(ctx: Ctx, providerId: string) {
rating: averageRating(provider.ratingSum, provider.ratingCount),
companyName: provider.company?.name ?? null,
},
earningsToday: doneToday.reduce((s, o) => s + orderPayout(o, provider), 0),
earningsToday: doneToday.reduce((s, o) => s + orderPayout(o, provider, fot), 0),
currentOrderId: current?.id ?? null,
orders: orders.map((o) => ({ ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status) })),
upcoming: upcoming.map(proOrderView),
@@ -109,6 +111,7 @@ type ProOrderRow = {
locale: import('@barev/db').Locale;
params: unknown;
totalPrice: number;
pointsUsed?: number;
paymentStatus: import('@barev/db').PaymentStatus;
service: { code: string; flow: import('@barev/db').ServiceFlow };
district: { code: string } | null;
@@ -134,6 +137,8 @@ export function proOrderView(o: ProOrderRow) {
clientName: o.client.user.firstName,
params: o.params as Record<string, unknown>,
totalPrice: o.totalPrice,
/** к оплате деньгами (часть могла быть оплачена бариками) */
dueAmount: o.totalPrice - (o.pointsUsed ?? 0),
paymentStatus: o.paymentStatus,
checklist: o.checklist?.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, checked: !!c.checkedAt, hasPhoto: !!c.fileId })) ?? [],
};
@@ -152,6 +157,7 @@ export async function proOrder(ctx: Ctx, providerId: string, orderId: string) {
/** PRO-6: история и заработок за период */
export async function proHistory(ctx: Ctx, providerId: string, from: Date, to: Date) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true, payoutPreset: true } });
const fot = { fotPct: (await getSettings(ctx.db))['partner.fotPct'] };
const orders = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: from, lt: to } },
include: { service: true, district: true, client: { include: { user: true } } },
@@ -159,10 +165,10 @@ export async function proHistory(ctx: Ctx, providerId: string, from: Date, to: D
});
const done = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
return {
orders: orders.map((o) => ({ ...proOrderView(o), payout: getFlow(o.service.flow as ServiceFlow).done.includes(o.status) ? orderPayout(o, provider) : 0 })),
totalEarned: done.reduce((s, o) => s + orderPayout(o, provider), 0),
orders: orders.map((o) => ({ ...proOrderView(o), payout: getFlow(o.service.flow as ServiceFlow).done.includes(o.status) ? orderPayout(o, provider, fot) : 0 })),
totalEarned: done.reduce((s, o) => s + orderPayout(o, provider, fot), 0),
doneCount: done.length,
rate: rateLabel(provider),
rate: rateLabel(provider, fot),
};
}
+2
View File
@@ -42,6 +42,8 @@ function clientOrderView(o: ClientOrderRow) {
discountPct: o.discountPct,
discountAmount: o.discountAmount,
totalPrice: o.totalPrice,
/** оплачено бариками; к оплате деньгами — totalPrice − pointsUsed */
pointsUsed: o.pointsUsed,
cancelFee: o.cancelFee,
paymentStatus: o.paymentStatus,
paymentMethod: o.paymentMethod ? { code: o.paymentMethod.code, name: o.paymentMethod.name as Record<string, string> } : null,
+470
View File
@@ -0,0 +1,470 @@
/**
* Партнёры услуг (клининговые компании): деньги по заказу, кабинет менеджера,
* автораспределение заказов по сотрудникам.
*/
import { createHash, randomBytes } from 'node:crypto';
import {
averageRating,
DAY,
getFlow,
isProviderFree,
isProviderFreeWithGap,
LOCALES,
partnerSplit,
partnerTerms,
startOfYerevanDay,
withinDailyLimits,
yerevanMonthKey,
type Locale,
type ServiceFlow,
type Settings,
} from '@barev/core';
import type { Db, Tx } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { transitionOrder } from './orders.ts';
import { proOrderView } from './providers.ts';
import { getSettings } from './settings.ts';
const ACTIVE_EXCLUDED = ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] as const;
export const managerInviteCode = () => `PM-${randomBytes(5).toString('hex').toUpperCase()}`;
export const staffInviteCode = () => `PRO-${randomBytes(5).toString('hex').toUpperCase()}`;
type CompanyRow = { deal: 'PROFIT_SHARE' | 'COMMISSION'; commissionPct: number; profitSharePct: number; fotPct: number | null; taxPct: number | null };
/** Условия партнёра с общими ФОТ и налогом из настроек */
export function termsOf(c: CompanyRow, settings: Settings) {
return partnerTerms(c, { fotPct: settings['partner.fotPct'], taxPct: settings['partner.taxPct'] });
}
/** Деньги по заказу у партнёра: ФОТ, налог, доход партнёра и что остаётся нам */
export function companySplit(c: CompanyRow, paid: number, settings: Settings) {
return partnerSplit(paid, termsOf(c, settings));
}
/** При оплате заказа сотрудником партнёра фиксируем доход партнёра (смена условий не меняет прошлое) */
export async function recordPartnerEarning(tx: Tx, order: { id: string; companyId: string | null; providerId: string | null; totalPrice: number }, settings: Settings, now: Date) {
if (!order.companyId || !order.providerId) return;
const company = await tx.providerCompany.findUnique({ where: { id: order.companyId } });
if (!company) return;
const s = companySplit(company, order.totalPrice, settings);
await tx.partnerEarning.upsert({
where: { orderId: order.id },
create: { companyId: company.id, orderId: order.id, deal: company.deal, paid: s.paid, fot: s.fot, tax: s.tax, pct: s.pct, amount: s.partner, period: yerevanMonthKey(now) },
update: {},
});
}
function interval(o: { scheduledAt: Date; durationMinutes: number }) {
return { start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) };
}
/** Перерыв на дорогу партнёра — не меньше минимума из настроек */
export function travelGap(company: { travelGapMinutes: number }, settings: Settings) {
return Math.max(company.travelGapMinutes, settings['partner.minTravelGapMinutes']);
}
export type StaffCheck = 'ok' | 'busy' | 'gap' | 'limit' | 'notLinked';
/**
* Сотрудники партнёра на заказ: свободен ли по графику (с перерывом на дорогу), в лимите ли нагрузки,
* район и язык. eligible — подходит для автораспределения. Порядок: язык и район, меньше заказов за неделю, выше рейтинг.
*/
export async function companyStaffCandidates(db: Db | Tx, companyId: string, orderId: string, settings: Settings) {
const order = await db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } });
const company = await db.providerCompany.findUniqueOrThrow({ where: { id: companyId } });
const slot = interval(order);
const dayStart = startOfYerevanDay(order.scheduledAt);
const dayEnd = new Date(dayStart.getTime() + DAY);
const gap = travelGap(company, settings);
const weekFrom = new Date(dayStart.getTime() - 6 * DAY);
const [avail, staff, week] = await Promise.all([
loadAvailability(db, order.service.providerRole, dayStart, dayEnd, { companyId, excludeOrderId: order.id }),
db.provider.findMany({
where: { companyId, active: true, role: order.service.providerRole },
include: {
districts: true,
orders: { where: { id: { not: order.id }, scheduledAt: { gte: dayStart, lt: dayEnd }, status: { notIn: [...ACTIVE_EXCLUDED] } }, select: { durationMinutes: true } },
},
orderBy: { name: 'asc' },
}),
db.order.groupBy({ by: ['providerId'], where: { companyId, scheduledAt: { gte: weekFrom, lt: dayEnd }, status: { not: 'CANCELLED' }, providerId: { not: null } }, _count: true }),
]);
const weekLoad = new Map(week.map((w) => [w.providerId, w._count]));
const rows = staff.map((p) => {
const a = avail.find((x) => x.id === p.id);
const free = !!a && isProviderFree(a, slot);
const freeGap = !!a && isProviderFreeWithGap(a, slot, gap);
const inLimit = withinDailyLimits(p, p.orders, order.durationMinutes);
const check: StaffCheck = !free ? 'busy' : !freeGap ? 'gap' : !inLimit ? 'limit' : !p.userId ? 'notLinked' : 'ok';
const language = p.languages.includes(order.locale);
const district = !!order.districtId && p.districts.some((d) => d.districtId === order.districtId);
return {
id: p.id,
name: p.name,
languages: p.languages as Locale[],
onShift: p.onShift,
linked: !!p.userId,
check,
eligible: check === 'ok',
language,
district,
dayOrders: p.orders.length,
weekOrders: weekLoad.get(p.id) ?? 0,
rating: averageRating(p.ratingSum, p.ratingCount),
maxOrdersPerDay: p.maxOrdersPerDay,
maxHoursPerDay: p.maxHoursPerDay,
};
});
const score = (r: (typeof rows)[number]) => (r.language ? 2 : 0) + (r.district ? 1 : 0);
rows.sort((a, b) => Number(b.eligible) - Number(a.eligible) || score(b) - score(a) || a.weekOrders - b.weekOrders || (b.rating ?? 4) - (a.rating ?? 4) || a.name.localeCompare(b.name));
return { gap, rows };
}
/** Есть ли у партнёра свободный сотрудник на заказ (для оператора) */
export async function companyAvailability(db: Db | Tx, companyId: string, orderId: string, settings: Settings) {
const { rows } = await companyStaffCandidates(db, companyId, orderId, settings);
return { free: rows.filter((r) => r.eligible).length, total: rows.length, rows };
}
async function managerOf(db: Db | Tx, companyId: string, userId: string) {
const link = await db.companyDispatcher.findUnique({ where: { companyId_userId: { companyId, userId } }, include: { company: true } });
if (!link || !link.company.active) throw new DomainError('FORBIDDEN');
return link.company;
}
export type CompanyAutoResult = { result: 'offered'; providerId: string } | { result: 'off' | 'skipped' } | { result: 'none' };
/**
* Автораспределение внутри партнёра: заказ партнёра без сотрудника предлагается лучшему подходящему.
* Отказ или молчание — следующему. Никого не осталось — заказ ждёт менеджера.
*/
export async function companyAutoAssign(ctx: Ctx, orderId: string): Promise<CompanyAutoResult> {
const settings = await getSettings(ctx.db);
const events: DomainEvent[] = [];
const res = await ctx.db.$transaction(async (tx): Promise<CompanyAutoResult> => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: orderId }, include: { service: true, offers: true, company: true } });
if (!order?.company || order.providerId) return { result: 'skipped' };
if (!order.company.autoAssign) return { result: 'off' };
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial || order.scheduledAt <= ctx.now()) return { result: 'skipped' };
const companyOffer = order.offers.find((o) => o.status === 'OFFERED' && !o.providerId && o.companyId === order.companyId);
if (!companyOffer || order.offers.some((o) => o.status === 'OFFERED' && o.providerId)) return { result: 'skipped' };
const tried = new Set(order.offers.map((o) => o.providerId).filter(Boolean));
const { rows } = await companyStaffCandidates(tx, order.company.id, order.id, settings);
const pick = rows.find((r) => r.eligible && !tried.has(r.id));
if (!pick) {
const last = await tx.orderStatusHistory.findFirst({ where: { orderId: order.id }, orderBy: { createdAt: 'desc' } });
if (last?.event !== 'partnerAutoNone') {
await tx.orderStatusHistory.create({ data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'partnerAutoNone', actorType: 'SYSTEM' } });
events.push({ type: 'company.noStaff', orderId: order.id, companyId: order.company.id });
}
return { result: 'none' };
}
// предложение партнёру принято — дальше заказ ведёт его сотрудник
await tx.orderOffer.update({ where: { id: companyOffer.id }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } });
const offer = await tx.orderOffer.create({
data: { orderId: order.id, providerId: pick.id, companyId: order.company.id, status: 'OFFERED', expiresAt: new Date(ctx.now().getTime() + settings['offer.acceptTimeoutMinutes'] * 60_000) },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'partnerAutoOffered', actorType: 'SYSTEM', comment: pick.name },
});
events.push({ type: 'offer.created', offerId: offer.id });
return { result: 'offered', providerId: pick.id };
});
for (const e of events) await ctx.notify.emit(e);
return res;
}
/** Воркер: заказы партнёров с автораспределением, которые ждут сотрудника */
export async function retryCompanyAutoAssign(ctx: Ctx) {
const waiting = await ctx.db.orderOffer.findMany({
where: { status: 'OFFERED', providerId: null, company: { autoAssign: true, active: true }, order: { providerId: null, scheduledAt: { gt: ctx.now() } } },
select: { orderId: true },
take: 50,
});
for (const w of waiting) await companyAutoAssign(ctx, w.orderId);
}
/** Партнёр не взял заказ: отказ менеджера или истёк срок — заказ возвращается оператору */
async function returnToOperator(ctx: Ctx, tx: Tx, orderId: string, companyId: string, reason: 'declined' | 'expired', actorUserId?: string): Promise<DomainEvent> {
const now = ctx.now();
await tx.orderOffer.updateMany({ where: { orderId, companyId, providerId: null, status: 'OFFERED' }, data: { status: reason === 'declined' ? 'DECLINED' : 'EXPIRED', respondedAt: now } });
await tx.orderOffer.updateMany({ where: { orderId, companyId, providerId: { not: null }, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: now } });
const order = await tx.order.update({ where: { id: orderId }, data: { companyId: null } });
await tx.orderStatusHistory.create({
data: { orderId, fromStatus: order.status, toStatus: order.status, event: reason === 'declined' ? 'partnerDeclined' : 'partnerExpired', actorType: reason === 'declined' ? 'DISPATCHER' : 'SYSTEM', actorId: actorUserId },
});
return { type: 'partner.returned', orderId, companyId, reason };
}
/** Менеджер отказывается от заказа — он возвращается оператору */
export async function partnerDeclineOrder(ctx: Ctx, input: { orderId: string; companyId: string; userId: string }) {
const event = await ctx.db.$transaction(async (tx) => {
await managerOf(tx, input.companyId, input.userId);
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: input.orderId } });
if (!order || order.companyId !== input.companyId || order.providerId) throw new DomainError('NOT_FOUND');
return returnToOperator(ctx, tx, order.id, input.companyId, 'declined', input.userId);
});
await ctx.notify.emit(event);
}
/** Воркер: партнёр слишком долго не назначает сотрудника — возвращаем заказ оператору */
export async function expireCompanyOffers(ctx: Ctx) {
const expired = await ctx.db.orderOffer.findMany({
where: { status: 'OFFERED', providerId: null, companyId: { not: null }, expiresAt: { lt: ctx.now() } },
select: { orderId: true, companyId: true },
});
for (const x of expired) {
const event = await ctx.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${x.orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: x.orderId } });
if (!order || order.providerId || order.companyId !== x.companyId) {
await tx.orderOffer.updateMany({ where: { orderId: x.orderId, companyId: x.companyId, providerId: null, status: 'OFFERED' }, data: { status: 'EXPIRED', respondedAt: ctx.now() } });
return null;
}
return returnToOperator(ctx, tx, order.id, x.companyId!, 'expired');
});
if (event) await ctx.notify.emit(event);
}
}
/** Менеджер снимает сотрудника с заказа — заказ снова у партнёра (и уйдёт другому при автораспределении) */
export async function partnerUnassign(ctx: Ctx, input: { orderId: string; companyId: string; userId: string }) {
const settings = await getSettings(ctx.db);
const events: DomainEvent[] = [];
await ctx.db.$transaction(async (tx) => {
await managerOf(tx, input.companyId, input.userId);
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
const flow = getFlow(order.service.flow);
const now = ctx.now();
const pending = await tx.orderOffer.findFirst({ where: { orderId: order.id, status: 'OFFERED', providerId: { not: null } } });
const providerId = order.providerId ?? pending?.providerId ?? null;
if (!providerId) throw new DomainError('TRANSITION_NOT_ALLOWED');
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: now } });
if (order.providerId) {
if (order.status !== flow.assigned && order.status !== flow.initial) throw new DomainError('TRANSITION_NOT_ALLOWED');
// отметка «уже предлагали», чтобы автораспределение не вернуло заказ тому же сотруднику
await tx.orderOffer.create({ data: { orderId: order.id, providerId, companyId: input.companyId, status: 'REVOKED', respondedAt: now } });
if (order.status === flow.assigned) {
const r = await transitionOrder(ctx, { orderId: order.id, to: flow.initial, actor: 'SYSTEM', actorId: input.userId, event: 'partnerUnassigned', data: { providerId: null } }, tx);
events.push(r.event);
} else {
await tx.order.update({ where: { id: order.id }, data: { providerId: null } });
}
events.push({ type: 'order.unassigned', orderId: order.id, providerId });
}
await tx.orderOffer.create({
data: { orderId: order.id, companyId: input.companyId, status: 'OFFERED', expiresAt: new Date(now.getTime() + settings['partner.acceptTimeoutMinutes'] * 60_000) },
});
});
for (const e of events) await ctx.notify.emit(e);
await companyAutoAssign(ctx, input.orderId);
}
// ───── кабинет менеджера ─────
const weekday = (n: number) => n >= 0 && n <= 6;
export interface StaffInput {
name: string;
phone?: string | null;
languages: Locale[];
districtIds: string[];
schedule: { weekday: number; startMinute: number; endMinute: number }[];
maxOrdersPerDay?: number | null;
maxHoursPerDay?: number | null;
}
function cleanStaff(input: StaffInput) {
const name = input.name.trim();
if (!name) throw new DomainError('INVALID_PARAMS');
const languages = input.languages.filter((l) => (LOCALES as readonly string[]).includes(l));
return {
name: name.slice(0, 100),
phone: input.phone?.trim() || null,
languages: languages.length ? languages : (['ru'] as Locale[]),
maxOrdersPerDay: input.maxOrdersPerDay && input.maxOrdersPerDay > 0 ? Math.min(input.maxOrdersPerDay, 20) : null,
maxHoursPerDay: input.maxHoursPerDay && input.maxHoursPerDay > 0 ? Math.min(input.maxHoursPerDay, 24) : null,
schedule: input.schedule.filter((s) => weekday(s.weekday) && s.endMinute > s.startMinute && s.startMinute >= 0 && s.endMinute <= 1440),
};
}
/** Менеджер добавляет сотрудника — без одобрения команды; сотрудник входит в рабочий бот по ссылке */
export async function partnerAddStaff(ctx: Ctx, input: { companyId: string; userId: string; staff: StaffInput }) {
const company = await managerOf(ctx.db, input.companyId, input.userId);
const { schedule, ...data } = cleanStaff(input.staff);
const role = company.roles[0] ?? 'CLEANER';
const districts = await ctx.db.district.findMany({ where: { id: { in: input.staff.districtIds } }, select: { id: true } });
const p = await ctx.db.provider.create({
data: {
...data,
role,
companyId: company.id,
inviteCode: staffInviteCode(),
districts: { create: districts.map((d) => ({ districtId: d.id })) },
schedule: { create: schedule },
},
});
return { id: p.id, inviteCode: p.inviteCode };
}
export async function partnerUpdateStaff(ctx: Ctx, input: { companyId: string; userId: string; providerId: string; staff: StaffInput; active?: boolean }) {
await managerOf(ctx.db, input.companyId, input.userId);
const p = await ctx.db.provider.findUnique({ where: { id: input.providerId } });
if (!p || p.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
const { schedule, ...data } = cleanStaff(input.staff);
const districts = await ctx.db.district.findMany({ where: { id: { in: input.staff.districtIds } }, select: { id: true } });
await ctx.db.provider.update({
where: { id: p.id },
data: {
...data,
...(input.active === undefined ? {} : input.active ? { active: true, blockedReason: null, blockedAt: null } : { active: false, onShift: false, blockedReason: 'partner', blockedAt: ctx.now() }),
districts: { deleteMany: {}, create: districts.map((d) => ({ districtId: d.id })) },
schedule: { deleteMany: {}, create: schedule },
},
});
if (input.active === false) await ctx.db.orderOffer.updateMany({ where: { providerId: p.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: ctx.now() } });
}
/** Новая ссылка для сотрудника (старая привязка снимается) */
export async function partnerResetStaffInvite(ctx: Ctx, input: { companyId: string; userId: string; providerId: string }) {
await managerOf(ctx.db, input.companyId, input.userId);
const p = await ctx.db.provider.findUnique({ where: { id: input.providerId } });
if (!p || p.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
const u = await ctx.db.provider.update({ where: { id: p.id }, data: { inviteCode: staffInviteCode(), userId: null, onShift: false } });
return { inviteCode: u.inviteCode };
}
/** Настройки партнёра, которые меняет менеджер: автораспределение и перерыв на дорогу */
export async function partnerSaveSettings(ctx: Ctx, input: { companyId: string; userId: string; autoAssign: boolean; travelGapMinutes: number }) {
await managerOf(ctx.db, input.companyId, input.userId);
const settings = await getSettings(ctx.db);
const gap = Math.min(240, Math.max(settings['partner.minTravelGapMinutes'], Math.round(input.travelGapMinutes)));
await ctx.db.providerCompany.update({ where: { id: input.companyId }, data: { autoAssign: input.autoAssign, travelGapMinutes: gap } });
if (input.autoAssign) await retryCompanyAutoAssign(ctx);
}
export async function partnerRemoveManager(ctx: Ctx, input: { companyId: string; userId: string; managerId: string }) {
await managerOf(ctx.db, input.companyId, input.userId);
const m = await ctx.db.companyDispatcher.findUnique({ where: { id: input.managerId } });
if (!m || m.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
if (m.userId === input.userId) throw new DomainError('FORBIDDEN');
await ctx.db.companyDispatcher.delete({ where: { id: m.id } });
}
const personName = (u: { firstName: string | null; lastName: string | null; username: string | null }) =>
[u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
/** Доход партнёра: по месяцам, последние заказы, выплаты и остаток к выплате */
export async function partnerMoney(db: Db | Tx, companyId: string) {
const [months, recent, payouts] = await Promise.all([
db.partnerEarning.groupBy({ by: ['period'], where: { companyId }, _sum: { amount: true, paid: true }, _count: true, orderBy: { period: 'desc' }, take: 12 }),
db.partnerEarning.findMany({ where: { companyId }, orderBy: { createdAt: 'desc' }, take: 50, include: { order: { select: { id: true, number: true, scheduledAt: true, provider: { select: { name: true } } } } } }),
db.payout.findMany({ where: { companyId }, orderBy: { createdAt: 'desc' }, take: 24 }),
]);
const accrued = (await db.partnerEarning.aggregate({ where: { companyId }, _sum: { amount: true } }))._sum.amount ?? 0;
const paid = payouts.filter((p) => p.status === 'PAID').reduce((s, p) => s + p.amount, 0);
return {
accrued,
paid,
balance: accrued - paid,
months: months.map((m) => ({ period: m.period, amount: m._sum.amount ?? 0, revenue: m._sum.paid ?? 0, orders: m._count })),
recent: recent.map((e) => ({ orderId: e.order.id, number: e.order.number, at: e.order.scheduledAt, staff: e.order.provider?.name ?? null, deal: e.deal, paid: e.paid, fot: e.fot, tax: e.tax, pct: e.pct, amount: e.amount })),
payouts: payouts.map((p) => ({ id: p.id, amount: p.amount, status: p.status, paidAt: p.paidAt, note: p.note })),
};
}
/** Кабинет менеджера партнёра: заказы, сотрудники, настройки, доход */
export async function partnerCabinetData(ctx: Ctx, companyId: string, userId: string) {
const company = await managerOf(ctx.db, companyId, userId);
const settings = await getSettings(ctx.db);
const today = startOfYerevanDay(ctx.now());
const [pending, assigned, staff, managers, money, districts, dayCounts] = await Promise.all([
ctx.db.order.findMany({
where: { companyId, providerId: null, status: { not: 'CANCELLED' }, offers: { some: { companyId, status: 'OFFERED' } } },
include: { service: true, district: true, client: { include: { user: true } }, offers: { where: { status: 'OFFERED' }, include: { provider: { select: { name: true } } } } },
orderBy: { scheduledAt: 'asc' },
}),
ctx.db.order.findMany({
where: { companyId, providerId: { not: null }, scheduledAt: { gte: today }, status: { notIn: [...ACTIVE_EXCLUDED] } },
include: { service: true, district: true, client: { include: { user: true } }, provider: true },
orderBy: { scheduledAt: 'asc' },
take: 100,
}),
ctx.db.provider.findMany({ where: { companyId }, include: { districts: true, schedule: true }, orderBy: [{ active: 'desc' }, { name: 'asc' }] }),
ctx.db.companyDispatcher.findMany({ where: { companyId }, include: { user: true }, orderBy: { createdAt: 'asc' } }),
partnerMoney(ctx.db, companyId),
ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } }),
ctx.db.order.groupBy({ by: ['providerId'], where: { companyId, scheduledAt: { gte: today, lt: new Date(today.getTime() + DAY) }, status: { notIn: [...ACTIVE_EXCLUDED] } }, _count: true }),
]);
const todayCount = new Map(dayCounts.map((d) => [d.providerId, d._count]));
const terms = termsOf(company, settings);
return {
company: { id: company.id, name: company.name, deal: company.deal, terms, autoAssign: company.autoAssign, travelGapMinutes: company.travelGapMinutes, minTravelGap: settings['partner.minTravelGapMinutes'], acceptTimeoutMinutes: settings['partner.acceptTimeoutMinutes'], managerInvite: company.inviteCode },
pending: pending.map((o) => {
const offer = o.offers.find((x) => x.providerId);
const companyOffer = o.offers.find((x) => !x.providerId);
return { ...proOrderView(o), offeredTo: offer?.provider?.name ?? null, offerExpiresAt: offer?.expiresAt ?? null, deadline: companyOffer?.expiresAt ?? null, income: companySplit(company, o.totalPrice, settings).partner };
}),
assigned: assigned.map((o) => ({ ...proOrderView(o), providerId: o.providerId, providerName: o.provider?.name ?? null, canUnassign: o.status === getFlow(o.service.flow as ServiceFlow).assigned, income: companySplit(company, o.totalPrice, settings).partner })),
staff: staff.map((p) => ({
id: p.id,
name: p.name,
phone: p.phone,
role: p.role,
active: p.active,
onShift: p.onShift,
linked: !!p.userId,
inviteCode: p.userId ? null : p.inviteCode,
languages: p.languages,
districtIds: p.districts.map((d) => d.districtId),
schedule: p.schedule.map((s) => ({ weekday: s.weekday, startMinute: s.startMinute, endMinute: s.endMinute })),
maxOrdersPerDay: p.maxOrdersPerDay,
maxHoursPerDay: p.maxHoursPerDay,
rating: averageRating(p.ratingSum, p.ratingCount),
today: todayCount.get(p.id) ?? 0,
})),
managers: managers.map((m) => ({ id: m.id, name: personName(m.user), me: m.userId === userId })),
districts: districts.map((d) => ({ id: d.id, code: d.code })),
money,
};
}
// ───── вход в кабинет с компьютера ─────
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
const WEB_CODE_TTL = 15 * 60_000;
const WEB_SESSION_TTL = 30 * DAY;
/** Бот: одноразовая ссылка для входа в кабинет из браузера (15 минут) */
export async function createProWebLogin(ctx: Ctx, userId: string) {
const code = randomBytes(18).toString('base64url');
await ctx.db.proWebSession.create({ data: { userId, codeHash: sha(code), codeExpiresAt: new Date(ctx.now().getTime() + WEB_CODE_TTL), expiresAt: new Date(ctx.now().getTime() + WEB_CODE_TTL) } });
return code;
}
/** Браузер меняет одноразовый код на токен на 30 дней */
export async function exchangeProWebLogin(ctx: Ctx, code: string) {
const s = await ctx.db.proWebSession.findUnique({ where: { codeHash: sha(code) } });
if (!s || !s.codeExpiresAt || s.codeExpiresAt < ctx.now()) throw new DomainError('INVITE_INVALID');
const token = randomBytes(32).toString('base64url');
await ctx.db.proWebSession.update({ where: { id: s.id }, data: { codeHash: null, codeExpiresAt: null, tokenHash: sha(token), expiresAt: new Date(ctx.now().getTime() + WEB_SESSION_TTL), lastUsedAt: ctx.now() } });
return token;
}
/** Telegram ID владельца токена браузера или null */
export async function proWebSessionUser(db: Db, token: string, now: Date) {
const s = await db.proWebSession.findUnique({ where: { tokenHash: sha(token) }, include: { user: { select: { telegramId: true } } } });
if (!s || s.expiresAt < now) return null;
if (!s.lastUsedAt || now.getTime() - s.lastUsedAt.getTime() > 3600_000) await db.proWebSession.update({ where: { id: s.id }, data: { lastUsedAt: now } });
return s.user.telegramId;
}
export async function revokeProWebSession(db: Db, token: string) {
await db.proWebSession.deleteMany({ where: { tokenHash: sha(token) } });
}
+113 -42
View File
@@ -1,11 +1,12 @@
import { randomBytes } from 'node:crypto';
import { quote, validateParams, windowStarts, yerevanDateKey, type Locale, type Utm } from '@barev/core';
import { DAY, quote, slotConfigSchema, validateParams, windowStarts, yerevanDateKey, type Locale, type Utm } from '@barev/core';
import type { Prisma, WebRequestStatus } from '@barev/db';
import { getServiceByCode } from './catalog.ts';
import { giveConsent } from './clients.ts';
import { DomainError, type Ctx } from './context.ts';
import { createOrder } from './orders.ts';
import { getSettings } from './settings.ts';
import { slotsForDay } from './availability.ts';
/** Телефон: цифры с необязательным «+», 8–15 цифр (E.164). Пробелы, скобки и дефисы убираем. */
export function normalizePhone(raw: string): string | null {
@@ -17,9 +18,10 @@ export function normalizePhone(raw: string): string | null {
export interface CreateWebRequestInput {
serviceCode: string;
params: Record<string, unknown>;
preferredAt: Date;
districtCode: string;
addressText: string;
/** время, район и адрес необязательны: недостающее спросит бот или уточнит оператор */
preferredAt?: Date;
districtCode?: string;
addressText?: string;
note?: string;
phone: string;
name?: string;
@@ -42,19 +44,22 @@ export async function createWebRequest(ctx: Ctx, input: CreateWebRequestInput) {
const phone = normalizePhone(input.phone);
if (!phone) throw new DomainError('PHONE_INVALID');
const addressText = input.addressText.trim();
const district = await ctx.db.district.findUnique({ where: { code: input.districtCode } });
if (!district || addressText.length < 3) throw new DomainError('ADDRESS_REQUIRED');
const addressText = input.addressText?.trim() || null;
if (addressText && addressText.length < 3) throw new DomainError('ADDRESS_REQUIRED');
const district = input.districtCode ? await ctx.db.district.findUnique({ where: { code: input.districtCode } }) : null;
if (input.districtCode && !district) throw new DomainError('ADDRESS_REQUIRED');
// длинная уборка может не поместиться в один день — тогда сверяем только начало, длительность согласует оператор
const window = service.slotConfig.dayEndMinute - service.slotConfig.dayStartMinute;
const starts = windowStarts({
dateKey: yerevanDateKey(input.preferredAt),
config: service.slotConfig,
durationMinutes: Math.min(q.durationMinutes, window),
now: ctx.now(),
});
if (!starts.some((s) => s.getTime() === input.preferredAt.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
if (input.preferredAt) {
// длинная уборка может не поместиться в один день — тогда сверяем только начало, длительность согласует оператор
const window = service.slotConfig.dayEndMinute - service.slotConfig.dayStartMinute;
const starts = windowStarts({
dateKey: yerevanDateKey(input.preferredAt),
config: service.slotConfig,
durationMinutes: Math.min(q.durationMinutes, window),
now: ctx.now(),
});
if (!starts.some((s) => s.getTime() === input.preferredAt!.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
}
const settings = await getSettings(ctx.db);
const token = randomBytes(6).toString('base64url').replace(/[-_]/g, 'x');
@@ -65,9 +70,9 @@ export async function createWebRequest(ctx: Ctx, input: CreateWebRequestInput) {
token,
serviceId: service.id,
params: params.values as Prisma.InputJsonValue,
preferredAt: input.preferredAt,
preferredAt: input.preferredAt ?? null,
durationMinutes: q.durationMinutes,
districtId: district.id,
districtId: district?.id ?? null,
addressText,
note: input.note?.trim() || undefined,
phone,
@@ -84,40 +89,36 @@ export async function createWebRequest(ctx: Ctx, input: CreateWebRequestInput) {
}
type Gift = Awaited<ReturnType<typeof giveConsent>>;
export type WebRequestField = 'district' | 'address' | 'time';
export type LinkResult =
| { kind: 'none' }
| { kind: 'ordered'; requestNumber: number; orderNumber: number; scheduledAt: Date; gift?: Gift }
| { kind: 'linked'; requestNumber: number; gift?: Gift };
| { kind: 'linked'; requestNumber: number; gift?: Gift }
/** не хватает данных для заказа — бот их доспросит */
| { kind: 'incomplete'; requestId: string; requestNumber: number; missing: WebRequestField[]; gift?: Gift };
/**
* Человек открыл бота по ссылке заявки: привязываем заявку к клиенту, переносим согласие с сайта
* и пробуем сразу оформить заказ на желаемое время. Если время занято или исполнителей нет —
* заявка остаётся оператору (LINKED). Повторный /start по той же ссылке ничего не меняет.
*/
export async function linkWebRequest(ctx: Ctx, token: string, clientId: string): Promise<LinkResult> {
const request = await ctx.db.webRequest.findUnique({ where: { token }, include: { service: true, district: true } });
if (!request) return { kind: 'none' };
if (request.clientId && request.clientId !== clientId) return { kind: 'none' };
if (request.status === 'ORDERED' && request.orderId) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: request.orderId } });
return { kind: 'ordered', requestNumber: request.number, orderNumber: order.number, scheduledAt: order.scheduledAt };
}
if (request.status !== 'NEW') return { kind: 'linked', requestNumber: request.number };
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
// согласие дано на сайте; вместе с ним активируется подарок агентства, если положен
const gift = client.consentAt ? null : await giveConsent(ctx, clientId);
if (!client.user.phone) await ctx.db.user.update({ where: { id: client.userId }, data: { phone: request.phone } });
await ctx.db.webRequest.update({ where: { id: request.id }, data: { clientId, status: 'LINKED' } });
/** Чего не хватает заявке, чтобы оформить заказ */
export function missingFields(r: { preferredAt: Date | null; districtId: string | null; addressText: string | null }): WebRequestField[] {
const out: WebRequestField[] = [];
if (!r.districtId) out.push('district');
if (!r.addressText) out.push('address');
if (!r.preferredAt) out.push('time');
return out;
}
/** Пробуем оформить заказ по заявке; если время занято или исполнителей нет — заявка остаётся оператору */
async function tryOrder(ctx: Ctx, requestId: string, clientId: string, gift?: Gift): Promise<LinkResult> {
const request = await ctx.db.webRequest.findUniqueOrThrow({ where: { id: requestId }, include: { service: true } });
const missing = missingFields(request);
if (missing.length) return { kind: 'incomplete', requestId: request.id, requestNumber: request.number, missing, gift };
try {
const order = await createOrder(ctx, {
clientId,
serviceCode: request.service.code,
params: request.params as Record<string, unknown>,
scheduledAt: request.preferredAt,
addressText: request.addressText,
districtId: request.districtId,
scheduledAt: request.preferredAt!,
addressText: request.addressText!,
districtId: request.districtId!,
note: request.note ?? undefined,
});
// заявка могла быть отменена оператором, пока создавался заказ, — тогда не перезаписываем статус
@@ -129,6 +130,76 @@ export async function linkWebRequest(ctx: Ctx, token: string, clientId: string):
}
}
/**
* Человек открыл бота по ссылке заявки: привязываем заявку к клиенту, переносим согласие с сайта
* и пробуем сразу оформить заказ на желаемое время. Если чего-то не хватает — бот доспросит (incomplete);
* если время занято или исполнителей нет — заявка остаётся оператору (LINKED). Повторный /start ничего не меняет.
*/
export async function linkWebRequest(ctx: Ctx, token: string, clientId: string): Promise<LinkResult> {
const request = await ctx.db.webRequest.findUnique({ where: { token } });
if (!request) return { kind: 'none' };
if (request.clientId && request.clientId !== clientId) return { kind: 'none' };
if (request.status === 'ORDERED' && request.orderId) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: request.orderId } });
return { kind: 'ordered', requestNumber: request.number, orderNumber: order.number, scheduledAt: order.scheduledAt };
}
if (request.status === 'LINKED') {
const missing = missingFields(request);
return missing.length ? { kind: 'incomplete', requestId: request.id, requestNumber: request.number, missing } : { kind: 'linked', requestNumber: request.number };
}
if (request.status !== 'NEW') return { kind: 'linked', requestNumber: request.number };
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
// согласие дано на сайте; вместе с ним активируется подарок агентства, если положен
const gift = client.consentAt ? null : await giveConsent(ctx, clientId);
if (!client.user.phone) await ctx.db.user.update({ where: { id: client.userId }, data: { phone: request.phone } });
await ctx.db.webRequest.update({ where: { id: request.id }, data: { clientId, status: 'LINKED' } });
return tryOrder(ctx, request.id, clientId, gift);
}
/** Незавершённая заявка клиента (бот доспрашивает данные) */
async function ownRequest(ctx: Ctx, requestId: string, clientId: string) {
const r = await ctx.db.webRequest.findUnique({ where: { id: requestId }, include: { service: true } });
if (!r || r.clientId !== clientId || r.status !== 'LINKED') throw new DomainError('NOT_FOUND');
return r;
}
/** Бот: клиент выбрал район, написал адрес или выбрал время. Когда всё есть — оформляем заказ */
export async function completeWebRequest(
ctx: Ctx,
input: { requestId: string; clientId: string; districtCode?: string; addressText?: string; preferredAt?: Date },
): Promise<LinkResult> {
const r = await ownRequest(ctx, input.requestId, input.clientId);
const data: Prisma.WebRequestUpdateInput = {};
if (input.districtCode) {
const d = await ctx.db.district.findUnique({ where: { code: input.districtCode } });
if (!d) throw new DomainError('ADDRESS_REQUIRED');
data.district = { connect: { id: d.id } };
}
if (input.addressText !== undefined) {
const a = input.addressText.trim();
if (a.length < 3) throw new DomainError('ADDRESS_REQUIRED');
data.addressText = a.slice(0, 300);
}
if (input.preferredAt) data.preferredAt = input.preferredAt;
await ctx.db.webRequest.update({ where: { id: r.id }, data });
return tryOrder(ctx, r.id, input.clientId);
}
/** Бот: дни со свободным временем и время на выбранный день (по графику исполнителей) */
export async function webRequestSlots(ctx: Ctx, input: { requestId: string; clientId: string; dateKey?: string }) {
const r = await ownRequest(ctx, input.requestId, input.clientId);
const cfg = slotConfigSchema.parse(r.service.slotConfig);
const now = ctx.now();
const keys = input.dateKey ? [input.dateKey] : Array.from({ length: Math.min(cfg.horizonDays + 1, 10) }, (_, i) => yerevanDateKey(new Date(now.getTime() + i * DAY)));
const days = [];
for (const dateKey of keys) {
const slots = await slotsForDay(ctx.db, { role: r.service.providerRole, config: cfg, durationMinutes: r.durationMinutes, dateKey, now });
if (slots.length) days.push({ dateKey, starts: slots.map((s) => s.start) });
}
return days;
}
/** Для админки: свежие сверху, по статусу */
export async function listWebRequests(ctx: Ctx, input: { status?: WebRequestStatus }) {
return ctx.db.webRequest.findMany({
+3 -3
View File
@@ -41,11 +41,11 @@ describe('автораспределение заказов', () => {
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Ani' });
const second = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED' } });
await respondToOffer(ctx, { offerId: second.id, providerId: fx.ani.id, accept: false });
// больше свободных частных нет — компания включается и получает заказ
// больше свободных частных нет; партнёрам система сама не назначает — это делает оператор
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: true } });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Chisto' });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'none' });
const offers = await db.orderOffer.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id, fx.company.id]);
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id]);
});
it('снятый с заказа исполнитель не получает его снова автоматически', async () => {
+6 -4
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { encodeStartPayload } from '@barev/core';
import { addMonths, encodeStartPayload } from '@barev/core';
import {
applyAgencyCode,
assignmentCandidates,
@@ -95,7 +95,7 @@ describe('онбординг (ONB-1…6)', () => {
expect(client.user.locale).toBe('hy');
const gift = await giveConsent(ctx, start.clientId);
expect(gift?.endsAt.getTime()).toBe(NOW.getTime() + 30 * 24 * 3600_000);
expect(gift?.endsAt.getTime()).toBe(addMonths(NOW, 1).getTime());
expect(events).toContainEqual(expect.objectContaining({ type: 'gift.activated' }));
expect(await db.subscription.count({ where: { clientId: start.clientId, source: 'AGENCY_GIFT' } })).toBe(1);
});
@@ -388,9 +388,11 @@ describe('выплаты клинеру по пресету', () => {
expect(orderPayout(full, maria)).toBe(9000 + 2500 + 1500 + 2000);
// без пресета — индивидуальная ставка 70% от итога
expect(orderPayout(full, { ...maria, payoutPreset: null })).toBe(Math.floor(full.totalPrice * 0.7));
// сотрудник компании — ставка компании
// сотрудник партнёра «как Uber» — ему платит партнёр, от нас 0
const lusine = await db.provider.findUniqueOrThrow({ where: { id: fx.lusine.id }, include: { company: true, payoutPreset: true } });
expect(orderPayout(full, lusine)).toBe(Math.floor(full.totalPrice * 0.65));
expect(orderPayout(full, lusine)).toBe(0);
// «расходы наши» — ФОТ клинера, % от оплаченного
expect(orderPayout(full, { ...lusine, company: { deal: 'PROFIT_SHARE', fotPct: 40 } })).toBe(Math.floor(full.totalPrice * 0.4));
});
});
+14
View File
@@ -49,6 +49,20 @@ export async function fixtures() {
],
});
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
// справочники бариков (в базе их создаёт миграция)
await db.pointsCategory.createMany({
data: [
{ code: 'cleaning', name: { ru: 'Клининг' }, rateBarev: 5, ratePlus: 8, rateElite: 10 },
{ code: 'documents', name: { ru: 'Документы' }, rateBarev: 5, ratePlus: 10, rateElite: 12 },
],
});
await db.pointsBonusType.createMany({
data: [
{ code: 'housewarming', name: { ru: 'Новоселье' }, defaultAmount: 5000, ttlDays: 60 },
{ code: 'promo', name: { ru: 'Промо' }, defaultAmount: 1000, ttlDays: 30 },
{ code: 'first_month_route', name: { ru: 'Маршрут' }, defaultAmount: 3000 },
],
});
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });
const c = CATALOG.find((s) => s.code === 'cleaning')!;
+70
View File
@@ -0,0 +1,70 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { createOrder, editOrderComposition, giveConsent, markPaid, previewOrderEdit, staffChecklistItem, startClient, transitionOrder } from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(() => db.$disconnect());
async function order(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
}
const admin = () => db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
describe('команда меняет заказ', () => {
it('состав: пересчёт цены и длительности, история, уведомление по галочке', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await order(ctx, 1501);
const op = await admin();
const p = await previewOrderEdit(ctx, { orderId: o.id, params: { cleaningType: 'regular', rooms: 3, bathrooms: 1, extras: { windows: 2 } } });
expect(p.totalPrice).toBeGreaterThan(o.totalPrice);
expect(p.durationMinutes).toBeGreaterThan(o.durationMinutes);
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: p.params, notify: true });
const after = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(after).toMatchObject({ totalPrice: p.totalPrice, durationMinutes: p.durationMinutes });
expect((after.params as Record<string, unknown>).rooms).toBe(3);
expect(events).toContainEqual({ type: 'order.edited', orderId: o.id });
expect(await db.orderStatusHistory.count({ where: { orderId: o.id, event: 'editedByStaff' } })).toBe(1);
});
it('своя цена — только с причиной', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 1502);
const op = await admin();
await expect(editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, totalPrice: 12_345 })).rejects.toMatchObject({ code: 'REASON_REQUIRED' });
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, totalPrice: 12_345, reason: 'договорились' });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).totalPrice).toBe(12_345);
});
it('после оплаты: кэшбек пересчитывается по новой цене', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 1503);
const op = await admin();
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
await db.orderChecklistItem.updateMany({ where: { orderId: o.id }, data: { checkedAt: NOW, requiresPhoto: false } });
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
await markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: op.id } });
const before = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id, status: { not: 'REVERSED' } } });
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 4, bathrooms: 2 } });
const now = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id, status: { not: 'REVERSED' } } });
expect(now.id).not.toBe(before.id);
expect(now.amount).toBeGreaterThan(before.amount);
});
it('чек-лист за клинера: пункт с фото — без фото только с причиной; потом заказ можно закрыть', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 1504);
const op = await admin();
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
const items = await db.orderChecklistItem.findMany({ where: { orderId: o.id } });
const photo = items.find((i) => i.requiresPhoto)!;
await expect(staffChecklistItem(ctx, { orderId: o.id, adminId: op.id, code: photo.code, checked: true })).rejects.toMatchObject({ code: 'PHOTO_REQUIRED' });
for (const i of items) await staffChecklistItem(ctx, { orderId: o.id, adminId: op.id, code: i.code, checked: true, reason: i.requiresPhoto ? 'фото в чате' : null });
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).status).toBe('DONE');
});
});
+193
View File
@@ -0,0 +1,193 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { addMonths } from '@barev/core';
import {
applyAgencyCode,
assignmentCandidates,
companyAutoAssign,
completeWebRequest,
createOrder,
createWebRequest,
dispatcherAssign,
expireOffers,
giveConsent,
linkWebRequest,
markPaid,
offerOrder,
partnerAddStaff,
partnerCabinetData,
partnerDeclineOrder,
partnerUnassign,
respondToOffer,
startClient,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(() => db.$disconnect());
type C = ReturnType<typeof makeCtx>['ctx'];
async function order(ctx: C, tg: number, at = slotAt(2, 11), allowBusySlot = false) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: at, addressText: 'Pushkin 10', districtId: fx.kentron.id, allowBusySlot });
}
const admin = () => db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
const events = async (orderId: string) => (await db.orderStatusHistory.findMany({ where: { orderId }, orderBy: { createdAt: 'asc' } })).map((h) => h.event);
describe('партнёры услуг: оператор назначает', () => {
it('у каждого варианта видно, что остаётся нам; самый выгодный подсвечен', async () => {
const { ctx } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { deal: 'COMMISSION', commissionPct: 40 } });
const o = await order(ctx, 901);
const r = await assignmentCandidates(ctx, o.id);
const chisto = r.ranked.find((c) => c.kind === 'company')!;
expect(chisto.margin).toBe(Math.floor(o.totalPrice * 0.4));
const maria = r.ranked.find((c) => c.name === 'Maria')!;
expect(maria.margin).toBe(o.totalPrice - maria.cost);
const best = [...r.ranked].sort((a, b) => b.margin - a.margin)[0]!;
expect(r.bestId).toBe(`${best.kind}:${best.id}`);
});
it('партнёр отказался — заказ снова у оператора', async () => {
const { ctx, events: ev } = makeCtx(NOW);
const o = await order(ctx, 902);
const op = await admin();
await offerOrder(ctx, { orderId: o.id, adminId: op.id, target: { kind: 'company', id: fx.company.id } });
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, companyId: fx.company.id, status: 'OFFERED' } });
expect(offer.expiresAt).not.toBeNull();
await partnerDeclineOrder(ctx, { orderId: o.id, companyId: fx.company.id, userId: fx.dispatcher.id });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).companyId).toBeNull();
expect(ev).toContainEqual({ type: 'partner.returned', orderId: o.id, companyId: fx.company.id, reason: 'declined' });
});
it('партнёр не назначил сотрудника вовремя — заказ возвращается оператору', async () => {
const { ctx, clock } = makeCtx(NOW);
const o = await order(ctx, 903);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
clock.now = new Date(NOW.getTime() + 61 * 60_000);
await expireOffers(ctx);
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).companyId).toBeNull();
expect(await events(o.id)).toContain('partnerExpired');
});
});
describe('партнёры услуг: автораспределение по сотрудникам', () => {
it('заказ сам уходит сотруднику; отказ — следующему; никого нет — менеджеру', async () => {
const { ctx, events: ev } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { autoAssign: true } });
const added = await partnerAddStaff(ctx, {
companyId: fx.company.id,
userId: fx.dispatcher.id,
staff: { name: 'Gayane', languages: ['hy'], districtIds: [fx.kentron.id], schedule: [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 540, endMinute: 1200 })) },
});
// сотрудник ещё не вошёл в бот — автоматически ему не предлагаем
expect(added.inviteCode).toMatch(/^PRO-/);
const o = await order(ctx, 904);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
const first = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED', providerId: { not: null } } });
expect(first.providerId).toBe(fx.lusine.id);
await respondToOffer(ctx, { offerId: first.id, providerId: fx.lusine.id, accept: false });
// больше подходящих нет — заказ ждёт менеджера
expect(await db.orderOffer.count({ where: { orderId: o.id, status: 'OFFERED', providerId: null, companyId: fx.company.id } })).toBe(1);
expect(ev).toContainEqual({ type: 'company.noStaff', orderId: o.id, companyId: fx.company.id });
expect(await companyAutoAssign(ctx, o.id)).toEqual({ result: 'none' });
});
it('лимит заказов в день и перерыв на дорогу', async () => {
const { ctx } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { autoAssign: true, travelGapMinutes: 60 } });
// первый заказ Лусине: 11:00, уборка 2 комнат ~3 часа
const a = await order(ctx, 905, slotAt(2, 11));
await offerOrder(ctx, { orderId: a.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
const offerA = await db.orderOffer.findFirstOrThrow({ where: { orderId: a.id, status: 'OFFERED', providerId: { not: null } } });
await respondToOffer(ctx, { offerId: offerA.id, providerId: fx.lusine.id, accept: true });
const end = new Date(a.scheduledAt.getTime() + a.durationMinutes * 60_000);
// сразу после окончания — нет времени доехать
const b = await order(ctx, 906, new Date(end.getTime() + 30 * 60_000), true);
const cab = await import('../src/servicePartners.ts');
const settings = await (await import('../src/settings.ts')).getSettings(db);
const tight = await cab.companyStaffCandidates(db, fx.company.id, b.id, settings);
expect(tight.rows.find((r) => r.id === fx.lusine.id)?.check).toBe('gap');
// лимит 1 заказ в день
await db.provider.update({ where: { id: fx.lusine.id }, data: { maxOrdersPerDay: 1 } });
const c = await order(ctx, 907, new Date(end.getTime() + 2 * 3600_000), true);
const limited = await cab.companyStaffCandidates(db, fx.company.id, c.id, settings);
expect(limited.rows.find((r) => r.id === fx.lusine.id)?.check).toBe('limit');
});
it('менеджер назначает вручную и снимает сотрудника', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 908);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
await dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: fx.dispatcher.id });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).providerId).toBe(fx.lusine.id);
await partnerUnassign(ctx, { orderId: o.id, companyId: fx.company.id, userId: fx.dispatcher.id });
const after = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(after).toMatchObject({ providerId: null, companyId: fx.company.id, status: 'NEW' });
const cab = await partnerCabinetData(ctx, fx.company.id, fx.dispatcher.id);
expect(cab.pending.map((x) => x.id)).toContain(o.id);
});
});
describe('партнёры услуг: доход', () => {
it('«расходы наши»: при оплате фиксируется доля партнёра от прибыли', async () => {
const { ctx } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { deal: 'PROFIT_SHARE', profitSharePct: 30, fotPct: 60, taxPct: 5 } });
const o = await order(ctx, 910);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
await dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: fx.dispatcher.id });
await markPaid(ctx, { orderId: o.id, by: { type: 'PROVIDER', id: fx.lusine.id } });
const e = await db.partnerEarning.findUniqueOrThrow({ where: { orderId: o.id } });
const fot = Math.floor(o.totalPrice * 0.6);
const tax = Math.floor(o.totalPrice * 0.05);
expect(e).toMatchObject({ deal: 'PROFIT_SHARE', paid: o.totalPrice, fot, tax, amount: Math.floor(((o.totalPrice - fot - tax) * 30) / 100) });
const cab = await partnerCabinetData(ctx, fx.company.id, fx.dispatcher.id);
expect(cab.money.balance).toBe(e.amount);
});
it('чужой менеджер не видит кабинет', async () => {
const { ctx } = makeCtx(NOW);
await expect(partnerCabinetData(ctx, fx.company.id, (await db.user.create({ data: { telegramId: 99999n } })).id)).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
});
describe('подарок агентства', () => {
it('уровень и срок задаются у агентства', async () => {
const { ctx } = makeCtx(NOW);
await db.agency.update({ where: { id: fx.agency.id }, data: { giftPlan: 'ELITE', giftMonths: 3 } });
const s = await startClient(ctx, tgUser(920));
await giveConsent(ctx, s.clientId);
const r = await applyAgencyCode(ctx, s.clientId, 'DOMIK');
expect(r.gift).toEqual({ plan: 'ELITE', endsAt: addMonths(NOW, 3) });
const sub = await db.subscription.findFirstOrThrow({ where: { clientId: s.clientId } });
expect(sub).toMatchObject({ plan: 'ELITE', agencyId: fx.agency.id, source: 'AGENCY_GIFT' });
});
it('агентство без подарка', async () => {
const { ctx } = makeCtx(NOW);
await db.agency.update({ where: { id: fx.agency.id }, data: { giftPlan: null } });
const s = await startClient(ctx, tgUser(921));
await giveConsent(ctx, s.clientId);
expect((await applyAgencyCode(ctx, s.clientId, 'DOMIK')).gift).toBeNull();
});
});
describe('заявка с сайта без времени и адреса', () => {
it('бот доспрашивает недостающее и оформляет заказ', async () => {
const { ctx } = makeCtx(NOW);
const req = await createWebRequest(ctx, { serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, phone: '+37491123456', locale: 'ru' });
expect(req).toMatchObject({ preferredAt: null, districtId: null, addressText: null });
const s = await startClient(ctx, tgUser(930));
const link = await linkWebRequest(ctx, req.token, s.clientId);
expect(link).toMatchObject({ kind: 'incomplete', missing: ['district', 'address', 'time'] });
if (link.kind !== 'incomplete') return;
expect(await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, districtCode: 'kentron' })).toMatchObject({ kind: 'incomplete', missing: ['address', 'time'] });
expect(await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, addressText: 'Abovyan 5' })).toMatchObject({ kind: 'incomplete', missing: ['time'] });
const done = await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, preferredAt: slotAt(2, 12) });
expect(done).toMatchObject({ kind: 'ordered' });
expect((await db.webRequest.findUniqueOrThrow({ where: { id: req.id } })).status).toBe('ORDERED');
});
});
+212
View File
@@ -0,0 +1,212 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import {
accruePoints,
activatePendingPoints,
burnPoints,
cancelByClient,
confirmRedemption,
createOrder,
giveConsent,
markPaid,
reconcilePoints,
redeemPoints,
requestRedemption,
reverseAccrual,
reversePreview,
reverseRedemption,
startClient,
transitionOrder,
warnExpiringPoints,
applyAgencyCode,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(() => db.$disconnect());
type C = ReturnType<typeof makeCtx>['ctx'];
const OWNER = { adminId: null, owner: true };
const H = 3600_000;
const D = 24 * H;
async function client(ctx: C, tg: number) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return s.clientId;
}
const cat = (code = 'cleaning') => db.pointsCategory.findUniqueOrThrow({ where: { code } });
const bal = async (id: string) => db.client.findUniqueOrThrow({ where: { id }, select: { pointsActive: true, pointsPending: true, pointsDebt: true } });
async function give(ctx: C, clientId: string, amount: number, extra: Record<string, unknown> = {}) {
return accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: (await cat()).id, orderAmount: 100_000, amount, reason: 'тест', serviceDate: NOW, noWait: true, channel: 'WEB', ...extra }, OWNER);
}
describe('барики: начисление', () => {
it('по заказу: подсказка по ставке Barev, ожидание 48 ч, потом доступны', async () => {
const { ctx, clock, events } = makeCtx(NOW);
const id = await client(ctx, 1001);
const r = await accruePoints(ctx, { clientId: id, kind: 'ORDER', categoryId: (await cat()).id, orderAmount: 18_000, serviceDate: NOW, channel: 'WEB', orderRef: '1042' }, OWNER);
expect(r).toMatchObject({ status: 'created', amount: 900 });
expect(await bal(id)).toMatchObject({ pointsActive: 0, pointsPending: 900 });
expect(events.some((e) => e.type === 'points.accrued')).toBe(true);
clock.now = new Date(NOW.getTime() + 49 * H);
expect(await activatePendingPoints(ctx)).toBe(1);
expect(await bal(id)).toMatchObject({ pointsActive: 900, pointsPending: 0 });
});
it('тот же номер заказа второй раз — нельзя; изменённая сумма — только с причиной', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1002);
const base = { clientId: id, kind: 'ORDER' as const, categoryId: (await cat()).id, orderAmount: 18_000, serviceDate: NOW, channel: 'WEB' as const };
await expect(accruePoints(ctx, { ...base, amount: 2000, orderRef: 'A1' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_REASON_REQUIRED' });
await accruePoints(ctx, { ...base, orderRef: 'A1' }, OWNER);
await expect(accruePoints(ctx, { ...base, orderRef: 'A1' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_DUPLICATE_ORDER' });
});
it('бонус со своим сроком; «без ожидания» у поддержки — только с причиной и в пределах лимитов', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1003);
const promo = await db.pointsBonusType.findUniqueOrThrow({ where: { code: 'promo' } });
await accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, serviceDate: NOW, channel: 'WEB' }, OWNER);
const a = await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } });
expect(a).toMatchObject({ status: 'ACTIVE', amount: 1000 });
expect(a.expiresAt?.getTime()).toBe(NOW.getTime() + 30 * D);
// поддержка без разрешения в настройках — нельзя
const support = { adminId: 'op1', owner: false };
await expect(give(ctx, id, 100, { orderRef: 'S1' }).then(() => accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, serviceDate: NOW, channel: 'WEB' }, support))).rejects.toMatchObject({ code: 'FORBIDDEN' });
await db.setting.create({ data: { key: 'points.supportCanManage', value: true } });
const big = await accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, amount: 25_000, serviceDate: NOW, channel: 'WEB' }, support);
expect(big.status).toBe('needs_approval');
});
it('серия уборок: третья подряд — +2 п.п. к ставке', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1004);
const c = await cat();
for (const [i, ref] of ['s1', 's2', 's3'].entries()) {
await accruePoints(ctx, { clientId: id, kind: 'ORDER', categoryId: c.id, orderAmount: 10_000, serviceDate: new Date(NOW.getTime() - (40 - i * 20) * D), channel: 'WEB', orderRef: ref }, OWNER);
}
const last = await db.pointsAccrual.findFirstOrThrow({ where: { orderRef: 's3' } });
expect(last).toMatchObject({ ratePct: 7, streakBonusPct: 2, amount: 700 });
});
});
describe('барики: списание', () => {
it('FIFO, лимит 30% и минимум 1000; два списания одновременно не уводят в минус', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1101);
await give(ctx, id, 3000, { orderRef: 'o1', serviceDate: new Date(NOW.getTime() - 10 * D) });
await give(ctx, id, 2000, { orderRef: 'o2' });
await expect(redeemPoints(ctx, { clientId: id, amount: 500, channel: 'WEB', reason: 'x' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_BELOW_MIN' });
await expect(redeemPoints(ctx, { clientId: id, amount: 4000, orderAmount: 10_000, channel: 'WEB', reason: 'x' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_OVER_SHARE' });
const results = await Promise.allSettled([
redeemPoints(ctx, { clientId: id, amount: 3500, channel: 'WEB', reason: 'a' }, OWNER),
redeemPoints(ctx, { clientId: id, amount: 3500, channel: 'WEB', reason: 'b' }, OWNER),
]);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect((await bal(id)).pointsActive).toBe(1500);
const red = await db.pointsRedemption.findFirstOrThrow({ where: { clientId: id, status: 'CONFIRMED' }, include: { items: { include: { accrual: true } } } });
expect(red.items.map((i) => [i.accrual.orderRef, i.amount])).toEqual([
['o1', 3000],
['o2', 500],
]);
});
it('запрос клиента → команда подтверждает по сумме заказа', async () => {
const { ctx, events } = makeCtx(NOW);
const id = await client(ctx, 1102);
await give(ctx, id, 10_000, { orderRef: 'o1' });
const r = await requestRedemption(ctx, { clientId: id, amount: 8000 });
expect(events).toContainEqual({ type: 'points.requested', redemptionId: r.id });
const res = await confirmRedemption(ctx, { redemptionId: r.id, orderAmount: 18_000 }, OWNER);
expect(res.amount).toBe(5400);
expect((await bal(id)).pointsActive).toBe(4600);
});
it('в заказе: барики уменьшают сумму к оплате, кэшбек — с оплаченного деньгами; отмена всё возвращает', async () => {
const { ctx, clock } = makeCtx(NOW);
const id = await client(ctx, 1103);
await give(ctx, id, 5000, { orderRef: 'o1' });
const o = await createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 3000 });
expect(o.pointsUsed).toBe(3000);
expect((await bal(id)).pointsActive).toBe(2000);
await expect(createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(3, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 1000, promoCode: 'X' })).rejects.toBeDefined();
// выполнен и оплачен — кэшбек с (цена − барики)
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
await db.orderChecklistItem.updateMany({ where: { orderId: o.id }, data: { checkedAt: NOW, requiresPhoto: false } });
await markPaid(ctx, { orderId: o.id, by: { type: 'PROVIDER', id: fx.maria.id } });
clock.now = new Date(slotAt(2, 14).getTime());
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
const acc = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id } });
expect(acc).toMatchObject({ channel: 'AUTO', paidWithPoints: 3000, amount: Math.floor(((o.totalPrice - 3000) * 5) / 100), status: 'PENDING' });
const pay = await db.payment.findFirstOrThrow({ where: { orderId: o.id } });
expect(pay.amount).toBe(o.totalPrice - 3000);
});
it('отмена заказа клиентом возвращает списанные барики', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1104);
await give(ctx, id, 5000, { orderRef: 'o1' });
const o = await createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(3, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 2000 });
expect((await bal(id)).pointsActive).toBe(3000);
await cancelByClient(ctx, { orderId: o.id, clientId: id });
expect((await bal(id)).pointsActive).toBe(5000);
expect(await db.pointsReversal.count({ where: { targetType: 'REDEMPTION' } })).toBe(1);
});
});
describe('барики: отмена и сгорание', () => {
it('отмена частично потраченного — минус с предупреждением, гасится следующим начислением', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1201);
const a = await give(ctx, id, 3000, { orderRef: 'o1' });
await redeemPoints(ctx, { clientId: id, amount: 2000, channel: 'WEB', reason: 'звонок' }, OWNER);
if (a.status !== 'created') throw new Error();
expect(await reversePreview(db, { type: 'ACCRUAL', id: a.accrualId })).toMatchObject({ spent: 2000, balanceAfter: -2000 });
await reverseAccrual(ctx, { accrualId: a.accrualId, reason: 'возврат', channel: 'WEB' }, OWNER);
expect(await bal(id)).toMatchObject({ pointsActive: -2000, pointsDebt: 2000 });
const orig = await db.pointsAccrual.findUniqueOrThrow({ where: { id: a.accrualId } });
expect(orig).toMatchObject({ status: 'REVERSED', amount: 3000 });
await give(ctx, id, 2500, { orderRef: 'o2' });
expect(await bal(id)).toMatchObject({ pointsActive: 500, pointsDebt: 0 });
await expect(reverseAccrual(ctx, { accrualId: a.accrualId, reason: 'ещё', channel: 'WEB' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_FINAL' });
});
it('отмена списания возвращает барики в исходные начисления', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1202);
await give(ctx, id, 3000, { orderRef: 'o1' });
const r = await redeemPoints(ctx, { clientId: id, amount: 3000, channel: 'WEB', reason: 'x' }, OWNER);
expect((await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } })).status).toBe('SPENT');
await reverseRedemption(ctx, { redemptionId: r.redemptionId!, reason: 'ошибка', channel: 'WEB' }, OWNER);
expect(await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } })).toMatchObject({ status: 'ACTIVE', remaining: 3000 });
});
it('сгорание через 120 дней без активности, предупреждения за 14 и 3 дня', async () => {
const { ctx, clock, events } = makeCtx(NOW);
const id = await client(ctx, 1203);
await give(ctx, id, 3250, { orderRef: 'o1' });
clock.now = new Date(NOW.getTime() + 107 * D);
await warnExpiringPoints(ctx);
expect(events).toContainEqual({ type: 'points.expiring', clientId: id, amount: 3250, days: 13 });
clock.now = new Date(NOW.getTime() + 118 * D);
await warnExpiringPoints(ctx);
await warnExpiringPoints(ctx);
expect(events.filter((e) => e.type === 'points.expiring')).toHaveLength(2);
clock.now = new Date(NOW.getTime() + 121 * D);
expect(await burnPoints(ctx)).toBe(3250);
expect((await bal(id)).pointsActive).toBe(0);
expect(await reconcilePoints(db)).toHaveLength(0);
});
it('новоселье от агентства — один раз', async () => {
const { ctx } = makeCtx(NOW);
await db.agency.update({ where: { id: fx.agency.id }, data: { housewarmingPoints: 5000, housewarmingDays: 60 } });
const id = await client(ctx, 1204);
await applyAgencyCode(ctx, id, 'DOMIK');
await applyAgencyCode(ctx, id, 'DOMIK');
expect((await bal(id)).pointsActive).toBe(5000);
expect(await db.pointsAccrual.count({ where: { clientId: id } })).toBe(1);
});
});