feat: podpora salátů z Pizza Chefie
ci/woodpecker/push/workflow Pipeline failed

This commit is contained in:
2026-04-02 10:51:46 +02:00
parent e9696f722c
commit d6729388ab
7 changed files with 246 additions and 52 deletions
+21 -9
View File
@@ -428,33 +428,45 @@ 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]);
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 } });
}
}
}
const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
await removePizza({ body: { pizzaOrder } });
@@ -821,7 +833,7 @@ function App() {
<SelectSearch
search={true}
options={pizzaSuggestions}
placeholder='Vyhledat pizzu...'
placeholder='Vyhledat pizzu nebo salát...'
onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
+41 -2
View File
@@ -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,6 +36,9 @@ 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.
*
@@ -85,3 +90,37 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
}
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<Salat[]> {
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;
}
+28
View File
@@ -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 [
{
+75 -5
View File
@@ -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';
@@ -38,6 +38,34 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
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<Salat[] | undefined> {
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<ClientData> {
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<ClientData> {
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í
+20 -4
View File
@@ -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,11 +24,26 @@ 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 (isNaN(req.body?.pizzaSizeIndex)) {
if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) {
throw Error("Nebyl předán index velikosti pizzy");
}
const pizzaSizeIndex = req.body.pizzaSizeIndex;
@@ -45,6 +60,7 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
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) => {
+7 -7
View File
@@ -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ě.
+34 -5
View File
@@ -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.