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:
1 parent
dda917fc71
commit
45477fb018
94 files changed
+10955
-650
No files matched your search
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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 (решение владельца) */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
/** бонус продажника: за каждое подключённое агентство, ֏ */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Слоты на день (решение владельца: окно услуги + хотя бы один свободный исполнитель по графику).
|
||||
* Неназначенные заказы на пересекающееся время тоже занимают мощность, чтобы не продать одно время дважды.
|
||||
|
||||
Reference in new issue
Block a user