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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-17 19:45:51 +04:00
commit 45d7512d2b
64 files changed
+9483

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
import {
agencyCommission,
cancelPolicy,
canRescheduleSelf,
checkTransition,
discountFor,
findActionTransition,
findEvent,
getFlow,
isAgencyAttributionActive,
isProviderFree,
isTerminal,
ManualPaymentProvider,
quote,
validateParams,
yerevanDateKey,
yerevanMonthKey,
type ActorType,
type GuardContext,
type OrderStatus,
} from '@barev/core';
import type { Db, Prisma, Tx } from '@barev/db';
import { loadAvailability, slotsForDay } from './availability.ts';
import { getServiceByCode, type ParsedService } from './catalog.ts';
import { getMembership } from './clients.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>) {
const service = await getServiceByCode(ctx.db, serviceCode, { activeOnly: true });
const params = validateParams(service.paramsSchema, rawParams);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const membership = await getMembership(ctx.db, clientId, ctx.now());
const q = quote(service.pricing, params.values, discountFor(membership.level, service.categoryDiscountPct));
return { service, params: params.values, quote: q, membership };
}
export async function availableSlots(ctx: Ctx, input: { serviceCode: string; params: Record<string, unknown>; dateKey: string; excludeOrderId?: string }) {
const service = await getServiceByCode(ctx.db, input.serviceCode, { activeOnly: true });
const params = validateParams(service.paramsSchema, input.params);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const { durationMinutes } = quote(service.pricing, params.values);
return slotsForDay(ctx.db, {
role: service.providerRole,
config: service.slotConfig,
durationMinutes,
dateKey: input.dateKey,
now: ctx.now(),
excludeOrderId: input.excludeOrderId,
});
}
async function assertSlotFree(tx: Tx, service: ParsedService, scheduledAt: Date, durationMinutes: number, now: Date, excludeOrderId?: string) {
const slots = await slotsForDay(tx, {
role: service.providerRole,
config: service.slotConfig,
durationMinutes,
dateKey: yerevanDateKey(scheduledAt),
now,
excludeOrderId,
});
if (!slots.some((s) => s.start.getTime() === scheduledAt.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
}
export interface CreateOrderInput {
clientId: string;
serviceCode: string;
params: Record<string, unknown>;
scheduledAt: Date;
addressText?: string;
districtId?: string;
lat?: number;
lng?: number;
note?: string;
paymentMethodId?: string;
repeatOfId?: string;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: input.clientId }, include: { user: true } });
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
const { service, params, quote: q } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params);
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
const flow = getFlow(service.flow);
const order = await ctx.db.$transaction(
async (tx) => {
// сериализуем создание заказов одной роли, чтобы не продать слот дважды
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
return tx.order.create({
data: {
clientId: input.clientId,
serviceId: service.id,
params,
scheduledAt: input.scheduledAt,
durationMinutes: q.durationMinutes,
isOnline: service.isOnline,
addressText: input.addressText?.trim(),
districtId: input.districtId,
lat: input.lat,
lng: input.lng,
note: input.note?.trim() || undefined,
locale: client.user.locale,
basePrice: q.basePrice,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
status: flow.initial,
paymentMethodId: input.paymentMethodId,
repeatOfId: input.repeatOfId,
checklist: {
create: checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })),
},
history: { create: { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
},
});
},
{ isolationLevel: 'ReadCommitted' },
);
await ctx.notify.emit({ type: 'order.created', orderId: order.id });
return order;
}
/** Контекст guard'ов для перехода — из чек-листа, сметы, пакета уроков */
export async function guardContext(db: Db | Tx, orderId: string): Promise<GuardContext> {
const [items, estimate, lessons] = await Promise.all([
db.orderChecklistItem.findMany({ where: { orderId } }),
db.estimate.findFirst({ where: { orderId }, orderBy: { createdAt: 'desc' } }),
db.lessonPackage.findUnique({ where: { orderId } }),
]);
return {
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId)),
estimateAccepted: estimate?.status === 'ACCEPTED',
lessonsTotal: lessons?.total,
lessonsCompleted: lessons?.completed,
};
}
function transitionError(res: Exclude<ReturnType<typeof checkTransition>, { ok: true }>): DomainError {
if (res.code === 'GUARD_FAILED') {
if (res.guard === 'checklistComplete') return new DomainError('CHECKLIST_INCOMPLETE');
if (res.guard === 'estimateAccepted') return new DomainError('ESTIMATE_NOT_ACCEPTED');
}
return new DomainError('TRANSITION_NOT_ALLOWED', { reason: res.code, guard: 'guard' in res ? res.guard : undefined });
}
export interface TransitionInput {
orderId: string;
to: OrderStatus;
actor: ActorType;
actorId?: string;
comment?: string;
event?: string;
/** дополнительные поля заказа, которые меняются вместе со статусом */
data?: Prisma.OrderUncheckedUpdateInput;
/** ожидаемый текущий статус — защита от гонок двойного нажатия */
expectFrom?: OrderStatus;
}
/**
* Единственная точка смены статуса. Проверяет state machine на бэкенде,
* пишет историю (кто, когда, комментарий) и порождает событие для уведомления.
*/
export async function transitionOrder(ctx: Ctx, input: TransitionInput, txArg?: Tx) {
const run = async (tx: Tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order) throw new DomainError('NOT_FOUND');
if (input.expectFrom && order.status !== input.expectFrom) throw new DomainError('TRANSITION_NOT_ALLOWED', { reason: 'STALE' });
const res = checkTransition(order.service.flow, order.status, input.to, input.actor, await guardContext(tx, order.id));
if (!res.ok) throw transitionError(res);
const flow = getFlow(order.service.flow);
const updated = await tx.order.update({
where: { id: order.id },
data: {
...input.data,
status: input.to,
completedAt: flow.done.includes(input.to) ? ctx.now() : undefined,
cancelledAt: input.to === 'CANCELLED' ? ctx.now() : undefined,
},
});
await tx.orderStatusHistory.create({
data: {
orderId: order.id,
fromStatus: order.status,
toStatus: input.to,
event: input.event,
actorType: input.actor,
actorId: input.actorId,
comment: input.comment,
},
});
return { order: updated, from: order.status };
};
const result = txArg ? await run(txArg) : await ctx.db.$transaction(run);
const event: DomainEvent = { type: 'order.status', orderId: input.orderId, from: result.from, to: input.to };
if (txArg) return { ...result, event };
await ctx.notify.emit(event);
return { ...result, event };
}
/** Событие без смены статуса («Я на месте») — пишется в историю, клиент получает уведомление */
export async function recordEvent(ctx: Ctx, input: { orderId: string; code: string; actor: ActorType; actorId?: string }) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
const ev = findEvent(order.service.flow, order.status, input.code);
if (!ev || !ev.by.includes(input.actor)) throw new DomainError('TRANSITION_NOT_ALLOWED');
await ctx.db.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: input.code, actorType: input.actor, actorId: input.actorId },
});
await ctx.notify.emit({ type: 'order.event', orderId: order.id, code: input.code });
}
/** PRO-3: кнопка исполнителя — переход или событие */
export async function providerAction(ctx: Ctx, input: { orderId: string; providerId: string; action: string; comment?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.providerId !== input.providerId) throw new DomainError('NOT_FOUND');
const t = findActionTransition(order.service.flow, order.status, input.action);
if (t) {
return transitionOrder(ctx, {
orderId: order.id,
to: t.to,
actor: 'PROVIDER',
actorId: input.providerId,
event: input.action,
comment: input.comment,
expectFrom: order.status,
});
}
await recordEvent(ctx, { orderId: order.id, code: input.action, actor: 'PROVIDER', actorId: input.providerId });
return null;
}
/** PRO-4: отметка пункта чек-листа. Пункт с обязательным фото не отмечается без файла. */
export async function setChecklistItem(
ctx: Ctx,
input: { orderId: string; providerId: string; code: string; checked: boolean; fileId?: string },
) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.providerId !== input.providerId) throw new DomainError('NOT_FOUND');
if (!getFlow(order.service.flow).checklistAt.includes(order.status)) throw new DomainError('TRANSITION_NOT_ALLOWED');
const item = await ctx.db.orderChecklistItem.findUnique({ where: { orderId_code: { orderId: order.id, code: input.code } } });
if (!item) throw new DomainError('NOT_FOUND');
const fileId = input.fileId ?? item.fileId;
if (input.checked && item.requiresPhoto && !fileId) throw new DomainError('PHOTO_REQUIRED');
if (input.fileId) {
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId } });
if (!file || file.orderId !== order.id) throw new DomainError('NOT_FOUND');
}
return ctx.db.orderChecklistItem.update({
where: { id: item.id },
data: { checkedAt: input.checked ? ctx.now() : null, fileId },
});
}
/** ORD-6: отмена клиентом. Внутри порога — с удержанием; после начала — только оператор. */
export async function cancelByClient(ctx: Ctx, input: { orderId: string; clientId: string; reason?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const policy = cancelPolicy({
scheduledAt: order.scheduledAt,
now: ctx.now(),
freeHours: settings['cancel.freeHours'],
lateFeePct: order.service.lateCancelFeePct,
totalPrice: order.totalPrice,
});
if (policy.kind === 'operator_only') throw new DomainError('CANCEL_VIA_OPERATOR');
const result = await ctx.db.$transaction(async (tx) => {
const r = await transitionOrder(
ctx,
{
orderId: order.id,
to: 'CANCELLED',
actor: 'CLIENT',
actorId: input.clientId,
comment: input.reason,
expectFrom: order.status,
data: { cancelFee: policy.fee, cancelReason: input.reason },
},
tx,
);
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED' }, data: { status: 'REVOKED' } });
return r;
});
await ctx.notify.emit(result.event);
return { order: result.order, fee: policy.fee };
}
/** Что увидит клиент, нажав «Отменить»: бесплатно / с удержанием / через оператора */
export async function cancelPreview(ctx: Ctx, orderId: string, clientId: string) {
const order = await ctx.db.order.findUnique({ where: { id: orderId }, include: { service: true } });
if (!order || order.clientId !== clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const flow = getFlow(order.service.flow);
const cancellable = flow.clientCancellable.includes(order.status);
return {
cancellable,
policy: cancelPolicy({
scheduledAt: order.scheduledAt,
now: ctx.now(),
freeHours: settings['cancel.freeHours'],
lateFeePct: order.service.lateCancelFeePct,
totalPrice: order.totalPrice,
}),
canReschedule: cancellable && canRescheduleSelf(order.scheduledAt, ctx.now(), settings['cancel.freeHours']),
freeHours: settings['cancel.freeHours'],
};
}
/**
* ORD-6: перенос клиентом — только вне порога. Если назначенный исполнитель занят в новое время,
* заказ возвращается в «Новые» и исполнитель получает уведомление.
*/
export async function rescheduleByClient(ctx: Ctx, input: { orderId: string; clientId: string; scheduledAt: Date }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: { include: { category: true } } } });
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const flow = getFlow(order.service.flow);
if (!flow.clientCancellable.includes(order.status) || !canRescheduleSelf(order.scheduledAt, ctx.now(), settings['cancel.freeHours'])) {
throw new DomainError('RESCHEDULE_VIA_OPERATOR');
}
const service = await getServiceByCode(ctx.db, order.service.code);
const events: DomainEvent[] = [{ type: 'order.rescheduled', orderId: order.id }];
await ctx.db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
await assertSlotFree(tx, service, input.scheduledAt, order.durationMinutes, ctx.now(), order.id);
let keepProvider = false;
if (order.providerId) {
const end = new Date(input.scheduledAt.getTime() + order.durationMinutes * 60_000);
const [p] = (await loadAvailability(tx, service.providerRole, input.scheduledAt, end, { excludeOrderId: order.id })).filter(
(x) => x.id === order.providerId,
);
keepProvider = !!p && isProviderFree(p, { start: input.scheduledAt, end });
}
await tx.order.update({
where: { id: order.id },
data: { scheduledAt: input.scheduledAt, reminded24hAt: null, reminded1hAt: null },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'rescheduled', actorType: 'CLIENT', actorId: input.clientId },
});
if (order.providerId && !keepProvider) {
const r = await transitionOrder(
ctx,
{ orderId: order.id, to: flow.initial, actor: 'SYSTEM', event: 'rescheduled', data: { providerId: null } },
tx,
);
events.push({ type: 'order.unassigned', orderId: order.id, providerId: order.providerId }, r.event);
}
});
for (const e of events) await ctx.notify.emit(e);
}
/** ORD-7: черновик повтора — те же параметры и адрес, новое время выбирает клиент */
export async function repeatDraft(db: Db, orderId: string, clientId: string) {
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
if (!order || order.clientId !== clientId) throw new DomainError('NOT_FOUND');
return {
serviceCode: order.service.code,
params: order.params as Record<string, unknown>,
addressText: order.addressText,
districtId: order.districtId,
note: order.note,
repeatOfId: order.id,
};
}
/** PAY-1: ручная отметка «оплачено». PAY-3: начисление агентству — только с оплаченных. */
export async function markPaid(ctx: Ctx, input: { orderId: string; by: { type: 'ADMIN' | 'PROVIDER'; id: string }; paymentMethodId?: string }) {
const provider = new ManualPaymentProvider();
const settings = await getSettings(ctx.db);
return ctx.db.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { client: { include: { agency: true } } } });
if (!order) throw new DomainError('NOT_FOUND');
if (input.by.type === 'PROVIDER' && order.providerId !== input.by.id) throw new DomainError('NOT_FOUND');
if (order.paymentStatus === 'PAID' || order.status === 'CANCELLED') throw new DomainError('NOT_PAID_STATE');
const res = await provider.confirmPayment(
{ orderId: order.id, amount: order.totalPrice, currency: 'AMD', description: `#${order.number}` },
{ markedBy: input.by.id },
);
const now = ctx.now();
await tx.payment.create({
data: { orderId: order.id, provider: res.provider, amount: order.totalPrice, status: 'PAID', markedById: input.by.id },
});
const updated = await tx.order.update({
where: { id: order.id },
data: { paymentStatus: 'PAID', paidAt: now, paymentMethodId: input.paymentMethodId ?? order.paymentMethodId },
});
const agency = order.client.agency;
if (agency && isAgencyAttributionActive(order.client.agencyAttachedAt, order.createdAt, settings['agency.attributionMonths'])) {
await tx.agencyCommission.create({
data: {
agencyId: agency.id,
orderId: order.id,
pct: agency.commissionPct,
amount: agencyCommission(order.totalPrice, agency.commissionPct),
period: yerevanMonthKey(now),
},
});
}
return updated;
});
}
export function isOrderActive(flowCode: Parameters<typeof isTerminal>[0], status: OrderStatus) {
return !isTerminal(flowCode, status);
}