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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user