479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
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,
|
||
};
|
||
}
|