feat: export historie jídel uživatele
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 1m1s
CI / Notify (push) Successful in 3s

This commit is contained in:
Ondřej Anděl
2026-08-12 13:53:36 +02:00
parent 065ccaf38a
commit eeb1630391
13 changed files with 1513 additions and 8 deletions
+24
View File
@@ -2,10 +2,34 @@ import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getStats } from "../stats";
import { EXPORT_FORMATS, generateUserExport, isExportFormat } from "../userExport";
import { WeeklyStats } from "../../../types/gen/types.gen";
const router = express.Router();
/**
* Vrátí přehled stravování přihlášeného uživatele za vybraný měsíc (XLSX, CSV nebo JSON).
*/
router.get("/export", async (req: Request<{}, any, undefined>, res: Response) => {
const login = getLogin(parseToken(req));
const year = Number(req.query.year);
const month = Number(req.query.month);
const format = req.query.format ?? 'xlsx';
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
return res.status(400).json({ error: "Neplatný rok" });
}
if (!Number.isInteger(month) || month < 1 || month > 12) {
return res.status(400).json({ error: "Neplatný měsíc" });
}
if (!isExportFormat(format)) {
return res.status(400).json({ error: `Neplatný formát exportu, podporované jsou: ${EXPORT_FORMATS.join(', ')}` });
}
const { content, mimeType, fileName } = await generateUserExport(login, year, month, format);
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
return res.status(200).send(content);
});
router.get("/", async (req: Request<{}, any, undefined>, res: Response<WeeklyStats>) => {
getLogin(parseToken(req));
if (typeof req.query.startDate === 'string' && typeof req.query.endDate === 'string') {
+58
View File
@@ -52,6 +52,64 @@ test('GET /stats s budoucím datem vrátí 400', async () => {
expect(res.status).toBe(400);
});
test('GET /stats/export vrátí XLSX soubor', async () => {
const res = await request(buildApp())
.get('/api/stats/export')
.query({ year: 2025, month: 3 })
.responseType('blob')
.set('Authorization', TOKEN);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('spreadsheetml');
expect(res.headers['content-disposition']).toContain('luncher-testuser-2025-03.xlsx');
// ZIP signatura XLSX souboru
expect(res.body.slice(0, 2).toString()).toBe('PK');
});
test('GET /stats/export?format=csv vrátí CSV', async () => {
const res = await request(buildApp())
.get('/api/stats/export')
.query({ year: 2025, month: 3, format: 'csv' })
.set('Authorization', TOKEN);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/csv');
expect(res.headers['content-disposition']).toContain('luncher-testuser-2025-03.csv');
expect(res.text).toContain('"Datum";"Den";"Typ"');
});
test('GET /stats/export?format=json vrátí JSON s metadaty', async () => {
const res = await request(buildApp())
.get('/api/stats/export')
.query({ year: 2025, month: 3, format: 'json' })
.set('Authorization', TOKEN);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('application/json');
expect(res.headers['content-disposition']).toContain('luncher-testuser-2025-03.json');
expect(res.body).toMatchObject({ login: 'testuser', year: 2025, month: 3, rowCount: 0, rows: [] });
});
test('GET /stats/export s neznámým formátem vrátí 400', async () => {
const res = await request(buildApp())
.get('/api/stats/export')
.query({ year: 2025, month: 3, format: 'pdf' })
.set('Authorization', TOKEN);
expect(res.status).toBe(400);
});
test('GET /stats/export s neplatným měsícem vrátí 400', async () => {
const res = await request(buildApp())
.get('/api/stats/export')
.query({ year: 2025, month: 13 })
.set('Authorization', TOKEN);
expect(res.status).toBe(400);
});
test('GET /stats/export bez parametrů vrátí 400', async () => {
const res = await request(buildApp())
.get('/api/stats/export')
.set('Authorization', TOKEN);
expect(res.status).toBe(400);
});
test('GET /stats bez tokenu vrátí chybu', async () => {
const res = await request(buildApp())
.get('/api/stats')
+310
View File
@@ -0,0 +1,310 @@
import getStorage from '../storage';
import { resetMemoryStorage } from '../storage/memory';
import { getUserExportRows, computeMemberAmount, getUserExportFileName, buildCsv, buildJson, generateUserExport, isExportFormat, UserExportRow } from '../userExport';
import { ClientData, GroupState, LunchChoice, OrderGroup, PizzaDayState, WeekMenu } from '../../../types/gen/types.gen';
const storage = getStorage();
const USER = 'petr';
const OTHER = 'tomas';
// 2025-03-03 je pondělí, 2025-03-04 úterý, ...
const YEAR = 2025;
const MONTH = 3;
/** Uloží denní data pro slot oběd. */
async function saveLunch(isoDate: string, choices: ClientData["choices"], pizzaDay?: ClientData["pizzaDay"]) {
await storage.setData<Partial<ClientData>>(isoDate, { choices, pizzaDay });
}
/** Uloží data slotu extra s předanými skupinami. */
async function saveExtra(isoDate: string, groups: OrderGroup[]) {
await storage.setData<Partial<ClientData>>(`${isoDate}_extra`, { choices: {}, groups });
}
/** Uloží týdenní menu tak, aby daný den obsahoval předaná jídla. */
async function saveWeekMenu(menuKey: string, dayIndex: number, foodNames: string[]) {
const week: WeekMenu = [{}, {}, {}, {}, {}] as WeekMenu;
week[dayIndex] = {
TECHTOWER: {
lastUpdate: 0,
closed: false,
food: foodNames.map(name => ({ name, isSoup: false })),
},
};
await storage.setData(menuKey, week);
}
beforeEach(() => {
resetMemoryStorage();
});
describe('getUserExportRows', () => {
test('vynechá dny bez záznamu i volbu "mám vlastní/neobědvám"', async () => {
await saveLunch('2025-03-03', { NEOBEDVAM: { [USER]: {} } });
await saveLunch('2025-03-04', { SPSE: { [USER]: {} } });
// den, kde má volbu jen jiný uživatel
await saveLunch('2025-03-05', { SPSE: { [OTHER]: {} } });
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ date: '2025-03-04', type: 'SPŠE' });
});
test('vyplní vybrané jídlo z uloženého menu i poznámku', async () => {
await saveWeekMenu('menu_2025_10', 0, ['Polévka', 'Svíčková', 'Guláš']);
await saveLunch('2025-03-03', {
TECHTOWER: { [USER]: { selectedFoods: [1, 2], note: 'bez knedlíku' } },
});
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
date: '2025-03-03',
dayOfWeek: 'pondělí',
type: 'TechTower',
food: 'Svíčková + Guláš',
note: 'bez knedlíku',
});
});
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', [{
id: 'g1',
name: 'KFC',
creatorLogin: OTHER,
state: GroupState.ORDERED,
orderedAt: '11:30',
shipping: 4000,
discountType: 'percent',
discountValue: 10,
members: {
[USER]: { amount: 20000, note: 'Twister menu', paid: true },
[OTHER]: { amount: 20000 },
},
}]);
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
date: '2025-03-06',
type: 'Budu objednávat',
food: 'Twister menu',
note: 'platím kartou',
store: 'KFC',
orderedBy: OTHER,
// 200 Kč + 20 Kč doprava (poloviny ze 40) - 20 Kč sleva (10 %)
amount: 20000 + 2000 - 2000,
});
});
test('objednávku bez volby oběda vypíše jako samostatný záznam', async () => {
await saveExtra('2025-03-07', [{
id: 'g2',
name: 'Bageterie',
creatorLogin: USER,
state: GroupState.OPEN,
members: { [USER]: { amount: 15000, surchargeText: 'sýr', surchargeAmount: 2000 } },
}]);
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
date: '2025-03-07',
type: 'Objednávka',
store: 'Bageterie',
orderedBy: USER,
food: 'příplatek: sýr',
amount: 17000,
});
});
test('u Pizza day vypíše objednané pizzy a celkovou cenu', async () => {
await saveLunch('2025-03-10', { PIZZA: { [USER]: {} } }, {
state: PizzaDayState.DELIVERED,
creator: OTHER,
orders: [{
customer: USER,
pizzaList: [{ varId: 1, name: 'Margherita', size: '32cm', price: 18000 }],
fee: { text: 'kuřecí maso', price: 3000 },
totalPrice: 21000,
note: 'bez oliv',
hasQr: true,
}],
} as ClientData["pizzaDay"]);
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
type: 'Pizza day',
food: 'Margherita (32cm) + příplatek: kuřecí maso',
note: 'bez oliv',
amount: 21000,
});
});
test('více skupin v jeden den vypíše jako samostatné řádky', async () => {
await saveLunch('2025-03-11', { OBJEDNAVAM: { [USER]: {} } });
await saveExtra('2025-03-11', [
{ id: 'a', name: 'KFC', creatorLogin: USER, state: GroupState.OPEN, members: { [USER]: { amount: 10000 } } },
{ id: 'b', name: 'Bageterie', creatorLogin: USER, state: GroupState.OPEN, members: { [USER]: { amount: 5000 } } },
]);
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(2);
// V rámci dne je nejdřív volba oběda (se sloučenou první skupinou), pak zbytek objednávek
expect(rows.map(r => r.type)).toEqual(['Budu objednávat', 'Objednávka']);
expect(rows.map(r => r.store)).toEqual(['KFC', 'Bageterie']);
});
test('nezahrne data mimo vybraný měsíc', async () => {
await saveLunch('2025-02-28', { SPSE: { [USER]: {} } });
await saveLunch('2025-03-03', { SPSE: { [USER]: {} } });
await saveLunch('2025-04-01', { SPSE: { [USER]: {} } });
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows.map(r => r.date)).toEqual(['2025-03-03']);
});
});
test('člen skupiny, který si nic neobjednal, nemá vyplněnou částku', async () => {
await saveExtra('2025-03-12', [{
id: 'g3',
name: 'KFC',
creatorLogin: USER,
state: GroupState.ORDERED,
members: { [USER]: {}, [OTHER]: { amount: 15000 } },
}]);
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(1);
// Objednávající je uveden, ale částka zůstává prázdná — není co platit
expect(rows[0].orderedBy).toBe(USER);
expect(rows[0].amount).toBeUndefined();
expect(buildCsv(rows).split('\r\n')[1].split(';')[7]).toBe('');
expect(buildJson(USER, YEAR, MONTH, rows).rows[0].amount).toBeUndefined();
});
describe('computeMemberAmount', () => {
const group = (overrides: Partial<OrderGroup>): OrderGroup => ({
id: 'g',
name: 'KFC',
creatorLogin: OTHER,
state: GroupState.OPEN,
members: {},
...overrides,
});
test('neaktivní člen (nic si neobjednal) platí nula', () => {
const g = group({ shipping: 4000, members: { [USER]: {}, [OTHER]: { amount: 10000 } } });
expect(computeMemberAmount(g, USER)).toBe(0);
});
test('pevná sleva se dělí mezi aktivní členy', () => {
const g = group({
discountType: 'fixed',
discountValue: 5000,
members: { [USER]: { amount: 10000 }, [OTHER]: { amount: 10000 } },
});
expect(computeMemberAmount(g, USER)).toBe(10000 - 2500);
});
test('poplatky se rozpočítají jen mezi aktivní členy', () => {
const g = group({
fees: 1000,
shipping: 5000,
tip: 0,
members: { [USER]: { amount: 10000 }, [OTHER]: {} },
});
expect(computeMemberAmount(g, USER)).toBe(16000);
});
});
test('getUserExportFileName očistí login a doplní měsíc s příponou dle formátu', () => {
expect(getUserExportFileName('domena\\petr', 2025, 3)).toBe('luncher-domena_petr-2025-03.xlsx');
expect(getUserExportFileName('petr', 2025, 3, 'csv')).toBe('luncher-petr-2025-03.csv');
expect(getUserExportFileName('petr', 2025, 12, 'json')).toBe('luncher-petr-2025-12.json');
});
test('isExportFormat propustí jen podporované formáty', () => {
expect(isExportFormat('xlsx')).toBe(true);
expect(isExportFormat('csv')).toBe(true);
expect(isExportFormat('json')).toBe(true);
expect(isExportFormat('pdf')).toBe(false);
expect(isExportFormat(undefined)).toBe(false);
});
describe('buildCsv', () => {
const rows: UserExportRow[] = [
{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'TechTower', food: 'Svíčková', note: 'bez knedlíku' },
{
date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'Budu objednávat', food: 'Twister; s "extra" sýrem',
store: 'KFC', orderedBy: 'tomas', amount: 20000,
},
];
test('začíná BOM a hlavičkou oddělenou středníkem', () => {
const csv = buildCsv(rows);
expect(csv.charCodeAt(0)).toBe(0xFEFF);
expect(csv.slice(1).split('\r\n')[0]).toBe('"Datum";"Den";"Typ";"Vybrané jídlo";"Poznámka";"Objednávka";"Objednával";"Částka"');
});
test('formátuje datum, částku s desetinnou čárkou a escapuje uvozovky i středníky', () => {
const lines = buildCsv(rows).split('\r\n');
expect(lines[1]).toBe('"03.03.2025";"pondělí";"TechTower";"Svíčková";"bez knedlíku";;;');
expect(lines[2]).toBe('"06.03.2025";"čtvrtek";"Budu objednávat";"Twister; s ""extra"" sýrem";;"KFC";"tomas";"200,00"');
});
test('u záznamu bez objednávky nechá sloupce objednávky prázdné', () => {
const csv = buildCsv([{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'Pizza day', amount: 21000 }]);
const cells = csv.split('\r\n')[1].split(';');
expect(cells[5]).toBe('');
expect(cells[6]).toBe('');
expect(cells[7]).toBe('"210,00"');
});
});
describe('buildJson', () => {
test('obsahuje metadata, součet v korunách a jen vyplněná pole', () => {
const rows: UserExportRow[] = [
{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'SPŠE' },
{ date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'Objednávka', store: 'KFC', orderedBy: 'tomas', amount: 22000 },
];
const json = buildJson('petr', 2025, 3, rows);
expect(json).toMatchObject({ login: 'petr', year: 2025, month: 3, rowCount: 2, totalAmount: 220 });
expect(json.rows[0]).toEqual({ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'SPŠE' });
expect(json.rows[1]).toEqual({
date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'Objednávka',
store: 'KFC', orderedBy: 'tomas', amount: 220,
});
});
});
describe('generateUserExport', () => {
beforeEach(async () => {
await storage.setData('2025-03-03', { choices: { SPSE: { [USER]: { note: 'polévka' } } } });
});
test('xlsx vrátí ZIP obsah se správným MIME a názvem', async () => {
const result = await generateUserExport(USER, YEAR, MONTH);
expect(result.content.subarray(0, 2).toString()).toBe('PK');
expect(result.mimeType).toContain('spreadsheetml');
expect(result.fileName).toBe('luncher-petr-2025-03.xlsx');
});
test('csv vrátí textový obsah s daty uživatele', async () => {
const result = await generateUserExport(USER, YEAR, MONTH, 'csv');
expect(result.mimeType).toBe('text/csv; charset=utf-8');
expect(result.fileName).toBe('luncher-petr-2025-03.csv');
expect(result.content.toString('utf-8')).toContain('"03.03.2025";"pondělí";"SPŠE"');
});
test('json vrátí parsovatelný obsah s metadaty', async () => {
const result = await generateUserExport(USER, YEAR, MONTH, 'json');
expect(result.mimeType).toBe('application/json; charset=utf-8');
expect(result.fileName).toBe('luncher-petr-2025-03.json');
const parsed = JSON.parse(result.content.toString('utf-8'));
expect(parsed).toMatchObject({ login: USER, year: YEAR, month: MONTH, rowCount: 1 });
expect(parsed.rows[0]).toMatchObject({ type: 'SPŠE', note: 'polévka' });
});
});
+385
View File
@@ -0,0 +1,385 @@
import ExcelJS from "exceljs";
import { ClientData, LunchChoice, OrderGroup, OrderGroupMember, Restaurant, UserExport, WeekMenu } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { formatDate, getDayOfWeekIndex } from "./utils";
import { getMenuKey, getToday } from "./service";
const storage = getStorage();
const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat('cs-CZ', { weekday: 'long' });
/** Lidsky čitelné názvy voleb stravování (serverová obdoba klientského enums.ts). */
const CHOICE_NAMES: Record<LunchChoice, string> = {
SLADOVNICKA: 'Sladovnická',
TECHTOWER: 'TechTower',
ZASTAVKAUMICHALA: 'Zastávka u Michala',
SENKSERIKOVA: 'Šenk Šeříková',
SPSE: 'SPŠE',
PIZZA: 'Pizza day',
OBJEDNAVAM: 'Budu objednávat',
NEOBEDVAM: 'Mám vlastní/neobědvám',
ROZHODUJI: 'Rozhoduji se',
};
/** Název typu záznamu pro objednávku bez odpovídající volby oběda. */
const ORDER_ONLY_LABEL = 'Objednávka';
/** Volby, které v přehledu nechceme (uživatel si nic neobjednal ani nikam nešel). */
const EXCLUDED_CHOICES: LunchChoice[] = [LunchChoice.NEOBEDVAM];
/**
* Jeden řádek přehledu stravování uživatele — interní podoba.
* Částky jsou zde v haléřích, na koruny se převádějí až při generování výstupu.
*/
export type UserExportRow = {
/** Datum záznamu ve formátu YYYY-MM-DD */
date: string;
/** Den v týdnu (pondělí, ...) */
dayOfWeek: string;
/** Název typu záznamu (podnik, "Budu objednávat", "Objednávka", ...) */
type: string;
/** Konkrétní vybrané jídlo (jídla z menu, pizzy, položky objednávky) */
food?: string;
/** Poznámka uživatele k volbě */
note?: string;
/** Obchod/restaurace objednávkové skupiny */
store?: string;
/** Login objednávajícího (zakladatele objednávkové skupiny) */
orderedBy?: string;
/** Částka k úhradě po slevě v haléřích */
amount?: number;
};
/**
* Vrátí true, pokud si člen skupiny reálně něco objednal.
* Duplikát klientského {@link ../../client/src/utils/groupFees.ts} — server a klient
* nemají společný modul pro doménovou logiku, výpočet ale musí dávat stejné výsledky.
*/
function isActiveMember(member: OrderGroupMember): boolean {
return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0;
}
/**
* Vypočte částku člena skupiny po rozpočítání poplatků a slevy (v haléřích).
* Poplatky i pevná sleva se dělí pouze mezi členy, kteří si něco objednali.
*/
export function computeMemberAmount(group: OrderGroup, login: string): number {
const member = group.members[login];
if (!member || !isActiveMember(member)) return 0;
const activeCount = Object.values(group.members).filter(isActiveMember).length;
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
const feeShare = activeCount > 0 ? Math.round(totalFees / activeCount) : 0;
const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0;
const discountValue = group.discountValue ?? 0;
const discount = discountValue > 0
? (group.discountType === 'percent'
? Math.round((base + surcharge) * discountValue / 100)
: Math.round(discountValue / activeCount))
: 0;
return base + surcharge + feeShare - discount;
}
/** Vrátí názvy jídel vybraných uživatelem v daném podniku, dle uloženého týdenního menu. */
function getSelectedFoodNames(weekMenu: WeekMenu | undefined, dayIndex: number, restaurant: Restaurant, indexes: number[]): string[] {
const food = weekMenu?.[dayIndex]?.[restaurant]?.food;
if (!food?.length) return [];
return indexes
.map(index => food[index]?.name)
.filter((name): name is string => !!name?.length);
}
/** Spojí neprázdné texty do jednoho řádku. */
function join(parts: (string | undefined)[], separator = ', '): string | undefined {
const filtered = parts.filter((part): part is string => !!part?.trim().length);
return filtered.length ? filtered.join(separator) : undefined;
}
/** Popis jednoho člena objednávky (co si objednal) — poznámka + případný příplatek. */
function getOrderItemText(member: OrderGroupMember): string | undefined {
return join([member.note, member.surchargeText ? `příplatek: ${member.surchargeText}` : undefined], ' + ');
}
/** Vrátí popis pizz objednaných uživatelem v rámci Pizza day. */
function getPizzaText(data: ClientData | undefined, login: string): { food?: string, note?: string, amount?: number } {
const order = data?.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) return {};
const pizzas = (order.pizzaList ?? []).map(variant => `${variant.name} (${variant.size})`);
return {
food: join([...pizzas, order.fee?.text ? `příplatek: ${order.fee.text}` : undefined], ' + '),
note: order.note,
amount: order.totalPrice,
};
}
/**
* Sestaví přehled stravování jednoho uživatele za vybraný měsíc.
*
* Do přehledu se dostanou pouze dny, kdy uživatel něco zvolil (vynechány jsou dny bez záznamu
* a volba "Mám vlastní/neobědvám"), plus dny, kdy byl členem objednávkové skupiny.
* Volba "Budu objednávat" se slučuje s odpovídající objednávkovou skupinou do jednoho řádku;
* objednávky bez této volby se vypisují jako samostatné řádky.
*
* @param login přihlašovací jméno uživatele
* @param year rok
* @param month měsíc (1 = leden)
* @returns řádky přehledu seřazené dle data
*/
export async function getUserExportRows(login: string, year: number, month: number): Promise<UserExportRow[]> {
const rows: UserExportRow[] = [];
const lastDayOfMonth = new Date(year, month, 0).getDate();
const today = getToday();
today.setHours(23, 59, 59, 999);
const weekMenuCache = new Map<string, WeekMenu | undefined>();
for (let day = 1; day <= lastDayOfMonth; day++) {
const date = new Date(year, month - 1, day);
if (date > today) break;
const isoDate = formatDate(date);
const dayOfWeek = DAY_OF_WEEK_FORMAT.format(date);
const lunchData = await storage.getData<ClientData>(isoDate);
const extraData = await storage.getData<ClientData>(`${isoDate}_extra`);
// Volba oběda daného uživatele
let choice: LunchChoice | undefined;
let choiceRow: UserExportRow | undefined;
for (const key of Object.keys(lunchData?.choices ?? {})) {
const locationKey = key as LunchChoice;
const userChoice = lunchData!.choices[locationKey]?.[login];
if (!userChoice) continue;
choice = locationKey;
if (EXCLUDED_CHOICES.includes(locationKey)) break;
let food: string | undefined;
let note = userChoice.note;
let amount: number | undefined;
if (locationKey === LunchChoice.PIZZA) {
const pizza = getPizzaText(lunchData, login);
food = pizza.food;
note = join([note, pizza.note], ' | ');
amount = pizza.amount;
} else if (userChoice.selectedFoods?.length) {
const menuKey = getMenuKey(date);
if (!weekMenuCache.has(menuKey)) {
weekMenuCache.set(menuKey, await storage.getData<WeekMenu>(menuKey));
}
food = join(getSelectedFoodNames(weekMenuCache.get(menuKey), getDayOfWeekIndex(date), locationKey as Restaurant, userChoice.selectedFoods), ' + ');
}
choiceRow = {
date: isoDate,
dayOfWeek,
// Fallback na klíč — v datech se mohou vyskytnout i historické/neznámé volby
type: CHOICE_NAMES[locationKey] ?? locationKey,
food,
note,
amount,
};
break;
}
// Objednávkové skupiny, ve kterých byl uživatel členem
const groups = (extraData?.groups ?? []).filter(group => !!group.members[login]);
const groupRows: UserExportRow[] = [];
let mergedGroup = false;
for (const group of groups) {
const member = group.members[login];
const orderInfo = {
store: group.name,
orderedBy: group.creatorLogin,
amount: isActiveMember(member) ? computeMemberAmount(group, login) : undefined,
};
// První objednávku sloučíme s volbou "Budu objednávat", pokud ji uživatel má
if (!mergedGroup && choiceRow && choice === LunchChoice.OBJEDNAVAM) {
choiceRow.food = join([choiceRow.food, getOrderItemText(member)], ' + ');
Object.assign(choiceRow, orderInfo);
mergedGroup = true;
continue;
}
groupRows.push({
date: isoDate,
dayOfWeek,
type: ORDER_ONLY_LABEL,
food: getOrderItemText(member),
...orderInfo,
});
}
// V rámci dne je první volba oběda, až za ní případné samostatné objednávky
if (choiceRow) {
rows.push(choiceRow);
}
rows.push(...groupRows);
}
return rows.sort((a, b) => a.date.localeCompare(b.date));
}
const MONEY_FORMAT = '#,##0.00 "Kč"';
/** Převede částku v haléřích na koruny pro zápis do XLSX. */
function toCrowns(amount?: number): number | undefined {
return amount == null ? undefined : amount / 100;
}
/** Podporované formáty exportu. */
export const EXPORT_FORMATS = ['xlsx', 'csv', 'json'] as const;
export type ExportFormat = typeof EXPORT_FORMATS[number];
/** MIME typy jednotlivých formátů. */
const MIME_TYPES: Record<ExportFormat, string> = {
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
csv: 'text/csv; charset=utf-8',
json: 'application/json; charset=utf-8',
};
/** Vrátí true, pokud je předaná hodnota podporovaným formátem exportu. */
export function isExportFormat(value: unknown): value is ExportFormat {
return typeof value === 'string' && (EXPORT_FORMATS as readonly string[]).includes(value);
}
/** Hlavičky sloupců přehledu (společné pro XLSX i CSV). */
const COLUMNS: { header: string, key: keyof UserExportRow, width: number }[] = [
{ header: 'Datum', key: 'date', width: 12 },
{ header: 'Den', key: 'dayOfWeek', width: 12 },
{ header: 'Typ', key: 'type', width: 22 },
{ header: 'Vybrané jídlo', key: 'food', width: 45 },
{ header: 'Poznámka', key: 'note', width: 30 },
{ header: 'Objednávka', key: 'store', width: 20 },
{ header: 'Objednával', key: 'orderedBy', width: 16 },
{ header: 'Částka', key: 'amount', width: 14 },
];
/**
* Vygeneruje CSV přehled stravování uživatele.
*
* Formát je uzpůsobený českému Excelu: oddělovač ';', desetinná čárka a UTF-8 BOM,
* aby se soubor otevřel se správnou diakritikou i bez ručního nastavování importu.
*/
export function buildCsv(rows: UserExportRow[]): string {
const SEPARATOR = ';';
const escape = (value: string) => `"${value.replace(/"/g, '""')}"`;
const lines = [COLUMNS.map(column => escape(column.header)).join(SEPARATOR)];
for (const row of rows) {
const values = COLUMNS.map(column => {
if (column.key === 'date') {
const [y, m, d] = row.date.split('-');
return escape(`${d}.${m}.${y}`);
}
if (column.key === 'amount') {
const crowns = toCrowns(row.amount);
// Desetinná čárka — český Excel jinak částku načte jako text
return crowns == null ? '' : escape(crowns.toFixed(2).replace('.', ','));
}
const value = row[column.key];
return value == null ? '' : escape(String(value));
});
lines.push(values.join(SEPARATOR));
}
// BOM kvůli správnému rozpoznání UTF-8 v Excelu
return `${lines.join('\r\n')}\r\n`;
}
/**
* Vygeneruje JSON přehled stravování uživatele.
* Částky jsou v korunách (na rozdíl od interní reprezentace v haléřích).
*/
export function buildJson(login: string, year: number, month: number, rows: UserExportRow[]): UserExport {
return {
login,
year,
month,
rowCount: rows.length,
totalAmount: toCrowns(rows.reduce((sum, row) => sum + (row.amount ?? 0), 0))!,
rows: rows.map(row => ({
date: row.date,
dayOfWeek: row.dayOfWeek,
type: row.type,
...(row.food ? { food: row.food } : {}),
...(row.note ? { note: row.note } : {}),
...(row.store ? { store: row.store } : {}),
...(row.orderedBy ? { orderedBy: row.orderedBy } : {}),
...(row.amount != null ? { amount: toCrowns(row.amount) } : {}),
})),
};
}
/**
* Vygeneruje přehled stravování uživatele za vybraný měsíc ve vyžádaném formátu.
*
* @param login přihlašovací jméno uživatele
* @param year rok
* @param month měsíc (1 = leden)
* @param format formát exportu (výchozí xlsx)
* @returns obsah souboru, jeho MIME typ a název
*/
export async function generateUserExport(login: string, year: number, month: number, format: ExportFormat = 'xlsx'): Promise<{ content: Buffer, mimeType: string, fileName: string }> {
const rows = await getUserExportRows(login, year, month);
const result = {
mimeType: MIME_TYPES[format],
fileName: getUserExportFileName(login, year, month, format),
};
if (format === 'csv') {
return { ...result, content: Buffer.from(buildCsv(rows), 'utf-8') };
}
if (format === 'json') {
return { ...result, content: Buffer.from(JSON.stringify(buildJson(login, year, month, rows), null, 2), 'utf-8') };
}
return { ...result, content: await buildXlsx(login, year, month, rows) };
}
/** Vygeneruje XLSX podobu přehledu. */
async function buildXlsx(login: string, year: number, month: number, rows: UserExportRow[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Luncher';
const sheet = workbook.addWorksheet('Přehled');
sheet.columns = COLUMNS.map(column => ({
...column,
...(column.key === 'date' ? { style: { numFmt: 'dd.mm.yyyy' } } : {}),
...(column.key === 'amount' ? { style: { numFmt: MONEY_FORMAT } } : {}),
}));
sheet.getRow(1).font = { bold: true };
sheet.views = [{ state: 'frozen', ySplit: 1 }];
for (const row of rows) {
const [y, m, d] = row.date.split('-').map(Number);
sheet.addRow({
...row,
// UTC, aby Excel nezobrazil o den dřív vlivem časové zóny
date: new Date(Date.UTC(y, m - 1, d)),
amount: toCrowns(row.amount),
});
}
sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: sheet.columns.length } };
// Souhrn: počty záznamů dle typu a celková zaplacená částka
const summary = workbook.addWorksheet('Souhrn');
summary.columns = [
{ header: 'Typ', key: 'type', width: 24 },
{ header: 'Počet záznamů', key: 'count', width: 15 },
{ header: 'Částka celkem', key: 'amount', width: 16, style: { numFmt: MONEY_FORMAT } },
];
summary.getRow(1).font = { bold: true };
const perType = new Map<string, { count: number, amount: number }>();
for (const row of rows) {
const entry = perType.get(row.type) ?? { count: 0, amount: 0 };
entry.count++;
entry.amount += row.amount ?? 0;
perType.set(row.type, entry);
}
for (const [type, entry] of perType) {
summary.addRow({ type, count: entry.count, amount: toCrowns(entry.amount) });
}
const totalAmount = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const totalRow = summary.addRow({ type: 'Celkem', count: rows.length, amount: toCrowns(totalAmount) });
totalRow.font = { bold: true };
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
/** Vrátí název souboru přehledu pro daného uživatele, měsíc a formát. */
export function getUserExportFileName(login: string, year: number, month: number, format: ExportFormat = 'xlsx'): string {
const safeLogin = login.replace(/[^a-zA-Z0-9._-]/g, '_');
return `luncher-${safeLogin}-${year}-${String(month).padStart(2, '0')}.${format}`;
}