diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java index 6b4c43e63..ed6891586 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java @@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository, JpaSpecif Collection findTellerByOfficeId(Long officeId); long countByDeletedFalse(); + + long countByOfficeIdAndDeletedFalse(Long officeId); } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java index 494c4069c..b29f3d19c 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java @@ -58,4 +58,9 @@ public class TellerRepositoryWrapper { public boolean existsAny() { return this.repository.countByDeletedFalse() > 0; } + + /** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */ + public boolean existsInOffice(final Long officeId) { + return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0; + } } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java index a465aa44d..973eca197 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java @@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce public TellerAlreadyExistsException() { super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE); } + + public TellerAlreadyExistsException(final String officeName) { + super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.", + officeName); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java index 2f05b3d4a..0af6f6811 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java @@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement @Override public Collection getTellers(Long officeId) { - return retrieveAllTellers(false); + if (officeId == null) { + return retrieveAllTellers(false); + } + // The tellers of that one branch. The officeId used to be ignored, so the head office, whose + // hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close. + return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList(); } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java index f4aaf9f4a..e8fe22949 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java @@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer try { this.context.authenticatedUser(); - if (this.tellerRepositoryWrapper.existsAny()) { - throw new TellerAlreadyExistsException(); - } - final Long officeId = command.longValueOfParameterNamed("officeId"); this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json()); @@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer // final Office parent = // validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId); final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + + // One teller (vault) per branch, not one for the whole bank: each branch holds and settles + // its own cash, so a second branch needs a vault of its own. + if (this.tellerRepositoryWrapper.existsInOffice(officeId)) { + throw new TellerAlreadyExistsException(tellerOffice.getName()); + } final Teller teller = Teller.fromJson(tellerOffice, command); // pre save to generate id for use in office hierarchy diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 71920ecff..de005116a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -71,6 +71,20 @@ import org.springframework.transaction.annotation.Transactional; @Produces({ MediaType.APPLICATION_JSON }) public class BranchDayRegisterApiResource { + /** + * The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier + * allocation if that allocation already existed when the cash was counted, otherwise the officer's + * own office. A person moved to another branch's teller keeps what they took at the old branch + * there. + */ + private static final String COUNTER_OFFICE_OF_STD = """ + COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id + JOIN m_tellers t ON t.id = mc.teller_id + WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """ + LIMIT 1), + (SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id)) + """; + private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; @@ -195,8 +209,8 @@ public class BranchDayRegisterApiResource { // Scoped to this office through the officer who took the cash (appuser -> staff -> cashier -> // teller -> office). Unscoped, a branch's own "does the physical count agree with the books" // cross-check was being compared against every branch's counted cash at once, which makes it - // wrong at every office but the first. A row whose officer maps to no cashier is still - // counted, for the same reason as the withdrawal legs in bookBreakdown. + // wrong at every office but the first. A row whose officer has no cashier allocation counts + // at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office. // // Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the // note there on why a manual GL entry's decoded reference is kept and a stamped millisecond @@ -205,23 +219,15 @@ public class BranchDayRegisterApiResource { SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT' AND """ + CountedCashLedger.COUNTED_ROW + """ - AND (std.officer_id IS NULL - OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - WHERE au.id = std.officer_id) - OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - JOIN m_tellers t ON t.id = c.teller_id - WHERE au.id = std.officer_id AND t.office_id = ?)) + AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """ + ) = ?) """, BigDecimal.class, Date.valueOf(businessDate), officeId)); final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL' AND """ + CountedCashLedger.COUNTED_ROW + """ - AND (std.officer_id IS NULL - OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - WHERE au.id = std.officer_id) - OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - JOIN m_tellers t ON t.id = c.teller_id - WHERE au.id = std.officer_id AND t.office_id = ?)) + AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """ + ) = ?) """, BigDecimal.class, Date.valueOf(businessDate), officeId)); result.put("denominationIn", countedIn); result.put("denominationOut", countedOut); @@ -609,11 +615,9 @@ public class BranchDayRegisterApiResource { // never opened is not waited for. final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId); if (isHeadOffice) { - final List stillOpen = jdbcTemplate.queryForList(""" - SELECT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id - WHERE r.business_date = ? AND r.closed_at IS NULL AND r.office_id <> ? - ORDER BY o.name - """, String.class, Date.valueOf(businessDate), officeId); + // Any day still open counts, not only today's: a branch whose earlier day was never closed is + // still working and has not posted its interest. + final List stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId); if (!stillOpen.isEmpty()) { throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open", "Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen) @@ -1058,6 +1062,9 @@ public class BranchDayRegisterApiResource { final Map r = rows.get(0); final Map m = new LinkedHashMap<>(); m.put("id", r.get("id")); + // The close screen fetches this office's tellers with it; left out, it fetched every teller + // the user can see, and the head office counted each branch's vault as its own. + m.put("officeId", r.get("office_id")); m.put("businessDate", str(r.get("business_date"))); m.put("openedByName", r.get("opened_by_name")); m.put("openedAt", str(r.get("opened_at"))); @@ -1111,11 +1118,13 @@ public class BranchDayRegisterApiResource { (rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null); final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate); + // By the counter that took the cash. A savings deposit carries no office_id, and the old + // "OR office_id IS NULL" counted every such deposit in the bank at every branch. final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request WHERE status = 'APPROVED' AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? - AND (office_id = ? OR office_id IS NULL) + AND COALESCE(counter_office_id, office_id) = ? """, BigDecimal.class, date, officeId)); // Cash paid over the counter to buy a demand draft is real cash arriving at the branch too -- diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 5b71779ea..003da0a43 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource { client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at, - dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource { "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), denominationData, activityLog, Timestamp.from(Instant.now()), - ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId + ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser) ); // Globally unique voucher number from the central m_voucher register @@ -691,6 +691,20 @@ public class CashierDepositRequestApiResource { .orElse(Map.of()); } + /** + * The branch whose counter took the cash: the office of the teller the user is allocated to as a + * cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries + * none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it. + */ + private Long counterOfficeId(final AppUser user) { + final List tellerOffice = user.getStaff() == null ? List.of() + : jdbcTemplate.queryForList(""" + SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id + WHERE c.staff_id = ? LIMIT 1 + """, Long.class, user.getStaff().getId()); + return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0); + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, voucher_no, account_no, account_type, savings_account_id, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java index 19a757709..954adbd8e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -93,7 +93,7 @@ public class CashReconciliationApiResource { + "AND " + CountedCashLedger.COUNTED_ROW + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Out: settlements to vault @@ -123,7 +123,7 @@ public class CashReconciliationApiResource { + "AND " + CountedCashLedger.COUNTED_ROW + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // In: cash exchange — customer gave denomination TO cashier (RECEIVED direction) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 761ca117f..89a350f01 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -671,25 +671,25 @@ public class CashReportApiResource { // day itself: taken from the vault, in over the counter, out over the counter, settled // back. What is left is what should physically be in the drawer. result.put("todayVaultIn", dayLegTotal( - "SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd" + "SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd" + " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id" + " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)" + " AND COALESCE(ct.is_reversed, false) = false", scopedCashierId, txnDate)); result.put("todaySettledOut", dayLegTotal( - "SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd" + "SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd" + " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id" + " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)" + " AND COALESCE(cs.is_reversed, false) = false", scopedCashierId, txnDate)); result.put("todayInterCashierIn", dayLegTotal( - "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + "SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + " AND COALESCE(ic.is_reversed, false) = false", scopedCashierId, txnDate)); result.put("todayInterCashierOut", dayLegTotal( - "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + "SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + " AND COALESCE(ic.is_reversed, false) = false", @@ -721,7 +721,7 @@ public class CashReportApiResource { final Long scopedCashierId) { final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : ""; final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : ""; final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : ""; @@ -1171,7 +1171,7 @@ public class CashReportApiResource { + "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Settlements back to vault @@ -1197,7 +1197,7 @@ public class CashReportApiResource { + "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Exchange IN: customer gave denomination to cashier (RECEIVED direction) @@ -1315,13 +1315,13 @@ public class CashReportApiResource { if (cashierId != null) { officerFilter = "AND std.officer_id IN (" + "SELECT au.id FROM m_appuser au " - + "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"; + + "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"; baseParams = new Object[] { txnDate, cashierId }; } else if (tellerId != null) { officerFilter = "AND std.officer_id IN (" + "SELECT au.id FROM m_appuser au " + "JOIN m_cashiers mc ON mc.staff_id = au.staff_id " - + "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)"; + + "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"; baseParams = new Object[] { txnDate, tellerId }; } else { officerFilter = "AND std.officer_id IN (" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java index d07b43810..9fe4d41da 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java @@ -130,9 +130,19 @@ public final class CountedCashLedger { + " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))" + " AND" + REAL_TXN_ID + ") "; + /** + * Over the aliases {@code std} and {@code mc} (the cashier a breakdown's officer maps to): only what + * the login counted once this allocation existed. A breakdown is tied to a cashier through its + * officer's login, so without this a person moved from one branch's teller to another's carried + * every rupee they had ever taken -- at the old branch too -- into the new drawer. An allocation + * with no recorded creation time keeps its whole history. + */ + public static final String SINCE_ALLOCATION = " AND (mc.created_at IS NULL OR std.created_at IS NULL" + + " OR std.created_at >= mc.created_at)"; + /** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */ private static String breakdownDiffersFrom(final String amountColumn) { - return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)" + return "ABS(" + amountColumn + " - (SELECT SUM(CAST(sd.count AS BIGINT) * sd.denomination_value)" + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id" + " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1"; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java index 02a6498af..8fa7c4fd7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java @@ -288,7 +288,7 @@ public class DenominationBreakdownWriter { + " WHERE std.txn_type = 'DEPOSIT'" + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)" @@ -312,7 +312,7 @@ public class DenominationBreakdownWriter { + " WHERE std.txn_type = 'WITHDRAWAL'" + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)" @@ -409,7 +409,7 @@ public class DenominationBreakdownWriter { + " WHERE std.txn_type = 'DEPOSIT'" + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)" @@ -433,7 +433,7 @@ public class DenominationBreakdownWriter { + " WHERE std.txn_type = 'WITHDRAWAL'" + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java index e3a7999c5..cc6c6206c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java @@ -222,9 +222,10 @@ public class SavingsInterestPostingReportService { return jdbcTemplate.queryForList(""" SELECT r.id FROM m_savings_interest_posting_report r WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ? - AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id AND b.business_date = ?) + AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id + AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?))) ORDER BY r.posting_date, r.office_id, r.savings_product_id - """, Long.class, businessDate, headOfficeId, businessDate); + """, Long.class, businessDate, headOfficeId, businessDate, businessDate); } /** Every approved report still waiting, for the nightly safety net. */ @@ -245,15 +246,15 @@ public class SavingsInterestPostingReportService { // ── reading ────────────────────────────────────────────────────────────────────────────────── /** - * Reports for the screen. A branch user sees only their branch; a head office user sees every - * branch, so it can act for one that did not open. canAct says whether this user may regenerate - * or approve the report. + * Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for a + * head office user, is every other branch (or one, with officeId). canAct says whether this user + * may regenerate or approve the report, and blockedReason why not. */ public List> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId, - final AppUser user) { + final String scope, final AppUser user) { final Long userOffice = officeOf(user); - final boolean headOffice = isHeadOffice(userOffice); - final Long branch = headOffice ? officeId : userOffice; + final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice); + final Long branch = others ? officeId : userOffice; final StringBuilder sql = new StringBuilder(""" SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId", @@ -287,6 +288,10 @@ public class SavingsInterestPostingReportService { sql.append(" AND r.office_id = ?"); params.add(branch); } + if (others) { + sql.append(" AND r.office_id <> ?"); + params.add(userOffice); + } sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC"); final LocalDate businessDate = DateUtils.getBusinessLocalDate(); @@ -294,7 +299,9 @@ public class SavingsInterestPostingReportService { for (final Map row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) { final Map out = new LinkedHashMap<>(row); final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue(); - out.put("canAct", reportOffice != null && mayAct(userOffice, reportOffice, businessDate)); + final String blocked = reportOffice == null ? "No branch on this report." : actBlockedReason(userOffice, reportOffice, businessDate); + out.put("canAct", blocked == null); + out.put("blockedReason", blocked); rows.add(out); } return rows; @@ -339,6 +346,45 @@ public class SavingsInterestPostingReportService { return report; } + /** + * What each savings account has earned so far in its current cycle — what it would be paid if + * interest were posted today. Read-only: nothing is saved, approved or posted. The ledger is first + * brought up to the business date (which moves no money) so the figure includes today. + * + *

+ * A branch user sees their own branch; a head office user every branch, or one with officeId. + */ + public List> preview(final Long officeId, final AppUser user) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate)); + + final Long userOffice = officeOf(user); + final Long branch = isHeadOffice(userOffice) ? officeId : userOffice; + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName", + p.id AS "productId", p.name AS "productName", sa.id AS "savingsAccountId", sa.account_no AS "accountNo", + COALESCE(c.display_name, g.display_name) AS "clientName", d.closing_balance AS "balance", + d.interest_rate AS "rate", d.cycle_start_date AS "cycleStart", d.posting_date AS "postingDate", + d.balance_date AS "asOf", ROUND(d.cycle_cumulative_interest, 2) AS "interest" + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + JOIN m_savings_product p ON p.id = d.savings_product_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE p.deposit_type_enum = 100 + AND sa.status_enum = ? + AND d.savings_transaction_id IS NULL + """); + final List params = new ArrayList<>(List.of(ACTIVE)); + if (branch != null) { + sql.append(" AND COALESCE(c.office_id, g.office_id) = ?"); + params.add(branch); + } + sql.append(" ORDER BY o.name, p.name, sa.account_no"); + return jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + /** * Branch and product interest for the head office: per report, how many customers and how much * interest, and where it stands. Superseded reports are left out — each line is the report that @@ -375,9 +421,13 @@ public class SavingsInterestPostingReportService { /** * Branch and product pairs with interest due by the date that have no report yet — the noon job ran - * before they had anything due, or failed. Limited to what the user may act on. + * before they had anything due, or failed. + * + *

+ * {@code scope} "own" (the default) lists the user's own branch. "others", for a head office user, + * lists the other branches — each with canAct, and where the head office may not act for it, why. */ - public List> dueWithoutReport(final LocalDate postingDate, final AppUser user) { + public List> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) { final List> out = new ArrayList<>(); // A future date has nothing due yet, and offering to generate for it would label this cycle's // part-earned interest with a date it has not reached. @@ -386,15 +436,21 @@ public class SavingsInterestPostingReportService { return out; } final Long userOffice = officeOf(user); - for (final DueKey due : dueKeys(postingDate, isHeadOffice(userOffice) ? null : userOffice)) { - if (activeReportId(due.productId(), due.officeId(), postingDate) == null - && mayAct(userOffice, due.officeId(), businessDate)) { + final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice); + for (final DueKey due : dueKeys(postingDate, others ? null : userOffice)) { + if (others == due.officeId().equals(userOffice)) { + continue; + } + if (activeReportId(due.productId(), due.officeId(), postingDate) == null) { final Map row = new LinkedHashMap<>(); row.put("productId", due.productId()); row.put("productName", productName(due.productId())); row.put("officeId", due.officeId()); - row.put("officeName", jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, due.officeId())); + row.put("officeName", officeName(due.officeId())); row.put("postingDate", postingDate.toString()); + final String blocked = actBlockedReason(userOffice, due.officeId(), businessDate); + row.put("canAct", blocked == null); + row.put("blockedReason", blocked); out.add(row); } } @@ -408,36 +464,79 @@ public class SavingsInterestPostingReportService { return !parent.isEmpty() && parent.get(0) == null; } - /** Whether the branch opened its day on the date — the head office may act only for one that did not. */ + /** + * Whether the branch is working on the date: it opened that day, or a day it opened earlier is still + * not closed. A branch with yesterday's day still open is open — it closes that day and goes on. + */ public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) { - final Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", - Long.class, officeId, businessDate); + final Long rows = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_branch_day_register + WHERE office_id = ? AND (business_date = ? OR (closed_at IS NULL AND business_date <= ?)) + """, Long.class, officeId, businessDate, businessDate); return rows != null && rows > 0; } - private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { + /** The branches, other than those given, that still have a day open on or before the date. */ + public List branchesStillOpen(final LocalDate businessDate, final Long... excludeOfficeIds) { + final StringBuilder sql = new StringBuilder(""" + SELECT DISTINCT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id + WHERE r.closed_at IS NULL AND r.business_date <= ? + """); + final List params = new ArrayList<>(List.of(businessDate)); + for (final Long exclude : excludeOfficeIds) { + sql.append(" AND r.office_id <> ?"); + params.add(exclude); + } + sql.append(" ORDER BY o.name"); + return jdbcTemplate.queryForList(sql.toString(), String.class, params.toArray()); + } + + /** + * Null where the user may generate or approve the branch's report, otherwise why not. + * + *

+ * A branch always acts for itself. The head office acts for another branch only when that branch + * has not opened at all, and only once every branch that did open has closed — the head office's + * own close being the last of the day, so nothing is taken out of a branch that may still open. + */ + private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { if (userOffice == null) { - return false; + return "No branch is set for this user."; } if (userOffice.equals(reportOffice)) { - return true; + return null; } - return isHeadOffice(userOffice) && !branchOpenedOn(reportOffice, businessDate); + final String branch = officeName(reportOffice); + if (!isHeadOffice(userOffice)) { + return "Only " + branch + " can generate or approve its own interest posting report."; + } + if (branchOpenedOn(reportOffice, businessDate)) { + return branch + " is open — it generates and approves its own report."; + } + final List stillOpen = branchesStillOpen(businessDate, userOffice, reportOffice); + if (!stillOpen.isEmpty()) { + return "The head office can act for " + branch + " only after every open branch has closed. Still open: " + + String.join(", ", stillOpen) + "."; + } + return null; + } + + private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { + return actBlockedReason(userOffice, reportOffice, businessDate) == null; } /** @return true where the head office is acting on a branch's behalf */ private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) { final Long userOffice = officeOf(user); - if (userOffice != null && userOffice.equals(reportOffice)) { - return false; + final String blocked = actBlockedReason(userOffice, reportOffice, businessDate); + if (blocked != null) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", blocked); } - if (mayAct(userOffice, reportOffice, businessDate)) { - return true; - } - final String branch = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, reportOffice); - throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", - "Only " + branch + " can generate or approve its own interest posting report. The head office may do so only on a day " - + branch + " has not opened."); + return !reportOffice.equals(userOffice); + } + + private String officeName(final Long officeId) { + return jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId); } private static Long officeOf(final AppUser user) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java index 070e2dbfb..a6a9b1bab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java @@ -65,20 +65,34 @@ public class SavingsInterestPostingReportApiResource { /** A branch user sees their own branch; a head office user every branch, or one with officeId. */ @GET public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status, - @QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId) { + @QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId, + @QueryParam("scope") final String scope) { final AppUser user = context.authenticatedUser(); user.validateHasReadPermission(RESOURCE); - return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, user).stream().map(this::plain).toList()); + return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream().map(this::plain).toList()); + } + + /** Who is looking: their branch, and whether it is the head office (which gets the Other branches tab). */ + @GET + @Path("/context") + public String userContext() { + final AppUser user = context.authenticatedUser(); + final Long officeId = user.getOffice().getId(); + final Map result = new LinkedHashMap<>(); + result.put("officeId", officeId); + result.put("officeName", user.getOffice().getName()); + result.put("headOffice", reportService.isHeadOffice(officeId)); + return gson.toJson(result); } /** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */ @GET @Path("/due-without-report") - public String dueWithoutReport(@QueryParam("postingDate") final String postingDate) { + public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) { final AppUser user = context.authenticatedUser(); user.validateHasReadPermission(RESOURCE); final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); - return gson.toJson(reportService.dueWithoutReport(date, user)); + return gson.toJson(reportService.dueWithoutReport(date, user, scope)); } /** @@ -99,6 +113,15 @@ public class SavingsInterestPostingReportApiResource { return gson.toJson(result); } + /** Interest each account has earned so far this cycle, as if posted today. View only — nothing is saved. */ + @GET + @Path("/preview") + public String preview(@QueryParam("officeId") final Long officeId) { + final AppUser user = context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return gson.toJson(reportService.preview(officeId, user).stream().map(this::plain).toList()); + } + /** The head office's view: per branch and product, customers paid and interest, over a date range. */ @GET @Path("/summary") diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 3d875a303..242415916 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -163,4 +163,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml new file mode 100644 index 000000000..15523b7b0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + UPDATE m_cashiers mc + SET created_at = (SELECT CAST(MIN(cs.made_on_date_utc) AS TIMESTAMP) + FROM m_portfolio_command_source cs + WHERE cs.entity_name = 'TELLER' AND cs.action_name = 'ALLOCATECASHIER' + AND cs.subresource_id = mc.id) + + + + + + + + + + + + + + + UPDATE m_cashier_deposit_request r + SET counter_office_id = COALESCE( + (SELECT t.office_id FROM m_appuser au + JOIN m_cashiers c ON c.staff_id = au.staff_id + JOIN m_tellers t ON t.id = c.teller_id + WHERE au.id = r.cashier_id + AND (c.created_at IS NULL OR r.submitted_at IS NULL OR r.submitted_at >= c.created_at) + LIMIT 1), + (SELECT au.office_id FROM m_appuser au WHERE au.id = r.cashier_id), + r.office_id) + + + +