Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96e414a6c | ||
|
|
d8f2d4a7d6 | ||
|
|
98bc9b7b9a |
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user