added batch file upload feature

This commit is contained in:
Prince
2026-06-20 02:33:37 +02:00
parent dcb917e899
commit 4d0e18c1a6
26 changed files with 2263 additions and 104 deletions

View File

@@ -6,14 +6,25 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import zw.qantra.tm.domain.models.Recipient;
import zw.qantra.tm.domain.models.RecipientGroup;
import zw.qantra.tm.domain.models.RecipientGroupMember;
import org.springframework.web.multipart.MultipartFile;
import zw.qantra.tm.domain.dtos.GroupCreateFromFileRequest;
import zw.qantra.tm.domain.dtos.GroupCreateFromFileResult;
import zw.qantra.tm.domain.dtos.ProductDto;
import zw.qantra.tm.domain.enums.*;
import zw.qantra.tm.domain.models.*;
import zw.qantra.tm.domain.repositories.GroupBatchItemRepository;
import zw.qantra.tm.domain.repositories.GroupBatchRepository;
import zw.qantra.tm.domain.repositories.RecipientGroupMemberRepository;
import zw.qantra.tm.domain.repositories.RecipientGroupRepository;
import zw.qantra.tm.domain.repositories.RecipientRepository;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -24,6 +35,347 @@ public class RecipientGroupService {
private final RecipientGroupRepository recipientGroupRepository;
private final RecipientGroupMemberRepository recipientGroupMemberRepository;
private final RecipientRepository recipientRepository;
private final GroupBatchRepository groupBatchRepository;
private final GroupBatchItemRepository groupBatchItemRepository;
private final ProviderService providerService;
/**
* Creates a recipient group, creates a batch from it, then updates batch items
* with bill/provider fields from the CSV file, and returns the new batch
* along with any per-row errors.
*
* CSV file structure: name, account, phone, billName, billProductName
*
* Steps:
* 1. Parse CSV rows, collecting per-row validation errors
* 2. Create recipient group from all valid rows
* 3. Create group batch from the group
* 4. Update batch items with billName & billProductName from CSV, skipping rows
* where the provider cannot be resolved and recording those errors
* 5. Return the batch + any row-level errors
*/
@Transactional
public GroupCreateFromFileResult createFromFile(MultipartFile file, GroupCreateFromFileRequest request, String userId) {
ParseResult parseResult = parseCsv(file);
if(!parseResult.parseErrors.isEmpty()) {
return GroupCreateFromFileResult.builder()
.totalRows(parseResult.rows.size() + parseResult.parseErrors.size())
.errorCount(parseResult.parseErrors.size())
.errors(parseResult.parseErrors)
.build();
}
if (parseResult.rows.isEmpty()) {
throw new ApiException("CSV file has no valid data rows after validation");
}
// Step 1: build recipient list from valid CSV rows only
List<Recipient> recipients = new ArrayList<>();
for (CsvRow row : parseResult.rows) {
Recipient recipient = Recipient.builder()
.name(row.name)
.account(row.account)
.phoneNumber(row.phone)
.userId(userId)
.build();
recipients.add(recipient);
}
// Step 2: create recipient group with recipients
RecipientGroup group = RecipientGroup.builder()
.name(request.getGroupName())
.description(request.getDescription())
.userId(userId)
.build();
RecipientGroup savedGroup = recipientGroupRepository.save(group);
List<RecipientGroupMember> members = addMembers(savedGroup.getId(), userId, recipients);
// Step 3: create group batch (inline to avoid circular dependency with GroupBatchService)
GroupBatch batch = createBatchInline(savedGroup.getId(), userId, request, members);
// Step 4: update batch items with bill/provider fields from CSV
List<GroupBatchItem> batchItems = groupBatchItemRepository.findAllByGroupBatchId(batch.getId());
List<GroupBatchItemUpdate> itemUpdates = new ArrayList<>();
List<GroupCreateFromFileResult.RowError> rowErrors = new ArrayList<>(parseResult.parseErrors);
for (int i = 0; i < batchItems.size() && i < parseResult.rows.size(); i++) {
GroupBatchItem batchItem = batchItems.get(i);
CsvRow row = parseResult.rows.get(i);
// Attempt to resolve provider by label
Provider provider = providerService.getProviderRepository().findByLabelAndCurrency(
row.billLabel, CurrencyType.valueOf(request.getCurrency()));
if (provider == null) {
rowErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(row.lineNumber)
.account(row.account)
.name(row.name)
.message("Provider not found for bill label: " + row.billLabel)
.build());
// Skip this row - item remains with default values from batch creation
continue;
}
itemUpdates.add(new GroupBatchItemUpdate(batchItem.getId(), row, provider));
}
// Apply successful item updates directly
if (!itemUpdates.isEmpty()) {
List<GroupCreateFromFileResult.RowError> providerErrors = updateBatchItemsInline(batch.getId(), itemUpdates);
if (!providerErrors.isEmpty()) {
return GroupCreateFromFileResult.builder()
.totalRows(parseResult.rows.size() + parseResult.parseErrors.size())
.errorCount(providerErrors.size())
.errors(providerErrors)
.build();
}
}
// Return the updated batch + errors
GroupBatch resultBatch = groupBatchRepository.findByIdAndUserId(batch.getId(), userId)
.orElseThrow(() -> new ApiException("Batch not found after creation"));
return GroupCreateFromFileResult.builder()
.batch(resultBatch)
.totalRows(parseResult.rows.size() + parseResult.parseErrors.size())
.successCount(itemUpdates.size())
.errorCount(rowErrors.size())
.errors(rowErrors)
.build();
}
/**
* Create a GroupBatch and its items directly (mirrors GroupBatchService.createBatch logic
* but without circular dependency).
*/
private GroupBatch createBatchInline(UUID recipientGroupId, String userId,
GroupCreateFromFileRequest request,
List<RecipientGroupMember> members) {
GroupBatch batch = GroupBatch.builder()
.recipientGroupId(recipientGroupId)
.userId(userId)
.description(request.getDescription())
.requestedBy(request.getRequestedBy())
.currency(request.getCurrency())
.status(GroupBatchStatus.CREATED)
.transactionTemplate(Utils.toJson(request.getTransactionTemplate()))
.build();
GroupBatch savedBatch = groupBatchRepository.save(batch);
int itemCount = 0;
for (RecipientGroupMember member : members) {
Recipient recipient = recipientRepository.findById(member.getRecipientUid())
.orElseThrow(() -> new ApiException("Recipient not found: " + member.getRecipientUid()));
GroupBatchItem item = GroupBatchItem.builder()
.groupBatchId(savedBatch.getId())
.recipientId(recipient.getId())
.recipientName(recipient.getName())
.recipientPhone(recipient.getPhoneNumber())
.status(GroupBatchItemStatus.NEW)
.confirmStatus(GroupBatchItemConfirmStatus.PENDING)
.integrationStatus(GroupBatchItemIntegrationStatus.PENDING)
.build();
itemCount++;
groupBatchItemRepository.save(item);
}
savedBatch.setCount(itemCount);
savedBatch.setValue(BigDecimal.ZERO);
savedBatch.setSuccessfulCount(0);
savedBatch.setFailedCount(0);
return groupBatchRepository.save(savedBatch);
}
/**
* Update batch items with CSV data directly (mirrors GroupBatchService.updateBatchItems logic
* but without circular dependency).
*/
private List<GroupCreateFromFileResult.RowError> updateBatchItemsInline(UUID batchId, List<GroupBatchItemUpdate> updates) {
List<GroupBatchItem> existingItems = groupBatchItemRepository.findAllByGroupBatchId(batchId);
java.util.Map<UUID, GroupBatchItem> itemMap = new java.util.HashMap<>();
for (GroupBatchItem item : existingItems) {
itemMap.put(item.getId(), item);
}
List<GroupBatchItem> updatedItems = new ArrayList<>();
List<GroupCreateFromFileResult.RowError> providerErrors = new ArrayList<>();
for (GroupBatchItemUpdate update : updates) {
GroupBatchItem item = itemMap.get(update.batchItemId);
if (item == null) {
continue;
}
try {
item.setRecipientName(update.row.name);
item.setRecipientPhone(update.row.phone);
item.setBillName(update.provider.getName());
item.setBillProductName(update.row.billProductName);
item.setProviderLabel(update.row.billLabel);
item.setProviderImage(update.provider.getImage());
item.setBillClientId(update.provider.getClientId());
if(update.row.billProductName.isBlank() && update.row.amount.compareTo(BigDecimal.ZERO) != 0) {
item.setAmount(update.row.amount);
} else if (!update.row.billProductName.isBlank()) {
ProductDto productDto = providerService.getProviderProduct(
update.provider.getId(), update.row.billProductName);
if( productDto == null) {
throw new ApiException("Product not found for provider: " + update.provider.getName() +
", product name: " + update.row.billProductName);
}
item.setAmount(productDto.getDefaultAmount());
}
updatedItems.add(groupBatchItemRepository.save(item));
} catch (Exception e) {
providerErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(update.row.lineNumber)
.account(update.row.account)
.name(update.row.name)
.message("Failed to update batch item: " + e.getMessage())
.build());
}
}
// Recalculate batch totals
if (!updatedItems.isEmpty()) {
List<GroupBatchItem> allItems = groupBatchItemRepository.findAllByGroupBatchId(batchId);
BigDecimal newTotalValue = allItems.stream()
.map(GroupBatchItem::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
GroupBatch batch = groupBatchRepository.findById(batchId)
.orElseThrow(() -> new ApiException("Batch not found"));
batch.setValue(newTotalValue);
batch.setCount(allItems.size());
groupBatchRepository.save(batch);
}
return providerErrors;
}
private record GroupBatchItemUpdate(UUID batchItemId, CsvRow row, Provider provider) {}
/**
* Parse CSV rows, collecting validation errors per row rather than failing fast.
*/
private ParseResult parseCsv(MultipartFile file) {
List<CsvRow> rows = new ArrayList<>();
List<GroupCreateFromFileResult.RowError> parseErrors = new ArrayList<>();
try (Reader reader = new InputStreamReader(file.getInputStream());
BufferedReader br = new BufferedReader(reader)) {
String headerLine = br.readLine(); // skip header
if (headerLine == null) {
throw new ApiException("CSV file is empty");
}
String line;
int lineNum = 1;
while ((line = br.readLine()) != null) {
lineNum++;
if (line.isBlank()) {
continue;
}
String[] parts = parseCsvLine(line);
if (parts.length < 5) {
parseErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(lineNum)
.message("Invalid CSV format: expected at least 5 columns (name, account, phone, billName, billProductName) but got " + parts.length)
.build());
continue;
}
String name = parts[0].trim();
String account = parts[1].trim();
String phone = parts[2].trim();
String billLabel = parts[3].trim();
String billProductName = parts[4].trim();
String amount = parts[5].trim();
if (name.isEmpty() || account.isEmpty()) {
parseErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(lineNum)
.account(account)
.name(name)
.message("name and account are required")
.build());
continue;
}
if(amount.isEmpty() && billProductName.isEmpty()) {
parseErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(lineNum)
.account(account)
.name(name)
.message("amount or billProductName is required")
.build());
continue;
}
CsvRow row = new CsvRow();
row.lineNumber = lineNum;
row.name = name;
row.account = account;
row.phone = phone;
row.billLabel = billLabel;
row.billProductName = billProductName;
row.amount = !amount.isEmpty() ? new BigDecimal(amount) : BigDecimal.ZERO;
rows.add(row);
}
if (rows.isEmpty() && parseErrors.isEmpty()) {
throw new ApiException("CSV file has no data rows");
}
} catch (IOException e) {
throw new ApiException("Failed to read CSV file: " + e.getMessage());
}
return new ParseResult(rows, parseErrors);
}
/**
* Parse a CSV line respecting simple double-quoted fields.
*/
private String[] parseCsvLine(String line) {
List<String> fields = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (c == '"') {
inQuotes = !inQuotes;
} else if (c == ',' && !inQuotes) {
fields.add(current.toString());
current = new StringBuilder();
} else {
current.append(c);
}
}
fields.add(current.toString());
return fields.toArray(new String[0]);
}
private static class CsvRow {
int lineNumber;
String name;
String account;
String phone;
String billLabel;
String billProductName;
BigDecimal amount;
}
private record ParseResult(List<CsvRow> rows, List<GroupCreateFromFileResult.RowError> parseErrors) {}
@Transactional
public RecipientGroup create(RecipientGroup group, List<Recipient> recipients) {
@@ -43,10 +395,6 @@ public class RecipientGroupService {
for (Recipient incomingRecipient : recipients) {
Recipient recipient = resolveOrCreateRecipient(incomingRecipient, userId);
if (recipientGroupMemberRepository.existsByRecipientGroupIdAndRecipientId(groupId, recipient.getId())) {
continue;
}
RecipientGroupMember member = RecipientGroupMember.builder()
.recipientGroupId(groupId)
.recipientUid(recipient.getId())
@@ -154,5 +502,4 @@ public class RecipientGroupService {
public Page<RecipientGroupMember> findAllMembers(Specification<RecipientGroupMember> spec, Pageable pageable) {
return recipientGroupMemberRepository.findAll(spec, pageable);
}
}
}