Backoffice stage 5: products, broadcasts, activity log, settings
- Products: services with full setup — content (name, description, extras names in 5 languages via text overrides), price tiers and extras, booking window, late-cancel fee, on/off, checklist; category discounts by membership level; membership plan prices; promo codes; relocation steps and documents. - Text overrides: TextOverride table on top of the i18n files; API, bots and the worker reload them every minute, mini apps fetch public.texts at start. - Promo codes applied to orders (admin new-order form and client API): not stacked with membership discount — the larger wins; once per client; validity, limit and categories. - Broadcasts to the client bot by any client filter, per-language texts with Russian fallback, test to yourself, ~25 msg/s, per-recipient status, resume after worker restart, history with progress and stop. - Activity log (who/when/what, filters, details) and Settings page with all owner settings grouped, plus payment methods. - Tests for promo codes and text overrides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ac786cc5cc
commit
0edc6432b7
34 files changed
+2704
-26
No files matched your search
@@ -0,0 +1,78 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createT } from '@barev/i18n';
|
||||
import { createOrder, DomainError, giveConsent, grantSubscription, loadTextOverrides, quoteForClient, startClient } from '../src/index.ts';
|
||||
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
|
||||
|
||||
let fx: Awaited<ReturnType<typeof fixtures>>;
|
||||
beforeEach(async () => {
|
||||
fx = await fixtures();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await db.textOverride.deleteMany({});
|
||||
await loadTextOverrides(db);
|
||||
await db.$disconnect();
|
||||
});
|
||||
|
||||
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
|
||||
const params = { cleaningType: 'regular', rooms: 2 };
|
||||
|
||||
async function client(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
|
||||
const s = await startClient(ctx, tgUser(tg));
|
||||
await giveConsent(ctx, s.clientId);
|
||||
return s.clientId;
|
||||
}
|
||||
|
||||
describe('промокоды', () => {
|
||||
it('скидка по коду; один раз на клиента; не складывается с подпиской — берётся большая', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
await db.promoCode.create({ data: { code: 'AUTUMN20', kind: 'DISCOUNT', discountPct: 20 } });
|
||||
await db.promoCode.create({ data: { code: 'SMALL5', kind: 'DISCOUNT', discountPct: 5 } });
|
||||
const c = await client(ctx, 901);
|
||||
|
||||
const q = await quoteForClient(ctx, c, 'cleaning', params, 'autumn20');
|
||||
expect(q.quote).toMatchObject({ basePrice: 19000, discountPct: 20, totalPrice: 15200 });
|
||||
expect(q.promo?.code).toBe('AUTUMN20');
|
||||
|
||||
const o = await createOrder(ctx, { clientId: c, serviceCode: 'cleaning', params, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, promoCode: 'AUTUMN20' });
|
||||
expect(o.totalPrice).toBe(15200);
|
||||
expect((await db.promoCode.findUniqueOrThrow({ where: { code: 'AUTUMN20' } })).usedCount).toBe(1);
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'AUTUMN20'))).toBe('PROMO_USED');
|
||||
|
||||
// подписчик со скидкой 10%: код на 5% не нужен — остаётся скидка подписки, код не тратится
|
||||
const member = await client(ctx, 902);
|
||||
await grantSubscription(ctx, { clientId: member, plan: 'BAREV_PLUS', days: 30 });
|
||||
const m = await quoteForClient(ctx, member, 'cleaning', params, 'SMALL5');
|
||||
expect(m.quote.discountPct).toBe(10);
|
||||
expect(m.promo).toBeNull();
|
||||
});
|
||||
|
||||
it('выключен, истёк, исчерпан или не для этого направления — понятная ошибка', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const c = await client(ctx, 903);
|
||||
await db.promoCode.create({ data: { code: 'OFF', kind: 'DISCOUNT', discountPct: 10, active: false } });
|
||||
await db.promoCode.create({ data: { code: 'OLD', kind: 'DISCOUNT', discountPct: 10, validUntil: new Date(NOW.getTime() - 1000) } });
|
||||
await db.promoCode.create({ data: { code: 'FULL', kind: 'DISCOUNT', discountPct: 10, maxUses: 1, usedCount: 1 } });
|
||||
await db.promoCode.create({ data: { code: 'DOCSONLY', kind: 'DISCOUNT', discountPct: 10, categories: ['DOCS'] } });
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'NOPE'))).toBe('PROMO_INVALID');
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OFF'))).toBe('PROMO_INVALID');
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OLD'))).toBe('PROMO_EXPIRED');
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'FULL'))).toBe('PROMO_EXPIRED');
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'DOCSONLY'))).toBe('PROMO_NOT_FOR_SERVICE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('тексты из админки', () => {
|
||||
it('правка подменяет стандартный текст, свой перевод языка важнее русской правки', async () => {
|
||||
const before = createT('ru')('services.cleaning.name');
|
||||
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'ru', value: 'Уборка квартиры' } });
|
||||
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'hy', value: 'Բնակարանի մաքրում' } });
|
||||
await loadTextOverrides(db);
|
||||
expect(createT('ru')('services.cleaning.name')).toBe('Уборка квартиры');
|
||||
expect(createT('hy')('services.cleaning.name')).toBe('Բնակարանի մաքրում');
|
||||
// у английского свой перевод — русская правка его не трогает
|
||||
expect(createT('en')('services.cleaning.name')).not.toBe('Уборка квартиры');
|
||||
await db.textOverride.deleteMany({});
|
||||
await loadTextOverrides(db);
|
||||
expect(createT('ru')('services.cleaning.name')).toBe(before);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user