feat: založení základní stránky příjem/výdej + import a statistiky
This commit is contained in:
commit
a5711f3e1e
98 files changed
+15505
No files matched your search
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user