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,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