View Javadoc
1   package de.aikiit.bilanzanalyser.upload;
2   
3   import de.aikiit.bilanzanalyser.entity.BilanzRow;
4   import de.aikiit.bilanzanalyser.entity.database.BilanzRowEntity;
5   import de.aikiit.bilanzanalyser.entity.database.CategoryEntity;
6   import de.aikiit.bilanzanalyser.entity.database.PaymentEntity;
7   import de.aikiit.bilanzanalyser.entity.database.ShopEntity;
8   import de.aikiit.bilanzanalyser.entity.database.SourceEntity;
9   import de.aikiit.bilanzanalyser.entity.database.repository.BilanzRowRepository;
10  import de.aikiit.bilanzanalyser.entity.database.repository.CategoryRepository;
11  import de.aikiit.bilanzanalyser.entity.database.repository.PaymentRepository;
12  import de.aikiit.bilanzanalyser.entity.database.repository.ShopRepository;
13  import de.aikiit.bilanzanalyser.entity.database.repository.SourceRepository;
14  import de.aikiit.bilanzanalyser.reader.BilanzOdsReader;
15  import de.aikiit.bilanzanalyser.reader.BilanzRowParserResult;
16  import jakarta.transaction.Transactional;
17  import lombok.extern.log4j.Log4j2;
18  import org.springframework.dao.DataIntegrityViolationException;
19  import org.springframework.scheduling.annotation.Async;
20  import org.springframework.stereotype.Service;
21  
22  import java.io.IOException;
23  import java.nio.file.Path;
24  import java.util.Optional;
25  import java.util.function.Function;
26  
27  @Service
28  @Log4j2
29  public class UploadAnalysisService {
30      private final BilanzRowRepository bilanzRowRepository;
31      private final ShopRepository shopRepository;
32      private final PaymentRepository paymentRepository;
33      private final CategoryRepository categoryRepository;
34      private final SourceRepository sourceRepository;
35  
36      public UploadAnalysisService(final BilanzRowRepository bilanzRowRepository, final ShopRepository shopRepository, final PaymentRepository paymentRepository, final CategoryRepository categoryRepository, final SourceRepository sourceRepository) {
37          this.bilanzRowRepository = bilanzRowRepository;
38          this.shopRepository = shopRepository;
39          this.paymentRepository = paymentRepository;
40          this.categoryRepository = categoryRepository;
41          this.sourceRepository = sourceRepository;
42      }
43  
44      /**
45       * Parses and analyses a given file.
46       *
47       * @param worksheetName selected worksheet name to process.
48       * @param spreadsheet   path to spreadsheet file.
49       * @return result container.
50       * @throws IOException in case of I/O problems.
51       */
52      BilanzRowParserResult processFile(final String worksheetName, final Path spreadsheet) throws IOException {
53          BilanzOdsReader reader = new BilanzOdsReader(worksheetName, spreadsheet);
54          return reader.extractData();
55      }
56  
57      @Async
58      @Transactional
59      public void flushDataIntoDatabase(final BilanzRowParserResult data) {
60          long start = System.nanoTime();
61          log.info("Starting to flush data into database...");
62  
63          for (BilanzRow row : data.rows()) {
64              BilanzRowEntity entity = new BilanzRowEntity();
65              entity.setDate(row.getDate());
66              entity.setAmount(row.getAmount());
67              entity.setDescription(replaceIfEmpty(row.getDescription()));
68              entity.setShop(getOrCreateShop(row.getShop()));
69              entity.setPayment(getOrCreatePayment(row.getPayment()));
70              entity.setCategory(getOrCreateCategory(row.getCategory()));
71              entity.setSource(getOrCreateSource(row.getSource()));
72              bilanzRowRepository.save(entity);
73          }
74  
75          log.info("Successfully flushed data into database in {} ms.", (System.nanoTime() - start) / 1_000_000);
76      }
77  
78      /**
79       * Retrieves an entity by name or creates and persists it if it does not exist,
80       * assuming that the underlying entity enforces uniqueness with a database constraint on the column 'name'.
81       * <p>
82       * This method implements a "get-or-create" pattern with basic concurrency handling:
83       * it first attempts to find an existing entity using the provided {@code finder}.
84       * If none is found, it invokes the {@code creator} to create and persist a new entity.
85       * <p>
86       * In case of a concurrent insert (e.g. due to a unique constraint on the name),
87       * a {@link DataIntegrityViolationException} may be thrown during creation. In that case,
88       * the method retries the lookup and returns the entity inserted by the concurrent transaction.
89       * <p>
90       * The input name is normalized using {@link #replaceIfEmpty(String)} before lookup and creation.
91       *
92       * @param rawName the original name value; may be {@code null} or blank
93       * @param finder  function used to look up an existing entity by normalized name
94       * @param creator function that creates and persists a new entity for the normalized name;
95       *                must return a fully initialized and saved entity
96       * @param <T>     the entity type
97       * @return an existing or newly created entity corresponding to the given name.
98       */
99      @Transactional
100     <T> T getOrCreate(final String rawName, final Function<String, Optional<T>> finder, final Function<String, T> creator) {
101         String name = replaceIfEmpty(rawName);
102 
103         return finder.apply(name).orElseGet(() -> {
104             try {
105                 return creator.apply(name);
106             } catch (DataIntegrityViolationException e) {
107                 // someone else inserted it concurrently
108                 return finder.apply(name).orElseThrow();
109             }
110         });
111     }
112 
113     @Transactional
114     ShopEntity getOrCreateShop(final String name) {
115         return getOrCreate(name, shopRepository::findByName, n -> {
116             ShopEntity e = new ShopEntity();
117             e.setName(n);
118             return shopRepository.save(e);
119         });
120     }
121 
122     @Transactional
123     PaymentEntity getOrCreatePayment(final String name) {
124         return getOrCreate(name, paymentRepository::findByName, n -> {
125             PaymentEntity e = new PaymentEntity();
126             e.setName(n);
127             return paymentRepository.save(e);
128         });
129     }
130 
131     @Transactional
132     CategoryEntity getOrCreateCategory(final String name) {
133         return getOrCreate(name, categoryRepository::findByName, n -> {
134             CategoryEntity e = new CategoryEntity();
135             e.setName(n);
136             return categoryRepository.save(e);
137         });
138     }
139 
140     @Transactional
141     SourceEntity getOrCreateSource(final String name) {
142         return getOrCreate(name, sourceRepository::findByName, n -> {
143             SourceEntity e = new SourceEntity();
144             e.setName(n); // no replaceIfEmpty here before, but now consistent
145             return sourceRepository.save(e);
146         });
147     }
148 
149     String replaceIfEmpty(final String value) {
150         return value == null || value.isBlank() ? "<empty>" : value.trim();
151     }
152 
153 }