Files
food-tracer/server/src/luncherImport.ts
T

479 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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(),
};
}
/** 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<ImportResult> {
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<string, number>();
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,
};
}