Bariki cashback, order edit, and current work in progress

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-25 22:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+54 -25
View File
@@ -7,6 +7,8 @@ import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
import { transitionOrder } from './orders.ts';
import { orderPayout } from './payouts.ts';
import { companyAutoAssign, companyAvailability, companySplit, expireCompanyOffers } from './servicePartners.ts';
const DAY_MS = 24 * 3600_000;
@@ -20,6 +22,7 @@ function interval(o: { scheduledAt: Date; durationMinutes: number }) {
*/
export async function assignmentCandidates(ctx: Ctx, orderId: string) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } });
const settings = await getSettings(ctx.db);
const slot = interval(order);
const dayStart = startOfYerevanDay(order.scheduledAt);
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
@@ -27,7 +30,7 @@ export async function assignmentCandidates(ctx: Ctx, orderId: string) {
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.provider.findMany({ where: { role, active: true }, include: { districts: true, payoutPreset: true } }),
ctx.db.providerCompany.findMany({ where: { active: true, roles: { has: role } } }),
]);
const availById = new Map(availability.map((a) => [a.id, a]));
@@ -36,26 +39,37 @@ export async function assignmentCandidates(ctx: Ctx, orderId: string) {
const a = availById.get(id);
return !!a && isProviderFree(a, slot);
};
/** что мы платим исполнителю и что остаётся нам (до налога) — оператор выбирает самый выгодный вариант */
const money = new Map<string, { cost: number; margin: number; partnerFree?: number; partnerStaff?: number }>();
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,
}));
.map((p) => {
const cost = orderPayout(order, { ...p, company: null }, { fotPct: settings['partner.fotPct'] });
money.set(`provider:${p.id}`, { cost, margin: order.totalPrice - cost });
return {
kind: 'provider' as const,
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 avail = await companyAvailability(ctx.db, c.id, order.id, settings);
const freeIds = new Set(avail.rows.filter((r) => r.check === 'ok' || r.check === 'notLinked').map((r) => r.id));
const free = staff.filter((p) => freeIds.has(p.id));
const ratings = staff.filter((p) => p.ratingCount > 0);
const split = companySplit(c, order.totalPrice, settings);
money.set(`company:${c.id}`, { cost: split.cost, margin: split.margin, partnerFree: free.length, partnerStaff: staff.length });
candidates.push({
kind: 'company',
id: c.id,
@@ -74,13 +88,16 @@ export async function assignmentCandidates(ctx: Ctx, orderId: string) {
});
}
const withMoney = <T extends Candidate>(c: T) => ({ ...c, ...(money.get(`${c.kind}:${c.id}`) ?? { cost: 0, margin: order.totalPrice }) });
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 };
}).map(withMoney);
const busy = candidates.filter((c) => !c.free).map(withMoney);
// самый выгодный для нас из доступных
const best = ranked.reduce<(typeof ranked)[number] | null>((b, c) => (!b || c.margin > b.margin ? c : b), null);
return { ranked, busy, bestId: best ? `${best.kind}:${best.id}` : null, paid: order.totalPrice };
}
async function revokeActiveOffers(tx: Tx, orderId: string) {
@@ -136,9 +153,9 @@ export async function offerOrder(
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 } });
// предложение компании без таймаута — заказ «Назначен на компанию», пока диспетчер не выберет сотрудника
// заказ «Назначен на партнёра», пока менеджер не выберет сотрудника; не успел за N минут — заказ вернётся оператору
const offer = await tx.orderOffer.create({
data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId },
data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId, expiresAt: new Date(ctx.now().getTime() + settings['partner.acceptTimeoutMinutes'] * 60_000) },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'assignedToCompany', actorType: input.adminId ? 'ADMIN' : 'SYSTEM', actorId: input.adminId, comment: company.name },
@@ -147,6 +164,7 @@ export async function offerOrder(
return offer;
});
for (const e of events) await ctx.notify.emit(e);
if (input.target.kind === 'company') await companyAutoAssign(ctx, input.orderId);
return offer;
}
@@ -182,6 +200,11 @@ export async function respondToOffer(ctx: Ctx, input: { offerId: string; provide
return declineOffer(ctx, tx, input.offerId, 'DECLINED', input.providerId);
});
if (event) await ctx.notify.emit(event);
// сотрудник партнёра отказался — автораспределение партнёра предложит следующему
if (!input.accept) {
const offer = await ctx.db.orderOffer.findUnique({ where: { id: input.offerId } });
if (offer?.companyId) await companyAutoAssign(ctx, offer.orderId);
}
}
async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED' | 'EXPIRED', providerId?: string): Promise<DomainEvent> {
@@ -190,8 +213,9 @@ async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED
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' } });
// сотрудник отказался — заказ снова у менеджера партнёра (срок на назначение — заново)
const settings = await getSettings(tx);
await tx.orderOffer.create({ data: { orderId: offer.orderId, companyId, status: 'OFFERED', expiresAt: new Date(ctx.now().getTime() + settings['partner.acceptTimeoutMinutes'] * 60_000) } });
} else {
await tx.order.update({ where: { id: offer.orderId }, data: { companyId: null } });
}
@@ -211,18 +235,21 @@ async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED
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 },
select: { id: true, orderId: true, companyId: true },
});
let n = 0;
for (const { id } of expired) {
for (const { id, orderId, companyId } of expired) {
try {
const event = await ctx.db.$transaction((tx) => declineOffer(ctx, tx, id, 'EXPIRED'));
await ctx.notify.emit(event);
n++;
if (companyId) await companyAutoAssign(ctx, orderId);
} catch (e) {
if (!(e instanceof DomainError)) throw e;
}
}
// партнёр не назначил сотрудника вовремя — заказ возвращается оператору
await expireCompanyOffers(ctx);
return n;
}
@@ -249,7 +276,9 @@ export async function dispatcherAssign(ctx: Ctx, input: { orderId: string; compa
(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() } });
// предложение партнёру принято; ожидающее предложение другому сотруднику (автораспределение) — отозвано
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED', providerId: null }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } });
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED', providerId: { not: null } }, data: { status: 'REVOKED', respondedAt: ctx.now() } });
if (flow.assigned) {
const r = await transitionOrder(
ctx,
@@ -334,9 +363,9 @@ export async function autoAssign(ctx: Ctx, orderId: string, opts: { force?: bool
}
const triedProviders = new Set(order.offers.map((o) => o.providerId).filter((x): x is string => !!x));
const triedCompanies = new Set(order.offers.filter((o) => !o.providerId).map((o) => o.companyId).filter((x): x is string => !!x));
const { ranked } = await assignmentCandidates(ctx, order.id);
const pick = ranked.find((c) => (c.kind === 'company' ? !triedCompanies.has(c.id) : !triedProviders.has(c.id)));
// партнёрам заказы назначает оператор (видит, что выгоднее); система предлагает только частным исполнителям
const pick = ranked.find((c) => c.kind === 'provider' && !triedProviders.has(c.id));
if (!pick) {
if (lastAuto === 'autoNoCandidate') return { result: 'none', first: false };
await mark('autoNoCandidate');
+16 -10
View File
@@ -9,9 +9,10 @@ import {
type Locale,
type MembershipLevel,
} from '@barev/core';
import type { Client, Db, Tx } from '@barev/db';
import type { Client, Db, SubscriptionPlan, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { getSettings } from './settings.ts';
import { grantHousewarming } from './points.ts';
export interface TelegramUserInput {
id: number | bigint;
@@ -58,7 +59,7 @@ export interface StartResult {
locale: Locale;
agencyName: string | null;
/** подарок активирован в этом /start */
gift: { endsAt: Date } | null;
gift: { endsAt: Date; plan: SubscriptionPlan } | null;
}
/**
@@ -136,12 +137,12 @@ export async function giveConsent(ctx: Ctx, clientId: string) {
}
/**
* ONB-4 / SUB-3: подарочный месяц Barev+ клиенту агентства, один раз.
* Возвращает дату окончания или null, если не положен.
* ONB-4 / SUB-3: подарок клиенту агентства, один раз. Уровень (Barev+ / Elite) и срок в месяцах задаются у агентства.
* Elite при действующем Barev+ начинается сразу (уровень выше), в остальных случаях — после текущей подписки.
* Возвращает уровень и дату окончания или null, если подарок не положен.
*/
export async function activateAgencyGift(ctx: Ctx, clientId: string): Promise<{ endsAt: Date } | null> {
export async function activateAgencyGift(ctx: Ctx, clientId: string): Promise<{ endsAt: Date; plan: SubscriptionPlan } | 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`;
@@ -150,23 +151,28 @@ export async function activateAgencyGift(ctx: Ctx, clientId: string): Promise<{
include: { agency: true, subscriptions: true },
});
if (!client.agency || !client.consentAt) return null;
const plan = client.agency.giftPlan;
if (!plan) return null;
const now = ctx.now();
const current = activeSubscription(client.subscriptions, now);
const extends_ = current && (current.plan === plan || current.plan === 'ELITE');
const decision = decideGift({
now,
giftUsedAt: client.giftUsedAt,
agencyActive: client.agency.active,
giftDays: settings['barevPlus.giftDays'],
currentEndsAt: current?.endsAt ?? null,
giftMonths: client.agency.giftMonths,
currentEndsAt: extends_ ? current.endsAt : null,
});
if (!decision.ok) return null;
await tx.subscription.create({
data: { clientId, source: 'AGENCY_GIFT', startsAt: decision.startsAt, endsAt: decision.endsAt },
data: { clientId, plan, source: 'AGENCY_GIFT', agencyId: client.agency.id, startsAt: decision.startsAt, endsAt: decision.endsAt },
});
await tx.client.update({ where: { id: clientId }, data: { giftUsedAt: now } });
return { endsAt: decision.endsAt };
return { endsAt: decision.endsAt, plan };
});
if (result) await ctx.notify.emit({ type: 'gift.activated', clientId, endsAt: result.endsAt.toISOString() });
// барики на новоселье от агентства — тоже один раз
await grantHousewarming(ctx, clientId);
return result;
}
+30 -2
View File
@@ -8,11 +8,17 @@ export type DomainEvent =
/** событие без смены статуса, например «Я на месте» */
| { type: 'order.event'; orderId: string; code: string }
| { type: 'order.rescheduled'; orderId: string }
/** команда изменила состав заказа (и цену) — сообщить клиенту и исполнителю */
| { type: 'order.edited'; 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: 'company.noStaff'; orderId: string; companyId: string }
/** партнёр отказался от заказа или не взял его вовремя — заказ снова у оператора */
| { type: 'partner.returned'; orderId: string; companyId: string; reason: 'declined' | 'expired' }
| { type: 'order.reminder'; orderId: string; hours: 24 | 1 }
| { type: 'gift.activated'; clientId: string; endsAt: string }
/** оператор сменил статус шага переезда — клиенту сообщение в бота */
@@ -31,7 +37,19 @@ export type DomainEvent =
| { type: 'broadcast.start'; broadcastId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
| { type: 'webRequest.created'; requestId: string }
/** барики: клиенту — начислено, стали доступны, списано, запрос отклонён, отмена, скоро сгорят, сгорели */
| { type: 'points.accrued'; accrualId: string }
| { type: 'points.activated'; accrualId: string }
| { type: 'points.redeemed'; redemptionId: string }
| { type: 'points.rejected'; redemptionId: string }
| { type: 'points.reversed'; reversalId: string }
| { type: 'points.expiring'; clientId: string; amount: number; days: number }
| { type: 'points.expired'; clientId: string; amount: number }
/** барики: команде — запрос клиента, предложения системы, операция поддержки сверх лимита */
| { type: 'points.requested'; redemptionId: string }
| { type: 'points.suggestions'; count: number }
| { type: 'points.overLimit'; suggestionId: string };
export interface Notifier {
emit(event: DomainEvent): Promise<void> | void;
@@ -94,7 +112,17 @@ export type DomainErrorCode =
| 'PROMO_INVALID'
| 'PROMO_EXPIRED'
| 'PROMO_USED'
| 'PROMO_NOT_FOR_SERVICE';
| 'PROMO_NOT_FOR_SERVICE'
| 'POINTS_DUPLICATE_ORDER'
| 'POINTS_REASON_REQUIRED'
| 'POINTS_BELOW_MIN'
| 'POINTS_NOT_ENOUGH'
| 'POINTS_OVER_SHARE'
| 'POINTS_OVER_LIMIT'
| 'POINTS_FINAL'
| 'POINTS_REQUEST_CLOSED'
| 'POINTS_TOO_MANY_REQUESTS'
| 'POINTS_WITH_PROMO';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+3
View File
@@ -20,3 +20,6 @@ export * from './texts.ts';
export * from './promo.ts';
export * from './partners.ts';
export * from './ratings.ts';
export * from './servicePartners.ts';
export * from './points.ts';
export * from './orderEdit.ts';
+134
View File
@@ -0,0 +1,134 @@
/**
* Команда меняет заказ: состав услуги (тип уборки, комнаты, доп. услуги) с пересчётом цены и длительности
* и чек-лист за исполнителя. Менять можно всегда — и после оплаты: тогда пересчитываются начисления
* (вознаграждение агентства, доход партнёра, барики), а разницу в деньгах команда решает с клиентом сама.
*/
import { agencyCommission, isProviderFree, quote, validateParams } from '@barev/core';
import type { Prisma } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { getServiceByCode } from './catalog.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { accrueForOrder, reverseAccrual, takePointsEvents } from './points.ts';
import { companySplit } from './servicePartners.ts';
import { getSettings } from './settings.ts';
export interface OrderEditInput {
orderId: string;
adminId: string;
params: Record<string, unknown>;
/** своя итоговая цена (с причиной); пусто — по прайсу с прежней скидкой */
totalPrice?: number | null;
reason?: string | null;
/** сообщить клиенту и исполнителю */
notify?: boolean;
}
/** Что получится: новая цена, длительность, успевает ли исполнитель (если заказ стал дольше) */
export async function previewOrderEdit(ctx: Ctx, input: Pick<OrderEditInput, 'orderId' | 'params'>) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order) throw new DomainError('NOT_FOUND');
const service = await getServiceByCode(ctx.db, order.service.code);
const params = validateParams(service.paramsSchema, input.params);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
// скидка — та же, что была при оформлении (подписка или промокод)
const q = quote(service.pricing, params.values, order.discountPct);
let providerBusy = false;
if (order.providerId && q.durationMinutes > order.durationMinutes) {
const end = new Date(order.scheduledAt.getTime() + q.durationMinutes * 60_000);
const [p] = (await loadAvailability(ctx.db, service.providerRole, order.scheduledAt, end, { excludeOrderId: order.id })).filter((x) => x.id === order.providerId);
providerBusy = !p || !isProviderFree(p, { start: order.scheduledAt, end });
}
return {
params: params.values,
basePrice: q.basePrice,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
durationMinutes: q.durationMinutes,
breakdown: q.breakdown,
was: { totalPrice: order.totalPrice, durationMinutes: order.durationMinutes },
paid: order.paymentStatus === 'PAID',
pointsUsed: order.pointsUsed,
providerBusy,
};
}
export async function editOrderComposition(ctx: Ctx, input: OrderEditInput) {
const p = await previewOrderEdit(ctx, input);
const manual = input.totalPrice != null && input.totalPrice !== p.totalPrice;
const reason = input.reason?.trim() || null;
if (manual && !reason) throw new DomainError('REASON_REQUIRED');
const total = manual ? Math.max(0, Math.round(input.totalPrice!)) : p.totalPrice;
if (p.pointsUsed > total) throw new DomainError('POINTS_OVER_SHARE');
const settings = await getSettings(ctx.db);
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: { client: { include: { agency: true } }, commission: true, partnerEarning: true } });
if (order.status === 'CANCELLED') throw new DomainError('TRANSITION_NOT_ALLOWED');
await tx.order.update({
where: { id: order.id },
data: {
params: p.params as Prisma.InputJsonValue,
priceBreakdown: p.breakdown as unknown as Prisma.InputJsonValue,
basePrice: p.basePrice,
discountAmount: manual ? Math.max(0, p.basePrice - total) : p.discountAmount,
totalPrice: total,
durationMinutes: p.durationMinutes,
},
});
const comment = [`${order.totalPrice} → ${total}`, reason].filter(Boolean).join(' · ');
await tx.orderStatusHistory.create({ data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'editedByStaff', actorType: 'ADMIN', actorId: input.adminId, comment } });
if (order.paymentStatus === 'PAID' && total !== order.totalPrice) {
const paidMoney = total - order.pointsUsed;
// начисления, зафиксированные при оплате, — по новой сумме
if (order.commission) await tx.agencyCommission.update({ where: { id: order.commission.id }, data: { amount: agencyCommission(paidMoney, order.commission.pct) } });
if (order.partnerEarning) {
const company = await tx.providerCompany.findUnique({ where: { id: order.partnerEarning.companyId } });
if (company) {
const s = companySplit(company, total, settings);
await tx.partnerEarning.update({ where: { id: order.partnerEarning.id }, data: { paid: s.paid, fot: s.fot, tax: s.tax, amount: s.partner } });
}
}
// кэшбек за заказ: отменяем прежнее начисление и начисляем по новой цене
const acc = await tx.pointsAccrual.findFirst({ where: { orderId: order.id, channel: 'AUTO', status: { in: ['PENDING', 'ACTIVE'] } } });
if (acc) {
await reverseAccrual(ctx, { accrualId: acc.id, reason: 'order_edited', channel: 'AUTO' }, { adminId: null, owner: true }, tx);
await accrueForOrder(ctx, tx, order.id);
}
events.push(...takePointsEvents(tx));
}
if (input.notify) events.push({ type: 'order.edited', orderId: order.id });
});
for (const e of events) await ctx.notify.emit(e);
return { totalPrice: total, durationMinutes: p.durationMinutes, providerBusy: p.providerBusy };
}
/** Команда отмечает пункт чек-листа за исполнителя: с фото или без (тогда — причина) */
export async function staffChecklistItem(ctx: Ctx, input: { orderId: string; adminId: string; code: string; checked: boolean; fileId?: string; reason?: string | null }) {
const item = await ctx.db.orderChecklistItem.findUnique({ where: { orderId_code: { orderId: input.orderId, code: input.code } } });
if (!item) throw new DomainError('NOT_FOUND');
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: input.orderId } });
if (order.status === 'CANCELLED') throw new DomainError('TRANSITION_NOT_ALLOWED');
if (input.fileId) {
const f = await ctx.db.orderFile.findUnique({ where: { id: input.fileId } });
if (!f || f.orderId !== input.orderId) throw new DomainError('NOT_FOUND');
}
const fileId = input.fileId ?? item.fileId;
const reason = input.reason?.trim() || null;
if (input.checked && item.requiresPhoto && !fileId && !reason) throw new DomainError('PHOTO_REQUIRED');
await ctx.db.$transaction([
ctx.db.orderChecklistItem.update({
where: { id: item.id },
data: input.checked
? { checkedAt: ctx.now(), fileId, checkedByAdminId: input.adminId, photoWaivedReason: item.requiresPhoto && !fileId ? reason : null }
: { checkedAt: null, checkedByAdminId: null, photoWaivedReason: null },
}),
ctx.db.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: input.checked ? 'checklistByStaff' : 'checklistUncheckedByStaff', actorType: 'ADMIN', actorId: input.adminId, comment: [input.code, reason].filter(Boolean).join(' · ') },
}),
]);
}
+31 -6
View File
@@ -25,7 +25,9 @@ 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 });
@@ -85,6 +87,8 @@ export interface CreateOrderInput {
allowBusySlot?: boolean;
/** промокод на скидку (применяется, если выгоднее скидки подписки) */
promoCode?: string | null;
/** оплатить часть бариками: сумма (не больше N% и баланса); с промокодом нельзя */
usePoints?: number;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
@@ -94,6 +98,8 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
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) => {
@@ -134,11 +140,17 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
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;
}
@@ -150,7 +162,7 @@ export async function guardContext(db: Db | Tx, orderId: string): Promise<GuardC
db.lessonPackage.findUnique({ where: { orderId } }),
]);
return {
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId)),
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId || i.photoWaivedReason)),
estimateAccepted: estimate?.status === 'ACCEPTED',
lessonsTotal: lessons?.total,
lessonsCompleted: lessons?.completed,
@@ -211,12 +223,16 @@ export async function transitionOrder(ctx: Ctx, input: TransitionInput, txArg?:
comment: input.comment,
},
});
return { order: updated, from: order.status };
// барики: отмена возвращает списанные и отменяет начисление; выполненный и оплаченный заказ — кэшбек
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 };
}
@@ -304,6 +320,7 @@ export async function cancelByClient(ctx: Ctx, input: { orderId: string; clientI
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 };
}
@@ -426,18 +443,19 @@ export async function repeatDraft(db: Db, orderId: string, clientId: string) {
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 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, currency: 'AMD', description: `#${order.number}` },
{ 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, status: 'PAID', markedById: input.by.id },
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 },
@@ -450,13 +468,20 @@ export async function markPaid(ctx: Ctx, input: { orderId: string; by: { type: '
agencyId: agency.id,
orderId: order.id,
pct: agency.commissionPct,
amount: agencyCommission(order.totalPrice, 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) {
+10 -7
View File
@@ -1,4 +1,5 @@
import {
DEFAULT_SETTINGS,
defaultParams,
paramsSchemaSchema,
payoutByRules,
@@ -16,7 +17,8 @@ type Rate = { type: RateType; value: number };
export interface PayoutProvider {
rateType: RateType;
rateValue: number;
company: { rateType: RateType; rateValue: number } | null;
/** сотрудник партнёра услуг: ФОТ платим мы только в модели «расходы наши» */
company: { deal: 'PROFIT_SHARE' | 'COMMISSION'; fotPct: number | null } | null;
payoutPreset: { rules: unknown } | null;
}
@@ -41,13 +43,13 @@ export function breakdownOf(o: PayoutOrder): PriceBreakdown | null {
}
/**
* PRO-8: выплата исполнителю за заказ.
* - сотрудник компании — договорная ставка компании (от итоговой цены);
* PRO-8: выплата исполнителю за заказ (от нас).
* - сотрудник партнёра: «расходы наши» — ФОТ, % от оплаченного; «как Uber» — 0, ему платит партнёр;
* - исполнитель с пресетом (джун / мидл / прайват) — фикс или % по каждой строке цены;
* - иначе — индивидуальная ставка (фикс за заказ или % от итога).
*/
export function orderPayout(o: PayoutOrder, p: PayoutProvider): number {
if (p.company) return payoutFor(o.totalPrice, { type: p.company.rateType, value: p.company.rateValue });
export function orderPayout(o: PayoutOrder, p: PayoutProvider, defaults: { fotPct: number } = { fotPct: DEFAULT_SETTINGS['partner.fotPct'] }): number {
if (p.company) return p.company.deal === 'PROFIT_SHARE' ? Math.floor((o.totalPrice * (p.company.fotPct ?? defaults.fotPct)) / 100) : 0;
if (p.payoutPreset) {
const rules = payoutRulesSchema.safeParse(p.payoutPreset.rules);
const b = breakdownOf(o);
@@ -58,8 +60,9 @@ export function orderPayout(o: PayoutOrder, p: PayoutProvider): number {
}
/** Описание ставки для экрана «История» исполнителя */
export function rateLabel(p: PayoutProvider & { payoutPreset: { name: string; rules: unknown } | null }) {
if (p.company) return { kind: 'company' as const, type: p.company.rateType, value: p.company.rateValue };
export function rateLabel(p: PayoutProvider & { payoutPreset: { name: string; rules: unknown } | null }, defaults: { fotPct: number } = { fotPct: DEFAULT_SETTINGS['partner.fotPct'] }) {
// «как Uber»: зарплату сотруднику платит партнёр — value пусто
if (p.company) return { kind: 'company' as const, type: 'PERCENT' as RateType, value: p.company.deal === 'PROFIT_SHARE' ? (p.company.fotPct ?? defaults.fotPct) : null };
if (p.payoutPreset) return { kind: 'preset' as const, name: p.payoutPreset.name };
return { kind: 'own' as const, type: p.rateType, value: p.rateValue };
}
+755
View File
@@ -0,0 +1,755 @@
/**
* Барики — кэшбек клуба. Журнал начислений (PointsAccrual) и списаний (PointsRedemption) — источник правды;
* баланс у клиента — кэш, который пересчитывается в той же транзакции, что и запись в журнал.
* Отмена ничего не удаляет: создаёт PointsReversal и меняет статус исходной записи.
*/
import {
DAY,
fifoPlan,
getFlow,
membershipLevel,
nextStreak,
redeemLimit,
streakBonusPct,
suggestAccrual,
tierRate,
type Settings,
type Tier,
} from '@barev/core';
import type { Db, PointsChannel, Prisma, Tx } from '@barev/db';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
const HOUR = 3600_000;
/** Кто выполняет операцию: суперадмин, сотрудник поддержки (с лимитами) или система */
export interface PointsActor {
adminId: string | null;
/** суперадмин: без лимитов, отменяет любые операции */
owner: boolean;
}
const SYSTEM: PointsActor = { adminId: null, owner: true };
type Out = { events: DomainEvent[] };
async function inTx<T>(ctx: Ctx, txArg: Tx | undefined, fn: (tx: Tx, out: Out) => Promise<T>): Promise<T> {
const out: Out = { events: [] };
if (txArg) {
const r = await fn(txArg, out);
// внутри чужой транзакции события отдаём вызывающему через pendingEvents
pendingEvents.get(txArg)?.push(...out.events) ?? pendingEvents.set(txArg, out.events);
return r;
}
const r = await ctx.db.$transaction((tx) => fn(tx, out));
for (const e of out.events) await ctx.notify.emit(e);
return r;
}
/** События бариков, созданные внутри транзакции вызывающего (заказы) — он отправит их после коммита */
const pendingEvents = new WeakMap<Tx, DomainEvent[]>();
export function takePointsEvents(tx: Tx): DomainEvent[] {
const e = pendingEvents.get(tx) ?? [];
pendingEvents.delete(tx);
return e;
}
async function lockClient(tx: Tx, clientId: string) {
await tx.$queryRaw`SELECT id FROM "Client" WHERE id = ${clientId} FOR UPDATE`;
return tx.client.findUniqueOrThrow({ where: { id: clientId } });
}
/** Пересчитать кэш баланса из журнала */
export async function recalcPoints(tx: Tx | Db, clientId: string) {
const [active, pending, client] = await Promise.all([
tx.pointsAccrual.aggregate({ where: { clientId, status: 'ACTIVE' }, _sum: { remaining: true } }),
tx.pointsAccrual.aggregate({ where: { clientId, status: 'PENDING' }, _sum: { remaining: true } }),
tx.client.findUniqueOrThrow({ where: { id: clientId }, select: { pointsDebt: true } }),
]);
const pointsActive = (active._sum.remaining ?? 0) - client.pointsDebt;
const pointsPending = pending._sum.remaining ?? 0;
await tx.client.update({ where: { id: clientId }, data: { pointsActive, pointsPending } });
return { active: pointsActive, pending: pointsPending };
}
const touch = (tx: Tx, clientId: string, now: Date) => tx.client.update({ where: { id: clientId }, data: { pointsActivityAt: now, pointsWarn14At: null, pointsWarn3At: null } });
/** Уровень клиента на дату услуги */
export async function tierAt(db: Db | Tx, clientId: string, at: Date): Promise<Tier> {
const subs = await db.subscription.findMany({ where: { clientId, startsAt: { lte: at }, endsAt: { gt: at } } });
return membershipLevel(subs, at);
}
/** Категория кэшбека услуги: из справочника, иначе — по коду услуги / направлению */
export async function pointsCategoryOf(db: Db | Tx, service: { code: string; pointsCategoryId: string | null; category?: { code: string } | null; categoryId: string }) {
if (service.pointsCategoryId) return db.pointsCategory.findUnique({ where: { id: service.pointsCategoryId } });
let code = ['cleaning', 'repair', 'transfer'].includes(service.code) ? service.code : null;
if (!code) {
const cat = service.category ?? (await db.category.findUnique({ where: { id: service.categoryId } }));
code = cat?.code === 'DOCS' ? 'documents' : cat?.code === 'LEARNING' ? 'learning' : null;
}
return code ? db.pointsCategory.findUnique({ where: { code } }) : null;
}
async function opLimits(tx: Tx, actor: PointsActor, amount: number, settings: Settings, now: Date) {
if (actor.owner || !actor.adminId) return { ok: true as const };
if (!settings['points.supportCanManage']) throw new DomainError('FORBIDDEN');
if (amount > settings['points.opMaxPerOperation']) return { ok: false as const };
const dayStart = new Date(now.getTime() - 24 * HOUR);
const [acc, red] = await Promise.all([
tx.pointsAccrual.aggregate({ where: { createdById: actor.adminId, createdAt: { gte: dayStart } }, _sum: { amount: true } }),
tx.pointsRedemption.aggregate({ where: { confirmedById: actor.adminId, confirmedAt: { gte: dayStart } }, _sum: { amount: true } }),
]);
if ((acc._sum.amount ?? 0) + (red._sum.amount ?? 0) + amount > settings['points.opMaxPerDay']) return { ok: false as const };
return { ok: true as const };
}
// ───── начисление ─────
export interface AccrualInput {
clientId: string;
kind: 'ORDER' | 'BONUS';
/** ORDER: категория кэшбека */
categoryId?: string;
/** BONUS: тип бонуса */
bonusTypeId?: string;
orderId?: string;
orderRef?: string | null;
orderAmount?: number;
excludedAmount?: number;
paidWithPoints?: number;
serviceDate: Date;
/** итоговая сумма; пусто — подсказка (ORDER) или сумма бонуса по умолчанию (BONUS) */
amount?: number;
/** причина: обязательна, если сумма изменена против подсказки, или для «без ожидания» у поддержки */
reason?: string | null;
commentForClient?: string | null;
/** сразу доступны, без ожидания */
noWait?: boolean;
/** свой срок жизни бонуса, дней (пусто — из типа бонуса) */
ttlDays?: number | null;
channel: PointsChannel;
idempotencyKey?: string | null;
}
/** Подсказка для формы начисления: уровень на дату услуги, ставка, надбавка за серию уборок, сумма, когда станут доступны */
export async function previewAccrual(db: Db | Tx, input: Pick<AccrualInput, 'clientId' | 'categoryId' | 'orderAmount' | 'excludedAmount' | 'paidWithPoints' | 'serviceDate'>, settings: Settings, now: Date) {
const [client, category] = await Promise.all([
db.client.findUniqueOrThrow({ where: { id: input.clientId } }),
input.categoryId ? db.pointsCategory.findUnique({ where: { id: input.categoryId } }) : null,
]);
if (!category) throw new DomainError('NOT_FOUND');
const tier = await tierAt(db, input.clientId, input.serviceDate);
const streak = category.code === 'cleaning' ? nextStreak({ count: client.streakCount, lastAt: client.streakLastAt }, input.serviceDate, settings['points.streakGapDays']) : 0;
const bonus = category.code === 'cleaning' ? streakBonusPct(streak) : 0;
const ratePct = tierRate(category, tier) + bonus;
const suggested = suggestAccrual({ orderAmount: input.orderAmount ?? 0, excludedAmount: input.excludedAmount, paidWithPoints: input.paidWithPoints, ratePct });
const availableAt = new Date(input.serviceDate.getTime() + settings['points.pendingHours'] * HOUR);
return { tier, baseRatePct: tierRate(category, tier), streak, streakBonusPct: bonus, ratePct, suggested, availableAt: availableAt > now ? availableAt : now, category: { id: category.id, code: category.code } };
}
export type AccrualResult = { status: 'created'; accrualId: string; amount: number } | { status: 'needs_approval'; suggestionId: string } | { status: 'skipped' };
/** Начислить (ORDER по заказу или BONUS). Блокирует строку клиента; номер заказа второй раз не начисляется */
export async function accruePoints(ctx: Ctx, input: AccrualInput, actor: PointsActor = SYSTEM, txArg?: Tx): Promise<AccrualResult> {
const settings = await getSettings(txArg ?? ctx.db);
return inTx(ctx, txArg, async (tx, out) => {
if (input.idempotencyKey) {
const prev = await tx.pointsAccrual.findUnique({ where: { idempotencyKey: input.idempotencyKey } });
if (prev) return { status: 'created', accrualId: prev.id, amount: prev.amount };
}
const client = await lockClient(tx, input.clientId);
const now = ctx.now();
const orderRef = input.orderRef?.trim() || null;
if (orderRef || input.orderId) {
const dup = await tx.pointsAccrual.findFirst({ where: { status: { not: 'REVERSED' }, OR: [orderRef ? { orderRef } : {}, input.orderId ? { orderId: input.orderId } : {}].filter((x) => Object.keys(x).length) } });
if (dup) throw new DomainError('POINTS_DUPLICATE_ORDER');
}
let amount: number;
let suggested: number | null = null;
let extra: Partial<Prisma.PointsAccrualUncheckedCreateInput> = {};
let availableAt = now;
let status: 'PENDING' | 'ACTIVE' = 'ACTIVE';
let expiresAt: Date | null = null;
let streak: number | null = null;
if (input.kind === 'ORDER') {
if (!input.categoryId || !input.orderAmount || input.orderAmount < 0) throw new DomainError('INVALID_PARAMS');
const p = await previewAccrual(tx, input, settings, now);
suggested = p.suggested;
amount = input.amount ?? p.suggested;
extra = { categoryId: p.category.id, tier: p.tier, ratePct: p.ratePct, streakBonusPct: p.streakBonusPct, orderAmount: input.orderAmount, excludedAmount: input.excludedAmount ?? 0, paidWithPoints: input.paidWithPoints ?? 0 };
availableAt = p.availableAt;
status = input.noWait || availableAt <= now ? 'ACTIVE' : 'PENDING';
if (input.noWait) availableAt = now;
if (p.category.code === 'cleaning') streak = p.streak;
} else {
const bt = input.bonusTypeId ? await tx.pointsBonusType.findUnique({ where: { id: input.bonusTypeId } }) : null;
if (!bt) throw new DomainError('NOT_FOUND');
suggested = bt.defaultAmount;
amount = input.amount ?? bt.defaultAmount;
const ttl = input.ttlDays === undefined ? bt.ttlDays : input.ttlDays;
if (ttl) expiresAt = new Date(now.getTime() + ttl * DAY);
extra = { bonusTypeId: bt.id };
}
if (!Number.isInteger(amount) || amount < 0) throw new DomainError('INVALID_PARAMS');
if (amount === 0) return { status: 'skipped' };
const reason = input.reason?.trim() || null;
if (input.kind === 'ORDER' && suggested !== null && amount !== suggested && !reason) throw new DomainError('POINTS_REASON_REQUIRED');
if (input.noWait && !actor.owner && !reason) throw new DomainError('POINTS_REASON_REQUIRED');
// поддержка сверх лимита — в очередь на подтверждение суперадмину
const lim = await opLimits(tx, actor, amount, settings, now);
if (!lim.ok) {
const s = await tx.pointsSuggestion.create({
data: { clientId: client.id, mechanic: 'over_limit', dedupeKey: `over_limit:${input.idempotencyKey ?? `${actor.adminId}:${now.getTime()}`}`, payload: { op: 'accrual', input: { ...input, serviceDate: input.serviceDate.toISOString() }, adminId: actor.adminId } as Prisma.InputJsonValue, suggestedAmount: amount },
});
out.events.push({ type: 'points.overLimit', suggestionId: s.id });
return { status: 'needs_approval', suggestionId: s.id };
}
// долг после отмены потраченного гасится новым доступным начислением
let remaining = amount;
let finalStatus: 'PENDING' | 'ACTIVE' | 'SPENT' = status;
if (status === 'ACTIVE' && client.pointsDebt > 0) {
const pay = Math.min(client.pointsDebt, remaining);
remaining -= pay;
await tx.client.update({ where: { id: client.id }, data: { pointsDebt: { decrement: pay } } });
if (remaining === 0) finalStatus = 'SPENT';
}
const a = await tx.pointsAccrual.create({
data: {
clientId: client.id,
kind: input.kind,
...extra,
orderId: input.orderId,
orderRef,
suggestedAmount: suggested,
amount,
remaining,
status: finalStatus,
serviceDate: input.serviceDate,
availableAt,
expiresAt,
reason,
commentForClient: input.commentForClient?.trim() || null,
createdById: actor.adminId,
channel: input.channel,
idempotencyKey: input.idempotencyKey || null,
},
});
if (streak !== null) await tx.client.update({ where: { id: client.id }, data: { streakCount: streak, streakLastAt: input.serviceDate } });
await touch(tx, client.id, now);
await recalcPoints(tx, client.id);
out.events.push({ type: 'points.accrued', accrualId: a.id });
return { status: 'created', accrualId: a.id, amount };
});
}
/** Воркер: ожидающие начисления, у которых наступил срок, становятся доступными */
export async function activatePendingPoints(ctx: Ctx) {
const due = await ctx.db.pointsAccrual.findMany({ where: { status: 'PENDING', availableAt: { lte: ctx.now() } }, select: { id: true, clientId: true }, take: 500 });
for (const { id, clientId } of due) {
await inTx(ctx, undefined, async (tx, out) => {
const client = await lockClient(tx, clientId);
const a = await tx.pointsAccrual.findUniqueOrThrow({ where: { id } });
if (a.status !== 'PENDING') return;
let remaining = a.remaining;
if (client.pointsDebt > 0) {
const pay = Math.min(client.pointsDebt, remaining);
remaining -= pay;
await tx.client.update({ where: { id: clientId }, data: { pointsDebt: { decrement: pay } } });
}
await tx.pointsAccrual.update({ where: { id }, data: { status: remaining > 0 ? 'ACTIVE' : 'SPENT', remaining } });
await recalcPoints(tx, clientId);
out.events.push({ type: 'points.activated', accrualId: id });
});
}
return due.length;
}
// ───── списание ─────
export interface RedeemInput {
clientId: string;
amount: number;
orderId?: string;
orderRef?: string | null;
/** сумма заказа — для проверки «не больше N%» */
orderAmount?: number | null;
channel: PointsChannel;
reason?: string | null;
idempotencyKey?: string | null;
}
/** Сколько клиент может потратить на заказ сейчас */
export async function redeemQuote(db: Db | Tx, clientId: string, orderAmount: number, settings: Settings) {
const c = await db.client.findUniqueOrThrow({ where: { id: clientId }, select: { pointsActive: true } });
return { balance: c.pointsActive, ...redeemLimit({ orderAmount, balance: c.pointsActive, sharePct: settings['points.maxSharePct'], min: settings['points.minRedeem'] }), min: settings['points.minRedeem'], sharePct: settings['points.maxSharePct'] };
}
/** Списать по FIFO в уже открытой транзакции, клиент заблокирован */
async function spend(tx: Tx, clientId: string, amount: number, settings: Settings, orderAmount: number | null | undefined) {
const [client, list] = await Promise.all([
tx.client.findUniqueOrThrow({ where: { id: clientId } }),
tx.pointsAccrual.findMany({ where: { clientId, status: 'ACTIVE', remaining: { gt: 0 } } }),
]);
const balance = list.reduce((s, a) => s + a.remaining, 0) - client.pointsDebt;
if (!Number.isInteger(amount) || amount <= 0) throw new DomainError('INVALID_PARAMS');
if (amount < settings['points.minRedeem']) throw new DomainError('POINTS_BELOW_MIN');
if (amount > balance) throw new DomainError('POINTS_NOT_ENOUGH');
if (orderAmount != null && amount > Math.floor((orderAmount * settings['points.maxSharePct']) / 100)) throw new DomainError('POINTS_OVER_SHARE');
const plan = fifoPlan(list, amount);
if (!plan) throw new DomainError('POINTS_NOT_ENOUGH');
for (const it of plan) {
const a = list.find((x) => x.id === it.id)!;
const left = a.remaining - it.amount;
await tx.pointsAccrual.update({ where: { id: a.id }, data: { remaining: left, status: left === 0 ? 'SPENT' : 'ACTIVE' } });
}
return plan;
}
/** Списание командой (по звонку, по заказу вне системы) или в заказе — сразу подтверждено */
export async function redeemPoints(ctx: Ctx, input: RedeemInput, actor: PointsActor = SYSTEM, txArg?: Tx) {
const settings = await getSettings(txArg ?? ctx.db);
return inTx(ctx, txArg, async (tx, out) => {
if (input.idempotencyKey) {
const prev = await tx.pointsRedemption.findUnique({ where: { idempotencyKey: input.idempotencyKey } });
if (prev) return { status: 'done' as const, redemptionId: prev.id };
}
await lockClient(tx, input.clientId);
if (actor.adminId && !input.orderId && !input.reason?.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
const lim = await opLimits(tx, actor, input.amount, settings, ctx.now());
if (!lim.ok) {
const s = await tx.pointsSuggestion.create({
data: { clientId: input.clientId, mechanic: 'over_limit', dedupeKey: `over_limit:${input.idempotencyKey ?? `${actor.adminId}:${ctx.now().getTime()}`}`, payload: { op: 'redemption', input, adminId: actor.adminId } as unknown as Prisma.InputJsonValue, suggestedAmount: input.amount },
});
out.events.push({ type: 'points.overLimit', suggestionId: s.id });
return { status: 'needs_approval' as const, suggestionId: s.id };
}
const plan = await spend(tx, input.clientId, input.amount, settings, input.orderAmount);
const r = await tx.pointsRedemption.create({
data: {
clientId: input.clientId,
orderId: input.orderId,
orderRef: input.orderRef?.trim() || null,
orderAmount: input.orderAmount ?? null,
amount: input.amount,
status: 'CONFIRMED',
channel: input.channel,
confirmedById: actor.adminId,
confirmedAt: ctx.now(),
reason: input.reason?.trim() || null,
idempotencyKey: input.idempotencyKey || null,
items: { create: plan.map((p) => ({ accrualId: p.id, amount: p.amount })) },
},
});
await touch(tx, input.clientId, ctx.now());
await recalcPoints(tx, input.clientId);
// в заказе клиент сам видит списание — сообщение только для списаний командой
if (!input.orderId) out.events.push({ type: 'points.redeemed', redemptionId: r.id });
return { status: 'done' as const, redemptionId: r.id };
});
}
/** Клиент просит оплатить бариками заказ вне системы — запрос ждёт подтверждения команды */
export async function requestRedemption(ctx: Ctx, input: { clientId: string; amount: number; orderId?: string | null; note?: string | null }) {
const settings = await getSettings(ctx.db);
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: input.clientId } });
if (!Number.isInteger(input.amount) || input.amount < settings['points.minRedeem']) throw new DomainError('POINTS_BELOW_MIN');
if (input.amount > client.pointsActive) throw new DomainError('POINTS_NOT_ENOUGH');
let orderAmount: number | null = null;
if (input.orderId) {
const o = await ctx.db.order.findUnique({ where: { id: input.orderId } });
if (!o || o.clientId !== client.id) throw new DomainError('NOT_FOUND');
orderAmount = o.totalPrice - o.pointsUsed;
if (input.amount > Math.floor((o.totalPrice * settings['points.maxSharePct']) / 100)) throw new DomainError('POINTS_OVER_SHARE');
}
const open = await ctx.db.pointsRedemption.count({ where: { clientId: client.id, status: 'REQUESTED' } });
if (open >= 3) throw new DomainError('POINTS_TOO_MANY_REQUESTS');
const r = await ctx.db.pointsRedemption.create({
data: { clientId: client.id, orderId: input.orderId ?? null, orderAmount, amount: input.amount, status: 'REQUESTED', channel: 'CLIENT', note: input.note?.trim() || null, expiresAt: new Date(ctx.now().getTime() + settings['points.requestTtlHours'] * HOUR) },
});
await ctx.notify.emit({ type: 'points.requested', redemptionId: r.id });
return r;
}
/** Команда подтверждает запрос клиента: вводит сумму заказа, списывается не больше лимита */
export async function confirmRedemption(ctx: Ctx, input: { redemptionId: string; orderAmount: number; amount?: number }, actor: PointsActor, txArg?: Tx) {
const settings = await getSettings(ctx.db);
return inTx(ctx, txArg, async (tx, out) => {
const r = await tx.pointsRedemption.findUnique({ where: { id: input.redemptionId } });
if (!r) throw new DomainError('NOT_FOUND');
await lockClient(tx, r.clientId);
const fresh = await tx.pointsRedemption.findUniqueOrThrow({ where: { id: r.id } });
if (fresh.status !== 'REQUESTED') throw new DomainError('POINTS_REQUEST_CLOSED');
if (fresh.expiresAt && fresh.expiresAt < ctx.now()) throw new DomainError('POINTS_REQUEST_CLOSED');
const amount = input.amount ?? Math.min(fresh.amount, Math.floor((input.orderAmount * settings['points.maxSharePct']) / 100));
const lim = await opLimits(tx, actor, amount, settings, ctx.now());
if (!lim.ok) throw new DomainError('POINTS_OVER_LIMIT');
const plan = await spend(tx, r.clientId, amount, settings, input.orderAmount);
await tx.pointsRedemption.update({
where: { id: r.id },
data: { status: 'CONFIRMED', amount, orderAmount: input.orderAmount, confirmedById: actor.adminId, confirmedAt: ctx.now(), items: { create: plan.map((p) => ({ accrualId: p.id, amount: p.amount })) } },
});
await touch(tx, r.clientId, ctx.now());
await recalcPoints(tx, r.clientId);
out.events.push({ type: 'points.redeemed', redemptionId: r.id });
return { amount };
});
}
export async function rejectRedemption(ctx: Ctx, input: { redemptionId: string; reason: string }, actor: PointsActor) {
if (!input.reason.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
const r = await ctx.db.pointsRedemption.updateMany({ where: { id: input.redemptionId, status: 'REQUESTED' }, data: { status: 'REJECTED', reason: input.reason.trim(), confirmedById: actor.adminId, confirmedAt: ctx.now() } });
if (!r.count) throw new DomainError('POINTS_REQUEST_CLOSED');
await ctx.notify.emit({ type: 'points.rejected', redemptionId: input.redemptionId });
}
/** Воркер: неподтверждённые запросы старше N часов закрываются */
export async function expirePointRequests(ctx: Ctx) {
const list = await ctx.db.pointsRedemption.findMany({ where: { status: 'REQUESTED', expiresAt: { lt: ctx.now() } }, select: { id: true } });
for (const { id } of list) {
const r = await ctx.db.pointsRedemption.updateMany({ where: { id, status: 'REQUESTED' }, data: { status: 'REJECTED', reason: 'timeout' } });
if (r.count) await ctx.notify.emit({ type: 'points.rejected', redemptionId: id });
}
}
// ───── отмена ─────
/** Что будет при отмене: сколько уже потрачено и уйдёт ли баланс в минус */
export async function reversePreview(db: Db | Tx, input: { type: 'ACCRUAL' | 'REDEMPTION'; id: string }) {
if (input.type === 'ACCRUAL') {
const a = await db.pointsAccrual.findUniqueOrThrow({ where: { id: input.id }, include: { client: { select: { pointsActive: true } } } });
const spent = a.amount - a.remaining - a.expiredAmount;
const lose = a.status === 'PENDING' ? 0 : a.remaining;
return { status: a.status, amount: a.amount, spent: a.status === 'PENDING' ? 0 : spent, balanceAfter: a.client.pointsActive - lose - (a.status === 'PENDING' ? 0 : spent), reversible: a.status === 'PENDING' || a.status === 'ACTIVE' };
}
const r = await db.pointsRedemption.findUniqueOrThrow({ where: { id: input.id }, include: { client: { select: { pointsActive: true } } } });
return { status: r.status, amount: r.amount, spent: 0, balanceAfter: r.client.pointsActive + r.amount, reversible: r.status === 'CONFIRMED' };
}
async function checkOwnership(actor: PointsActor, createdById: string | null, createdAt: Date, now: Date) {
if (actor.owner) return;
if (createdById !== actor.adminId || now.getTime() - createdAt.getTime() > 24 * HOUR) throw new DomainError('FORBIDDEN');
}
/** Отменить начисление. Уже потраченная часть становится долгом (минус гасится следующими начислениями) */
export async function reverseAccrual(ctx: Ctx, input: { accrualId: string; reason: string; channel: PointsChannel }, actor: PointsActor = SYSTEM, txArg?: Tx) {
if (!input.reason.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
return inTx(ctx, txArg, async (tx, out) => {
const a0 = await tx.pointsAccrual.findUnique({ where: { id: input.accrualId } });
if (!a0) throw new DomainError('NOT_FOUND');
await lockClient(tx, a0.clientId);
const a = await tx.pointsAccrual.findUniqueOrThrow({ where: { id: a0.id } });
if (a.status !== 'PENDING' && a.status !== 'ACTIVE') throw new DomainError('POINTS_FINAL');
await checkOwnership(actor, a.createdById, a.createdAt, ctx.now());
const debt = a.status === 'PENDING' ? 0 : a.amount - a.remaining - a.expiredAmount;
await tx.pointsAccrual.update({ where: { id: a.id }, data: { status: 'REVERSED', remaining: 0 } });
if (debt > 0) await tx.client.update({ where: { id: a.clientId }, data: { pointsDebt: { increment: debt } } });
const rv = await tx.pointsReversal.create({ data: { targetType: 'ACCRUAL', targetId: a.id, clientId: a.clientId, amount: a.amount, debt, createdById: actor.adminId, channel: input.channel, reason: input.reason.trim() } });
await recalcPoints(tx, a.clientId);
out.events.push({ type: 'points.reversed', reversalId: rv.id });
return { debt };
});
}
/** Отменить списание: барики возвращаются в исходные начисления с их сроками */
export async function reverseRedemption(ctx: Ctx, input: { redemptionId: string; reason: string; channel: PointsChannel }, actor: PointsActor = SYSTEM, txArg?: Tx) {
if (!input.reason.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
return inTx(ctx, txArg, async (tx, out) => {
const r0 = await tx.pointsRedemption.findUnique({ where: { id: input.redemptionId } });
if (!r0) throw new DomainError('NOT_FOUND');
await lockClient(tx, r0.clientId);
const r = await tx.pointsRedemption.findUniqueOrThrow({ where: { id: r0.id }, include: { items: { include: { accrual: true } } } });
if (r.status !== 'CONFIRMED') throw new DomainError('POINTS_FINAL');
await checkOwnership(actor, r.confirmedById, r.confirmedAt ?? r.createdAt, ctx.now());
let debtBack = 0;
for (const it of r.items) {
const a = it.accrual;
if (a.status === 'REVERSED') debtBack += it.amount; // начисление отменили после траты — возврат гасит долг
else if (a.status === 'EXPIRED') await tx.pointsAccrual.update({ where: { id: a.id }, data: { expiredAmount: { increment: it.amount } } });
else await tx.pointsAccrual.update({ where: { id: a.id }, data: { remaining: { increment: it.amount }, status: a.status === 'SPENT' ? 'ACTIVE' : a.status } });
}
if (debtBack > 0) {
const c = await tx.client.findUniqueOrThrow({ where: { id: r.clientId } });
await tx.client.update({ where: { id: r.clientId }, data: { pointsDebt: Math.max(0, c.pointsDebt - debtBack) } });
}
await tx.pointsRedemption.update({ where: { id: r.id }, data: { status: 'REVERSED' } });
if (r.orderId) await tx.order.update({ where: { id: r.orderId }, data: { pointsUsed: { decrement: r.amount } } });
const rv = await tx.pointsReversal.create({ data: { targetType: 'REDEMPTION', targetId: r.id, clientId: r.clientId, amount: r.amount, createdById: actor.adminId, channel: input.channel, reason: input.reason.trim() } });
await touch(tx, r.clientId, ctx.now());
await recalcPoints(tx, r.clientId);
out.events.push({ type: 'points.reversed', reversalId: rv.id });
});
}
// ───── заказы в системе ─────
/** Автоначисление: заказ выполнен и оплачен. Вызывается из смены статуса и отметки оплаты (в их транзакции) */
export async function accrueForOrder(ctx: Ctx, tx: Tx, orderId: string) {
const o = await tx.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: { include: { category: true } } } });
if (o.paymentStatus !== 'PAID' || o.status === 'CANCELLED') return;
if (!getFlow(o.service.flow).done.includes(o.status)) return;
if (await tx.pointsAccrual.findFirst({ where: { orderId: o.id, status: { not: 'REVERSED' } } })) return;
const category = await pointsCategoryOf(tx, o.service);
if (!category?.active) return;
await accruePoints(
ctx,
{ clientId: o.clientId, kind: 'ORDER', categoryId: category.id, orderId: o.id, orderRef: `#${o.number}`, orderAmount: o.totalPrice, paidWithPoints: o.pointsUsed, serviceDate: o.scheduledAt, channel: 'AUTO' },
SYSTEM,
tx,
);
}
/** Заказ отменён: барики, списанные в заказе, возвращаются; начисление за заказ отменяется */
export async function onOrderCancelled(ctx: Ctx, tx: Tx, orderId: string) {
const [reds, accs] = await Promise.all([
tx.pointsRedemption.findMany({ where: { orderId, status: 'CONFIRMED' } }),
tx.pointsAccrual.findMany({ where: { orderId, status: { in: ['PENDING', 'ACTIVE'] } } }),
]);
for (const r of reds) await reverseRedemption(ctx, { redemptionId: r.id, reason: 'order_cancelled', channel: 'AUTO' }, SYSTEM, tx);
for (const a of accs) await reverseAccrual(ctx, { accrualId: a.id, reason: 'order_cancelled', channel: 'AUTO' }, SYSTEM, tx);
}
// ───── новоселье ─────
/** Барики на новоселье от агентства — один раз на клиента, сразу доступны, со своим сроком */
export async function grantHousewarming(ctx: Ctx, clientId: string) {
const client = await ctx.db.client.findUnique({ where: { id: clientId }, include: { agency: true } });
const ag = client?.agency;
if (!client?.consentAt || !ag?.active || ag.housewarmingPoints <= 0) return null;
const bt = await ctx.db.pointsBonusType.findUnique({ where: { code: 'housewarming' } });
if (!bt) return null;
if (await ctx.db.pointsAccrual.findFirst({ where: { clientId, bonusTypeId: bt.id } })) return null;
try {
return await accruePoints(ctx, {
clientId,
kind: 'BONUS',
bonusTypeId: bt.id,
amount: ag.housewarmingPoints,
ttlDays: ag.housewarmingDays,
serviceDate: ctx.now(),
channel: 'AUTO',
commentForClient: ag.name,
idempotencyKey: `housewarming:${clientId}`,
});
} catch (e) {
if (e instanceof DomainError) return null;
throw e;
}
}
// ───── сгорание и предупреждения ─────
/** Воркер (раз в сутки): сгорают все барики без активности N дней и бонусы с истёкшим сроком */
export async function burnPoints(ctx: Ctx) {
const settings = await getSettings(ctx.db);
const now = ctx.now();
const cutoff = new Date(now.getTime() - settings['points.burnDays'] * DAY);
const [inactive, bonuses] = await Promise.all([
ctx.db.client.findMany({ where: { pointsActivityAt: { lt: cutoff }, pointsAccruals: { some: { status: 'ACTIVE', remaining: { gt: 0 } } } }, select: { id: true } }),
ctx.db.pointsAccrual.findMany({ where: { status: 'ACTIVE', remaining: { gt: 0 }, expiresAt: { lt: now } }, select: { clientId: true } }),
]);
const ids = new Set([...inactive.map((c) => c.id), ...bonuses.map((b) => b.clientId)]);
let burned = 0;
for (const clientId of ids) {
await inTx(ctx, undefined, async (tx, out) => {
const c = await lockClient(tx, clientId);
const all = c.pointsActivityAt && c.pointsActivityAt < cutoff;
const list = await tx.pointsAccrual.findMany({ where: { clientId, status: 'ACTIVE', remaining: { gt: 0 }, ...(all ? {} : { expiresAt: { lt: now } }) } });
let sum = 0;
for (const a of list) {
sum += a.remaining;
await tx.pointsAccrual.update({ where: { id: a.id }, data: { status: 'EXPIRED', expiredAmount: { increment: a.remaining }, remaining: 0 } });
}
if (!sum) return;
burned += sum;
await recalcPoints(tx, clientId);
out.events.push({ type: 'points.expired', clientId, amount: sum });
});
}
return burned;
}
/** Воркер (раз в сутки): предупредить за N и M дней до сгорания */
export async function warnExpiringPoints(ctx: Ctx) {
const s = await getSettings(ctx.db);
const now = ctx.now();
const burn = s['points.burnDays'] * DAY;
for (const [days, field] of [
[s['points.warnDays2'], 'pointsWarn3At'],
[s['points.warnDays1'], 'pointsWarn14At'],
] as const) {
const from = new Date(now.getTime() - burn); // уже сгорело бы
const to = new Date(now.getTime() - burn + days * DAY);
const list = await ctx.db.client.findMany({ where: { pointsActive: { gt: 0 }, pointsActivityAt: { gt: from, lte: to }, [field]: null }, select: { id: true, pointsActive: true, pointsActivityAt: true } });
for (const c of list) {
await ctx.db.client.update({ where: { id: c.id }, data: field === 'pointsWarn3At' ? { pointsWarn3At: now, pointsWarn14At: now } : { pointsWarn14At: now } });
const left = Math.max(1, Math.ceil((c.pointsActivityAt!.getTime() + burn - now.getTime()) / DAY));
await ctx.notify.emit({ type: 'points.expiring', clientId: c.id, amount: c.pointsActive, days: left });
}
}
}
/** Воркер (раз в сутки): кэш баланса против журнала. Возвращает расхождения (и чинит кэш) */
export async function reconcilePoints(db: Db) {
const rows = await db.$queryRaw<{ id: string; cached: number; pending_cached: number; actual: number; pending: number }[]>`
SELECT c.id, c."pointsActive" AS cached, c."pointsPending" AS pending_cached,
COALESCE(SUM(a.remaining) FILTER (WHERE a.status = 'ACTIVE'), 0)::int - c."pointsDebt" AS actual,
COALESCE(SUM(a.remaining) FILTER (WHERE a.status = 'PENDING'), 0)::int AS pending
FROM "Client" c LEFT JOIN "PointsAccrual" a ON a."clientId" = c.id
GROUP BY c.id HAVING c."pointsActive" <> COALESCE(SUM(a.remaining) FILTER (WHERE a.status = 'ACTIVE'), 0)::int - c."pointsDebt"
OR c."pointsPending" <> COALESCE(SUM(a.remaining) FILTER (WHERE a.status = 'PENDING'), 0)::int`;
for (const r of rows) await db.client.update({ where: { id: r.id }, data: { pointsActive: r.actual, pointsPending: r.pending } });
return rows;
}
// ───── предложения системы ─────
/** Воркер (раз в час): карточки «Предложено системой» — маршрут первого месяца и отзыв с фото */
export async function buildPointSuggestions(ctx: Ctx) {
const now = ctx.now();
const types = await ctx.db.pointsBonusType.findMany({ where: { code: { in: ['first_month_route', 'photo_review'] }, active: true } });
const route = types.find((t) => t.code === 'first_month_route');
const photo = types.find((t) => t.code === 'photo_review');
let created = 0;
const add = async (data: Prisma.PointsSuggestionUncheckedCreateInput) => {
const r = await ctx.db.pointsSuggestion.createMany({ data: [data], skipDuplicates: true });
created += r.count;
};
if (route) {
// 3 разные категории за 30 дней с регистрации
const fresh = await ctx.db.client.findMany({
where: { createdAt: { gte: new Date(now.getTime() - 45 * DAY) } },
select: { id: true, createdAt: true, orders: { where: { status: { not: 'CANCELLED' }, completedAt: { not: null } }, select: { createdAt: true, service: { select: { code: true, pointsCategoryId: true, categoryId: true } } } } },
});
for (const c of fresh) {
const until = c.createdAt.getTime() + 30 * DAY;
const cats = new Set(c.orders.filter((o) => o.createdAt.getTime() <= until).map((o) => o.service.pointsCategoryId ?? o.service.code));
if (cats.size >= 3) await add({ clientId: c.id, mechanic: 'first_month_route', dedupeKey: `route:${c.id}`, payload: { categories: [...cats] }, suggestedAmount: route.defaultAmount });
}
}
if (photo) {
// фото в чате в течение 7 дней после выполненного заказа
const msgs = await ctx.db.conciergeMessage.findMany({
where: { author: 'CLIENT', clientId: { not: null }, createdAt: { gte: new Date(now.getTime() - 3 * DAY) }, OR: [{ mime: { startsWith: 'image/' } }, { tgFileId: { not: null }, fileName: 'photo.jpg' }] },
select: { clientId: true, createdAt: true },
});
for (const m of msgs) {
const o = await ctx.db.order.findFirst({ where: { clientId: m.clientId!, completedAt: { lte: m.createdAt, gte: new Date(m.createdAt.getTime() - 7 * DAY) }, status: { not: 'CANCELLED' } }, orderBy: { completedAt: 'desc' } });
if (o) await add({ clientId: m.clientId!, mechanic: 'photo_review', dedupeKey: `photo:${o.id}`, payload: { orderId: o.id, orderNumber: o.number }, suggestedAmount: photo.defaultAmount });
}
}
if (created) await ctx.notify.emit({ type: 'points.suggestions', count: created });
return created;
}
/** Принять предложение: бонус — начислить; сверх лимита — выполнить операцию поддержки от имени суперадмина */
export async function acceptSuggestion(ctx: Ctx, input: { id: string; amount?: number; reason?: string | null }, actor: PointsActor) {
if (!actor.owner) throw new DomainError('FORBIDDEN');
const s = await ctx.db.pointsSuggestion.findUnique({ where: { id: input.id } });
if (!s || s.status !== 'OPEN') throw new DomainError('POINTS_REQUEST_CLOSED');
const p = s.payload as Record<string, unknown>;
if (s.mechanic === 'over_limit') {
const op = p.op as string;
const inp = p.input as Record<string, unknown>;
if (op === 'accrual') await accruePoints(ctx, { ...(inp as unknown as AccrualInput), serviceDate: new Date(inp.serviceDate as string), idempotencyKey: `sugg:${s.id}` }, actor);
else await redeemPoints(ctx, { ...(inp as unknown as RedeemInput), idempotencyKey: `sugg:${s.id}` }, actor);
} else {
const code = s.mechanic === 'first_month_route' ? 'first_month_route' : s.mechanic;
const bt = await ctx.db.pointsBonusType.findUnique({ where: { code } });
if (!bt) throw new DomainError('NOT_FOUND');
const amount = input.amount ?? s.suggestedAmount;
if (amount !== s.suggestedAmount && !input.reason?.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
await accruePoints(ctx, { clientId: s.clientId, kind: 'BONUS', bonusTypeId: bt.id, amount, reason: input.reason, serviceDate: ctx.now(), channel: 'WEB', idempotencyKey: `sugg:${s.id}` }, actor);
}
await ctx.db.pointsSuggestion.update({ where: { id: s.id }, data: { status: 'ACCEPTED', resolvedById: actor.adminId, resolvedAt: ctx.now(), reason: input.reason?.trim() || null } });
}
export async function rejectSuggestion(ctx: Ctx, input: { id: string; reason: string }, actor: PointsActor) {
if (!input.reason.trim()) throw new DomainError('POINTS_REASON_REQUIRED');
const r = await ctx.db.pointsSuggestion.updateMany({ where: { id: input.id, status: 'OPEN' }, data: { status: 'REJECTED', resolvedById: actor.adminId, resolvedAt: ctx.now(), reason: input.reason.trim() } });
if (!r.count) throw new DomainError('POINTS_REQUEST_CLOSED');
}
// ───── клиент ─────
/** Экран «Барики»: баланс, ожидание, сгорание, ставки, серия, маршрут, сколько дал бы Barev+ */
export async function clientPoints(ctx: Ctx, clientId: string) {
const s = await getSettings(ctx.db);
const now = ctx.now();
const [client, pendingNext, cats, last30, tier, routeType] = await Promise.all([
ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { orders: { where: { status: { not: 'CANCELLED' }, completedAt: { not: null } }, select: { createdAt: true, service: { select: { code: true, pointsCategoryId: true } } } } } }),
ctx.db.pointsAccrual.findFirst({ where: { clientId, status: 'PENDING' }, orderBy: { availableAt: 'asc' } }),
ctx.db.pointsCategory.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' } }),
ctx.db.pointsAccrual.findMany({ where: { clientId, kind: 'ORDER', status: { not: 'REVERSED' }, serviceDate: { gte: new Date(now.getTime() - 30 * DAY) } }, include: { category: true } }),
tierAt(ctx.db, clientId, now),
ctx.db.pointsBonusType.findUnique({ where: { code: 'first_month_route' } }),
]);
const burnAt = client.pointsActive > 0 && client.pointsActivityAt ? new Date(client.pointsActivityAt.getTime() + s['points.burnDays'] * DAY) : null;
// на Barev+ за 30 дней получил бы
const plusWouldGet = tier === 'BAREV' ? last30.reduce((sum, a) => sum + suggestAccrual({ orderAmount: a.orderAmount ?? 0, excludedAmount: a.excludedAmount, paidWithPoints: a.paidWithPoints, ratePct: (a.category?.ratePlus ?? 0) + a.streakBonusPct }) - a.amount, 0) : 0;
const streakAlive = client.streakLastAt && now.getTime() - client.streakLastAt.getTime() <= s['points.streakGapDays'] * DAY;
const streak = streakAlive ? client.streakCount : 0;
const routeUntil = new Date(client.createdAt.getTime() + 30 * DAY);
const routeCats = new Set(client.orders.filter((o) => o.createdAt <= routeUntil).map((o) => o.service.pointsCategoryId ?? o.service.code));
return {
balance: client.pointsActive,
pending: client.pointsPending,
pendingNextAt: pendingNext?.availableAt ?? null,
burnAt,
burnAmount: burnAt ? client.pointsActive : 0,
tier,
rates: cats.map((c) => ({ code: c.code, name: c.name as Record<string, string>, barev: c.rateBarev, plus: c.ratePlus, elite: c.rateElite })),
plusWouldGet: Math.max(0, plusWouldGet),
streak: { count: streak, next: streak < 3 ? 3 : streak < 6 ? 6 : null, bonusPct: streakBonusPct(streak), gapDays: s['points.streakGapDays'] },
route: routeType && now < routeUntil ? { done: Math.min(3, routeCats.size), until: routeUntil, amount: routeType.defaultAmount } : null,
redeem: { sharePct: s['points.maxSharePct'], min: s['points.minRedeem'] },
openRequests: await ctx.db.pointsRedemption.count({ where: { clientId, status: 'REQUESTED' } }),
};
}
export type PointsHistoryRow = {
id: string;
at: Date;
type: 'accrual' | 'bonus' | 'redemption' | 'request' | 'reversal' | 'expired';
amount: number;
status: string;
category: Record<string, string> | null;
bonus: Record<string, string> | null;
orderRef: string | null;
comment: string | null;
availableAt?: Date | null;
expiresAt?: Date | null;
};
/** История клиента: начисления, списания, отмены — новые сверху */
export async function pointsHistory(db: Db | Tx, clientId: string, opts: { take?: number; before?: Date } = {}) {
const take = Math.min(opts.take ?? 30, 100);
const at = opts.before ? { lt: opts.before } : undefined;
const [acc, red, rev] = await Promise.all([
db.pointsAccrual.findMany({ where: { clientId, createdAt: at }, include: { category: true, bonusType: true }, orderBy: { createdAt: 'desc' }, take }),
db.pointsRedemption.findMany({ where: { clientId, requestedAt: at }, include: { order: { select: { number: true } } }, orderBy: { requestedAt: 'desc' }, take }),
db.pointsReversal.findMany({ where: { clientId, createdAt: at }, orderBy: { createdAt: 'desc' }, take }),
]);
const rows: PointsHistoryRow[] = [
...acc.map((a) => ({
id: a.id,
at: a.createdAt,
type: (a.kind === 'BONUS' ? 'bonus' : 'accrual') as PointsHistoryRow['type'],
amount: a.amount,
status: a.status,
category: (a.category?.name as Record<string, string>) ?? null,
bonus: (a.bonusType?.name as Record<string, string>) ?? null,
orderRef: a.orderRef,
comment: a.commentForClient,
availableAt: a.availableAt,
expiresAt: a.expiresAt,
})),
...red.map((r) => ({ id: r.id, at: r.requestedAt, type: (r.status === 'REQUESTED' || r.status === 'REJECTED' ? 'request' : 'redemption') as PointsHistoryRow['type'], amount: -r.amount, status: r.status, category: null, bonus: null, orderRef: r.order ? `#${r.order.number}` : r.orderRef, comment: r.status === 'REJECTED' && r.reason !== 'timeout' ? r.reason : null })),
...rev.map((r) => ({ id: r.id, at: r.createdAt, type: 'reversal' as const, amount: r.targetType === 'ACCRUAL' ? -r.amount : r.amount, status: r.targetType, category: null, bonus: null, orderRef: null, comment: r.reason === 'order_cancelled' ? null : r.reason })),
];
return rows.sort((a, b) => b.at.getTime() - a.at.getTime()).slice(0, take);
}
+10 -4
View File
@@ -2,6 +2,7 @@ import { averageRating, providerActions, startOfYerevanDay, getFlow, type Servic
import { orderPayout, rateLabel } from './payouts.ts';
import type { Db } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { getSettings } from './settings.ts';
import { upsertUser, type TelegramUserInput } from './clients.ts';
const DAY_MS = 24 * 3600_000;
@@ -55,6 +56,7 @@ export async function setShift(db: Db, providerId: string, onShift: boolean) {
/** 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, payoutPreset: true } });
const fot = { fotPct: (await getSettings(ctx.db))['partner.fotPct'] };
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [orders, offers] = await Promise.all([
@@ -89,7 +91,7 @@ export async function proToday(ctx: Ctx, providerId: string) {
rating: averageRating(provider.ratingSum, provider.ratingCount),
companyName: provider.company?.name ?? null,
},
earningsToday: doneToday.reduce((s, o) => s + orderPayout(o, provider), 0),
earningsToday: doneToday.reduce((s, o) => s + orderPayout(o, provider, fot), 0),
currentOrderId: current?.id ?? null,
orders: orders.map((o) => ({ ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status) })),
upcoming: upcoming.map(proOrderView),
@@ -109,6 +111,7 @@ type ProOrderRow = {
locale: import('@barev/db').Locale;
params: unknown;
totalPrice: number;
pointsUsed?: number;
paymentStatus: import('@barev/db').PaymentStatus;
service: { code: string; flow: import('@barev/db').ServiceFlow };
district: { code: string } | null;
@@ -134,6 +137,8 @@ export function proOrderView(o: ProOrderRow) {
clientName: o.client.user.firstName,
params: o.params as Record<string, unknown>,
totalPrice: o.totalPrice,
/** к оплате деньгами (часть могла быть оплачена бариками) */
dueAmount: o.totalPrice - (o.pointsUsed ?? 0),
paymentStatus: o.paymentStatus,
checklist: o.checklist?.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, checked: !!c.checkedAt, hasPhoto: !!c.fileId })) ?? [],
};
@@ -152,6 +157,7 @@ export async function proOrder(ctx: Ctx, providerId: string, orderId: string) {
/** 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, payoutPreset: true } });
const fot = { fotPct: (await getSettings(ctx.db))['partner.fotPct'] };
const orders = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: from, lt: to } },
include: { service: true, district: true, client: { include: { user: true } } },
@@ -159,10 +165,10 @@ export async function proHistory(ctx: Ctx, providerId: string, from: Date, to: D
});
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) ? orderPayout(o, provider) : 0 })),
totalEarned: done.reduce((s, o) => s + orderPayout(o, provider), 0),
orders: orders.map((o) => ({ ...proOrderView(o), payout: getFlow(o.service.flow as ServiceFlow).done.includes(o.status) ? orderPayout(o, provider, fot) : 0 })),
totalEarned: done.reduce((s, o) => s + orderPayout(o, provider, fot), 0),
doneCount: done.length,
rate: rateLabel(provider),
rate: rateLabel(provider, fot),
};
}
+2
View File
@@ -42,6 +42,8 @@ function clientOrderView(o: ClientOrderRow) {
discountPct: o.discountPct,
discountAmount: o.discountAmount,
totalPrice: o.totalPrice,
/** оплачено бариками; к оплате деньгами — totalPrice − pointsUsed */
pointsUsed: o.pointsUsed,
cancelFee: o.cancelFee,
paymentStatus: o.paymentStatus,
paymentMethod: o.paymentMethod ? { code: o.paymentMethod.code, name: o.paymentMethod.name as Record<string, string> } : null,
+470
View File
@@ -0,0 +1,470 @@
/**
* Партнёры услуг (клининговые компании): деньги по заказу, кабинет менеджера,
* автораспределение заказов по сотрудникам.
*/
import { createHash, randomBytes } from 'node:crypto';
import {
averageRating,
DAY,
getFlow,
isProviderFree,
isProviderFreeWithGap,
LOCALES,
partnerSplit,
partnerTerms,
startOfYerevanDay,
withinDailyLimits,
yerevanMonthKey,
type Locale,
type ServiceFlow,
type Settings,
} from '@barev/core';
import type { Db, Tx } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { transitionOrder } from './orders.ts';
import { proOrderView } from './providers.ts';
import { getSettings } from './settings.ts';
const ACTIVE_EXCLUDED = ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] as const;
export const managerInviteCode = () => `PM-${randomBytes(5).toString('hex').toUpperCase()}`;
export const staffInviteCode = () => `PRO-${randomBytes(5).toString('hex').toUpperCase()}`;
type CompanyRow = { deal: 'PROFIT_SHARE' | 'COMMISSION'; commissionPct: number; profitSharePct: number; fotPct: number | null; taxPct: number | null };
/** Условия партнёра с общими ФОТ и налогом из настроек */
export function termsOf(c: CompanyRow, settings: Settings) {
return partnerTerms(c, { fotPct: settings['partner.fotPct'], taxPct: settings['partner.taxPct'] });
}
/** Деньги по заказу у партнёра: ФОТ, налог, доход партнёра и что остаётся нам */
export function companySplit(c: CompanyRow, paid: number, settings: Settings) {
return partnerSplit(paid, termsOf(c, settings));
}
/** При оплате заказа сотрудником партнёра фиксируем доход партнёра (смена условий не меняет прошлое) */
export async function recordPartnerEarning(tx: Tx, order: { id: string; companyId: string | null; providerId: string | null; totalPrice: number }, settings: Settings, now: Date) {
if (!order.companyId || !order.providerId) return;
const company = await tx.providerCompany.findUnique({ where: { id: order.companyId } });
if (!company) return;
const s = companySplit(company, order.totalPrice, settings);
await tx.partnerEarning.upsert({
where: { orderId: order.id },
create: { companyId: company.id, orderId: order.id, deal: company.deal, paid: s.paid, fot: s.fot, tax: s.tax, pct: s.pct, amount: s.partner, period: yerevanMonthKey(now) },
update: {},
});
}
function interval(o: { scheduledAt: Date; durationMinutes: number }) {
return { start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) };
}
/** Перерыв на дорогу партнёра — не меньше минимума из настроек */
export function travelGap(company: { travelGapMinutes: number }, settings: Settings) {
return Math.max(company.travelGapMinutes, settings['partner.minTravelGapMinutes']);
}
export type StaffCheck = 'ok' | 'busy' | 'gap' | 'limit' | 'notLinked';
/**
* Сотрудники партнёра на заказ: свободен ли по графику (с перерывом на дорогу), в лимите ли нагрузки,
* район и язык. eligible — подходит для автораспределения. Порядок: язык и район, меньше заказов за неделю, выше рейтинг.
*/
export async function companyStaffCandidates(db: Db | Tx, companyId: string, orderId: string, settings: Settings) {
const order = await db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } });
const company = await db.providerCompany.findUniqueOrThrow({ where: { id: companyId } });
const slot = interval(order);
const dayStart = startOfYerevanDay(order.scheduledAt);
const dayEnd = new Date(dayStart.getTime() + DAY);
const gap = travelGap(company, settings);
const weekFrom = new Date(dayStart.getTime() - 6 * DAY);
const [avail, staff, week] = await Promise.all([
loadAvailability(db, order.service.providerRole, dayStart, dayEnd, { companyId, excludeOrderId: order.id }),
db.provider.findMany({
where: { companyId, active: true, role: order.service.providerRole },
include: {
districts: true,
orders: { where: { id: { not: order.id }, scheduledAt: { gte: dayStart, lt: dayEnd }, status: { notIn: [...ACTIVE_EXCLUDED] } }, select: { durationMinutes: true } },
},
orderBy: { name: 'asc' },
}),
db.order.groupBy({ by: ['providerId'], where: { companyId, scheduledAt: { gte: weekFrom, lt: dayEnd }, status: { not: 'CANCELLED' }, providerId: { not: null } }, _count: true }),
]);
const weekLoad = new Map(week.map((w) => [w.providerId, w._count]));
const rows = staff.map((p) => {
const a = avail.find((x) => x.id === p.id);
const free = !!a && isProviderFree(a, slot);
const freeGap = !!a && isProviderFreeWithGap(a, slot, gap);
const inLimit = withinDailyLimits(p, p.orders, order.durationMinutes);
const check: StaffCheck = !free ? 'busy' : !freeGap ? 'gap' : !inLimit ? 'limit' : !p.userId ? 'notLinked' : 'ok';
const language = p.languages.includes(order.locale);
const district = !!order.districtId && p.districts.some((d) => d.districtId === order.districtId);
return {
id: p.id,
name: p.name,
languages: p.languages as Locale[],
onShift: p.onShift,
linked: !!p.userId,
check,
eligible: check === 'ok',
language,
district,
dayOrders: p.orders.length,
weekOrders: weekLoad.get(p.id) ?? 0,
rating: averageRating(p.ratingSum, p.ratingCount),
maxOrdersPerDay: p.maxOrdersPerDay,
maxHoursPerDay: p.maxHoursPerDay,
};
});
const score = (r: (typeof rows)[number]) => (r.language ? 2 : 0) + (r.district ? 1 : 0);
rows.sort((a, b) => Number(b.eligible) - Number(a.eligible) || score(b) - score(a) || a.weekOrders - b.weekOrders || (b.rating ?? 4) - (a.rating ?? 4) || a.name.localeCompare(b.name));
return { gap, rows };
}
/** Есть ли у партнёра свободный сотрудник на заказ (для оператора) */
export async function companyAvailability(db: Db | Tx, companyId: string, orderId: string, settings: Settings) {
const { rows } = await companyStaffCandidates(db, companyId, orderId, settings);
return { free: rows.filter((r) => r.eligible).length, total: rows.length, rows };
}
async function managerOf(db: Db | Tx, companyId: string, userId: string) {
const link = await db.companyDispatcher.findUnique({ where: { companyId_userId: { companyId, userId } }, include: { company: true } });
if (!link || !link.company.active) throw new DomainError('FORBIDDEN');
return link.company;
}
export type CompanyAutoResult = { result: 'offered'; providerId: string } | { result: 'off' | 'skipped' } | { result: 'none' };
/**
* Автораспределение внутри партнёра: заказ партнёра без сотрудника предлагается лучшему подходящему.
* Отказ или молчание — следующему. Никого не осталось — заказ ждёт менеджера.
*/
export async function companyAutoAssign(ctx: Ctx, orderId: string): Promise<CompanyAutoResult> {
const settings = await getSettings(ctx.db);
const events: DomainEvent[] = [];
const res = await ctx.db.$transaction(async (tx): Promise<CompanyAutoResult> => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: orderId }, include: { service: true, offers: true, company: true } });
if (!order?.company || order.providerId) return { result: 'skipped' };
if (!order.company.autoAssign) return { result: 'off' };
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial || order.scheduledAt <= ctx.now()) return { result: 'skipped' };
const companyOffer = order.offers.find((o) => o.status === 'OFFERED' && !o.providerId && o.companyId === order.companyId);
if (!companyOffer || order.offers.some((o) => o.status === 'OFFERED' && o.providerId)) return { result: 'skipped' };
const tried = new Set(order.offers.map((o) => o.providerId).filter(Boolean));
const { rows } = await companyStaffCandidates(tx, order.company.id, order.id, settings);
const pick = rows.find((r) => r.eligible && !tried.has(r.id));
if (!pick) {
const last = await tx.orderStatusHistory.findFirst({ where: { orderId: order.id }, orderBy: { createdAt: 'desc' } });
if (last?.event !== 'partnerAutoNone') {
await tx.orderStatusHistory.create({ data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'partnerAutoNone', actorType: 'SYSTEM' } });
events.push({ type: 'company.noStaff', orderId: order.id, companyId: order.company.id });
}
return { result: 'none' };
}
// предложение партнёру принято — дальше заказ ведёт его сотрудник
await tx.orderOffer.update({ where: { id: companyOffer.id }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } });
const offer = await tx.orderOffer.create({
data: { orderId: order.id, providerId: pick.id, companyId: order.company.id, status: 'OFFERED', expiresAt: new Date(ctx.now().getTime() + settings['offer.acceptTimeoutMinutes'] * 60_000) },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'partnerAutoOffered', actorType: 'SYSTEM', comment: pick.name },
});
events.push({ type: 'offer.created', offerId: offer.id });
return { result: 'offered', providerId: pick.id };
});
for (const e of events) await ctx.notify.emit(e);
return res;
}
/** Воркер: заказы партнёров с автораспределением, которые ждут сотрудника */
export async function retryCompanyAutoAssign(ctx: Ctx) {
const waiting = await ctx.db.orderOffer.findMany({
where: { status: 'OFFERED', providerId: null, company: { autoAssign: true, active: true }, order: { providerId: null, scheduledAt: { gt: ctx.now() } } },
select: { orderId: true },
take: 50,
});
for (const w of waiting) await companyAutoAssign(ctx, w.orderId);
}
/** Партнёр не взял заказ: отказ менеджера или истёк срок — заказ возвращается оператору */
async function returnToOperator(ctx: Ctx, tx: Tx, orderId: string, companyId: string, reason: 'declined' | 'expired', actorUserId?: string): Promise<DomainEvent> {
const now = ctx.now();
await tx.orderOffer.updateMany({ where: { orderId, companyId, providerId: null, status: 'OFFERED' }, data: { status: reason === 'declined' ? 'DECLINED' : 'EXPIRED', respondedAt: now } });
await tx.orderOffer.updateMany({ where: { orderId, companyId, providerId: { not: null }, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: now } });
const order = await tx.order.update({ where: { id: orderId }, data: { companyId: null } });
await tx.orderStatusHistory.create({
data: { orderId, fromStatus: order.status, toStatus: order.status, event: reason === 'declined' ? 'partnerDeclined' : 'partnerExpired', actorType: reason === 'declined' ? 'DISPATCHER' : 'SYSTEM', actorId: actorUserId },
});
return { type: 'partner.returned', orderId, companyId, reason };
}
/** Менеджер отказывается от заказа — он возвращается оператору */
export async function partnerDeclineOrder(ctx: Ctx, input: { orderId: string; companyId: string; userId: string }) {
const event = await ctx.db.$transaction(async (tx) => {
await managerOf(tx, input.companyId, input.userId);
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: input.orderId } });
if (!order || order.companyId !== input.companyId || order.providerId) throw new DomainError('NOT_FOUND');
return returnToOperator(ctx, tx, order.id, input.companyId, 'declined', input.userId);
});
await ctx.notify.emit(event);
}
/** Воркер: партнёр слишком долго не назначает сотрудника — возвращаем заказ оператору */
export async function expireCompanyOffers(ctx: Ctx) {
const expired = await ctx.db.orderOffer.findMany({
where: { status: 'OFFERED', providerId: null, companyId: { not: null }, expiresAt: { lt: ctx.now() } },
select: { orderId: true, companyId: true },
});
for (const x of expired) {
const event = await ctx.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${x.orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: x.orderId } });
if (!order || order.providerId || order.companyId !== x.companyId) {
await tx.orderOffer.updateMany({ where: { orderId: x.orderId, companyId: x.companyId, providerId: null, status: 'OFFERED' }, data: { status: 'EXPIRED', respondedAt: ctx.now() } });
return null;
}
return returnToOperator(ctx, tx, order.id, x.companyId!, 'expired');
});
if (event) await ctx.notify.emit(event);
}
}
/** Менеджер снимает сотрудника с заказа — заказ снова у партнёра (и уйдёт другому при автораспределении) */
export async function partnerUnassign(ctx: Ctx, input: { orderId: string; companyId: string; userId: string }) {
const settings = await getSettings(ctx.db);
const events: DomainEvent[] = [];
await ctx.db.$transaction(async (tx) => {
await managerOf(tx, input.companyId, input.userId);
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 || order.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
const flow = getFlow(order.service.flow);
const now = ctx.now();
const pending = await tx.orderOffer.findFirst({ where: { orderId: order.id, status: 'OFFERED', providerId: { not: null } } });
const providerId = order.providerId ?? pending?.providerId ?? null;
if (!providerId) throw new DomainError('TRANSITION_NOT_ALLOWED');
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: now } });
if (order.providerId) {
if (order.status !== flow.assigned && order.status !== flow.initial) throw new DomainError('TRANSITION_NOT_ALLOWED');
// отметка «уже предлагали», чтобы автораспределение не вернуло заказ тому же сотруднику
await tx.orderOffer.create({ data: { orderId: order.id, providerId, companyId: input.companyId, status: 'REVOKED', respondedAt: now } });
if (order.status === flow.assigned) {
const r = await transitionOrder(ctx, { orderId: order.id, to: flow.initial, actor: 'SYSTEM', actorId: input.userId, event: 'partnerUnassigned', data: { providerId: null } }, tx);
events.push(r.event);
} else {
await tx.order.update({ where: { id: order.id }, data: { providerId: null } });
}
events.push({ type: 'order.unassigned', orderId: order.id, providerId });
}
await tx.orderOffer.create({
data: { orderId: order.id, companyId: input.companyId, status: 'OFFERED', expiresAt: new Date(now.getTime() + settings['partner.acceptTimeoutMinutes'] * 60_000) },
});
});
for (const e of events) await ctx.notify.emit(e);
await companyAutoAssign(ctx, input.orderId);
}
// ───── кабинет менеджера ─────
const weekday = (n: number) => n >= 0 && n <= 6;
export interface StaffInput {
name: string;
phone?: string | null;
languages: Locale[];
districtIds: string[];
schedule: { weekday: number; startMinute: number; endMinute: number }[];
maxOrdersPerDay?: number | null;
maxHoursPerDay?: number | null;
}
function cleanStaff(input: StaffInput) {
const name = input.name.trim();
if (!name) throw new DomainError('INVALID_PARAMS');
const languages = input.languages.filter((l) => (LOCALES as readonly string[]).includes(l));
return {
name: name.slice(0, 100),
phone: input.phone?.trim() || null,
languages: languages.length ? languages : (['ru'] as Locale[]),
maxOrdersPerDay: input.maxOrdersPerDay && input.maxOrdersPerDay > 0 ? Math.min(input.maxOrdersPerDay, 20) : null,
maxHoursPerDay: input.maxHoursPerDay && input.maxHoursPerDay > 0 ? Math.min(input.maxHoursPerDay, 24) : null,
schedule: input.schedule.filter((s) => weekday(s.weekday) && s.endMinute > s.startMinute && s.startMinute >= 0 && s.endMinute <= 1440),
};
}
/** Менеджер добавляет сотрудника — без одобрения команды; сотрудник входит в рабочий бот по ссылке */
export async function partnerAddStaff(ctx: Ctx, input: { companyId: string; userId: string; staff: StaffInput }) {
const company = await managerOf(ctx.db, input.companyId, input.userId);
const { schedule, ...data } = cleanStaff(input.staff);
const role = company.roles[0] ?? 'CLEANER';
const districts = await ctx.db.district.findMany({ where: { id: { in: input.staff.districtIds } }, select: { id: true } });
const p = await ctx.db.provider.create({
data: {
...data,
role,
companyId: company.id,
inviteCode: staffInviteCode(),
districts: { create: districts.map((d) => ({ districtId: d.id })) },
schedule: { create: schedule },
},
});
return { id: p.id, inviteCode: p.inviteCode };
}
export async function partnerUpdateStaff(ctx: Ctx, input: { companyId: string; userId: string; providerId: string; staff: StaffInput; active?: boolean }) {
await managerOf(ctx.db, input.companyId, input.userId);
const p = await ctx.db.provider.findUnique({ where: { id: input.providerId } });
if (!p || p.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
const { schedule, ...data } = cleanStaff(input.staff);
const districts = await ctx.db.district.findMany({ where: { id: { in: input.staff.districtIds } }, select: { id: true } });
await ctx.db.provider.update({
where: { id: p.id },
data: {
...data,
...(input.active === undefined ? {} : input.active ? { active: true, blockedReason: null, blockedAt: null } : { active: false, onShift: false, blockedReason: 'partner', blockedAt: ctx.now() }),
districts: { deleteMany: {}, create: districts.map((d) => ({ districtId: d.id })) },
schedule: { deleteMany: {}, create: schedule },
},
});
if (input.active === false) await ctx.db.orderOffer.updateMany({ where: { providerId: p.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: ctx.now() } });
}
/** Новая ссылка для сотрудника (старая привязка снимается) */
export async function partnerResetStaffInvite(ctx: Ctx, input: { companyId: string; userId: string; providerId: string }) {
await managerOf(ctx.db, input.companyId, input.userId);
const p = await ctx.db.provider.findUnique({ where: { id: input.providerId } });
if (!p || p.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
const u = await ctx.db.provider.update({ where: { id: p.id }, data: { inviteCode: staffInviteCode(), userId: null, onShift: false } });
return { inviteCode: u.inviteCode };
}
/** Настройки партнёра, которые меняет менеджер: автораспределение и перерыв на дорогу */
export async function partnerSaveSettings(ctx: Ctx, input: { companyId: string; userId: string; autoAssign: boolean; travelGapMinutes: number }) {
await managerOf(ctx.db, input.companyId, input.userId);
const settings = await getSettings(ctx.db);
const gap = Math.min(240, Math.max(settings['partner.minTravelGapMinutes'], Math.round(input.travelGapMinutes)));
await ctx.db.providerCompany.update({ where: { id: input.companyId }, data: { autoAssign: input.autoAssign, travelGapMinutes: gap } });
if (input.autoAssign) await retryCompanyAutoAssign(ctx);
}
export async function partnerRemoveManager(ctx: Ctx, input: { companyId: string; userId: string; managerId: string }) {
await managerOf(ctx.db, input.companyId, input.userId);
const m = await ctx.db.companyDispatcher.findUnique({ where: { id: input.managerId } });
if (!m || m.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
if (m.userId === input.userId) throw new DomainError('FORBIDDEN');
await ctx.db.companyDispatcher.delete({ where: { id: m.id } });
}
const personName = (u: { firstName: string | null; lastName: string | null; username: string | null }) =>
[u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
/** Доход партнёра: по месяцам, последние заказы, выплаты и остаток к выплате */
export async function partnerMoney(db: Db | Tx, companyId: string) {
const [months, recent, payouts] = await Promise.all([
db.partnerEarning.groupBy({ by: ['period'], where: { companyId }, _sum: { amount: true, paid: true }, _count: true, orderBy: { period: 'desc' }, take: 12 }),
db.partnerEarning.findMany({ where: { companyId }, orderBy: { createdAt: 'desc' }, take: 50, include: { order: { select: { id: true, number: true, scheduledAt: true, provider: { select: { name: true } } } } } }),
db.payout.findMany({ where: { companyId }, orderBy: { createdAt: 'desc' }, take: 24 }),
]);
const accrued = (await db.partnerEarning.aggregate({ where: { companyId }, _sum: { amount: true } }))._sum.amount ?? 0;
const paid = payouts.filter((p) => p.status === 'PAID').reduce((s, p) => s + p.amount, 0);
return {
accrued,
paid,
balance: accrued - paid,
months: months.map((m) => ({ period: m.period, amount: m._sum.amount ?? 0, revenue: m._sum.paid ?? 0, orders: m._count })),
recent: recent.map((e) => ({ orderId: e.order.id, number: e.order.number, at: e.order.scheduledAt, staff: e.order.provider?.name ?? null, deal: e.deal, paid: e.paid, fot: e.fot, tax: e.tax, pct: e.pct, amount: e.amount })),
payouts: payouts.map((p) => ({ id: p.id, amount: p.amount, status: p.status, paidAt: p.paidAt, note: p.note })),
};
}
/** Кабинет менеджера партнёра: заказы, сотрудники, настройки, доход */
export async function partnerCabinetData(ctx: Ctx, companyId: string, userId: string) {
const company = await managerOf(ctx.db, companyId, userId);
const settings = await getSettings(ctx.db);
const today = startOfYerevanDay(ctx.now());
const [pending, assigned, staff, managers, money, districts, dayCounts] = await Promise.all([
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 } }, offers: { where: { status: 'OFFERED' }, include: { provider: { select: { name: true } } } } },
orderBy: { scheduledAt: 'asc' },
}),
ctx.db.order.findMany({
where: { companyId, providerId: { not: null }, scheduledAt: { gte: today }, status: { notIn: [...ACTIVE_EXCLUDED] } },
include: { service: true, district: true, client: { include: { user: true } }, provider: true },
orderBy: { scheduledAt: 'asc' },
take: 100,
}),
ctx.db.provider.findMany({ where: { companyId }, include: { districts: true, schedule: true }, orderBy: [{ active: 'desc' }, { name: 'asc' }] }),
ctx.db.companyDispatcher.findMany({ where: { companyId }, include: { user: true }, orderBy: { createdAt: 'asc' } }),
partnerMoney(ctx.db, companyId),
ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } }),
ctx.db.order.groupBy({ by: ['providerId'], where: { companyId, scheduledAt: { gte: today, lt: new Date(today.getTime() + DAY) }, status: { notIn: [...ACTIVE_EXCLUDED] } }, _count: true }),
]);
const todayCount = new Map(dayCounts.map((d) => [d.providerId, d._count]));
const terms = termsOf(company, settings);
return {
company: { id: company.id, name: company.name, deal: company.deal, terms, autoAssign: company.autoAssign, travelGapMinutes: company.travelGapMinutes, minTravelGap: settings['partner.minTravelGapMinutes'], acceptTimeoutMinutes: settings['partner.acceptTimeoutMinutes'], managerInvite: company.inviteCode },
pending: pending.map((o) => {
const offer = o.offers.find((x) => x.providerId);
const companyOffer = o.offers.find((x) => !x.providerId);
return { ...proOrderView(o), offeredTo: offer?.provider?.name ?? null, offerExpiresAt: offer?.expiresAt ?? null, deadline: companyOffer?.expiresAt ?? null, income: companySplit(company, o.totalPrice, settings).partner };
}),
assigned: assigned.map((o) => ({ ...proOrderView(o), providerId: o.providerId, providerName: o.provider?.name ?? null, canUnassign: o.status === getFlow(o.service.flow as ServiceFlow).assigned, income: companySplit(company, o.totalPrice, settings).partner })),
staff: staff.map((p) => ({
id: p.id,
name: p.name,
phone: p.phone,
role: p.role,
active: p.active,
onShift: p.onShift,
linked: !!p.userId,
inviteCode: p.userId ? null : p.inviteCode,
languages: p.languages,
districtIds: p.districts.map((d) => d.districtId),
schedule: p.schedule.map((s) => ({ weekday: s.weekday, startMinute: s.startMinute, endMinute: s.endMinute })),
maxOrdersPerDay: p.maxOrdersPerDay,
maxHoursPerDay: p.maxHoursPerDay,
rating: averageRating(p.ratingSum, p.ratingCount),
today: todayCount.get(p.id) ?? 0,
})),
managers: managers.map((m) => ({ id: m.id, name: personName(m.user), me: m.userId === userId })),
districts: districts.map((d) => ({ id: d.id, code: d.code })),
money,
};
}
// ───── вход в кабинет с компьютера ─────
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
const WEB_CODE_TTL = 15 * 60_000;
const WEB_SESSION_TTL = 30 * DAY;
/** Бот: одноразовая ссылка для входа в кабинет из браузера (15 минут) */
export async function createProWebLogin(ctx: Ctx, userId: string) {
const code = randomBytes(18).toString('base64url');
await ctx.db.proWebSession.create({ data: { userId, codeHash: sha(code), codeExpiresAt: new Date(ctx.now().getTime() + WEB_CODE_TTL), expiresAt: new Date(ctx.now().getTime() + WEB_CODE_TTL) } });
return code;
}
/** Браузер меняет одноразовый код на токен на 30 дней */
export async function exchangeProWebLogin(ctx: Ctx, code: string) {
const s = await ctx.db.proWebSession.findUnique({ where: { codeHash: sha(code) } });
if (!s || !s.codeExpiresAt || s.codeExpiresAt < ctx.now()) throw new DomainError('INVITE_INVALID');
const token = randomBytes(32).toString('base64url');
await ctx.db.proWebSession.update({ where: { id: s.id }, data: { codeHash: null, codeExpiresAt: null, tokenHash: sha(token), expiresAt: new Date(ctx.now().getTime() + WEB_SESSION_TTL), lastUsedAt: ctx.now() } });
return token;
}
/** Telegram ID владельца токена браузера или null */
export async function proWebSessionUser(db: Db, token: string, now: Date) {
const s = await db.proWebSession.findUnique({ where: { tokenHash: sha(token) }, include: { user: { select: { telegramId: true } } } });
if (!s || s.expiresAt < now) return null;
if (!s.lastUsedAt || now.getTime() - s.lastUsedAt.getTime() > 3600_000) await db.proWebSession.update({ where: { id: s.id }, data: { lastUsedAt: now } });
return s.user.telegramId;
}
export async function revokeProWebSession(db: Db, token: string) {
await db.proWebSession.deleteMany({ where: { tokenHash: sha(token) } });
}
+113 -42
View File
@@ -1,11 +1,12 @@
import { randomBytes } from 'node:crypto';
import { quote, validateParams, windowStarts, yerevanDateKey, type Locale, type Utm } from '@barev/core';
import { DAY, quote, slotConfigSchema, validateParams, windowStarts, yerevanDateKey, type Locale, type Utm } from '@barev/core';
import type { Prisma, WebRequestStatus } from '@barev/db';
import { getServiceByCode } from './catalog.ts';
import { giveConsent } from './clients.ts';
import { DomainError, type Ctx } from './context.ts';
import { createOrder } from './orders.ts';
import { getSettings } from './settings.ts';
import { slotsForDay } from './availability.ts';
/** Телефон: цифры с необязательным «+», 8–15 цифр (E.164). Пробелы, скобки и дефисы убираем. */
export function normalizePhone(raw: string): string | null {
@@ -17,9 +18,10 @@ export function normalizePhone(raw: string): string | null {
export interface CreateWebRequestInput {
serviceCode: string;
params: Record<string, unknown>;
preferredAt: Date;
districtCode: string;
addressText: string;
/** время, район и адрес необязательны: недостающее спросит бот или уточнит оператор */
preferredAt?: Date;
districtCode?: string;
addressText?: string;
note?: string;
phone: string;
name?: string;
@@ -42,19 +44,22 @@ export async function createWebRequest(ctx: Ctx, input: CreateWebRequestInput) {
const phone = normalizePhone(input.phone);
if (!phone) throw new DomainError('PHONE_INVALID');
const addressText = input.addressText.trim();
const district = await ctx.db.district.findUnique({ where: { code: input.districtCode } });
if (!district || addressText.length < 3) throw new DomainError('ADDRESS_REQUIRED');
const addressText = input.addressText?.trim() || null;
if (addressText && addressText.length < 3) throw new DomainError('ADDRESS_REQUIRED');
const district = input.districtCode ? await ctx.db.district.findUnique({ where: { code: input.districtCode } }) : null;
if (input.districtCode && !district) throw new DomainError('ADDRESS_REQUIRED');
// длинная уборка может не поместиться в один день — тогда сверяем только начало, длительность согласует оператор
const window = service.slotConfig.dayEndMinute - service.slotConfig.dayStartMinute;
const starts = windowStarts({
dateKey: yerevanDateKey(input.preferredAt),
config: service.slotConfig,
durationMinutes: Math.min(q.durationMinutes, window),
now: ctx.now(),
});
if (!starts.some((s) => s.getTime() === input.preferredAt.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
if (input.preferredAt) {
// длинная уборка может не поместиться в один день — тогда сверяем только начало, длительность согласует оператор
const window = service.slotConfig.dayEndMinute - service.slotConfig.dayStartMinute;
const starts = windowStarts({
dateKey: yerevanDateKey(input.preferredAt),
config: service.slotConfig,
durationMinutes: Math.min(q.durationMinutes, window),
now: ctx.now(),
});
if (!starts.some((s) => s.getTime() === input.preferredAt!.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
}
const settings = await getSettings(ctx.db);
const token = randomBytes(6).toString('base64url').replace(/[-_]/g, 'x');
@@ -65,9 +70,9 @@ export async function createWebRequest(ctx: Ctx, input: CreateWebRequestInput) {
token,
serviceId: service.id,
params: params.values as Prisma.InputJsonValue,
preferredAt: input.preferredAt,
preferredAt: input.preferredAt ?? null,
durationMinutes: q.durationMinutes,
districtId: district.id,
districtId: district?.id ?? null,
addressText,
note: input.note?.trim() || undefined,
phone,
@@ -84,40 +89,36 @@ export async function createWebRequest(ctx: Ctx, input: CreateWebRequestInput) {
}
type Gift = Awaited<ReturnType<typeof giveConsent>>;
export type WebRequestField = 'district' | 'address' | 'time';
export type LinkResult =
| { kind: 'none' }
| { kind: 'ordered'; requestNumber: number; orderNumber: number; scheduledAt: Date; gift?: Gift }
| { kind: 'linked'; requestNumber: number; gift?: Gift };
| { kind: 'linked'; requestNumber: number; gift?: Gift }
/** не хватает данных для заказа — бот их доспросит */
| { kind: 'incomplete'; requestId: string; requestNumber: number; missing: WebRequestField[]; gift?: Gift };
/**
* Человек открыл бота по ссылке заявки: привязываем заявку к клиенту, переносим согласие с сайта
* и пробуем сразу оформить заказ на желаемое время. Если время занято или исполнителей нет —
* заявка остаётся оператору (LINKED). Повторный /start по той же ссылке ничего не меняет.
*/
export async function linkWebRequest(ctx: Ctx, token: string, clientId: string): Promise<LinkResult> {
const request = await ctx.db.webRequest.findUnique({ where: { token }, include: { service: true, district: true } });
if (!request) return { kind: 'none' };
if (request.clientId && request.clientId !== clientId) return { kind: 'none' };
if (request.status === 'ORDERED' && request.orderId) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: request.orderId } });
return { kind: 'ordered', requestNumber: request.number, orderNumber: order.number, scheduledAt: order.scheduledAt };
}
if (request.status !== 'NEW') return { kind: 'linked', requestNumber: request.number };
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
// согласие дано на сайте; вместе с ним активируется подарок агентства, если положен
const gift = client.consentAt ? null : await giveConsent(ctx, clientId);
if (!client.user.phone) await ctx.db.user.update({ where: { id: client.userId }, data: { phone: request.phone } });
await ctx.db.webRequest.update({ where: { id: request.id }, data: { clientId, status: 'LINKED' } });
/** Чего не хватает заявке, чтобы оформить заказ */
export function missingFields(r: { preferredAt: Date | null; districtId: string | null; addressText: string | null }): WebRequestField[] {
const out: WebRequestField[] = [];
if (!r.districtId) out.push('district');
if (!r.addressText) out.push('address');
if (!r.preferredAt) out.push('time');
return out;
}
/** Пробуем оформить заказ по заявке; если время занято или исполнителей нет — заявка остаётся оператору */
async function tryOrder(ctx: Ctx, requestId: string, clientId: string, gift?: Gift): Promise<LinkResult> {
const request = await ctx.db.webRequest.findUniqueOrThrow({ where: { id: requestId }, include: { service: true } });
const missing = missingFields(request);
if (missing.length) return { kind: 'incomplete', requestId: request.id, requestNumber: request.number, missing, gift };
try {
const order = await createOrder(ctx, {
clientId,
serviceCode: request.service.code,
params: request.params as Record<string, unknown>,
scheduledAt: request.preferredAt,
addressText: request.addressText,
districtId: request.districtId,
scheduledAt: request.preferredAt!,
addressText: request.addressText!,
districtId: request.districtId!,
note: request.note ?? undefined,
});
// заявка могла быть отменена оператором, пока создавался заказ, — тогда не перезаписываем статус
@@ -129,6 +130,76 @@ export async function linkWebRequest(ctx: Ctx, token: string, clientId: string):
}
}
/**
* Человек открыл бота по ссылке заявки: привязываем заявку к клиенту, переносим согласие с сайта
* и пробуем сразу оформить заказ на желаемое время. Если чего-то не хватает — бот доспросит (incomplete);
* если время занято или исполнителей нет — заявка остаётся оператору (LINKED). Повторный /start ничего не меняет.
*/
export async function linkWebRequest(ctx: Ctx, token: string, clientId: string): Promise<LinkResult> {
const request = await ctx.db.webRequest.findUnique({ where: { token } });
if (!request) return { kind: 'none' };
if (request.clientId && request.clientId !== clientId) return { kind: 'none' };
if (request.status === 'ORDERED' && request.orderId) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: request.orderId } });
return { kind: 'ordered', requestNumber: request.number, orderNumber: order.number, scheduledAt: order.scheduledAt };
}
if (request.status === 'LINKED') {
const missing = missingFields(request);
return missing.length ? { kind: 'incomplete', requestId: request.id, requestNumber: request.number, missing } : { kind: 'linked', requestNumber: request.number };
}
if (request.status !== 'NEW') return { kind: 'linked', requestNumber: request.number };
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
// согласие дано на сайте; вместе с ним активируется подарок агентства, если положен
const gift = client.consentAt ? null : await giveConsent(ctx, clientId);
if (!client.user.phone) await ctx.db.user.update({ where: { id: client.userId }, data: { phone: request.phone } });
await ctx.db.webRequest.update({ where: { id: request.id }, data: { clientId, status: 'LINKED' } });
return tryOrder(ctx, request.id, clientId, gift);
}
/** Незавершённая заявка клиента (бот доспрашивает данные) */
async function ownRequest(ctx: Ctx, requestId: string, clientId: string) {
const r = await ctx.db.webRequest.findUnique({ where: { id: requestId }, include: { service: true } });
if (!r || r.clientId !== clientId || r.status !== 'LINKED') throw new DomainError('NOT_FOUND');
return r;
}
/** Бот: клиент выбрал район, написал адрес или выбрал время. Когда всё есть — оформляем заказ */
export async function completeWebRequest(
ctx: Ctx,
input: { requestId: string; clientId: string; districtCode?: string; addressText?: string; preferredAt?: Date },
): Promise<LinkResult> {
const r = await ownRequest(ctx, input.requestId, input.clientId);
const data: Prisma.WebRequestUpdateInput = {};
if (input.districtCode) {
const d = await ctx.db.district.findUnique({ where: { code: input.districtCode } });
if (!d) throw new DomainError('ADDRESS_REQUIRED');
data.district = { connect: { id: d.id } };
}
if (input.addressText !== undefined) {
const a = input.addressText.trim();
if (a.length < 3) throw new DomainError('ADDRESS_REQUIRED');
data.addressText = a.slice(0, 300);
}
if (input.preferredAt) data.preferredAt = input.preferredAt;
await ctx.db.webRequest.update({ where: { id: r.id }, data });
return tryOrder(ctx, r.id, input.clientId);
}
/** Бот: дни со свободным временем и время на выбранный день (по графику исполнителей) */
export async function webRequestSlots(ctx: Ctx, input: { requestId: string; clientId: string; dateKey?: string }) {
const r = await ownRequest(ctx, input.requestId, input.clientId);
const cfg = slotConfigSchema.parse(r.service.slotConfig);
const now = ctx.now();
const keys = input.dateKey ? [input.dateKey] : Array.from({ length: Math.min(cfg.horizonDays + 1, 10) }, (_, i) => yerevanDateKey(new Date(now.getTime() + i * DAY)));
const days = [];
for (const dateKey of keys) {
const slots = await slotsForDay(ctx.db, { role: r.service.providerRole, config: cfg, durationMinutes: r.durationMinutes, dateKey, now });
if (slots.length) days.push({ dateKey, starts: slots.map((s) => s.start) });
}
return days;
}
/** Для админки: свежие сверху, по статусу */
export async function listWebRequests(ctx: Ctx, input: { status?: WebRequestStatus }) {
return ctx.db.webRequest.findMany({
+3 -3
View File
@@ -41,11 +41,11 @@ describe('автораспределение заказов', () => {
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Ani' });
const second = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED' } });
await respondToOffer(ctx, { offerId: second.id, providerId: fx.ani.id, accept: false });
// больше свободных частных нет — компания включается и получает заказ
// больше свободных частных нет; партнёрам система сама не назначает — это делает оператор
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: true } });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Chisto' });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'none' });
const offers = await db.orderOffer.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id, fx.company.id]);
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id]);
});
it('снятый с заказа исполнитель не получает его снова автоматически', async () => {
+6 -4
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { encodeStartPayload } from '@barev/core';
import { addMonths, encodeStartPayload } from '@barev/core';
import {
applyAgencyCode,
assignmentCandidates,
@@ -95,7 +95,7 @@ describe('онбординг (ONB-1…6)', () => {
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(gift?.endsAt.getTime()).toBe(addMonths(NOW, 1).getTime());
expect(events).toContainEqual(expect.objectContaining({ type: 'gift.activated' }));
expect(await db.subscription.count({ where: { clientId: start.clientId, source: 'AGENCY_GIFT' } })).toBe(1);
});
@@ -388,9 +388,11 @@ describe('выплаты клинеру по пресету', () => {
expect(orderPayout(full, maria)).toBe(9000 + 2500 + 1500 + 2000);
// без пресета — индивидуальная ставка 70% от итога
expect(orderPayout(full, { ...maria, payoutPreset: null })).toBe(Math.floor(full.totalPrice * 0.7));
// сотрудник компании — ставка компании
// сотрудник партнёра «как Uber» — ему платит партнёр, от нас 0
const lusine = await db.provider.findUniqueOrThrow({ where: { id: fx.lusine.id }, include: { company: true, payoutPreset: true } });
expect(orderPayout(full, lusine)).toBe(Math.floor(full.totalPrice * 0.65));
expect(orderPayout(full, lusine)).toBe(0);
// «расходы наши» — ФОТ клинера, % от оплаченного
expect(orderPayout(full, { ...lusine, company: { deal: 'PROFIT_SHARE', fotPct: 40 } })).toBe(Math.floor(full.totalPrice * 0.4));
});
});
+14
View File
@@ -49,6 +49,20 @@ export async function fixtures() {
],
});
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
// справочники бариков (в базе их создаёт миграция)
await db.pointsCategory.createMany({
data: [
{ code: 'cleaning', name: { ru: 'Клининг' }, rateBarev: 5, ratePlus: 8, rateElite: 10 },
{ code: 'documents', name: { ru: 'Документы' }, rateBarev: 5, ratePlus: 10, rateElite: 12 },
],
});
await db.pointsBonusType.createMany({
data: [
{ code: 'housewarming', name: { ru: 'Новоселье' }, defaultAmount: 5000, ttlDays: 60 },
{ code: 'promo', name: { ru: 'Промо' }, defaultAmount: 1000, ttlDays: 30 },
{ code: 'first_month_route', name: { ru: 'Маршрут' }, defaultAmount: 3000 },
],
});
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')!;
+70
View File
@@ -0,0 +1,70 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { createOrder, editOrderComposition, giveConsent, markPaid, previewOrderEdit, staffChecklistItem, startClient, transitionOrder } 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 order(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
}
const admin = () => db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
describe('команда меняет заказ', () => {
it('состав: пересчёт цены и длительности, история, уведомление по галочке', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await order(ctx, 1501);
const op = await admin();
const p = await previewOrderEdit(ctx, { orderId: o.id, params: { cleaningType: 'regular', rooms: 3, bathrooms: 1, extras: { windows: 2 } } });
expect(p.totalPrice).toBeGreaterThan(o.totalPrice);
expect(p.durationMinutes).toBeGreaterThan(o.durationMinutes);
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: p.params, notify: true });
const after = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(after).toMatchObject({ totalPrice: p.totalPrice, durationMinutes: p.durationMinutes });
expect((after.params as Record<string, unknown>).rooms).toBe(3);
expect(events).toContainEqual({ type: 'order.edited', orderId: o.id });
expect(await db.orderStatusHistory.count({ where: { orderId: o.id, event: 'editedByStaff' } })).toBe(1);
});
it('своя цена — только с причиной', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 1502);
const op = await admin();
await expect(editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, totalPrice: 12_345 })).rejects.toMatchObject({ code: 'REASON_REQUIRED' });
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, totalPrice: 12_345, reason: 'договорились' });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).totalPrice).toBe(12_345);
});
it('после оплаты: кэшбек пересчитывается по новой цене', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 1503);
const op = await admin();
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
await db.orderChecklistItem.updateMany({ where: { orderId: o.id }, data: { checkedAt: NOW, requiresPhoto: false } });
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
await markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: op.id } });
const before = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id, status: { not: 'REVERSED' } } });
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 4, bathrooms: 2 } });
const now = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id, status: { not: 'REVERSED' } } });
expect(now.id).not.toBe(before.id);
expect(now.amount).toBeGreaterThan(before.amount);
});
it('чек-лист за клинера: пункт с фото — без фото только с причиной; потом заказ можно закрыть', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 1504);
const op = await admin();
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
const items = await db.orderChecklistItem.findMany({ where: { orderId: o.id } });
const photo = items.find((i) => i.requiresPhoto)!;
await expect(staffChecklistItem(ctx, { orderId: o.id, adminId: op.id, code: photo.code, checked: true })).rejects.toMatchObject({ code: 'PHOTO_REQUIRED' });
for (const i of items) await staffChecklistItem(ctx, { orderId: o.id, adminId: op.id, code: i.code, checked: true, reason: i.requiresPhoto ? 'фото в чате' : null });
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).status).toBe('DONE');
});
});
+193
View File
@@ -0,0 +1,193 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { addMonths } from '@barev/core';
import {
applyAgencyCode,
assignmentCandidates,
companyAutoAssign,
completeWebRequest,
createOrder,
createWebRequest,
dispatcherAssign,
expireOffers,
giveConsent,
linkWebRequest,
markPaid,
offerOrder,
partnerAddStaff,
partnerCabinetData,
partnerDeclineOrder,
partnerUnassign,
respondToOffer,
startClient,
} 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());
type C = ReturnType<typeof makeCtx>['ctx'];
async function order(ctx: C, tg: number, at = slotAt(2, 11), allowBusySlot = false) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: at, addressText: 'Pushkin 10', districtId: fx.kentron.id, allowBusySlot });
}
const admin = () => db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
const events = async (orderId: string) => (await db.orderStatusHistory.findMany({ where: { orderId }, orderBy: { createdAt: 'asc' } })).map((h) => h.event);
describe('партнёры услуг: оператор назначает', () => {
it('у каждого варианта видно, что остаётся нам; самый выгодный подсвечен', async () => {
const { ctx } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { deal: 'COMMISSION', commissionPct: 40 } });
const o = await order(ctx, 901);
const r = await assignmentCandidates(ctx, o.id);
const chisto = r.ranked.find((c) => c.kind === 'company')!;
expect(chisto.margin).toBe(Math.floor(o.totalPrice * 0.4));
const maria = r.ranked.find((c) => c.name === 'Maria')!;
expect(maria.margin).toBe(o.totalPrice - maria.cost);
const best = [...r.ranked].sort((a, b) => b.margin - a.margin)[0]!;
expect(r.bestId).toBe(`${best.kind}:${best.id}`);
});
it('партнёр отказался — заказ снова у оператора', async () => {
const { ctx, events: ev } = makeCtx(NOW);
const o = await order(ctx, 902);
const op = await admin();
await offerOrder(ctx, { orderId: o.id, adminId: op.id, target: { kind: 'company', id: fx.company.id } });
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, companyId: fx.company.id, status: 'OFFERED' } });
expect(offer.expiresAt).not.toBeNull();
await partnerDeclineOrder(ctx, { orderId: o.id, companyId: fx.company.id, userId: fx.dispatcher.id });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).companyId).toBeNull();
expect(ev).toContainEqual({ type: 'partner.returned', orderId: o.id, companyId: fx.company.id, reason: 'declined' });
});
it('партнёр не назначил сотрудника вовремя — заказ возвращается оператору', async () => {
const { ctx, clock } = makeCtx(NOW);
const o = await order(ctx, 903);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
clock.now = new Date(NOW.getTime() + 61 * 60_000);
await expireOffers(ctx);
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).companyId).toBeNull();
expect(await events(o.id)).toContain('partnerExpired');
});
});
describe('партнёры услуг: автораспределение по сотрудникам', () => {
it('заказ сам уходит сотруднику; отказ — следующему; никого нет — менеджеру', async () => {
const { ctx, events: ev } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { autoAssign: true } });
const added = await partnerAddStaff(ctx, {
companyId: fx.company.id,
userId: fx.dispatcher.id,
staff: { name: 'Gayane', languages: ['hy'], districtIds: [fx.kentron.id], schedule: [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 540, endMinute: 1200 })) },
});
// сотрудник ещё не вошёл в бот — автоматически ему не предлагаем
expect(added.inviteCode).toMatch(/^PRO-/);
const o = await order(ctx, 904);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
const first = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED', providerId: { not: null } } });
expect(first.providerId).toBe(fx.lusine.id);
await respondToOffer(ctx, { offerId: first.id, providerId: fx.lusine.id, accept: false });
// больше подходящих нет — заказ ждёт менеджера
expect(await db.orderOffer.count({ where: { orderId: o.id, status: 'OFFERED', providerId: null, companyId: fx.company.id } })).toBe(1);
expect(ev).toContainEqual({ type: 'company.noStaff', orderId: o.id, companyId: fx.company.id });
expect(await companyAutoAssign(ctx, o.id)).toEqual({ result: 'none' });
});
it('лимит заказов в день и перерыв на дорогу', async () => {
const { ctx } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { autoAssign: true, travelGapMinutes: 60 } });
// первый заказ Лусине: 11:00, уборка 2 комнат ~3 часа
const a = await order(ctx, 905, slotAt(2, 11));
await offerOrder(ctx, { orderId: a.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
const offerA = await db.orderOffer.findFirstOrThrow({ where: { orderId: a.id, status: 'OFFERED', providerId: { not: null } } });
await respondToOffer(ctx, { offerId: offerA.id, providerId: fx.lusine.id, accept: true });
const end = new Date(a.scheduledAt.getTime() + a.durationMinutes * 60_000);
// сразу после окончания — нет времени доехать
const b = await order(ctx, 906, new Date(end.getTime() + 30 * 60_000), true);
const cab = await import('../src/servicePartners.ts');
const settings = await (await import('../src/settings.ts')).getSettings(db);
const tight = await cab.companyStaffCandidates(db, fx.company.id, b.id, settings);
expect(tight.rows.find((r) => r.id === fx.lusine.id)?.check).toBe('gap');
// лимит 1 заказ в день
await db.provider.update({ where: { id: fx.lusine.id }, data: { maxOrdersPerDay: 1 } });
const c = await order(ctx, 907, new Date(end.getTime() + 2 * 3600_000), true);
const limited = await cab.companyStaffCandidates(db, fx.company.id, c.id, settings);
expect(limited.rows.find((r) => r.id === fx.lusine.id)?.check).toBe('limit');
});
it('менеджер назначает вручную и снимает сотрудника', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 908);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
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 } })).providerId).toBe(fx.lusine.id);
await partnerUnassign(ctx, { orderId: o.id, companyId: fx.company.id, userId: fx.dispatcher.id });
const after = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(after).toMatchObject({ providerId: null, companyId: fx.company.id, status: 'NEW' });
const cab = await partnerCabinetData(ctx, fx.company.id, fx.dispatcher.id);
expect(cab.pending.map((x) => x.id)).toContain(o.id);
});
});
describe('партнёры услуг: доход', () => {
it('«расходы наши»: при оплате фиксируется доля партнёра от прибыли', async () => {
const { ctx } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { deal: 'PROFIT_SHARE', profitSharePct: 30, fotPct: 60, taxPct: 5 } });
const o = await order(ctx, 910);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
await dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: fx.dispatcher.id });
await markPaid(ctx, { orderId: o.id, by: { type: 'PROVIDER', id: fx.lusine.id } });
const e = await db.partnerEarning.findUniqueOrThrow({ where: { orderId: o.id } });
const fot = Math.floor(o.totalPrice * 0.6);
const tax = Math.floor(o.totalPrice * 0.05);
expect(e).toMatchObject({ deal: 'PROFIT_SHARE', paid: o.totalPrice, fot, tax, amount: Math.floor(((o.totalPrice - fot - tax) * 30) / 100) });
const cab = await partnerCabinetData(ctx, fx.company.id, fx.dispatcher.id);
expect(cab.money.balance).toBe(e.amount);
});
it('чужой менеджер не видит кабинет', async () => {
const { ctx } = makeCtx(NOW);
await expect(partnerCabinetData(ctx, fx.company.id, (await db.user.create({ data: { telegramId: 99999n } })).id)).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
});
describe('подарок агентства', () => {
it('уровень и срок задаются у агентства', async () => {
const { ctx } = makeCtx(NOW);
await db.agency.update({ where: { id: fx.agency.id }, data: { giftPlan: 'ELITE', giftMonths: 3 } });
const s = await startClient(ctx, tgUser(920));
await giveConsent(ctx, s.clientId);
const r = await applyAgencyCode(ctx, s.clientId, 'DOMIK');
expect(r.gift).toEqual({ plan: 'ELITE', endsAt: addMonths(NOW, 3) });
const sub = await db.subscription.findFirstOrThrow({ where: { clientId: s.clientId } });
expect(sub).toMatchObject({ plan: 'ELITE', agencyId: fx.agency.id, source: 'AGENCY_GIFT' });
});
it('агентство без подарка', async () => {
const { ctx } = makeCtx(NOW);
await db.agency.update({ where: { id: fx.agency.id }, data: { giftPlan: null } });
const s = await startClient(ctx, tgUser(921));
await giveConsent(ctx, s.clientId);
expect((await applyAgencyCode(ctx, s.clientId, 'DOMIK')).gift).toBeNull();
});
});
describe('заявка с сайта без времени и адреса', () => {
it('бот доспрашивает недостающее и оформляет заказ', async () => {
const { ctx } = makeCtx(NOW);
const req = await createWebRequest(ctx, { serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, phone: '+37491123456', locale: 'ru' });
expect(req).toMatchObject({ preferredAt: null, districtId: null, addressText: null });
const s = await startClient(ctx, tgUser(930));
const link = await linkWebRequest(ctx, req.token, s.clientId);
expect(link).toMatchObject({ kind: 'incomplete', missing: ['district', 'address', 'time'] });
if (link.kind !== 'incomplete') return;
expect(await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, districtCode: 'kentron' })).toMatchObject({ kind: 'incomplete', missing: ['address', 'time'] });
expect(await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, addressText: 'Abovyan 5' })).toMatchObject({ kind: 'incomplete', missing: ['time'] });
const done = await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, preferredAt: slotAt(2, 12) });
expect(done).toMatchObject({ kind: 'ordered' });
expect((await db.webRequest.findUniqueOrThrow({ where: { id: req.id } })).status).toBe('ORDERED');
});
});
+212
View File
@@ -0,0 +1,212 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import {
accruePoints,
activatePendingPoints,
burnPoints,
cancelByClient,
confirmRedemption,
createOrder,
giveConsent,
markPaid,
reconcilePoints,
redeemPoints,
requestRedemption,
reverseAccrual,
reversePreview,
reverseRedemption,
startClient,
transitionOrder,
warnExpiringPoints,
applyAgencyCode,
} 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());
type C = ReturnType<typeof makeCtx>['ctx'];
const OWNER = { adminId: null, owner: true };
const H = 3600_000;
const D = 24 * H;
async function client(ctx: C, tg: number) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return s.clientId;
}
const cat = (code = 'cleaning') => db.pointsCategory.findUniqueOrThrow({ where: { code } });
const bal = async (id: string) => db.client.findUniqueOrThrow({ where: { id }, select: { pointsActive: true, pointsPending: true, pointsDebt: true } });
async function give(ctx: C, clientId: string, amount: number, extra: Record<string, unknown> = {}) {
return accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: (await cat()).id, orderAmount: 100_000, amount, reason: 'тест', serviceDate: NOW, noWait: true, channel: 'WEB', ...extra }, OWNER);
}
describe('барики: начисление', () => {
it('по заказу: подсказка по ставке Barev, ожидание 48 ч, потом доступны', async () => {
const { ctx, clock, events } = makeCtx(NOW);
const id = await client(ctx, 1001);
const r = await accruePoints(ctx, { clientId: id, kind: 'ORDER', categoryId: (await cat()).id, orderAmount: 18_000, serviceDate: NOW, channel: 'WEB', orderRef: '1042' }, OWNER);
expect(r).toMatchObject({ status: 'created', amount: 900 });
expect(await bal(id)).toMatchObject({ pointsActive: 0, pointsPending: 900 });
expect(events.some((e) => e.type === 'points.accrued')).toBe(true);
clock.now = new Date(NOW.getTime() + 49 * H);
expect(await activatePendingPoints(ctx)).toBe(1);
expect(await bal(id)).toMatchObject({ pointsActive: 900, pointsPending: 0 });
});
it('тот же номер заказа второй раз — нельзя; изменённая сумма — только с причиной', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1002);
const base = { clientId: id, kind: 'ORDER' as const, categoryId: (await cat()).id, orderAmount: 18_000, serviceDate: NOW, channel: 'WEB' as const };
await expect(accruePoints(ctx, { ...base, amount: 2000, orderRef: 'A1' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_REASON_REQUIRED' });
await accruePoints(ctx, { ...base, orderRef: 'A1' }, OWNER);
await expect(accruePoints(ctx, { ...base, orderRef: 'A1' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_DUPLICATE_ORDER' });
});
it('бонус со своим сроком; «без ожидания» у поддержки — только с причиной и в пределах лимитов', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1003);
const promo = await db.pointsBonusType.findUniqueOrThrow({ where: { code: 'promo' } });
await accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, serviceDate: NOW, channel: 'WEB' }, OWNER);
const a = await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } });
expect(a).toMatchObject({ status: 'ACTIVE', amount: 1000 });
expect(a.expiresAt?.getTime()).toBe(NOW.getTime() + 30 * D);
// поддержка без разрешения в настройках — нельзя
const support = { adminId: 'op1', owner: false };
await expect(give(ctx, id, 100, { orderRef: 'S1' }).then(() => accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, serviceDate: NOW, channel: 'WEB' }, support))).rejects.toMatchObject({ code: 'FORBIDDEN' });
await db.setting.create({ data: { key: 'points.supportCanManage', value: true } });
const big = await accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, amount: 25_000, serviceDate: NOW, channel: 'WEB' }, support);
expect(big.status).toBe('needs_approval');
});
it('серия уборок: третья подряд — +2 п.п. к ставке', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1004);
const c = await cat();
for (const [i, ref] of ['s1', 's2', 's3'].entries()) {
await accruePoints(ctx, { clientId: id, kind: 'ORDER', categoryId: c.id, orderAmount: 10_000, serviceDate: new Date(NOW.getTime() - (40 - i * 20) * D), channel: 'WEB', orderRef: ref }, OWNER);
}
const last = await db.pointsAccrual.findFirstOrThrow({ where: { orderRef: 's3' } });
expect(last).toMatchObject({ ratePct: 7, streakBonusPct: 2, amount: 700 });
});
});
describe('барики: списание', () => {
it('FIFO, лимит 30% и минимум 1000; два списания одновременно не уводят в минус', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1101);
await give(ctx, id, 3000, { orderRef: 'o1', serviceDate: new Date(NOW.getTime() - 10 * D) });
await give(ctx, id, 2000, { orderRef: 'o2' });
await expect(redeemPoints(ctx, { clientId: id, amount: 500, channel: 'WEB', reason: 'x' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_BELOW_MIN' });
await expect(redeemPoints(ctx, { clientId: id, amount: 4000, orderAmount: 10_000, channel: 'WEB', reason: 'x' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_OVER_SHARE' });
const results = await Promise.allSettled([
redeemPoints(ctx, { clientId: id, amount: 3500, channel: 'WEB', reason: 'a' }, OWNER),
redeemPoints(ctx, { clientId: id, amount: 3500, channel: 'WEB', reason: 'b' }, OWNER),
]);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect((await bal(id)).pointsActive).toBe(1500);
const red = await db.pointsRedemption.findFirstOrThrow({ where: { clientId: id, status: 'CONFIRMED' }, include: { items: { include: { accrual: true } } } });
expect(red.items.map((i) => [i.accrual.orderRef, i.amount])).toEqual([
['o1', 3000],
['o2', 500],
]);
});
it('запрос клиента → команда подтверждает по сумме заказа', async () => {
const { ctx, events } = makeCtx(NOW);
const id = await client(ctx, 1102);
await give(ctx, id, 10_000, { orderRef: 'o1' });
const r = await requestRedemption(ctx, { clientId: id, amount: 8000 });
expect(events).toContainEqual({ type: 'points.requested', redemptionId: r.id });
const res = await confirmRedemption(ctx, { redemptionId: r.id, orderAmount: 18_000 }, OWNER);
expect(res.amount).toBe(5400);
expect((await bal(id)).pointsActive).toBe(4600);
});
it('в заказе: барики уменьшают сумму к оплате, кэшбек — с оплаченного деньгами; отмена всё возвращает', async () => {
const { ctx, clock } = makeCtx(NOW);
const id = await client(ctx, 1103);
await give(ctx, id, 5000, { orderRef: 'o1' });
const o = await createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 3000 });
expect(o.pointsUsed).toBe(3000);
expect((await bal(id)).pointsActive).toBe(2000);
await expect(createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(3, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 1000, promoCode: 'X' })).rejects.toBeDefined();
// выполнен и оплачен — кэшбек с (цена − барики)
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
await db.orderChecklistItem.updateMany({ where: { orderId: o.id }, data: { checkedAt: NOW, requiresPhoto: false } });
await markPaid(ctx, { orderId: o.id, by: { type: 'PROVIDER', id: fx.maria.id } });
clock.now = new Date(slotAt(2, 14).getTime());
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
const acc = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id } });
expect(acc).toMatchObject({ channel: 'AUTO', paidWithPoints: 3000, amount: Math.floor(((o.totalPrice - 3000) * 5) / 100), status: 'PENDING' });
const pay = await db.payment.findFirstOrThrow({ where: { orderId: o.id } });
expect(pay.amount).toBe(o.totalPrice - 3000);
});
it('отмена заказа клиентом возвращает списанные барики', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1104);
await give(ctx, id, 5000, { orderRef: 'o1' });
const o = await createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(3, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 2000 });
expect((await bal(id)).pointsActive).toBe(3000);
await cancelByClient(ctx, { orderId: o.id, clientId: id });
expect((await bal(id)).pointsActive).toBe(5000);
expect(await db.pointsReversal.count({ where: { targetType: 'REDEMPTION' } })).toBe(1);
});
});
describe('барики: отмена и сгорание', () => {
it('отмена частично потраченного — минус с предупреждением, гасится следующим начислением', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1201);
const a = await give(ctx, id, 3000, { orderRef: 'o1' });
await redeemPoints(ctx, { clientId: id, amount: 2000, channel: 'WEB', reason: 'звонок' }, OWNER);
if (a.status !== 'created') throw new Error();
expect(await reversePreview(db, { type: 'ACCRUAL', id: a.accrualId })).toMatchObject({ spent: 2000, balanceAfter: -2000 });
await reverseAccrual(ctx, { accrualId: a.accrualId, reason: 'возврат', channel: 'WEB' }, OWNER);
expect(await bal(id)).toMatchObject({ pointsActive: -2000, pointsDebt: 2000 });
const orig = await db.pointsAccrual.findUniqueOrThrow({ where: { id: a.accrualId } });
expect(orig).toMatchObject({ status: 'REVERSED', amount: 3000 });
await give(ctx, id, 2500, { orderRef: 'o2' });
expect(await bal(id)).toMatchObject({ pointsActive: 500, pointsDebt: 0 });
await expect(reverseAccrual(ctx, { accrualId: a.accrualId, reason: 'ещё', channel: 'WEB' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_FINAL' });
});
it('отмена списания возвращает барики в исходные начисления', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1202);
await give(ctx, id, 3000, { orderRef: 'o1' });
const r = await redeemPoints(ctx, { clientId: id, amount: 3000, channel: 'WEB', reason: 'x' }, OWNER);
expect((await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } })).status).toBe('SPENT');
await reverseRedemption(ctx, { redemptionId: r.redemptionId!, reason: 'ошибка', channel: 'WEB' }, OWNER);
expect(await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } })).toMatchObject({ status: 'ACTIVE', remaining: 3000 });
});
it('сгорание через 120 дней без активности, предупреждения за 14 и 3 дня', async () => {
const { ctx, clock, events } = makeCtx(NOW);
const id = await client(ctx, 1203);
await give(ctx, id, 3250, { orderRef: 'o1' });
clock.now = new Date(NOW.getTime() + 107 * D);
await warnExpiringPoints(ctx);
expect(events).toContainEqual({ type: 'points.expiring', clientId: id, amount: 3250, days: 13 });
clock.now = new Date(NOW.getTime() + 118 * D);
await warnExpiringPoints(ctx);
await warnExpiringPoints(ctx);
expect(events.filter((e) => e.type === 'points.expiring')).toHaveLength(2);
clock.now = new Date(NOW.getTime() + 121 * D);
expect(await burnPoints(ctx)).toBe(3250);
expect((await bal(id)).pointsActive).toBe(0);
expect(await reconcilePoints(db)).toHaveLength(0);
});
it('новоселье от агентства — один раз', async () => {
const { ctx } = makeCtx(NOW);
await db.agency.update({ where: { id: fx.agency.id }, data: { housewarmingPoints: 5000, housewarmingDays: 60 } });
const id = await client(ctx, 1204);
await applyAgencyCode(ctx, id, 'DOMIK');
await applyAgencyCode(ctx, id, 'DOMIK');
expect((await bal(id)).pointsActive).toBe(5000);
expect(await db.pointsAccrual.count({ where: { clientId: id } })).toBe(1);
});
});