512 lines
22 KiB
Java
512 lines
22 KiB
Java
package zw.qantra.tm.domain.services;
|
|
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.data.domain.Page;
|
|
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 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;
|
|
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
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) {
|
|
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(request.getUserId().toString())
|
|
.workspaceId(request.getWorkspaceId())
|
|
.build();
|
|
recipients.add(recipient);
|
|
}
|
|
|
|
// Step 2: create recipient group with recipients
|
|
RecipientGroup group = RecipientGroup.builder()
|
|
.name(request.getGroupName())
|
|
.description(request.getDescription())
|
|
.userId(request.getUserId().toString())
|
|
.workspaceId(request.getWorkspaceId())
|
|
.build();
|
|
|
|
RecipientGroup savedGroup = recipientGroupRepository.save(group);
|
|
|
|
List<RecipientGroupMember> members = addMembers(savedGroup.getId(), request.getWorkspaceId(), recipients);
|
|
|
|
// Step 3: create group batch (inline to avoid circular dependency with GroupBatchService)
|
|
GroupBatch batch = createBatchInline(savedGroup.getId(), request.getWorkspaceId(), 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.findByIdAndWorkspaceId(batch.getId(), request.getWorkspaceId())
|
|
.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, UUID workspaceId,
|
|
GroupCreateFromFileRequest request,
|
|
List<RecipientGroupMember> members) {
|
|
GroupBatch batch = GroupBatch.builder()
|
|
.recipientGroupId(recipientGroupId)
|
|
.userId(request.getUserId().toString())
|
|
.workspaceId(workspaceId)
|
|
.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())
|
|
.recipientAccount(recipient.getAccount())
|
|
.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.setRecipientAccount(update.row.account);
|
|
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));
|
|
|
|
// Recalculate batch totals
|
|
BigDecimal newTotalValue = updatedItems.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(updatedItems.size());
|
|
groupBatchRepository.save(batch);
|
|
} catch (Exception e) {
|
|
e.printStackTrace();
|
|
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());
|
|
}
|
|
}
|
|
|
|
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) {
|
|
RecipientGroup savedGroup = recipientGroupRepository.save(group);
|
|
|
|
if (recipients != null && !recipients.isEmpty()) {
|
|
addMembers(savedGroup.getId(), savedGroup.getWorkspaceId(), recipients);
|
|
}
|
|
|
|
return savedGroup;
|
|
}
|
|
|
|
@Transactional
|
|
public List<RecipientGroupMember> addMembers(UUID groupId, UUID workspaceId, List<Recipient> recipients) {
|
|
get(groupId, workspaceId);
|
|
List<RecipientGroupMember> added = new ArrayList<>();
|
|
for (Recipient incomingRecipient : recipients) {
|
|
Recipient recipient = resolveOrCreateRecipient(incomingRecipient, workspaceId);
|
|
|
|
RecipientGroupMember member = RecipientGroupMember.builder()
|
|
.recipientGroupId(groupId)
|
|
.recipientUid(recipient.getId())
|
|
.recipient(recipient)
|
|
.account(recipient.getAccount())
|
|
.workspaceId(workspaceId)
|
|
.userId(incomingRecipient.getUserId())
|
|
.build();
|
|
added.add(recipientGroupMemberRepository.save(member));
|
|
}
|
|
return added;
|
|
}
|
|
|
|
public Page<RecipientGroup> findAll(Specification<RecipientGroup> spec, Pageable pageable) {
|
|
return recipientGroupRepository.findAll(spec, pageable);
|
|
}
|
|
|
|
public List<RecipientGroup> list(UUID workspaceId) {
|
|
return recipientGroupRepository.findAllByWorkspaceId(workspaceId);
|
|
}
|
|
|
|
public RecipientGroup get(UUID groupId, UUID workspaceId) {
|
|
return recipientGroupRepository.findByIdAndWorkspaceId(groupId, workspaceId)
|
|
.orElseThrow(() -> new ApiException("Recipient group not found"));
|
|
}
|
|
|
|
public RecipientGroup update(UUID groupId, UUID workspaceId, String name, String description) {
|
|
RecipientGroup group = get(groupId, workspaceId);
|
|
group.setName(name);
|
|
group.setDescription(description);
|
|
return recipientGroupRepository.save(group);
|
|
}
|
|
|
|
@Transactional
|
|
public void delete(UUID groupId, UUID workspaceId) {
|
|
RecipientGroup group = get(groupId, workspaceId);
|
|
group.setDeleted(true);
|
|
recipientGroupRepository.save(group);
|
|
|
|
List<RecipientGroupMember> members = recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
|
|
members.forEach(member -> member.setDeleted(true));
|
|
recipientGroupMemberRepository.saveAll(members);
|
|
}
|
|
|
|
@Transactional
|
|
public List<RecipientGroupMember> addMembersByIds(UUID groupId, UUID workspaceId, List<UUID> recipientIds) {
|
|
get(groupId, workspaceId);
|
|
List<RecipientGroupMember> added = new ArrayList<>();
|
|
for (UUID recipientId : recipientIds) {
|
|
Recipient recipient = recipientRepository.findById(recipientId)
|
|
.orElseThrow(() -> new ApiException("Recipient not found: " + recipientId));
|
|
if (!workspaceId.equals(recipient.getWorkspaceId())) {
|
|
throw new ApiException("Recipient does not belong to workspace");
|
|
}
|
|
if (recipientGroupMemberRepository.existsByRecipientGroupIdAndRecipientId(groupId, recipientId)) {
|
|
continue;
|
|
}
|
|
RecipientGroupMember member = RecipientGroupMember.builder()
|
|
.recipientGroupId(groupId)
|
|
.recipientUid(recipientId)
|
|
.recipient(recipient)
|
|
.account(recipient.getAccount())
|
|
.workspaceId(workspaceId)
|
|
.build();
|
|
added.add(recipientGroupMemberRepository.save(member));
|
|
}
|
|
return added;
|
|
}
|
|
|
|
private Recipient resolveOrCreateRecipient(Recipient incomingRecipient, UUID fallbackWorkspaceId) {
|
|
if (incomingRecipient == null || incomingRecipient.getAccount() == null || incomingRecipient.getAccount().isBlank()) {
|
|
throw new ApiException("Recipient account is required");
|
|
}
|
|
|
|
UUID recipientWorkspaceId = incomingRecipient.getWorkspaceId() == null
|
|
? fallbackWorkspaceId
|
|
: incomingRecipient.getWorkspaceId();
|
|
|
|
List<Recipient> existing = recipientRepository.findByAccountAndWorkspaceId(incomingRecipient.getAccount(), recipientWorkspaceId);
|
|
if (!existing.isEmpty()) {
|
|
Recipient recipient = existing.get(0);
|
|
recipient.setName(incomingRecipient.getName());
|
|
recipient.setPhoneNumber(incomingRecipient.getPhoneNumber());
|
|
return recipientRepository.save(recipient);
|
|
}
|
|
|
|
incomingRecipient.setId(null);
|
|
incomingRecipient.setWorkspaceId(recipientWorkspaceId);
|
|
return recipientRepository.save(incomingRecipient);
|
|
}
|
|
|
|
@Transactional
|
|
public void removeMember(UUID groupId, UUID recipientId, UUID workspaceId) {
|
|
get(groupId, workspaceId);
|
|
List<RecipientGroupMember> members = recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
|
|
RecipientGroupMember target = members.stream()
|
|
.filter(item -> recipientId.equals(item.getRecipientUid()))
|
|
.findFirst()
|
|
.orElseThrow(() -> new ApiException("Group member not found"));
|
|
target.setDeleted(true);
|
|
recipientGroupMemberRepository.save(target);
|
|
}
|
|
|
|
public List<RecipientGroupMember> members(UUID groupId, UUID workspaceId) {
|
|
get(groupId, workspaceId);
|
|
return recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
|
|
}
|
|
|
|
public Page<RecipientGroupMember> findAllMembers(Specification<RecipientGroupMember> spec, Pageable pageable) {
|
|
return recipientGroupMemberRepository.findAll(spec, pageable);
|
|
}
|
|
} |