Compare commits

Author SHA1 Message Date
FujiwaraChoki b96e414a6c Merge branch 'main' into hand-coding-stubs
# Conflicts:
#	core/src/main/java/ch/zhaw/pm3/ember/core/assistant/IntentParser.java
2026-10-01 10:57:54 +02:00
FujiwaraChoki d8f2d4a7d6 Merge branch 'main' into hand-coding-stubs 2026-10-01 09:22:01 +02:00
FujiwaraChoki 98bc9b7b9a Stub four core classes for hand-coding tasks
CsvParser, GoalProjector, CategoryRules + DuplicateRules and IntentParser keep
their public API but throw UnsupportedOperationException. The existing tests
in core stay as the target for each task.
2026-10-01 09:14:09 +02:00
5 changed files with 39 additions and 569 deletions
@@ -1,260 +1,19 @@
package ch.zhaw.pm3.ember.core.assistant;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.Month;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** German rule-based intent parser (docs/ARCHITECTURE.md §3.10). Pure, unit-tested. */
/**
* German rule-based intent parser (docs/ARCHITECTURE.md §3.10). Pure, unit-tested.
*
* <p>Hand-coding task 4 – see the "Ember – Hand-Coding Tasks" sheet. Target: {@code IntentParserTest}.
*/
public final class IntentParser {
private static final Pattern AMOUNT = Pattern.compile(
"(?i)(?:chf|fr\\.?|sfr\\.?)?\\s*(\\d{1,3}(?:[’'.]\\d{3})+|\\d+)(?:[.,](\\d{1,2}))?(?:\\s*(?:chf|fr\\.?|franken|stutz|\\.–|\\.-))?");
private static final Pattern MONTHLY = Pattern.compile("(?i)(pro monat|monatlich|im monat|jeden monat|je monat)");
private static final Pattern GOAL_NAME = Pattern.compile("(?i)für\\s+(?:eine?n?\\s+|die\\s+|den\\s+|das\\s+|meine?n?\\s+)?([\\p{L}\\-]+(?:\\s+[\\p{L}\\-]+)?)");
private static final Pattern DEADLINE_DATE = Pattern.compile("(?i)bis\\s+(?:zum\\s+|am\\s+)?(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})");
private static final Pattern DEADLINE_YEAR = Pattern.compile("(?i)bis\\s+(?:ende\\s+)?(20\\d{2})");
private static final Map<String, Month> MONTHS = Map.ofEntries(
Map.entry("januar", Month.JANUARY), Map.entry("februar", Month.FEBRUARY), Map.entry("märz", Month.MARCH), Map.entry("maerz", Month.MARCH),
Map.entry("april", Month.APRIL), Map.entry("mai", Month.MAY), Map.entry("juni", Month.JUNE), Map.entry("juli", Month.JULY),
Map.entry("august", Month.AUGUST), Map.entry("september", Month.SEPTEMBER), Map.entry("oktober", Month.OCTOBER),
Map.entry("november", Month.NOVEMBER), Map.entry("dezember", Month.DECEMBER));
private static final Map<String, Month> SEASONS = Map.of("frühling", Month.APRIL, "fruehling", Month.APRIL, "sommer", Month.JULY,
"herbst", Month.OCTOBER, "winter", Month.JANUARY);
/** Category synonyms → system category name. */
private static final Map<String, String> CATEGORY_WORDS = Map.ofEntries(
Map.entry("restaurant", "Restaurants"), Map.entry("essen gehen", "Restaurants"), Map.entry("auswärts", "Restaurants"),
Map.entry("lebensmittel", "Lebensmittel"), Map.entry("einkauf", "Lebensmittel"), Map.entry("einkäufe", "Lebensmittel"), Map.entry("migros", "Lebensmittel"), Map.entry("coop", "Lebensmittel"),
Map.entry("haushalt", "Haushalt"), Map.entry("freizeit", "Freizeit"), Map.entry("transport", "Transport"), Map.entry("öv", "Transport"), Map.entry("sbb", "Transport"),
Map.entry("wohnen", "Wohnen"), Map.entry("miete", "Wohnen"), Map.entry("gesundheit", "Gesundheit"), Map.entry("shopping", "Shopping"), Map.entry("kleider", "Shopping"),
Map.entry("abos", "Abos"), Map.entry("abo", "Abos"), Map.entry("reisen", "Reisen"), Map.entry("einnahmen", "Einnahmen"), Map.entry("rückzahlung", "Rückzahlung"));
private IntentParser() {}
/** Never returns null; anything unrecognised is {@link Intent.Type#HELP}. {@code today} resolves relative deadlines. */
public static Intent parse(String message, LocalDate today) {
String raw = message == null ? "" : message.trim();
String text = " " + raw.toLowerCase(Locale.ROOT) + " ";
List<BigDecimal> amounts = amounts(raw);
BigDecimal monthly = monthlyAmount(raw);
BigDecimal main = amounts.stream().filter(a -> monthly == null || a.compareTo(monthly) != 0).findFirst().orElse(null);
String category = category(text);
YearMonth period = period(text, today);
if (containsAny(text, "welche daten", "datenbasis", "woher weisst", "woher weißt", "welche informationen", "benutzt du", "verwendest du", "nutzt du", "datenschutz")) {
return Intent.of(Intent.Type.BASIS, raw);
}
if (containsAny(text, "aufteilen", "aufteilung", "tipp", " rat ", "ratschlag", "wie spare ich", "wo kann ich sparen", "wo spare ich", "sparpotenzial")) {
return new Intent(Intent.Type.ADVICE, null, null, null, null, category, period, raw);
}
boolean budget = containsAny(text, "budget", "limit", "höchstens", "hoechstens", "maximal", "nicht mehr als");
if (budget && (monthly != null || main != null) && !containsAny(text, "sparen", "zurücklegen", "ziel")) {
BigDecimal limit = monthly != null ? monthly : main;
return new Intent(Intent.Type.BUDGET, limit, limit, null, null, category, null, raw);
}
boolean question = containsAny(text, "wie viel", "wieviel", "was habe ich", "ausgegeben", "wie hoch", "wie teuer");
boolean whatIf = containsAny(text, "was wäre wenn", "was waere wenn", "was macht", "was passiert", "auswirk", "wenn ich", "trip", "reise für", "ausgabe von", "leisten", "würde", "wuerde", "kann ich mir");
if (whatIf && main != null && !question) {
String label = whatIfLabel(raw);
return new Intent(Intent.Type.WHAT_IF, main, null, label, null, category, period, raw);
}
boolean goal = containsAny(text, "sparen", "spar", "ziel", "zurücklegen", "zuruecklegen", "auf die seite");
if (goal && main != null && !question) {
return new Intent(Intent.Type.GOAL, main, monthly, goalName(raw), deadline(text, raw, today), null, null, raw);
}
if (question) {
return new Intent(Intent.Type.QUERY, null, null, null, null, category, period, raw);
}
if (containsAny(text, "übersicht", "uebersicht", "zeig", "zuerst", "wichtig", "gewicht", "oben", "anfang", "ausblenden", "weniger", "priorisier", "dashboard", "widget")) {
return Intent.of(Intent.Type.PROPOSAL, raw);
}
if (goal && monthly != null) {
return new Intent(Intent.Type.GOAL, null, monthly, null, null, null, null, raw);
}
return Intent.of(Intent.Type.HELP, raw);
}
static List<BigDecimal> amounts(String raw) {
List<BigDecimal> out = new ArrayList<>();
Matcher m = AMOUNT.matcher(raw);
while (m.find()) {
String whole = m.group(1).replaceAll("[’'.]", "");
String frac = m.group(2);
boolean hasCurrency = m.group(0).toLowerCase(Locale.ROOT).contains("chf") || m.group(0).toLowerCase(Locale.ROOT).contains("fr");
boolean grouped = m.group(1).matches(".*[’'.].*");
try {
BigDecimal value = new BigDecimal(whole + (frac != null ? "." + frac : ""));
int asInt = value.intValue();
boolean looksLikeYear = !hasCurrency && !grouped && frac == null && asInt >= 1990 && asInt <= 2100 && precededByYearWord(raw, m.start());
boolean looksLikeDay = !hasCurrency && !grouped && frac == null && asInt <= 31 && precededByDatePattern(raw, m.start());
if (!looksLikeYear && !looksLikeDay && value.signum() > 0) {
out.add(value.setScale(2, RoundingMode.HALF_UP));
}
} catch (NumberFormatException ignored) {
// skip
}
}
return out;
}
/** "bis 2027", "Ende 2027", "im Jahr 2027", "im Juli 2027", "Sommer 2027" – a year, not an amount. */
private static boolean precededByYearWord(String raw, int start) {
String before = raw.substring(Math.max(0, start - 12), start).toLowerCase(Locale.ROOT).trim();
return before.endsWith("bis") || before.endsWith("ende") || before.endsWith("jahr") || before.endsWith("ab") || before.endsWith("seit")
|| MONTHS.keySet().stream().anyMatch(before::endsWith) || SEASONS.keySet().stream().anyMatch(before::endsWith);
}
private static boolean precededByDatePattern(String raw, int start) {
int end = start;
while (end < raw.length() && Character.isDigit(raw.charAt(end))) {
end++;
}
return end < raw.length() && raw.charAt(end) == '.' && end + 1 < raw.length() && Character.isDigit(raw.charAt(end + 1));
}
static BigDecimal monthlyAmount(String raw) {
Matcher monthly = MONTHLY.matcher(raw);
if (!monthly.find()) {
return null;
}
BigDecimal best = null;
Matcher m = AMOUNT.matcher(raw);
while (m.find() && m.start() < monthly.start()) {
List<BigDecimal> one = amounts(raw.substring(m.start(), m.end()));
if (!one.isEmpty()) {
best = one.get(0);
}
}
if (best == null) {
List<BigDecimal> after = amounts(raw.substring(monthly.end()));
best = after.isEmpty() ? null : after.get(0);
}
return best;
}
static String goalName(String raw) {
Matcher m = GOAL_NAME.matcher(raw);
while (m.find()) {
String candidate = m.group(1).trim();
String lower = candidate.toLowerCase(Locale.ROOT);
if (lower.matches("^(chf|fr|\\d.*|monat.*|bis.*|mein.*|das|die|der)$")) {
continue;
}
String[] words = candidate.split("\\s+");
StringBuilder name = new StringBuilder();
for (String w : words) {
if (w.toLowerCase(Locale.ROOT).matches("bis|sparen|zurücklegen|und|mit|pro|monatlich|im|ab|ca|etwa|rund")) {
break;
}
if (!name.isEmpty()) {
name.append(' ');
}
name.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1));
}
if (!name.isEmpty()) {
return name.toString();
}
}
return "Sparziel";
}
static LocalDate deadline(String text, String raw, LocalDate today) {
Matcher d = DEADLINE_DATE.matcher(raw);
if (d.find()) {
try {
return LocalDate.of(Integer.parseInt(d.group(3)), Integer.parseInt(d.group(2)), Integer.parseInt(d.group(1)));
} catch (Exception ignored) {
// fall through
}
}
Matcher y = DEADLINE_YEAR.matcher(raw);
Integer year = y.find() ? Integer.parseInt(y.group(1)) : null;
for (Map.Entry<String, Month> e : MONTHS.entrySet()) {
if (text.contains("bis " + e.getKey()) || text.contains("bis ende " + e.getKey()) || text.contains("im " + e.getKey())) {
int yr = year != null ? year : (today.getMonth().getValue() >= e.getValue().getValue() ? today.getYear() + 1 : today.getYear());
return YearMonth.of(yr, e.getValue()).atEndOfMonth();
}
}
for (Map.Entry<String, Month> e : SEASONS.entrySet()) {
if (text.contains(e.getKey())) {
boolean next = text.contains("nächste") || text.contains("naechste") || text.contains("kommende");
int yr = year != null ? year : (next || today.getMonth().getValue() >= e.getValue().getValue() ? today.getYear() + 1 : today.getYear());
return YearMonth.of(yr, e.getValue()).atDay(1);
}
}
if (year != null) {
return LocalDate.of(year, 12, 31);
}
return null;
}
static String category(String text) {
for (Map.Entry<String, String> e : CATEGORY_WORDS.entrySet()) {
if (text.contains(e.getKey())) {
return e.getValue();
}
}
return null;
}
static YearMonth period(String text, LocalDate today) {
if (containsAny(text, "letzten monat", "letzter monat", "vormonat", "im letzten monat")) {
return YearMonth.from(today).minusMonths(1);
}
for (Map.Entry<String, Month> e : MONTHS.entrySet()) {
if (text.contains(" im " + e.getKey()) || text.contains(" " + e.getKey() + " ")) {
Matcher y = Pattern.compile(e.getKey() + "\\s+(20\\d{2})").matcher(text);
int yr = y.find() ? Integer.parseInt(y.group(1)) : today.getYear();
if (!y.reset().find() && e.getValue().getValue() > today.getMonthValue()) {
yr = today.getYear() - 1;
}
return YearMonth.of(yr, e.getValue());
}
}
return null;
}
private static String whatIfLabel(String raw) {
String lower = raw.toLowerCase(Locale.ROOT);
if (lower.contains("trip") || lower.contains("wochenend")) {
return "Der Wochenendtrip";
}
if (lower.contains("reise")) {
return "Die Reise";
}
if (lower.contains("ferien") || lower.contains("urlaub")) {
return "Die Ferien";
}
return Optional.ofNullable(afterFor(raw)).orElse("Diese Ausgabe");
}
private static String afterFor(String raw) {
Matcher m = GOAL_NAME.matcher(raw);
if (m.find()) {
String name = m.group(1).trim();
if (!name.toLowerCase(Locale.ROOT).matches("^(chf|fr|\\d.*|mein.*)$")) {
return Character.toUpperCase(name.charAt(0)) + name.substring(1);
}
}
return null;
}
private static boolean containsAny(String text, String... needles) {
for (String n : needles) {
if (text.contains(n)) {
return true;
}
}
return false;
// TODO(task 4): type, amounts (CHF 4'000), monthly amount, goal name, deadline, category, period
throw new UnsupportedOperationException("IntentParser.parse – hand-coding task 4");
}
}
@@ -1,12 +1,14 @@
package ch.zhaw.pm3.ember.core.category;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import static ch.zhaw.pm3.ember.core.category.SystemCategories.*;
/** Keyword rules on the normalised merchant + description (docs/ARCHITECTURE.md §4). Pure, no I/O. */
/**
* Keyword rules on the normalised merchant + description (docs/ARCHITECTURE.md §4). Pure, no I/O.
*
* <p>Hand-coding task 3 – see the "Ember – Hand-Coding Tasks" sheet. Target: {@code CategoryRulesTest}.
* Category names live in {@link SystemCategories}; {@link Merchants#normalize} may help.
*/
public final class CategoryRules {
/** Confidence below this means the entry should land in review. */
@@ -16,50 +18,15 @@ public final class CategoryRules {
public record Match(String category, double confidence) {}
private record Rule(String category, double confidence, boolean incomeOnly, List<String> keywords) {}
private static final List<Rule> RULES = List.of(
new Rule(EINNAHMEN, 0.95, true, List.of("lohn", "salary", "gehalt", "salaer")),
new Rule(RUECKZAHLUNG, 0.70, true, List.of("twint", "rueckzahlung", "rückzahlung", "refund", "erstattung")),
new Rule(LEBENSMITTEL, 0.90, false, List.of("coop", "migros", "denner", "lidl", "aldi", "volg", "markt", "spar", "baeckerei", "bäckerei")),
new Rule(RESTAURANTS, 0.85, false, List.of("restaurant", "cafe", "café", "bar", "pizzeria", "takeaway", "take away", "mcdonald", "burger", "kebab", "sushi", "bistro")),
new Rule(TRANSPORT, 0.90, false, List.of("sbb", "zvv", "vbz", "tram", "bus", "uber", "postauto", "bahn", "tankstelle", "parking")),
new Rule(ABOS, 0.90, false, List.of("netflix", "spotify", "swisscom", "sunrise", "salt", "disney", "apple.com", "icloud", "youtube", "abo")),
new Rule(SHOPPING, 0.80, false, List.of("galaxus", "digitec", "zalando", "h&m", "hm", "ikea", "amazon", "zara", "manor", "decathlon")),
new Rule(GESUNDHEIT, 0.85, false, List.of("apotheke", "arzt", "zahnarzt", "drogerie", "pharmacy", "spital", "physio")),
new Rule(WOHNEN, 0.90, false, List.of("miete", "wohnung", "hausverwaltung", "immobilien", "ewz", "energie")),
new Rule(FREIZEIT, 0.75, false, List.of("kino", "cinema", "fitness", "gym", "konzert", "ticketcorner", "museum", "bad", "schwimm")),
new Rule(REISEN, 0.80, false, List.of("hotel", "airbnb", "booking", "swiss", "easyjet", "flug", "airline", "hostel")),
new Rule(HAUSHALT, 0.70, false, List.of("jumbo", "obi", "hornbach", "bauhaus", "haushalt", "landi", "interio"))
);
private CategoryRules() {}
public static Optional<Match> match(String merchant, String description, BigDecimal amount) {
String haystack = " " + Merchants.normalize(merchant) + " " + Merchants.normalize(description) + " ";
boolean income = amount != null && amount.signum() > 0;
for (Rule rule : RULES) {
if (rule.incomeOnly() && !income) {
continue;
}
for (String keyword : rule.keywords()) {
if (matches(haystack, Merchants.normalize(keyword))) {
return Optional.of(new Match(rule.category(), rule.confidence()));
}
}
}
return income ? Optional.of(new Match(EINNAHMEN, 0.6)) : Optional.empty();
// TODO(task 3): keyword rules → category + confidence; income-only rules; fallback for unmatched income
throw new UnsupportedOperationException("CategoryRules.match – hand-coding task 3");
}
public static boolean needsReview(double confidence) {
return confidence < REVIEW_THRESHOLD;
}
/** Short keywords must match whole words ("bar" must not hit "barbara"); longer ones may be substrings. */
private static boolean matches(String haystack, String keyword) {
if (keyword.isEmpty()) {
return false;
}
return keyword.length() <= 4 ? haystack.contains(" " + keyword + " ") : haystack.contains(keyword);
// TODO(task 3)
throw new UnsupportedOperationException("CategoryRules.needsReview – hand-coding task 3");
}
}
@@ -1,24 +1,17 @@
package ch.zhaw.pm3.ember.core.goal;
import ch.zhaw.pm3.ember.core.money.Amounts;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Pure projection math from docs/ARCHITECTURE.md §3.6. Uses the planned rate, or the actual savings of the last months
* when they are lower, and explains its assumptions and what it leaves out.
*
* <p>Hand-coding task 2 – see the "Ember – Hand-Coding Tasks" sheet. Target: {@code GoalProjectorTest}.
*/
public final class GoalProjector {
private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.GERMAN);
private GoalProjector() {}
/** Plan only – no savings history known. */
@@ -26,45 +19,11 @@ public final class GoalProjector {
return project(target, saved, monthly, null, deadline, today);
}
/** {@code actual} may be null (no months with income yet) – then the planned rate is used. */
public static Projection project(BigDecimal target, BigDecimal saved, BigDecimal planned, ActualSavings actual,
LocalDate deadline, LocalDate today) {
BigDecimal plannedMonthly = planned == null ? Amounts.zero() : Amounts.scale(planned).max(Amounts.zero());
BigDecimal actualMonthly = actual == null ? null : Amounts.scale(actual.monthlyAverage());
BigDecimal effective = actualMonthly == null ? plannedMonthly : plannedMonthly.min(actualMonthly.max(Amounts.zero()));
Projection.Basis basis = effective.compareTo(plannedMonthly) < 0 ? Projection.Basis.ACTUAL : Projection.Basis.PLAN;
BigDecimal remaining = Amounts.scale(target).subtract(Amounts.scale(saved)).max(Amounts.zero());
List<String> assumptions = new ArrayList<>();
assumptions.add("Gespart bisher: " + Amounts.formatChf(saved));
if (actualMonthly == null) {
assumptions.add("Sparrate " + Amounts.formatChf(plannedMonthly) + " pro Monat bleibt gleich (noch keine Monate mit Einnahmen erfasst)");
} else if (basis == Projection.Basis.ACTUAL) {
assumptions.add("Geplant " + Amounts.formatChf(plannedMonthly) + " pro Monat, tatsächlich gespart im Schnitt "
+ Amounts.formatChf(actualMonthly) + " (" + actual.periodLabel() + ") – gerechnet wird mit " + Amounts.formatChf(effective));
} else {
assumptions.add("Geplant " + Amounts.formatChf(plannedMonthly) + " pro Monat, tatsächlich gespart im Schnitt "
+ Amounts.formatChf(actualMonthly) + " (" + actual.periodLabel() + ") – gerechnet wird mit dem Plan");
}
assumptions.add("Nur bestätigte Buchungen; offene Einträge zählen erst nach dem Bestätigen");
assumptions.add("Stand: " + today.format(DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN)));
BigDecimal requiredMonthly = null;
if (deadline != null) {
long months = Math.max(1, ChronoUnit.MONTHS.between(today.withDayOfMonth(1), deadline.withDayOfMonth(1)));
requiredMonthly = remaining.divide(BigDecimal.valueOf(months), 2, RoundingMode.CEILING);
}
if (remaining.signum() == 0) {
return new Projection(today, 0, true, requiredMonthly, plannedMonthly, actualMonthly, effective, basis, assumptions);
}
if (effective.signum() <= 0) {
assumptions.add("Ohne Sparrate gibt es keinen Zieltermin");
return new Projection(null, null, false, requiredMonthly, plannedMonthly, actualMonthly, effective, basis, assumptions);
}
int monthsRemaining = remaining.divide(effective, 0, RoundingMode.CEILING).intValue();
LocalDate projectedDate = today.plusMonths(monthsRemaining);
boolean onTrack = deadline == null || !projectedDate.isAfter(deadline);
return new Projection(projectedDate, monthsRemaining, onTrack, requiredMonthly, plannedMonthly, actualMonthly, effective,
basis, assumptions);
// TODO(task 2): effective rate, months remaining, projected date, onTrack, requiredMonthly, assumptions
throw new UnsupportedOperationException("GoalProjector.project – hand-coding task 2");
}
public static WhatIf whatIf(String label, BigDecimal extraExpense, BigDecimal target, BigDecimal saved, BigDecimal monthly,
@@ -75,35 +34,7 @@ public final class GoalProjector {
/** Was-wäre-wenn: {@code saved − extraExpense} (not below 0), same rate. */
public static WhatIf whatIf(String label, BigDecimal extraExpense, BigDecimal target, BigDecimal saved, BigDecimal monthly,
ActualSavings actual, LocalDate deadline, LocalDate today, List<String> excluded) {
BigDecimal extra = Amounts.scale(extraExpense).abs();
BigDecimal savedAfter = Amounts.scale(saved).subtract(extra).max(Amounts.zero());
Projection before = project(target, saved, monthly, actual, deadline, today);
Projection after = project(target, savedAfter, monthly, actual, deadline, today);
Integer delta = before.monthsRemaining() != null && after.monthsRemaining() != null
? after.monthsRemaining() - before.monthsRemaining() : null;
String name = label == null || label.isBlank() ? "Diese Ausgabe" : label;
StringBuilder text = new StringBuilder();
text.append(name).append(" von ").append(Amounts.formatChf(extra));
if (delta == null) {
text.append(" reduziert das Gesparte auf ").append(Amounts.formatChf(savedAfter))
.append(". Ohne Sparrate lässt sich kein Termin berechnen.");
} else if (delta == 0) {
text.append(" ändert den Zieltermin nicht (weiterhin ").append(after.projectedDate().format(MONTH)).append(").");
} else {
text.append(" verschiebt das Ziel um ").append(delta).append(delta == 1 ? " Monat" : " Monate")
.append(": von ").append(before.projectedDate().format(MONTH))
.append(" auf ").append(after.projectedDate().format(MONTH)).append(".");
if (deadline != null && !after.onTrack() && before.onTrack()) {
text.append(" Der Termin ").append(deadline.format(MONTH)).append(" wäre damit nicht mehr erreichbar");
if (after.requiredMonthly() != null) {
text.append(", ausser die Sparrate steigt auf ").append(Amounts.formatChf(after.requiredMonthly())).append(" pro Monat");
}
text.append(".");
}
}
List<String> assumptions = new ArrayList<>(before.assumptions());
assumptions.add("Die Ausgabe wird vollständig aus dem Gesparten bezahlt");
return new WhatIf(name, extra, before, after, delta, text.toString(), assumptions, excluded);
// TODO(task 2): project before/after, delta in months, German explanation, assumptions
throw new UnsupportedOperationException("GoalProjector.whatIf – hand-coding task 2");
}
}
@@ -2,183 +2,32 @@ package ch.zhaw.pm3.ember.core.importer;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* Tolerant bank-statement CSV parser: auto-detects the delimiter and maps header synonyms (de/en)
* to date / text / amount (or separate debit / credit columns). Pure.
*
* <p>Hand-coding task 1 – see the "Ember – Hand-Coding Tasks" sheet. Target: {@code CsvParserTest}.
*/
public final class CsvParser {
public record Row(int line, LocalDate bookedOn, BigDecimal amount, String text) {}
/** {@code format}: GENERIC_AMOUNT, GENERIC_DEBIT_CREDIT or UNKNOWN. {@code problems}: one German message per skipped line. */
public record Result(String format, List<Row> rows, List<String> problems) {}
private static final List<String> DATE_HEADERS = List.of("datum", "date", "buchungsdatum", "valuta", "valutadatum", "transaktionsdatum");
private static final List<String> TEXT_HEADERS = List.of("buchungstext", "text", "beschreibung", "description", "details", "merchant", "haendler", "händler", "empfaenger", "empfänger", "zahlungsempfaenger", "verwendungszweck");
private static final List<String> AMOUNT_HEADERS = List.of("betrag", "amount", "chf", "betrag chf");
private static final List<String> DEBIT_HEADERS = List.of("belastung", "debit", "soll", "ausgabe", "lastschrift");
private static final List<String> CREDIT_HEADERS = List.of("gutschrift", "credit", "haben", "einnahme");
private static final List<DateTimeFormatter> DATE_FORMATS = List.of(
DateTimeFormatter.ofPattern("dd.MM.yyyy"), DateTimeFormatter.ISO_LOCAL_DATE,
DateTimeFormatter.ofPattern("dd/MM/yyyy"), DateTimeFormatter.ofPattern("d.M.yyyy"), DateTimeFormatter.ofPattern("dd.MM.yy"));
private static final Pattern NUMBER_JUNK = Pattern.compile("[^0-9,.\\-+]");
private CsvParser() {}
public static Result parse(String content) {
List<String> lines = content.lines().filter(l -> !l.isBlank()).toList();
List<String> problems = new ArrayList<>();
if (lines.isEmpty()) {
return new Result("EMPTY", List.of(), List.of("Die Datei ist leer."));
}
char delimiter = detectDelimiter(lines.get(0));
List<String> header = split(lines.get(0), delimiter).stream().map(h -> h.trim().toLowerCase(Locale.ROOT).replace("\"", "")).toList();
int dateIdx = indexOf(header, DATE_HEADERS);
int textIdx = indexOf(header, TEXT_HEADERS);
int amountIdx = indexOf(header, AMOUNT_HEADERS);
int debitIdx = indexOf(header, DEBIT_HEADERS);
int creditIdx = indexOf(header, CREDIT_HEADERS);
if (dateIdx < 0 || textIdx < 0 || (amountIdx < 0 && debitIdx < 0 && creditIdx < 0)) {
return new Result("UNKNOWN", List.of(),
List.of("Spalten nicht erkannt. Erwartet werden Datum, Text und Betrag (oder Belastung/Gutschrift)."));
}
String format = amountIdx >= 0 ? "GENERIC_AMOUNT" : "GENERIC_DEBIT_CREDIT";
List<Row> rows = new ArrayList<>();
for (int i = 1; i < lines.size(); i++) {
List<String> cells = split(lines.get(i), delimiter);
int max = Math.max(Math.max(dateIdx, textIdx), Math.max(amountIdx, Math.max(debitIdx, creditIdx)));
if (cells.size() <= max) {
problems.add("Zeile " + (i + 1) + ": zu wenige Spalten.");
continue;
}
Optional<LocalDate> date = parseDate(cells.get(dateIdx));
if (date.isEmpty()) {
problems.add("Zeile " + (i + 1) + ": Datum nicht lesbar.");
continue;
}
Optional<BigDecimal> amount;
if (amountIdx >= 0) {
amount = parseAmount(cells.get(amountIdx));
} else {
Optional<BigDecimal> debit = debitIdx >= 0 ? parseAmount(cells.get(debitIdx)) : Optional.empty();
Optional<BigDecimal> credit = creditIdx >= 0 ? parseAmount(cells.get(creditIdx)) : Optional.empty();
amount = debit.filter(d -> d.signum() != 0).map(d -> d.abs().negate())
.or(() -> credit.map(BigDecimal::abs));
}
if (amount.isEmpty()) {
problems.add("Zeile " + (i + 1) + ": Betrag nicht lesbar.");
continue;
}
String text = cells.get(textIdx).trim();
if (text.isEmpty()) {
text = "Unbekannt";
}
rows.add(new Row(i + 1, date.get(), amount.get(), text));
}
return new Result(format, rows, problems);
// TODO(task 1): detect delimiter, map headers, parse rows (Swiss amounts, several date formats), collect problems
throw new UnsupportedOperationException("CsvParser.parse – hand-coding task 1");
}
static char detectDelimiter(String headerLine) {
char best = ';';
long bestCount = -1;
for (char c : new char[] {';', ',', '\t', '|'}) {
long count = headerLine.chars().filter(ch -> ch == c).count();
if (count > bestCount) {
best = c;
bestCount = count;
}
}
return best;
}
/** RFC-4180-ish split with quote support. */
static List<String> split(String line, char delimiter) {
List<String> out = new ArrayList<>();
StringBuilder cur = new StringBuilder();
boolean quoted = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (c == '"') {
if (quoted && i + 1 < line.length() && line.charAt(i + 1) == '"') {
cur.append('"');
i++;
} else {
quoted = !quoted;
}
} else if (c == delimiter && !quoted) {
out.add(cur.toString());
cur.setLength(0);
} else {
cur.append(c);
}
}
out.add(cur.toString());
return out;
}
private static int indexOf(List<String> header, List<String> synonyms) {
for (int i = 0; i < header.size(); i++) {
if (synonyms.contains(header.get(i))) {
return i;
}
}
for (int i = 0; i < header.size(); i++) {
for (String s : synonyms) {
if (header.get(i).contains(s)) {
return i;
}
}
}
return -1;
}
static Optional<LocalDate> parseDate(String raw) {
String s = raw.trim().replace("\"", "");
for (DateTimeFormatter f : DATE_FORMATS) {
try {
return Optional.of(LocalDate.parse(s, f));
} catch (DateTimeParseException ignored) {
// try next
}
}
return Optional.empty();
}
/** Handles "1'234.50", "1’234.50", "1.234,50", "-63.45", "63.45-", "CHF 12.00". */
/** Handles "1'234.50", "1’234.50", "1.234,50", "-63.45", "63.45-", "CHF 12.00". Also used by {@link EmailParser}. */
static Optional<BigDecimal> parseAmount(String raw) {
if (raw == null || raw.isBlank()) {
return Optional.empty();
}
String s = raw.trim().replace("’", "").replace("'", "").replace(" ", "").replace(" ", "");
boolean negative = s.startsWith("-") || s.endsWith("-") || (s.startsWith("(") && s.endsWith(")"));
s = NUMBER_JUNK.matcher(s).replaceAll("").replace("-", "").replace("+", "");
if (s.isEmpty()) {
return Optional.empty();
}
int lastComma = s.lastIndexOf(',');
int lastDot = s.lastIndexOf('.');
if (lastComma > lastDot) {
s = s.replace(".", "").replace(',', '.');
} else {
s = s.replace(",", "");
}
try {
BigDecimal value = new BigDecimal(s);
return Optional.of(negative ? value.negate() : value);
} catch (NumberFormatException e) {
return Optional.empty();
}
// TODO(task 1)
throw new UnsupportedOperationException("CsvParser.parseAmount – hand-coding task 1");
}
}
@@ -1,66 +1,30 @@
package ch.zhaw.pm3.ember.core.transaction;
import ch.zhaw.pm3.ember.core.category.Merchants;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Duplicate detection across all sources (docs/ARCHITECTURE.md §3.3): (a) same fingerprint, (b) same amount, date ±2 days
* and a similar merchant, (c) same amount, date ±2 days and exactly one of both entries comes from a receipt. Pure.
*
* <p>Hand-coding task 3 – see the "Ember – Hand-Coding Tasks" sheet. No tests yet: write {@code DuplicateRulesTest}.
*/
public final class DuplicateRules {
public static final int DAY_TOLERANCE = 2;
/** Tokens that say nothing about the merchant ("AG", payment rails, cities). */
private static final Set<String> NOISE = Set.of("ag", "gmbh", "sa", "se", "ab", "ltd", "inc", "und", "the", "zh", "zuerich", "zurich",
"bern", "basel", "twint", "sumup", "paypal", "sbb", "hb", "com", "www", "ch", "kauf", "einkauf", "zahlung", "karte");
public record Candidate(LocalDate bookedOn, BigDecimal amount, String merchant, String fingerprint, boolean fromReceipt) {}
private DuplicateRules() {}
public static boolean isDuplicate(Candidate fresh, Candidate existing) {
if (fresh.fingerprint() != null && fresh.fingerprint().equals(existing.fingerprint())) {
return true;
}
if (fresh.amount() == null || existing.amount() == null || fresh.amount().compareTo(existing.amount()) != 0) {
return false;
}
if (Math.abs(ChronoUnit.DAYS.between(fresh.bookedOn(), existing.bookedOn())) > DAY_TOLERANCE) {
return false;
}
return similarMerchant(fresh.merchant(), existing.merchant()) || fresh.fromReceipt() != existing.fromReceipt();
// TODO(task 3): rules (a)–(c) above
throw new UnsupportedOperationException("DuplicateRules.isDuplicate – hand-coding task 3");
}
/** Normalised token overlap: "Coop" ↔ "COOP-4471 ZUERICH", "Migros Hauptbahnhof" ↔ "MIGROS M ZH HB". */
public static boolean similarMerchant(String a, String b) {
Set<String> left = tokens(a);
Set<String> right = tokens(b);
if (left.isEmpty() || right.isEmpty()) {
return false;
}
for (String t : left) {
if (right.contains(t)) {
return true;
}
for (String u : right) {
if (t.length() >= 5 && u.length() >= 5 && (t.startsWith(u) || u.startsWith(t))) {
return true;
}
}
}
return false;
}
static Set<String> tokens(String merchant) {
return Arrays.stream(Merchants.normalize(merchant).replace("*", " ").split(" "))
.filter(t -> t.length() >= 3 && !NOISE.contains(t) && !t.chars().allMatch(Character::isDigit))
.collect(Collectors.toSet());
// TODO(task 3): tokenise, drop noise words ("AG", cities, "TWINT", digits), compare
throw new UnsupportedOperationException("DuplicateRules.similarMerchant – hand-coding task 3");
}
}