import { z } from 'zod'; import type { ParamValues } from './params.ts'; /** * Правила цены услуги (Service.pricing). Все суммы — целые драмы. * * - `rooms`: тариф по варианту (тип уборки) + доплата за каждую комнату и каждый санузел сверх первого, * плюс доп. услуги с количеством (модель Qlean). Длительность считается так же. * - `fixed`: фиксированная цена (заглушка для услуг Этапа 2). */ const roomsTier = z.object({ base: z.number().int().nonnegative(), 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; 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'), price: z.number().int().nonnegative(), minutes: z.number().int().positive(), }), ]); export type Pricing = z.infer; 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; durationMinutes: number; } export class PricingError extends Error { constructor(public code: 'UNKNOWN_VARIANT' | 'BAD_ROOMS' | 'BAD_DISCOUNT' | 'UNKNOWN_EXTRA') { super(code); } } 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 = 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+ по направлению. * Сумма скидки округляется вниз до целого драма. */ export function quote(pricing: Pricing, params: ParamValues, discountPct = 0): Quote { if (!Number.isInteger(discountPct) || discountPct < 0 || discountPct > 100) throw new PricingError('BAD_DISCOUNT'); 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, durationMinutes: minutes, }; } /** Минимальная цена — для «от N ֏» на лендинге и в каталоге */ export function fromPrice(pricing: Pricing): number { if (pricing.kind === 'fixed') return pricing.price; return Math.min(...Object.values(pricing.tiers).map((t) => t.base)); } export function formatAmd(amount: number, locale = 'ru'): string { const n = new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale, { maximumFractionDigits: 0 }).format(amount); return `${n} ֏`; }