借书/还书/续借/逾期管理
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
package com.mzh.library.service.impl;
|
||||
|
||||
import com.mzh.library.dao.BorrowRecordDao;
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.Book;
|
||||
import com.mzh.library.entity.BookStatus;
|
||||
import com.mzh.library.entity.BorrowRecord;
|
||||
import com.mzh.library.entity.BorrowRecordSearchCriteria;
|
||||
import com.mzh.library.entity.BorrowRecordStatus;
|
||||
import com.mzh.library.entity.Permission;
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderStatus;
|
||||
import com.mzh.library.entity.Role;
|
||||
import com.mzh.library.exception.DaoException;
|
||||
import com.mzh.library.service.BorrowingService;
|
||||
import com.mzh.library.service.PermissionPolicy;
|
||||
import com.mzh.library.service.ServiceResult;
|
||||
import com.mzh.library.util.JdbcUtil;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class BorrowingServiceImpl implements BorrowingService {
|
||||
public interface TransactionExecutor {
|
||||
<T> T execute(JdbcUtil.TransactionCallback<T> callback);
|
||||
}
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(BorrowingServiceImpl.class.getName());
|
||||
private static final String UNAVAILABLE_MESSAGE =
|
||||
"Borrowing service is temporarily unavailable. Please try again later.";
|
||||
private static final String VALIDATION_MESSAGE = "Please correct the highlighted borrowing fields.";
|
||||
private static final String DENIED_MESSAGE = "You do not have permission to manage borrowing.";
|
||||
private static final String HISTORY_DENIED_MESSAGE = "You do not have permission to view loan history.";
|
||||
private static final int LOAN_DAYS = 14;
|
||||
private static final int MAX_RENEWALS = 1;
|
||||
|
||||
private final BorrowRecordDao borrowRecordDao;
|
||||
private final PermissionPolicy permissionPolicy;
|
||||
private final Clock clock;
|
||||
private final TransactionExecutor transactionExecutor;
|
||||
|
||||
public BorrowingServiceImpl(BorrowRecordDao borrowRecordDao) {
|
||||
this(borrowRecordDao, new PermissionPolicy(), Clock.systemDefaultZone(), new JdbcTransactionExecutor());
|
||||
}
|
||||
|
||||
public BorrowingServiceImpl(BorrowRecordDao borrowRecordDao, PermissionPolicy permissionPolicy, Clock clock,
|
||||
TransactionExecutor transactionExecutor) {
|
||||
this.borrowRecordDao = borrowRecordDao;
|
||||
this.permissionPolicy = permissionPolicy;
|
||||
this.clock = clock;
|
||||
this.transactionExecutor = transactionExecutor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<List<BorrowRecord>> searchRecords(AuthenticatedUser actor, BorrowRecordSearchCriteria criteria) {
|
||||
if (!canManageBorrowing(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
BorrowRecordSearchCriteria normalized = criteria == null ? new BorrowRecordSearchCriteria() : criteria;
|
||||
Map<String, String> errors = validateSearch(normalized);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure("Please correct the borrowing search filters.", errors);
|
||||
}
|
||||
|
||||
try {
|
||||
return ServiceResult.success(borrowRecordDao.search(normalized));
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to search borrow records actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Long> borrowBook(AuthenticatedUser actor, String readerIdentifier, String bookIdentifier) {
|
||||
if (!canManageBorrowing(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
String normalizedReaderIdentifier = trim(readerIdentifier);
|
||||
String normalizedBookIdentifier = trim(bookIdentifier);
|
||||
Map<String, String> errors = validateBorrowIdentifiers(normalizedReaderIdentifier, normalizedBookIdentifier);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
try {
|
||||
return transactionExecutor.execute(connection -> {
|
||||
Map<String, String> transactionErrors = new LinkedHashMap<>();
|
||||
Optional<Reader> readerResult = borrowRecordDao.findReaderByIdentifierForUpdate(connection,
|
||||
normalizedReaderIdentifier);
|
||||
if (!readerResult.isPresent()) {
|
||||
transactionErrors.put("readerIdentifier", "Reader was not found.");
|
||||
}
|
||||
|
||||
Optional<Book> bookResult = borrowRecordDao.findBookByIdentifierForUpdate(connection,
|
||||
normalizedBookIdentifier);
|
||||
if (!bookResult.isPresent()) {
|
||||
transactionErrors.put("bookIdentifier", "Book was not found.");
|
||||
}
|
||||
|
||||
if (!transactionErrors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, transactionErrors);
|
||||
}
|
||||
|
||||
Reader reader = readerResult.get();
|
||||
Book book = bookResult.get();
|
||||
validateBorrowEligibility(transactionErrors, reader, book, connection);
|
||||
if (!transactionErrors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, transactionErrors);
|
||||
}
|
||||
|
||||
LocalDateTime borrowedAt = now();
|
||||
BorrowRecord record = new BorrowRecord();
|
||||
record.setReaderId(reader.getId());
|
||||
record.setBookId(book.getId());
|
||||
record.setBorrowedAt(borrowedAt);
|
||||
record.setDueAt(borrowedAt.plusDays(LOAN_DAYS));
|
||||
record.setRenewalCount(0);
|
||||
record.setStatus(BorrowRecordStatus.ACTIVE);
|
||||
|
||||
long id = borrowRecordDao.create(connection, record);
|
||||
if (!borrowRecordDao.decrementAvailableCopies(connection, book.getId())) {
|
||||
throw new DaoException("Book inventory was not decremented for borrow record " + id, null);
|
||||
}
|
||||
|
||||
LOGGER.info("Borrowed book recordId=" + id + " readerId=" + reader.getId()
|
||||
+ " bookId=" + book.getId() + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(id, "Book borrowed.");
|
||||
});
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to borrow book actorId=" + actor.getId()
|
||||
+ " readerIdentifier=" + normalizedReaderIdentifier
|
||||
+ " bookIdentifier=" + normalizedBookIdentifier, ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Void> returnBook(AuthenticatedUser actor, long recordId) {
|
||||
if (!canManageBorrowing(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
if (recordId <= 0) {
|
||||
return ServiceResult.failure("Select a valid borrowing record.");
|
||||
}
|
||||
|
||||
try {
|
||||
return transactionExecutor.execute(connection -> {
|
||||
Optional<BorrowRecord> recordResult = borrowRecordDao.findByIdForUpdate(connection, recordId);
|
||||
if (!recordResult.isPresent()) {
|
||||
return ServiceResult.failure("Borrowing record was not found.");
|
||||
}
|
||||
|
||||
BorrowRecord record = recordResult.get();
|
||||
Map<String, String> errors = validateActiveLoan(record);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure("Borrowing record cannot be returned.", errors);
|
||||
}
|
||||
|
||||
if (!borrowRecordDao.markReturned(connection, recordId, now())) {
|
||||
throw new DaoException("Borrow record was not marked returned: " + recordId, null);
|
||||
}
|
||||
borrowRecordDao.incrementAvailableCopies(connection, record.getBookId());
|
||||
|
||||
LOGGER.info("Returned borrow recordId=" + recordId + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(null, "Book returned.");
|
||||
});
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to return borrow record id=" + recordId + " actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Void> renewLoan(AuthenticatedUser actor, long recordId) {
|
||||
if (!canManageBorrowing(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
if (recordId <= 0) {
|
||||
return ServiceResult.failure("Select a valid borrowing record.");
|
||||
}
|
||||
|
||||
try {
|
||||
return transactionExecutor.execute(connection -> {
|
||||
Optional<BorrowRecord> recordResult = borrowRecordDao.findByIdForUpdate(connection, recordId);
|
||||
if (!recordResult.isPresent()) {
|
||||
return ServiceResult.failure("Borrowing record was not found.");
|
||||
}
|
||||
|
||||
BorrowRecord record = recordResult.get();
|
||||
Map<String, String> errors = validateActiveLoan(record);
|
||||
if (record.getRenewalCount() >= MAX_RENEWALS) {
|
||||
errors.put("renewalCount", "This loan has already reached the renewal limit.");
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure("Borrowing record cannot be renewed.", errors);
|
||||
}
|
||||
|
||||
LocalDateTime currentDueAt = record.getDueAt() == null ? now() : record.getDueAt();
|
||||
LocalDateTime newDueAt = currentDueAt.plusDays(LOAN_DAYS);
|
||||
if (!borrowRecordDao.renew(connection, recordId, newDueAt)) {
|
||||
throw new DaoException("Borrow record was not renewed: " + recordId, null);
|
||||
}
|
||||
|
||||
LOGGER.info("Renewed borrow recordId=" + recordId + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(null, "Loan renewed.");
|
||||
});
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to renew borrow record id=" + recordId + " actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<List<BorrowRecord>> listCurrentReaderHistory(AuthenticatedUser actor) {
|
||||
if (!canViewOwnHistory(actor)) {
|
||||
return ServiceResult.failure(HISTORY_DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
try {
|
||||
Optional<Reader> readerResult = borrowRecordDao.findReaderByUserId(actor.getId());
|
||||
if (!readerResult.isPresent()) {
|
||||
return ServiceResult.success(Collections.emptyList(), "No reader profile is linked to your account.");
|
||||
}
|
||||
|
||||
return ServiceResult.success(borrowRecordDao.findByReaderId(readerResult.get().getId()));
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to load reader loan history actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaxRenewals() {
|
||||
return MAX_RENEWALS;
|
||||
}
|
||||
|
||||
private void validateBorrowEligibility(Map<String, String> errors, Reader reader, Book book,
|
||||
java.sql.Connection connection) {
|
||||
if (reader.getStatus() != ReaderStatus.ACTIVE) {
|
||||
errors.put("readerIdentifier", "Reader must be active to borrow books.");
|
||||
}
|
||||
int activeLoans = borrowRecordDao.countActiveByReaderId(connection, reader.getId());
|
||||
if (activeLoans >= reader.getMaxBorrowCount()) {
|
||||
errors.put("readerIdentifier", "Reader has reached the active borrowing limit.");
|
||||
}
|
||||
if (book.getStatus() != BookStatus.AVAILABLE) {
|
||||
errors.put("bookIdentifier", "Book status does not allow borrowing.");
|
||||
} else if (book.getAvailableCopies() <= 0) {
|
||||
errors.put("bookIdentifier", "No available copies remain for this book.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> validateSearch(BorrowRecordSearchCriteria criteria) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
String statusCode = trim(criteria.getStatusCode());
|
||||
criteria.setReaderIdentifier(criteria.getReaderIdentifier());
|
||||
criteria.setBookIdentifier(criteria.getBookIdentifier());
|
||||
criteria.setStatusCode(statusCode);
|
||||
|
||||
if (!statusCode.isEmpty() && !BorrowRecordSearchCriteria.OVERDUE_STATUS.equals(statusCode)) {
|
||||
try {
|
||||
BorrowRecordStatus.fromCode(statusCode);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
errors.put("status", "Select a valid borrowing status.");
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private Map<String, String> validateBorrowIdentifiers(String readerIdentifier, String bookIdentifier) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
requireLength(errors, "readerIdentifier", readerIdentifier, "Reader ID", 64);
|
||||
requireLength(errors, "bookIdentifier", bookIdentifier, "Book ID", 64);
|
||||
return errors;
|
||||
}
|
||||
|
||||
private Map<String, String> validateActiveLoan(BorrowRecord record) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
if (record.getStatus() != BorrowRecordStatus.ACTIVE || record.getReturnedAt() != null) {
|
||||
errors.put("status", "Only active loans can use this action.");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void requireLength(Map<String, String> errors, String field, String value, String label, int maxLength) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
errors.put(field, label + " is required.");
|
||||
return;
|
||||
}
|
||||
if (value.length() > maxLength) {
|
||||
errors.put(field, label + " must be " + maxLength + " characters or fewer.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canManageBorrowing(AuthenticatedUser actor) {
|
||||
return actor != null && permissionPolicy.allows(actor.getRole(), Permission.MANAGE_BORROWING);
|
||||
}
|
||||
|
||||
private boolean canViewOwnHistory(AuthenticatedUser actor) {
|
||||
return actor != null
|
||||
&& actor.getRole() == Role.READER
|
||||
&& permissionPolicy.allows(actor.getRole(), Permission.BORROW_BOOKS);
|
||||
}
|
||||
|
||||
private LocalDateTime now() {
|
||||
return LocalDateTime.now(clock);
|
||||
}
|
||||
|
||||
private String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static final class JdbcTransactionExecutor implements TransactionExecutor {
|
||||
@Override
|
||||
public <T> T execute(JdbcUtil.TransactionCallback<T> callback) {
|
||||
return JdbcUtil.executeInTransaction(callback);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DirectTransactionExecutor implements TransactionExecutor {
|
||||
@Override
|
||||
public <T> T execute(JdbcUtil.TransactionCallback<T> callback) {
|
||||
try {
|
||||
return callback.execute(null);
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to execute direct transaction", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user