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, 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; 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; 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 { 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, { 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, 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[0], status: OrderStatus) { return !isTerminal(flowCode, status); }