Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import type { ParamsSchema } from './params.ts';
|
||||
import type { Pricing } from './pricing.ts';
|
||||
import type { ExtraUnit, Pricing } from './pricing.ts';
|
||||
import type { SlotConfig } from './slots.ts';
|
||||
import type { CategoryCode, ProviderRole, ServiceFlow } from './types.ts';
|
||||
|
||||
@@ -24,6 +24,32 @@ export interface CatalogService {
|
||||
const DAY_WINDOW: SlotConfig = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
|
||||
const fixed = (price: number, minutes: number): Pricing => ({ kind: 'fixed', price, minutes });
|
||||
|
||||
/**
|
||||
* Доп. услуги клининга (референс — qlean.ru). Цены — заглушки в драмах, владелец меняет их в админке.
|
||||
* max — сколько единиц клиент может выбрать; minutes — сколько времени добавляет одна единица.
|
||||
*/
|
||||
export const CLEANING_EXTRAS: { code: string; unit: ExtraUnit; price: number; minutes: number; max: number }[] = [
|
||||
{ code: 'windows', unit: 'item', price: 3500, minutes: 20, max: 20 },
|
||||
{ code: 'balconyGlazing', unit: 'item', price: 8000, minutes: 45, max: 3 },
|
||||
{ code: 'balcony', unit: 'item', price: 4500, minutes: 30, max: 3 },
|
||||
{ code: 'fridge', unit: 'service', price: 3000, minutes: 30, max: 1 },
|
||||
{ code: 'oven', unit: 'service', price: 2500, minutes: 30, max: 1 },
|
||||
{ code: 'microwave', unit: 'service', price: 1500, minutes: 15, max: 1 },
|
||||
{ code: 'kitchenCabinets', unit: 'service', price: 4000, minutes: 45, max: 1 },
|
||||
{ code: 'complexKitchen', unit: 'service', price: 7500, minutes: 60, max: 1 },
|
||||
{ code: 'chandelier', unit: 'item', price: 2500, minutes: 20, max: 10 },
|
||||
{ code: 'wardrobe', unit: 'service', price: 3500, minutes: 30, max: 1 },
|
||||
{ code: 'limescale', unit: 'service', price: 4000, minutes: 30, max: 1 },
|
||||
{ code: 'petHair', unit: 'service', price: 2000, minutes: 20, max: 1 },
|
||||
{ code: 'litter', unit: 'item', price: 1500, minutes: 10, max: 5 },
|
||||
{ code: 'ironing', unit: 'hour', price: 3500, minutes: 60, max: 4 },
|
||||
{ code: 'extraTime', unit: 'halfHour', price: 2500, minutes: 30, max: 8 },
|
||||
{ code: 'keys', unit: 'service', price: 3500, minutes: 0, max: 1 },
|
||||
{ code: 'experienced', unit: 'service', price: 5000, minutes: 0, max: 1 },
|
||||
{ code: 'vacuum', unit: 'service', price: 4000, minutes: 0, max: 1 },
|
||||
];
|
||||
const CLEANING_EXTRAS_PRICES = Object.fromEntries(CLEANING_EXTRAS.map(({ code, unit, price, minutes }) => [code, { unit, price, minutes }]));
|
||||
|
||||
export const CATALOG: CatalogService[] = [
|
||||
{
|
||||
code: 'cleaning',
|
||||
@@ -32,20 +58,26 @@ export const CATALOG: CatalogService[] = [
|
||||
providerRole: 'CLEANER',
|
||||
stage: 1,
|
||||
isOnline: false,
|
||||
// модель цены как у Qlean: тип уборки × комнаты × санузлы + доп. услуги, которые выбирает клиент
|
||||
pricing: {
|
||||
kind: 'rooms',
|
||||
variantParam: 'cleaningType',
|
||||
roomsParam: 'rooms',
|
||||
bathroomsParam: 'bathrooms',
|
||||
extrasParam: 'extras',
|
||||
tiers: {
|
||||
regular: { base: 15000, perRoom: 4000, baseMinutes: 120, perRoomMinutes: 45 },
|
||||
general: { base: 25000, perRoom: 7000, baseMinutes: 210, perRoomMinutes: 75 },
|
||||
renovation: { base: 35000, perRoom: 10000, baseMinutes: 300, perRoomMinutes: 90 },
|
||||
regular: { base: 15000, perRoom: 4000, baseMinutes: 120, perRoomMinutes: 45, perBathroom: 3000, perBathroomMinutes: 30 },
|
||||
general: { base: 25000, perRoom: 7000, baseMinutes: 210, perRoomMinutes: 75, perBathroom: 5000, perBathroomMinutes: 45 },
|
||||
renovation: { base: 35000, perRoom: 10000, baseMinutes: 300, perRoomMinutes: 90, perBathroom: 7000, perBathroomMinutes: 60 },
|
||||
},
|
||||
extras: CLEANING_EXTRAS_PRICES,
|
||||
},
|
||||
paramsSchema: {
|
||||
fields: [
|
||||
{ key: 'cleaningType', type: 'select', options: ['regular', 'general', 'renovation'], default: 'regular', required: true },
|
||||
{ key: 'rooms', type: 'int', min: 1, max: 6, default: 2, required: true },
|
||||
{ key: 'bathrooms', type: 'int', min: 1, max: 4, default: 1, required: true },
|
||||
{ key: 'extras', type: 'extras', items: CLEANING_EXTRAS.map(({ code, max }) => ({ code, max })), required: false },
|
||||
],
|
||||
},
|
||||
slotConfig: DAY_WINDOW,
|
||||
@@ -182,6 +214,8 @@ export const CATALOG: CatalogService[] = [
|
||||
|
||||
/** Скидка Barev+ по направлениям — заглушки */
|
||||
export const DEFAULT_CATEGORY_DISCOUNTS: Record<CategoryCode, number> = { HOME: 10, DOCS: 10, LEARNING: 15, PERKS: 0 };
|
||||
/** скидки Barev Elite по направлениям, % — по умолчанию «до −35%» с главной */
|
||||
export const DEFAULT_ELITE_DISCOUNTS: Record<CategoryCode, number> = { HOME: 35, DOCS: 35, LEARNING: 35, PERKS: 0 };
|
||||
|
||||
/** 12 административных районов Еревана; названия — i18n districts.<code> */
|
||||
export const YEREVAN_DISTRICTS = [
|
||||
|
||||
@@ -6,6 +6,7 @@ import { decodeStartPayload, encodeStartPayload, pickUtm } from './deeplink.ts';
|
||||
import { agencyCommission, decideGift, discountFor, isAgencyAttributionActive, membershipLevel, payoutFor } from './membership.ts';
|
||||
import { defaultParams, validateParams } from './params.ts';
|
||||
import { fromPrice, PricingError, quote } from './pricing.ts';
|
||||
import { flatPercentRules, payoutByRules, type PayoutRules } from './payout.ts';
|
||||
import { checkPromo, combineDiscounts, type PromoLike } from './promo.ts';
|
||||
import { DEFAULT_SETTINGS, resolveSettings } from './settings.ts';
|
||||
import { daySlots, type ProviderAvailability } from './slots.ts';
|
||||
@@ -17,6 +18,9 @@ describe('pricing (ORD-4)', () => {
|
||||
it('считает базовую цену по типу и комнатам', () => {
|
||||
expect(quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 })).toEqual({
|
||||
basePrice: 15000,
|
||||
breakdown: { variant: 'regular', base: 15000, rooms: { count: 0, amount: 0 }, bathrooms: { count: 0, amount: 0 }, extras: [] },
|
||||
servicePrice: 15000,
|
||||
extras: [],
|
||||
discountPct: 0,
|
||||
discountAmount: 0,
|
||||
totalPrice: 15000,
|
||||
@@ -38,6 +42,26 @@ describe('pricing (ORD-4)', () => {
|
||||
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 }, 101)).toThrow(PricingError);
|
||||
});
|
||||
|
||||
it('санузлы и доп. услуги с количеством (модель Qlean)', () => {
|
||||
const q = quote(cleaning.pricing, { cleaningType: 'regular', rooms: 2, bathrooms: 2, extras: { windows: 3, fridge: 1, ironing: 2 } }, 10);
|
||||
// 15000 + 4000 (комната) + 3000 (санузел) = 22000; окна 3×3500, холодильник 3000, глажка 2×3500
|
||||
expect(q.servicePrice).toBe(22000);
|
||||
expect(q.extras).toEqual([
|
||||
{ code: 'windows', qty: 3, unit: 'item', unitPrice: 3500, amount: 10500 },
|
||||
{ code: 'fridge', qty: 1, unit: 'service', unitPrice: 3000, amount: 3000 },
|
||||
{ code: 'ironing', qty: 2, unit: 'hour', unitPrice: 3500, amount: 7000 },
|
||||
]);
|
||||
expect(q.basePrice).toBe(42500);
|
||||
expect(q.discountAmount).toBe(4250);
|
||||
expect(q.totalPrice).toBe(38250);
|
||||
// 120 + 45 + 30 + окна 60 + холодильник 30 + глажка 120
|
||||
expect(q.durationMinutes).toBe(405);
|
||||
});
|
||||
|
||||
it('неизвестная доп. услуга — ошибка', () => {
|
||||
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1, extras: { spaceship: 1 } })).toThrow(PricingError);
|
||||
});
|
||||
|
||||
it('«от N ֏» — минимальный тариф', () => {
|
||||
expect(fromPrice(cleaning.pricing)).toBe(15000);
|
||||
});
|
||||
@@ -45,19 +69,29 @@ describe('pricing (ORD-4)', () => {
|
||||
|
||||
describe('params (ORD-2)', () => {
|
||||
it('дефолты и валидация', () => {
|
||||
expect(defaultParams(cleaning.paramsSchema)).toEqual({ cleaningType: 'regular', rooms: 2 });
|
||||
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'general', rooms: '3', junk: 1 })).toEqual({
|
||||
expect(defaultParams(cleaning.paramsSchema)).toEqual({ cleaningType: 'regular', rooms: 2, bathrooms: 1, extras: {} });
|
||||
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'general', rooms: '3', bathrooms: 2, extras: { windows: 2, oven: 0 }, junk: 1 })).toEqual({
|
||||
ok: true,
|
||||
values: { cleaningType: 'general', rooms: 3 },
|
||||
values: { cleaningType: 'general', rooms: 3, bathrooms: 2, extras: { windows: 2 } },
|
||||
});
|
||||
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'x', rooms: 9 })).toEqual({
|
||||
// больше максимума, неизвестный код, дробное количество
|
||||
for (const extras of [{ fridge: 2 }, { spaceship: 1 }, { windows: 1.5 }, [1]]) {
|
||||
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'regular', rooms: 1, bathrooms: 1, extras })).toEqual({
|
||||
ok: false,
|
||||
errors: [{ key: 'extras', code: 'invalid' }],
|
||||
});
|
||||
}
|
||||
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'x', rooms: 9, bathrooms: 1 })).toEqual({
|
||||
ok: false,
|
||||
errors: [
|
||||
{ key: 'cleaningType', code: 'invalid' },
|
||||
{ key: 'rooms', code: 'invalid' },
|
||||
],
|
||||
});
|
||||
expect(validateParams(cleaning.paramsSchema, {})).toMatchObject({ ok: false });
|
||||
// пропущенные поля с дефолтом подставляются, без дефолта — ошибка
|
||||
expect(validateParams(cleaning.paramsSchema, {})).toEqual({ ok: true, values: { cleaningType: 'regular', rooms: 2, bathrooms: 1, extras: {} } });
|
||||
const repair = CATALOG.find((x) => x.code === 'repair')!;
|
||||
expect(validateParams(repair.paramsSchema, {})).toMatchObject({ ok: false, errors: [{ key: 'repairType', code: 'required' }, { key: 'problem', code: 'required' }] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,3 +301,45 @@ describe('settings', () => {
|
||||
expect(s['offer.acceptTimeoutMinutes']).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('выплаты по пресету (джун / мидл / прайват)', () => {
|
||||
const q = quote(cleaning.pricing, { cleaningType: 'general', rooms: 3, bathrooms: 2, extras: { windows: 4, fridge: 1 } }, 10);
|
||||
|
||||
it('разбивка цены по строкам', () => {
|
||||
expect(q.breakdown).toMatchObject({ variant: 'general', base: 25000, rooms: { count: 2, amount: 14000 }, bathrooms: { count: 1, amount: 5000 } });
|
||||
expect(q.breakdown.extras.map((e) => [e.code, e.qty, e.amount])).toEqual([
|
||||
['windows', 4, 14000],
|
||||
['fridge', 1, 3000],
|
||||
]);
|
||||
});
|
||||
|
||||
it('фикс и процент по каждой строке, от цены до скидки Barev+', () => {
|
||||
const rules: PayoutRules = {
|
||||
base: { general: { type: 'PERCENT', value: 50 }, regular: { type: 'FIXED', value: 9000 } },
|
||||
room: { type: 'FIXED', value: 2000 },
|
||||
bathroom: { type: 'PERCENT', value: 60 },
|
||||
extras: { windows: { type: 'FIXED', value: 1500 } },
|
||||
};
|
||||
const p = payoutByRules(q.breakdown, rules);
|
||||
// 50% от 25000 + 2 комнаты × 2000 + 60% от 5000 + 4 окна × 1500; холодильник без правила — 0
|
||||
expect(p.lines).toEqual([
|
||||
{ key: 'base', amount: 12500 },
|
||||
{ key: 'room', amount: 4000 },
|
||||
{ key: 'bathroom', amount: 3000 },
|
||||
{ key: 'extra:windows', amount: 6000 },
|
||||
]);
|
||||
expect(p.total).toBe(25500);
|
||||
});
|
||||
|
||||
it('единый процент на всё', () => {
|
||||
const rules = flatPercentRules(60, ['regular', 'general', 'renovation'], ['windows', 'fridge']);
|
||||
// 60% от (25000 + 14000 + 5000 + 14000 + 3000)
|
||||
expect(payoutByRules(q.breakdown, rules).total).toBe(Math.floor(25000 * 0.6) + Math.floor(14000 * 0.6) + 3000 + Math.floor(14000 * 0.6) + 1800);
|
||||
});
|
||||
|
||||
it('услуга с фикс-ценой — основа по ключу «*»', () => {
|
||||
const repair = CATALOG.find((x) => x.code === 'repair')!;
|
||||
const b = quote(repair.pricing, { repairType: 'minor', problem: 'x' }).breakdown;
|
||||
expect(payoutByRules(b, { base: { '*': { type: 'PERCENT', value: 70 } }, room: { type: 'PERCENT', value: 0 }, bathroom: { type: 'PERCENT', value: 0 }, extras: {} }).total).toBe(5600);
|
||||
});
|
||||
});
|
||||
@@ -12,3 +12,4 @@ export * from './promo.ts';
|
||||
export * from './settings.ts';
|
||||
export * from './payments.ts';
|
||||
export * from './catalog.ts';
|
||||
export * from './payout.ts';
|
||||
@@ -4,21 +4,31 @@ export interface SubscriptionLike {
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
status: 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
|
||||
/** без плана — Barev+ (старые записи и тесты) */
|
||||
plan?: 'BAREV_PLUS' | 'ELITE';
|
||||
}
|
||||
|
||||
export type MembershipLevel = 'BAREV' | 'BAREV_PLUS';
|
||||
export type MembershipLevel = 'BAREV' | 'BAREV_PLUS' | 'ELITE';
|
||||
|
||||
/** Активная подписка; если их несколько, Elite важнее Barev+ */
|
||||
export function activeSubscription<T extends SubscriptionLike>(subs: T[], now: Date): T | null {
|
||||
return subs.find((s) => s.status === 'ACTIVE' && s.startsAt <= now && s.endsAt > now) ?? null;
|
||||
const active = subs.filter((s) => s.status === 'ACTIVE' && s.startsAt <= now && s.endsAt > now);
|
||||
return active.find((s) => s.plan === 'ELITE') ?? active[0] ?? null;
|
||||
}
|
||||
|
||||
export function membershipLevel(subs: SubscriptionLike[], now: Date): MembershipLevel {
|
||||
return activeSubscription(subs, now) ? 'BAREV_PLUS' : 'BAREV';
|
||||
const active = activeSubscription(subs, now);
|
||||
if (!active) return 'BAREV';
|
||||
return active.plan === 'ELITE' ? 'ELITE' : 'BAREV_PLUS';
|
||||
}
|
||||
|
||||
/** Скидка Barev+ по направлению применяется только при активной подписке (SUB-2) */
|
||||
export function discountFor(level: MembershipLevel, categoryDiscountPct: number): number {
|
||||
return level === 'BAREV_PLUS' ? categoryDiscountPct : 0;
|
||||
/**
|
||||
* Скидка по направлению применяется только при активной подписке (SUB-2).
|
||||
* У Elite своя скидка; если её не задали (0), действует скидка Barev+.
|
||||
*/
|
||||
export function discountFor(level: MembershipLevel, plusPct: number, elitePct = 0): number {
|
||||
if (level === 'ELITE') return Math.max(elitePct, plusPct);
|
||||
return level === 'BAREV_PLUS' ? plusPct : 0;
|
||||
}
|
||||
|
||||
export type GiftDecision =
|
||||
|
||||
@@ -32,13 +32,25 @@ export const paramFieldSchema = z.discriminatedUnion('type', [
|
||||
default: z.boolean().optional(),
|
||||
required: z.boolean().default(false),
|
||||
}),
|
||||
/**
|
||||
* Доп. услуги с количеством (как в Qlean: окна, холодильник, глажка по часам…).
|
||||
* Значение — { code: количество }; подписи — i18n `extras.<code>.name`, цены — в Service.pricing.
|
||||
*/
|
||||
z.object({
|
||||
key: z.string(),
|
||||
type: z.literal('extras'),
|
||||
items: z.array(z.object({ code: z.string(), max: z.number().int().positive() })).min(1),
|
||||
required: z.boolean().default(false),
|
||||
}),
|
||||
]);
|
||||
export type ParamField = z.infer<typeof paramFieldSchema>;
|
||||
|
||||
export const paramsSchemaSchema = z.object({ fields: z.array(paramFieldSchema) });
|
||||
export type ParamsSchema = z.infer<typeof paramsSchemaSchema>;
|
||||
|
||||
export type ParamValues = Record<string, string | number | boolean>;
|
||||
export type ExtrasValue = Record<string, number>;
|
||||
export type ParamValue = string | number | boolean | ExtrasValue;
|
||||
export type ParamValues = Record<string, ParamValue>;
|
||||
|
||||
export function defaultParams(schema: ParamsSchema): ParamValues {
|
||||
const out: ParamValues = {};
|
||||
@@ -46,6 +58,7 @@ export function defaultParams(schema: ParamsSchema): ParamValues {
|
||||
if (f.type === 'select') out[f.key] = f.default ?? f.options[0]!;
|
||||
else if (f.type === 'int') out[f.key] = f.default ?? f.min;
|
||||
else if (f.type === 'bool') out[f.key] = f.default ?? false;
|
||||
else if (f.type === 'extras') out[f.key] = {};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -63,7 +76,10 @@ export function validateParams(
|
||||
const raw = input[f.key];
|
||||
const missing = raw === undefined || raw === null || raw === '';
|
||||
if (missing) {
|
||||
if (f.required) errors.push({ key: f.key, code: 'required' });
|
||||
// поле с дефолтом подставляем — старые клиенты и «Повторить» не знают о новых параметрах
|
||||
if ('default' in f && f.default !== undefined) values[f.key] = f.default;
|
||||
else if (f.type === 'extras') values[f.key] = {};
|
||||
else if (f.required) errors.push({ key: f.key, code: 'required' });
|
||||
continue;
|
||||
}
|
||||
switch (f.type) {
|
||||
@@ -85,6 +101,23 @@ export function validateParams(
|
||||
if (typeof raw === 'boolean') values[f.key] = raw;
|
||||
else errors.push({ key: f.key, code: 'invalid' });
|
||||
break;
|
||||
case 'extras': {
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
errors.push({ key: f.key, code: 'invalid' });
|
||||
break;
|
||||
}
|
||||
const picked: ExtrasValue = {};
|
||||
let bad = false;
|
||||
for (const [code, qty] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const item = f.items.find((i) => i.code === code);
|
||||
const n = Number(qty);
|
||||
if (!item || !Number.isInteger(n) || n < 0 || n > item.max) bad = true;
|
||||
else if (n > 0) picked[code] = n;
|
||||
}
|
||||
if (bad) errors.push({ key: f.key, code: 'invalid' });
|
||||
else values[f.key] = picked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.length ? { ok: false, errors } : { ok: true, values };
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from 'zod';
|
||||
import type { PriceBreakdown } from './pricing.ts';
|
||||
|
||||
/**
|
||||
* Ставки исполнителя по строкам цены (пресеты «Джун / Мидл / Прайват» в админке).
|
||||
* Для каждой строки — фикс (драмы за единицу) или процент от суммы строки.
|
||||
* Процент считается от цены до скидки Barev+: скидку клиента оплачивает клуб, а не исполнитель.
|
||||
*/
|
||||
export const payoutRuleSchema = z.object({
|
||||
type: z.enum(['FIXED', 'PERCENT']),
|
||||
value: z.number().int().min(0),
|
||||
});
|
||||
export type PayoutRule = z.infer<typeof payoutRuleSchema>;
|
||||
|
||||
const ZERO: PayoutRule = { type: 'PERCENT', value: 0 };
|
||||
|
||||
export const payoutRulesSchema = z.object({
|
||||
/** основа по варианту услуги (тип уборки); для услуг без вариантов — ключ «*» */
|
||||
base: z.record(z.string(), payoutRuleSchema).default({}),
|
||||
/** каждая комната сверх первой */
|
||||
room: payoutRuleSchema.default(ZERO),
|
||||
/** каждый санузел сверх первого */
|
||||
bathroom: payoutRuleSchema.default(ZERO),
|
||||
/** доп. услуги по коду */
|
||||
extras: z.record(z.string(), payoutRuleSchema).default({}),
|
||||
});
|
||||
export type PayoutRules = z.infer<typeof payoutRulesSchema>;
|
||||
|
||||
export interface PayoutLine {
|
||||
/** base | room | bathroom | extra:<code> */
|
||||
key: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
function apply(rule: PayoutRule | undefined, units: number, amount: number): number {
|
||||
if (!rule || units <= 0) return 0;
|
||||
return rule.type === 'FIXED' ? rule.value * units : Math.floor((amount * rule.value) / 100);
|
||||
}
|
||||
|
||||
/** Выплата исполнителю по разбивке цены заказа и правилам пресета */
|
||||
export function payoutByRules(b: PriceBreakdown, rules: PayoutRules): { total: number; lines: PayoutLine[] } {
|
||||
const lines: PayoutLine[] = [
|
||||
{ key: 'base', amount: apply(rules.base[b.variant ?? '*'] ?? rules.base['*'], 1, b.base) },
|
||||
{ key: 'room', amount: apply(rules.room, b.rooms.count, b.rooms.amount) },
|
||||
{ key: 'bathroom', amount: apply(rules.bathroom, b.bathrooms.count, b.bathrooms.amount) },
|
||||
...b.extras.map((e) => ({ key: `extra:${e.code}`, amount: apply(rules.extras[e.code], e.qty, e.amount) })),
|
||||
].filter((l) => l.amount > 0);
|
||||
return { total: lines.reduce((s, l) => s + l.amount, 0), lines };
|
||||
}
|
||||
|
||||
/** Пресет «одинаковый процент на всё» — для заготовок */
|
||||
export function flatPercentRules(pct: number, variants: string[], extraCodes: string[]): PayoutRules {
|
||||
const r: PayoutRule = { type: 'PERCENT', value: pct };
|
||||
return {
|
||||
base: Object.fromEntries((variants.length ? variants : ['*']).map((v) => [v, r])),
|
||||
room: r,
|
||||
bathroom: r,
|
||||
extras: Object.fromEntries(extraCodes.map((c) => [c, r])),
|
||||
};
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import type { ParamValues } from './params.ts';
|
||||
/**
|
||||
* Правила цены услуги (Service.pricing). Все суммы — целые драмы.
|
||||
*
|
||||
* - `rooms`: тариф по варианту (тип уборки) + доплата за каждую комнату сверх первой.
|
||||
* Длительность считается так же.
|
||||
* - `rooms`: тариф по варианту (тип уборки) + доплата за каждую комнату и каждый санузел сверх первого,
|
||||
* плюс доп. услуги с количеством (модель Qlean). Длительность считается так же.
|
||||
* - `fixed`: фиксированная цена (заглушка для услуг Этапа 2).
|
||||
*/
|
||||
const roomsTier = z.object({
|
||||
@@ -13,15 +13,32 @@ const roomsTier = z.object({
|
||||
perRoom: z.number().int().nonnegative(),
|
||||
baseMinutes: z.number().int().positive(),
|
||||
perRoomMinutes: z.number().int().nonnegative(),
|
||||
perBathroom: z.number().int().nonnegative().default(0),
|
||||
perBathroomMinutes: z.number().int().nonnegative().default(0),
|
||||
});
|
||||
|
||||
/** Единица доп. услуги: штука, услуга целиком, час, 30 минут */
|
||||
export const EXTRA_UNITS = ['item', 'service', 'hour', 'halfHour'] as const;
|
||||
export type ExtraUnit = (typeof EXTRA_UNITS)[number];
|
||||
|
||||
const extraPrice = z.object({
|
||||
price: z.number().int().nonnegative(),
|
||||
/** сколько минут добавляет к визиту одна единица */
|
||||
minutes: z.number().int().nonnegative(),
|
||||
unit: z.enum(EXTRA_UNITS),
|
||||
});
|
||||
export type ExtraPrice = z.infer<typeof extraPrice>;
|
||||
|
||||
export const pricingSchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('rooms'),
|
||||
/** параметр-вариант, например cleaningType */
|
||||
variantParam: z.string(),
|
||||
roomsParam: z.string(),
|
||||
bathroomsParam: z.string().optional(),
|
||||
extrasParam: z.string().optional(),
|
||||
tiers: z.record(z.string(), roomsTier),
|
||||
extras: z.record(z.string(), extraPrice).default({}),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('fixed'),
|
||||
@@ -31,8 +48,34 @@ export const pricingSchema = z.discriminatedUnion('kind', [
|
||||
]);
|
||||
export type Pricing = z.infer<typeof pricingSchema>;
|
||||
|
||||
export interface QuoteLine {
|
||||
code: string;
|
||||
qty: number;
|
||||
unit: ExtraUnit;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** Разбивка цены по строкам — сохраняется в заказе и служит основой для выплат исполнителю */
|
||||
export interface PriceBreakdown {
|
||||
variant: string | null;
|
||||
/** основа: тариф за 1 комнату и 1 санузел (или фикс-цена услуги) */
|
||||
base: number;
|
||||
/** комнаты сверх первой */
|
||||
rooms: { count: number; amount: number };
|
||||
/** санузлы сверх первого */
|
||||
bathrooms: { count: number; amount: number };
|
||||
extras: QuoteLine[];
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
/** стоимость до скидки: основная услуга + доп. услуги */
|
||||
basePrice: number;
|
||||
breakdown: PriceBreakdown;
|
||||
/** основная услуга (тип уборки × комнаты × санузлы) */
|
||||
servicePrice: number;
|
||||
/** доп. услуги построчно */
|
||||
extras: QuoteLine[];
|
||||
discountPct: number;
|
||||
discountAmount: number;
|
||||
totalPrice: number;
|
||||
@@ -40,34 +83,68 @@ export interface Quote {
|
||||
}
|
||||
|
||||
export class PricingError extends Error {
|
||||
constructor(public code: 'UNKNOWN_VARIANT' | 'BAD_ROOMS' | 'BAD_DISCOUNT') {
|
||||
constructor(public code: 'UNKNOWN_VARIANT' | 'BAD_ROOMS' | 'BAD_DISCOUNT' | 'UNKNOWN_EXTRA') {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
function baseOf(pricing: Pricing, params: ParamValues): { price: number; minutes: number } {
|
||||
if (pricing.kind === 'fixed') return { price: pricing.price, minutes: pricing.minutes };
|
||||
function count(params: ParamValues, key: string | undefined, fallback: number): number {
|
||||
if (!key) return fallback;
|
||||
const v = params[key];
|
||||
return v === undefined ? fallback : Number(v);
|
||||
}
|
||||
|
||||
function compute(pricing: Pricing, params: ParamValues): { service: number; minutes: number; extras: QuoteLine[]; breakdown: PriceBreakdown } {
|
||||
if (pricing.kind === 'fixed') {
|
||||
return {
|
||||
service: pricing.price,
|
||||
minutes: pricing.minutes,
|
||||
extras: [],
|
||||
breakdown: { variant: null, base: pricing.price, rooms: { count: 0, amount: 0 }, bathrooms: { count: 0, amount: 0 }, extras: [] },
|
||||
};
|
||||
}
|
||||
const tier = pricing.tiers[String(params[pricing.variantParam])];
|
||||
if (!tier) throw new PricingError('UNKNOWN_VARIANT');
|
||||
const rooms = Number(params[pricing.roomsParam]);
|
||||
if (!Number.isInteger(rooms) || rooms < 1) throw new PricingError('BAD_ROOMS');
|
||||
const extra = rooms - 1;
|
||||
return {
|
||||
price: tier.base + tier.perRoom * extra,
|
||||
minutes: tier.baseMinutes + tier.perRoomMinutes * extra,
|
||||
const rooms = count(params, pricing.roomsParam, 1);
|
||||
const baths = count(params, pricing.bathroomsParam, 1);
|
||||
if (!Number.isInteger(rooms) || rooms < 1 || !Number.isInteger(baths) || baths < 1) throw new PricingError('BAD_ROOMS');
|
||||
const service = tier.base + tier.perRoom * (rooms - 1) + tier.perBathroom * (baths - 1);
|
||||
let minutes = tier.baseMinutes + tier.perRoomMinutes * (rooms - 1) + tier.perBathroomMinutes * (baths - 1);
|
||||
const lines: QuoteLine[] = [];
|
||||
const picked = pricing.extrasParam ? params[pricing.extrasParam] : undefined;
|
||||
if (picked && typeof picked === 'object') {
|
||||
for (const [code, qty] of Object.entries(picked)) {
|
||||
if (!qty) continue;
|
||||
const e = pricing.extras[code];
|
||||
if (!e) throw new PricingError('UNKNOWN_EXTRA');
|
||||
lines.push({ code, qty, unit: e.unit, unitPrice: e.price, amount: e.price * qty });
|
||||
minutes += e.minutes * qty;
|
||||
}
|
||||
}
|
||||
const breakdown: PriceBreakdown = {
|
||||
variant: String(params[pricing.variantParam]),
|
||||
base: tier.base,
|
||||
rooms: { count: rooms - 1, amount: tier.perRoom * (rooms - 1) },
|
||||
bathrooms: { count: baths - 1, amount: tier.perBathroom * (baths - 1) },
|
||||
extras: lines,
|
||||
};
|
||||
return { service, minutes, extras: lines, breakdown };
|
||||
}
|
||||
|
||||
/**
|
||||
* ORD-4: базовая цена по параметрам минус скидка Barev+ по направлению.
|
||||
* ORD-4: стоимость по параметрам (основная услуга + доп. услуги) минус скидка Barev+ по направлению.
|
||||
* Сумма скидки округляется вниз до целого драма.
|
||||
*/
|
||||
export function quote(pricing: Pricing, params: ParamValues, discountPct = 0): Quote {
|
||||
if (!Number.isInteger(discountPct) || discountPct < 0 || discountPct > 100) throw new PricingError('BAD_DISCOUNT');
|
||||
const { price, minutes } = baseOf(pricing, params);
|
||||
const { service, minutes, extras, breakdown } = compute(pricing, params);
|
||||
const price = service + extras.reduce((s, l) => s + l.amount, 0);
|
||||
const discountAmount = Math.floor((price * discountPct) / 100);
|
||||
return {
|
||||
basePrice: price,
|
||||
breakdown,
|
||||
servicePrice: service,
|
||||
extras,
|
||||
discountPct,
|
||||
discountAmount,
|
||||
totalPrice: price - discountAmount,
|
||||
|
||||
@@ -14,7 +14,31 @@ export const settingsSchema = z.object({
|
||||
/** SUB-3: длительность подарочного Barev+ */
|
||||
'barevPlus.giftDays': z.number().int().min(1).max(365).default(30),
|
||||
/** цена подписки в месяц, ֏ (заглушка до решения владельца) */
|
||||
'barevPlus.monthlyPrice': z.number().int().min(0).default(4900),
|
||||
'barevPlus.monthlyPrice': z.number().int().min(0).default(4990),
|
||||
/** Barev+: «скидки до N%» на главной и в тарифах */
|
||||
'barevPlus.maxDiscountPct': z.number().int().min(0).max(100).default(15),
|
||||
/** Barev Elite: цена в месяц, ֏ (пока продаётся через оператора) */
|
||||
'elite.monthlyPrice': z.number().int().min(0).default(35000),
|
||||
/** Barev Elite: «скидки до N%» на главной, в тарифах и в прайсе /live */
|
||||
'elite.maxDiscountPct': z.number().int().min(0).max(100).default(35),
|
||||
/** главная: отель при переезде, ֏/сутки с Barev+ */
|
||||
'relocation.hotelWithPlus': z.number().int().min(0).default(20000),
|
||||
/** главная: отель при переезде, ֏/сутки без подписки */
|
||||
'relocation.hotelBase': z.number().int().min(0).default(35000),
|
||||
/** главная: консультация по переезду и ВНЖ, ֏ */
|
||||
'relocation.consultation': z.number().int().min(0).default(45000),
|
||||
/** /live: документы с подпиской «от N ֏» */
|
||||
'docs.fromPrice': z.number().int().min(0).default(45000),
|
||||
/** страница /social-card: соцкарта под ключ (перевод паспорта + запись + сопровождение), ֏ */
|
||||
'docs.socialCardPrice': z.number().int().min(0).default(25000),
|
||||
/** страница /residence-permit: сопровождение ВНЖ до получения карты «от N ֏», без госпошлины */
|
||||
'residence.escortFrom': z.number().int().min(0).default(150000),
|
||||
/** страница /residence-permit: госпошлина за ВНЖ на 1 год (с 01.11.2026 — 150 000 + 5 950 за карту), ֏ */
|
||||
'residence.stateFee': z.number().int().min(0).default(155950),
|
||||
/** страница /airport: встреча «Стандарт» — табличка, до 3 пассажиров, 60 мин ожидания, ֏ */
|
||||
'transfer.standardPrice': z.number().int().min(0).default(10000),
|
||||
/** страница /airport: встреча «Премиум» — бизнес-авто, вода, SIM-карта, ֏ */
|
||||
'transfer.premiumPrice': z.number().int().min(0).default(20000),
|
||||
/** PRO-5: сколько минут исполнитель может думать над предложением */
|
||||
'offer.acceptTimeoutMinutes': z.number().int().min(1).max(1440).default(15),
|
||||
/** MSG-1: чат закрывается через N часов после завершения (Этап 2) */
|
||||
@@ -22,7 +46,7 @@ export const settingsSchema = z.object({
|
||||
/** раздел 6: автоудаление сканов через N дней после завершения */
|
||||
'files.retentionDays': z.number().int().min(1).default(90),
|
||||
/** текущая версия политики ПДн — сохраняется вместе с согласием */
|
||||
'consent.version': z.string().default('2026-10-draft'),
|
||||
'consent.version': z.string().default('2026-10-20'),
|
||||
});
|
||||
|
||||
export type Settings = z.infer<typeof settingsSchema>;
|
||||
|
||||
@@ -48,6 +48,23 @@ export function isProviderFree(p: ProviderAvailability, slot: Interval): boolean
|
||||
* Слоты на день (решение владельца: окно услуги + хотя бы один свободный исполнитель по графику).
|
||||
* Неназначенные заказы на пересекающееся время тоже занимают мощность, чтобы не продать одно время дважды.
|
||||
*/
|
||||
/**
|
||||
* Время начала в рабочем окне услуги без учёта графиков исполнителей — для заявки с сайта,
|
||||
* где клиент называет желаемое время, а оператор его подтверждает.
|
||||
*/
|
||||
export function windowStarts(input: { dateKey: string; config: SlotConfig; durationMinutes: number; now: Date }): Date[] {
|
||||
const { year, month, day } = parseDateKey(input.dateKey);
|
||||
const { config } = input;
|
||||
const earliest = input.now.getTime() + config.leadHours * HOUR;
|
||||
const horizonEnd = input.now.getTime() + (config.horizonDays + 1) * 24 * HOUR;
|
||||
const out: Date[] = [];
|
||||
for (let m = config.dayStartMinute; m + input.durationMinutes <= config.dayEndMinute; m += config.stepMinutes) {
|
||||
const start = fromYerevan(year, month, day, m);
|
||||
if (start.getTime() >= earliest && start.getTime() <= horizonEnd) out.push(start);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function daySlots(input: {
|
||||
dateKey: string;
|
||||
config: SlotConfig;
|
||||
|
||||
Reference in new issue
Block a user