diff --git a/client/src/App.tsx b/client/src/App.tsx index d642daa..698de0b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -428,31 +428,43 @@ function App() { } const pizzaSuggestions = useMemo(() => { - if (!data?.pizzaList) { + if (!data?.pizzaList && !data?.salatList) { return []; } const suggestions: SelectSearchOption[] = []; - data.pizzaList.forEach((pizza, index) => { + data.pizzaList?.forEach((pizza, index) => { const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] } pizza.sizes.forEach((size, sizeIndex) => { const name = `${size.size} (${size.price} Kč)`; - const value = `${index}|${sizeIndex}`; + const value = `pizza|${index}|${sizeIndex}`; group.items?.push({ name, value }); }) suggestions.push(group); - }) + }); + if (data.salatList?.length) { + const salatGroup: SelectSearchOption = { name: "Saláty", type: "group", items: [] } + data.salatList.forEach((salat, index) => { + salatGroup.items?.push({ name: `${salat.name} (${salat.price} Kč)`, value: `salat|${index}` }); + }); + suggestions.push(salatGroup); + } return suggestions; - }, [data?.pizzaList]); + }, [data?.pizzaList, data?.salatList]); const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => { - if (auth?.login && data?.pizzaList) { + if (auth?.login) { if (typeof value !== 'string') { throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value); } const s = value.split('|'); - const pizzaIndex = Number.parseInt(s[0]); - const pizzaSizeIndex = Number.parseInt(s[1]); - await addPizza({ body: { pizzaIndex, pizzaSizeIndex } }); + if (s[0] === 'salat') { + const salatIndex = Number.parseInt(s[1]); + await addPizza({ body: { salatIndex } }); + } else { + const pizzaIndex = Number.parseInt(s[1]); + const pizzaSizeIndex = Number.parseInt(s[2]); + await addPizza({ body: { pizzaIndex, pizzaSizeIndex } }); + } } } @@ -821,7 +833,7 @@ function App() { { }} onFocus={_ => { }} diff --git a/server/src/chefie.ts b/server/src/chefie.ts index fe41d40..8cd50be 100644 --- a/server/src/chefie.ts +++ b/server/src/chefie.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { load } from 'cheerio'; -import { getPizzaListMock } from './mock'; +import { getPizzaListMock, getSalatListMock } from './mock'; +import { Salat } from '../../types/gen/types.gen'; // TODO přesunout do types type PizzaSize = { @@ -20,7 +21,8 @@ type Pizza = { // TODO mělo by být konfigurovatelné proměnnou z prostředí s tímhle jako default const baseUrl = 'https://www.pizzachefie.cz'; -const pizzyUrl = `${baseUrl}/pizzy.html?pobocka=plzen`; +const pizzyUrl = `${baseUrl}/pizzy.html`; +const salayUrl = `${baseUrl}/salaty.html`; const buildPizzaUrl = (pizzaUrl: string) => { return `${baseUrl}/${pizzaUrl}`; @@ -34,9 +36,12 @@ const boxPrices: { [key: string]: number } = { "50cm": 25 } +// Cena obalu pro salát +const SALAT_BOX_PRICE = 13; + /** * Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie. - * + * * @param mock zda vrátit pouze mock data */ export async function downloadPizzy(mock: boolean): Promise { @@ -84,4 +89,38 @@ export async function downloadPizzy(mock: boolean): Promise { }); } return result; +} + +/** + * Stáhne a scrapne aktuální saláty ze stránek Pizza Chefie. + * Příplatek za obal je pro každý salát pevně 13 Kč. + * + * @param mock zda vrátit pouze mock data + */ +export async function downloadSalaty(mock: boolean): Promise { + if (mock) { + return new Promise((resolve) => setTimeout(() => resolve(getSalatListMock()), 1000)); + } + const html = await axios.get(salayUrl).then(res => res.data); + const $ = load(html); + const links = $('.vypisproduktu > div > h4 > a'); + const urls = []; + for (const element of links) { + if (element.name === 'a' && element.attribs?.href) { + urls.push(buildPizzaUrl(element.attribs.href)); + } + } + const result: Salat[] = []; + for (const url of urls) { + const salatHtml = await axios.get(url).then(res => res.data); + const name = $('.produkt > h2', salatHtml).first().text().trim(); + const ingredients: string[] = []; + $('.prisady > li', salatHtml).each((i, elm) => { + ingredients.push($(elm).text()); + }); + const priceText = $('.cena > span', salatHtml).first().text().trim(); + const price = Number.parseInt(priceText.split(' Kč')[0]); + result.push({ name, ingredients, price: price + SALAT_BOX_PRICE }); + } + return result; } \ No newline at end of file diff --git a/server/src/mock.ts b/server/src/mock.ts index 68ecb13..6620fbf 100644 --- a/server/src/mock.ts +++ b/server/src/mock.ts @@ -1429,6 +1429,34 @@ export const getPizzaListMock = () => { return MOCK_PIZZA_LIST; } +// Mockovací data pro saláty +const MOCK_SALAT_LIST = [ + { + name: "Greek", + ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"], + price: 174 + 13, + }, + { + name: "Caesar", + ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"], + price: 184 + 13, + }, + { + name: "Šopský salát", + ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"], + price: 164 + 13, + }, + { + name: "Těstovinový salát", + ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"], + price: 184 + 13, + }, +] + +export const getSalatListMock = () => { + return MOCK_SALAT_LIST; +} + export const getStatsMock = (): WeeklyStats => { return [ { diff --git a/server/src/pizza.ts b/server/src/pizza.ts index a9434c8..1bd870e 100644 --- a/server/src/pizza.ts +++ b/server/src/pizza.ts @@ -2,9 +2,9 @@ import { formatDate } from "./utils"; import { callNotifikace } from "./notifikace"; import { generateQr } from "./qr"; import getStorage from "./storage"; -import { downloadPizzy } from "./chefie"; +import { downloadPizzy, downloadSalaty } from "./chefie"; import { getClientData, getToday, initIfNeeded } from "./service"; -import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen"; +import { Pizza, Salat, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen"; const storage = getStorage(); const PENDING_QR_PREFIX = 'pending_qr'; @@ -25,7 +25,7 @@ export async function getPizzaList(): Promise { /** * Uloží seznam dostupných pizz pro dnešní den. - * + * * @param pizzaList seznam dostupných pizz */ export async function savePizzaList(pizzaList: Pizza[]): Promise { @@ -38,6 +38,34 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise { return clientData; } +/** + * Vrátí seznam dostupných salátů pro dnešní den. + * Stáhne je, pokud je pro dnešní den nemá. + */ +export async function getSalatList(): Promise { + await initIfNeeded(); + let clientData = await getClientData(getToday()); + if (!clientData.salatList) { + const mock = process.env.MOCK_DATA === 'true'; + clientData = await saveSalatList(await downloadSalaty(mock)); + } + return Promise.resolve(clientData.salatList); +} + +/** + * Uloží seznam dostupných salátů pro dnešní den. + * + * @param salatList seznam dostupných salátů + */ +export async function saveSalatList(salatList: Salat[]): Promise { + await initIfNeeded(); + const today = formatDate(getToday()); + const clientData = await getClientData(getToday()); + clientData.salatList = salatList; + await storage.setData(today, clientData); + return clientData; +} + /** * Vytvoří pizza day pro aktuální den a vrátí data pro klienta. */ @@ -48,8 +76,8 @@ export async function createPizzaDay(creator: string): Promise { throw Error("Pizza day pro dnešní den již existuje"); } // TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě! - const pizzaList = await getPizzaList(); - const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData }; + const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]); + const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList, ...clientData }; const today = formatDate(getToday()); await storage.setData(today, data); callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } }) @@ -113,6 +141,46 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize return clientData; } +/** + * Přidá objednávku salátu uživateli. + * + * @param login login uživatele + * @param salat zvolený salát + */ +export async function addSalatOrder(login: string, salat: Salat) { + const today = formatDate(getToday()); + const clientData = await getClientData(getToday()); + if (!clientData.pizzaDay) { + throw Error("Pizza day pro dnešní den neexistuje"); + } + if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { + throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); + } + let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login); + if (!order) { + order = { + customer: login, + pizzaList: [], + totalPrice: 0, + hasQr: false, + } + clientData.pizzaDay.orders ??= []; + clientData.pizzaDay.orders.push(order); + } + const salatOrder: PizzaVariant = { + varId: 0, + name: salat.name, + size: "1 porce", + price: salat.price, + category: 'salat', + } + order.pizzaList ??= []; + order.pizzaList.push(salatOrder); + order.totalPrice += salatOrder.price; + await storage.setData(today, clientData); + return clientData; +} + /** * Odstraní všechny pizzy uživatele (celou jeho objednávku). * Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic. @@ -269,7 +337,9 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b if (bankAccount?.length && bankAccountHolder?.length) { for (const order of clientData.pizzaDay.orders!) { if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl - let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', '); + let message = order.pizzaList!.map(item => + item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})` + ).join(', '); await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message); order.hasQr = true; // Uložíme nevyřízený QR kód pro persistentní zobrazení diff --git a/server/src/routes/pizzaDayRoutes.ts b/server/src/routes/pizzaDayRoutes.ts index 82a26c5..ddf5c2e 100644 --- a/server/src/routes/pizzaDayRoutes.ts +++ b/server/src/routes/pizzaDayRoutes.ts @@ -1,6 +1,6 @@ import express, { Request } from "express"; import { getLogin } from "../auth"; -import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza"; +import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza"; import { parseToken } from "../utils"; import { getWebsocket } from "../websocket"; import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types"; @@ -24,27 +24,43 @@ router.post("/delete", async (req: Request<{}, any, undefined>, res) => { router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) => { const login = getLogin(parseToken(req)); - if (isNaN(req.body?.pizzaIndex)) { - throw Error("Nebyl předán index pizzy"); + if (req.body?.salatIndex !== undefined && !isNaN(req.body.salatIndex)) { + // Přidání salátu + const salatIndex = req.body.salatIndex; + const salaty = await getSalatList(); + if (!salaty) { + throw Error("Selhalo získání seznamu dostupných salátů."); + } + if (!salaty[salatIndex]) { + throw Error("Neplatný index salátu: " + salatIndex); + } + const data = await addSalatOrder(login, salaty[salatIndex]); + getWebsocket().emit("message", data); + res.status(200).json({}); + } else { + // Přidání pizzy + if (req.body?.pizzaIndex === undefined || isNaN(req.body.pizzaIndex)) { + throw Error("Nebyl předán index pizzy ani salátu"); + } + const pizzaIndex = req.body.pizzaIndex; + if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) { + throw Error("Nebyl předán index velikosti pizzy"); + } + const pizzaSizeIndex = req.body.pizzaSizeIndex; + let pizzy = await getPizzaList(); + if (!pizzy) { + throw Error("Selhalo získání seznamu dostupných pizz."); + } + if (!pizzy[pizzaIndex]) { + throw Error("Neplatný index pizzy: " + pizzaIndex); + } + if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) { + throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex); + } + const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]); + getWebsocket().emit("message", data); + res.status(200).json({}); } - const pizzaIndex = req.body.pizzaIndex; - if (isNaN(req.body?.pizzaSizeIndex)) { - throw Error("Nebyl předán index velikosti pizzy"); - } - const pizzaSizeIndex = req.body.pizzaSizeIndex; - let pizzy = await getPizzaList(); - if (!pizzy) { - throw Error("Selhalo získání seznamu dostupných pizz."); - } - if (!pizzy[pizzaIndex]) { - throw Error("Neplatný index pizzy: " + pizzaIndex); - } - if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) { - throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex); - } - const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]); - getWebsocket().emit("message", data); - res.status(200).json({}); }); router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => { diff --git a/types/paths/pizzaDay/addPizza.yml b/types/paths/pizzaDay/addPizza.yml index fed8511..9637580 100644 --- a/types/paths/pizzaDay/addPizza.yml +++ b/types/paths/pizzaDay/addPizza.yml @@ -1,21 +1,21 @@ post: operationId: addPizza - summary: Přidání pizzy do objednávky. + summary: Přidání pizzy nebo salátu do objednávky. requestBody: required: true content: application/json: schema: - required: - - pizzaIndex - - pizzaSizeIndex properties: pizzaIndex: - description: Index pizzy v nabídce + description: Index pizzy v nabídce (pro přidání pizzy) type: integer pizzaSizeIndex: - description: Index velikosti pizzy v nabídce variant + description: Index velikosti pizzy v nabídce variant (pro přidání pizzy) + type: integer + salatIndex: + description: Index salátu v nabídce (pro přidání salátu) type: integer responses: "200": - description: Přidání pizzy do objednávky proběhlo úspěšně. + description: Přidání pizzy nebo salátu do objednávky proběhlo úspěšně. diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 7992b6b..22b4a3d 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -53,6 +53,11 @@ ClientData: description: Datum a čas poslední aktualizace pizz type: string format: date-time + salatList: + description: Seznam dostupných salátů pro předaný den + type: array + items: + $ref: "#/Salat" pendingQrs: description: Nevyřízené QR kódy pro platbu z předchozích pizza day type: array @@ -426,7 +431,7 @@ Pizza: items: $ref: "#/PizzaSize" PizzaVariant: - description: Konkrétní varianta (velikost) jedné pizzy. + description: Konkrétní varianta (velikost) jedné pizzy nebo salátu. type: object additionalProperties: false required: @@ -436,16 +441,40 @@ PizzaVariant: - price properties: varId: - description: Unikátní identifikátor varianty pizzy + description: Unikátní identifikátor varianty type: integer name: - description: Název pizzy + description: Název pizzy nebo salátu type: string size: - description: Velikost pizzy (např. "30cm") + description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát type: string price: - description: Cena pizzy v Kč, včetně krabice + description: Cena v Kč, včetně krabice/obalu + type: number + category: + description: Kategorie položky (pizza nebo salat) + type: string + enum: [pizza, salat] +Salat: + description: Salát z nabídky Pizza Chefie + type: object + additionalProperties: false + required: + - name + - ingredients + - price + properties: + name: + description: Název salátu + type: string + ingredients: + description: Seznam obsažených ingrediencí + type: array + items: + type: string + price: + description: Cena salátu v Kč (bez obalu) type: number PizzaOrder: description: Údaje o objednávce pizzy jednoho uživatele.