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

+434
View File
@@ -0,0 +1,434 @@
import ExcelJS from 'exceljs';
import { randomUUID } from 'crypto';
import { ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
import { addEntries, getExistingImportKeys, isMealType } from "./meals";
import { BadRequestError, isValidIsoDate } from "./utils";
/** Označení zdroje, kterým se importované záznamy značí. */
export const IMPORT_SOURCE = 'luncher';
/** Podporované formáty importu (shodné s formáty exportu Luncheru). */
export const IMPORT_FORMATS = ['xlsx', 'csv', 'json'] as const;
export type ImportFormat = typeof IMPORT_FORMATS[number];
/** Název listu s jednotlivými záznamy v XLSX exportu. Druhý list ("Souhrn") se ignoruje. */
const DETAIL_SHEET_NAME = 'Přehled';
/** Oddělovač polí v CSV exportu — Luncher generuje CSV pro český Excel. */
const CSV_SEPARATOR = ';';
/**
* Mapování hlaviček sloupců exportu na klíče. Sloupce se hledají podle názvu,
* ne podle pozice, aby import přežil případné přeuspořádání exportu.
*/
const COLUMN_HEADERS: Record<string, keyof LuncherRow> = {
'datum': 'date',
'den': 'dayOfWeek',
'typ': 'type',
'vybrané jídlo': 'food',
'poznámka': 'note',
'objednávka': 'store',
'objednával': 'orderedBy',
'částka': 'amount',
};
/**
* Typy záznamů, které nejsou podnikem — jde o stav volby, ne o místo, kde se jedlo.
* Odpovídají volbám OBJEDNAVAM / ROZHODUJI / NEOBEDVAM a objednávce bez volby
* v exportu Luncheru. Jako zdroj jídla by nedávaly smysl, tak se nepoužijí.
*/
const NON_PLACE_TYPES = new Set([
'Budu objednávat',
'Rozhoduji se',
'Mám vlastní/neobědvám',
'Objednávka',
]);
/** Jeden řádek přehledu z Luncheru, převedený do jednotné podoby. */
type LuncherRow = {
/** Datum ve formátu YYYY-MM-DD */
date: string;
dayOfWeek?: string;
/** Typ záznamu — název podniku/volby, nebo "Objednávka" */
type: string;
food?: string;
note?: string;
store?: string;
orderedBy?: string;
/** Částka v haléřích */
amount?: number;
};
/** Výsledek zpracování souboru — řádky plus upozornění na to, co se nepovedlo. */
type ParsedFile = { rows: LuncherRow[], warnings: string[] };
/**
* Rozpozná formát souboru podle přípony.
*
* @param fileName název souboru
*/
export function detectFormat(fileName: string): ImportFormat {
const extension = fileName.toLowerCase().split('.').pop();
if (extension && (IMPORT_FORMATS as readonly string[]).includes(extension)) {
return extension as ImportFormat;
}
throw new BadRequestError(`Nepodporovaný formát souboru '${fileName}'. Podporované jsou .xlsx, .csv a .json.`);
}
/** Ořízne text a prázdnou hodnotu vrátí jako undefined. */
function text(value: unknown): string | undefined {
if (value == null) return undefined;
const trimmed = String(value).trim();
return trimmed.length ? trimmed : undefined;
}
/**
* Převede částku v korunách na haléře.
* Přijímá i český zápis s desetinnou čárkou a mezerami mezi řády ("1 234,50 Kč").
*/
function parseAmount(value: unknown): number | undefined {
if (value == null || value === '') return undefined;
if (typeof value === 'number') {
return Number.isFinite(value) ? Math.round(value * 100) : undefined;
}
const cleaned = String(value)
.replace(/\s| /g, '') // mezery i nezlomitelné mezery mezi řády
.replace(/Kč/gi, '')
.replace(',', '.');
if (!cleaned.length) return undefined;
const parsed = Number(cleaned);
return Number.isFinite(parsed) ? Math.round(parsed * 100) : undefined;
}
/**
* Převede datum z exportu na ISO formát YYYY-MM-DD.
* Zvládne ISO řetězec (JSON export), český zápis DD.MM.YYYY (CSV) i Date (XLSX).
*/
function parseDate(value: unknown): string | undefined {
if (value == null) return undefined;
if (value instanceof Date) {
// XLSX ukládá datum v UTC (viz export Luncheru), proto se čte v UTC
const year = value.getUTCFullYear();
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
const day = String(value.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
const raw = String(value).trim();
if (isValidIsoDate(raw)) {
return raw;
}
const czech = /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})$/.exec(raw);
if (czech) {
const [, day, month, year] = czech;
const iso = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
return isValidIsoDate(iso) ? iso : undefined;
}
return undefined;
}
/**
* Rozdělí CSV na buňky. Respektuje uvozovky i zdvojené uvozovky uvnitř hodnoty,
* takže si poradí i s poznámkou obsahující oddělovač nebo konec řádku.
*/
function parseCsv(content: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let inQuotes = false;
for (let index = 0; index < content.length; index++) {
const char = content[index];
if (inQuotes) {
if (char === '"') {
if (content[index + 1] === '"') {
cell += '"';
index++;
} else {
inQuotes = false;
}
} else {
cell += char;
}
continue;
}
if (char === '"') {
inQuotes = true;
} else if (char === CSV_SEPARATOR) {
row.push(cell);
cell = '';
} else if (char === '\n') {
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else if (char !== '\r') {
cell += char;
}
}
if (cell.length || row.length) {
row.push(cell);
rows.push(row);
}
return rows.filter(current => current.some(value => value.trim().length));
}
/**
* Sestaví z hlavičkového řádku mapu index sloupce -> klíč řádku.
* Nerozpoznané sloupce se ignorují.
*/
function mapHeaders(headers: unknown[]): Map<number, keyof LuncherRow> {
const mapping = new Map<number, keyof LuncherRow>();
headers.forEach((header, index) => {
const key = COLUMN_HEADERS[String(header ?? '').trim().toLowerCase()];
if (key) {
mapping.set(index, key);
}
});
return mapping;
}
/** Poskládá řádek přehledu z buněk dle mapování sloupců. */
function buildRow(cells: unknown[], mapping: Map<number, keyof LuncherRow>): LuncherRow | undefined {
const raw: Record<string, unknown> = {};
for (const [index, key] of mapping) {
raw[key] = cells[index];
}
const date = parseDate(raw.date);
if (!date) return undefined;
return {
date,
dayOfWeek: text(raw.dayOfWeek),
type: text(raw.type) ?? '',
food: text(raw.food),
note: text(raw.note),
store: text(raw.store),
orderedBy: text(raw.orderedBy),
amount: parseAmount(raw.amount),
};
}
/** Zpracuje CSV podobu exportu. */
function parseCsvExport(content: Buffer): ParsedFile {
// Luncher píše CSV s BOM kvůli Excelu, JS ho musí odstranit ručně
const csv = content.toString('utf-8').replace(/^/, '');
const table = parseCsv(csv);
if (!table.length) {
throw new BadRequestError("CSV soubor je prázdný");
}
const mapping = mapHeaders(table[0]);
if (!mapping.size) {
throw new BadRequestError("V CSV se nepodařilo najít očekávané sloupce přehledu z Luncheru");
}
const rows: LuncherRow[] = [];
const warnings: string[] = [];
table.slice(1).forEach((cells, index) => {
const row = buildRow(cells, mapping);
if (row) {
rows.push(row);
} else {
warnings.push(`Řádek ${index + 2} přeskočen — nepodařilo se přečíst datum`);
}
});
return { rows, warnings };
}
/** Zpracuje XLSX podobu exportu. */
async function parseXlsxExport(content: Buffer): Promise<ParsedFile> {
const workbook = new ExcelJS.Workbook();
try {
await workbook.xlsx.load(content as unknown as ArrayBuffer);
} catch {
throw new BadRequestError("Soubor se nepodařilo přečíst jako XLSX");
}
const sheet = workbook.getWorksheet(DETAIL_SHEET_NAME) ?? workbook.worksheets[0];
if (!sheet) {
throw new BadRequestError("XLSX soubor neobsahuje žádný list");
}
/** Vrátí hodnoty buněk řádku v poli indexovaném od nuly. */
const cellsOf = (rowNumber: number): unknown[] => {
const row = sheet.getRow(rowNumber);
const values: unknown[] = [];
for (let column = 1; column <= sheet.columnCount; column++) {
const cell = row.getCell(column);
// Vzorce vracejí objekt { formula, result } — zajímá nás spočtená hodnota
const value = cell.value;
values.push(value && typeof value === 'object' && 'result' in value ? value.result : value);
}
return values;
};
const mapping = mapHeaders(cellsOf(1));
if (!mapping.size) {
throw new BadRequestError("V XLSX se nepodařilo najít očekávané sloupce přehledu z Luncheru");
}
const rows: LuncherRow[] = [];
const warnings: string[] = [];
for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber++) {
const cells = cellsOf(rowNumber);
if (cells.every(value => value == null || String(value).trim() === '')) continue;
const row = buildRow(cells, mapping);
if (row) {
rows.push(row);
} else {
warnings.push(`Řádek ${rowNumber} přeskočen — nepodařilo se přečíst datum`);
}
}
return { rows, warnings };
}
/** Zpracuje JSON podobu exportu (schéma UserExport z Luncheru). */
function parseJsonExport(content: Buffer): ParsedFile {
let parsed: any;
try {
parsed = JSON.parse(content.toString('utf-8'));
} catch {
throw new BadRequestError("Soubor se nepodařilo přečíst jako JSON");
}
const rawRows = Array.isArray(parsed) ? parsed : parsed?.rows;
if (!Array.isArray(rawRows)) {
throw new BadRequestError("JSON neobsahuje pole 'rows' s řádky přehledu");
}
const rows: LuncherRow[] = [];
const warnings: string[] = [];
rawRows.forEach((raw: any, index: number) => {
const date = parseDate(raw?.date);
if (!date) {
warnings.push(`Záznam ${index + 1} přeskočen — nepodařilo se přečíst datum`);
return;
}
rows.push({
date,
dayOfWeek: text(raw.dayOfWeek),
type: text(raw.type) ?? '',
food: text(raw.food),
note: text(raw.note),
store: text(raw.store),
orderedBy: text(raw.orderedBy),
amount: parseAmount(raw.amount),
});
});
return { rows, warnings };
}
/**
* Sestaví klíč pro rozpoznání duplicit.
*
* Součástí je i pořadí řádku v rámci dne — v jednom dni může být víc stejných
* záznamů (dvě totožné objednávky) a ty se nesmí navzájem odfiltrovat.
* Export Luncheru řadí řádky deterministicky, takže při opakovaném exportu
* téhož měsíce vyjde stejné pořadí.
*/
function buildImportKey(row: LuncherRow, indexWithinDay: number): string {
return [
IMPORT_SOURCE,
row.date,
indexWithinDay,
row.type,
row.food ?? '',
row.amount ?? '',
].join('|');
}
/**
* Převede řádek přehledu na záznam o jídle.
*
* Název se bere z vybraného jídla. U voleb jako "Budu objednávat" nebo
* "Rozhoduji se" je ale sloupec s jídlem prázdný a co uživatel reálně jedl stojí
* v poznámce ("Chefie - Těstovinový salát"), tak se použije ta. Teprve když není
* ani poznámka, zbude typ záznamu — uživatel si zvolil podnik, ale jídlo neuvedl.
*
* Zdrojem je obchod objednávky, jinak podnik. Stav volby se jako zdroj nepoužije.
*/
function toMealEntry(row: LuncherRow, mealType: MealType, importKey: string): MealEntry {
const noteAsName = !row.food && !!row.note;
const name = row.food ?? row.note ?? row.type;
// Poznámku, ze které se stal název, už do poznámky nekopírujeme
const remainingNote = noteAsName ? undefined : row.note;
const note = row.orderedBy
? [remainingNote, `objednal(a): ${row.orderedBy}`].filter(Boolean).join(' | ')
: remainingNote;
return {
id: randomUUID(),
date: row.date,
mealType,
name,
source: row.store ?? (NON_PLACE_TYPES.has(row.type) ? undefined : row.type),
price: row.amount,
note,
importSource: IMPORT_SOURCE,
importKey,
createdAt: new Date().toISOString(),
};
}
/**
* Naimportuje přehled vyexportovaný z Luncheru do jídel uživatele.
*
* Export je typicky za celý měsíc — záznamy se rozdělí do dnů, ke kterým patří.
* Řádky, které už z dřívějšího importu existují, se přeskočí, takže opakovaný
* import stejného měsíce data nezduplikuje.
*
* @param login přihlašovací jméno uživatele
* @param fileName název souboru (určuje formát)
* @param content obsah souboru
* @param defaultMealType typ jídla pro importované řádky, výchozí je oběd
* @param dryRun pokud true, výsledek se jen spočítá a nic se neuloží
*/
export async function importLuncherExport(
login: string,
fileName: string,
content: Buffer,
defaultMealType?: unknown,
dryRun = false,
): Promise<ImportResult> {
const format = detectFormat(fileName);
if (defaultMealType != null && !isMealType(defaultMealType)) {
throw new BadRequestError("Nebyl předán platný typ jídla");
}
// Luncher řeší výběr obědů, takže bez upřesnění zakládáme importy jako oběd
const mealType: MealType = isMealType(defaultMealType) ? defaultMealType : MealType.OBED;
let parsed: ParsedFile;
if (format === 'csv') {
parsed = parseCsvExport(content);
} else if (format === 'json') {
parsed = parseJsonExport(content);
} else {
parsed = await parseXlsxExport(content);
}
const dates = Array.from(new Set(parsed.rows.map(row => row.date)));
const existingKeys = await getExistingImportKeys(login, dates);
const perDayIndex = new Map<string, number>();
const entries: MealEntry[] = [];
let skipped = 0;
for (const row of parsed.rows) {
const indexWithinDay = perDayIndex.get(row.date) ?? 0;
perDayIndex.set(row.date, indexWithinDay + 1);
const importKey = buildImportKey(row, indexWithinDay);
if (existingKeys.has(importKey)) {
skipped++;
continue;
}
entries.push(toMealEntry(row, mealType, importKey));
}
const days = dryRun
? Array.from(new Set(entries.map(entry => entry.date))).sort()
: await addEntries(login, entries);
return {
dryRun,
format,
rowCount: parsed.rows.length,
imported: entries.length,
skipped,
days,
totalPrice: entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
entries,
warnings: parsed.warnings,
};
}