import ExcelJS from 'exceljs'; import { randomUUID } from 'crypto'; import { ImportMode, ImportResult, MealEntry, MealType } from "../../types/gen/types.gen"; import { addEntries, createNutritionFiller, findAllMeals, findMealsForDays, 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 = { '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 { const mapping = new Map(); 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): LuncherRow | undefined { const raw: Record = {}; 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 { 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(), }; } /** Vrátí true, pokud je hodnota podporovaným režimem importu. */ const isImportMode = (value: unknown): value is ImportMode => value === ImportMode.PRIDAT || value === ImportMode.NAHRADIT; /** * 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ří. * V režimu PRIDAT se řádky, které už z dřívějšího importu existují, přeskočí, * takže opakovaný import stejného měsíce data nezduplikuje. Režim NAHRADIT * dřívější import dotčených dnů odebere a založí ho znovu — hodí se, když se * v Luncheru něco opravilo. Ručně zadaná jídla zůstávají v obou režimech. * * Luncher kalorie nezná, takže se importovaným jídlům doplní energie a živiny * z historie — z posledního jídla stejného názvu, které je má (viz * `createNutritionFiller`). * * Náhled vrací všechny řádky, které jde založit, a k nim jídla, která už * v dotčených dnech jsou. Uživatel si v něm odškrtne, co nechce, a ostrý * import pak dostane jen vybrané klíče. * * @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ží * @param mode režim importu, výchozí je PRIDAT * @param selectedKeys klíče řádků k založení; bez nich se založí všechny */ export async function importLuncherExport( login: string, fileName: string, content: Buffer, defaultMealType?: unknown, dryRun = false, mode?: unknown, selectedKeys?: unknown, ): Promise { const format = detectFormat(fileName); if (defaultMealType != null && !isMealType(defaultMealType)) { throw new BadRequestError("Nebyl předán platný typ jídla"); } if (mode != null && !isImportMode(mode)) { throw new BadRequestError("Nebyl předán platný režim importu"); } if (selectedKeys != null && !Array.isArray(selectedKeys)) { throw new BadRequestError("Vybrané řádky musí být pole klíčů"); } // 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; const resolvedMode: ImportMode = isImportMode(mode) ? mode : ImportMode.PRIDAT; const replace = resolvedMode === ImportMode.NAHRADIT; 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 existing = await findMealsForDays(login, dates); const fillNutrition = createNutritionFiller(await findAllMeals(login)); // Při nahrazení se dřívější import založí znovu, duplicitou tedy není const existingKeys = new Set(replace ? [] : existing.map(entry => entry.importKey).filter(key => !!key)); const perDayIndex = new Map(); const candidates: 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; } candidates.push(fillNutrition(toMealEntry(row, mealType, importKey))); } const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String)); const selected = candidates.filter(entry => !selectedKeyList || selectedKeyList.has(entry.importKey!)); skipped += candidates.length - selected.length; // Nahrazuje se jen ve dnech, do kterých se opravdu něco zakládá const selectedDays = new Set(selected.map(entry => entry.date)); const replaced = replace ? existing.filter(entry => entry.importSource === IMPORT_SOURCE && selectedDays.has(entry.date)).length : 0; const days = dryRun ? Array.from(selectedDays).sort() : await addEntries(login, selected, replace ? IMPORT_SOURCE : undefined); return { dryRun, format, mode: resolvedMode, rowCount: parsed.rows.length, imported: selected.length, skipped, replaced, days, totalPrice: selected.reduce((sum, entry) => sum + (entry.price ?? 0), 0), entries: dryRun ? candidates : selected, existing, warnings: parsed.warnings, }; }