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
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:
1 parent
065ccaf38a
commit
eeb1630391
13 files changed
+1513
-8
No files matched your search
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user