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:
@@ -2,10 +2,34 @@ import express, { Request, Response } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getStats } from "../stats";
|
||||
import { EXPORT_FORMATS, generateUserExport, isExportFormat } from "../userExport";
|
||||
import { WeeklyStats } from "../../../types/gen/types.gen";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Vrátí přehled stravování přihlášeného uživatele za vybraný měsíc (XLSX, CSV nebo JSON).
|
||||
*/
|
||||
router.get("/export", async (req: Request<{}, any, undefined>, res: Response) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const year = Number(req.query.year);
|
||||
const month = Number(req.query.month);
|
||||
const format = req.query.format ?? 'xlsx';
|
||||
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
|
||||
return res.status(400).json({ error: "Neplatný rok" });
|
||||
}
|
||||
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
||||
return res.status(400).json({ error: "Neplatný měsíc" });
|
||||
}
|
||||
if (!isExportFormat(format)) {
|
||||
return res.status(400).json({ error: `Neplatný formát exportu, podporované jsou: ${EXPORT_FORMATS.join(', ')}` });
|
||||
}
|
||||
const { content, mimeType, fileName } = await generateUserExport(login, year, month, format);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
return res.status(200).send(content);
|
||||
});
|
||||
|
||||
router.get("/", async (req: Request<{}, any, undefined>, res: Response<WeeklyStats>) => {
|
||||
getLogin(parseToken(req));
|
||||
if (typeof req.query.startDate === 'string' && typeof req.query.endDate === 'string') {
|
||||
|
||||
@@ -52,6 +52,64 @@ test('GET /stats s budoucím datem vrátí 400', async () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /stats/export vrátí XLSX soubor', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 3 })
|
||||
.responseType('blob')
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('spreadsheetml');
|
||||
expect(res.headers['content-disposition']).toContain('luncher-testuser-2025-03.xlsx');
|
||||
// ZIP signatura XLSX souboru
|
||||
expect(res.body.slice(0, 2).toString()).toBe('PK');
|
||||
});
|
||||
|
||||
test('GET /stats/export?format=csv vrátí CSV', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 3, format: 'csv' })
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/csv');
|
||||
expect(res.headers['content-disposition']).toContain('luncher-testuser-2025-03.csv');
|
||||
expect(res.text).toContain('"Datum";"Den";"Typ"');
|
||||
});
|
||||
|
||||
test('GET /stats/export?format=json vrátí JSON s metadaty', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 3, format: 'json' })
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
expect(res.headers['content-disposition']).toContain('luncher-testuser-2025-03.json');
|
||||
expect(res.body).toMatchObject({ login: 'testuser', year: 2025, month: 3, rowCount: 0, rows: [] });
|
||||
});
|
||||
|
||||
test('GET /stats/export s neznámým formátem vrátí 400', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 3, format: 'pdf' })
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /stats/export s neplatným měsícem vrátí 400', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 13 })
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /stats/export bez parametrů vrátí 400', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /stats bez tokenu vrátí chybu', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats')
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import getStorage from '../storage';
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import { getUserExportRows, computeMemberAmount, getUserExportFileName, buildCsv, buildJson, generateUserExport, isExportFormat, UserExportRow } from '../userExport';
|
||||
import { ClientData, GroupState, LunchChoice, OrderGroup, PizzaDayState, WeekMenu } from '../../../types/gen/types.gen';
|
||||
|
||||
const storage = getStorage();
|
||||
const USER = 'petr';
|
||||
const OTHER = 'tomas';
|
||||
|
||||
// 2025-03-03 je pondělí, 2025-03-04 úterý, ...
|
||||
const YEAR = 2025;
|
||||
const MONTH = 3;
|
||||
|
||||
/** Uloží denní data pro slot oběd. */
|
||||
async function saveLunch(isoDate: string, choices: ClientData["choices"], pizzaDay?: ClientData["pizzaDay"]) {
|
||||
await storage.setData<Partial<ClientData>>(isoDate, { choices, pizzaDay });
|
||||
}
|
||||
|
||||
/** Uloží data slotu extra s předanými skupinami. */
|
||||
async function saveExtra(isoDate: string, groups: OrderGroup[]) {
|
||||
await storage.setData<Partial<ClientData>>(`${isoDate}_extra`, { choices: {}, groups });
|
||||
}
|
||||
|
||||
/** Uloží týdenní menu tak, aby daný den obsahoval předaná jídla. */
|
||||
async function saveWeekMenu(menuKey: string, dayIndex: number, foodNames: string[]) {
|
||||
const week: WeekMenu = [{}, {}, {}, {}, {}] as WeekMenu;
|
||||
week[dayIndex] = {
|
||||
TECHTOWER: {
|
||||
lastUpdate: 0,
|
||||
closed: false,
|
||||
food: foodNames.map(name => ({ name, isSoup: false })),
|
||||
},
|
||||
};
|
||||
await storage.setData(menuKey, week);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetMemoryStorage();
|
||||
});
|
||||
|
||||
describe('getUserExportRows', () => {
|
||||
test('vynechá dny bez záznamu i volbu "mám vlastní/neobědvám"', async () => {
|
||||
await saveLunch('2025-03-03', { NEOBEDVAM: { [USER]: {} } });
|
||||
await saveLunch('2025-03-04', { SPSE: { [USER]: {} } });
|
||||
// den, kde má volbu jen jiný uživatel
|
||||
await saveLunch('2025-03-05', { SPSE: { [OTHER]: {} } });
|
||||
|
||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ date: '2025-03-04', type: 'SPŠE' });
|
||||
});
|
||||
|
||||
test('vyplní vybrané jídlo z uloženého menu i poznámku', async () => {
|
||||
await saveWeekMenu('menu_2025_10', 0, ['Polévka', 'Svíčková', 'Guláš']);
|
||||
await saveLunch('2025-03-03', {
|
||||
TECHTOWER: { [USER]: { selectedFoods: [1, 2], note: 'bez knedlíku' } },
|
||||
});
|
||||
|
||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
date: '2025-03-03',
|
||||
dayOfWeek: 'pondělí',
|
||||
type: 'TechTower',
|
||||
food: 'Svíčková + Guláš',
|
||||
note: 'bez knedlíku',
|
||||
});
|
||||
});
|
||||
|
||||
test('u volby "budu objednávat" sloučí záznam s objednávkovou skupinou včetně ceny po slevě', async () => {
|
||||
await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } });
|
||||
await saveExtra('2025-03-06', [{
|
||||
id: 'g1',
|
||||
name: 'KFC',
|
||||
creatorLogin: OTHER,
|
||||
state: GroupState.ORDERED,
|
||||
orderedAt: '11:30',
|
||||
shipping: 4000,
|
||||
discountType: 'percent',
|
||||
discountValue: 10,
|
||||
members: {
|
||||
[USER]: { amount: 20000, note: 'Twister menu', paid: true },
|
||||
[OTHER]: { amount: 20000 },
|
||||
},
|
||||
}]);
|
||||
|
||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
date: '2025-03-06',
|
||||
type: 'Budu objednávat',
|
||||
food: 'Twister menu',
|
||||
note: 'platím kartou',
|
||||
store: 'KFC',
|
||||
orderedBy: OTHER,
|
||||
// 200 Kč + 20 Kč doprava (poloviny ze 40) - 20 Kč sleva (10 %)
|
||||
amount: 20000 + 2000 - 2000,
|
||||
});
|
||||
});
|
||||
|
||||
test('objednávku bez volby oběda vypíše jako samostatný záznam', async () => {
|
||||
await saveExtra('2025-03-07', [{
|
||||
id: 'g2',
|
||||
name: 'Bageterie',
|
||||
creatorLogin: USER,
|
||||
state: GroupState.OPEN,
|
||||
members: { [USER]: { amount: 15000, surchargeText: 'sýr', surchargeAmount: 2000 } },
|
||||
}]);
|
||||
|
||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
date: '2025-03-07',
|
||||
type: 'Objednávka',
|
||||
store: 'Bageterie',
|
||||
orderedBy: USER,
|
||||
food: 'příplatek: sýr',
|
||||
amount: 17000,
|
||||
});
|
||||
});
|
||||
|
||||
test('u Pizza day vypíše objednané pizzy a celkovou cenu', async () => {
|
||||
await saveLunch('2025-03-10', { PIZZA: { [USER]: {} } }, {
|
||||
state: PizzaDayState.DELIVERED,
|
||||
creator: OTHER,
|
||||
orders: [{
|
||||
customer: USER,
|
||||
pizzaList: [{ varId: 1, name: 'Margherita', size: '32cm', price: 18000 }],
|
||||
fee: { text: 'kuřecí maso', price: 3000 },
|
||||
totalPrice: 21000,
|
||||
note: 'bez oliv',
|
||||
hasQr: true,
|
||||
}],
|
||||
} as ClientData["pizzaDay"]);
|
||||
|
||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: 'Pizza day',
|
||||
food: 'Margherita (32cm) + příplatek: kuřecí maso',
|
||||
note: 'bez oliv',
|
||||
amount: 21000,
|
||||
});
|
||||
});
|
||||
|
||||
test('více skupin v jeden den vypíše jako samostatné řádky', async () => {
|
||||
await saveLunch('2025-03-11', { OBJEDNAVAM: { [USER]: {} } });
|
||||
await saveExtra('2025-03-11', [
|
||||
{ id: 'a', name: 'KFC', creatorLogin: USER, state: GroupState.OPEN, members: { [USER]: { amount: 10000 } } },
|
||||
{ id: 'b', name: 'Bageterie', creatorLogin: USER, state: GroupState.OPEN, members: { [USER]: { amount: 5000 } } },
|
||||
]);
|
||||
|
||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||
expect(rows).toHaveLength(2);
|
||||
// V rámci dne je nejdřív volba oběda (se sloučenou první skupinou), pak zbytek objednávek
|
||||
expect(rows.map(r => r.type)).toEqual(['Budu objednávat', 'Objednávka']);
|
||||
expect(rows.map(r => r.store)).toEqual(['KFC', 'Bageterie']);
|
||||
});
|
||||
|
||||
test('nezahrne data mimo vybraný měsíc', async () => {
|
||||
await saveLunch('2025-02-28', { SPSE: { [USER]: {} } });
|
||||
await saveLunch('2025-03-03', { SPSE: { [USER]: {} } });
|
||||
await saveLunch('2025-04-01', { SPSE: { [USER]: {} } });
|
||||
|
||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||
expect(rows.map(r => r.date)).toEqual(['2025-03-03']);
|
||||
});
|
||||
});
|
||||
|
||||
test('člen skupiny, který si nic neobjednal, nemá vyplněnou částku', async () => {
|
||||
await saveExtra('2025-03-12', [{
|
||||
id: 'g3',
|
||||
name: 'KFC',
|
||||
creatorLogin: USER,
|
||||
state: GroupState.ORDERED,
|
||||
members: { [USER]: {}, [OTHER]: { amount: 15000 } },
|
||||
}]);
|
||||
|
||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||
expect(rows).toHaveLength(1);
|
||||
// Objednávající je uveden, ale částka zůstává prázdná — není co platit
|
||||
expect(rows[0].orderedBy).toBe(USER);
|
||||
expect(rows[0].amount).toBeUndefined();
|
||||
expect(buildCsv(rows).split('\r\n')[1].split(';')[7]).toBe('');
|
||||
expect(buildJson(USER, YEAR, MONTH, rows).rows[0].amount).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('computeMemberAmount', () => {
|
||||
const group = (overrides: Partial<OrderGroup>): OrderGroup => ({
|
||||
id: 'g',
|
||||
name: 'KFC',
|
||||
creatorLogin: OTHER,
|
||||
state: GroupState.OPEN,
|
||||
members: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('neaktivní člen (nic si neobjednal) platí nula', () => {
|
||||
const g = group({ shipping: 4000, members: { [USER]: {}, [OTHER]: { amount: 10000 } } });
|
||||
expect(computeMemberAmount(g, USER)).toBe(0);
|
||||
});
|
||||
|
||||
test('pevná sleva se dělí mezi aktivní členy', () => {
|
||||
const g = group({
|
||||
discountType: 'fixed',
|
||||
discountValue: 5000,
|
||||
members: { [USER]: { amount: 10000 }, [OTHER]: { amount: 10000 } },
|
||||
});
|
||||
expect(computeMemberAmount(g, USER)).toBe(10000 - 2500);
|
||||
});
|
||||
|
||||
test('poplatky se rozpočítají jen mezi aktivní členy', () => {
|
||||
const g = group({
|
||||
fees: 1000,
|
||||
shipping: 5000,
|
||||
tip: 0,
|
||||
members: { [USER]: { amount: 10000 }, [OTHER]: {} },
|
||||
});
|
||||
expect(computeMemberAmount(g, USER)).toBe(16000);
|
||||
});
|
||||
});
|
||||
|
||||
test('getUserExportFileName očistí login a doplní měsíc s příponou dle formátu', () => {
|
||||
expect(getUserExportFileName('domena\\petr', 2025, 3)).toBe('luncher-domena_petr-2025-03.xlsx');
|
||||
expect(getUserExportFileName('petr', 2025, 3, 'csv')).toBe('luncher-petr-2025-03.csv');
|
||||
expect(getUserExportFileName('petr', 2025, 12, 'json')).toBe('luncher-petr-2025-12.json');
|
||||
});
|
||||
|
||||
test('isExportFormat propustí jen podporované formáty', () => {
|
||||
expect(isExportFormat('xlsx')).toBe(true);
|
||||
expect(isExportFormat('csv')).toBe(true);
|
||||
expect(isExportFormat('json')).toBe(true);
|
||||
expect(isExportFormat('pdf')).toBe(false);
|
||||
expect(isExportFormat(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
describe('buildCsv', () => {
|
||||
const rows: UserExportRow[] = [
|
||||
{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'TechTower', food: 'Svíčková', note: 'bez knedlíku' },
|
||||
{
|
||||
date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'Budu objednávat', food: 'Twister; s "extra" sýrem',
|
||||
store: 'KFC', orderedBy: 'tomas', amount: 20000,
|
||||
},
|
||||
];
|
||||
|
||||
test('začíná BOM a hlavičkou oddělenou středníkem', () => {
|
||||
const csv = buildCsv(rows);
|
||||
expect(csv.charCodeAt(0)).toBe(0xFEFF);
|
||||
expect(csv.slice(1).split('\r\n')[0]).toBe('"Datum";"Den";"Typ";"Vybrané jídlo";"Poznámka";"Objednávka";"Objednával";"Částka"');
|
||||
});
|
||||
|
||||
test('formátuje datum, částku s desetinnou čárkou a escapuje uvozovky i středníky', () => {
|
||||
const lines = buildCsv(rows).split('\r\n');
|
||||
expect(lines[1]).toBe('"03.03.2025";"pondělí";"TechTower";"Svíčková";"bez knedlíku";;;');
|
||||
expect(lines[2]).toBe('"06.03.2025";"čtvrtek";"Budu objednávat";"Twister; s ""extra"" sýrem";;"KFC";"tomas";"200,00"');
|
||||
});
|
||||
|
||||
test('u záznamu bez objednávky nechá sloupce objednávky prázdné', () => {
|
||||
const csv = buildCsv([{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'Pizza day', amount: 21000 }]);
|
||||
const cells = csv.split('\r\n')[1].split(';');
|
||||
expect(cells[5]).toBe('');
|
||||
expect(cells[6]).toBe('');
|
||||
expect(cells[7]).toBe('"210,00"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildJson', () => {
|
||||
test('obsahuje metadata, součet v korunách a jen vyplněná pole', () => {
|
||||
const rows: UserExportRow[] = [
|
||||
{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'SPŠE' },
|
||||
{ date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'Objednávka', store: 'KFC', orderedBy: 'tomas', amount: 22000 },
|
||||
];
|
||||
const json = buildJson('petr', 2025, 3, rows);
|
||||
expect(json).toMatchObject({ login: 'petr', year: 2025, month: 3, rowCount: 2, totalAmount: 220 });
|
||||
expect(json.rows[0]).toEqual({ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'SPŠE' });
|
||||
expect(json.rows[1]).toEqual({
|
||||
date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'Objednávka',
|
||||
store: 'KFC', orderedBy: 'tomas', amount: 220,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateUserExport', () => {
|
||||
beforeEach(async () => {
|
||||
await storage.setData('2025-03-03', { choices: { SPSE: { [USER]: { note: 'polévka' } } } });
|
||||
});
|
||||
|
||||
test('xlsx vrátí ZIP obsah se správným MIME a názvem', async () => {
|
||||
const result = await generateUserExport(USER, YEAR, MONTH);
|
||||
expect(result.content.subarray(0, 2).toString()).toBe('PK');
|
||||
expect(result.mimeType).toContain('spreadsheetml');
|
||||
expect(result.fileName).toBe('luncher-petr-2025-03.xlsx');
|
||||
});
|
||||
|
||||
test('csv vrátí textový obsah s daty uživatele', async () => {
|
||||
const result = await generateUserExport(USER, YEAR, MONTH, 'csv');
|
||||
expect(result.mimeType).toBe('text/csv; charset=utf-8');
|
||||
expect(result.fileName).toBe('luncher-petr-2025-03.csv');
|
||||
expect(result.content.toString('utf-8')).toContain('"03.03.2025";"pondělí";"SPŠE"');
|
||||
});
|
||||
|
||||
test('json vrátí parsovatelný obsah s metadaty', async () => {
|
||||
const result = await generateUserExport(USER, YEAR, MONTH, 'json');
|
||||
expect(result.mimeType).toBe('application/json; charset=utf-8');
|
||||
expect(result.fileName).toBe('luncher-petr-2025-03.json');
|
||||
const parsed = JSON.parse(result.content.toString('utf-8'));
|
||||
expect(parsed).toMatchObject({ login: USER, year: YEAR, month: MONTH, rowCount: 1 });
|
||||
expect(parsed.rows[0]).toMatchObject({ type: 'SPŠE', note: 'polévka' });
|
||||
});
|
||||
});
|
||||
@@ -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