added batch file upload feature
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user