Products: create services from the admin

- "New service": from scratch (category, who does it — cleaner, driver, career
  consultant or docs agent —, fixed price and duration, online, name and
  description in 5 languages) or as a copy of an existing service (prices,
  questions, checklist, booking window and question texts copied). Created
  switched off; delete only without orders; reorder within a category.
- "Questions in the order form" builder for fixed-price services: choice,
  number, text, yes/no, required flag; labels stored as text overrides.
- The admin itself now loads text overrides, so new service names show right
  away; the mini app hides missing optional texts (description, option hints)
  instead of showing keys.

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-21 03:32:17 +04:00
1 parent 0edc6432b7
commit 8866616782
8 files changed
+599 -13

No files matched your search

+149 -2
View File
@@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto';
import { CATEGORY_CODES, DAY, fromPrice, paramsSchemaSchema, pricingSchema, slotConfigSchema } from '@barev/core';
import { defaultText, LOCALES, type Locale } from '@barev/i18n';
import { CATEGORY_CODES, DAY, fromPrice, paramsSchemaSchema, pricingSchema, ROLE_FLOWS, slotConfigSchema } from '@barev/core';
import { createT, defaultText, LOCALES, type Locale } from '@barev/i18n';
import { isEditableTextKey, listRelocationItems, loadTextOverrides, moveRelocationItem, normalizePromo, saveRelocationItem } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
@@ -11,6 +11,18 @@ const view = rolesProcedure('SUPPORT');
const edit = superProcedure;
const localized = z.object({ ru: z.string().max(500), en: z.string().max(500), zh: z.string().max(500), hy: z.string().max(500), es: z.string().max(500) });
/**
* С какими исполнителями можно завести новую услугу с нуля: у них простой сценарий заказа.
* Ремонт (смета) и уроки (пакет занятий) — только копией существующей услуги.
*/
const NEW_SERVICE_ROLES = ['CLEANER', 'DRIVER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
const DEFAULT_SLOTS = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
/** Ключи подписей вопросов услуги: params.<код>.<поле>.label и варианты */
function paramKeys(schema: { fields: { key: string; type: string; options?: string[] }[] }, code: string) {
return schema.fields.flatMap((f) => [`params.${code}.${f.key}.label`, ...(f.options ?? []).flatMap((o) => [`params.${code}.${f.key}.${o}`, `params.${code}.${f.key}.${o}_hint`])]);
}
/** Текст по ключу на 5 языках: стандартный (из переводов) и изменённый в админке */
async function textsFor(db: import('@barev/db').Db, keys: string[]) {
const rows = await db.textOverride.findMany({ where: { key: { in: keys } } });
@@ -105,6 +117,141 @@ export const boProductsRouter = router({
await loadTextOverrides(ctx.db);
}),
// ───── новые услуги ─────
/**
* Новая услуга: с нуля (фикс-цена, свои вопросы клиенту) или копия существующей (с её ценами, вопросами и чек-листом).
* Код генерируется; название и описание — тексты из админки. Создаётся выключенной: включите, когда всё настроено.
*/
createService: edit
.input(
z.object({
categoryId: z.string(),
name: localized.extend({ ru: z.string().trim().min(1).max(120) }),
description: localized,
copyFromId: z.string().optional(),
providerRole: z.enum(NEW_SERVICE_ROLES).optional(),
isOnline: z.boolean().default(false),
price: z.number().int().min(0).default(0),
minutes: z.number().int().min(15).max(24 * 60).default(60),
}),
)
.mutation(async ({ ctx, input }) => {
const code = `s${randomBytes(4).toString('hex')}`;
const src = input.copyFromId ? await ctx.db.service.findUnique({ where: { id: input.copyFromId }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } }) : null;
if (input.copyFromId && !src) throw new TRPCError({ code: 'NOT_FOUND' });
if (!src && !input.providerRole) throw new TRPCError({ code: 'BAD_REQUEST', message: 'ROLE_REQUIRED' });
const role = src?.providerRole ?? input.providerRole!;
const last = await ctx.db.service.findFirst({ where: { categoryId: input.categoryId }, orderBy: { sortOrder: 'desc' } });
const created = await ctx.db.service.create({
data: {
code,
categoryId: input.categoryId,
flow: src?.flow ?? ROLE_FLOWS[role][0]!,
providerRole: role,
isOnline: src?.isOnline ?? input.isOnline,
pricing: src ? (src.pricing as object) : { kind: 'fixed', price: input.price, minutes: input.minutes },
paramsSchema: src ? (src.paramsSchema as object) : { fields: [] },
slotConfig: src ? (src.slotConfig as object) : DEFAULT_SLOTS,
lateCancelFeePct: src?.lateCancelFeePct ?? 30,
active: false,
sortOrder: (last?.sortOrder ?? 0) + 1,
checklist: src ? { create: src.checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })) } : undefined,
},
});
// тексты: название и описание; у копии — ещё подписи вопросов (params.<код>.*) на всех языках
const rows: { key: string; locale: Locale; value: string }[] = [];
for (const l of LOCALES) {
if (input.name[l]?.trim()) rows.push({ key: `services.${code}.name`, locale: l, value: input.name[l].trim() });
if (input.description[l]?.trim()) rows.push({ key: `services.${code}.description`, locale: l, value: input.description[l].trim() });
}
if (src) {
for (const l of LOCALES) {
const t = createT(l);
for (const key of paramKeys(paramsSchemaSchema.parse(src.paramsSchema), src.code)) {
const v = t(key);
if (v !== key) rows.push({ key: key.replace(`params.${src.code}.`, `params.${code}.`), locale: l, value: v });
}
}
}
for (const r of rows) await ctx.db.textOverride.create({ data: { ...r, updatedById: ctx.admin.id } });
await loadTextOverrides(ctx.db);
return { id: created.id };
}),
/**
* Вопросы клиенту при заказе (для услуг с фикс-ценой): выбор из вариантов, число, текст, да/нет.
* Подписи — по-русски (в других языках клиент увидит русский, пока нет перевода).
*/
saveParams: edit
.input(
z.object({
serviceId: z.string(),
fields: z
.array(
z.object({
key: z.string().max(40).optional(),
type: z.enum(['select', 'int', 'text', 'bool']),
label: z.string().trim().min(1).max(120),
required: z.boolean(),
options: z.array(z.object({ key: z.string().max(40).optional(), label: z.string().trim().min(1).max(120) })).max(20).default([]),
min: z.number().int().min(0).max(1000).default(1),
max: z.number().int().min(1).max(1000).default(10),
}),
)
.max(12),
}),
)
.mutation(async ({ ctx, input }) => {
const s = await ctx.db.service.findUnique({ where: { id: input.serviceId } });
if (!s) throw new TRPCError({ code: 'NOT_FOUND' });
if (pricingSchema.parse(s.pricing).kind !== 'fixed') throw new TRPCError({ code: 'BAD_REQUEST', message: 'PARAMS_LOCKED' });
const short = () => randomBytes(2).toString('hex');
const fields = input.fields.map((f) => ({ ...f, key: f.key || `q${short()}`, options: f.options.map((o) => ({ ...o, key: o.key || `o${short()}` })) }));
for (const f of fields) if (f.type === 'select' && f.options.length < 2) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OPTIONS_REQUIRED' });
const schema = {
fields: fields.map((f) =>
f.type === 'select'
? { key: f.key, type: 'select' as const, options: f.options.map((o) => o.key), required: f.required }
: f.type === 'int'
? { key: f.key, type: 'int' as const, min: Math.min(f.min, f.max), max: Math.max(f.min, f.max), default: Math.min(f.min, f.max), required: f.required }
: f.type === 'text'
? { key: f.key, type: 'text' as const, maxLength: 1000, required: f.required }
: { key: f.key, type: 'bool' as const, default: false, required: false },
),
};
paramsSchemaSchema.parse(schema);
await ctx.db.service.update({ where: { id: s.id }, data: { paramsSchema: schema } });
// подписи: старые подписи вопросов этой услуги убираем, новые пишем
await ctx.db.textOverride.deleteMany({ where: { key: { startsWith: `params.${s.code}.` }, locale: 'ru' } });
for (const f of fields) {
await ctx.db.textOverride.create({ data: { key: `params.${s.code}.${f.key}.label`, locale: 'ru', value: f.label, updatedById: ctx.admin.id } });
for (const o of f.options) await ctx.db.textOverride.create({ data: { key: `params.${s.code}.${f.key}.${o.key}`, locale: 'ru', value: o.label, updatedById: ctx.admin.id } });
}
await loadTextOverrides(ctx.db);
}),
/** Удалить можно только услугу без заказов; иначе — выключить */
deleteService: edit.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const s = await ctx.db.service.findUnique({ where: { id: input.id }, include: { _count: { select: { orders: true, webRequests: true } } } });
if (!s) throw new TRPCError({ code: 'NOT_FOUND' });
if (s._count.orders || s._count.webRequests) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SERVICE_HAS_ORDERS' });
await ctx.db.service.delete({ where: { id: s.id } });
await ctx.db.textOverride.deleteMany({ where: { OR: [{ key: { startsWith: `services.${s.code}.` } }, { key: { startsWith: `params.${s.code}.` } }] } });
await loadTextOverrides(ctx.db);
}),
/** Порядок услуг в направлении — как в каталоге у клиента */
moveService: edit.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(async ({ ctx, input }) => {
const s = await ctx.db.service.findUniqueOrThrow({ where: { id: input.id } });
const list = await ctx.db.service.findMany({ where: { categoryId: s.categoryId }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
const i = list.findIndex((x) => x.id === s.id);
const j = i + input.dir;
if (j < 0 || j >= list.length) return;
[list[i], list[j]] = [list[j]!, list[i]!];
await ctx.db.$transaction(list.map((x, k) => ctx.db.service.update({ where: { id: x.id }, data: { sortOrder: k } })));
}),
// ───── промокоды ─────
promos: view.query(async ({ ctx }) => {