Monorepo skeleton, Prisma schema, core domain and services with tests

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-17 19:45:51 +04:00
commit 45d7512d2b
64 files changed
+9483

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
import { z } from 'zod';
import type { ParamValues } from './params.ts';
/**
* Правила цены услуги (Service.pricing). Все суммы — целые драмы.
*
* - `rooms`: тариф по варианту (тип уборки) + доплата за каждую комнату сверх первой.
* Длительность считается так же.
* - `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(),
});
export const pricingSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('rooms'),
/** параметр-вариант, например cleaningType */
variantParam: z.string(),
roomsParam: z.string(),
tiers: z.record(z.string(), roomsTier),
}),
z.object({
kind: z.literal('fixed'),
price: z.number().int().nonnegative(),
minutes: z.number().int().positive(),
}),
]);
export type Pricing = z.infer<typeof pricingSchema>;
export interface Quote {
basePrice: number;
discountPct: number;
discountAmount: number;
totalPrice: number;
durationMinutes: number;
}
export class PricingError extends Error {
constructor(public code: 'UNKNOWN_VARIANT' | 'BAD_ROOMS' | 'BAD_DISCOUNT') {
super(code);
}
}
function baseOf(pricing: Pricing, params: ParamValues): { price: number; minutes: number } {
if (pricing.kind === 'fixed') return { price: pricing.price, minutes: pricing.minutes };
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,
};
}
/**
* 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 discountAmount = Math.floor((price * discountPct) / 100);
return {
basePrice: price,
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} ֏`;
}