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,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';
|
||||
Reference in new issue
Block a user