feat: import jen některých dnů

feat: násobení jídel/cviků
feat: refaktor napojování šablon
This commit is contained in:
Ondřej Anděl committed 2026-09-30 19:10:54 +02:00
1 parent c3990d80f8
commit 8dac67bdd1
37 files changed
+2314 -481

No files matched your search

+51 -12
View File
@@ -1,7 +1,7 @@
import ExcelJS from 'exceljs';
import { randomUUID } from 'crypto';
import { ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
import { addEntries, getExistingImportKeys, isMealType } from "./meals";
import { ImportMode, ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
import { addEntries, findMealsForDays, isMealType } from "./meals";
import { BadRequestError, isValidIsoDate } from "./utils";
/** Označení zdroje, kterým se importované záznamy značí. */
@@ -363,18 +363,30 @@ function toMealEntry(row: LuncherRow, mealType: MealType, importKey: string): Me
};
}
/** 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ří.
* Řádky, které už z dřívějšího importu existují, se přeskočí, takže opakovaný
* import stejného měsíce data nezduplikuje.
* 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.
*
* 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,
@@ -382,13 +394,23 @@ export async function importLuncherExport(
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') {
@@ -400,10 +422,14 @@ export async function importLuncherExport(
}
const dates = Array.from(new Set(parsed.rows.map(row => row.date)));
const existingKeys = await getExistingImportKeys(login, dates);
const existing = await findMealsForDays(login, dates);
// 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 entries: MealEntry[] = [];
const candidates: MealEntry[] = [];
let skipped = 0;
for (const row of parsed.rows) {
const indexWithinDay = perDayIndex.get(row.date) ?? 0;
@@ -413,22 +439,35 @@ export async function importLuncherExport(
skipped++;
continue;
}
entries.push(toMealEntry(row, mealType, importKey));
candidates.push(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(new Set(entries.map(entry => entry.date))).sort()
: await addEntries(login, entries);
? Array.from(selectedDays).sort()
: await addEntries(login, selected, replace ? IMPORT_SOURCE : undefined);
return {
dryRun,
format,
mode: resolvedMode,
rowCount: parsed.rows.length,
imported: entries.length,
imported: selected.length,
skipped,
replaced,
days,
totalPrice: entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
entries,
totalPrice: selected.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
entries: dryRun ? candidates : selected,
existing,
warnings: parsed.warnings,
};
}