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,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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user