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:
commit
45d7512d2b
64 files changed
+9483
No files matched your search
@@ -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} ֏`;
|
||||
}
|
||||
Reference in new issue
Block a user