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
392 lines
17 KiB
TypeScript
392 lines
17 KiB
TypeScript
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}`;
|
||
}
|