Files
Luncher/server/src/userExport.ts
T
Ondřej Anděl 8a7dbd94c8
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 31s
CI / Build client (push) Successful in 43s
CI / Playwright E2E tests (push) Successful in 1m58s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 1s
fix: export nedává "prázdné" záznamy
2026-10-14 13:35:25 +02:00

392 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
/** 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<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 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<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}`;
}