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 committed 2026-08-12 13:53:36 +02:00
1 parent 065ccaf38a
commit eeb1630391
13 files changed
+1513 -8

No files matched your search

+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}`;
}