diff --git a/client/src/components/modals/GenerateMockOrdersModal.tsx b/client/src/components/modals/GenerateMockOrdersModal.tsx new file mode 100644 index 0000000..5b98c47 --- /dev/null +++ b/client/src/components/modals/GenerateMockOrdersModal.tsx @@ -0,0 +1,175 @@ +import { useState } from "react"; +import { Modal, Button, Form, Alert } from "react-bootstrap"; +import { generateMockOrders } from "../../../../types"; + +type Props = { + isOpen: boolean; + onClose: () => void; + /** Příznak, zda je pro dnešní den založena alespoň jedna skupina objednávky. */ + hasGroups: boolean; + /** Příznak, zda je nadefinován alespoň jeden obchod (bez něj nelze generovat). */ + hasStores: boolean; +}; + +const DEFAULT_COUNT = '3'; +const DEFAULT_MIN_AMOUNT = '100'; +const DEFAULT_MAX_AMOUNT = '1000'; + +/** Vybere český tvar slova podle počtu (1 osoba / 2 osoby / 5 osob). */ +function plural(count: number, one: string, few: string, many: string): string { + if (count === 1) return one; + if (count >= 2 && count <= 4) return few; + return many; +} + +/** Modální dialog pro generování mock dat sekce Objednání (pouze DEV). */ +export default function GenerateMockOrdersModal({ isOpen, onClose, hasGroups, hasStores }: Readonly) { + const [count, setCount] = useState(DEFAULT_COUNT); + const [minAmount, setMinAmount] = useState(DEFAULT_MIN_AMOUNT); + const [maxAmount, setMaxAmount] = useState(DEFAULT_MAX_AMOUNT); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const handleGenerate = async () => { + setError(null); + + const countNum = parseInt(count, 10); + if (isNaN(countNum) || countNum < 1 || countNum > 50) { + setError('Počet osob musí být číslo mezi 1 a 50'); + return; + } + const minNum = parseInt(minAmount, 10); + const maxNum = parseInt(maxAmount, 10); + if (isNaN(minNum) || isNaN(maxNum) || minNum < 0 || maxNum < minNum) { + setError('Zadejte platný rozsah částek (od ≤ do)'); + return; + } + + setLoading(true); + try { + const response = await generateMockOrders({ + body: { count: countNum, minAmount: minNum, maxAmount: maxNum }, + }); + if (response.error) { + setError((response.error as any).error || 'Nastala chyba při generování dat'); + } else { + const groups = response.data?.groups ?? []; + const total = response.data?.count ?? 0; + const created = groups.find(g => g.created); + setSuccess( + `Doplněno: ${total} ${plural(total, 'osoba', 'osoby', 'osob')}` + + ` / ${groups.length} ${plural(groups.length, 'skupina', 'skupiny', 'skupin')}` + + (created ? ` (skupina „${created.name}“ byla nově založena).` : '.') + ); + setTimeout(() => { + onClose(); + setSuccess(null); + }, 2000); + } + } catch (e: any) { + setError(e.message || 'Nastala chyba při generování dat'); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setError(null); + setSuccess(null); + onClose(); + }; + + return ( + + +

Generovat mock objednávky

+
+ + {success ? ( + {success} + ) : ( + <> + + DEV režim - Tato funkce je dostupná pouze ve vývojovém prostředí. + Data se generují vždy jen pro aktuální den. + + + {error && ( + setError(null)} dismissible> + {error} + + )} + + {hasStores ? ( + <> + + Počet osob + setCount(e.target.value)} + min={1} + max={50} + onKeyDown={e => e.stopPropagation()} + /> + + {hasGroups + ? 'Zadaný počet osob se doplní do každé existující skupiny.' + : 'Není založena žádná skupina — jedna se založí z náhodně vybraného obchodu a doplní se do ní zadaný počet osob.'} + + + + + Rozsah částek (Kč) +
+ setMinAmount(e.target.value)} + min={0} + onKeyDown={e => e.stopPropagation()} + /> + + setMaxAmount(e.target.value)} + min={0} + onKeyDown={e => e.stopPropagation()} + /> +
+ + Každé osobě se nastaví náhodná částka z tohoto rozmezí a do poznámky náhodné jídlo. + +
+ + ) : ( +

+ Není nadefinován žádný obchod — mock objednávky nelze vygenerovat. + Nejprve přidejte obchod ve správě obchodů. +

+ )} + + )} +
+ + {!success && ( + <> + + + + )} + {success && ( + + )} + +
+ ); +} diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index 8e2ce27..df9ee94 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -24,6 +24,7 @@ import StoreAdminModal from '../components/modals/StoreAdminModal'; import PayForGroupModal from '../components/modals/PayForGroupModal'; import EditGroupFeesModal from '../components/modals/EditGroupFeesModal'; import BoltSimulationModal from '../components/modals/BoltSimulationModal'; +import GenerateMockOrdersModal from '../components/modals/GenerateMockOrdersModal'; import PendingPayments from '../components/PendingPayments'; import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress'; @@ -137,6 +138,7 @@ export default function OrderGroupsPage() { const [payModal, setPayModal] = useState(null); const [feesModal, setFeesModal] = useState(null); const [boltSimModal, setBoltSimModal] = useState(null); + const [mockOrdersModalOpen, setMockOrdersModalOpen] = useState(false); const [confirmOrderGroup, setConfirmOrderGroup] = useState(null); const [pageError, setPageError] = useState(null); @@ -401,10 +403,17 @@ export default function OrderGroupsPage() {

Objednání

- +
+ {IS_DEV && !isReadOnly && ( + + )} + +

Skupinové objednávky z obchodů a restaurací

@@ -955,6 +964,19 @@ export default function OrderGroupsPage() { }} /> )} + {IS_DEV && ( + { + setMockOrdersModalOpen(false); + // Data přijdou i websocketem, po založení skupiny se ale mění i dny s objednávkou + fetchData(); + fetchOrderDates(); + }} + hasGroups={groups.length > 0} + hasStores={stores.length > 0} + /> + )} {IS_DEV && boltSimModal && ( { } catch (e: any) { next(e) } }); +// --- DEV mock data pro sekci Objednání --- + +/** Vrátí náhodné celé číslo z intervalu . */ +function randomInt(min: number, max: number): number { + return min + Math.floor(Math.random() * (max - min + 1)); +} + +/** Vrátí náhodný prvek neprázdného pole. */ +function randomItem(items: Type[]): Type { + return items[randomInt(0, items.length - 1)]; +} + +/** + * Vybere jméno, které ve skupině ještě není použité. + * Po vyčerpání seznamu jmen přidává číselný sufix (Alice2, Alice3, …). + */ +function pickMemberName(members: Record): string { + const free = MOCK_NAMES.filter(name => !members[name]); + if (free.length > 0) { + return randomItem(free); + } + const base = randomItem(MOCK_NAMES); + let suffix = 2; + while (members[`${base}${suffix}`]) { + suffix++; + } + return `${base}${suffix}`; +} + +/** Rozešle klientům aktuální data sekce Objednání (dnešní den). */ +async function broadcastExtra() { + getWebsocket().emit("message", await getData(getToday(), MealSlot.EXTRA)); +} + +/** + * Vygeneruje mock data pro sekci Objednání — vždy jen pro aktuální den. + * Do každé existující skupiny doplní zadaný počet osob s náhodnou částkou + * a názvem náhodného jídla v poznámce. Pokud žádná skupina neexistuje, + * založí jednu z náhodně vybraného obchodu. + */ +router.post("/generateOrders", async (req: Request<{}, any, any>, res, next) => { + try { + const login = getLogin(parseToken(req)); + const count: number = req.body?.count ?? DEFAULT_ORDER_MEMBER_COUNT; + const minAmount: number = req.body?.minAmount ?? DEFAULT_MIN_AMOUNT; + const maxAmount: number = req.body?.maxAmount ?? DEFAULT_MAX_AMOUNT; + + if (!Number.isInteger(count) || count < 1 || count > MAX_ORDER_MEMBER_COUNT) { + return res.status(400).json({ error: `Počet osob musí být celé číslo mezi 1 a ${MAX_ORDER_MEMBER_COUNT}` }); + } + if (!Number.isInteger(minAmount) || !Number.isInteger(maxAmount) + || minAmount < 0 || maxAmount > MAX_ORDER_AMOUNT || minAmount > maxAmount) { + return res.status(400).json({ error: 'Neplatný rozsah částek' }); + } + + const stores = await getStores(); + if (stores.length === 0) { + return res.status(400).json({ error: 'Není nadefinován žádný obchod — mock data pro objednávání nelze vygenerovat' }); + } + + const key = `${formatDate(getToday())}_extra`; + const existing = await storage.getData(key); + let createdGroupId: string | undefined; + + // Bez skupiny není kam objednávky doplnit — jednu založíme z náhodně vybraného obchodu + if (!existing?.groups?.length) { + const store = randomItem(stores); + const urls = store.urls ?? []; + const created = await createGroup(login, store.name, undefined, urls.length > 0 ? randomItem(urls) : undefined); + createdGroupId = created.groups?.[created.groups.length - 1]?.id; + } + + const results: { id: string, name: string, added: number, created?: boolean }[] = []; + await storage.updateData(key, current => { + const data = current!; + // Mutátor může být (u Redis) zavolán opakovaně — výsledky proto vždy sestavíme znovu + results.length = 0; + for (const group of data.groups ?? []) { + for (let i = 0; i < count; i++) { + group.members[pickMemberName(group.members)] = { + amount: randomInt(minAmount, maxAmount) * 100, + note: randomItem(MOCK_ORDER_FOODS), + }; + } + results.push({ + id: group.id, + name: group.name, + added: count, + ...(group.id === createdGroupId ? { created: true } : {}), + }); + } + return data; + }); + + await broadcastExtra(); + + res.status(200).json({ + success: true, + count: results.reduce((sum, group) => sum + group.added, 0), + groups: results, + }); + } catch (e: any) { + next(e); + } +}); + // --- DEV simulace sledování Bolt Food (Wolt simulaci zatím nemá) --- /** Nastaví/zruší sledovací token skupiny a zajistí stav ORDERED. Vrátí aktualizovaná data. */ diff --git a/server/src/tests/devRoutes.test.ts b/server/src/tests/devRoutes.test.ts new file mode 100644 index 0000000..bc1d8c5 --- /dev/null +++ b/server/src/tests/devRoutes.test.ts @@ -0,0 +1,157 @@ +import express from 'express'; +import request from 'supertest'; +import bodyParser from 'body-parser'; +import { generateToken } from '../auth'; +import { resetMemoryStorage } from '../storage/memory'; +import getStorage from '../storage'; +import devRouter from '../routes/devRoutes'; +import { createGroup } from '../groups'; +import { ClientData, Store } from '../../../types/gen/types.gen'; +import { formatDate } from '../utils'; +import { getToday } from '../service'; + +// Websocket v testech neexistuje – broadcast jen odchytíme +jest.mock('../websocket', () => ({ + getWebsocket: () => ({ emit: jest.fn() }), + emitToUser: jest.fn(), +})); + +const storage = getStorage(); +const TOKEN = `Bearer ${generateToken('testuser')}`; + +function buildApp() { + const app = express(); + app.use(bodyParser.json()); + app.use('/api/dev', devRouter); + app.use((err: any, _req: any, res: any, _next: any) => { + res.status(400).json({ error: err.message }); + }); + return app; +} + +/** Vrátí uložená data sekce Objednání pro dnešní den. */ +async function getExtra(): Promise { + return storage.getData(`${formatDate(getToday())}_extra`); +} + +async function setStores(stores: Store[]) { + await storage.setData('stores', stores); +} + +beforeEach(() => { + resetMemoryStorage(); +}); + +test('POST /dev/generateOrders bez nadefinovaného obchodu vrátí 400', async () => { + const res = await request(buildApp()) + .post('/api/dev/generateOrders') + .set('Authorization', TOKEN) + .send({}); + expect(res.status).toBe(400); +}); + +test('POST /dev/generateOrders bez existující skupiny založí jednu a doplní do ní osoby', async () => { + await setStores([{ name: 'Bistro', urls: ['https://example.com/bistro'] }]); + + const res = await request(buildApp()) + .post('/api/dev/generateOrders') + .set('Authorization', TOKEN) + .send({ count: 4, minAmount: 100, maxAmount: 1000 }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(4); + expect(res.body.groups).toHaveLength(1); + expect(res.body.groups[0].created).toBe(true); + + const data = await getExtra(); + const group = data?.groups?.[0]; + expect(group?.name).toBe('Bistro'); + expect(group?.url).toBe('https://example.com/bistro'); + // Zakladatel (přihlášený uživatel) + 4 doplněné osoby + const members = Object.entries(group!.members); + expect(members).toHaveLength(5); + const mockMembers = members.filter(([login]) => login !== 'testuser'); + expect(mockMembers).toHaveLength(4); + for (const [, member] of mockMembers) { + expect(member.amount).toBeGreaterThanOrEqual(100 * 100); + expect(member.amount).toBeLessThanOrEqual(1000 * 100); + expect(member.note).toBeTruthy(); + } +}); + +test('POST /dev/generateOrders doplní osoby do všech existujících skupin', async () => { + await setStores([{ name: 'Bistro' }, { name: 'Pizzerie' }]); + await createGroup('testuser', 'Bistro'); + await createGroup('kolega', 'Pizzerie'); + + const res = await request(buildApp()) + .post('/api/dev/generateOrders') + .set('Authorization', TOKEN) + .send({ count: 2 }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(4); + expect(res.body.groups).toHaveLength(2); + expect(res.body.groups.every((g: any) => g.created === undefined)).toBe(true); + + const data = await getExtra(); + expect(data?.groups).toHaveLength(2); + for (const group of data!.groups!) { + // Zakladatel + 2 doplněné osoby + expect(Object.keys(group.members)).toHaveLength(3); + } +}); + +test('POST /dev/generateOrders použije výchozí hodnoty, když nejsou zadány', async () => { + await setStores([{ name: 'Bistro' }]); + + const res = await request(buildApp()) + .post('/api/dev/generateOrders') + .set('Authorization', TOKEN) + .send({}); + + expect(res.status).toBe(200); + // Výchozí počet osob je 3 + expect(res.body.count).toBe(3); + + const data = await getExtra(); + for (const [login, member] of Object.entries(data!.groups![0].members)) { + if (login === 'testuser') continue; + // Výchozí rozsah částek je 100–1000 Kč + expect(member.amount).toBeGreaterThanOrEqual(100 * 100); + expect(member.amount).toBeLessThanOrEqual(1000 * 100); + } +}); + +test('POST /dev/generateOrders zvládne počet osob přesahující seznam jmen', async () => { + await setStores([{ name: 'Bistro' }]); + + const res = await request(buildApp()) + .post('/api/dev/generateOrders') + .set('Authorization', TOKEN) + .send({ count: 50 }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(50); + + const data = await getExtra(); + // Všechna jména musí být unikátní (zakladatel + 50 doplněných osob) + expect(Object.keys(data!.groups![0].members)).toHaveLength(51); +}); + +test('POST /dev/generateOrders odmítne neplatný počet osob a rozsah částek', async () => { + await setStores([{ name: 'Bistro' }]); + const app = buildApp(); + + const badCount = await request(app) + .post('/api/dev/generateOrders') + .set('Authorization', TOKEN) + .send({ count: 0 }); + expect(badCount.status).toBe(400); + + const badRange = await request(app) + .post('/api/dev/generateOrders') + .set('Authorization', TOKEN) + .send({ minAmount: 500, maxAmount: 100 }); + expect(badRange.status).toBe(400); +}); diff --git a/types/api.yml b/types/api.yml index 1011680..a33afc0 100644 --- a/types/api.yml +++ b/types/api.yml @@ -162,6 +162,8 @@ paths: $ref: "./paths/dev/generate.yml" /dev/clear: $ref: "./paths/dev/clear.yml" + /dev/generateOrders: + $ref: "./paths/dev/generateOrders.yml" /dev/bolt/simulate: $ref: "./paths/dev/boltSimulate.yml" /dev/bolt/advance: diff --git a/types/paths/dev/generateOrders.yml b/types/paths/dev/generateOrders.yml new file mode 100644 index 0000000..d1998ab --- /dev/null +++ b/types/paths/dev/generateOrders.yml @@ -0,0 +1,31 @@ +post: + operationId: generateMockOrders + summary: Vygenerování mock dat pro sekci Objednání – vždy jen pro aktuální den (pouze DEV režim) + requestBody: + required: false + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/GenerateMockOrdersRequest" + responses: + "200": + description: Mock data pro objednávání byla úspěšně vygenerována + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + count: + description: Celkový počet doplněných osob + type: integer + groups: + description: Rozpis doplněných osob po skupinách + type: array + items: + $ref: "../../schemas/_index.yml#/MockOrdersGroupResult" + "400": + description: Chybný požadavek (např. není nadefinován žádný obchod) + "403": + description: Endpoint není dostupný v tomto režimu diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index a76cc68..d0d2be9 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -783,6 +783,47 @@ MockDataDayResult: count: description: Počet vygenerovaných záznamů pro daný den type: integer +GenerateMockOrdersRequest: + description: Request pro generování mock dat sekce Objednání (pouze DEV režim) + type: object + additionalProperties: false + properties: + count: + description: Počet osob, které se doplní do každé existující skupiny objednávky + type: integer + minimum: 1 + maximum: 50 + minAmount: + description: Dolní hranice náhodně generované částky v Kč + type: integer + minimum: 0 + maximum: 100000 + maxAmount: + description: Horní hranice náhodně generované částky v Kč + type: integer + minimum: 0 + maximum: 100000 +MockOrdersGroupResult: + description: Výsledek doplnění mock osob do jedné skupiny objednávky + type: object + additionalProperties: false + required: + - id + - name + - added + properties: + id: + description: ID skupiny objednávky + type: string + name: + description: Název obchodu skupiny + type: string + added: + description: Počet osob doplněných do skupiny + type: integer + created: + description: Příznak, zda byla skupina právě založena (neexistovala žádná) + type: boolean ClearMockDataRequest: description: Request pro smazání mock dat (pouze DEV režim) type: object