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 = { 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; } /** Vrátí true, pokud má členství ve skupině do přehledu co přinést (položku nebo poznámku). */ function isRelevantMember(member: OrderGroupMember | undefined): boolean { return !!member && (isActiveMember(member) || !!member.note?.trim().length); } /** * 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 { const rows: UserExportRow[] = []; const lastDayOfMonth = new Date(year, month, 0).getDate(); const today = getToday(); today.setHours(23, 59, 59, 999); const weekMenuCache = new Map(); 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(isoDate); const extraData = await storage.getData(`${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(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 si uživatel něco objednal nebo k nim má poznámku. // Zakladatel skupiny, který si z ní nic nevzal, v ní figuruje jen formálně — řádek by byl prázdný. const groups = (extraData?.groups ?? []).filter(group => isRelevantMember(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 = { 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 { 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(); 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}`; }