Files
BarevClub/packages/services/src/orders.ts
T
2026-10-25 22:57:03 +04:00

490 lines
25 KiB
TypeScript

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';
import { recordPartnerEarning } from './servicePartners.ts';
import { findPromo } from './promo.ts';
import { accrueForOrder, onOrderCancelled, redeemPoints, takePointsEvents } from './points.ts';
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>, promoCode?: string | null) {
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 memberPct = discountFor(membership.level, service.categoryDiscountPct, service.categoryEliteDiscountPct);
// промокод не складывается со скидкой подписки — берём большую
const promo = promoCode?.trim() ? await findPromo(ctx.db, { code: promoCode, clientId, category: service.category, now: ctx.now() }) : null;
const promoWins = !!promo && promo.discountPct! > memberPct;
const q = quote(service.pricing, params.values, promoWins ? promo!.discountPct! : memberPct);
return { service, params: params.values, quote: q, membership, promo: promoWins ? promo : null };
}
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;
/** заказ оформила команда за клиента (новая админка) */
createdBy?: { type: 'ADMIN'; id: string };
/** оператор ставит время, даже если по графику все заняты (договорился с исполнителем сам) */
allowBusySlot?: boolean;
/** промокод на скидку (применяется, если выгоднее скидки подписки) */
promoCode?: string | null;
/** оплатить часть бариками: сумма (не больше N% и баланса); с промокодом нельзя */
usePoints?: number;
}
/** 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, promo } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params, input.promoCode);
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
const flow = getFlow(service.flow);
if (input.usePoints && promo) throw new DomainError('POINTS_WITH_PROMO');
const pointEvents: DomainEvent[] = [];
const order = await ctx.db.$transaction(
async (tx) => {
// сериализуем создание заказов одной роли, чтобы не продать слот дважды
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
else if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
const created = await 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,
priceBreakdown: q.breakdown as unknown as Prisma.InputJsonValue,
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: input.createdBy ? { toStatus: flow.initial, actorType: 'ADMIN', actorId: input.createdBy.id, event: 'createdByStaff' } : { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
},
});
if (promo) {
await tx.promoCodeUse.create({ data: { promoId: promo.id, clientId: input.clientId, orderId: created.id } });
await tx.promoCode.update({ where: { id: promo.id }, data: { usedCount: { increment: 1 } } });
}
if (input.usePoints && input.usePoints > 0) {
await redeemPoints(ctx, { clientId: input.clientId, amount: input.usePoints, orderId: created.id, orderRef: `#${created.number}`, orderAmount: q.totalPrice, channel: input.createdBy ? 'WEB' : 'CLIENT' }, { adminId: input.createdBy?.id ?? null, owner: true }, tx);
pointEvents.push(...takePointsEvents(tx));
return tx.order.update({ where: { id: created.id }, data: { pointsUsed: input.usePoints } });
}
return created;
},
{ isolationLevel: 'ReadCommitted' },
);
await ctx.notify.emit({ type: 'order.created', orderId: order.id });
for (const e of pointEvents) await ctx.notify.emit(e);
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 || i.photoWaivedReason)),
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,
},
});
// барики: отмена возвращает списанные и отменяет начисление; выполненный и оплаченный заказ — кэшбек
if (input.to === 'CANCELLED') await onOrderCancelled(ctx, tx, order.id);
else if (flow.done.includes(input.to)) await accrueForOrder(ctx, tx, order.id);
return { order: updated, from: order.status, extra: takePointsEvents(tx) };
};
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);
for (const e of result.extra) await ctx.notify.emit(e);
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);
for (const e of result.extra) await ctx.notify.emit(e);
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);
}
/**
* Перенос командой: в любой момент до начала работы. Если назначенный исполнитель занят в новое время —
* заказ возвращается в «Новые» (и уйдёт в автораспределение). allowBusySlot — время вне свободных слотов.
*/
export async function rescheduleByAdmin(ctx: Ctx, input: { orderId: string; adminId: string; scheduledAt: Date; allowBusySlot?: boolean; comment?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order) throw new DomainError('NOT_FOUND');
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial && order.status !== flow.assigned) throw new DomainError('TRANSITION_NOT_ALLOWED');
if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
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}))`;
if (!input.allowBusySlot) 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.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED', providerId: { not: null } }, data: { status: 'REVOKED', respondedAt: ctx.now() } });
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: 'ADMIN', actorId: input.adminId, comment: input.comment },
});
if (order.providerId && !keepProvider) {
const r = await transitionOrder(ctx, { orderId: order.id, to: flow.initial, actor: 'ADMIN', actorId: input.adminId, event: 'rescheduled', data: { providerId: null, companyId: 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);
return { keptProvider: events.length === 1 };
}
/** 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);
const events: DomainEvent[] = [];
const res = await 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 - order.pointsUsed, 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 - order.pointsUsed, 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 - order.pointsUsed, agency.commissionPct),
period: yerevanMonthKey(now),
},
});
}
// заказ выполнил сотрудник партнёра — фиксируем доход партнёра по его условиям
await recordPartnerEarning(tx, order, settings, now);
// уже выполнен — сразу кэшбек
await accrueForOrder(ctx, tx, order.id);
events.push(...takePointsEvents(tx));
return updated;
});
for (const e of events) await ctx.notify.emit(e);
return res;
}
export function isOrderActive(flowCode: Parameters<typeof isTerminal>[0], status: OrderStatus) {
return !isTerminal(flowCode, status);
}