feat: založení základní stránky příjem/výdej + import a statistiky

This commit is contained in:
Ondřej Anděl committed 2026-09-07 14:58:49 +02:00
commit a5711f3e1e
98 files changed
+15505

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
import ExcelJS from 'exceljs';
import { importLuncherExport } from "../luncherImport";
import { getDay } from "../meals";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
/** Hlavička přehledu tak, jak ji generuje Luncher (server/src/userExport.ts). */
const HEADERS = ['Datum', 'Den', 'Typ', 'Vybrané jídlo', 'Poznámka', 'Objednávka', 'Objednával', 'Částka'];
/** Řádky používané napříč testy — pokrývají volbu podniku, pizzu i objednávku. */
const ROWS = [
{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'Sladovnická', food: 'Svíčková na smetaně', note: '', store: '', orderedBy: '', amount: 159 },
{ date: '2025-03-04', dayOfWeek: 'úterý', type: 'Pizza day', food: 'Margherita (32cm)', note: 'bez oregana', store: '', orderedBy: '', amount: 215.5 },
{ date: '2025-03-05', dayOfWeek: 'středa', type: 'Budu objednávat', food: 'Kuřecí burger', note: '', store: 'Bolt Food', orderedBy: 'novak', amount: 249 },
];
/** Sestaví CSV přesně tak, jak ho Luncher exportuje — BOM, ';' a desetinná čárka. */
function buildLuncherCsv(): Buffer {
const escape = (value: string) => `"${value.replace(/"/g, '""')}"`;
const lines = [HEADERS.map(escape).join(';')];
for (const row of ROWS) {
const [y, m, d] = row.date.split('-');
lines.push([
escape(`${d}.${m}.${y}`),
escape(row.dayOfWeek),
escape(row.type),
escape(row.food),
escape(row.note),
escape(row.store),
escape(row.orderedBy),
escape(row.amount.toFixed(2).replace('.', ',')),
].join(';'));
}
return Buffer.from(`${lines.join('\r\n')}\r\n`, 'utf-8');
}
/** Sestaví JSON přehled ve schématu UserExport. */
function buildLuncherJson(): Buffer {
return Buffer.from(JSON.stringify({
login: 'tester',
year: 2025,
month: 3,
rowCount: ROWS.length,
totalAmount: ROWS.reduce((sum, row) => sum + row.amount, 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 } : {}),
amount: row.amount,
})),
}), 'utf-8');
}
/** Sestaví XLSX se dvěma listy — detailem a souhrnem — jako skutečný export. */
async function buildLuncherXlsx(): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Přehled');
sheet.addRow(HEADERS);
for (const row of ROWS) {
const [y, m, d] = row.date.split('-').map(Number);
sheet.addRow([
new Date(Date.UTC(y, m - 1, d)),
row.dayOfWeek,
row.type,
row.food,
row.note,
row.store,
row.orderedBy,
row.amount,
]);
}
// Souhrnný list má vlastní sloupce a import ho musí ignorovat
const summary = workbook.addWorksheet('Souhrn');
summary.addRow(['Typ', 'Počet záznamů', 'Částka celkem']);
summary.addRow(['Celkem', ROWS.length, ROWS.reduce((sum, row) => sum + row.amount, 0)]);
return Buffer.from(await workbook.xlsx.writeBuffer());
}
beforeEach(() => resetMemoryStorage());
describe('import přehledu z Luncheru', () => {
it.each([
['CSV', 'luncher-tester-2025-03.csv', () => buildLuncherCsv()],
['JSON', 'luncher-tester-2025-03.json', () => buildLuncherJson()],
['XLSX', 'luncher-tester-2025-03.xlsx', () => buildLuncherXlsx()],
])('%s naimportuje všechny řádky do jejich dnů', async (_label, fileName, build) => {
const result = await importLuncherExport(LOGIN, fileName, await build());
expect(result.warnings).toEqual([]);
expect(result.rowCount).toBe(3);
expect(result.imported).toBe(3);
expect(result.skipped).toBe(0);
expect(result.days).toEqual(['2025-03-03', '2025-03-04', '2025-03-05']);
// 159 + 215,50 + 249 Kč v haléřích
expect(result.totalPrice).toBe(62350);
});
it('založí importované řádky jako oběd, pokud se neuvede jinak', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const day = await getDay(LOGIN, '2025-03-03');
expect(day.entries).toHaveLength(1);
expect(day.entries[0].mealType).toBe(MealType.OBED);
});
it('respektuje zvolený typ jídla', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), MealType.VECERE);
const day = await getDay(LOGIN, '2025-03-03');
expect(day.entries[0].mealType).toBe(MealType.VECERE);
});
it('mapuje jídlo, zdroj i cenu na záznam', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const [entry] = (await getDay(LOGIN, '2025-03-03')).entries;
expect(entry.name).toBe('Svíčková na smetaně');
expect(entry.source).toBe('Sladovnická');
expect(entry.price).toBe(15900);
expect(entry.importSource).toBe('luncher');
});
it('u objednávky bere jako zdroj obchod a objednávajícího přidá do poznámky', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const [entry] = (await getDay(LOGIN, '2025-03-05')).entries;
expect(entry.name).toBe('Kuřecí burger');
expect(entry.source).toBe('Bolt Food');
expect(entry.note).toBe('objednal(a): novak');
});
it('u volby bez jídla i poznámky použije jako název podnik', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{ date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'TechTower', amount: 145 }],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-06')).entries;
expect(entry.name).toBe('TechTower');
expect(entry.source).toBe('TechTower');
});
it('u "Budu objednávat" bez jídla vezme jako název poznámku', async () => {
// Skutečný tvar řádku z exportu: jídlo prázdné, obsah stojí v poznámce
const json = Buffer.from(JSON.stringify({
rows: [{
date: '2025-03-06', dayOfWeek: 'čtvrtek',
type: 'Budu objednávat', note: 'Chefie - Těstovinový salát',
}],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-06')).entries;
expect(entry.name).toBe('Chefie - Těstovinový salát');
// Poznámka se stala názvem, nemá se opakovat
expect(entry.note).toBeUndefined();
// "Budu objednávat" není podnik, jako zdroj nedává smysl
expect(entry.source).toBeUndefined();
});
it('u "Rozhoduji se" vezme jako název poznámku a nenastaví zdroj', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{ date: '2025-03-07', dayOfWeek: 'pátek', type: 'Rozhoduji se', note: 'asi pudu tower' }],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-07')).entries;
expect(entry.name).toBe('asi pudu tower');
expect(entry.source).toBeUndefined();
});
it('když je vyplněné jídlo i poznámka, poznámka zůstane poznámkou', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{
date: '2025-03-08', dayOfWeek: 'sobota', type: 'Budu objednávat',
food: 'Chicken tikka massala', note: 'EVEREST',
store: 'Everest', orderedBy: 'Anděl Ondřej', amount: 215.54,
}],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-08')).entries;
expect(entry.name).toBe('Chicken tikka massala');
expect(entry.note).toBe('EVEREST | objednal(a): Anděl Ondřej');
expect(entry.source).toBe('Everest');
expect(entry.price).toBe(21554);
});
it('řádek bez částky uloží bez ceny', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{ date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'SPŠE', food: 'Guláš' }],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-06')).entries;
expect(entry.price).toBeUndefined();
expect((await getDay(LOGIN, '2025-03-06')).totalPrice).toBe(0);
});
it('opakovaný import stejného měsíce data nezduplikuje', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const second = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
expect(second.imported).toBe(0);
expect(second.skipped).toBe(3);
expect((await getDay(LOGIN, '2025-03-03')).entries).toHaveLength(1);
});
it('naimportuje i dva shodné záznamy v jednom dni', async () => {
const row = { date: '2025-03-07', dayOfWeek: 'pátek', type: 'Objednávka', food: 'Kebab', amount: 120 };
const json = Buffer.from(JSON.stringify({ rows: [row, row] }), 'utf-8');
const first = await importLuncherExport(LOGIN, 'export.json', json);
expect(first.imported).toBe(2);
// Ani při opakování se nesmí jeden z nich považovat za duplicitu toho druhého
const second = await importLuncherExport(LOGIN, 'export.json', json);
expect(second.imported).toBe(0);
expect(second.skipped).toBe(2);
expect((await getDay(LOGIN, '2025-03-07')).entries).toHaveLength(2);
});
it('dryRun vrátí náhled, ale nic neuloží', async () => {
const result = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), undefined, true);
expect(result.dryRun).toBe(true);
expect(result.imported).toBe(3);
expect(result.entries).toHaveLength(3);
expect((await getDay(LOGIN, '2025-03-03')).entries).toHaveLength(0);
});
it('poradí si s poznámkou obsahující oddělovač a uvozovky', async () => {
const csv = Buffer.from(
`"Datum";"Typ";"Vybrané jídlo";"Poznámka";"Částka"\r\n`
+ `"03.03.2025";"Sladovnická";"Svíčková";"bez knedlíku; navíc ""extra"" omáčka";"159,00"\r\n`,
'utf-8');
await importLuncherExport(LOGIN, 'export.csv', csv);
const [entry] = (await getDay(LOGIN, '2025-03-03')).entries;
expect(entry.note).toBe('bez knedlíku; navíc "extra" omáčka');
});
it('upozorní na řádek s nečitelným datem a ostatní naimportuje', async () => {
const json = Buffer.from(JSON.stringify({
rows: [
{ date: 'nesmysl', type: 'Sladovnická', food: 'Svíčková' },
{ date: '2025-03-03', type: 'Sladovnická', food: 'Guláš' },
],
}), 'utf-8');
const result = await importLuncherExport(LOGIN, 'export.json', json);
expect(result.imported).toBe(1);
expect(result.warnings).toHaveLength(1);
});
it('odmítne nepodporovaný formát souboru', async () => {
await expect(importLuncherExport(LOGIN, 'export.pdf', Buffer.from('cokoliv')))
.rejects.toThrow(/Nepodporovaný formát/);
});
it('odmítne soubor bez očekávaných sloupců', async () => {
const csv = Buffer.from('"Sloupec A";"Sloupec B"\r\n"1";"2"\r\n', 'utf-8');
await expect(importLuncherExport(LOGIN, 'export.csv', csv))
.rejects.toThrow(/nepodařilo najít očekávané sloupce/);
});
});