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:
commit
45d7512d2b
64 files changed
+9483
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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}` : ''}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types.ts';
|
||||
export * from './definitions.ts';
|
||||
export * from './machine.ts';
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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} ֏`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "include": ["src"] }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src", "prisma.config.ts"] }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src"] }
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user