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

+11 -5
View File
@@ -230,15 +230,21 @@ describe('Barev+ и агентства (ONB-4, SUB-3, ADM-8)', () => {
const now = new Date('2026-10-17T10:00:00Z');
it('подарочный месяц — один раз', () => {
const ok = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: null });
expect(ok).toEqual({ ok: true, startsAt: now, endsAt: new Date('2026-11-16T10:00:00Z') });
expect(decideGift({ now, giftUsedAt: now, agencyActive: true, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'ALREADY_USED' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: false, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'AGENCY_INACTIVE' });
const ok = decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 1, currentEndsAt: null });
expect(ok).toEqual({ ok: true, startsAt: now, endsAt: new Date('2026-11-17T10:00:00Z') });
expect(decideGift({ now, giftUsedAt: now, agencyActive: true, giftMonths: 1, currentEndsAt: null })).toEqual({ ok: false, reason: 'ALREADY_USED' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: false, giftMonths: 1, currentEndsAt: null })).toEqual({ ok: false, reason: 'AGENCY_INACTIVE' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 0, currentEndsAt: null })).toEqual({ ok: false, reason: 'NO_GIFT' });
});
it('подарок на несколько месяцев', () => {
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 3, currentEndsAt: null });
expect(r.ok && r.endsAt).toEqual(new Date('2027-01-17T10:00:00Z'));
});
it('подарок продлевает действующую подписку', () => {
const endsAt = new Date('2026-10-20T00:00:00Z');
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: endsAt });
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 1, currentEndsAt: endsAt });
expect(r.ok && r.startsAt).toEqual(endsAt);
});
+2
View File
@@ -13,3 +13,5 @@ export * from './settings.ts';
export * from './payments.ts';
export * from './catalog.ts';
export * from './payout.ts';
export * from './partners.ts';
export * from './points.ts';
+8 -6
View File
@@ -1,4 +1,4 @@
import { addMonths, DAY } from './time.ts';
import { addMonths } from './time.ts';
export interface SubscriptionLike {
startsAt: Date;
@@ -33,23 +33,25 @@ export function discountFor(level: MembershipLevel, plusPct: number, elitePct =
export type GiftDecision =
| { ok: true; startsAt: Date; endsAt: Date }
| { ok: false; reason: 'ALREADY_USED' | 'AGENCY_INACTIVE' };
| { ok: false; reason: 'ALREADY_USED' | 'AGENCY_INACTIVE' | 'NO_GIFT' };
/**
* SUB-3 / ONB-4: подарочный месяц по коду агентства, один раз на клиента.
* Если у клиента уже есть активный Barev+, подарок продлевает его с даты окончания.
* SUB-3 / ONB-4: подарок по коду агентства (уровень и срок задаются у агентства), один раз на клиента.
* Если у клиента уже есть активная подписка, подарок начинается с даты её окончания.
*/
export function decideGift(input: {
now: Date;
giftUsedAt: Date | null;
agencyActive: boolean;
giftDays: number;
/** срок подарка агентства в месяцах; 0 — агентство ничего не дарит */
giftMonths: number;
currentEndsAt: Date | null;
}): GiftDecision {
if (input.giftUsedAt) return { ok: false, reason: 'ALREADY_USED' };
if (!input.agencyActive) return { ok: false, reason: 'AGENCY_INACTIVE' };
if (input.giftMonths <= 0) return { ok: false, reason: 'NO_GIFT' };
const startsAt = input.currentEndsAt && input.currentEndsAt > input.now ? input.currentEndsAt : input.now;
return { ok: true, startsAt, endsAt: new Date(startsAt.getTime() + input.giftDays * DAY) };
return { ok: true, startsAt, endsAt: addMonths(startsAt, input.giftMonths) };
}
/** Клиент «принадлежит» агентству N месяцев с первого /start (решение владельца) */
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { partnerSplit, partnerTerms, withinDailyLimits } from './partners.ts';
import { isProviderFreeWithGap } from './slots.ts';
import { fromYerevan } from './time.ts';
describe('партнёры услуг: деньги', () => {
it('как Uber: наш % с оплаченного, остальное партнёру', () => {
const r = partnerSplit(20_000, { deal: 'COMMISSION', commissionPct: 30, profitSharePct: 0, fotPct: 0, taxPct: 0 });
expect(r).toMatchObject({ partner: 14_000, margin: 6_000, fot: 0, tax: 0, pct: 30 });
});
it('расходы наши: % партнёра от (оплачено − ФОТ − налог)', () => {
const r = partnerSplit(20_000, { deal: 'PROFIT_SHARE', commissionPct: 0, profitSharePct: 30, fotPct: 60, taxPct: 5 });
// ФОТ 12 000, налог 1 000, прибыль 7 000 → партнёру 2 100
expect(r).toMatchObject({ fot: 12_000, tax: 1_000, partner: 2_100, cost: 14_100, margin: 5_900 });
});
it('ФОТ и налог по умолчанию из настроек, свои — у партнёра', () => {
const base = { deal: 'PROFIT_SHARE' as const, commissionPct: 0, profitSharePct: 50 };
expect(partnerTerms({ ...base, fotPct: null, taxPct: null }, { fotPct: 50, taxPct: 10 })).toMatchObject({ fotPct: 50, taxPct: 10 });
expect(partnerTerms({ ...base, fotPct: 40, taxPct: 0 }, { fotPct: 50, taxPct: 10 })).toMatchObject({ fotPct: 40, taxPct: 0 });
});
});
describe('партнёры услуг: нагрузка сотрудника', () => {
it('лимит заказов и часов в день', () => {
const day = [{ durationMinutes: 180 }, { durationMinutes: 120 }];
expect(withinDailyLimits({ maxOrdersPerDay: 3, maxHoursPerDay: null }, day, 60)).toBe(true);
expect(withinDailyLimits({ maxOrdersPerDay: 2, maxHoursPerDay: null }, day, 60)).toBe(false);
expect(withinDailyLimits({ maxOrdersPerDay: null, maxHoursPerDay: 6 }, day, 60)).toBe(true);
expect(withinDailyLimits({ maxOrdersPerDay: null, maxHoursPerDay: 6 }, day, 61)).toBe(false);
});
it('между заказами остаётся время на дорогу', () => {
// суббота 24.10.2026, график 9:00–21:00 (0 — понедельник), занят 10:00–12:00
const p = { id: 'p', schedule: [{ weekday: 5, startMinute: 9 * 60, endMinute: 21 * 60 }], busy: [{ start: fromYerevan(2026, 10, 24, 600), end: fromYerevan(2026, 10, 24, 720) }] };
const at = (m: number, len = 120) => ({ start: fromYerevan(2026, 10, 24, m), end: fromYerevan(2026, 10, 24, m + len) });
expect(isProviderFreeWithGap(p, at(12 * 60), 0)).toBe(true);
expect(isProviderFreeWithGap(p, at(12 * 60 + 30), 60)).toBe(false);
expect(isProviderFreeWithGap(p, at(13 * 60), 60)).toBe(true);
expect(isProviderFreeWithGap(p, at(7 * 60 + 30, 90), 60)).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
/** Условия партнёра услуг (клининговой компании) */
export interface PartnerTerms {
deal: 'PROFIT_SHARE' | 'COMMISSION';
/** COMMISSION: наш % с заказа */
commissionPct: number;
/** PROFIT_SHARE: % партнёра от прибыли */
profitSharePct: number;
/** PROFIT_SHARE: ФОТ клинера и налог, % от оплаченного (уже с учётом настроек по умолчанию) */
fotPct: number;
taxPct: number;
}
export interface PartnerSplit {
/** фактически оплачено клиентом */
paid: number;
/** PROFIT_SHARE: ФОТ клинера (платим мы) и налог */
fot: number;
tax: number;
/** доход партнёра */
partner: number;
/** всё, что мы платим исполнителям по заказу: ФОТ + доля партнёра (без налога) — для сравнения с фрилансерами */
cost: number;
/** нам остаётся до налога: paid − cost */
margin: number;
/** % партнёра (PROFIT_SHARE) или наша комиссия (COMMISSION) — то, что запоминаем в начислении */
pct: number;
}
const pctOf = (sum: number, pct: number) => Math.floor((sum * pct) / 100);
/**
* Деньги по заказу партнёра.
* - «Расходы наши» (PROFIT_SHARE): партнёр получает X% от (оплачено − ФОТ − налог); ФОТ клинеру платим мы.
* - «Как Uber» (COMMISSION): мы забираем свой %, остальное — партнёру; его расходы — его.
*/
export function partnerSplit(paid: number, t: PartnerTerms): PartnerSplit {
if (t.deal === 'COMMISSION') {
const ours = pctOf(paid, t.commissionPct);
const partner = paid - ours;
return { paid, fot: 0, tax: 0, partner, cost: partner, margin: ours, pct: t.commissionPct };
}
const fot = pctOf(paid, t.fotPct);
const tax = pctOf(paid, t.taxPct);
const profit = Math.max(0, paid - fot - tax);
const partner = pctOf(profit, t.profitSharePct);
const cost = fot + partner;
return { paid, fot, tax, partner, cost, margin: paid - cost, pct: t.profitSharePct };
}
/** Условия партнёра с подставленными общими ФОТ и налогом из настроек */
export function partnerTerms(
c: { deal: 'PROFIT_SHARE' | 'COMMISSION'; commissionPct: number; profitSharePct: number; fotPct: number | null; taxPct: number | null },
defaults: { fotPct: number; taxPct: number },
): PartnerTerms {
return { deal: c.deal, commissionPct: c.commissionPct, profitSharePct: c.profitSharePct, fotPct: c.fotPct ?? defaults.fotPct, taxPct: c.taxPct ?? defaults.taxPct };
}
/** Лимиты нагрузки сотрудника на день: заказов и часов (пусто — без ограничения) */
export function withinDailyLimits(
limits: { maxOrdersPerDay: number | null; maxHoursPerDay: number | null },
dayOrders: { durationMinutes: number }[],
newDurationMinutes: number,
): boolean {
if (limits.maxOrdersPerDay != null && dayOrders.length + 1 > limits.maxOrdersPerDay) return false;
if (limits.maxHoursPerDay != null) {
const minutes = dayOrders.reduce((s, o) => s + o.durationMinutes, 0) + newDurationMinutes;
if (minutes > limits.maxHoursPerDay * 60) return false;
}
return true;
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { fifoPlan, nextStreak, redeemLimit, streakBonusPct, suggestAccrual, tierRate } from './points.ts';
const d = (s: string) => new Date(`${s}T10:00:00Z`);
describe('барики', () => {
it('подсказка: (сумма − не облагается − оплачено бариками) × ставка, вниз', () => {
expect(suggestAccrual({ orderAmount: 18_000, ratePct: 5 })).toBe(900);
expect(suggestAccrual({ orderAmount: 18_000, excludedAmount: 3_000, paidWithPoints: 5_000, ratePct: 8 })).toBe(800);
expect(suggestAccrual({ orderAmount: 999, ratePct: 5 })).toBe(49);
expect(suggestAccrual({ orderAmount: 1_000, excludedAmount: 5_000, ratePct: 5 })).toBe(0);
});
it('ставка по уровню', () => {
const c = { rateBarev: 5, ratePlus: 8, rateElite: 10 };
expect([tierRate(c, 'BAREV'), tierRate(c, 'BAREV_PLUS'), tierRate(c, 'ELITE')]).toEqual([5, 8, 10]);
});
it('серия уборок: перерыв ≤ 35 дней — +1, больше — заново; надбавка с 3-й и 6-й', () => {
expect(nextStreak({ count: 0, lastAt: null }, d('2026-10-01'), 35)).toBe(1);
expect(nextStreak({ count: 2, lastAt: d('2026-10-01') }, d('2026-11-05'), 35)).toBe(3);
expect(nextStreak({ count: 2, lastAt: d('2026-10-01') }, d('2026-11-06'), 35)).toBe(1);
expect([streakBonusPct(2), streakBonusPct(3), streakBonusPct(5), streakBonusPct(6)]).toEqual([0, 2, 2, 3]);
});
it('лимит списания: 30% заказа, не больше баланса, минимум 1000', () => {
expect(redeemLimit({ orderAmount: 18_000, balance: 10_000, sharePct: 30, min: 1000 })).toEqual({ max: 5_400, allowed: true });
expect(redeemLimit({ orderAmount: 18_000, balance: 4_150, sharePct: 30, min: 1000 })).toEqual({ max: 4_150, allowed: true });
expect(redeemLimit({ orderAmount: 18_000, balance: 800, sharePct: 30, min: 1000 })).toEqual({ max: 800, allowed: false });
});
it('FIFO: сначала со своим сроком, потом самые старые', () => {
const list = [
{ id: 'new', remaining: 900, availableAt: d('2026-10-20'), expiresAt: null },
{ id: 'old', remaining: 500, availableAt: d('2026-09-01'), expiresAt: null },
{ id: 'bonus', remaining: 300, availableAt: d('2026-10-10'), expiresAt: d('2026-11-10') },
];
expect(fifoPlan(list, 1000)).toEqual([
{ id: 'bonus', amount: 300 },
{ id: 'old', amount: 500 },
{ id: 'new', amount: 200 },
]);
expect(fifoPlan(list, 5000)).toBeNull();
});
});
+68
View File
@@ -0,0 +1,68 @@
/** Барики — кэшбек клуба: 1 барик = 1 ֏, только целые числа */
export type Tier = 'BAREV' | 'BAREV_PLUS' | 'ELITE';
export interface RateRow {
rateBarev: number;
ratePlus: number;
rateElite: number;
}
export function tierRate(c: RateRow, tier: Tier): number {
return tier === 'ELITE' ? c.rateElite : tier === 'BAREV_PLUS' ? c.ratePlus : c.rateBarev;
}
/** Надбавка за серию уборок: с 3-й подряд +2 п.п., с 6-й +3 п.п. */
export function streakBonusPct(count: number): number {
return count >= 6 ? 3 : count >= 3 ? 2 : 0;
}
/** Какой будет серия после уборки в serviceDate: перерыв больше gapDays — серия заново */
export function nextStreak(prev: { count: number; lastAt: Date | null }, serviceDate: Date, gapDays: number): number {
if (!prev.lastAt || prev.count <= 0) return 1;
const gap = (serviceDate.getTime() - prev.lastAt.getTime()) / 86_400_000;
if (gap < 0) return prev.count; // начисление задним числом не ломает серию
return gap <= gapDays ? prev.count + 1 : 1;
}
/** Подсказка суммы: ⌊(сумма − не облагается − оплачено бариками) × ставка⌋ */
export function suggestAccrual(input: { orderAmount: number; excludedAmount?: number; paidWithPoints?: number; ratePct: number }): number {
const base = Math.max(0, input.orderAmount - (input.excludedAmount ?? 0) - (input.paidWithPoints ?? 0));
return Math.floor((base * input.ratePct) / 100);
}
/** Сколько можно списать: не больше N% заказа и баланса; меньше минимума — нельзя */
export function redeemLimit(input: { orderAmount: number; balance: number; sharePct: number; min: number }): { max: number; allowed: boolean } {
const max = Math.max(0, Math.min(Math.floor((input.orderAmount * input.sharePct) / 100), input.balance));
return { max, allowed: max >= input.min && max > 0 };
}
export interface Spendable {
id: string;
remaining: number;
availableAt: Date;
expiresAt: Date | null;
}
/** Порядок трат: сначала то, что сгорит раньше (свой срок), потом самые старые */
export function fifoOrder<T extends Spendable>(list: T[]): T[] {
return [...list].sort((a, b) => {
const ea = a.expiresAt?.getTime() ?? Infinity;
const eb = b.expiresAt?.getTime() ?? Infinity;
return ea - eb || a.availableAt.getTime() - b.availableAt.getTime() || a.id.localeCompare(b.id);
});
}
/** Из каких начислений списать amount */
export function fifoPlan<T extends Spendable>(list: T[], amount: number): { id: string; amount: number }[] | null {
const out: { id: string; amount: number }[] = [];
let left = amount;
for (const a of fifoOrder(list)) {
if (left <= 0) break;
if (a.remaining <= 0) continue;
const take = Math.min(a.remaining, left);
out.push({ id: a.id, amount: take });
left -= take;
}
return left > 0 ? null : out;
}
+31 -1
View File
@@ -11,7 +11,7 @@ export const settingsSchema = z.object({
'agency.attributionMonths': z.number().int().min(1).max(60).default(12),
/** % агентства по умолчанию для новых агентств */
'agency.defaultCommissionPct': z.number().int().min(0).max(100).default(10),
/** SUB-3: длительность подарочного Barev+ */
/** лендинг: «первые N дней в подарок» в текстах; сам подарок задаётся у каждого агентства */
'barevPlus.giftDays': z.number().int().min(1).max(365).default(30),
/** цена подписки в месяц, ֏ (заглушка до решения владельца) */
'barevPlus.monthlyPrice': z.number().int().min(0).default(4990),
@@ -45,6 +45,36 @@ export const settingsSchema = z.object({
'assign.auto': z.boolean().default(true),
/** сколько исполнителей подряд система пробует сама, дальше заказ ждёт диспетчера */
'assign.maxAutoOffers': z.number().int().min(1).max(20).default(5),
/** партнёры услуг (модель «расходы наши»): ФОТ клинера, % от оплаченного заказа; у партнёра можно задать свой */
'partner.fotPct': z.number().int().min(0).max(100).default(50),
/** …налог, % от оплаченного заказа; у партнёра можно задать свой */
'partner.taxPct': z.number().int().min(0).max(100).default(10),
/** минимальный перерыв между заказами сотрудника партнёра на дорогу, мин (менеджер может поставить больше) */
'partner.minTravelGapMinutes': z.number().int().min(0).max(240).default(30),
/** сколько минут партнёр может не брать заказ — дальше заказ возвращается оператору */
'partner.acceptTimeoutMinutes': z.number().int().min(5).max(1440).default(60),
/** барики: через сколько часов после даты услуги начисление становится доступным */
'points.pendingHours': z.number().int().min(0).max(720).default(48),
/** барики: сколько % заказа можно оплатить бариками */
'points.maxSharePct': z.number().int().min(1).max(100).default(30),
/** барики: минимум за одно списание */
'points.minRedeem': z.number().int().min(0).default(1000),
/** барики: сгорают через N дней без начислений и списаний */
'points.burnDays': z.number().int().min(7).max(1000).default(120),
/** барики: предупреждения за N и M дней до сгорания */
'points.warnDays1': z.number().int().min(1).max(60).default(14),
'points.warnDays2': z.number().int().min(1).max(60).default(3),
/** барики: запрос клиента на оплату бариками ждёт подтверждения N часов */
'points.requestTtlHours': z.number().int().min(1).max(240).default(24),
/** барики: серия уборок — максимальный перерыв, дней */
'points.streakGapDays': z.number().int().min(1).max(120).default(35),
/** барики: поддержка тоже может начислять и списывать (в пределах лимитов) */
'points.supportCanManage': z.boolean().default(false),
/** барики: лимит поддержки на одну операцию и на сутки; выше — подтверждение суперадмина */
'points.opMaxPerOperation': z.number().int().min(0).default(20000),
'points.opMaxPerDay': z.number().int().min(0).default(100000),
/** барики: ручная сумма отличается от подсказки больше чем на N% — подсветить в журнале */
'points.diffHighlightPct': z.number().int().min(0).max(100).default(20),
/** бонус продажника: за каждое подключённое заведение, ֏ (0 — не начисляется; задаёт владелец) */
'sales.bonusPerVenue': z.number().int().min(0).default(0),
/** бонус продажника: за каждое подключённое агентство, ֏ */
+8
View File
@@ -44,6 +44,14 @@ export function isProviderFree(p: ProviderAvailability, slot: Interval): boolean
return inSchedule && !p.busy.some((b) => overlaps(b, slot));
}
/** Свободен и между соседними заказами остаётся не меньше gapMinutes на дорогу */
export function isProviderFreeWithGap(p: ProviderAvailability, slot: Interval, gapMinutes: number): boolean {
if (!isProviderFree(p, slot)) return false;
const g = gapMinutes * 60_000;
const padded = { start: new Date(slot.start.getTime() - g), end: new Date(slot.end.getTime() + g) };
return !p.busy.some((b) => overlaps(b, padded));
}
/**
* Слоты на день (решение владельца: окно услуги + хотя бы один свободный исполнитель по графику).
* Неназначенные заказы на пересекающееся время тоже занимают мощность, чтобы не продать одно время дважды.