fix(cash): recognize GL-entry cash correctly in reconciliation and reports
- Add CountedCashLedger: one shared rule for which m_savings_txn_denomination rows are real counted cash. Replaces a magnitude-only guard (which had itself been silently excluding legitimate manual-GL-entry cash from every cash screen) with a date-plausibility test -- a stamped garbage id decodes back to the day it was written, a real GL reference to an arbitrary date -- so a hex reference that happens to land in the old guard's excluded band is no longer misclassified as timestamp garbage - Apply it consistently in the day register close preview, the cashier position/day-book reports, the cash reconciliation screen, and the withdrawal stock check, so all four agree on the same drawer - GL Entries report: join m_voucher_line to show both legs of each voucher (debit and credit account) instead of only the cash leg its own denomination row happens to carry Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+9
-11
@@ -43,6 +43,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
|||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||||
|
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -72,13 +73,6 @@ public class BranchDayRegisterApiResource {
|
|||||||
private final VaultPositionService vaultPositionService;
|
private final VaultPositionService vaultPositionService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
|
||||||
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
|
||||||
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
|
||||||
// never corrected -- without this same filter here, the close-preview's counted-in/counted-out
|
|
||||||
// figures would include that phantom amount, on top of the real cash the branch actually counted.
|
|
||||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
|
||||||
|
|
||||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,28 +191,32 @@ public class BranchDayRegisterApiResource {
|
|||||||
// cross-check was being compared against every branch's counted cash at once, which makes it
|
// 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
|
// 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.
|
// counted, for the same reason as the withdrawal legs in bookBreakdown.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// timestamp is not.
|
||||||
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||||
AND (std.txn_id IS NULL OR std.txn_id < ?)
|
AND """ + CountedCashLedger.REAL_TXN_ID + """
|
||||||
AND (std.officer_id IS NULL
|
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
|
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)
|
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
|
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
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId));
|
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||||
AND (std.txn_id IS NULL OR std.txn_id < ?)
|
AND """ + CountedCashLedger.REAL_TXN_ID + """
|
||||||
AND (std.officer_id IS NULL
|
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
|
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)
|
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
|
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
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId));
|
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||||
result.put("denominationIn", countedIn);
|
result.put("denominationIn", countedIn);
|
||||||
result.put("denominationOut", countedOut);
|
result.put("denominationOut", countedOut);
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -87,9 +87,9 @@ public class CashierDepositRequestApiResource {
|
|||||||
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
|
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
|
||||||
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
|
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
|
||||||
// real transaction id will reach in this system's lifetime makes every such match unambiguous,
|
// real transaction id will reach in this system's lifetime makes every such match unambiguous,
|
||||||
// without a schema change. Kept well under CashReportApiResource.MAX_PLAUSIBLE_TXN_ID (10^12) so
|
// without a schema change. Kept well under CashReportApiResource's own implausible-millisecond-id
|
||||||
// a request still sitting PENDING continues to count in cashier-position reports -- the cash is
|
// band (10^12) so a request still sitting PENDING continues to count in cashier-position reports
|
||||||
// physically in the drawer already, approval or not.
|
// -- the cash is physically in the drawer already, approval or not.
|
||||||
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
|
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|||||||
+3
-9
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
|
|||||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
|
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
|
||||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
|
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
|
||||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
|
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
|
||||||
|
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -47,13 +48,6 @@ public class CashReconciliationApiResource {
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
|
||||||
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
|
||||||
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
|
||||||
// never corrected -- without this same filter here, this endpoint (unlike the cashier-position
|
|
||||||
// report) would count that phantom figure into a cashier's expected cash.
|
|
||||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
|
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
|
||||||
* Returns denomination-wise expected balance for a cashier.
|
* Returns denomination-wise expected balance for a cashier.
|
||||||
@@ -96,7 +90,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'DEPOSIT' "
|
+ "WHERE std.txn_type = 'DEPOSIT' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " 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 = ?) "
|
||||||
@@ -126,7 +120,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " 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 = ?) "
|
||||||
|
|||||||
+39
-14
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||||
|
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||||
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -59,18 +60,14 @@ public class CashReportApiResource {
|
|||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* The timestamp test is a magnitude test, not a lookup, because a missing transaction is not by
|
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
|
||||||
* itself wrong: the Demand Draft register keys its breakdowns by its own request id, and those
|
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
|
||||||
* rows are genuine cash that has to keep counting. Request ids and transaction ids are both
|
* reads the same on all four.
|
||||||
* small; a millisecond epoch is thirteen digits and cannot be either. So anything at or above
|
|
||||||
* 10^12 is rejected as not-an-id, and everything smaller is kept.
|
|
||||||
*/
|
*/
|
||||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
|
||||||
|
|
||||||
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
|
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
|
||||||
+ " SELECT 1 FROM m_savings_account_transaction rt "
|
+ " SELECT 1 FROM m_savings_account_transaction rt "
|
||||||
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
|
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
|
||||||
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
|
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
|
||||||
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
|
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
|
||||||
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
|
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
|
||||||
@@ -83,8 +80,9 @@ public class CashReportApiResource {
|
|||||||
// everything counted after it) out of the cashier's position.
|
// everything counted after it) out of the cashier's position.
|
||||||
//
|
//
|
||||||
// Compared per transaction inside a correlated subquery rather than as a join, so a
|
// Compared per transaction inside a correlated subquery rather than as a join, so a
|
||||||
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id --
|
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
|
||||||
// is untouched by this test and still handled by the id-magnitude test above.
|
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
|
||||||
|
// CountedCashLedger's rule above.
|
||||||
+ "AND NOT EXISTS ("
|
+ "AND NOT EXISTS ("
|
||||||
+ " SELECT 1 FROM m_savings_account_transaction mt "
|
+ " SELECT 1 FROM m_savings_account_transaction mt "
|
||||||
+ " WHERE mt.id = std.txn_id"
|
+ " WHERE mt.id = std.txn_id"
|
||||||
@@ -1341,21 +1339,42 @@ public class CashReportApiResource {
|
|||||||
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
|
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
|
||||||
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
|
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
|
||||||
|
|
||||||
// 2. Paged data query
|
// 2. Paged data query. Each row here is one physical-cash leg -- the only one a manual GL
|
||||||
final String dataSql = "SELECT "
|
// entry ever has a denomination breakdown for -- so on its own it names only the cash side
|
||||||
|
// (e.g. "CASH AT VAULT") and neither the voucher's other leg nor which two accounts the
|
||||||
|
// entry actually moved money between. Every voucher raised through VoucherService (both this
|
||||||
|
// one and an ordinary counter deposit) has its full pair of legs recorded in m_voucher_line,
|
||||||
|
// the same table the My Work register reads its own debit/credit columns from -- joined here
|
||||||
|
// by voucher_no so this report shows the identical pair rather than only the half this
|
||||||
|
// table's own denomination row happens to know about.
|
||||||
|
final String baseSql = "SELECT "
|
||||||
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
|
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
|
||||||
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
|
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
|
||||||
+ " std.account_no AS ac_no, "
|
+ " std.account_no AS ac_no, "
|
||||||
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
|
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
|
||||||
+ " MAX(std.txn_date) AS txn_date, "
|
+ " MAX(std.txn_date) AS txn_date, "
|
||||||
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
||||||
|
+ " MAX(std.created_at) AS sort_key, "
|
||||||
+ " MAX(std.officer_username) AS teller_name, "
|
+ " MAX(std.officer_username) AS teller_name, "
|
||||||
+ " MAX(std.payment_type) AS payment_type, "
|
+ " MAX(std.payment_type) AS payment_type, "
|
||||||
+ " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, "
|
+ " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, "
|
||||||
+ " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, "
|
+ " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, "
|
||||||
+ " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
|
+ " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
|
||||||
+ baseGroupSql
|
+ baseGroupSql;
|
||||||
+ "ORDER BY MAX(std.created_at) "
|
|
||||||
|
final String dataSql = "SELECT b.*, "
|
||||||
|
+ " dr.party_name AS debit_party, dr.account_no AS debit_account, "
|
||||||
|
+ " cr.party_name AS credit_party, cr.account_no AS credit_account "
|
||||||
|
+ "FROM (" + baseSql + ") b "
|
||||||
|
+ "LEFT JOIN LATERAL ("
|
||||||
|
+ " SELECT party_name, account_no FROM m_voucher_line"
|
||||||
|
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'DR'"
|
||||||
|
+ " ORDER BY line_no LIMIT 1) dr ON TRUE "
|
||||||
|
+ "LEFT JOIN LATERAL ("
|
||||||
|
+ " SELECT party_name, account_no FROM m_voucher_line"
|
||||||
|
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'CR'"
|
||||||
|
+ " ORDER BY line_no LIMIT 1) cr ON TRUE "
|
||||||
|
+ "ORDER BY b.sort_key "
|
||||||
+ "LIMIT ? OFFSET ?";
|
+ "LIMIT ? OFFSET ?";
|
||||||
|
|
||||||
// Append limit and offset to params
|
// Append limit and offset to params
|
||||||
@@ -1376,6 +1395,12 @@ public class CashReportApiResource {
|
|||||||
row.put("txnSource", rs.getString("txn_source"));
|
row.put("txnSource", rs.getString("txn_source"));
|
||||||
row.put("dr", rs.getBigDecimal("dr_amount"));
|
row.put("dr", rs.getBigDecimal("dr_amount"));
|
||||||
row.put("cr", rs.getBigDecimal("cr_amount"));
|
row.put("cr", rs.getBigDecimal("cr_amount"));
|
||||||
|
// Falls back to null rather than the cash-leg-only ac_no/ac_name above when a voucher
|
||||||
|
// was raised before m_voucher_line existed, or belongs to no register that writes one.
|
||||||
|
row.put("debitParty", rs.getString("debit_party"));
|
||||||
|
row.put("debitAccount", rs.getString("debit_account"));
|
||||||
|
row.put("creditParty", rs.getString("credit_party"));
|
||||||
|
row.put("creditAccount", rs.getString("credit_account"));
|
||||||
return row;
|
return row;
|
||||||
}, pagedParams);
|
}, pagedParams);
|
||||||
|
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.denominationmaster.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one definition of which {@code m_savings_txn_denomination} rows are real counted cash.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Written here once because it is read from four places -- the branch day register's close preview,
|
||||||
|
* the cashier position report, the cash reconciliation screen and the withdrawal stock check -- and
|
||||||
|
* those places disagreeing is what makes a drawer hold one figure on one screen and another on the
|
||||||
|
* next. A change to the rule belongs in this file and nowhere else.
|
||||||
|
*
|
||||||
|
* <h3>What the rule has to separate</h3>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The {@code txn_id} column is a bigint shared by every register that counts notes, and each writes
|
||||||
|
* a different kind of id into it:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>a savings, current or deposit transaction writes its own transaction id -- always small;
|
||||||
|
* <li>a cashier deposit request writes its request id plus a fixed offset -- also small;
|
||||||
|
* <li>a Demand Draft issue writes its own request id -- small;
|
||||||
|
* <li>a manual GL entry has no numeric id at all, so its hex transaction reference is decoded into
|
||||||
|
* this column (see {@code CashierGlDenominationApiResource}, matching the encoding
|
||||||
|
* {@code CashReportApiResource}'s voucher lookup decodes back out of hex) -- almost always very
|
||||||
|
* large, but a hex string beginning with low digits can land anywhere;
|
||||||
|
* <li>and older code once wrote {@code System.currentTimeMillis()} here, leaving rows belonging to
|
||||||
|
* no transaction at all. Those are phantom cash: a cashier's position is cumulative, so they are
|
||||||
|
* carried forward for ever, and one database held ₹7.29 lakh of them.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Why this is not a magnitude test</h3>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A magnitude test cannot tell the last two apart, and a wider version of this rule -- "reject
|
||||||
|
* anything at or above 10^12" -- rejected every manual GL entry's cash along with the phantom rows.
|
||||||
|
* A narrower band still misses: one live row carries a decoded GL reference of 6,547,106,798,661,
|
||||||
|
* which sits squarely inside the 13-digit band a millisecond epoch occupies.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* What actually separates them is that a millisecond epoch stamped at write time decodes back to
|
||||||
|
* approximately the day the row itself belongs to, while a hex reference that happens to land in the
|
||||||
|
* same band decodes to an arbitrary date -- the row above reads as June 2177 against a June 2026
|
||||||
|
* transaction date. So the test reads the id as a date and asks whether it lands on the row's own
|
||||||
|
* day. That is self-maintaining: it needs no constant moved forward as real timestamps grow.
|
||||||
|
*/
|
||||||
|
public final class CountedCashLedger {
|
||||||
|
|
||||||
|
private CountedCashLedger() {}
|
||||||
|
|
||||||
|
/** Widest gap still read as "this id is the moment the row was written", in days. */
|
||||||
|
private static final int SAME_DAY_TOLERANCE_DAYS = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL predicate keeping only rows whose {@code txn_id} is a real reference, as an expression over
|
||||||
|
* the alias {@code std} ({@code m_savings_txn_denomination}).
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The date decoding is confined to the 13-digit band, where {@code to_timestamp} is always inside
|
||||||
|
* PostgreSQL's own timestamp range; ids outside that band are kept without decoding, so no value
|
||||||
|
* this column can hold can overflow the comparison.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Padded with a space at each end so it reads the same spliced into a text block -- which strips
|
||||||
|
* the trailing space off the {@code AND } it follows -- as concatenated onto a plain string.
|
||||||
|
*/
|
||||||
|
public static final String REAL_TXN_ID = " (std.txn_id IS NULL"
|
||||||
|
+ " OR std.txn_id < 1000000000000 OR std.txn_id >= 10000000000000"
|
||||||
|
+ " OR COALESCE(std.txn_date, CAST(std.created_at AS DATE)) IS NULL"
|
||||||
|
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
|
||||||
|
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
|
||||||
|
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
|
||||||
|
}
|
||||||
+4
-12
@@ -40,14 +40,6 @@ public class DenominationBreakdownWriter {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
|
||||||
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
|
||||||
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
|
||||||
// never corrected -- without this same filter here, a cashier's computed stock (and the
|
|
||||||
// insufficient-stock check that guards a withdrawal against it) would count that phantom figure
|
|
||||||
// as real notes and coins in the drawer.
|
|
||||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -294,7 +286,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " 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 = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -318,7 +310,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " 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 = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -415,7 +407,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " 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 = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -439,7 +431,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " 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 = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
|
|||||||
Reference in New Issue
Block a user