Monorepo skeleton, Prisma schema, core domain and services with tests

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-17 19:45:51 +04:00
commit 45d7512d2b
64 files changed
+9483

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
{
"name": "@barev/core",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": { ".": "./src/index.ts" },
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^4.3.0"
},
"devDependencies": {
"vitest": "^4.1.0"
}
}
+77
View File
@@ -0,0 +1,77 @@
import type { Locale } from './types.ts';
/** Кандидат на заказ: частный исполнитель или компания-партнёр (PRO-7) */
export interface Candidate {
kind: 'provider' | 'company';
id: string;
name: string;
languages: Locale[];
districtIds: string[];
/** заказов на этот день уже в работе */
load: number;
/** средняя оценка 0–5, null — ещё нет оценок */
rating: number | null;
/** свободен в нужное время (по графику и занятости) */
free: boolean;
onShift: boolean;
}
export interface OrderNeeds {
locale: Locale;
districtId: string | null;
isOnline: boolean;
}
export interface RankedCandidate extends Candidate {
score: number;
reasons: ('language' | 'district' | 'onShift' | 'lowLoad')[];
}
/** Веса подсказки (ADM-3). Язык клиента важнее района, район важнее загрузки. */
export const ASSIGNMENT_WEIGHTS = {
language: 100,
district: 50,
onShift: 10,
perLoad: -15,
perRatingPoint: 4,
} as const;
/**
* Ранжирует свободных кандидатов. Занятые не попадают в список вовсе.
* Сортировка стабильная: при равном счёте — по имени.
*/
export function rankCandidates(candidates: Candidate[], needs: OrderNeeds): RankedCandidate[] {
const w = ASSIGNMENT_WEIGHTS;
return candidates
.filter((c) => c.free)
.map((c) => {
const reasons: RankedCandidate['reasons'] = [];
let score = 0;
if (c.languages.includes(needs.locale)) {
score += w.language;
reasons.push('language');
}
if (!needs.isOnline && needs.districtId && c.districtIds.includes(needs.districtId)) {
score += w.district;
reasons.push('district');
}
if (c.onShift) {
score += w.onShift;
reasons.push('onShift');
}
if (c.load === 0) reasons.push('lowLoad');
score += w.perLoad * c.load;
score += w.perRatingPoint * (c.rating ?? 4);
return { ...c, score, reasons };
})
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
}
/** Лучший кандидат — для кнопки «Назначить: <имя>» */
export function suggestCandidate(candidates: Candidate[], needs: OrderNeeds): RankedCandidate | null {
return rankCandidates(candidates, needs)[0] ?? null;
}
export function averageRating(sum: number, count: number): number | null {
return count > 0 ? Math.round((sum / count) * 10) / 10 : null;
}
+31
View File
@@ -0,0 +1,31 @@
import { HOUR } from './time.ts';
export interface CancelPolicyInput {
scheduledAt: Date;
now: Date;
/** порог бесплатной отмены, часы (настройка cancel.freeHours) */
freeHours: number;
/** удержание при поздней отмене, % (Service.lateCancelFeePct) */
lateFeePct: number;
totalPrice: number;
}
export type CancelPolicy =
| { kind: 'free'; fee: 0 }
/** клиент может отменить сам, с удержанием */
| { kind: 'late'; fee: number; feePct: number }
/** услуга уже началась или прошла — только через оператора */
| { kind: 'operator_only' };
/** ORD-6 */
export function cancelPolicy(i: CancelPolicyInput): CancelPolicy {
const left = i.scheduledAt.getTime() - i.now.getTime();
if (left <= 0) return { kind: 'operator_only' };
if (left >= i.freeHours * HOUR) return { kind: 'free', fee: 0 };
return { kind: 'late', fee: Math.floor((i.totalPrice * i.lateFeePct) / 100), feePct: i.lateFeePct };
}
/** Перенос: бесплатно только вне порога; внутри — через оператора */
export function canRescheduleSelf(scheduledAt: Date, now: Date, freeHours: number): boolean {
return scheduledAt.getTime() - now.getTime() >= freeHours * HOUR;
}
+210
View File
@@ -0,0 +1,210 @@
import type { ParamsSchema } from './params.ts';
import type { Pricing } from './pricing.ts';
import type { SlotConfig } from './slots.ts';
import type { CategoryCode, ProviderRole, ServiceFlow } from './types.ts';
/**
* Состав каталога из PRD (раздел 2) — «только эти, ничего не добавлять».
* Цены здесь — заглушки для seed; реальные задаёт владелец в админке.
* `stage` — этап, в котором услуга становится доступной для заказа.
*/
export interface CatalogService {
code: string;
category: CategoryCode;
flow: ServiceFlow;
providerRole: ProviderRole;
stage: 1 | 2 | 3;
isOnline: boolean;
pricing: Pricing;
paramsSchema: ParamsSchema;
slotConfig: SlotConfig;
checklist: { code: string; requiresPhoto?: boolean }[];
}
const DAY_WINDOW: SlotConfig = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
const fixed = (price: number, minutes: number): Pricing => ({ kind: 'fixed', price, minutes });
export const CATALOG: CatalogService[] = [
{
code: 'cleaning',
category: 'HOME',
flow: 'CLEANING',
providerRole: 'CLEANER',
stage: 1,
isOnline: false,
pricing: {
kind: 'rooms',
variantParam: 'cleaningType',
roomsParam: 'rooms',
tiers: {
regular: { base: 15000, perRoom: 4000, baseMinutes: 120, perRoomMinutes: 45 },
general: { base: 25000, perRoom: 7000, baseMinutes: 210, perRoomMinutes: 75 },
renovation: { base: 35000, perRoom: 10000, baseMinutes: 300, perRoomMinutes: 90 },
},
},
paramsSchema: {
fields: [
{ key: 'cleaningType', type: 'select', options: ['regular', 'general', 'renovation'], default: 'regular', required: true },
{ key: 'rooms', type: 'int', min: 1, max: 6, default: 2, required: true },
],
},
slotConfig: DAY_WINDOW,
checklist: [
{ code: 'kitchen' },
{ code: 'bathroom' },
{ code: 'rooms' },
{ code: 'floors' },
{ code: 'photoReport', requiresPhoto: true },
],
},
{
code: 'repair',
category: 'HOME',
flow: 'REPAIR',
providerRole: 'HANDYMAN',
stage: 2,
isOnline: false,
pricing: fixed(8000, 90),
paramsSchema: {
fields: [
{ key: 'repairType', type: 'select', options: ['plumbing', 'electrical', 'minor'], required: true },
{ key: 'problem', type: 'text', maxLength: 1000, required: true },
],
},
slotConfig: DAY_WINDOW,
checklist: [{ code: 'diagnostics' }, { code: 'estimate' }, { code: 'repair' }, { code: 'photo', requiresPhoto: true }],
},
{
code: 'transfer',
category: 'HOME',
flow: 'TRANSFER',
providerRole: 'DRIVER',
// решение владельца: трансфер отложен, сценарий и state machine готовы
stage: 2,
isOnline: false,
pricing: fixed(10000, 60),
paramsSchema: {
fields: [
{ key: 'direction', type: 'select', options: ['fromAirport', 'toAirport'], required: true },
{ key: 'flight', type: 'text', maxLength: 16, required: true },
{ key: 'passengers', type: 'int', min: 1, max: 7, default: 1, required: true },
{ key: 'luggage', type: 'int', min: 0, max: 10, default: 1, required: true },
],
},
slotConfig: { dayStartMinute: 0, dayEndMinute: 1440, stepMinutes: 15, leadHours: 6, horizonDays: 30 },
checklist: [{ code: 'luggage' }, { code: 'address' }],
},
{
code: 'social_card',
category: 'DOCS',
flow: 'DOCUMENTS',
providerRole: 'DOCS_AGENT',
stage: 2,
isOnline: false,
pricing: fixed(12000, 60),
paramsSchema: { fields: [{ key: 'forWhom', type: 'select', options: ['self', 'family'], required: true }] },
slotConfig: DAY_WINDOW,
checklist: [],
},
{
code: 'passport_translation',
category: 'DOCS',
flow: 'DOCUMENTS',
providerRole: 'DOCS_AGENT',
stage: 2,
isOnline: false,
pricing: fixed(9000, 60),
paramsSchema: { fields: [{ key: 'forWhom', type: 'select', options: ['self', 'family'], required: true }] },
slotConfig: DAY_WINDOW,
checklist: [],
},
{
code: 'residence_permit',
category: 'DOCS',
flow: 'DOCUMENTS',
providerRole: 'DOCS_AGENT',
stage: 2,
isOnline: false,
pricing: fixed(45000, 60),
paramsSchema: { fields: [{ key: 'forWhom', type: 'select', options: ['self', 'family'], required: true }] },
slotConfig: DAY_WINDOW,
checklist: [],
},
{
code: 'lang_armenian',
category: 'LEARNING',
flow: 'LANGUAGE',
providerRole: 'TEACHER',
stage: 2,
isOnline: true,
pricing: fixed(40000, 60),
paramsSchema: {
fields: [
{ key: 'level', type: 'select', options: ['A1', 'A2', 'B1', 'B2'], required: true },
{ key: 'format', type: 'select', options: ['online', 'offline'], required: true },
{ key: 'lessons', type: 'select', options: ['4', '8', '12'], required: true },
],
},
slotConfig: DAY_WINDOW,
checklist: [{ code: 'review' }, { code: 'newTopic' }, { code: 'homework' }],
},
{
code: 'lang_english',
category: 'LEARNING',
flow: 'LANGUAGE',
providerRole: 'TEACHER',
stage: 2,
isOnline: true,
pricing: fixed(40000, 60),
paramsSchema: {
fields: [
{ key: 'level', type: 'select', options: ['A1', 'A2', 'B1', 'B2', 'C1'], required: true },
{ key: 'format', type: 'select', options: ['online', 'offline'], required: true },
{ key: 'lessons', type: 'select', options: ['4', '8', '12'], required: true },
],
},
slotConfig: DAY_WINDOW,
checklist: [{ code: 'review' }, { code: 'newTopic' }, { code: 'homework' }],
},
{
code: 'career',
category: 'LEARNING',
flow: 'CAREER',
providerRole: 'CAREER_CONSULTANT',
stage: 2,
isOnline: true,
pricing: fixed(20000, 60),
paramsSchema: { fields: [{ key: 'topic', type: 'select', options: ['resume', 'jobSearch'], required: true }] },
slotConfig: DAY_WINDOW,
checklist: [{ code: 'resume' }, { code: 'plan' }, { code: 'materials' }],
},
];
/** Скидка Barev+ по направлениям — заглушки */
export const DEFAULT_CATEGORY_DISCOUNTS: Record<CategoryCode, number> = { HOME: 10, DOCS: 10, LEARNING: 15, PERKS: 0 };
/** 12 административных районов Еревана; названия — i18n districts.<code> */
export const YEREVAN_DISTRICTS = [
'ajapnyak',
'arabkir',
'avan',
'davtashen',
'erebuni',
'kanaker_zeytun',
'kentron',
'malatia_sebastia',
'nork_marash',
'nor_nork',
'nubarashen',
'shengavit',
] as const;
/** Роль исполнителя → какие сценарии он ведёт (для бота исполнителей) */
export const ROLE_FLOWS: Record<ProviderRole, ServiceFlow[]> = {
CLEANER: ['CLEANING'],
HANDYMAN: ['REPAIR'],
DRIVER: ['TRANSFER'],
TEACHER: ['LANGUAGE'],
CAREER_CONSULTANT: ['CAREER'],
DOCS_AGENT: ['DOCUMENTS'],
};
+71
View File
@@ -0,0 +1,71 @@
import { isLocale, type Locale } from './types.ts';
/**
* Payload для t.me/<bot>?start=<payload> (LND-3, ONB-1).
* Telegram ограничивает его 64 символами из [A-Za-z0-9_-], поэтому формат компактный:
*
* l-ru_a-AGENCY1 язык и код агентства
* s-k3j9x2 короткий токен; язык, агентство и UTM лежат в БД (StartToken)
*
* Части разделены "_", ключ и значение — "-". Неизвестные части игнорируются.
*/
export interface StartPayload {
locale?: Locale;
agency?: string;
token?: string;
}
export const START_PAYLOAD_MAX = 64;
const SAFE = /^[A-Za-z0-9]+$/;
export function normalizeAgencyCode(code: string): string | null {
const c = code.trim().toUpperCase();
return SAFE.test(c) && c.length <= 32 ? c : null;
}
export function encodeStartPayload(p: StartPayload): string {
const parts: string[] = [];
if (p.token) {
if (!SAFE.test(p.token)) throw new Error('Bad token');
parts.push(`s-${p.token}`);
}
if (p.locale) parts.push(`l-${p.locale}`);
if (p.agency) {
const a = normalizeAgencyCode(p.agency);
if (a) parts.push(`a-${a}`);
}
const out = parts.join('_');
if (out.length > START_PAYLOAD_MAX) throw new Error('Start payload too long');
return out;
}
export function decodeStartPayload(raw: string | undefined | null): StartPayload {
const out: StartPayload = {};
if (!raw) return out;
for (const part of raw.slice(0, START_PAYLOAD_MAX).split('_')) {
const [key, value] = [part.slice(0, 1), part.slice(2)];
if (part[1] !== '-' || !value) continue;
if (key === 'l' && isLocale(value)) out.locale = value;
else if (key === 'a') {
const a = normalizeAgencyCode(value);
if (a) out.agency = a;
} else if (key === 's' && SAFE.test(value)) out.token = value;
}
return out;
}
export const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
export type Utm = Partial<Record<(typeof UTM_KEYS)[number], string>>;
export function pickUtm(params: URLSearchParams): Utm {
const utm: Utm = {};
for (const k of UTM_KEYS) {
const v = params.get(k);
if (v) utm[k] = v.slice(0, 120);
}
return utm;
}
export function botLink(username: string, payload: string): string {
return `https://t.me/${username}${payload ? `?start=${payload}` : ''}`;
}
+269
View File
@@ -0,0 +1,269 @@
import { describe, expect, it } from 'vitest';
import { rankCandidates, suggestCandidate, type Candidate } from './assignment.ts';
import { cancelPolicy, canRescheduleSelf } from './cancellation.ts';
import { CATALOG } from './catalog.ts';
import { decodeStartPayload, encodeStartPayload, pickUtm } from './deeplink.ts';
import { agencyCommission, decideGift, discountFor, isAgencyAttributionActive, membershipLevel, payoutFor } from './membership.ts';
import { defaultParams, validateParams } from './params.ts';
import { fromPrice, PricingError, quote } from './pricing.ts';
import { checkPromo, combineDiscounts, type PromoLike } from './promo.ts';
import { DEFAULT_SETTINGS, resolveSettings } from './settings.ts';
import { daySlots, type ProviderAvailability } from './slots.ts';
import { fromYerevan, HOUR, toYerevan, yerevanDateKey } from './time.ts';
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
describe('pricing (ORD-4)', () => {
it('считает базовую цену по типу и комнатам', () => {
expect(quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 })).toEqual({
basePrice: 15000,
discountPct: 0,
discountAmount: 0,
totalPrice: 15000,
durationMinutes: 120,
});
expect(quote(cleaning.pricing, { cleaningType: 'general', rooms: 3 }).basePrice).toBe(25000 + 2 * 7000);
});
it('вычитает скидку Barev+ и округляет её вниз', () => {
const q = quote({ kind: 'fixed', price: 15005, minutes: 60 }, {}, 10);
expect(q.discountAmount).toBe(1500);
expect(q.totalPrice).toBe(13505);
expect(q.basePrice - q.discountAmount).toBe(q.totalPrice);
});
it('отклоняет неизвестный вариант, плохие комнаты и скидку вне 0–100', () => {
expect(() => quote(cleaning.pricing, { cleaningType: 'deluxe', rooms: 1 })).toThrow(PricingError);
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 0 })).toThrow(PricingError);
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 }, 101)).toThrow(PricingError);
});
it('«от N ֏» — минимальный тариф', () => {
expect(fromPrice(cleaning.pricing)).toBe(15000);
});
});
describe('params (ORD-2)', () => {
it('дефолты и валидация', () => {
expect(defaultParams(cleaning.paramsSchema)).toEqual({ cleaningType: 'regular', rooms: 2 });
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'general', rooms: '3', junk: 1 })).toEqual({
ok: true,
values: { cleaningType: 'general', rooms: 3 },
});
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'x', rooms: 9 })).toEqual({
ok: false,
errors: [
{ key: 'cleaningType', code: 'invalid' },
{ key: 'rooms', code: 'invalid' },
],
});
expect(validateParams(cleaning.paramsSchema, {})).toMatchObject({ ok: false });
});
});
describe('time (Asia/Yerevan)', () => {
it('UTC+4 без летнего времени', () => {
const d = fromYerevan(2026, 7, 1, 9 * 60);
expect(d.toISOString()).toBe('2026-07-01T05:00:00.000Z');
expect(toYerevan(new Date('2026-01-15T21:30:00Z'))).toMatchObject({ day: 16, hour: 1, minute: 30, weekday: 4 });
expect(yerevanDateKey(new Date('2026-12-31T20:00:00Z'))).toBe('2027-01-01');
});
});
describe('slots (ORD-3)', () => {
// 2026-10-19 — понедельник
const now = fromYerevan(2026, 10, 18, 12 * 60);
const fullDay = [{ weekday: 0, startMinute: 9 * 60, endMinute: 20 * 60 }];
const config = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
it('нет исполнителей — нет слотов', () => {
expect(daySlots({ dateKey: '2026-10-19', config, durationMinutes: 120, now, providers: [], unassigned: [] })).toEqual([]);
});
it('слоты по окну услуги, длительности и графику', () => {
const p: ProviderAvailability = { id: 'a', schedule: fullDay, busy: [] };
const slots = daySlots({ dateKey: '2026-10-19', config, durationMinutes: 120, now, providers: [p], unassigned: [] });
// 9:00 … 18:00 (последний слот заканчивается в 20:00)
expect(slots).toHaveLength(10);
expect(toYerevan(slots[0]!.start).hour).toBe(9);
expect(toYerevan(slots.at(-1)!.start).hour).toBe(18);
});
it('занятость исполнителя и неназначенные заказы уменьшают мощность', () => {
const busy = [{ start: fromYerevan(2026, 10, 19, 10 * 60), end: fromYerevan(2026, 10, 19, 12 * 60) }];
const a: ProviderAvailability = { id: 'a', schedule: fullDay, busy };
const b: ProviderAvailability = { id: 'b', schedule: fullDay, busy: [] };
const unassigned = [{ start: fromYerevan(2026, 10, 19, 14 * 60), end: fromYerevan(2026, 10, 19, 15 * 60) }];
const slots = daySlots({ dateKey: '2026-10-19', config, durationMinutes: 60, now, providers: [a, b], unassigned });
const cap = (h: number) => slots.find((s) => toYerevan(s.start).hour === h)?.capacity ?? 0;
expect(cap(9)).toBe(2);
expect(cap(10)).toBe(1);
expect(cap(14)).toBe(1);
});
it('учитывает минимальный запас до начала', () => {
const p: ProviderAvailability = { id: 'a', schedule: [{ weekday: 6, startMinute: 0, endMinute: 1440 }], busy: [] };
// сегодня воскресенье 12:00 → первый слот не раньше 15:00
const slots = daySlots({ dateKey: '2026-10-18', config, durationMinutes: 60, now, providers: [p], unassigned: [] });
expect(toYerevan(slots[0]!.start).hour).toBe(15);
});
});
describe('assignment (ADM-3)', () => {
const base: Omit<Candidate, 'id' | 'name'> = {
kind: 'provider',
languages: ['ru'],
districtIds: [],
load: 0,
rating: 4.5,
free: true,
onShift: true,
};
it('язык клиента важнее района, район — важнее загрузки', () => {
const c: Candidate[] = [
{ ...base, id: '1', name: 'A', languages: ['ru'], districtIds: ['kentron'] },
{ ...base, id: '2', name: 'B', languages: ['en'], districtIds: ['kentron'] },
{ ...base, id: '3', name: 'C', languages: ['en'], districtIds: [], load: 0 },
];
const ranked = rankCandidates(c, { locale: 'en', districtId: 'kentron', isOnline: false });
expect(ranked.map((r) => r.id)).toEqual(['2', '3', '1']);
expect(ranked[0]!.reasons).toEqual(expect.arrayContaining(['language', 'district']));
});
it('занятые исключаются, загрузка снижает приоритет', () => {
const c: Candidate[] = [
{ ...base, id: '1', name: 'A', free: false },
{ ...base, id: '2', name: 'B', load: 3 },
{ ...base, id: '3', name: 'C', load: 0 },
];
expect(suggestCandidate(c, { locale: 'ru', districtId: null, isOnline: false })?.id).toBe('3');
expect(rankCandidates(c, { locale: 'ru', districtId: null, isOnline: false })).toHaveLength(2);
});
it('нет свободных — нет подсказки', () => {
expect(suggestCandidate([{ ...base, id: '1', name: 'A', free: false }], { locale: 'ru', districtId: null, isOnline: false })).toBeNull();
});
});
describe('cancellation (ORD-6)', () => {
const now = new Date('2026-10-17T10:00:00Z');
const at = (h: number) => new Date(now.getTime() + h * HOUR);
it('за 24 ч и раньше — бесплатно', () => {
expect(cancelPolicy({ scheduledAt: at(24), now, freeHours: 24, lateFeePct: 30, totalPrice: 20000 })).toEqual({ kind: 'free', fee: 0 });
});
it('позже — удержание 30%', () => {
expect(cancelPolicy({ scheduledAt: at(23), now, freeHours: 24, lateFeePct: 30, totalPrice: 20001 })).toEqual({
kind: 'late',
fee: 6000,
feePct: 30,
});
});
it('после начала — только через оператора', () => {
expect(cancelPolicy({ scheduledAt: at(-1), now, freeHours: 24, lateFeePct: 30, totalPrice: 1 })).toEqual({ kind: 'operator_only' });
});
it('перенос сам — только вне порога', () => {
expect(canRescheduleSelf(at(25), now, 24)).toBe(true);
expect(canRescheduleSelf(at(5), now, 24)).toBe(false);
});
});
describe('deep-link payload (LND-3 / ONB-1)', () => {
it('кодирует и декодирует язык и агентство', () => {
const p = encodeStartPayload({ locale: 'hy', agency: 'domik1' });
expect(p).toBe('l-hy_a-DOMIK1');
expect(decodeStartPayload(p)).toEqual({ locale: 'hy', agency: 'DOMIK1' });
});
it('токен для UTM', () => {
expect(decodeStartPayload(encodeStartPayload({ token: 'k3j9x2', locale: 'en' }))).toEqual({ token: 'k3j9x2', locale: 'en' });
});
it('игнорирует мусор и неизвестный язык', () => {
expect(decodeStartPayload('l-fr_a-<script>_zzz_x-1')).toEqual({});
expect(decodeStartPayload(undefined)).toEqual({});
});
it('укладывается в лимит Telegram', () => {
const p = encodeStartPayload({ token: 'abcdefghij', locale: 'zh', agency: 'A'.repeat(32) });
expect(p.length).toBeLessThanOrEqual(64);
expect(p).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('достаёт UTM из URL', () => {
expect(pickUtm(new URLSearchParams('utm_source=qr&utm_campaign=oct&x=1'))).toEqual({ utm_source: 'qr', utm_campaign: 'oct' });
});
});
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' });
});
it('подарок продлевает действующую подписку', () => {
const endsAt = new Date('2026-10-20T00:00:00Z');
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: endsAt });
expect(r.ok && r.startsAt).toEqual(endsAt);
});
it('уровень членства и скидка', () => {
const subs = [{ startsAt: new Date('2026-10-01'), endsAt: new Date('2026-11-01'), status: 'ACTIVE' as const }];
expect(membershipLevel(subs, now)).toBe('BAREV_PLUS');
expect(membershipLevel(subs, new Date('2026-11-02'))).toBe('BAREV');
expect(membershipLevel([{ ...subs[0]!, status: 'CANCELLED' }], now)).toBe('BAREV');
expect(discountFor('BAREV_PLUS', 10)).toBe(10);
expect(discountFor('BAREV', 10)).toBe(0);
});
it('клиент закреплён за агентством 12 месяцев, вознаграждение 10%', () => {
const attached = new Date('2025-11-01T00:00:00Z');
expect(isAgencyAttributionActive(attached, now, 12)).toBe(true);
expect(isAgencyAttributionActive(attached, new Date('2026-11-01T00:00:01Z'), 12)).toBe(false);
expect(isAgencyAttributionActive(null, now, 12)).toBe(false);
expect(agencyCommission(18999, 10)).toBe(1899);
});
it('выплата исполнителю: фикс или процент (PRO-8)', () => {
expect(payoutFor(20000, { type: 'FIXED', value: 9000 })).toBe(9000);
expect(payoutFor(20000, { type: 'PERCENT', value: 70 })).toBe(14000);
});
});
describe('promo codes', () => {
const now = new Date('2026-10-17T10:00:00Z');
const p: PromoLike = { kind: 'DISCOUNT', active: true, maxUses: 2, usedCount: 0, validFrom: null, validUntil: null };
it('проверяет активность, сроки, лимиты и повторное использование', () => {
expect(checkPromo(p, now, false)).toEqual({ ok: true });
expect(checkPromo({ ...p, active: false }, now, false)).toEqual({ ok: false, reason: 'INACTIVE' });
expect(checkPromo({ ...p, validFrom: new Date('2026-11-01') }, now, false)).toEqual({ ok: false, reason: 'NOT_STARTED' });
expect(checkPromo({ ...p, validUntil: new Date('2026-10-01') }, now, false)).toEqual({ ok: false, reason: 'EXPIRED' });
expect(checkPromo({ ...p, usedCount: 2 }, now, false)).toEqual({ ok: false, reason: 'EXHAUSTED' });
expect(checkPromo(p, now, true)).toEqual({ ok: false, reason: 'ALREADY_USED_BY_CLIENT' });
});
it('скидки не суммируются', () => {
expect(combineDiscounts(10, 15)).toBe(15);
expect(combineDiscounts(10, null)).toBe(10);
});
});
describe('settings', () => {
it('дефолты из PRD и решений владельца', () => {
expect(DEFAULT_SETTINGS['cancel.freeHours']).toBe(24);
expect(DEFAULT_SETTINGS['agency.attributionMonths']).toBe(12);
expect(DEFAULT_SETTINGS['agency.defaultCommissionPct']).toBe(10);
expect(DEFAULT_SETTINGS['files.retentionDays']).toBe(90);
});
it('битые значения заменяются дефолтом, неизвестные ключи игнорируются', () => {
const s = resolveSettings([
{ key: 'cancel.freeHours', value: 48 },
{ key: 'offer.acceptTimeoutMinutes', value: 'abc' },
{ key: 'nope', value: 1 },
]);
expect(s['cancel.freeHours']).toBe(48);
expect(s['offer.acceptTimeoutMinutes']).toBe(15);
});
});
+132
View File
@@ -0,0 +1,132 @@
import type { ServiceFlow } from '../types.ts';
import type { FlowDef } from './types.ts';
const OPS = ['ADMIN', 'SYSTEM'] as const;
/** назначение: админ, система (принятие предложения) или диспетчер компании */
const ASSIGN = ['ADMIN', 'SYSTEM', 'DISPATCHER'] as const;
const PRO = ['PROVIDER', 'ADMIN'] as const;
/** Клининг: Новый → Назначен → В пути → Уборка → Готово */
const CLEANING: FlowDef = {
flow: 'CLEANING',
steps: ['NEW', 'ASSIGNED', 'EN_ROUTE', 'CLEANING', 'DONE'],
initial: 'NEW',
assigned: 'ASSIGNED',
done: ['DONE'],
clientCancellable: ['NEW', 'ASSIGNED'],
checklistAt: ['CLEANING'],
transitions: [
{ from: 'NEW', to: 'ASSIGNED', by: ASSIGN },
{ from: 'ASSIGNED', to: 'NEW', by: OPS },
{ from: 'ASSIGNED', to: 'EN_ROUTE', by: PRO, action: 'depart' },
{ from: 'EN_ROUTE', to: 'CLEANING', by: PRO, action: 'startCleaning' },
{ from: 'CLEANING', to: 'DONE', by: PRO, action: 'finishCleaning', guards: ['checklistComplete'] },
],
events: [{ code: 'arrived', at: ['EN_ROUTE'], by: PRO }],
};
/** Ремонт: Новый → Назначен → На месте → Смета согласована → Ремонт → Готово */
const REPAIR: FlowDef = {
flow: 'REPAIR',
steps: ['NEW', 'ASSIGNED', 'ON_SITE', 'ESTIMATE_APPROVED', 'REPAIRING', 'DONE'],
initial: 'NEW',
assigned: 'ASSIGNED',
done: ['DONE'],
clientCancellable: ['NEW', 'ASSIGNED'],
checklistAt: ['ON_SITE', 'ESTIMATE_APPROVED', 'REPAIRING'],
transitions: [
{ from: 'NEW', to: 'ASSIGNED', by: ASSIGN },
{ from: 'ASSIGNED', to: 'NEW', by: OPS },
{ from: 'ASSIGNED', to: 'ON_SITE', by: PRO, action: 'arrived' },
{ from: 'ON_SITE', to: 'ESTIMATE_APPROVED', by: ['CLIENT', 'SYSTEM'], guards: ['estimateAccepted'] },
{ from: 'ESTIMATE_APPROVED', to: 'REPAIRING', by: PRO, action: 'startWork', guards: ['estimateAccepted'] },
{ from: 'REPAIRING', to: 'DONE', by: PRO, action: 'closeRepair', guards: ['checklistComplete'] },
],
events: [],
};
/** Трансфер: Новый → Назначен → Водитель ждёт → В пути → Завершён */
const TRANSFER: FlowDef = {
flow: 'TRANSFER',
steps: ['NEW', 'ASSIGNED', 'DRIVER_WAITING', 'RIDE_IN_PROGRESS', 'COMPLETED'],
initial: 'NEW',
assigned: 'ASSIGNED',
done: ['COMPLETED'],
clientCancellable: ['NEW', 'ASSIGNED'],
checklistAt: ['RIDE_IN_PROGRESS'],
transitions: [
{ from: 'NEW', to: 'ASSIGNED', by: ASSIGN },
{ from: 'ASSIGNED', to: 'NEW', by: OPS },
{ from: 'ASSIGNED', to: 'DRIVER_WAITING', by: PRO, action: 'atPickup' },
{ from: 'DRIVER_WAITING', to: 'RIDE_IN_PROGRESS', by: PRO, action: 'passengerIn' },
{ from: 'RIDE_IN_PROGRESS', to: 'COMPLETED', by: PRO, action: 'finishRide', guards: ['checklistComplete'] },
],
events: [],
};
/** Языки: Записан → Урок N запланирован → Идёт урок → Урок завершён → … → Завершён */
const LANGUAGE: FlowDef = {
flow: 'LANGUAGE',
steps: ['ENROLLED', 'LESSON_SCHEDULED', 'LESSON_IN_PROGRESS', 'LESSON_DONE', 'COMPLETED'],
initial: 'ENROLLED',
assigned: null,
done: ['COMPLETED'],
clientCancellable: ['ENROLLED'],
checklistAt: ['LESSON_IN_PROGRESS'],
transitions: [
{ from: 'ENROLLED', to: 'LESSON_SCHEDULED', by: PRO, action: 'scheduleLesson', guards: ['lessonsRemaining'] },
{ from: 'LESSON_SCHEDULED', to: 'LESSON_IN_PROGRESS', by: PRO, action: 'startLesson' },
{ from: 'LESSON_IN_PROGRESS', to: 'LESSON_DONE', by: PRO, action: 'finishLesson', guards: ['checklistComplete'] },
{ from: 'LESSON_DONE', to: 'LESSON_SCHEDULED', by: PRO, action: 'scheduleLesson', guards: ['lessonsRemaining'] },
{ from: 'LESSON_DONE', to: 'COMPLETED', by: OPS, guards: ['allLessonsDone'] },
],
events: [{ code: 'openLesson', at: ['LESSON_SCHEDULED'], by: PRO }],
};
/** Карьера: Новый → Консультант назначен → Резюме получено → Встреча → План отправлен */
const CAREER: FlowDef = {
flow: 'CAREER',
steps: ['NEW', 'CONSULTANT_ASSIGNED', 'RESUME_RECEIVED', 'MEETING', 'PLAN_SENT'],
initial: 'NEW',
assigned: 'CONSULTANT_ASSIGNED',
done: ['PLAN_SENT'],
clientCancellable: ['NEW', 'CONSULTANT_ASSIGNED'],
checklistAt: ['MEETING'],
transitions: [
{ from: 'NEW', to: 'CONSULTANT_ASSIGNED', by: ASSIGN },
{ from: 'CONSULTANT_ASSIGNED', to: 'NEW', by: OPS },
{ from: 'CONSULTANT_ASSIGNED', to: 'RESUME_RECEIVED', by: ['CLIENT', 'PROVIDER', 'ADMIN', 'SYSTEM'] },
{ from: 'RESUME_RECEIVED', to: 'MEETING', by: PRO, action: 'startMeeting' },
{ from: 'MEETING', to: 'PLAN_SENT', by: PRO, action: 'finishMeeting', guards: ['checklistComplete'] },
],
events: [{ code: 'openMeeting', at: ['RESUME_RECEIVED'], by: PRO }],
};
/** Документы: Новый → Документы проверяются → Подача → Готово (+ «Нужны доп. документы») */
const DOCUMENTS: FlowDef = {
flow: 'DOCUMENTS',
steps: ['NEW', 'DOCS_REVIEW', 'SUBMITTED', 'DONE'],
initial: 'NEW',
assigned: null,
done: ['DONE'],
clientCancellable: ['NEW'],
checklistAt: [],
transitions: [
{ from: 'NEW', to: 'DOCS_REVIEW', by: PRO },
{ from: 'DOCS_REVIEW', to: 'NEEDS_MORE_DOCS', by: PRO },
{ from: 'NEEDS_MORE_DOCS', to: 'DOCS_REVIEW', by: ['CLIENT', 'PROVIDER', 'ADMIN', 'SYSTEM'] },
{ from: 'DOCS_REVIEW', to: 'SUBMITTED', by: PRO },
{ from: 'SUBMITTED', to: 'NEEDS_MORE_DOCS', by: PRO },
{ from: 'SUBMITTED', to: 'DONE', by: PRO },
],
events: [],
};
export const FLOWS: Record<ServiceFlow, FlowDef> = {
CLEANING,
REPAIR,
TRANSFER,
LANGUAGE,
CAREER,
DOCUMENTS,
};
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import type { ActorType, OrderStatus, ServiceFlow } from '../types.ts';
import { FLOWS } from './definitions.ts';
import { checkTransition, flowStatuses, kanbanColumn, providerActions } from './machine.ts';
import type { GuardContext } from './types.ts';
type Step = [OrderStatus, OrderStatus, ActorType, GuardContext?];
/** Счастливый путь каждой категории — как в таблице STS */
const HAPPY: Record<ServiceFlow, Step[]> = {
CLEANING: [
['NEW', 'ASSIGNED', 'SYSTEM'],
['ASSIGNED', 'EN_ROUTE', 'PROVIDER'],
['EN_ROUTE', 'CLEANING', 'PROVIDER'],
['CLEANING', 'DONE', 'PROVIDER', { checklistComplete: true }],
],
REPAIR: [
['NEW', 'ASSIGNED', 'SYSTEM'],
['ASSIGNED', 'ON_SITE', 'PROVIDER'],
['ON_SITE', 'ESTIMATE_APPROVED', 'CLIENT', { estimateAccepted: true }],
['ESTIMATE_APPROVED', 'REPAIRING', 'PROVIDER', { estimateAccepted: true }],
['REPAIRING', 'DONE', 'PROVIDER', { checklistComplete: true }],
],
TRANSFER: [
['NEW', 'ASSIGNED', 'SYSTEM'],
['ASSIGNED', 'DRIVER_WAITING', 'PROVIDER'],
['DRIVER_WAITING', 'RIDE_IN_PROGRESS', 'PROVIDER'],
['RIDE_IN_PROGRESS', 'COMPLETED', 'PROVIDER', { checklistComplete: true }],
],
LANGUAGE: [
['ENROLLED', 'LESSON_SCHEDULED', 'PROVIDER', { lessonsTotal: 2, lessonsCompleted: 0 }],
['LESSON_SCHEDULED', 'LESSON_IN_PROGRESS', 'PROVIDER'],
['LESSON_IN_PROGRESS', 'LESSON_DONE', 'PROVIDER', { checklistComplete: true }],
['LESSON_DONE', 'LESSON_SCHEDULED', 'PROVIDER', { lessonsTotal: 2, lessonsCompleted: 1 }],
['LESSON_SCHEDULED', 'LESSON_IN_PROGRESS', 'PROVIDER'],
['LESSON_IN_PROGRESS', 'LESSON_DONE', 'PROVIDER', { checklistComplete: true }],
['LESSON_DONE', 'COMPLETED', 'SYSTEM', { lessonsTotal: 2, lessonsCompleted: 2 }],
],
CAREER: [
['NEW', 'CONSULTANT_ASSIGNED', 'SYSTEM'],
['CONSULTANT_ASSIGNED', 'RESUME_RECEIVED', 'CLIENT'],
['RESUME_RECEIVED', 'MEETING', 'PROVIDER'],
['MEETING', 'PLAN_SENT', 'PROVIDER', { checklistComplete: true }],
],
DOCUMENTS: [
['NEW', 'DOCS_REVIEW', 'ADMIN'],
['DOCS_REVIEW', 'NEEDS_MORE_DOCS', 'ADMIN'],
['NEEDS_MORE_DOCS', 'DOCS_REVIEW', 'CLIENT'],
['DOCS_REVIEW', 'SUBMITTED', 'ADMIN'],
['SUBMITTED', 'DONE', 'ADMIN'],
],
};
/** Недопустимые переходы — «перепрыгнуть» шаг или вернуться из финала */
const INVALID: Record<ServiceFlow, [OrderStatus, OrderStatus][]> = {
CLEANING: [['NEW', 'CLEANING'], ['NEW', 'DONE'], ['ASSIGNED', 'DONE'], ['DONE', 'NEW'], ['EN_ROUTE', 'ASSIGNED']],
REPAIR: [['ASSIGNED', 'REPAIRING'], ['ON_SITE', 'REPAIRING'], ['NEW', 'DONE'], ['DONE', 'REPAIRING']],
TRANSFER: [['NEW', 'RIDE_IN_PROGRESS'], ['ASSIGNED', 'COMPLETED'], ['COMPLETED', 'ASSIGNED']],
LANGUAGE: [['ENROLLED', 'LESSON_DONE'], ['ENROLLED', 'COMPLETED'], ['LESSON_SCHEDULED', 'LESSON_DONE']],
CAREER: [['NEW', 'MEETING'], ['CONSULTANT_ASSIGNED', 'PLAN_SENT'], ['PLAN_SENT', 'NEW']],
DOCUMENTS: [['NEW', 'SUBMITTED'], ['NEW', 'DONE'], ['NEEDS_MORE_DOCS', 'DONE'], ['DONE', 'DOCS_REVIEW']],
};
describe.each(Object.keys(FLOWS) as ServiceFlow[])('state machine: %s', (flow) => {
it('проходит счастливый путь', () => {
let status = FLOWS[flow].initial;
for (const [from, to, actor, ctx] of HAPPY[flow]) {
expect(status).toBe(from);
const res = checkTransition(flow, from, to, actor, ctx);
expect(res, `${from} → ${to} by ${actor}`).toMatchObject({ ok: true });
status = to;
}
expect(FLOWS[flow].done).toContain(status);
});
it.each(INVALID[flow])('отклоняет %s → %s', (from, to) => {
for (const actor of ['CLIENT', 'PROVIDER', 'ADMIN', 'SYSTEM'] as const) {
const res = checkTransition(flow, from, to, actor, {
checklistComplete: true,
estimateAccepted: true,
lessonsTotal: 4,
lessonsCompleted: 4,
});
expect(res.ok).toBe(false);
}
});
it('отклоняет статусы чужой категории', () => {
const foreign = (['CLEANING', 'DRIVER_WAITING', 'LESSON_DONE', 'SUBMITTED'] as OrderStatus[]).find(
(s) => !flowStatuses(flow).includes(s),
)!;
expect(checkTransition(flow, FLOWS[flow].initial, foreign, 'ADMIN')).toEqual({ ok: false, code: 'UNKNOWN_STATUS' });
});
it('клиент не двигает рабочие шаги исполнителя', () => {
for (const t of FLOWS[flow].transitions.filter((x) => !x.by.includes('CLIENT'))) {
expect(checkTransition(flow, t.from, t.to, 'CLIENT', { checklistComplete: true }).ok).toBe(false);
}
});
it('отмена: клиент — только из ранних статусов, админ — из любого нефинального, из финала — никто', () => {
const def = FLOWS[flow];
for (const s of flowStatuses(flow)) {
const terminal = s === 'CANCELLED' || def.done.includes(s);
expect(checkTransition(flow, s, 'CANCELLED', 'ADMIN').ok).toBe(!terminal);
expect(checkTransition(flow, s, 'CANCELLED', 'CLIENT').ok).toBe(!terminal && def.clientCancellable.includes(s));
expect(checkTransition(flow, s, 'CANCELLED', 'PROVIDER').ok).toBe(false);
}
});
});
describe('guards', () => {
it('клининг нельзя закрыть без чек-листа (PRO-4)', () => {
expect(checkTransition('CLEANING', 'CLEANING', 'DONE', 'PROVIDER', { checklistComplete: false })).toEqual({
ok: false,
code: 'GUARD_FAILED',
guard: 'checklistComplete',
});
// и админ тоже не может обойти
expect(checkTransition('CLEANING', 'CLEANING', 'DONE', 'ADMIN').ok).toBe(false);
});
it('ремонт не начинается без согласованной сметы (STS-1)', () => {
expect(checkTransition('REPAIR', 'ON_SITE', 'ESTIMATE_APPROVED', 'CLIENT', { estimateAccepted: false }).ok).toBe(false);
expect(checkTransition('REPAIR', 'ESTIMATE_APPROVED', 'REPAIRING', 'PROVIDER', { estimateAccepted: false }).ok).toBe(false);
expect(checkTransition('REPAIR', 'ON_SITE', 'ESTIMATE_APPROVED', 'PROVIDER', { estimateAccepted: true })).toMatchObject({
ok: false,
code: 'ACTOR_NOT_ALLOWED',
});
});
it('языки: нельзя запланировать урок сверх пакета и закончить пакет раньше', () => {
expect(checkTransition('LANGUAGE', 'LESSON_DONE', 'LESSON_SCHEDULED', 'PROVIDER', { lessonsTotal: 4, lessonsCompleted: 4 }).ok).toBe(false);
expect(checkTransition('LANGUAGE', 'LESSON_DONE', 'COMPLETED', 'SYSTEM', { lessonsTotal: 4, lessonsCompleted: 3 }).ok).toBe(false);
});
});
describe('providerActions', () => {
it('клинер: Выехал → Я на месте + Начать уборку → Завершить', () => {
expect(providerActions('CLEANING', 'ASSIGNED').map((a) => a.code)).toEqual(['depart']);
expect(providerActions('CLEANING', 'EN_ROUTE').map((a) => a.code)).toEqual(['arrived', 'startCleaning']);
expect(providerActions('CLEANING', 'CLEANING')).toEqual([
{ kind: 'transition', code: 'finishCleaning', to: 'DONE', guards: ['checklistComplete'] },
]);
expect(providerActions('CLEANING', 'DONE')).toEqual([]);
});
it('водитель: Я на месте подачи → Пассажир в машине → Завершить поездку', () => {
expect(providerActions('TRANSFER', 'ASSIGNED').map((a) => a.code)).toEqual(['atPickup']);
expect(providerActions('TRANSFER', 'DRIVER_WAITING').map((a) => a.code)).toEqual(['passengerIn']);
expect(providerActions('TRANSFER', 'RIDE_IN_PROGRESS').map((a) => a.code)).toEqual(['finishRide']);
});
});
describe('kanbanColumn', () => {
it('раскладывает по колонкам ADM-1', () => {
expect(kanbanColumn('CLEANING', 'NEW')).toBe('new');
expect(kanbanColumn('CLEANING', 'NEW', { hasPendingOffer: true })).toBe('assigned');
expect(kanbanColumn('CLEANING', 'NEW', { hasCompany: true })).toBe('assigned');
expect(kanbanColumn('CLEANING', 'ASSIGNED')).toBe('assigned');
expect(kanbanColumn('CLEANING', 'EN_ROUTE')).toBe('in_progress');
expect(kanbanColumn('CLEANING', 'DONE')).toBe('done');
expect(kanbanColumn('TRANSFER', 'COMPLETED')).toBe('done');
expect(kanbanColumn('CLEANING', 'CANCELLED')).toBe('cancelled');
});
});
+3
View File
@@ -0,0 +1,3 @@
export * from './types.ts';
export * from './definitions.ts';
export * from './machine.ts';
+122
View File
@@ -0,0 +1,122 @@
import type { ActorType, OrderStatus, ServiceFlow } from '../types.ts';
import { FLOWS } from './definitions.ts';
import type { FlowDef, FlowEvent, Guard, GuardContext, Transition, TransitionCheck } from './types.ts';
export function getFlow(flow: ServiceFlow): FlowDef {
return FLOWS[flow];
}
/** Все статусы, которые встречаются в сценарии (по порядку появления) */
export function flowStatuses(flow: ServiceFlow): OrderStatus[] {
const def = FLOWS[flow];
const seen = new Set<OrderStatus>([def.initial]);
for (const t of def.transitions) {
seen.add(t.from);
seen.add(t.to);
}
seen.add('CANCELLED');
return [...seen];
}
export function isTerminal(flow: ServiceFlow, status: OrderStatus): boolean {
return status === 'CANCELLED' || FLOWS[flow].done.includes(status);
}
function checkGuard(guard: Guard, ctx: GuardContext): boolean {
switch (guard) {
case 'checklistComplete':
return ctx.checklistComplete === true;
case 'estimateAccepted':
return ctx.estimateAccepted === true;
case 'lessonsRemaining':
return (ctx.lessonsTotal ?? 0) > (ctx.lessonsCompleted ?? 0);
case 'allLessonsDone':
return (ctx.lessonsTotal ?? 0) > 0 && (ctx.lessonsCompleted ?? 0) >= (ctx.lessonsTotal ?? 0);
}
}
/**
* Можно ли перевести заказ из `from` в `to`.
* Отмена разрешена клиенту из clientCancellable, админу и системе — из любого нефинального статуса.
*/
export function checkTransition(
flow: ServiceFlow,
from: OrderStatus,
to: OrderStatus,
actor: ActorType,
ctx: GuardContext = {},
): TransitionCheck {
const def = FLOWS[flow];
const statuses = flowStatuses(flow);
if (!statuses.includes(from) || !statuses.includes(to)) return { ok: false, code: 'UNKNOWN_STATUS' };
if (to === 'CANCELLED') {
if (isTerminal(flow, from)) return { ok: false, code: 'TRANSITION_NOT_ALLOWED' };
if (actor === 'ADMIN' || actor === 'SYSTEM') return { ok: true, transition: null };
if (actor === 'CLIENT' && def.clientCancellable.includes(from)) return { ok: true, transition: null };
return { ok: false, code: 'ACTOR_NOT_ALLOWED' };
}
const candidates = def.transitions.filter((t) => t.from === from && t.to === to);
if (candidates.length === 0) return { ok: false, code: 'TRANSITION_NOT_ALLOWED' };
const transition = candidates.find((t) => t.by.includes(actor));
if (!transition) return { ok: false, code: 'ACTOR_NOT_ALLOWED' };
for (const guard of transition.guards ?? []) {
if (!checkGuard(guard, ctx)) return { ok: false, code: 'GUARD_FAILED', guard };
}
return { ok: true, transition };
}
/** Переход по коду кнопки исполнителя из текущего статуса */
export function findActionTransition(flow: ServiceFlow, from: OrderStatus, action: string): Transition | undefined {
return FLOWS[flow].transitions.find((t) => t.from === from && t.action === action);
}
export function findEvent(flow: ServiceFlow, status: OrderStatus, code: string): FlowEvent | undefined {
return FLOWS[flow].events.find((e) => e.code === code && e.at.includes(status));
}
export interface ProAction {
kind: 'transition' | 'event';
code: string;
to?: OrderStatus;
guards: readonly Guard[];
}
/** Кнопки, доступные исполнителю в текущем статусе (PRO-3) */
export function providerActions(flow: ServiceFlow, status: OrderStatus): ProAction[] {
const def = FLOWS[flow];
const events: ProAction[] = def.events
.filter((e) => e.at.includes(status) && e.by.includes('PROVIDER'))
.map((e) => ({ kind: 'event', code: e.code, guards: [] }));
const transitions: ProAction[] = def.transitions
.filter((t) => t.from === status && t.action && t.by.includes('PROVIDER'))
.map((t) => ({ kind: 'transition', code: t.action!, to: t.to, guards: t.guards ?? [] }));
return [...events, ...transitions];
}
export type KanbanColumn = 'new' | 'assigned' | 'in_progress' | 'done' | 'cancelled';
/**
* Колонка канбана (ADM-1). Заказ, предложенный исполнителю или переданный компании,
* но ещё не принятый, показывается в «Назначены» с пометкой.
*/
export function kanbanColumn(
flow: ServiceFlow,
status: OrderStatus,
opts: { hasProvider?: boolean; hasPendingOffer?: boolean; hasCompany?: boolean } = {},
): KanbanColumn {
const def = FLOWS[flow];
if (status === 'CANCELLED') return 'cancelled';
if (def.done.includes(status)) return 'done';
if (status === def.initial) {
return opts.hasProvider || opts.hasPendingOffer || opts.hasCompany ? 'assigned' : 'new';
}
if (status === def.assigned) return 'assigned';
return 'in_progress';
}
/** Работа по заказу уже началась (исполнитель выехал и т.п.) */
export function isInProgress(flow: ServiceFlow, status: OrderStatus): boolean {
return kanbanColumn(flow, status, { hasProvider: true }) === 'in_progress';
}
+63
View File
@@ -0,0 +1,63 @@
import type { ActorType, OrderStatus, ServiceFlow } from '../types.ts';
/** Условия, которые проверяются перед переходом */
export type Guard =
/** все пункты чек-листа отмечены, фото приложены там, где нужно */
| 'checklistComplete'
/** клиент принял смету (ремонт) */
| 'estimateAccepted'
/** в пакете остались уроки */
| 'lessonsRemaining'
/** все уроки пакета пройдены */
| 'allLessonsDone';
export interface Transition {
from: OrderStatus;
to: OrderStatus;
by: readonly ActorType[];
/** код кнопки исполнителя; i18n: pro.action.<action> */
action?: string;
guards?: readonly Guard[];
}
/** Событие без смены статуса — например «Я на месте» */
export interface FlowEvent {
code: string;
at: readonly OrderStatus[];
by: readonly ActorType[];
}
export interface FlowDef {
flow: ServiceFlow;
/** основной путь — для шкалы прогресса у клиента */
steps: readonly OrderStatus[];
/** статус при создании заказа */
initial: OrderStatus;
/** статус, в который заказ переходит, когда исполнитель принял предложение (null — статус не меняется) */
assigned: OrderStatus | null;
/** успешные финальные статусы */
done: readonly OrderStatus[];
/** из каких статусов клиент может отменить сам */
clientCancellable: readonly OrderStatus[];
/** статусы, где у исполнителя идёт работа и показывается чек-лист */
checklistAt: readonly OrderStatus[];
transitions: readonly Transition[];
events: readonly FlowEvent[];
}
export interface GuardContext {
checklistComplete?: boolean;
estimateAccepted?: boolean;
lessonsTotal?: number;
lessonsCompleted?: number;
}
export type TransitionErrorCode =
| 'UNKNOWN_STATUS'
| 'TRANSITION_NOT_ALLOWED'
| 'ACTOR_NOT_ALLOWED'
| 'GUARD_FAILED';
export type TransitionCheck =
| { ok: true; transition: Transition | null }
| { ok: false; code: TransitionErrorCode; guard?: Guard };
+14
View File
@@ -0,0 +1,14 @@
export * from './types.ts';
export * from './flows/index.ts';
export * from './params.ts';
export * from './pricing.ts';
export * from './time.ts';
export * from './slots.ts';
export * from './assignment.ts';
export * from './cancellation.ts';
export * from './deeplink.ts';
export * from './membership.ts';
export * from './promo.ts';
export * from './settings.ts';
export * from './payments.ts';
export * from './catalog.ts';
+59
View File
@@ -0,0 +1,59 @@
import { addMonths, DAY } from './time.ts';
export interface SubscriptionLike {
startsAt: Date;
endsAt: Date;
status: 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
}
export type MembershipLevel = 'BAREV' | 'BAREV_PLUS';
export function activeSubscription<T extends SubscriptionLike>(subs: T[], now: Date): T | null {
return subs.find((s) => s.status === 'ACTIVE' && s.startsAt <= now && s.endsAt > now) ?? null;
}
export function membershipLevel(subs: SubscriptionLike[], now: Date): MembershipLevel {
return activeSubscription(subs, now) ? 'BAREV_PLUS' : 'BAREV';
}
/** Скидка Barev+ по направлению применяется только при активной подписке (SUB-2) */
export function discountFor(level: MembershipLevel, categoryDiscountPct: number): number {
return level === 'BAREV_PLUS' ? categoryDiscountPct : 0;
}
export type GiftDecision =
| { ok: true; startsAt: Date; endsAt: Date }
| { ok: false; reason: 'ALREADY_USED' | 'AGENCY_INACTIVE' };
/**
* SUB-3 / ONB-4: подарочный месяц по коду агентства, один раз на клиента.
* Если у клиента уже есть активный Barev+, подарок продлевает его с даты окончания.
*/
export function decideGift(input: {
now: Date;
giftUsedAt: Date | null;
agencyActive: boolean;
giftDays: number;
currentEndsAt: Date | null;
}): GiftDecision {
if (input.giftUsedAt) return { ok: false, reason: 'ALREADY_USED' };
if (!input.agencyActive) return { ok: false, reason: 'AGENCY_INACTIVE' };
const startsAt = input.currentEndsAt && input.currentEndsAt > input.now ? input.currentEndsAt : input.now;
return { ok: true, startsAt, endsAt: new Date(startsAt.getTime() + input.giftDays * DAY) };
}
/** Клиент «принадлежит» агентству N месяцев с первого /start (решение владельца) */
export function isAgencyAttributionActive(attachedAt: Date | null, now: Date, months: number): boolean {
if (!attachedAt) return false;
return now < addMonths(attachedAt, months);
}
/** ADM-8 / PAY-3: вознаграждение агентства с оплаченного заказа */
export function agencyCommission(totalPrice: number, pct: number): number {
return Math.floor((totalPrice * pct) / 100);
}
/** PRO-8: выплата исполнителю или компании по заказу */
export function payoutFor(totalPrice: number, rate: { type: 'FIXED' | 'PERCENT'; value: number }): number {
return rate.type === 'FIXED' ? rate.value : Math.floor((totalPrice * rate.value) / 100);
}
+91
View File
@@ -0,0 +1,91 @@
import { z } from 'zod';
/**
* Описание параметров формы заказа (Service.paramsSchema).
* Подписи: i18n `params.<serviceCode>.<key>.label`, варианты — `params.<serviceCode>.<key>.<option>`.
*/
export const paramFieldSchema = z.discriminatedUnion('type', [
z.object({
key: z.string(),
type: z.literal('select'),
options: z.array(z.string()).min(1),
default: z.string().optional(),
required: z.boolean().default(true),
}),
z.object({
key: z.string(),
type: z.literal('int'),
min: z.number().int(),
max: z.number().int(),
default: z.number().int().optional(),
required: z.boolean().default(true),
}),
z.object({
key: z.string(),
type: z.literal('text'),
maxLength: z.number().int().default(500),
required: z.boolean().default(false),
}),
z.object({
key: z.string(),
type: z.literal('bool'),
default: z.boolean().optional(),
required: z.boolean().default(false),
}),
]);
export type ParamField = z.infer<typeof paramFieldSchema>;
export const paramsSchemaSchema = z.object({ fields: z.array(paramFieldSchema) });
export type ParamsSchema = z.infer<typeof paramsSchemaSchema>;
export type ParamValues = Record<string, string | number | boolean>;
export function defaultParams(schema: ParamsSchema): ParamValues {
const out: ParamValues = {};
for (const f of schema.fields) {
if (f.type === 'select') out[f.key] = f.default ?? f.options[0]!;
else if (f.type === 'int') out[f.key] = f.default ?? f.min;
else if (f.type === 'bool') out[f.key] = f.default ?? false;
}
return out;
}
export type ParamsError = { key: string; code: 'required' | 'invalid' };
/** Проверяет и нормализует значения; лишние ключи отбрасываются */
export function validateParams(
schema: ParamsSchema,
input: Record<string, unknown>,
): { ok: true; values: ParamValues } | { ok: false; errors: ParamsError[] } {
const values: ParamValues = {};
const errors: ParamsError[] = [];
for (const f of schema.fields) {
const raw = input[f.key];
const missing = raw === undefined || raw === null || raw === '';
if (missing) {
if (f.required) errors.push({ key: f.key, code: 'required' });
continue;
}
switch (f.type) {
case 'select':
if (typeof raw === 'string' && f.options.includes(raw)) values[f.key] = raw;
else errors.push({ key: f.key, code: 'invalid' });
break;
case 'int': {
const n = typeof raw === 'number' ? raw : Number(raw);
if (Number.isInteger(n) && n >= f.min && n <= f.max) values[f.key] = n;
else errors.push({ key: f.key, code: 'invalid' });
break;
}
case 'text':
if (typeof raw === 'string' && raw.length <= f.maxLength) values[f.key] = raw.trim();
else errors.push({ key: f.key, code: 'invalid' });
break;
case 'bool':
if (typeof raw === 'boolean') values[f.key] = raw;
else errors.push({ key: f.key, code: 'invalid' });
break;
}
}
return errors.length ? { ok: false, errors } : { ok: true, values };
}
+36
View File
@@ -0,0 +1,36 @@
/**
* PAY-2: абстракция платёжного провайдера. В Этапах 1–2 работает только ManualPaymentProvider —
* оператор или исполнитель отмечает «оплачено» вручную. Реальный провайдер (Армения) появится в Этапе 3.
*/
export interface PaymentIntent {
orderId: string;
amount: number;
currency: 'AMD';
description: string;
}
export type PaymentResult =
| { status: 'PAID'; provider: string; externalId?: string }
| { status: 'PENDING'; provider: string; redirectUrl?: string; externalId?: string };
export interface PaymentProvider {
readonly code: string;
/** может ли клиент оплатить сам (онлайн) */
readonly online: boolean;
createPayment(intent: PaymentIntent): Promise<PaymentResult>;
/** ручная отметка; для онлайн-провайдеров — подтверждение по webhook */
confirmPayment(intent: PaymentIntent, meta: { markedBy: string }): Promise<PaymentResult>;
}
export class ManualPaymentProvider implements PaymentProvider {
readonly code = 'manual';
readonly online = false;
async createPayment(_intent: PaymentIntent): Promise<PaymentResult> {
return { status: 'PENDING', provider: this.code };
}
async confirmPayment(_intent: PaymentIntent, _meta: { markedBy: string }): Promise<PaymentResult> {
return { status: 'PAID', provider: this.code };
}
}
+87
View File
@@ -0,0 +1,87 @@
import { z } from 'zod';
import type { ParamValues } from './params.ts';
/**
* Правила цены услуги (Service.pricing). Все суммы — целые драмы.
*
* - `rooms`: тариф по варианту (тип уборки) + доплата за каждую комнату сверх первой.
* Длительность считается так же.
* - `fixed`: фиксированная цена (заглушка для услуг Этапа 2).
*/
const roomsTier = z.object({
base: z.number().int().nonnegative(),
perRoom: z.number().int().nonnegative(),
baseMinutes: z.number().int().positive(),
perRoomMinutes: z.number().int().nonnegative(),
});
export const pricingSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('rooms'),
/** параметр-вариант, например cleaningType */
variantParam: z.string(),
roomsParam: z.string(),
tiers: z.record(z.string(), roomsTier),
}),
z.object({
kind: z.literal('fixed'),
price: z.number().int().nonnegative(),
minutes: z.number().int().positive(),
}),
]);
export type Pricing = z.infer<typeof pricingSchema>;
export interface Quote {
basePrice: number;
discountPct: number;
discountAmount: number;
totalPrice: number;
durationMinutes: number;
}
export class PricingError extends Error {
constructor(public code: 'UNKNOWN_VARIANT' | 'BAD_ROOMS' | 'BAD_DISCOUNT') {
super(code);
}
}
function baseOf(pricing: Pricing, params: ParamValues): { price: number; minutes: number } {
if (pricing.kind === 'fixed') return { price: pricing.price, minutes: pricing.minutes };
const tier = pricing.tiers[String(params[pricing.variantParam])];
if (!tier) throw new PricingError('UNKNOWN_VARIANT');
const rooms = Number(params[pricing.roomsParam]);
if (!Number.isInteger(rooms) || rooms < 1) throw new PricingError('BAD_ROOMS');
const extra = rooms - 1;
return {
price: tier.base + tier.perRoom * extra,
minutes: tier.baseMinutes + tier.perRoomMinutes * extra,
};
}
/**
* ORD-4: базовая цена по параметрам минус скидка Barev+ по направлению.
* Сумма скидки округляется вниз до целого драма.
*/
export function quote(pricing: Pricing, params: ParamValues, discountPct = 0): Quote {
if (!Number.isInteger(discountPct) || discountPct < 0 || discountPct > 100) throw new PricingError('BAD_DISCOUNT');
const { price, minutes } = baseOf(pricing, params);
const discountAmount = Math.floor((price * discountPct) / 100);
return {
basePrice: price,
discountPct,
discountAmount,
totalPrice: price - discountAmount,
durationMinutes: minutes,
};
}
/** Минимальная цена — для «от N ֏» на лендинге и в каталоге */
export function fromPrice(pricing: Pricing): number {
if (pricing.kind === 'fixed') return pricing.price;
return Math.min(...Object.values(pricing.tiers).map((t) => t.base));
}
export function formatAmd(amount: number, locale = 'ru'): string {
const n = new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale, { maximumFractionDigits: 0 }).format(amount);
return `${n} ֏`;
}
+26
View File
@@ -0,0 +1,26 @@
export interface PromoLike {
kind: 'AGENCY_GIFT' | 'DISCOUNT';
active: boolean;
maxUses: number | null;
usedCount: number;
validFrom: Date | null;
validUntil: Date | null;
}
export type PromoCheck =
| { ok: true }
| { ok: false; reason: 'INACTIVE' | 'NOT_STARTED' | 'EXPIRED' | 'EXHAUSTED' | 'ALREADY_USED_BY_CLIENT' };
export function checkPromo(promo: PromoLike, now: Date, usedByClient: boolean): PromoCheck {
if (!promo.active) return { ok: false, reason: 'INACTIVE' };
if (promo.validFrom && now < promo.validFrom) return { ok: false, reason: 'NOT_STARTED' };
if (promo.validUntil && now > promo.validUntil) return { ok: false, reason: 'EXPIRED' };
if (promo.maxUses !== null && promo.usedCount >= promo.maxUses) return { ok: false, reason: 'EXHAUSTED' };
if (usedByClient) return { ok: false, reason: 'ALREADY_USED_BY_CLIENT' };
return { ok: true };
}
/** Промокод-скидка не суммируется с Barev+: берётся большая из двух */
export function combineDiscounts(barevPlusPct: number, promoPct: number | null): number {
return Math.max(barevPlusPct, promoPct ?? 0);
}
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod';
/**
* Настройки, которые владелец меняет в админке (таблица Setting).
* Здесь — схема и значения по умолчанию; в коде порогов нет.
*/
export const settingsSchema = z.object({
/** ORD-6: бесплатная отмена/перенос не позднее чем за N часов */
'cancel.freeHours': z.number().int().min(0).max(168).default(24),
/** ADM-8: сколько месяцев клиент закреплён за агентством */
'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+ */
'barevPlus.giftDays': z.number().int().min(1).max(365).default(30),
/** цена подписки в месяц, ֏ (заглушка до решения владельца) */
'barevPlus.monthlyPrice': z.number().int().min(0).default(4900),
/** PRO-5: сколько минут исполнитель может думать над предложением */
'offer.acceptTimeoutMinutes': z.number().int().min(1).max(1440).default(15),
/** MSG-1: чат закрывается через N часов после завершения (Этап 2) */
'chat.closeAfterHours': z.number().int().min(0).default(24),
/** раздел 6: автоудаление сканов через N дней после завершения */
'files.retentionDays': z.number().int().min(1).default(90),
/** текущая версия политики ПДн — сохраняется вместе с согласием */
'consent.version': z.string().default('2026-10-draft'),
});
export type Settings = z.infer<typeof settingsSchema>;
export type SettingKey = keyof Settings;
export const SETTING_KEYS = Object.keys(settingsSchema.shape) as SettingKey[];
export const DEFAULT_SETTINGS: Settings = settingsSchema.parse({});
/** Собирает настройки из строк БД; битые значения заменяются дефолтом */
export function resolveSettings(rows: { key: string; value: unknown }[]): Settings {
const out: Record<string, unknown> = { ...DEFAULT_SETTINGS };
for (const r of rows) {
const field = settingsSchema.shape[r.key as SettingKey];
if (!field) continue;
const parsed = field.safeParse(r.value);
if (parsed.success) out[r.key] = parsed.data;
}
return out as Settings;
}
+74
View File
@@ -0,0 +1,74 @@
import { z } from 'zod';
import { fromYerevan, HOUR, parseDateKey, toYerevan } from './time.ts';
/** Окно записи услуги (Service.slotConfig) */
export const slotConfigSchema = z.object({
/** начало и конец окна, минуты от полуночи */
dayStartMinute: z.number().int().min(0).max(1440),
dayEndMinute: z.number().int().min(0).max(1440),
stepMinutes: z.number().int().positive(),
/** минимальный запас до начала, часы */
leadHours: z.number().nonnegative(),
/** на сколько дней вперёд открыта запись */
horizonDays: z.number().int().positive(),
});
export type SlotConfig = z.infer<typeof slotConfigSchema>;
export interface Interval {
start: Date;
end: Date;
}
export interface ProviderAvailability {
id: string;
/** недельный график: weekday 0 = Пн */
schedule: { weekday: number; startMinute: number; endMinute: number }[];
/** занятые интервалы (принятые заказы) */
busy: Interval[];
}
export interface Slot {
start: Date;
end: Date;
/** сколько исполнителей свободно с учётом ещё не назначенных заказов */
capacity: number;
}
const overlaps = (a: Interval, b: Interval) => a.start < b.end && b.start < a.end;
export function isProviderFree(p: ProviderAvailability, slot: Interval): boolean {
const s = toYerevan(slot.start);
const startMin = s.hour * 60 + s.minute;
const endMin = startMin + (slot.end.getTime() - slot.start.getTime()) / 60_000;
const inSchedule = p.schedule.some((w) => w.weekday === s.weekday && w.startMinute <= startMin && w.endMinute >= endMin);
return inSchedule && !p.busy.some((b) => overlaps(b, slot));
}
/**
* Слоты на день (решение владельца: окно услуги + хотя бы один свободный исполнитель по графику).
* Неназначенные заказы на пересекающееся время тоже занимают мощность, чтобы не продать одно время дважды.
*/
export function daySlots(input: {
dateKey: string;
config: SlotConfig;
durationMinutes: number;
now: Date;
providers: ProviderAvailability[];
unassigned: Interval[];
}): Slot[] {
const { year, month, day } = parseDateKey(input.dateKey);
const { config } = input;
const earliest = input.now.getTime() + config.leadHours * HOUR;
const horizonEnd = input.now.getTime() + (config.horizonDays + 1) * 24 * HOUR;
const slots: Slot[] = [];
for (let m = config.dayStartMinute; m + input.durationMinutes <= config.dayEndMinute; m += config.stepMinutes) {
const start = fromYerevan(year, month, day, m);
if (start.getTime() < earliest || start.getTime() > horizonEnd) continue;
const slot = { start, end: new Date(start.getTime() + input.durationMinutes * 60_000) };
const free = input.providers.filter((p) => isProviderFree(p, slot)).length;
const taken = input.unassigned.filter((u) => overlaps(u, slot)).length;
const capacity = free - taken;
if (capacity > 0) slots.push({ ...slot, capacity });
}
return slots;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Время в Asia/Yerevan. В Армении нет перехода на летнее время с 2012 года,
* смещение постоянно UTC+4 — это позволяет считать слоты без tz-библиотек.
*/
export const YEREVAN_TZ = 'Asia/Yerevan';
export const YEREVAN_OFFSET_MINUTES = 4 * 60;
const MINUTE = 60_000;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export interface LocalParts {
year: number;
month: number; // 1–12
day: number;
hour: number;
minute: number;
/** 0 = понедельник … 6 = воскресенье */
weekday: number;
}
export function toYerevan(date: Date): LocalParts {
const local = new Date(date.getTime() + YEREVAN_OFFSET_MINUTES * MINUTE);
return {
year: local.getUTCFullYear(),
month: local.getUTCMonth() + 1,
day: local.getUTCDate(),
hour: local.getUTCHours(),
minute: local.getUTCMinutes(),
weekday: (local.getUTCDay() + 6) % 7,
};
}
/** Момент времени по местной дате и минутам от полуночи */
export function fromYerevan(year: number, month: number, day: number, minuteOfDay = 0): Date {
return new Date(Date.UTC(year, month - 1, day) + (minuteOfDay - YEREVAN_OFFSET_MINUTES) * MINUTE);
}
/** 'YYYY-MM-DD' по Еревану */
export function yerevanDateKey(date: Date): string {
const p = toYerevan(date);
return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
}
export function parseDateKey(key: string): { year: number; month: number; day: number } {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
if (!m) throw new Error(`Bad date key: ${key}`);
return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
}
export function startOfYerevanDay(date: Date): Date {
const p = toYerevan(date);
return fromYerevan(p.year, p.month, p.day);
}
/** 'YYYY-MM' по Еревану — период для актов и выплат */
export function yerevanMonthKey(date: Date): string {
return yerevanDateKey(date).slice(0, 7);
}
export function addMonths(date: Date, months: number): Date {
const d = new Date(date.getTime());
d.setUTCMonth(d.getUTCMonth() + months);
return d;
}
+28
View File
@@ -0,0 +1,28 @@
// Типы домена. Повторяют enum'ы Prisma, но не зависят от него —
// core используется и во фронтендах.
export const LOCALES = ['ru', 'en', 'zh', 'hy', 'es'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'ru';
export const isLocale = (v: unknown): v is Locale => typeof v === 'string' && (LOCALES as readonly string[]).includes(v);
export const CATEGORY_CODES = ['HOME', 'DOCS', 'LEARNING', 'PERKS'] as const;
export type CategoryCode = (typeof CATEGORY_CODES)[number];
export const SERVICE_FLOWS = ['CLEANING', 'REPAIR', 'TRANSFER', 'LANGUAGE', 'CAREER', 'DOCUMENTS'] as const;
export type ServiceFlow = (typeof SERVICE_FLOWS)[number];
export const PROVIDER_ROLES = ['CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
export type ProviderRole = (typeof PROVIDER_ROLES)[number];
export const ORDER_STATUSES = [
'NEW', 'ASSIGNED', 'EN_ROUTE', 'CLEANING', 'ON_SITE', 'ESTIMATE_APPROVED', 'REPAIRING',
'DRIVER_WAITING', 'RIDE_IN_PROGRESS', 'ENROLLED', 'LESSON_SCHEDULED', 'LESSON_IN_PROGRESS',
'LESSON_DONE', 'CONSULTANT_ASSIGNED', 'RESUME_RECEIVED', 'MEETING', 'PLAN_SENT', 'DOCS_REVIEW',
'NEEDS_MORE_DOCS', 'SUBMITTED', 'DONE', 'COMPLETED', 'CANCELLED',
] as const;
export type OrderStatus = (typeof ORDER_STATUSES)[number];
export type ActorType = 'CLIENT' | 'PROVIDER' | 'DISPATCHER' | 'ADMIN' | 'SYSTEM';
export type RateType = 'FIXED' | 'PERCENT';
export type PaymentStatus = 'UNPAID' | 'PAID' | 'REFUNDED';
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "include": ["src"] }
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@barev/db",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"generate": "prisma generate",
"migrate": "prisma migrate dev",
"deploy": "prisma migrate deploy",
"reset": "prisma migrate reset --force",
"seed": "tsx src/seed/index.ts",
"studio": "prisma studio",
"typecheck": "tsc --noEmit",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/adapter-pg": "7.10.1",
"@prisma/client": "7.10.1",
"pg": "^8.16.0"
},
"devDependencies": {
"@barev/core": "workspace:*",
"@node-rs/argon2": "^2.0.2",
"dotenv": "^17.0.0",
"prisma": "7.10.1",
"tsx": "^4.20.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
import path from 'node:path';
import { config } from 'dotenv';
import { defineConfig, env } from 'prisma/config';
config({ path: path.resolve(import.meta.dirname, '../../.env'), quiet: true });
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations', seed: 'tsx src/seed/index.ts' },
datasource: { url: env('DATABASE_URL') },
});
@@ -0,0 +1,824 @@
-- CreateEnum
CREATE TYPE "Locale" AS ENUM ('ru', 'en', 'zh', 'hy', 'es');
-- CreateEnum
CREATE TYPE "CategoryCode" AS ENUM ('HOME', 'DOCS', 'LEARNING', 'PERKS');
-- CreateEnum
CREATE TYPE "ServiceFlow" AS ENUM ('CLEANING', 'REPAIR', 'TRANSFER', 'LANGUAGE', 'CAREER', 'DOCUMENTS');
-- CreateEnum
CREATE TYPE "ProviderRole" AS ENUM ('CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT');
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('NEW', 'ASSIGNED', 'EN_ROUTE', 'CLEANING', 'ON_SITE', 'ESTIMATE_APPROVED', 'REPAIRING', 'DRIVER_WAITING', 'RIDE_IN_PROGRESS', 'ENROLLED', 'LESSON_SCHEDULED', 'LESSON_IN_PROGRESS', 'LESSON_DONE', 'CONSULTANT_ASSIGNED', 'RESUME_RECEIVED', 'MEETING', 'PLAN_SENT', 'DOCS_REVIEW', 'NEEDS_MORE_DOCS', 'SUBMITTED', 'DONE', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ActorType" AS ENUM ('CLIENT', 'PROVIDER', 'DISPATCHER', 'ADMIN', 'SYSTEM');
-- CreateEnum
CREATE TYPE "OfferStatus" AS ENUM ('OFFERED', 'ACCEPTED', 'DECLINED', 'EXPIRED', 'REVOKED');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('UNPAID', 'PAID', 'REFUNDED');
-- CreateEnum
CREATE TYPE "RateType" AS ENUM ('FIXED', 'PERCENT');
-- CreateEnum
CREATE TYPE "AdminRole" AS ENUM ('OWNER', 'OPERATOR');
-- CreateEnum
CREATE TYPE "SubscriptionPlan" AS ENUM ('BAREV_PLUS');
-- CreateEnum
CREATE TYPE "SubscriptionSource" AS ENUM ('AGENCY_GIFT', 'MANUAL', 'PAID');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'EXPIRED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "PromoKind" AS ENUM ('AGENCY_GIFT', 'DISCOUNT');
-- CreateEnum
CREATE TYPE "FileKind" AS ENUM ('PHOTO_REPORT', 'CLIENT_PHOTO', 'DOCUMENT_SCAN', 'RESUME', 'OTHER');
-- CreateEnum
CREATE TYPE "EstimateStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('OPEN', 'DONE');
-- CreateEnum
CREATE TYPE "PayoutStatus" AS ENUM ('DRAFT', 'APPROVED', 'PAID');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"telegramId" BIGINT NOT NULL,
"username" TEXT,
"firstName" TEXT,
"lastName" TEXT,
"phone" TEXT,
"locale" "Locale" NOT NULL DEFAULT 'ru',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Client" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"consentAt" TIMESTAMP(3),
"consentVersion" TEXT,
"agencyId" TEXT,
"agencyAttachedAt" TIMESTAMP(3),
"source" TEXT NOT NULL DEFAULT 'organic',
"utm" JSONB,
"giftUsedAt" TIMESTAMP(3),
"cardCode" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProviderCompany" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"roles" "ProviderRole"[],
"rateType" "RateType" NOT NULL DEFAULT 'PERCENT',
"rateValue" INTEGER NOT NULL DEFAULT 70,
"contactPhone" TEXT,
"inviteCode" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProviderCompany_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CompanyDispatcher" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CompanyDispatcher_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Provider" (
"id" TEXT NOT NULL,
"userId" TEXT,
"name" TEXT NOT NULL,
"phone" TEXT,
"role" "ProviderRole" NOT NULL,
"companyId" TEXT,
"languages" "Locale"[],
"rateType" "RateType" NOT NULL DEFAULT 'PERCENT',
"rateValue" INTEGER NOT NULL DEFAULT 70,
"ratingSum" INTEGER NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"onShift" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"inviteCode" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Provider_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "District" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "District_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProviderDistrict" (
"providerId" TEXT NOT NULL,
"districtId" TEXT NOT NULL,
CONSTRAINT "ProviderDistrict_pkey" PRIMARY KEY ("providerId","districtId")
);
-- CreateTable
CREATE TABLE "ProviderSchedule" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"weekday" INTEGER NOT NULL,
"startMinute" INTEGER NOT NULL,
"endMinute" INTEGER NOT NULL,
CONSTRAINT "ProviderSchedule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"code" "CategoryCode" NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"barevPlusDiscountPct" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Service" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"flow" "ServiceFlow" NOT NULL,
"providerRole" "ProviderRole" NOT NULL,
"pricing" JSONB NOT NULL,
"paramsSchema" JSONB NOT NULL,
"slotConfig" JSONB NOT NULL,
"lateCancelFeePct" INTEGER NOT NULL DEFAULT 30,
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Service_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChecklistItem" (
"id" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"requiresPhoto" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "ChecklistItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentMethod" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Order" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"clientId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"params" JSONB NOT NULL,
"scheduledAt" TIMESTAMP(3) NOT NULL,
"durationMinutes" INTEGER NOT NULL,
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"addressText" TEXT,
"districtId" TEXT,
"lat" DOUBLE PRECISION,
"lng" DOUBLE PRECISION,
"note" TEXT,
"locale" "Locale" NOT NULL,
"basePrice" INTEGER NOT NULL,
"discountPct" INTEGER NOT NULL DEFAULT 0,
"discountAmount" INTEGER NOT NULL DEFAULT 0,
"totalPrice" INTEGER NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'NEW',
"providerId" TEXT,
"companyId" TEXT,
"paymentMethodId" TEXT,
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'UNPAID',
"paidAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"cancelFee" INTEGER NOT NULL DEFAULT 0,
"cancelReason" TEXT,
"completedAt" TIMESTAMP(3),
"repeatOfId" TEXT,
"reminded24hAt" TIMESTAMP(3),
"reminded1hAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderStatusHistory" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"fromStatus" "OrderStatus",
"toStatus" "OrderStatus" NOT NULL,
"event" TEXT,
"actorType" "ActorType" NOT NULL,
"actorId" TEXT,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderStatusHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderOffer" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"providerId" TEXT,
"companyId" TEXT,
"status" "OfferStatus" NOT NULL DEFAULT 'OFFERED',
"expiresAt" TIMESTAMP(3),
"respondedAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderOffer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderChecklistItem" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"requiresPhoto" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"checkedAt" TIMESTAMP(3),
"fileId" TEXT,
CONSTRAINT "OrderChecklistItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderFile" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"kind" "FileKind" NOT NULL,
"storageKey" TEXT NOT NULL,
"mime" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"uploadedBy" "ActorType" NOT NULL,
"uploadedById" TEXT,
"deleteAfter" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileAccessLog" (
"id" TEXT NOT NULL,
"fileId" TEXT NOT NULL,
"actorType" "ActorType" NOT NULL,
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FileAccessLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Estimate" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"items" JSONB NOT NULL,
"total" INTEGER NOT NULL,
"status" "EstimateStatus" NOT NULL DEFAULT 'PENDING',
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Estimate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Message" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"fromType" "ActorType" NOT NULL,
"fromUserId" TEXT,
"text" TEXT,
"fileId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rating" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"providerId" TEXT,
"score" INTEGER NOT NULL,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LessonPackage" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"total" INTEGER NOT NULL,
"completed" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "LessonPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"status" "PaymentStatus" NOT NULL,
"externalId" TEXT,
"markedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OperatorTask" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"orderId" TEXT,
"status" "TaskStatus" NOT NULL DEFAULT 'OPEN',
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"doneAt" TIMESTAMP(3),
CONSTRAINT "OperatorTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Agency" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"commissionPct" INTEGER NOT NULL DEFAULT 10,
"contact" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Agency_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgencyCommission" (
"id" TEXT NOT NULL,
"agencyId" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"pct" INTEGER NOT NULL,
"amount" INTEGER NOT NULL,
"period" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgencyCommission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PromoCode" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"kind" "PromoKind" NOT NULL,
"agencyId" TEXT,
"discountPct" INTEGER,
"maxUses" INTEGER,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PromoCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PromoCodeUse" (
"id" TEXT NOT NULL,
"promoId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"orderId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PromoCodeUse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"plan" "SubscriptionPlan" NOT NULL DEFAULT 'BAREV_PLUS',
"source" "SubscriptionSource" NOT NULL,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"remindedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Partner" (
"id" TEXT NOT NULL,
"category" TEXT NOT NULL,
"name" TEXT NOT NULL,
"offer" JSONB NOT NULL,
"terms" JSONB,
"address" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Partner_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CardUsage" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"partnerId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CardUsage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payout" (
"id" TEXT NOT NULL,
"providerId" TEXT,
"companyId" TEXT,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"amount" INTEGER NOT NULL,
"status" "PayoutStatus" NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Payout_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminUser" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "AdminRole" NOT NULL DEFAULT 'OPERATOR',
"totpSecret" TEXT,
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminSession" (
"id" TEXT NOT NULL,
"adminUserId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"verified" BOOLEAN NOT NULL DEFAULT false,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"adminUserId" TEXT,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT,
"data" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Setting" (
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "StartToken" (
"id" TEXT NOT NULL,
"locale" "Locale",
"agency" TEXT,
"utm" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StartToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_telegramId_key" ON "User"("telegramId");
-- CreateIndex
CREATE UNIQUE INDEX "Client_userId_key" ON "Client"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Client_cardCode_key" ON "Client"("cardCode");
-- CreateIndex
CREATE INDEX "Client_agencyId_idx" ON "Client"("agencyId");
-- CreateIndex
CREATE UNIQUE INDEX "ProviderCompany_inviteCode_key" ON "ProviderCompany"("inviteCode");
-- CreateIndex
CREATE UNIQUE INDEX "CompanyDispatcher_companyId_userId_key" ON "CompanyDispatcher"("companyId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "Provider_userId_key" ON "Provider"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Provider_inviteCode_key" ON "Provider"("inviteCode");
-- CreateIndex
CREATE INDEX "Provider_role_active_idx" ON "Provider"("role", "active");
-- CreateIndex
CREATE UNIQUE INDEX "District_code_key" ON "District"("code");
-- CreateIndex
CREATE INDEX "ProviderSchedule_providerId_weekday_idx" ON "ProviderSchedule"("providerId", "weekday");
-- CreateIndex
CREATE UNIQUE INDEX "Category_code_key" ON "Category"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Service_code_key" ON "Service"("code");
-- CreateIndex
CREATE UNIQUE INDEX "ChecklistItem_serviceId_code_key" ON "ChecklistItem"("serviceId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_code_key" ON "PaymentMethod"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Order_number_key" ON "Order"("number");
-- CreateIndex
CREATE INDEX "Order_status_idx" ON "Order"("status");
-- CreateIndex
CREATE INDEX "Order_scheduledAt_idx" ON "Order"("scheduledAt");
-- CreateIndex
CREATE INDEX "Order_clientId_idx" ON "Order"("clientId");
-- CreateIndex
CREATE INDEX "Order_providerId_scheduledAt_idx" ON "Order"("providerId", "scheduledAt");
-- CreateIndex
CREATE INDEX "OrderStatusHistory_orderId_createdAt_idx" ON "OrderStatusHistory"("orderId", "createdAt");
-- CreateIndex
CREATE INDEX "OrderOffer_orderId_status_idx" ON "OrderOffer"("orderId", "status");
-- CreateIndex
CREATE INDEX "OrderOffer_providerId_status_idx" ON "OrderOffer"("providerId", "status");
-- CreateIndex
CREATE INDEX "OrderOffer_companyId_status_idx" ON "OrderOffer"("companyId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "OrderChecklistItem_fileId_key" ON "OrderChecklistItem"("fileId");
-- CreateIndex
CREATE UNIQUE INDEX "OrderChecklistItem_orderId_code_key" ON "OrderChecklistItem"("orderId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "OrderFile_storageKey_key" ON "OrderFile"("storageKey");
-- CreateIndex
CREATE INDEX "Message_orderId_createdAt_idx" ON "Message"("orderId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "Rating_orderId_key" ON "Rating"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "LessonPackage_orderId_key" ON "LessonPackage"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "Agency_code_key" ON "Agency"("code");
-- CreateIndex
CREATE UNIQUE INDEX "AgencyCommission_orderId_key" ON "AgencyCommission"("orderId");
-- CreateIndex
CREATE INDEX "AgencyCommission_agencyId_period_idx" ON "AgencyCommission"("agencyId", "period");
-- CreateIndex
CREATE UNIQUE INDEX "PromoCode_code_key" ON "PromoCode"("code");
-- CreateIndex
CREATE UNIQUE INDEX "PromoCodeUse_promoId_clientId_key" ON "PromoCodeUse"("promoId", "clientId");
-- CreateIndex
CREATE INDEX "Subscription_clientId_endsAt_idx" ON "Subscription"("clientId", "endsAt");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
-- CreateIndex
CREATE UNIQUE INDEX "AdminSession_tokenHash_key" ON "AdminSession"("tokenHash");
-- CreateIndex
CREATE INDEX "AuditLog_entity_entityId_idx" ON "AuditLog"("entity", "entityId");
-- AddForeignKey
ALTER TABLE "Client" ADD CONSTRAINT "Client_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Client" ADD CONSTRAINT "Client_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CompanyDispatcher" ADD CONSTRAINT "CompanyDispatcher_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CompanyDispatcher" ADD CONSTRAINT "CompanyDispatcher_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Provider" ADD CONSTRAINT "Provider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Provider" ADD CONSTRAINT "Provider_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProviderDistrict" ADD CONSTRAINT "ProviderDistrict_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProviderDistrict" ADD CONSTRAINT "ProviderDistrict_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProviderSchedule" ADD CONSTRAINT "ProviderSchedule_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_paymentMethodId_fkey" FOREIGN KEY ("paymentMethodId") REFERENCES "PaymentMethod"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderStatusHistory" ADD CONSTRAINT "OrderStatusHistory_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderOffer" ADD CONSTRAINT "OrderOffer_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderOffer" ADD CONSTRAINT "OrderOffer_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderOffer" ADD CONSTRAINT "OrderOffer_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderChecklistItem" ADD CONSTRAINT "OrderChecklistItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderChecklistItem" ADD CONSTRAINT "OrderChecklistItem_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "OrderFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderFile" ADD CONSTRAINT "OrderFile_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileAccessLog" ADD CONSTRAINT "FileAccessLog_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "OrderFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Estimate" ADD CONSTRAINT "Estimate_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LessonPackage" ADD CONSTRAINT "LessonPackage_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OperatorTask" ADD CONSTRAINT "OperatorTask_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgencyCommission" ADD CONSTRAINT "AgencyCommission_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgencyCommission" ADD CONSTRAINT "AgencyCommission_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromoCode" ADD CONSTRAINT "PromoCode_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromoCodeUse" ADD CONSTRAINT "PromoCodeUse_promoId_fkey" FOREIGN KEY ("promoId") REFERENCES "PromoCode"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromoCodeUse" ADD CONSTRAINT "PromoCodeUse_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CardUsage" ADD CONSTRAINT "CardUsage_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CardUsage" ADD CONSTRAINT "CardUsage_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AdminSession" ADD CONSTRAINT "AdminSession_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+749
View File
@@ -0,0 +1,749 @@
// Barev Club — модель данных (PRD, раздел 7).
// Схема заложена сразу целиком; часть сущностей используется начиная с Этапов 2–3.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
}
datasource db {
provider = "postgresql"
}
// ───────────── Справочные перечисления ─────────────
enum Locale {
ru
en
zh
hy
es
}
/// Направление (раздел 2)
enum CategoryCode {
HOME
DOCS
LEARNING
PERKS
}
/// Сценарий заказа — определяет state machine и шаги исполнителя
enum ServiceFlow {
CLEANING
REPAIR
TRANSFER
LANGUAGE
CAREER
DOCUMENTS
}
enum ProviderRole {
CLEANER
HANDYMAN
DRIVER
TEACHER
CAREER_CONSULTANT
DOCS_AGENT
}
/// Все статусы всех сценариев. Допустимые переходы — в packages/core (flows).
enum OrderStatus {
NEW
ASSIGNED
EN_ROUTE
CLEANING
ON_SITE
ESTIMATE_APPROVED
REPAIRING
DRIVER_WAITING
RIDE_IN_PROGRESS
ENROLLED
LESSON_SCHEDULED
LESSON_IN_PROGRESS
LESSON_DONE
CONSULTANT_ASSIGNED
RESUME_RECEIVED
MEETING
PLAN_SENT
DOCS_REVIEW
NEEDS_MORE_DOCS
SUBMITTED
DONE
COMPLETED
CANCELLED
}
enum ActorType {
CLIENT
PROVIDER
DISPATCHER
ADMIN
SYSTEM
}
enum OfferStatus {
OFFERED
ACCEPTED
DECLINED
EXPIRED
REVOKED
}
enum PaymentStatus {
UNPAID
PAID
REFUNDED
}
enum RateType {
FIXED
PERCENT
}
enum AdminRole {
OWNER
OPERATOR
}
enum SubscriptionPlan {
BAREV_PLUS
}
enum SubscriptionSource {
AGENCY_GIFT
MANUAL
PAID
}
enum SubscriptionStatus {
ACTIVE
EXPIRED
CANCELLED
}
enum PromoKind {
AGENCY_GIFT
DISCOUNT
}
enum FileKind {
PHOTO_REPORT
CLIENT_PHOTO
DOCUMENT_SCAN
RESUME
OTHER
}
enum EstimateStatus {
PENDING
ACCEPTED
REJECTED
}
enum TaskStatus {
OPEN
DONE
}
enum PayoutStatus {
DRAFT
APPROVED
PAID
}
// ───────────── Пользователи ─────────────
/// Пользователь Telegram. Один и тот же человек может быть клиентом и исполнителем.
model User {
id String @id @default(cuid())
telegramId BigInt @unique
username String?
firstName String?
lastName String?
phone String?
locale Locale @default(ru)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client?
provider Provider?
dispatcher CompanyDispatcher[]
messages Message[]
}
model Client {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
consentAt DateTime?
consentVersion String?
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
agencyAttachedAt DateTime?
/// источник: deeplink | manual_code | organic
source String @default("organic")
utm Json?
/// подарочный месяц уже использован (один раз на клиента)
giftUsedAt DateTime?
/// короткий код клубной карты (Этап 3)
cardCode String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
subscriptions Subscription[]
ratings Rating[]
cardUsages CardUsage[]
promoUses PromoCodeUse[]
@@index([agencyId])
}
// ───────────── Исполнители ─────────────
model ProviderCompany {
id String @id @default(cuid())
name String
/// какие роли исполнителей есть в компании (какие услуги она берёт)
roles ProviderRole[]
/// договорная ставка компании
rateType RateType @default(PERCENT)
rateValue Int @default(70)
contactPhone String?
inviteCode String @unique
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employees Provider[]
dispatchers CompanyDispatcher[]
orders Order[]
offers OrderOffer[]
payouts Payout[]
}
model CompanyDispatcher {
id String @id @default(cuid())
companyId String
company ProviderCompany @relation(fields: [companyId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@unique([companyId, userId])
}
model Provider {
id String @id @default(cuid())
/// заполняется, когда исполнитель открыл бота по инвайт-коду
userId String? @unique
user User? @relation(fields: [userId], references: [id])
name String
phone String?
role ProviderRole
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
languages Locale[]
/// ставка фрилансера; для сотрудников компании не используется
rateType RateType @default(PERCENT)
rateValue Int @default(70)
ratingSum Int @default(0)
ratingCount Int @default(0)
onShift Boolean @default(false)
active Boolean @default(true)
inviteCode String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
districts ProviderDistrict[]
schedule ProviderSchedule[]
orders Order[]
offers OrderOffer[]
ratings Rating[]
payouts Payout[]
@@index([role, active])
}
model District {
id String @id @default(cuid())
code String @unique
sortOrder Int @default(0)
providers ProviderDistrict[]
orders Order[]
}
model ProviderDistrict {
providerId String
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
districtId String
district District @relation(fields: [districtId], references: [id], onDelete: Cascade)
@@id([providerId, districtId])
}
/// Недельный график: 0 = понедельник … 6 = воскресенье, минуты от полуночи по Asia/Yerevan
model ProviderSchedule {
id String @id @default(cuid())
providerId String
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
weekday Int
startMinute Int
endMinute Int
@@index([providerId, weekday])
}
// ───────────── Каталог ─────────────
model Category {
id String @id @default(cuid())
code CategoryCode @unique
sortOrder Int @default(0)
/// скидка Barev+ на направление, %
barevPlusDiscountPct Int @default(0)
active Boolean @default(true)
services Service[]
}
model Service {
id String @id @default(cuid())
/// код услуги; тексты берутся из i18n по ключу services.<code>.*
code String @unique
categoryId String
category Category @relation(fields: [categoryId], references: [id])
flow ServiceFlow
providerRole ProviderRole
/// правила цены (см. packages/core/src/pricing)
pricing Json
/// описание параметров формы заказа (см. packages/core/src/params)
paramsSchema Json
/// окно записи и шаг слотов (см. packages/core/src/slots)
slotConfig Json
/// удержание при поздней отмене, %
lateCancelFeePct Int @default(30)
isOnline Boolean @default(false)
active Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
checklist ChecklistItem[]
orders Order[]
}
/// Шаблон пункта чек-листа услуги
model ChecklistItem {
id String @id @default(cuid())
serviceId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
/// код пункта; подпись — i18n checklist.<code>
code String
requiresPhoto Boolean @default(false)
sortOrder Int @default(0)
@@unique([serviceId, code])
}
model PaymentMethod {
id String @id @default(cuid())
code String @unique
/// { ru, en, zh, hy, es }
name Json
active Boolean @default(true)
sortOrder Int @default(0)
orders Order[]
}
// ───────────── Заказы ─────────────
model Order {
id String @id @default(cuid())
number Int @unique @default(autoincrement())
clientId String
client Client @relation(fields: [clientId], references: [id])
serviceId String
service Service @relation(fields: [serviceId], references: [id])
params Json
scheduledAt DateTime
durationMinutes Int
isOnline Boolean @default(false)
addressText String?
districtId String?
district District? @relation(fields: [districtId], references: [id])
lat Float?
lng Float?
note String?
/// язык клиента на момент заказа
locale Locale
basePrice Int
discountPct Int @default(0)
discountAmount Int @default(0)
totalPrice Int
status OrderStatus @default(NEW)
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
/// заказ передан компании; пока providerId пуст — «Назначен на компанию»
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
paymentMethodId String?
paymentMethod PaymentMethod? @relation(fields: [paymentMethodId], references: [id])
paymentStatus PaymentStatus @default(UNPAID)
paidAt DateTime?
cancelledAt DateTime?
cancelFee Int @default(0)
cancelReason String?
completedAt DateTime?
repeatOfId String?
reminded24hAt DateTime?
reminded1hAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
history OrderStatusHistory[]
checklist OrderChecklistItem[]
offers OrderOffer[]
files OrderFile[]
messages Message[]
estimates Estimate[]
rating Rating?
lessons LessonPackage?
payments Payment[]
commission AgencyCommission?
tasks OperatorTask[]
@@index([status])
@@index([scheduledAt])
@@index([clientId])
@@index([providerId, scheduledAt])
}
model OrderStatusHistory {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
fromStatus OrderStatus?
toStatus OrderStatus
/// событие без смены статуса (например «Я на месте») или код действия
event String?
actorType ActorType
actorId String?
comment String?
createdAt DateTime @default(now())
@@index([orderId, createdAt])
}
/// Предложение заказа исполнителю или компании (принять / отклонить с таймаутом)
model OrderOffer {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
status OfferStatus @default(OFFERED)
expiresAt DateTime?
respondedAt DateTime?
createdById String?
createdAt DateTime @default(now())
@@index([orderId, status])
@@index([providerId, status])
@@index([companyId, status])
}
model OrderChecklistItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
code String
requiresPhoto Boolean @default(false)
sortOrder Int @default(0)
checkedAt DateTime?
fileId String? @unique
file OrderFile? @relation(fields: [fileId], references: [id])
@@unique([orderId, code])
}
model OrderFile {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
kind FileKind
storageKey String @unique
mime String
size Int
uploadedBy ActorType
uploadedById String?
/// дата автоудаления (документы — через N дней после завершения)
deleteAfter DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
checklistItem OrderChecklistItem?
accessLog FileAccessLog[]
}
model FileAccessLog {
id String @id @default(cuid())
fileId String
file OrderFile @relation(fields: [fileId], references: [id], onDelete: Cascade)
actorType ActorType
actorId String?
createdAt DateTime @default(now())
}
/// Смета ремонта (Этап 2)
model Estimate {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
items Json
total Int
status EstimateStatus @default(PENDING)
respondedAt DateTime?
createdAt DateTime @default(now())
}
/// Чат клиент ↔ исполнитель через бота (Этап 2)
model Message {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
fromType ActorType
fromUserId String?
fromUser User? @relation(fields: [fromUserId], references: [id])
text String?
fileId String?
createdAt DateTime @default(now())
@@index([orderId, createdAt])
}
model Rating {
id String @id @default(cuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
clientId String
client Client @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
score Int
comment String?
createdAt DateTime @default(now())
}
/// Пакет уроков (Этап 2)
model LessonPackage {
id String @id @default(cuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
total Int
completed Int @default(0)
}
model Payment {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
/// manual | <будущий провайдер>
provider String
amount Int
status PaymentStatus
externalId String?
markedById String?
createdAt DateTime @default(now())
}
model OperatorTask {
id String @id @default(cuid())
type String
orderId String?
order Order? @relation(fields: [orderId], references: [id], onDelete: Cascade)
status TaskStatus @default(OPEN)
note String?
createdAt DateTime @default(now())
doneAt DateTime?
}
// ───────────── Агентства, промокоды, членство ─────────────
model Agency {
id String @id @default(cuid())
/// код для deep-link и ручного ввода (латиница/цифры)
code String @unique
name String
/// вознаграждение, % от оплаченных заказов клиентов агентства
commissionPct Int @default(10)
contact String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clients Client[]
commissions AgencyCommission[]
promoCodes PromoCode[]
}
model AgencyCommission {
id String @id @default(cuid())
agencyId String
agency Agency @relation(fields: [agencyId], references: [id])
orderId String @unique
order Order @relation(fields: [orderId], references: [id])
pct Int
amount Int
/// YYYY-MM
period String
createdAt DateTime @default(now())
@@index([agencyId, period])
}
model PromoCode {
id String @id @default(cuid())
code String @unique
kind PromoKind
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
discountPct Int?
maxUses Int?
usedCount Int @default(0)
validFrom DateTime?
validUntil DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
uses PromoCodeUse[]
}
model PromoCodeUse {
id String @id @default(cuid())
promoId String
promo PromoCode @relation(fields: [promoId], references: [id])
clientId String
client Client @relation(fields: [clientId], references: [id])
orderId String?
createdAt DateTime @default(now())
@@unique([promoId, clientId])
}
model Subscription {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
plan SubscriptionPlan @default(BAREV_PLUS)
source SubscriptionSource
status SubscriptionStatus @default(ACTIVE)
startsAt DateTime
endsAt DateTime
remindedAt DateTime?
createdAt DateTime @default(now())
@@index([clientId, endsAt])
}
// ───────────── Партнёры и клубная карта (Этап 3) ─────────────
model Partner {
id String @id @default(cuid())
/// cafe | restaurant | other
category String
name String
/// { ru, en, ... }
offer Json
terms Json?
address String?
active Boolean @default(true)
createdAt DateTime @default(now())
usages CardUsage[]
}
model CardUsage {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
partnerId String?
partner Partner? @relation(fields: [partnerId], references: [id])
createdAt DateTime @default(now())
}
// ───────────── Финансы ─────────────
model Payout {
id String @id @default(cuid())
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
periodStart DateTime
periodEnd DateTime
amount Int
status PayoutStatus @default(DRAFT)
createdAt DateTime @default(now())
}
// ───────────── Админка ─────────────
model AdminUser {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
role AdminRole @default(OPERATOR)
totpSecret String?
totpEnabled Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
sessions AdminSession[]
audit AuditLog[]
}
model AdminSession {
id String @id @default(cuid())
adminUserId String
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
/// sha256 токена сессии
tokenHash String @unique
/// сессия прошла 2FA
verified Boolean @default(false)
expiresAt DateTime
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(cuid())
adminUserId String?
adminUser AdminUser? @relation(fields: [adminUserId], references: [id])
action String
entity String
entityId String?
data Json?
createdAt DateTime @default(now())
@@index([entity, entityId])
}
/// Настройки (ключ → значение). Список ключей и дефолты — packages/core/src/settings.ts
model Setting {
key String @id
value Json
updatedAt DateTime @updatedAt
}
/// Короткий токен для deep-link с лендинга, когда payload не влезает в 64 символа (UTM)
model StartToken {
id String @id
locale Locale?
agency String?
utm Json?
createdAt DateTime @default(now())
}
+21
View File
@@ -0,0 +1,21 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient, type Prisma } from './generated/prisma/client.ts';
export * from './generated/prisma/client.ts';
export type Db = PrismaClient;
/** Клиент внутри транзакции */
export type Tx = Prisma.TransactionClient;
let instance: PrismaClient | undefined;
export function createDb(url = process.env.DATABASE_URL): PrismaClient {
if (!url) throw new Error('DATABASE_URL is not set');
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
}
/** Общий клиент процесса */
export function getDb(): PrismaClient {
instance ??= createDb();
return instance;
}
+351
View File
@@ -0,0 +1,351 @@
/**
* Seed для локальной разработки и staging: справочники, исполнители, компания, агентства,
* тестовые клиенты и заказы во всех колонках канбана. Полностью пересоздаёт данные.
*
* pnpm db:seed
*/
import path from 'node:path';
import { config } from 'dotenv';
import { hash } from '@node-rs/argon2';
import {
CATALOG,
DEFAULT_CATEGORY_DISCOUNTS,
DEFAULT_SETTINGS,
YEREVAN_DISTRICTS,
agencyCommission,
fromYerevan,
quote,
toYerevan,
yerevanMonthKey,
type CategoryCode,
} from '@barev/core';
import { createDb, type Locale, type OrderStatus } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
if (process.env.NODE_ENV === 'production' && process.env.SEED_FORCE !== '1') {
console.error('Seed пересоздаёт данные. В production запустите с SEED_FORCE=1, если уверены.');
process.exit(1);
}
const db = createDb();
const HOUR = 3600_000;
/** Telegram ID тестовых пользователей — ими можно войти в Mini App через pnpm dev:initdata */
export const SEED_TG = {
clientAnna: 100000001n,
clientJohn: 100000002n,
clientLi: 100000003n,
cleanerMaria: 200000001n,
cleanerAni: 200000002n,
cleanerElena: 200000003n,
companyLusine: 200000011n,
driverArman: 200000021n,
dispatcherKaren: 300000001n,
} as const;
async function wipe() {
const tables = await db.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`;
const list = tables.map((t) => `"public"."${t.tablename}"`).join(', ');
if (list) await db.$executeRawUnsafe(`TRUNCATE ${list} RESTART IDENTITY CASCADE`);
}
/** Ереванское время: сегодня + dayOffset, hh:mm */
function at(dayOffset: number, hour: number, minute = 0): Date {
const p = toYerevan(new Date(Date.now() + dayOffset * 24 * HOUR));
return fromYerevan(p.year, p.month, p.day, hour * 60 + minute);
}
async function main() {
await wipe();
// ── настройки ──
await db.setting.createMany({ data: Object.entries(DEFAULT_SETTINGS).map(([key, value]) => ({ key, value })) });
// ── районы ──
await db.district.createMany({ data: YEREVAN_DISTRICTS.map((code, i) => ({ code, sortOrder: i })) });
const districts = Object.fromEntries((await db.district.findMany()).map((d) => [d.code, d.id])) as Record<(typeof YEREVAN_DISTRICTS)[number], string>;
// ── направления и услуги ──
const categoryOrder: CategoryCode[] = ['HOME', 'DOCS', 'LEARNING', 'PERKS'];
const categories: Record<string, string> = {};
for (const [i, code] of categoryOrder.entries()) {
const c = await db.category.create({ data: { code, sortOrder: i, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[code] } });
categories[code] = c.id;
}
const services: Record<string, { id: string }> = {};
for (const [i, s] of CATALOG.entries()) {
services[s.code] = await db.service.create({
data: {
code: s.code,
categoryId: categories[s.category]!,
flow: s.flow,
providerRole: s.providerRole,
pricing: s.pricing,
paramsSchema: s.paramsSchema,
slotConfig: s.slotConfig,
isOnline: s.isOnline,
lateCancelFeePct: 30,
active: s.stage === 1,
sortOrder: i,
checklist: { create: s.checklist.map((c, j) => ({ code: c.code, requiresPhoto: !!c.requiresPhoto, sortOrder: j })) },
},
});
}
// ── способы оплаты (PAY-1) ──
const cash = await db.paymentMethod.create({
data: { code: 'cash', sortOrder: 0, name: { ru: 'Наличными исполнителю', en: 'Cash to the specialist', zh: '现金支付给服务人员', hy: 'Կանխիկ՝ կատարողին', es: 'En efectivo al especialista' } },
});
await db.paymentMethod.create({
data: { code: 'transfer', sortOrder: 1, name: { ru: 'Перевод на карту', en: 'Card transfer', zh: '银行卡转账', hy: 'Փոխանցում քարտին', es: 'Transferencia a tarjeta' } },
});
// ── агентства ──
const domik = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik Realty', commissionPct: 10, contact: '+374 00 000000' } });
await db.agency.create({ data: { code: 'YERHOME', name: 'Yerevan Home', commissionPct: 10 } });
await db.agency.create({ data: { code: 'OLDAGENCY', name: 'Закрытое агентство', active: false } });
// ── админка ──
const owner = await db.adminUser.create({
data: {
email: process.env.SEED_OWNER_EMAIL ?? 'owner@barev.local',
name: 'Владелец',
role: 'OWNER',
passwordHash: await hash(process.env.SEED_OWNER_PASSWORD ?? 'barev-owner-123'),
},
});
await db.adminUser.create({
data: { email: 'operator@barev.local', name: 'Оператор', role: 'OPERATOR', passwordHash: await hash('barev-operator-123') },
});
// ── пользователи Telegram ──
const user = (telegramId: bigint, firstName: string, locale: Locale, lastName?: string) =>
db.user.create({ data: { telegramId, firstName, lastName, locale, username: `seed_${telegramId}` } });
// ── исполнители ──
const workweek = [0, 1, 2, 3, 4, 5].map((weekday) => ({ weekday, startMinute: 9 * 60, endMinute: 20 * 60 }));
const everyday = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 8 * 60, endMinute: 21 * 60 }));
async function provider(p: {
tg?: bigint;
name: string;
role: 'CLEANER' | 'DRIVER';
languages: Locale[];
districts: (keyof typeof districts)[];
companyId?: string;
rate?: { type: 'FIXED' | 'PERCENT'; value: number };
onShift?: boolean;
rating?: [number, number];
schedule?: typeof workweek;
invite: string;
}) {
const u = p.tg ? await user(p.tg, p.name.split(' ')[0]!, p.languages[0]!) : null;
return db.provider.create({
data: {
userId: u?.id,
name: p.name,
role: p.role,
companyId: p.companyId,
languages: p.languages,
rateType: p.rate?.type ?? 'PERCENT',
rateValue: p.rate?.value ?? 70,
onShift: p.onShift ?? true,
ratingSum: p.rating?.[0] ?? 0,
ratingCount: p.rating?.[1] ?? 0,
inviteCode: p.invite,
districts: { create: p.districts.map((d) => ({ districtId: districts[d] })) },
schedule: { create: p.schedule ?? workweek },
},
});
}
const maria = await provider({ tg: SEED_TG.cleanerMaria, name: 'Мария Петросян', role: 'CLEANER', languages: ['ru', 'hy', 'en'], districts: ['kentron', 'arabkir'], rating: [47, 10], invite: 'PRO-MARIA' });
const ani = await provider({ tg: SEED_TG.cleanerAni, name: 'Ани Саргсян', role: 'CLEANER', languages: ['hy', 'ru'], districts: ['shengavit', 'erebuni', 'malatia_sebastia'], rate: { type: 'FIXED', value: 9000 }, rating: [22, 5], invite: 'PRO-ANI' });
const elena = await provider({ tg: SEED_TG.cleanerElena, name: 'Elena Gómez', role: 'CLEANER', languages: ['es', 'en', 'ru'], districts: ['kentron', 'arabkir', 'nor_nork'], schedule: everyday, invite: 'PRO-ELENA' });
await provider({ tg: SEED_TG.driverArman, name: 'Арман Мкртчян', role: 'DRIVER', languages: ['ru', 'hy', 'en'], districts: ['kentron'], schedule: everyday, rate: { type: 'FIXED', value: 7000 }, invite: 'PRO-ARMAN' });
const company = await db.providerCompany.create({
data: { name: 'Чисто Ереван', roles: ['CLEANER'], rateType: 'PERCENT', rateValue: 65, contactPhone: '+374 00 111111', inviteCode: 'CO-CHISTO' },
});
const karen = await user(SEED_TG.dispatcherKaren, 'Карен', 'ru');
await db.companyDispatcher.create({ data: { companyId: company.id, userId: karen.id } });
const lusine = await provider({ tg: SEED_TG.companyLusine, name: 'Лусине Акопян', role: 'CLEANER', languages: ['hy', 'ru'], districts: ['kentron', 'nor_nork', 'avan'], companyId: company.id, invite: 'PRO-LUSINE' });
await provider({ name: 'Гаяне Арутюнян', role: 'CLEANER', languages: ['ru', 'en'], districts: ['arabkir', 'ajapnyak', 'davtashen'], companyId: company.id, onShift: false, invite: 'PRO-GAYANE' });
// ── клиенты ──
const now = new Date();
async function client(tg: bigint, name: string, locale: Locale, opts: { agency?: string; gift?: boolean; card: string; attachedMonthsAgo?: number }) {
const u = await user(tg, name, locale);
const attachedAt = opts.agency ? new Date(now.getTime() - (opts.attachedMonthsAgo ?? 0) * 30 * 24 * HOUR - HOUR) : undefined;
const c = await db.client.create({
data: {
userId: u.id,
consentAt: now,
consentVersion: DEFAULT_SETTINGS['consent.version'],
agencyId: opts.agency,
agencyAttachedAt: attachedAt,
source: opts.agency ? 'deeplink' : 'organic',
utm: opts.agency ? { utm_source: 'qr', utm_campaign: 'lease' } : undefined,
cardCode: opts.card,
giftUsedAt: opts.gift ? attachedAt : undefined,
},
});
if (opts.gift) {
await db.subscription.create({
data: { clientId: c.id, source: 'AGENCY_GIFT', startsAt: attachedAt!, endsAt: new Date(attachedAt!.getTime() + 30 * 24 * HOUR) },
});
}
return c;
}
const anna = await client(SEED_TG.clientAnna, 'Анна', 'ru', { agency: domik.id, gift: true, card: 'ANNA2026' });
const john = await client(SEED_TG.clientJohn, 'John', 'en', { card: 'JOHN2026' });
const li = await client(SEED_TG.clientLi, '李明', 'zh', { agency: domik.id, gift: false, card: 'LIMING26' });
// ── заказы клининга ──
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
const checklistCodes = cleaning.checklist;
async function order(o: {
client: { id: string };
locale: Locale;
when: Date;
status: OrderStatus;
params: { cleaningType: string; rooms: number };
district: keyof typeof districts;
address: string;
providerId?: string;
companyId?: string;
discount: boolean;
checked?: number;
paid?: boolean;
note?: string;
history: [OrderStatus | null, OrderStatus, 'CLIENT' | 'SYSTEM' | 'PROVIDER' | 'ADMIN' | 'DISPATCHER', string?][];
}) {
const q = quote(cleaning.pricing, o.params, o.discount ? DEFAULT_CATEGORY_DISCOUNTS.HOME : 0);
const created = await db.order.create({
data: {
clientId: o.client.id,
serviceId: services.cleaning!.id,
params: o.params,
scheduledAt: o.when,
durationMinutes: q.durationMinutes,
addressText: o.address,
districtId: districts[o.district],
note: o.note,
locale: o.locale,
basePrice: q.basePrice,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
status: o.status,
providerId: o.providerId,
companyId: o.companyId,
paymentMethodId: cash.id,
paymentStatus: o.paid ? 'PAID' : 'UNPAID',
paidAt: o.paid ? o.when : undefined,
completedAt: o.status === 'DONE' ? new Date(o.when.getTime() + q.durationMinutes * 60_000) : undefined,
cancelledAt: o.status === 'CANCELLED' ? now : undefined,
checklist: {
create: checklistCodes.map((c, i) => ({
code: c.code,
requiresPhoto: !!c.requiresPhoto,
sortOrder: i,
checkedAt: i < (o.checked ?? 0) && !c.requiresPhoto ? now : undefined,
})),
},
history: {
create: o.history.map(([from, to, actor, event], i) => ({
fromStatus: from,
toStatus: to,
actorType: actor,
event,
createdAt: new Date(now.getTime() - (o.history.length - i) * 600_000),
})),
},
},
});
return created;
}
// Новый, без исполнителя — колонка «Новые»
await order({
client: anna, locale: 'ru', when: at(1, 11), status: 'NEW', params: { cleaningType: 'general', rooms: 2 },
district: 'kentron', address: 'ул. Пушкина, 10, кв. 15', discount: true, note: 'Есть кот, он дружелюбный',
history: [[null, 'NEW', 'CLIENT']],
});
// Предложен Марии, ждёт подтверждения — колонка «Назначены»
const offered = await order({
client: john, locale: 'en', when: at(1, 15), status: 'NEW', params: { cleaningType: 'regular', rooms: 3 },
district: 'arabkir', address: 'Komitas Ave 25, apt 7', discount: false, history: [[null, 'NEW', 'CLIENT']],
});
await db.orderOffer.create({ data: { orderId: offered.id, providerId: maria.id, status: 'OFFERED', expiresAt: new Date(now.getTime() + 15 * 60_000), createdById: owner.id } });
// Передан компании — «Назначен на компанию»
const toCompany = await order({
client: li, locale: 'zh', when: at(2, 12), status: 'NEW', params: { cleaningType: 'renovation', rooms: 2 },
district: 'nor_nork', address: 'Gai Ave 3/1, apt 40', discount: false, history: [[null, 'NEW', 'CLIENT']],
});
await db.order.update({ where: { id: toCompany.id }, data: { companyId: company.id } });
await db.orderOffer.create({ data: { orderId: toCompany.id, companyId: company.id, status: 'OFFERED', createdById: owner.id } });
// Назначен Марии на сегодня — для демонстрации шагов исполнителя
const todayHour = Math.min(Math.max(toYerevan(now).hour + 2, 9), 17);
await order({
client: anna, locale: 'ru', when: at(0, todayHour), status: 'ASSIGNED', params: { cleaningType: 'regular', rooms: 2 },
district: 'kentron', address: 'ул. Абовяна, 22, кв. 5', providerId: maria.id, discount: true, note: 'Домофон 5#',
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted']],
});
// Идёт уборка у Елены — колонка «В работе»
await order({
client: li, locale: 'zh', when: at(0, Math.max(9, todayHour - 2)), status: 'CLEANING', params: { cleaningType: 'regular', rooms: 1 },
district: 'arabkir', address: 'Baghramyan Ave 50, apt 12', providerId: elena.id, discount: false, checked: 2,
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted'], ['ASSIGNED', 'EN_ROUTE', 'PROVIDER', 'depart'], ['EN_ROUTE', 'EN_ROUTE', 'PROVIDER', 'arrived'], ['EN_ROUTE', 'CLEANING', 'PROVIDER', 'startCleaning']],
});
// Назначен Ани на послезавтра
await order({
client: john, locale: 'en', when: at(2, 10), status: 'ASSIGNED', params: { cleaningType: 'general', rooms: 1 },
district: 'shengavit', address: 'Arshakunyats Ave 8', providerId: ani.id, discount: false,
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted']],
});
// Сотрудник компании — назначен диспетчером
await order({
client: anna, locale: 'ru', when: at(3, 14), status: 'ASSIGNED', params: { cleaningType: 'regular', rooms: 4 },
district: 'kentron', address: 'ул. Туманяна, 5', providerId: lusine.id, companyId: company.id, discount: true,
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'DISPATCHER', 'dispatcherAssigned']],
});
// Готов и оплачен — с вознаграждением агентству
const done = await order({
client: anna, locale: 'ru', when: at(-3, 10), status: 'DONE', params: { cleaningType: 'general', rooms: 2 },
district: 'kentron', address: 'ул. Пушкина, 10, кв. 15', providerId: maria.id, discount: true, checked: 5, paid: true,
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted'], ['ASSIGNED', 'EN_ROUTE', 'PROVIDER', 'depart'], ['EN_ROUTE', 'CLEANING', 'PROVIDER', 'startCleaning'], ['CLEANING', 'DONE', 'PROVIDER', 'finishCleaning']],
});
await db.payment.create({ data: { orderId: done.id, provider: 'manual', amount: done.totalPrice, status: 'PAID', markedById: owner.id } });
await db.agencyCommission.create({
data: { agencyId: domik.id, orderId: done.id, pct: domik.commissionPct, amount: agencyCommission(done.totalPrice, domik.commissionPct), period: yerevanMonthKey(done.scheduledAt) },
});
// Отменён клиентом
await order({
client: john, locale: 'en', when: at(4, 9), status: 'CANCELLED', params: { cleaningType: 'regular', rooms: 1 },
district: 'kentron', address: 'Amiryan St 4', discount: false, history: [[null, 'NEW', 'CLIENT'], ['NEW', 'CANCELLED', 'CLIENT']],
});
// ── партнёры (Этап 3, для каталога) ──
await db.partner.createMany({
data: [
{ category: 'cafe', name: 'Coffee Lab', offer: { ru: '−10% на кофе', en: '−10% on coffee' }, address: 'Abovyan St 3' },
{ category: 'restaurant', name: 'Lavash', offer: { ru: 'Десерт в подарок', en: 'Free dessert' }, address: 'Tumanyan St 21' },
{ category: 'other', name: 'Yerevan Books', offer: { ru: '−15% на книги', en: '−15% on books' }, address: 'Nalbandyan St 7' },
],
});
console.log('Seed готов.');
console.log(` Админка: ${owner.email} / ${process.env.SEED_OWNER_PASSWORD ?? 'barev-owner-123'} (владелец), operator@barev.local / barev-operator-123`);
console.log(' Агентства: DOMIK, YERHOME (OLDAGENCY — неактивно)');
console.log(' Инвайты исполнителей: PRO-GAYANE (не привязан), компания CO-CHISTO');
console.log(' Telegram ID для pnpm dev:initdata:', Object.entries(SEED_TG).map(([k, v]) => `${k}=${v}`).join(', '));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => db.$disconnect());
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src", "prisma.config.ts"] }
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@barev/services",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@barev/core": "workspace:*",
"@barev/db": "workspace:*"
},
"devDependencies": {
"dotenv": "^17.0.0",
"vitest": "^4.1.0",
"pg": "^8.16.0",
"@types/pg": "^8.15.0"
}
}
+279
View File
@@ -0,0 +1,279 @@
import { averageRating, getFlow, isProviderFree, rankCandidates, startOfYerevanDay, type Candidate, type Locale } from '@barev/core';
import type { Tx } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
import { transitionOrder } from './orders.ts';
const DAY_MS = 24 * 3600_000;
function interval(o: { scheduledAt: Date; durationMinutes: number }) {
return { start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) };
}
/**
* ADM-3: подсказка исполнителя. Частные исполнители и компании нужной роли;
* компания «свободна», если свободен хотя бы один её сотрудник.
*/
export async function assignmentCandidates(ctx: Ctx, orderId: string) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } });
const slot = interval(order);
const dayStart = startOfYerevanDay(order.scheduledAt);
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const role = order.service.providerRole;
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.providerCompany.findMany({ where: { active: true, roles: { has: role } } }),
]);
const availById = new Map(availability.map((a) => [a.id, a]));
const loadOf = (id: string) => availById.get(id)?.busy.length ?? 0;
const freeOf = (id: string) => {
const a = availById.get(id);
return !!a && isProviderFree(a, slot);
};
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,
}));
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 ratings = staff.filter((p) => p.ratingCount > 0);
candidates.push({
kind: 'company',
id: c.id,
name: c.name,
languages: [...new Set(free.flatMap((p) => p.languages as Locale[]))],
districtIds: [...new Set(free.flatMap((p) => p.districts.map((d) => d.districtId)))],
load: Math.min(...staff.map((p) => loadOf(p.id))),
rating: ratings.length
? averageRating(
ratings.reduce((s, p) => s + p.ratingSum, 0),
ratings.reduce((s, p) => s + p.ratingCount, 0),
)
: null,
free: free.length > 0,
onShift: free.some((p) => p.onShift),
});
}
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 };
}
async function revokeActiveOffers(tx: Tx, orderId: string) {
await tx.orderOffer.updateMany({ where: { orderId, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: new Date() } });
}
/** Назначение из админки: частному исполнителю (с подтверждением) или компании (PRO-5, PRO-7) */
export async function offerOrder(
ctx: Ctx,
input: { orderId: string; adminId: string; target: { kind: 'provider' | 'company'; id: string } },
) {
const settings = await getSettings(ctx.db);
const timeout = settings['offer.acceptTimeoutMinutes'];
const events: DomainEvent[] = [];
const offer = 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: { service: true } });
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial || order.providerId) throw new DomainError('ALREADY_ASSIGNED');
if (input.target.kind === 'provider') {
const provider = await tx.provider.findUnique({ where: { id: input.target.id } });
if (!provider || !provider.active || provider.role !== order.service.providerRole) throw new DomainError('NOT_FOUND');
const [avail] = await loadAvailability(tx, provider.role, order.scheduledAt, interval(order).end, { excludeOrderId: order.id }).then((a) =>
a.filter((x) => x.id === provider.id),
);
if (!avail || !isProviderFree(avail, interval(order))) throw new DomainError('PROVIDER_BUSY');
await revokeActiveOffers(tx, order.id);
await tx.order.update({ where: { id: order.id }, data: { companyId: provider.companyId } });
const offer = await tx.orderOffer.create({
data: {
orderId: order.id,
providerId: provider.id,
status: 'OFFERED',
expiresAt: new Date(ctx.now().getTime() + timeout * 60_000),
createdById: input.adminId,
},
});
events.push({ type: 'offer.created', offerId: offer.id });
return offer;
}
const company = await tx.providerCompany.findUnique({ where: { id: input.target.id } });
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 } });
// предложение компании без таймаута — заказ «Назначен на компанию», пока диспетчер не выберет сотрудника
const offer = await tx.orderOffer.create({
data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'assignedToCompany', actorType: 'ADMIN', actorId: input.adminId, comment: company.name },
});
events.push({ type: 'company.order', orderId: order.id, companyId: company.id });
return offer;
});
for (const e of events) await ctx.notify.emit(e);
return offer;
}
/** Принять заказ исполнителем: проставляется исполнитель, статус → «Назначен» */
async function acceptOffer(ctx: Ctx, tx: Tx, offerId: string, providerId: string) {
const offer = await tx.orderOffer.findUnique({ where: { id: offerId }, include: { order: { include: { service: true } } } });
if (!offer || offer.providerId !== providerId || offer.status !== 'OFFERED') throw new DomainError('OFFER_NOT_ACTIVE');
if (offer.expiresAt && offer.expiresAt < ctx.now()) throw new DomainError('OFFER_NOT_ACTIVE');
const flow = getFlow(offer.order.service.flow);
await tx.orderOffer.update({ where: { id: offer.id }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } });
if (flow.assigned) {
const r = await transitionOrder(
ctx,
{ orderId: offer.orderId, to: flow.assigned, actor: 'SYSTEM', actorId: providerId, event: 'accepted', data: { providerId }, expectFrom: flow.initial },
tx,
);
return r.event;
}
await tx.order.update({ where: { id: offer.orderId }, data: { providerId } });
return null;
}
/**
* PRO-5: ответ исполнителя. Отказ (или таймаут) возвращает заказ в «Новые».
* Если исполнитель — сотрудник компании, заказ возвращается диспетчеру компании.
*/
export async function respondToOffer(ctx: Ctx, input: { offerId: string; providerId: string; accept: boolean }) {
const event = await ctx.db.$transaction(async (tx): Promise<DomainEvent | null> => {
const locked = await tx.orderOffer.findUnique({ where: { id: input.offerId } });
if (!locked) throw new DomainError('OFFER_NOT_ACTIVE');
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${locked.orderId} FOR UPDATE`;
if (input.accept) return acceptOffer(ctx, tx, input.offerId, input.providerId);
return declineOffer(ctx, tx, input.offerId, 'DECLINED', input.providerId);
});
if (event) await ctx.notify.emit(event);
}
async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED' | 'EXPIRED', providerId?: string): Promise<DomainEvent> {
const offer = await tx.orderOffer.findUnique({ where: { id: offerId }, include: { provider: true } });
if (!offer || offer.status !== 'OFFERED' || (providerId && offer.providerId !== providerId)) throw new DomainError('OFFER_NOT_ACTIVE');
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' } });
} else {
await tx.order.update({ where: { id: offer.orderId }, data: { companyId: null } });
}
await tx.orderStatusHistory.create({
data: {
orderId: offer.orderId,
toStatus: (await tx.order.findUniqueOrThrow({ where: { id: offer.orderId } })).status,
event: status === 'DECLINED' ? 'offerDeclined' : 'offerExpired',
actorType: status === 'DECLINED' ? 'PROVIDER' : 'SYSTEM',
actorId: offer.providerId,
},
});
return { type: status === 'DECLINED' ? 'offer.declined' : 'offer.expired', offerId: offer.id };
}
/** Воркер: просроченные предложения → как отказ */
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 },
});
let n = 0;
for (const { id } of expired) {
try {
const event = await ctx.db.$transaction((tx) => declineOffer(ctx, tx, id, 'EXPIRED'));
await ctx.notify.emit(event);
n++;
} catch (e) {
if (!(e instanceof DomainError)) throw e;
}
}
return n;
}
/**
* PRO-7: диспетчер компании выбирает сотрудника. Компания отвечает за своих людей,
* поэтому заказ сразу «Назначен», без отдельного подтверждения сотрудником.
*/
export async function dispatcherAssign(ctx: Ctx, input: { orderId: string; companyId: string; providerId: string; dispatcherUserId: string }) {
const event = await ctx.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const dispatcher = await tx.companyDispatcher.findUnique({
where: { companyId_userId: { companyId: input.companyId, userId: input.dispatcherUserId } },
});
if (!dispatcher) throw new DomainError('FORBIDDEN');
const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
if (order.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
const flow = getFlow(order.service.flow);
if (order.providerId || order.status !== flow.initial) throw new DomainError('ALREADY_ASSIGNED');
const provider = await tx.provider.findUnique({ where: { id: input.providerId } });
if (!provider || provider.companyId !== input.companyId || !provider.active || provider.role !== order.service.providerRole) {
throw new DomainError('NOT_FOUND');
}
const [avail] = (await loadAvailability(tx, provider.role, order.scheduledAt, interval(order).end, { excludeOrderId: order.id })).filter(
(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() } });
if (flow.assigned) {
const r = await transitionOrder(
ctx,
{ orderId: order.id, to: flow.assigned, actor: 'DISPATCHER', actorId: input.dispatcherUserId, event: 'dispatcherAssigned', data: { providerId: provider.id } },
tx,
);
return r.event;
}
await tx.order.update({ where: { id: order.id }, data: { providerId: provider.id } });
return null;
});
if (event) await ctx.notify.emit(event);
}
/** Снять исполнителя (админ): заказ возвращается в «Новые» */
export async function unassignOrder(ctx: Ctx, input: { orderId: string; adminId: string }) {
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: { service: true } });
const flow = getFlow(order.service.flow);
await revokeActiveOffers(tx, order.id);
if (order.status === flow.assigned) {
const r = await transitionOrder(
ctx,
{ orderId: order.id, to: flow.initial, actor: 'ADMIN', actorId: input.adminId, event: 'unassigned', data: { providerId: null, companyId: null } },
tx,
);
events.push(r.event);
} else if (order.status === flow.initial) {
await tx.order.update({ where: { id: order.id }, data: { providerId: null, companyId: null } });
} else {
throw new DomainError('TRANSITION_NOT_ALLOWED');
}
if (order.providerId) events.push({ type: 'order.unassigned', orderId: order.id, providerId: order.providerId });
});
for (const e of events) await ctx.notify.emit(e);
}
+69
View File
@@ -0,0 +1,69 @@
import { daySlots, fromYerevan, parseDateKey, type Interval, type ProviderAvailability, type ProviderRole, type Slot, type SlotConfig } from '@barev/core';
import type { Db, Tx } from '@barev/db';
const ACTIVE_STATUSES_EXCLUDED = ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] as const;
/** Исполнители роли с их графиком и занятостью на интервал [from, to) */
export async function loadAvailability(
db: Db | Tx,
role: ProviderRole,
from: Date,
to: Date,
opts: { excludeOrderId?: string; companyId?: string } = {},
): Promise<ProviderAvailability[]> {
const providers = await db.provider.findMany({
where: { role, active: true, ...(opts.companyId ? { companyId: opts.companyId } : {}) },
include: {
schedule: true,
orders: {
where: {
status: { notIn: [...ACTIVE_STATUSES_EXCLUDED] },
scheduledAt: { lt: to },
...(opts.excludeOrderId ? { id: { not: opts.excludeOrderId } } : {}),
},
select: { scheduledAt: true, durationMinutes: true },
},
},
});
return providers.map((p) => ({
id: p.id,
schedule: p.schedule,
busy: p.orders
.map((o) => ({ start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) }))
.filter((i) => i.end > from),
}));
}
/**
* Неназначенные заказы роли (без исполнителя), занимающие мощность.
* Заказ, переданный компании, тоже ещё без исполнителя — он учитывается здесь.
*/
export async function loadUnassigned(db: Db | Tx, role: ProviderRole, from: Date, to: Date, excludeOrderId?: string): Promise<Interval[]> {
const orders = await db.order.findMany({
where: {
providerId: null,
status: { notIn: [...ACTIVE_STATUSES_EXCLUDED] },
service: { providerRole: role },
scheduledAt: { lt: to, gte: new Date(from.getTime() - 24 * 3600_000) },
...(excludeOrderId ? { id: { not: excludeOrderId } } : {}),
},
select: { scheduledAt: true, durationMinutes: true },
});
return orders
.map((o) => ({ start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) }))
.filter((i) => i.end > from);
}
export async function slotsForDay(
db: Db | Tx,
input: { role: ProviderRole; config: SlotConfig; durationMinutes: number; dateKey: string; now: Date; excludeOrderId?: string },
): Promise<Slot[]> {
const { year, month, day } = parseDateKey(input.dateKey);
const from = fromYerevan(year, month, day);
const to = fromYerevan(year, month, day + 1);
const [providers, unassigned] = await Promise.all([
loadAvailability(db, input.role, from, to, { excludeOrderId: input.excludeOrderId }),
loadUnassigned(db, input.role, from, to, input.excludeOrderId),
]);
return daySlots({ ...input, providers, unassigned });
}
+72
View File
@@ -0,0 +1,72 @@
import {
fromPrice,
paramsSchemaSchema,
pricingSchema,
slotConfigSchema,
type CategoryCode,
type ParamsSchema,
type Pricing,
type SlotConfig,
} from '@barev/core';
import type { Db, Service, Tx } from '@barev/db';
import { DomainError } from './context.ts';
export interface ParsedService {
id: string;
code: string;
category: CategoryCode;
categoryDiscountPct: number;
flow: Service['flow'];
providerRole: Service['providerRole'];
pricing: Pricing;
paramsSchema: ParamsSchema;
slotConfig: SlotConfig;
lateCancelFeePct: number;
isOnline: boolean;
active: boolean;
fromPrice: number;
}
type ServiceWithCategory = Service & { category: { code: CategoryCode; barevPlusDiscountPct: number } };
export function parseService(s: ServiceWithCategory): ParsedService {
const pricing = pricingSchema.parse(s.pricing);
return {
id: s.id,
code: s.code,
category: s.category.code,
categoryDiscountPct: s.category.barevPlusDiscountPct,
flow: s.flow,
providerRole: s.providerRole,
pricing,
paramsSchema: paramsSchemaSchema.parse(s.paramsSchema),
slotConfig: slotConfigSchema.parse(s.slotConfig),
lateCancelFeePct: s.lateCancelFeePct,
isOnline: s.isOnline,
active: s.active,
fromPrice: fromPrice(pricing),
};
}
/** Каталог для лендинга и Mini App: все направления, у услуг — «от N ֏» и признак доступности */
export async function listCatalog(db: Db) {
const categories = await db.category.findMany({
orderBy: { sortOrder: 'asc' },
include: { services: { orderBy: { sortOrder: 'asc' }, include: { category: true } } },
});
return categories.map((c) => ({
code: c.code,
barevPlusDiscountPct: c.barevPlusDiscountPct,
services: c.services.map((s) => {
const p = parseService(s);
return { code: p.code, flow: p.flow, fromPrice: p.fromPrice, active: p.active, isOnline: p.isOnline };
}),
}));
}
export async function getServiceByCode(db: Db | Tx, code: string, opts: { activeOnly?: boolean } = {}): Promise<ParsedService> {
const s = await db.service.findUnique({ where: { code }, include: { category: true } });
if (!s) throw new DomainError('NOT_FOUND');
if (opts.activeOnly && !s.active) throw new DomainError('SERVICE_UNAVAILABLE');
return parseService(s);
}
+205
View File
@@ -0,0 +1,205 @@
import { randomBytes } from 'node:crypto';
import {
activeSubscription,
decideGift,
decodeStartPayload,
isLocale,
membershipLevel,
normalizeAgencyCode,
type Locale,
type MembershipLevel,
} from '@barev/core';
import type { Client, Db, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { getSettings } from './settings.ts';
export interface TelegramUserInput {
id: number | bigint;
first_name?: string;
last_name?: string;
username?: string;
language_code?: string;
}
/** Код клубной карты: 8 символов без похожих букв */
const CARD_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export function generateCode(length = 8): string {
const bytes = randomBytes(length);
return Array.from(bytes, (b) => CARD_ALPHABET[b % CARD_ALPHABET.length]).join('');
}
/** Язык из Telegram, если он один из наших */
export function localeFromTelegram(code?: string): Locale | null {
const base = code?.split('-')[0]?.toLowerCase();
return isLocale(base) ? base : null;
}
export async function upsertUser(db: Db | Tx, tg: TelegramUserInput, locale?: Locale) {
const telegramId = BigInt(tg.id);
return db.user.upsert({
where: { telegramId },
create: {
telegramId,
firstName: tg.first_name,
lastName: tg.last_name,
username: tg.username,
locale: locale ?? localeFromTelegram(tg.language_code) ?? 'ru',
},
update: { firstName: tg.first_name, lastName: tg.last_name, username: tg.username },
});
}
export interface StartResult {
clientId: string;
isNew: boolean;
/** язык не пришёл в payload и не был выбран раньше — показать выбор (ONB-2) */
needsLocale: boolean;
needsConsent: boolean;
locale: Locale;
agencyName: string | null;
/** подарок активирован в этом /start */
gift: { endsAt: Date } | null;
}
/**
* ONB-1: /start с payload. Создаёт клиента, сохраняет источник (агентство, UTM),
* язык. Агентство закрепляется только при первом контакте — дальше не перезаписывается.
*/
export async function startClient(ctx: Ctx, tg: TelegramUserInput, rawPayload?: string): Promise<StartResult> {
const { db } = ctx;
const payload = decodeStartPayload(rawPayload);
let locale = payload.locale;
let agencyCode = payload.agency;
let utm: unknown = undefined;
if (payload.token) {
const token = await db.startToken.findUnique({ where: { id: payload.token } });
if (token) {
locale ??= token.locale ?? undefined;
agencyCode ??= token.agency ?? undefined;
utm = token.utm ?? undefined;
}
}
const agency = agencyCode ? await db.agency.findUnique({ where: { code: agencyCode } }) : null;
const usableAgency = agency?.active ? agency : null;
const existing = await db.user.findUnique({ where: { telegramId: BigInt(tg.id) }, include: { client: true } });
const user = await upsertUser(db, tg, locale);
if (locale && user.locale !== locale) await db.user.update({ where: { id: user.id }, data: { locale } });
let client = existing?.client ?? null;
const isNew = !client;
if (!client) {
client = await db.client.create({
data: {
userId: user.id,
cardCode: generateCode(),
source: usableAgency ? 'deeplink' : 'organic',
agencyId: usableAgency?.id,
agencyAttachedAt: usableAgency ? ctx.now() : undefined,
utm: utm ?? undefined,
},
});
} else if (!client.agencyId && usableAgency) {
client = await db.client.update({
where: { id: client.id },
data: { agencyId: usableAgency.id, agencyAttachedAt: ctx.now(), source: 'deeplink' },
});
}
// подарок выдаём сразу, если согласие уже есть; иначе — после согласия (giveConsent)
const gift = client.consentAt ? await activateAgencyGift(ctx, client.id) : null;
const finalAgency = client.agencyId ? await db.agency.findUnique({ where: { id: client.agencyId } }) : null;
return {
clientId: client.id,
isNew,
needsLocale: !locale && isNew,
needsConsent: !client.consentAt,
locale: locale ?? user.locale,
agencyName: finalAgency?.name ?? null,
gift,
};
}
export async function setLocale(db: Db, telegramId: bigint, locale: Locale) {
await db.user.update({ where: { telegramId }, data: { locale } });
}
/** ONB-3: согласие на обработку ПДн. После него — подарок агентства, если положен. */
export async function giveConsent(ctx: Ctx, clientId: string) {
const settings = await getSettings(ctx.db);
await ctx.db.client.update({
where: { id: clientId },
data: { consentAt: ctx.now(), consentVersion: settings['consent.version'] },
});
return activateAgencyGift(ctx, clientId);
}
/**
* ONB-4 / SUB-3: подарочный месяц Barev+ клиенту агентства, один раз.
* Возвращает дату окончания или null, если не положен.
*/
export async function activateAgencyGift(ctx: Ctx, clientId: string): Promise<{ endsAt: Date } | 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`;
const client = await tx.client.findUniqueOrThrow({
where: { id: clientId },
include: { agency: true, subscriptions: true },
});
if (!client.agency || !client.consentAt) return null;
const now = ctx.now();
const current = activeSubscription(client.subscriptions, now);
const decision = decideGift({
now,
giftUsedAt: client.giftUsedAt,
agencyActive: client.agency.active,
giftDays: settings['barevPlus.giftDays'],
currentEndsAt: current?.endsAt ?? null,
});
if (!decision.ok) return null;
await tx.subscription.create({
data: { clientId, source: 'AGENCY_GIFT', startsAt: decision.startsAt, endsAt: decision.endsAt },
});
await tx.client.update({ where: { id: clientId }, data: { giftUsedAt: now } });
return { endsAt: decision.endsAt };
});
if (result) await ctx.notify.emit({ type: 'gift.activated', clientId, endsAt: result.endsAt.toISOString() });
return result;
}
/** ONB-6: ручной ввод кода агентства, если клиент пришёл без deep-link */
export async function applyAgencyCode(ctx: Ctx, clientId: string, rawCode: string) {
const code = normalizeAgencyCode(rawCode);
const agency = code ? await ctx.db.agency.findUnique({ where: { code } }) : null;
if (!agency || !agency.active) throw new DomainError('AGENCY_CODE_INVALID');
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId } });
if (client.agencyId && client.agencyId !== agency.id) throw new DomainError('AGENCY_ALREADY_SET');
if (!client.agencyId) {
await ctx.db.client.update({
where: { id: clientId },
data: { agencyId: agency.id, agencyAttachedAt: ctx.now(), source: 'manual_code' },
});
}
const gift = await activateAgencyGift(ctx, clientId);
return { agencyName: agency.name, gift };
}
export interface Membership {
level: MembershipLevel;
endsAt: Date | null;
}
export async function getMembership(db: Db | Tx, clientId: string, now: Date): Promise<Membership> {
const subs = await db.subscription.findMany({ where: { clientId, status: 'ACTIVE', endsAt: { gt: now } } });
const active = activeSubscription(subs, now);
return { level: membershipLevel(subs, now), endsAt: active?.endsAt ?? null };
}
export async function getClientByTelegramId(db: Db, telegramId: bigint): Promise<(Client & { user: { locale: Locale; firstName: string | null } }) | null> {
const user = await db.user.findUnique({ where: { telegramId }, include: { client: true } });
if (!user?.client) return null;
return { ...user.client, user: { locale: user.locale, firstName: user.firstName } };
}
+62
View File
@@ -0,0 +1,62 @@
import type { Db } from '@barev/db';
import type { OrderStatus } from '@barev/core';
/** Доменные события. Воркер превращает их в уведомления на языке получателя. */
export type DomainEvent =
| { type: 'order.created'; orderId: string }
| { type: 'order.status'; orderId: string; from: OrderStatus; to: OrderStatus }
/** событие без смены статуса, например «Я на месте» */
| { type: 'order.event'; orderId: string; code: string }
| { type: 'order.rescheduled'; 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: 'order.reminder'; orderId: string; hours: 24 | 1 }
| { type: 'gift.activated'; clientId: string; endsAt: string };
export interface Notifier {
emit(event: DomainEvent): Promise<void> | void;
}
export interface Ctx {
db: Db;
notify: Notifier;
now: () => Date;
}
export const silentNotifier: Notifier = { emit: () => {} };
/** Ошибка бизнес-логики. code переводится на фронте как errors.<code>. */
export class DomainError extends Error {
constructor(
public code: DomainErrorCode,
public details?: Record<string, unknown>,
) {
super(code);
this.name = 'DomainError';
}
}
export type DomainErrorCode =
| 'NOT_FOUND'
| 'FORBIDDEN'
| 'CONSENT_REQUIRED'
| 'SERVICE_UNAVAILABLE'
| 'INVALID_PARAMS'
| 'SLOT_UNAVAILABLE'
| 'ADDRESS_REQUIRED'
| 'TRANSITION_NOT_ALLOWED'
| 'CHECKLIST_INCOMPLETE'
| 'PHOTO_REQUIRED'
| 'ESTIMATE_NOT_ACCEPTED'
| 'CANCEL_VIA_OPERATOR'
| 'RESCHEDULE_VIA_OPERATOR'
| 'AGENCY_CODE_INVALID'
| 'AGENCY_ALREADY_SET'
| 'OFFER_NOT_ACTIVE'
| 'PROVIDER_BUSY'
| 'ALREADY_ASSIGNED'
| 'INVITE_INVALID'
| 'NOT_PAID_STATE';
+10
View File
@@ -0,0 +1,10 @@
export * from './context.ts';
export * from './settings.ts';
export * from './catalog.ts';
export * from './clients.ts';
export * from './availability.ts';
export * from './orders.ts';
export * from './assignment.ts';
export * from './providers.ts';
export * from './queries.ts';
export * from './reminders.ts';
+410
View File
@@ -0,0 +1,410 @@
import {
agencyCommission,
cancelPolicy,
canRescheduleSelf,
checkTransition,
discountFor,
findActionTransition,
findEvent,
getFlow,
isAgencyAttributionActive,
isProviderFree,
isTerminal,
ManualPaymentProvider,
quote,
validateParams,
yerevanDateKey,
yerevanMonthKey,
type ActorType,
type GuardContext,
type OrderStatus,
} from '@barev/core';
import type { Db, Prisma, Tx } from '@barev/db';
import { loadAvailability, slotsForDay } from './availability.ts';
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';
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>) {
const service = await getServiceByCode(ctx.db, serviceCode, { activeOnly: true });
const params = validateParams(service.paramsSchema, rawParams);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const membership = await getMembership(ctx.db, clientId, ctx.now());
const q = quote(service.pricing, params.values, discountFor(membership.level, service.categoryDiscountPct));
return { service, params: params.values, quote: q, membership };
}
export async function availableSlots(ctx: Ctx, input: { serviceCode: string; params: Record<string, unknown>; dateKey: string; excludeOrderId?: string }) {
const service = await getServiceByCode(ctx.db, input.serviceCode, { activeOnly: true });
const params = validateParams(service.paramsSchema, input.params);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const { durationMinutes } = quote(service.pricing, params.values);
return slotsForDay(ctx.db, {
role: service.providerRole,
config: service.slotConfig,
durationMinutes,
dateKey: input.dateKey,
now: ctx.now(),
excludeOrderId: input.excludeOrderId,
});
}
async function assertSlotFree(tx: Tx, service: ParsedService, scheduledAt: Date, durationMinutes: number, now: Date, excludeOrderId?: string) {
const slots = await slotsForDay(tx, {
role: service.providerRole,
config: service.slotConfig,
durationMinutes,
dateKey: yerevanDateKey(scheduledAt),
now,
excludeOrderId,
});
if (!slots.some((s) => s.start.getTime() === scheduledAt.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
}
export interface CreateOrderInput {
clientId: string;
serviceCode: string;
params: Record<string, unknown>;
scheduledAt: Date;
addressText?: string;
districtId?: string;
lat?: number;
lng?: number;
note?: string;
paymentMethodId?: string;
repeatOfId?: string;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: input.clientId }, include: { user: true } });
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
const { service, params, quote: q } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params);
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
const flow = getFlow(service.flow);
const order = await ctx.db.$transaction(
async (tx) => {
// сериализуем создание заказов одной роли, чтобы не продать слот дважды
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
return tx.order.create({
data: {
clientId: input.clientId,
serviceId: service.id,
params,
scheduledAt: input.scheduledAt,
durationMinutes: q.durationMinutes,
isOnline: service.isOnline,
addressText: input.addressText?.trim(),
districtId: input.districtId,
lat: input.lat,
lng: input.lng,
note: input.note?.trim() || undefined,
locale: client.user.locale,
basePrice: q.basePrice,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
status: flow.initial,
paymentMethodId: input.paymentMethodId,
repeatOfId: input.repeatOfId,
checklist: {
create: checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })),
},
history: { create: { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
},
});
},
{ isolationLevel: 'ReadCommitted' },
);
await ctx.notify.emit({ type: 'order.created', orderId: order.id });
return order;
}
/** Контекст guard'ов для перехода — из чек-листа, сметы, пакета уроков */
export async function guardContext(db: Db | Tx, orderId: string): Promise<GuardContext> {
const [items, estimate, lessons] = await Promise.all([
db.orderChecklistItem.findMany({ where: { orderId } }),
db.estimate.findFirst({ where: { orderId }, orderBy: { createdAt: 'desc' } }),
db.lessonPackage.findUnique({ where: { orderId } }),
]);
return {
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId)),
estimateAccepted: estimate?.status === 'ACCEPTED',
lessonsTotal: lessons?.total,
lessonsCompleted: lessons?.completed,
};
}
function transitionError(res: Exclude<ReturnType<typeof checkTransition>, { ok: true }>): DomainError {
if (res.code === 'GUARD_FAILED') {
if (res.guard === 'checklistComplete') return new DomainError('CHECKLIST_INCOMPLETE');
if (res.guard === 'estimateAccepted') return new DomainError('ESTIMATE_NOT_ACCEPTED');
}
return new DomainError('TRANSITION_NOT_ALLOWED', { reason: res.code, guard: 'guard' in res ? res.guard : undefined });
}
export interface TransitionInput {
orderId: string;
to: OrderStatus;
actor: ActorType;
actorId?: string;
comment?: string;
event?: string;
/** дополнительные поля заказа, которые меняются вместе со статусом */
data?: Prisma.OrderUncheckedUpdateInput;
/** ожидаемый текущий статус — защита от гонок двойного нажатия */
expectFrom?: OrderStatus;
}
/**
* Единственная точка смены статуса. Проверяет state machine на бэкенде,
* пишет историю (кто, когда, комментарий) и порождает событие для уведомления.
*/
export async function transitionOrder(ctx: Ctx, input: TransitionInput, txArg?: Tx) {
const run = async (tx: Tx) => {
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) throw new DomainError('NOT_FOUND');
if (input.expectFrom && order.status !== input.expectFrom) throw new DomainError('TRANSITION_NOT_ALLOWED', { reason: 'STALE' });
const res = checkTransition(order.service.flow, order.status, input.to, input.actor, await guardContext(tx, order.id));
if (!res.ok) throw transitionError(res);
const flow = getFlow(order.service.flow);
const updated = await tx.order.update({
where: { id: order.id },
data: {
...input.data,
status: input.to,
completedAt: flow.done.includes(input.to) ? ctx.now() : undefined,
cancelledAt: input.to === 'CANCELLED' ? ctx.now() : undefined,
},
});
await tx.orderStatusHistory.create({
data: {
orderId: order.id,
fromStatus: order.status,
toStatus: input.to,
event: input.event,
actorType: input.actor,
actorId: input.actorId,
comment: input.comment,
},
});
return { order: updated, from: order.status };
};
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);
return { ...result, event };
}
/** Событие без смены статуса («Я на месте») — пишется в историю, клиент получает уведомление */
export async function recordEvent(ctx: Ctx, input: { orderId: string; code: string; actor: ActorType; actorId?: string }) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
const ev = findEvent(order.service.flow, order.status, input.code);
if (!ev || !ev.by.includes(input.actor)) throw new DomainError('TRANSITION_NOT_ALLOWED');
await ctx.db.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: input.code, actorType: input.actor, actorId: input.actorId },
});
await ctx.notify.emit({ type: 'order.event', orderId: order.id, code: input.code });
}
/** PRO-3: кнопка исполнителя — переход или событие */
export async function providerAction(ctx: Ctx, input: { orderId: string; providerId: string; action: string; comment?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.providerId !== input.providerId) throw new DomainError('NOT_FOUND');
const t = findActionTransition(order.service.flow, order.status, input.action);
if (t) {
return transitionOrder(ctx, {
orderId: order.id,
to: t.to,
actor: 'PROVIDER',
actorId: input.providerId,
event: input.action,
comment: input.comment,
expectFrom: order.status,
});
}
await recordEvent(ctx, { orderId: order.id, code: input.action, actor: 'PROVIDER', actorId: input.providerId });
return null;
}
/** PRO-4: отметка пункта чек-листа. Пункт с обязательным фото не отмечается без файла. */
export async function setChecklistItem(
ctx: Ctx,
input: { orderId: string; providerId: string; code: string; checked: boolean; fileId?: string },
) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.providerId !== input.providerId) throw new DomainError('NOT_FOUND');
if (!getFlow(order.service.flow).checklistAt.includes(order.status)) throw new DomainError('TRANSITION_NOT_ALLOWED');
const item = await ctx.db.orderChecklistItem.findUnique({ where: { orderId_code: { orderId: order.id, code: input.code } } });
if (!item) throw new DomainError('NOT_FOUND');
const fileId = input.fileId ?? item.fileId;
if (input.checked && item.requiresPhoto && !fileId) throw new DomainError('PHOTO_REQUIRED');
if (input.fileId) {
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId } });
if (!file || file.orderId !== order.id) throw new DomainError('NOT_FOUND');
}
return ctx.db.orderChecklistItem.update({
where: { id: item.id },
data: { checkedAt: input.checked ? ctx.now() : null, fileId },
});
}
/** ORD-6: отмена клиентом. Внутри порога — с удержанием; после начала — только оператор. */
export async function cancelByClient(ctx: Ctx, input: { orderId: string; clientId: string; reason?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const policy = cancelPolicy({
scheduledAt: order.scheduledAt,
now: ctx.now(),
freeHours: settings['cancel.freeHours'],
lateFeePct: order.service.lateCancelFeePct,
totalPrice: order.totalPrice,
});
if (policy.kind === 'operator_only') throw new DomainError('CANCEL_VIA_OPERATOR');
const result = await ctx.db.$transaction(async (tx) => {
const r = await transitionOrder(
ctx,
{
orderId: order.id,
to: 'CANCELLED',
actor: 'CLIENT',
actorId: input.clientId,
comment: input.reason,
expectFrom: order.status,
data: { cancelFee: policy.fee, cancelReason: input.reason },
},
tx,
);
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED' }, data: { status: 'REVOKED' } });
return r;
});
await ctx.notify.emit(result.event);
return { order: result.order, fee: policy.fee };
}
/** Что увидит клиент, нажав «Отменить»: бесплатно / с удержанием / через оператора */
export async function cancelPreview(ctx: Ctx, orderId: string, clientId: string) {
const order = await ctx.db.order.findUnique({ where: { id: orderId }, include: { service: true } });
if (!order || order.clientId !== clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const flow = getFlow(order.service.flow);
const cancellable = flow.clientCancellable.includes(order.status);
return {
cancellable,
policy: cancelPolicy({
scheduledAt: order.scheduledAt,
now: ctx.now(),
freeHours: settings['cancel.freeHours'],
lateFeePct: order.service.lateCancelFeePct,
totalPrice: order.totalPrice,
}),
canReschedule: cancellable && canRescheduleSelf(order.scheduledAt, ctx.now(), settings['cancel.freeHours']),
freeHours: settings['cancel.freeHours'],
};
}
/**
* ORD-6: перенос клиентом — только вне порога. Если назначенный исполнитель занят в новое время,
* заказ возвращается в «Новые» и исполнитель получает уведомление.
*/
export async function rescheduleByClient(ctx: Ctx, input: { orderId: string; clientId: string; scheduledAt: Date }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: { include: { category: true } } } });
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const flow = getFlow(order.service.flow);
if (!flow.clientCancellable.includes(order.status) || !canRescheduleSelf(order.scheduledAt, ctx.now(), settings['cancel.freeHours'])) {
throw new DomainError('RESCHEDULE_VIA_OPERATOR');
}
const service = await getServiceByCode(ctx.db, order.service.code);
const events: DomainEvent[] = [{ type: 'order.rescheduled', orderId: order.id }];
await ctx.db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
await assertSlotFree(tx, service, input.scheduledAt, order.durationMinutes, ctx.now(), order.id);
let keepProvider = false;
if (order.providerId) {
const end = new Date(input.scheduledAt.getTime() + order.durationMinutes * 60_000);
const [p] = (await loadAvailability(tx, service.providerRole, input.scheduledAt, end, { excludeOrderId: order.id })).filter(
(x) => x.id === order.providerId,
);
keepProvider = !!p && isProviderFree(p, { start: input.scheduledAt, end });
}
await tx.order.update({
where: { id: order.id },
data: { scheduledAt: input.scheduledAt, reminded24hAt: null, reminded1hAt: null },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'rescheduled', actorType: 'CLIENT', actorId: input.clientId },
});
if (order.providerId && !keepProvider) {
const r = await transitionOrder(
ctx,
{ orderId: order.id, to: flow.initial, actor: 'SYSTEM', event: 'rescheduled', data: { providerId: null } },
tx,
);
events.push({ type: 'order.unassigned', orderId: order.id, providerId: order.providerId }, r.event);
}
});
for (const e of events) await ctx.notify.emit(e);
}
/** ORD-7: черновик повтора — те же параметры и адрес, новое время выбирает клиент */
export async function repeatDraft(db: Db, orderId: string, clientId: string) {
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
if (!order || order.clientId !== clientId) throw new DomainError('NOT_FOUND');
return {
serviceCode: order.service.code,
params: order.params as Record<string, unknown>,
addressText: order.addressText,
districtId: order.districtId,
note: order.note,
repeatOfId: order.id,
};
}
/** PAY-1: ручная отметка «оплачено». PAY-3: начисление агентству — только с оплаченных. */
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 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}` },
{ 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 },
});
const updated = await tx.order.update({
where: { id: order.id },
data: { paymentStatus: 'PAID', paidAt: now, paymentMethodId: input.paymentMethodId ?? order.paymentMethodId },
});
const agency = order.client.agency;
if (agency && isAgencyAttributionActive(order.client.agencyAttachedAt, order.createdAt, settings['agency.attributionMonths'])) {
await tx.agencyCommission.create({
data: {
agencyId: agency.id,
orderId: order.id,
pct: agency.commissionPct,
amount: agencyCommission(order.totalPrice, agency.commissionPct),
period: yerevanMonthKey(now),
},
});
}
return updated;
});
}
export function isOrderActive(flowCode: Parameters<typeof isTerminal>[0], status: OrderStatus) {
return !isTerminal(flowCode, status);
}
+197
View File
@@ -0,0 +1,197 @@
import { averageRating, payoutFor, providerActions, startOfYerevanDay, getFlow, type ServiceFlow } from '@barev/core';
import type { Db } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { upsertUser, type TelegramUserInput } from './clients.ts';
const DAY_MS = 24 * 3600_000;
/** Исполнитель или диспетчер открывает бота по инвайт-коду из админки */
export async function linkByInvite(ctx: Ctx, tg: TelegramUserInput, code: string) {
const user = await upsertUser(ctx.db, tg);
const provider = await ctx.db.provider.findUnique({ where: { inviteCode: code } });
if (provider) {
if (provider.userId && provider.userId !== user.id) throw new DomainError('INVITE_INVALID');
await ctx.db.provider.update({ where: { id: provider.id }, data: { userId: user.id } });
return { kind: 'provider' as const, name: provider.name };
}
const company = await ctx.db.providerCompany.findUnique({ where: { inviteCode: code } });
if (company) {
await ctx.db.companyDispatcher.upsert({
where: { companyId_userId: { companyId: company.id, userId: user.id } },
create: { companyId: company.id, userId: user.id },
update: {},
});
return { kind: 'dispatcher' as const, name: company.name };
}
throw new DomainError('INVITE_INVALID');
}
/** Кто этот пользователь в боте исполнителей */
export async function proIdentity(db: Db, telegramId: bigint) {
const user = await db.user.findUnique({
where: { telegramId },
include: {
provider: { include: { company: true } },
dispatcher: { include: { company: true } },
},
});
if (!user) return null;
const provider = user.provider?.active ? user.provider : null;
return {
user,
provider,
companies: user.dispatcher.filter((d) => d.company.active).map((d) => d.company),
};
}
export async function setShift(db: Db, providerId: string, onShift: boolean) {
return db.provider.update({ where: { id: providerId }, data: { onShift } });
}
/** Ставка, по которой считается заработок: у сотрудника компании — ставка компании */
function rateOf(p: { rateType: 'FIXED' | 'PERCENT'; rateValue: number; company: { rateType: 'FIXED' | 'PERCENT'; rateValue: number } | null }) {
return p.company ? { type: p.company.rateType, value: p.company.rateValue } : { type: p.rateType, value: p.rateValue };
}
/** 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 } });
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [orders, offers] = await Promise.all([
ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: dayStart, lt: dayEnd }, status: { not: 'CANCELLED' } },
include: { service: true, district: true, checklist: { orderBy: { sortOrder: 'asc' } }, client: { include: { user: true } } },
orderBy: { scheduledAt: 'asc' },
}),
ctx.db.orderOffer.findMany({
where: { providerId, status: 'OFFERED', OR: [{ expiresAt: null }, { expiresAt: { gt: ctx.now() } }] },
include: { order: { include: { service: true, district: true, client: { include: { user: true } } } } },
orderBy: { createdAt: 'asc' },
}),
]);
const upcoming = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: dayEnd }, status: { not: 'CANCELLED' } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'asc' },
take: 20,
});
const rate = rateOf(provider);
const doneToday = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
const current =
orders.find((o) => !getFlow(o.service.flow as ServiceFlow).done.includes(o.status) && o.status !== getFlow(o.service.flow as ServiceFlow).assigned) ??
orders.find((o) => !getFlow(o.service.flow as ServiceFlow).done.includes(o.status)) ??
null;
return {
provider: {
id: provider.id,
name: provider.name,
role: provider.role,
onShift: provider.onShift,
rating: averageRating(provider.ratingSum, provider.ratingCount),
companyName: provider.company?.name ?? null,
},
earningsToday: doneToday.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
currentOrderId: current?.id ?? null,
orders: orders.map((o) => ({ ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status) })),
upcoming: upcoming.map(proOrderView),
offers: offers.map((f) => ({ offerId: f.id, expiresAt: f.expiresAt, order: proOrderView(f.order) })),
};
}
type ProOrderRow = {
id: string;
number: number;
status: import('@barev/db').OrderStatus;
scheduledAt: Date;
durationMinutes: number;
isOnline: boolean;
addressText: string | null;
note: string | null;
locale: import('@barev/db').Locale;
params: unknown;
totalPrice: number;
paymentStatus: import('@barev/db').PaymentStatus;
service: { code: string; flow: import('@barev/db').ServiceFlow };
district: { code: string } | null;
client: { user: { firstName: string | null } };
checklist?: { code: string; requiresPhoto: boolean; checkedAt: Date | null; fileId: string | null }[];
};
/** Исполнитель видит имя, язык, адрес и заметку клиента — но не телефон и не Telegram */
export function proOrderView(o: ProOrderRow) {
return {
id: o.id,
number: o.number,
status: o.status,
flow: o.service.flow,
serviceCode: o.service.code,
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
isOnline: o.isOnline,
addressText: o.addressText,
districtCode: o.district?.code ?? null,
note: o.note,
clientLocale: o.locale,
clientName: o.client.user.firstName,
params: o.params as Record<string, unknown>,
totalPrice: o.totalPrice,
paymentStatus: o.paymentStatus,
checklist: o.checklist?.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, checked: !!c.checkedAt, hasPhoto: !!c.fileId })) ?? [],
};
}
export async function proOrder(ctx: Ctx, providerId: string, orderId: string) {
const o = await ctx.db.order.findUnique({
where: { id: orderId },
include: { service: true, district: true, checklist: { orderBy: { sortOrder: 'asc' } }, client: { include: { user: true } } },
});
if (!o || o.providerId !== providerId) throw new DomainError('NOT_FOUND');
const flow = getFlow(o.service.flow as ServiceFlow);
return { ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status), checklistActive: flow.checklistAt.includes(o.status) };
}
/** 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 } });
const orders = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: from, lt: to } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'desc' },
});
const rate = rateOf(provider);
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) ? payoutFor(o.totalPrice, rate) : 0 })),
totalEarned: done.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
doneCount: done.length,
rate,
};
}
/** Диспетчер: заказы компании без исполнителя и сотрудники */
export async function companyBoard(ctx: Ctx, companyId: string, userId: string) {
const link = await ctx.db.companyDispatcher.findUnique({ where: { companyId_userId: { companyId, userId } } });
if (!link) throw new DomainError('FORBIDDEN');
const [company, pending, assigned, staff] = await Promise.all([
ctx.db.providerCompany.findUniqueOrThrow({ where: { id: companyId } }),
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 } } },
orderBy: { scheduledAt: 'asc' },
}),
ctx.db.order.findMany({
where: { companyId, providerId: { not: null }, scheduledAt: { gte: startOfYerevanDay(ctx.now()) }, status: { not: 'CANCELLED' } },
include: { service: true, district: true, client: { include: { user: true } }, provider: true },
orderBy: { scheduledAt: 'asc' },
take: 50,
}),
ctx.db.provider.findMany({ where: { companyId, active: true }, orderBy: { name: 'asc' } }),
]);
return {
company: { id: company.id, name: company.name },
pending: pending.map(proOrderView),
assigned: assigned.map((o) => ({ ...proOrderView(o), providerName: o.provider?.name ?? null })),
staff: staff.map((p) => ({ id: p.id, name: p.name, role: p.role, onShift: p.onShift, languages: p.languages, linked: !!p.userId })),
};
}
+216
View File
@@ -0,0 +1,216 @@
import { getFlow, isProviderFree, isTerminal, kanbanColumn, startOfYerevanDay, type KanbanColumn, type ServiceFlow } from '@barev/core';
import type { Db, Prisma } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx } from './context.ts';
const DAY_MS = 24 * 3600_000;
const clientOrderInclude = {
service: { include: { category: true } },
district: true,
provider: { select: { name: true, ratingSum: true, ratingCount: true } },
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
paymentMethod: true,
} satisfies Prisma.OrderInclude;
type ClientOrderRow = Prisma.OrderGetPayload<{ include: typeof clientOrderInclude }>;
/** Клиент видит имя исполнителя, но не его контакты (MSG-1) */
function clientOrderView(o: ClientOrderRow) {
const flow = o.service.flow as ServiceFlow;
return {
id: o.id,
number: o.number,
serviceCode: o.service.code,
category: o.service.category.code,
flow,
status: o.status,
active: !isTerminal(flow, o.status),
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
isOnline: o.isOnline,
addressText: o.addressText,
districtCode: o.district?.code ?? null,
note: o.note,
params: o.params as Record<string, unknown>,
basePrice: o.basePrice,
discountPct: o.discountPct,
discountAmount: o.discountAmount,
totalPrice: o.totalPrice,
cancelFee: o.cancelFee,
paymentStatus: o.paymentStatus,
paymentMethod: o.paymentMethod ? { code: o.paymentMethod.code, name: o.paymentMethod.name as Record<string, string> } : null,
providerName: o.provider?.name ?? null,
history: o.history.map((h) => ({ from: h.fromStatus, to: h.toStatus, event: h.event, at: h.createdAt })),
photos: o.checklist.filter((c) => c.fileId).map((c) => ({ code: c.code, fileId: c.fileId! })),
/** основной путь сценария — для шкалы прогресса */
steps: getFlow(flow).steps,
};
}
export async function listClientOrders(db: Db, clientId: string) {
const orders = await db.order.findMany({
where: { clientId },
include: clientOrderInclude,
orderBy: { scheduledAt: 'desc' },
take: 100,
});
const views = orders.map(clientOrderView);
return {
active: views.filter((o) => o.active).sort((a, b) => +a.scheduledAt - +b.scheduledAt),
history: views.filter((o) => !o.active),
};
}
export async function clientOrder(db: Db, clientId: string, orderId: string) {
const o = await db.order.findUnique({ where: { id: orderId }, include: clientOrderInclude });
if (!o || o.clientId !== clientId) throw new DomainError('NOT_FOUND');
return clientOrderView(o);
}
// ───────────── Админка ─────────────
export interface KanbanFilter {
category?: string;
serviceCode?: string;
dateFrom?: Date;
dateTo?: Date;
search?: string;
includeCancelled?: boolean;
}
/** ADM-1: канбан. Готовые и отменённые — только за последние 7 дней, если не задан период. */
export async function kanban(ctx: Ctx, f: KanbanFilter) {
const where: Prisma.OrderWhereInput = {
...(f.serviceCode ? { service: { code: f.serviceCode } } : f.category ? { service: { category: { code: f.category as never } } } : {}),
...(f.dateFrom || f.dateTo ? { scheduledAt: { gte: f.dateFrom, lt: f.dateTo } } : {}),
};
if (f.search?.trim()) {
const q = f.search.trim();
const n = Number(q.replace(/^#/, ''));
where.OR = [
...(Number.isInteger(n) && n > 0 ? [{ number: n }] : []),
{ addressText: { contains: q, mode: 'insensitive' as const } },
{ client: { user: { OR: [{ firstName: { contains: q, mode: 'insensitive' as const } }, { username: { contains: q, mode: 'insensitive' as const } }, { phone: { contains: q } }] } } },
{ provider: { name: { contains: q, mode: 'insensitive' as const } } },
];
}
if (!f.dateFrom && !f.dateTo) {
const weekAgo = new Date(ctx.now().getTime() - 7 * DAY_MS);
where.AND = [{ OR: [{ status: { notIn: ['DONE', 'COMPLETED', 'PLAN_SENT', 'CANCELLED'] } }, { updatedAt: { gte: weekAgo } }] }];
}
const orders = await ctx.db.order.findMany({
where,
include: {
service: { include: { category: true } },
district: true,
provider: { select: { id: true, name: true } },
company: { select: { id: true, name: true } },
client: { include: { user: true } },
offers: { where: { status: 'OFFERED' }, include: { provider: { select: { name: true } } } },
},
orderBy: { scheduledAt: 'asc' },
take: 500,
});
const columns: Record<KanbanColumn, ReturnType<typeof card>[]> = { new: [], assigned: [], in_progress: [], done: [], cancelled: [] };
function card(o: (typeof orders)[number]) {
const offer = o.offers[0];
return {
id: o.id,
number: o.number,
serviceCode: o.service.code,
category: o.service.category.code,
status: o.status,
scheduledAt: o.scheduledAt,
districtCode: o.district?.code ?? null,
clientName: o.client.user.firstName,
clientLocale: o.locale,
totalPrice: o.totalPrice,
paymentStatus: o.paymentStatus,
providerName: o.provider?.name ?? null,
companyName: o.company?.name ?? null,
/** «Ждёт подтверждения: Мария» / «Назначен на компанию» */
pending: offer ? (offer.providerId ? { kind: 'provider' as const, name: offer.provider?.name ?? '' } : { kind: 'company' as const, name: o.company?.name ?? '' }) : null,
};
}
for (const o of orders) {
const col = kanbanColumn(o.service.flow as ServiceFlow, o.status, {
hasProvider: !!o.providerId,
hasPendingOffer: o.offers.length > 0,
hasCompany: !!o.companyId,
});
if (col === 'cancelled' && !f.includeCancelled) continue;
columns[col].push(card(o));
}
return columns;
}
/** ADM-2: сводка за сегодня */
export async function summary(ctx: Ctx) {
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [today, unassigned, paidToday, providers, openTasks] = await Promise.all([
ctx.db.order.count({ where: { scheduledAt: { gte: dayStart, lt: dayEnd }, status: { not: 'CANCELLED' } } }),
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
ctx.db.order.aggregate({ _sum: { totalPrice: true }, where: { paidAt: { gte: dayStart, lt: dayEnd }, paymentStatus: 'PAID' } }),
ctx.db.provider.findMany({ where: { active: true }, include: { schedule: true } }),
ctx.db.operatorTask.count({ where: { status: 'OPEN' } }),
]);
// свободен сейчас: на смене и нет заказа, который идёт прямо сейчас
const now = ctx.now();
const busyNow = await ctx.db.order.findMany({
where: { providerId: { not: null }, scheduledAt: { lte: now, gte: new Date(now.getTime() - DAY_MS) }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] } },
select: { providerId: true, scheduledAt: true, durationMinutes: true },
});
const busyIds = new Set(busyNow.filter((o) => o.scheduledAt.getTime() + o.durationMinutes * 60_000 > now.getTime()).map((o) => o.providerId));
const freeProviders = providers.filter((p) => p.onShift && !busyIds.has(p.id)).length;
return { ordersToday: today, unassigned, revenueToday: paidToday._sum.totalPrice ?? 0, freeProviders, providersOnShift: providers.filter((p) => p.onShift).length, openTasks };
}
/** ADM-4: карточка заказа */
export async function adminOrder(db: Db, orderId: string) {
const o = await db.order.findUnique({
where: { id: orderId },
include: {
service: { include: { category: true } },
district: true,
provider: true,
company: true,
client: { include: { user: true, agency: true } },
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
offers: { orderBy: { createdAt: 'desc' }, include: { provider: { select: { name: true } }, company: { select: { name: true } } } },
files: { where: { deletedAt: null } },
messages: { orderBy: { createdAt: 'asc' } },
paymentMethod: true,
payments: { orderBy: { createdAt: 'desc' } },
},
});
if (!o) throw new DomainError('NOT_FOUND');
return o;
}
/** Сотрудники компании и свободны ли они на время заказа — для диспетчера */
export async function companyStaffForOrder(ctx: Ctx, companyId: string, orderId: string) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } });
if (order.companyId !== companyId) throw new DomainError('NOT_FOUND');
const end = new Date(order.scheduledAt.getTime() + order.durationMinutes * 60_000);
const avail = await loadAvailability(ctx.db, order.service.providerRole, startOfYerevanDay(order.scheduledAt), new Date(startOfYerevanDay(order.scheduledAt).getTime() + DAY_MS), {
companyId,
excludeOrderId: order.id,
});
const staff = await ctx.db.provider.findMany({ where: { companyId, active: true, role: order.service.providerRole }, orderBy: { name: 'asc' } });
return staff.map((p) => {
const a = avail.find((x) => x.id === p.id);
return {
id: p.id,
name: p.name,
languages: p.languages,
onShift: p.onShift,
free: !!a && isProviderFree(a, { start: order.scheduledAt, end }),
load: a?.busy.length ?? 0,
speaksClientLanguage: p.languages.includes(order.locale),
};
});
}
+34
View File
@@ -0,0 +1,34 @@
import { HOUR } from '@barev/core';
import type { Ctx } from './context.ts';
const FINAL = ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] as const;
/**
* MSG-2: напоминания за 24 часа и за 1 час. Отметка reminded*At делает обработку идемпотентной:
* воркер может запускать её каждую минуту. Заказы, созданные позже чем за 24 ч, получают только часовое.
*/
export async function dueReminders(ctx: Ctx): Promise<number> {
const now = ctx.now();
let sent = 0;
for (const hours of [24, 1] as const) {
const field = hours === 24 ? 'reminded24hAt' : 'reminded1hAt';
const due = await ctx.db.order.findMany({
where: {
status: { notIn: [...FINAL] },
[field]: null,
scheduledAt: { gt: now, lte: new Date(now.getTime() + hours * HOUR) },
// 24-часовое не шлём, если до начала уже меньше часа — придёт часовое
...(hours === 24 ? { NOT: { scheduledAt: { lte: new Date(now.getTime() + HOUR) } } } : {}),
},
select: { id: true },
});
for (const { id } of due) {
const { count } = await ctx.db.order.updateMany({ where: { id, [field]: null }, data: { [field]: now } });
if (count === 1) {
await ctx.notify.emit({ type: 'order.reminder', orderId: id, hours });
sent++;
}
}
}
return sent;
}
+11
View File
@@ -0,0 +1,11 @@
import { resolveSettings, settingsSchema, type SettingKey, type Settings } from '@barev/core';
import type { Db, Tx } from '@barev/db';
export async function getSettings(db: Db | Tx): Promise<Settings> {
return resolveSettings(await db.setting.findMany());
}
export async function setSetting<K extends SettingKey>(db: Db, key: K, value: Settings[K]): Promise<void> {
const parsed = settingsSchema.shape[key].parse(value);
await db.setting.upsert({ where: { key }, create: { key, value: parsed }, update: { value: parsed } });
}
+327
View File
@@ -0,0 +1,327 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { encodeStartPayload } from '@barev/core';
import {
applyAgencyCode,
assignmentCandidates,
availableSlots,
cancelByClient,
createOrder,
dispatcherAssign,
dueReminders,
expireOffers,
giveConsent,
kanban,
markPaid,
offerOrder,
providerAction,
quoteForClient,
respondToOffer,
rescheduleByClient,
setChecklistItem,
startClient,
transitionOrder,
DomainError,
} 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 onboardedClient(ctx: ReturnType<typeof makeCtx>['ctx'], id: number, payload?: string) {
const start = await startClient(ctx, tgUser(id), payload);
await giveConsent(ctx, start.clientId);
return start.clientId;
}
async function newOrder(ctx: ReturnType<typeof makeCtx>['ctx'], clientId: string, when = slotAt(2, 11)) {
return createOrder(ctx, {
clientId,
serviceCode: 'cleaning',
params: { cleaningType: 'regular', rooms: 2 },
scheduledAt: when,
addressText: 'Pushkin 10',
districtId: fx.kentron.id,
});
}
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
describe('онбординг (ONB-1…6)', () => {
it('/start с кодом агентства: клиент с источником, язык, подарок после согласия', async () => {
const { ctx, events } = makeCtx(NOW);
const start = await startClient(ctx, tgUser(1, 'en'), encodeStartPayload({ locale: 'hy', agency: 'domik' }));
expect(start).toMatchObject({ isNew: true, needsLocale: false, needsConsent: true, locale: 'hy', agencyName: 'Domik', gift: null });
const client = await db.client.findUniqueOrThrow({ where: { id: start.clientId }, include: { user: true } });
expect(client.agencyId).toBe(fx.agency.id);
expect(client.source).toBe('deeplink');
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(events).toContainEqual(expect.objectContaining({ type: 'gift.activated' }));
expect(await db.subscription.count({ where: { clientId: start.clientId, source: 'AGENCY_GIFT' } })).toBe(1);
});
it('подарок — один раз; повторный /start с другим агентством не перезаписывает источник', async () => {
const { ctx } = makeCtx(NOW);
const id = await onboardedClient(ctx, 2, encodeStartPayload({ agency: 'DOMIK' }));
await db.agency.create({ data: { code: 'OTHER', name: 'Other' } });
const again = await startClient(ctx, tgUser(2), encodeStartPayload({ agency: 'OTHER' }));
expect(again).toMatchObject({ isNew: false, agencyName: 'Domik', gift: null });
expect(await db.subscription.count({ where: { clientId: id } })).toBe(1);
});
it('без языка в payload и у нового клиента — спрашиваем язык', async () => {
const { ctx } = makeCtx(NOW);
expect((await startClient(ctx, tgUser(3, 'de'))).needsLocale).toBe(true);
});
it('UTM через короткий токен', async () => {
const { ctx } = makeCtx(NOW);
await db.startToken.create({ data: { id: 'tok1', locale: 'es', agency: 'DOMIK', utm: { utm_source: 'qr' } } });
const s = await startClient(ctx, tgUser(4), encodeStartPayload({ token: 'tok1' }));
expect(s.locale).toBe('es');
const c = await db.client.findUniqueOrThrow({ where: { id: s.clientId } });
expect(c.utm).toEqual({ utm_source: 'qr' });
expect(c.agencyId).toBe(fx.agency.id);
});
it('ручной ввод кода (ONB-6): неверный/неактивный — ошибка, верный — подарок', async () => {
const { ctx } = makeCtx(NOW);
const id = await onboardedClient(ctx, 5);
expect(await code(applyAgencyCode(ctx, id, 'nope'))).toBe('AGENCY_CODE_INVALID');
expect(await code(applyAgencyCode(ctx, id, 'closed'))).toBe('AGENCY_CODE_INVALID');
const r = await applyAgencyCode(ctx, id, ' domik ');
expect(r.gift).not.toBeNull();
expect((await db.client.findUniqueOrThrow({ where: { id } })).source).toBe('manual_code');
});
});
describe('заказ (ORD-3…6)', () => {
it('цена со скидкой Barev+ только у подписчика', async () => {
const { ctx } = makeCtx(NOW);
const plus = await onboardedClient(ctx, 10, encodeStartPayload({ agency: 'DOMIK' }));
const plain = await onboardedClient(ctx, 11);
const p = { cleaningType: 'regular', rooms: 2 };
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toEqual({ basePrice: 19000, discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
expect((await quoteForClient(ctx, plain, 'cleaning', p)).quote.totalPrice).toBe(19000);
});
it('без согласия заказ не создаётся; без адреса — тоже', async () => {
const { ctx } = makeCtx(NOW);
const s = await startClient(ctx, tgUser(12));
expect(await code(newOrder(ctx, s.clientId))).toBe('CONSENT_REQUIRED');
await giveConsent(ctx, s.clientId);
expect(
await code(createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 1 }, scheduledAt: slotAt(2, 11) })),
).toBe('ADDRESS_REQUIRED');
});
it('создаёт «Новый» с чек-листом и историей, шлёт событие', async () => {
const { ctx, events } = makeCtx(NOW);
const id = await onboardedClient(ctx, 13);
const order = await newOrder(ctx, id);
expect(order.status).toBe('NEW');
expect(await db.orderChecklistItem.count({ where: { orderId: order.id } })).toBe(5);
expect(await db.orderStatusHistory.count({ where: { orderId: order.id } })).toBe(1);
expect(events).toContainEqual({ type: 'order.created', orderId: order.id });
const cols = await kanban(ctx, {});
expect(cols.new.map((c) => c.id)).toContain(order.id);
});
it('слот не продаётся больше раз, чем есть свободных исполнителей', async () => {
const { ctx } = makeCtx(NOW);
const when = slotAt(2, 11);
// 3 клинера (Maria, Ani, Lusine) → 3 заказа, четвёртый — отказ
for (let i = 0; i < 3; i++) await newOrder(ctx, await onboardedClient(ctx, 20 + i), when);
expect(await code(newOrder(ctx, await onboardedClient(ctx, 30), when))).toBe('SLOT_UNAVAILABLE');
const slots = await availableSlots(ctx, { serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, dateKey: '2026-10-21' });
expect(slots.some((s) => s.start.getTime() === when.getTime())).toBe(false);
});
it('время вне окна или в прошлом — отказ', async () => {
const { ctx } = makeCtx(NOW);
const id = await onboardedClient(ctx, 31);
expect(await code(newOrder(ctx, id, slotAt(0, 9)))).toBe('SLOT_UNAVAILABLE');
expect(await code(newOrder(ctx, id, slotAt(2, 19)))).toBe('SLOT_UNAVAILABLE');
});
it('отмена: бесплатно за 24 ч, позже — удержание 30%, после начала — только оператор', async () => {
const { ctx, clock } = makeCtx(NOW);
const id = await onboardedClient(ctx, 32);
const free = await newOrder(ctx, id, slotAt(2, 11));
expect((await cancelByClient(ctx, { orderId: free.id, clientId: id })).fee).toBe(0);
const late = await newOrder(ctx, id, slotAt(1, 9));
clock.now = new Date(late.scheduledAt.getTime() - 2 * 3600_000);
expect((await cancelByClient(ctx, { orderId: late.id, clientId: id })).fee).toBe(Math.floor(late.totalPrice * 0.3));
clock.now = NOW;
const started = await newOrder(ctx, id, slotAt(1, 12));
clock.now = new Date(started.scheduledAt.getTime() + 60_000);
expect(await code(cancelByClient(ctx, { orderId: started.id, clientId: id }))).toBe('CANCEL_VIA_OPERATOR');
});
it('чужой заказ отменить нельзя', async () => {
const { ctx } = makeCtx(NOW);
const a = await onboardedClient(ctx, 33);
const b = await onboardedClient(ctx, 34);
const o = await newOrder(ctx, a);
expect(await code(cancelByClient(ctx, { orderId: o.id, clientId: b }))).toBe('NOT_FOUND');
});
});
describe('назначение (ADM-3, PRO-5, PRO-7)', () => {
it('подсказка учитывает язык клиента', async () => {
const { ctx } = makeCtx(NOW);
const s = await startClient(ctx, tgUser(40), encodeStartPayload({ locale: 'hy' }));
await giveConsent(ctx, s.clientId);
const o = await newOrder(ctx, s.clientId);
const { ranked } = await assignmentCandidates(ctx, o.id);
expect(ranked[0]).toMatchObject({ kind: 'provider', name: 'Ani' });
expect(ranked.map((r) => r.name)).toContain('Chisto');
});
it('предложение → принятие → «Назначен», клиент получает событие', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 41));
const offer = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
expect((await kanban(ctx, {})).assigned.find((c) => c.id === o.id)?.pending).toEqual({ kind: 'provider', name: 'Maria' });
expect(await code(respondToOffer(ctx, { offerId: offer.id, providerId: fx.ani.id, accept: true }))).toBe('OFFER_NOT_ACTIVE');
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: true });
const updated = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(updated).toMatchObject({ status: 'ASSIGNED', providerId: fx.maria.id });
expect(events).toContainEqual({ type: 'order.status', orderId: o.id, from: 'NEW', to: 'ASSIGNED' });
});
it('отказ и таймаут возвращают заказ в «Новые»', async () => {
const { ctx, clock } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 42));
const f1 = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f1.id, providerId: fx.maria.id, accept: false });
expect((await kanban(ctx, {})).new.map((c) => c.id)).toContain(o.id);
await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.ani.id } });
clock.now = new Date(NOW.getTime() + 16 * 60_000);
expect(await expireOffers(ctx)).toBe(1);
expect((await kanban(ctx, {})).new.map((c) => c.id)).toContain(o.id);
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).status).toBe('NEW');
});
it('компания: «Назначен на компанию» → диспетчер выбирает сотрудника', async () => {
const { ctx } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 43));
await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'company', id: fx.company.id } });
expect((await kanban(ctx, {})).assigned.find((c) => c.id === o.id)?.pending).toEqual({ kind: 'company', name: 'Chisto' });
// чужой сотрудник и не-диспетчер — отказ
expect(await code(dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.maria.id, dispatcherUserId: fx.dispatcher.id }))).toBe('NOT_FOUND');
expect(await code(dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: 'x' }))).toBe('FORBIDDEN');
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 } })).toMatchObject({ status: 'ASSIGNED', providerId: fx.lusine.id, companyId: fx.company.id });
const hist = await db.orderStatusHistory.findFirst({ where: { orderId: o.id, toStatus: 'ASSIGNED' } });
expect(hist?.actorType).toBe('DISPATCHER');
});
it('занятому исполнителю предложить нельзя', async () => {
const { ctx } = makeCtx(NOW);
const when = slotAt(2, 11);
const a = await newOrder(ctx, await onboardedClient(ctx, 44), when);
const f = await offerOrder(ctx, { orderId: a.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
const b = await newOrder(ctx, await onboardedClient(ctx, 45), when);
expect(await code(offerOrder(ctx, { orderId: b.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } }))).toBe('PROVIDER_BUSY');
});
});
describe('исполнитель ведёт заказ (PRO-3, PRO-4)', () => {
async function assigned(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
const o = await newOrder(ctx, await onboardedClient(ctx, tg));
const f = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
return o;
}
it('шаги клинера; закрыть без чек-листа и фото нельзя', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await assigned(ctx, 50);
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'startCleaning' }))).toBe('TRANSITION_NOT_ALLOWED');
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'depart' });
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'arrived' });
expect(events).toContainEqual({ type: 'order.event', orderId: o.id, code: 'arrived' });
// чек-лист до начала уборки не отмечается
expect(await code(setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'kitchen', checked: true }))).toBe('TRANSITION_NOT_ALLOWED');
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'startCleaning' });
for (const c of ['kitchen', 'bathroom', 'rooms', 'floors']) await setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: c, checked: true });
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'finishCleaning' }))).toBe('CHECKLIST_INCOMPLETE');
expect(await code(setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'photoReport', checked: true }))).toBe('PHOTO_REQUIRED');
const file = await db.orderFile.create({ data: { orderId: o.id, kind: 'PHOTO_REPORT', storageKey: `t/${o.id}.jpg`, mime: 'image/jpeg', size: 1, uploadedBy: 'PROVIDER' } });
await setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'photoReport', checked: true, fileId: file.id });
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'finishCleaning' });
const done = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(done.status).toBe('DONE');
expect(done.completedAt).not.toBeNull();
const hist = await db.orderStatusHistory.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
expect(hist.map((h) => h.event ?? h.toStatus)).toEqual(['NEW', 'accepted', 'depart', 'arrived', 'startCleaning', 'finishCleaning']);
});
it('чужой исполнитель не может двигать заказ', async () => {
const { ctx } = makeCtx(NOW);
const o = await assigned(ctx, 51);
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.ani.id, action: 'depart' }))).toBe('NOT_FOUND');
});
it('бэкенд отклоняет недопустимый переход напрямую', async () => {
const { ctx } = makeCtx(NOW);
const o = await assigned(ctx, 52);
expect(await code(transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' }))).toBe('TRANSITION_NOT_ALLOWED');
expect(await code(transitionOrder(ctx, { orderId: o.id, to: 'DRIVER_WAITING', actor: 'ADMIN' }))).toBe('TRANSITION_NOT_ALLOWED');
});
it('перенос: если исполнитель занят в новое время — заказ возвращается в «Новые»', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await assigned(ctx, 53);
// Мария уже занята в 15:00 другим заказом
const other = await newOrder(ctx, await onboardedClient(ctx, 54), slotAt(2, 15));
const f = await offerOrder(ctx, { orderId: other.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
const client = (await db.order.findUniqueOrThrow({ where: { id: o.id } })).clientId;
await rescheduleByClient(ctx, { orderId: o.id, clientId: client, scheduledAt: slotAt(2, 15) });
expect(await db.order.findUniqueOrThrow({ where: { id: o.id } })).toMatchObject({ status: 'NEW', providerId: null });
expect(events).toContainEqual(expect.objectContaining({ type: 'order.unassigned', orderId: o.id }));
});
});
describe('оплата и напоминания', () => {
it('оплата вручную: начисление агентству только с оплаченных и в срок закрепления', async () => {
const { ctx } = makeCtx(NOW);
const agencyClient = await onboardedClient(ctx, 60, encodeStartPayload({ agency: 'DOMIK' }));
const o = await newOrder(ctx, agencyClient);
expect(await db.agencyCommission.count()).toBe(0);
await markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: 'admin' } });
const comm = await db.agencyCommission.findUniqueOrThrow({ where: { orderId: o.id } });
expect(comm).toMatchObject({ pct: 10, amount: Math.floor(o.totalPrice * 0.1), period: '2026-10' });
expect(await code(markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: 'admin' } }))).toBe('NOT_PAID_STATE');
const organic = await onboardedClient(ctx, 61);
const o2 = await newOrder(ctx, organic, slotAt(2, 13));
await markPaid(ctx, { orderId: o2.id, by: { type: 'ADMIN', id: 'admin' } });
expect(await db.agencyCommission.count()).toBe(1);
});
it('напоминания за 24 ч и 1 ч отправляются по одному разу', async () => {
const { ctx, events, clock } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 62), slotAt(2, 11));
clock.now = new Date(o.scheduledAt.getTime() - 23 * 3600_000);
expect(await dueReminders(ctx)).toBe(1);
expect(await dueReminders(ctx)).toBe(0);
clock.now = new Date(o.scheduledAt.getTime() - 50 * 60_000);
expect(await dueReminders(ctx)).toBe(1);
expect(events.filter((e) => e.type === 'order.reminder').map((e) => (e as { hours: number }).hours)).toEqual([24, 1]);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { execSync } from 'node:child_process';
import path from 'node:path';
import { config } from 'dotenv';
import pg from 'pg';
/** Отдельная БД barev_test рядом с основной; схема накатывается миграциями */
export default async function setup() {
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
const base = new URL(process.env.DATABASE_URL!);
const testUrl = new URL(base);
testUrl.pathname = '/barev_test';
const admin = new pg.Client({ connectionString: base.toString() });
await admin.connect();
const exists = await admin.query(`SELECT 1 FROM pg_database WHERE datname = 'barev_test'`);
if (!exists.rowCount) await admin.query('CREATE DATABASE barev_test');
await admin.end();
execSync('npx prisma migrate deploy', {
cwd: path.resolve(import.meta.dirname, '../../db'),
env: { ...process.env, DATABASE_URL: testUrl.toString() },
stdio: 'pipe',
});
process.env.TEST_DATABASE_URL = testUrl.toString();
}
+87
View File
@@ -0,0 +1,87 @@
import path from 'node:path';
import { config } from 'dotenv';
import { CATALOG, DEFAULT_CATEGORY_DISCOUNTS, fromYerevan, toYerevan, YEREVAN_DISTRICTS } from '@barev/core';
import { createDb, type Db } from '@barev/db';
import type { Ctx, DomainEvent } from '../src/context.ts';
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
function testUrl() {
const u = new URL(process.env.DATABASE_URL!);
u.pathname = '/barev_test';
return u.toString();
}
export const db: Db = createDb(testUrl());
export function makeCtx(now: Date) {
const events: DomainEvent[] = [];
const clock = { now };
const ctx: Ctx = { db, notify: { emit: (e) => void events.push(e) }, now: () => clock.now };
return { ctx, events, clock };
}
export async function wipe() {
const tables = await db.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`;
await db.$executeRawUnsafe(`TRUNCATE ${tables.map((t) => `"public"."${t.tablename}"`).join(', ')} RESTART IDENTITY CASCADE`);
}
/** Понедельник 2026-10-19, 10:00 по Еревану — «сейчас» в тестах */
export const NOW = fromYerevan(2026, 10, 19, 10 * 60);
/** Слот послезавтра (ср) в hh:00 — вне 24-часового порога */
export const slotAt = (dayOffset: number, hour: number) => {
const p = toYerevan(new Date(NOW.getTime() + dayOffset * 24 * 3600_000));
return fromYerevan(p.year, p.month, p.day, hour * 60);
};
let tgSeq = 1000;
export async function fixtures() {
await wipe();
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
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')!;
const cleaning = await db.service.create({
data: {
code: c.code,
categoryId: home.id,
flow: c.flow,
providerRole: c.providerRole,
pricing: c.pricing,
paramsSchema: c.paramsSchema,
slotConfig: c.slotConfig,
lateCancelFeePct: 30,
checklist: { create: c.checklist.map((x, i) => ({ code: x.code, requiresPhoto: !!x.requiresPhoto, sortOrder: i })) },
},
});
const agency = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik', commissionPct: 10 } });
await db.agency.create({ data: { code: 'CLOSED', name: 'Closed', active: false } });
const schedule = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 9 * 60, endMinute: 20 * 60 }));
const mkProvider = async (name: string, languages: ('ru' | 'en' | 'hy')[], companyId?: string) => {
const user = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: name } });
return db.provider.create({
data: {
name,
userId: user.id,
role: 'CLEANER',
languages,
companyId,
inviteCode: `INV-${name}`,
onShift: true,
districts: { create: [{ districtId: kentron.id }] },
schedule: { create: schedule },
},
});
};
const maria = await mkProvider('Maria', ['ru', 'en']);
const ani = await mkProvider('Ani', ['hy']);
const company = await db.providerCompany.create({ data: { name: 'Chisto', roles: ['CLEANER'], inviteCode: 'CO-1', rateValue: 65 } });
const lusine = await mkProvider('Lusine', ['ru'], company.id);
const dispatcher = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: 'Karen' } });
await db.companyDispatcher.create({ data: { companyId: company.id, userId: dispatcher.id } });
return { kentron, cleaning, agency, maria, ani, company, lusine, dispatcher };
}
export const tgUser = (id: number, lang = 'ru') => ({ id, first_name: `User${id}`, language_code: lang });
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src"] }
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: ['./test/global-setup.ts'],
// тесты делят одну БД — запускаем последовательно
fileParallelism: false,
testTimeout: 20_000,
hookTimeout: 60_000,
},
});