feat: zadávání cen
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 28s
CI / Build server (push) Successful in 37s
CI / Build client (push) Successful in 45s
CI / Playwright E2E tests (push) Successful in 2m15s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s

This commit is contained in:
Ondřej Anděl committed 2026-10-21 09:42:04 +02:00
1 parent 96dc3f0053
commit 9a3987a2fa
13 files changed
+244 -11

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
[
"U oběda v restauraci si můžete zadat cenu (ikonka cenovky u vaší volby) — promítne se do měsíčního přehledu stravování a předvyplní se i v „Zaplatit za všechny“"
]
+21 -3
View File
@@ -1,10 +1,10 @@
import express, { Request, Response } from "express";
import { getLogin, getTrusted } from "../auth";
import { addChoice, getActiveRestaurants, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service";
import { addChoice, getActiveRestaurants, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, updatePrice, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service";
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace";
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData, UpdatePriceData } from "../../../types/gen/types.gen";
// RateLimit na refresh endpoint
@@ -54,7 +54,7 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
* @param req request
* @returns index dne v týdnu
*/
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"] | UpdatePriceData["body"]>) => {
if (req.body.dayIndex == null) {
throw new Error(`Nebyl předán index dne v týdnu.`);
}
@@ -171,6 +171,24 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
} catch (e: any) { next(e) }
});
router.post("/updatePrice", async (req: Request<{}, any, UpdatePriceData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
try {
date = getDateForWeekIndex(parseValidateFutureDayIndex(req));
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
}
try {
const data = await updatePrice(login, trusted, req.body.price ?? undefined, date);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
let date = undefined;
+31
View File
@@ -590,6 +590,37 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
});
}
/** Volby, u kterých nemá smysl zadávat cenu ručně — cena je dána objednávkou nebo se neobědvá. */
export const CHOICES_WITHOUT_PRICE: LunchChoice[] = [LunchChoice.OBJEDNAVAM, LunchChoice.PIZZA, LunchChoice.NEOBEDVAM, LunchChoice.ROZHODUJI];
/**
* Nastaví nebo odstraní cenu oběda u aktuálně vybrané možnosti uživatele.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param price cena v haléřích, při nevyplnění se cena odstraní
* @param date datum, ke kterému se volba vztahuje
*/
export async function updatePrice(login: string, trusted: boolean, price?: number, date?: Date) {
if (price != null && (!Number.isInteger(price) || price < 0)) {
throw new Error(`Neplatná cena: ${price}`);
}
const usedDate = date ?? getToday();
const snapshot = await getClientData(usedDate);
validateTrusted(snapshot, login, trusted);
return storage.updateData<ClientData>(getDataKey(usedDate), (current) => {
const data = current ?? getEmptyData(date);
const userEntry = Object.entries(data.choices ?? {}).find(entry => entry[1][login] != null);
if (!userEntry) throw new Error("Cenu lze zadat jen k vybrané volbě");
if (CHOICES_WITHOUT_PRICE.includes(userEntry[0] as LunchChoice)) {
throw new Error("K této volbě nelze zadat cenu");
}
if (price == null) delete userEntry[1][login].price;
else userEntry[1][login].price = price;
return data;
});
}
/**
* Aktualizuje preferovaný čas odchodu strávníka.
*
+42
View File
@@ -0,0 +1,42 @@
import getStorage from '../storage';
import { resetMemoryStorage } from '../storage/memory';
import { updatePrice } from '../service';
import { ClientData } from '../../../types/gen/types.gen';
const storage = getStorage();
const USER = 'petr';
const DATE = new Date('2025-03-04');
const ISO_DATE = '2025-03-04';
/** Uloží volby uživatelů pro testovací den. */
async function saveChoices(choices: ClientData["choices"]) {
await storage.setData<Partial<ClientData>>(ISO_DATE, { choices });
}
beforeEach(() => {
resetMemoryStorage();
});
describe('updatePrice', () => {
test('nastaví a odstraní cenu u volby podniku', async () => {
await saveChoices({ SPSE: { [USER]: { note: 'polévka' } } });
let data = await updatePrice(USER, false, 9500, DATE);
expect(data.choices.SPSE?.[USER]).toMatchObject({ price: 9500, note: 'polévka' });
data = await updatePrice(USER, false, undefined, DATE);
expect(data.choices.SPSE?.[USER]?.price).toBeUndefined();
expect(data.choices.SPSE?.[USER]?.note).toBe('polévka');
});
test.each(['OBJEDNAVAM', 'PIZZA', 'NEOBEDVAM', 'ROZHODUJI'])('odmítne cenu u volby %s', async (choice) => {
await saveChoices({ [choice]: { [USER]: {} } });
await expect(updatePrice(USER, false, 10000, DATE)).rejects.toThrow('K této volbě nelze zadat cenu');
});
test('odmítne cenu bez vybrané volby i zápornou cenu', async () => {
await saveChoices({ SPSE: { tomas: {} } });
await expect(updatePrice(USER, false, 10000, DATE)).rejects.toThrow('Cenu lze zadat jen k vybrané volbě');
await expect(updatePrice(USER, false, -1, DATE)).rejects.toThrow('Neplatná cena');
});
});
+8
View File
@@ -67,6 +67,14 @@ describe('getUserExportRows', () => {
});
});
test('u volby podniku vyplní uživatelem zadanou cenu', async () => {
await saveLunch('2025-03-04', { SPSE: { [USER]: { price: 9500 } } });
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ type: 'SPŠE', amount: 9500 });
});
test('u volby "budu objednávat" sloučí záznam s objednávkovou skupinou včetně ceny po slevě', async () => {
await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } });
await saveExtra('2025-03-06', [{
+1 -1
View File
@@ -157,7 +157,7 @@ export async function getUserExportRows(login: string, year: number, month: numb
let food: string | undefined;
let note = userChoice.note;
let amount: number | undefined;
let amount = userChoice.price;
if (locationKey === LunchChoice.PIZZA) {
const pizza = getPizzaText(lunchData, login);
food = pizza.food;