Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
490 lines
25 KiB
TypeScript
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);
|
|
}
|