feat: cash management backend - transfer, settlement, inter-cashier, reconciliation + cashier /me position endpoint
OVERVIEW
--------
Added complete cash management backend covering the full cashier workflow:
vault transfers, inter-cashier transfers, end-of-day settlement, GL
reconciliation, and a new authenticated /me endpoint for cashier position
lookup without needing to pass a cashier ID.
NEW API ENDPOINT
----------------
GET /cashreports/cashier-position/me
- Auto-detects the authenticated user via PlatformSecurityContext
- Resolves appUser -> staff -> cashier via m_cashiers JOIN chain
- Accepts optional ?asOfDate=YYYY-MM-DD query param (defaults to today)
- Reuses existing buildPositionForCashier(cashierId, dateFilter) internally
- Returns: { cashierId, asOfDate, totalAmount, denominations[] }
- Non-cashier users get empty denominations array (no error thrown)
- Used by Angular UI to show opening/closing denomination panels
CASH TRANSFER (vault -> cashier)
---------------------------------
CashTransferApiResource.java — REST endpoints (POST /cashtransfer, GET list/detail)
CashTransfer.java — JPA entity (m_cash_transfer)
CashTransferDetail.java — JPA entity (denomination breakdown, m_cash_transfer_detail)
CashTransferRepository.java — Spring Data JPA repository
CashTransferDetailRepository.java
DB migrations:
0014_cash_transfer.xml — creates m_cash_transfer + m_cash_transfer_detail tables
0015_cash_transfer_add_ids.xml — adds missing ID columns to transfer detail
CASH SETTLEMENT (end-of-day cashier close)
------------------------------------------
CashSettlementApiResource.java — REST endpoints (POST /cashsettlement, GET list/detail)
CashSettlement.java — JPA entity (m_cash_settlement)
CashSettlementDetail.java — JPA entity (denomination breakdown)
CashSettlementRepository.java
CashSettlementDetailRepository.java
DB migration:
0016_cash_settlement.xml — creates m_cash_settlement + m_cash_settlement_detail
INTER-CASHIER TRANSFER
-----------------------
CashInterCashierApiResource.java — REST endpoints (POST /cashintercashier, GET list/detail)
CashInterCashier.java — JPA entity (m_cash_inter_cashier)
CashInterCashierDetail.java — JPA entity (denomination breakdown)
CashInterCashierRepository.java
CashInterCashierDetailRepository.java
DB migration:
0017_cash_inter_cashier.xml — creates m_cash_inter_cashier + detail table
CASH RECONCILIATION
--------------------
CashReconciliationApiResource.java — REST endpoints (POST /cashreconciliation, GET list)
CashReconciliation.java — JPA entity (m_cash_reconciliation)
CashReconciliationDetail.java — JPA entity (denomination breakdown)
CashReconciliationRepository.java
CashReconciliationDetailRepository.java
DB migrations:
0018_cash_reconciliation.xml — creates m_cash_reconciliation + detail table
0019_cash_reversal_columns.xml — adds reversal tracking columns
0020_cash_permissions.xml — adds permissions for all cash management APIs
GL DISPATCH SERVICE
-------------------
GlDispatchService.java (portfolio/teller/service/)
- Shared service for posting GL journal entries for cash movements
- Used by settlement and reconciliation flows
CHANGELOG
---------
changelog-tenant.xml updated to include migrations 0014 through 0020.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
9a30ede278
commit
b89e653bcb
+224
@@ -0,0 +1,224 @@
|
||||
package org.apache.fineract.portfolio.cashintercashier.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
|
||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
|
||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
|
||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Path("/v1/cashintercashier")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class CashInterCashierApiResource {
|
||||
|
||||
private final CashInterCashierRepository repository;
|
||||
private final CashInterCashierDetailRepository detailRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@POST
|
||||
@Transactional
|
||||
public String create(String body) {
|
||||
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHINTERCASHIER");
|
||||
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
|
||||
final Long tellerId = json.has("tellerId") && !json.get("tellerId").isJsonNull()
|
||||
? json.get("tellerId").getAsLong() : null;
|
||||
final Long fromCashierId = json.has("fromCashierId") && !json.get("fromCashierId").isJsonNull()
|
||||
? json.get("fromCashierId").getAsLong() : null;
|
||||
final Long toCashierId = json.has("toCashierId") && !json.get("toCashierId").isJsonNull()
|
||||
? json.get("toCashierId").getAsLong() : null;
|
||||
|
||||
if (tellerId == null || fromCashierId == null || toCashierId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.parties.required",
|
||||
"tellerId, fromCashierId and toCashierId are required.");
|
||||
}
|
||||
if (fromCashierId.equals(toCashierId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.same.cashier",
|
||||
"From-cashier and To-cashier cannot be the same.");
|
||||
}
|
||||
|
||||
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now();
|
||||
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
|
||||
? json.get("fromParty").getAsString() : null;
|
||||
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
|
||||
? json.get("toParty").getAsString() : null;
|
||||
final String note = json.has("note") && !json.get("note").isJsonNull()
|
||||
? json.get("note").getAsString() : null;
|
||||
|
||||
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
||||
? json.getAsJsonArray("denominations") : new JsonArray();
|
||||
|
||||
if (!hasPositiveCount(denominations)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
|
||||
"At least one denomination with a positive count is required.");
|
||||
}
|
||||
final BigDecimal total = sumTotal(denominations);
|
||||
|
||||
CashInterCashier header = new CashInterCashier();
|
||||
header.setTransferDate(transferDate);
|
||||
header.setTellerId(tellerId);
|
||||
header.setFromCashierId(fromCashierId);
|
||||
header.setToCashierId(toCashierId);
|
||||
header.setFromParty(fromParty);
|
||||
header.setToParty(toParty);
|
||||
header.setTransferAmount(total);
|
||||
header.setNote(note);
|
||||
header.setCreatedAt(LocalDateTime.now());
|
||||
header.setIsReversed(false);
|
||||
try {
|
||||
AppUser user = this.context.authenticatedUser();
|
||||
header.setPerformedBy(user.getId());
|
||||
header.setPerformedByUsername(user.getUsername());
|
||||
} catch (Exception ignored) {}
|
||||
CashInterCashier saved = repository.saveAndFlush(header);
|
||||
saveDetails(saved.getId(), denominations);
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", saved.getId());
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/reverse")
|
||||
@Transactional
|
||||
public String reverse(@PathParam("id") Long id, String body) {
|
||||
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHINTERCASHIER");
|
||||
|
||||
CashInterCashier record = repository.findById(id)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashintercashier.not.found",
|
||||
"Inter-cashier transfer not found: " + id));
|
||||
if (Boolean.TRUE.equals(record.getIsReversed())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.already.reversed",
|
||||
"Inter-cashier transfer " + id + " is already reversed.");
|
||||
}
|
||||
|
||||
String reason = null;
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
AppUser user = null;
|
||||
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {}
|
||||
|
||||
record.setIsReversed(true);
|
||||
record.setReversedAt(LocalDateTime.now());
|
||||
record.setReverseReason(reason);
|
||||
if (user != null) record.setReversedBy(user.getId());
|
||||
repository.save(record);
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("reversed", true);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
public String getAll() {
|
||||
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
|
||||
.map(this::toMap).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{id}")
|
||||
public String getOne(@PathParam("id") Long id) {
|
||||
CashInterCashier header = repository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Inter-cashier transfer not found: " + id));
|
||||
Map<String, Object> map = toMap(header);
|
||||
map.put("details", detailRepository.findByInterCashierId(id).stream()
|
||||
.map(this::detailToMap).collect(Collectors.toList()));
|
||||
return gson.toJson(map);
|
||||
}
|
||||
|
||||
private BigDecimal sumTotal(JsonArray items) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
||||
int count = item.has("count") ? item.get("count").getAsInt() : 0;
|
||||
if (count > 0) total = total.add(BigDecimal.valueOf((long) value * count));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private boolean hasPositiveCount(JsonArray items) {
|
||||
for (JsonElement el : items) {
|
||||
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void saveDetails(Long interCashierId, JsonArray items) {
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int count = item.has("count") ? item.get("count").getAsInt() : 0;
|
||||
if (count <= 0) continue;
|
||||
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
||||
Long denomId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
|
||||
? item.get("denominationId").getAsLong() : null;
|
||||
CashInterCashierDetail d = new CashInterCashierDetail();
|
||||
d.setInterCashierId(interCashierId);
|
||||
d.setDenominationId(denomId);
|
||||
d.setDenominationValue(value);
|
||||
d.setCount(count);
|
||||
d.setTotalAmount(BigDecimal.valueOf((long) value * count));
|
||||
detailRepository.save(d);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(CashInterCashier e) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", e.getId());
|
||||
m.put("transferDate", e.getTransferDate() != null ? e.getTransferDate().toString() : null);
|
||||
m.put("tellerId", e.getTellerId());
|
||||
m.put("fromCashierId", e.getFromCashierId());
|
||||
m.put("toCashierId", e.getToCashierId());
|
||||
m.put("fromParty", e.getFromParty());
|
||||
m.put("toParty", e.getToParty());
|
||||
m.put("transferAmount", e.getTransferAmount());
|
||||
m.put("note", e.getNote());
|
||||
m.put("performedByUsername", e.getPerformedByUsername());
|
||||
m.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
|
||||
m.put("isReversed", e.getIsReversed());
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> detailToMap(CashInterCashierDetail d) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", d.getId());
|
||||
m.put("denominationId", d.getDenominationId());
|
||||
m.put("denominationValue", d.getDenominationValue());
|
||||
m.put("count", d.getCount());
|
||||
m.put("totalAmount", d.getTotalAmount());
|
||||
return m;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package org.apache.fineract.portfolio.cashintercashier.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_cash_inter_cashier")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class CashInterCashier {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "transfer_date")
|
||||
private LocalDate transferDate;
|
||||
|
||||
@Column(name = "teller_id")
|
||||
private Long tellerId;
|
||||
|
||||
@Column(name = "from_cashier_id")
|
||||
private Long fromCashierId;
|
||||
|
||||
@Column(name = "to_cashier_id")
|
||||
private Long toCashierId;
|
||||
|
||||
@Column(name = "from_party", length = 100)
|
||||
private String fromParty;
|
||||
|
||||
@Column(name = "to_party", length = 100)
|
||||
private String toParty;
|
||||
|
||||
@Column(name = "transfer_amount", nullable = false)
|
||||
private BigDecimal transferAmount;
|
||||
|
||||
@Column(name = "note", length = 500)
|
||||
private String note;
|
||||
|
||||
@Column(name = "performed_by")
|
||||
private Long performedBy;
|
||||
|
||||
@Column(name = "performed_by_username", length = 100)
|
||||
private String performedByUsername;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "is_reversed")
|
||||
private Boolean isReversed = false;
|
||||
|
||||
@Column(name = "reversed_by")
|
||||
private Long reversedBy;
|
||||
|
||||
@Column(name = "reversed_at")
|
||||
private LocalDateTime reversedAt;
|
||||
|
||||
@Column(name = "reverse_reason", length = 500)
|
||||
private String reverseReason;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package org.apache.fineract.portfolio.cashintercashier.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_cash_inter_cashier_detail")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class CashInterCashierDetail {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "inter_cashier_id", nullable = false)
|
||||
private Long interCashierId;
|
||||
|
||||
@Column(name = "denomination_id")
|
||||
private Long denominationId;
|
||||
|
||||
@Column(name = "denomination_value", nullable = false)
|
||||
private Integer denominationValue;
|
||||
|
||||
@Column(name = "count", nullable = false)
|
||||
private Integer count;
|
||||
|
||||
@Column(name = "total_amount", nullable = false)
|
||||
private BigDecimal totalAmount;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package org.apache.fineract.portfolio.cashintercashier.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface CashInterCashierDetailRepository extends JpaRepository<CashInterCashierDetail, Long> {
|
||||
List<CashInterCashierDetail> findByInterCashierId(Long interCashierId);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package org.apache.fineract.portfolio.cashintercashier.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface CashInterCashierRepository extends JpaRepository<CashInterCashier, Long> {
|
||||
List<CashInterCashier> findAllByOrderByCreatedAtDesc();
|
||||
List<CashInterCashier> findByTellerIdOrderByCreatedAtDesc(Long tellerId);
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
package org.apache.fineract.portfolio.cashreconciliation.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliation;
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Path("/v1/cashreconciliation")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class CashReconciliationApiResource {
|
||||
|
||||
private final CashReconciliationRepository repository;
|
||||
private final CashReconciliationDetailRepository detailRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
|
||||
* Returns denomination-wise expected balance for a cashier.
|
||||
* Computation: allocations (m_cash_transfer_detail where to_cashier_id=X)
|
||||
* + inter-cashier-in (m_cash_inter_cashier_detail where to_cashier_id=X)
|
||||
* - settlements (m_cash_settlement_detail where from_cashier_id=X)
|
||||
* - inter-cashier-out (m_cash_inter_cashier_detail where from_cashier_id=X)
|
||||
*/
|
||||
@GET
|
||||
@Path("expected")
|
||||
public String getExpected(@QueryParam("cashierId") Long cashierId, @QueryParam("asOfDate") String asOfDate) {
|
||||
context.authenticatedUser().validateHasPermissionTo("READ_CASHRECONCILIATION");
|
||||
if (cashierId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
|
||||
"cashierId query param is required.");
|
||||
}
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
|
||||
|
||||
// In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
|
||||
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
|
||||
+ "FROM m_cash_transfer_detail ctd "
|
||||
+ "JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id "
|
||||
+ "WHERE ct.to_cashier_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) "
|
||||
+ "AND ct.transfer_date <= CAST(? AS DATE) "
|
||||
+ "GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind";
|
||||
|
||||
// In: inter-cashier in
|
||||
final String sqlIn2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
|
||||
+ "FROM m_cash_inter_cashier_detail icd "
|
||||
+ "JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
|
||||
+ "WHERE ic.to_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
|
||||
+ "AND ic.transfer_date <= CAST(? AS DATE) "
|
||||
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
|
||||
|
||||
// In: customer deposits received by this cashier
|
||||
final String sqlIn3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
|
||||
+ "FROM m_savings_txn_denomination std "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||
+ "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 = ?) "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Out: settlements to vault
|
||||
final String sqlOut1 = "SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count "
|
||||
+ "FROM m_cash_settlement_detail csd "
|
||||
+ "JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id "
|
||||
+ "WHERE cs.from_cashier_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) "
|
||||
+ "AND cs.settlement_date <= CAST(? AS DATE) "
|
||||
+ "GROUP BY csd.denomination_id, csd.denomination_value, dm.kind";
|
||||
|
||||
// Out: inter-cashier out
|
||||
final String sqlOut2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
|
||||
+ "FROM m_cash_inter_cashier_detail icd "
|
||||
+ "JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
|
||||
+ "WHERE ic.from_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
|
||||
+ "AND ic.transfer_date <= CAST(? AS DATE) "
|
||||
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
|
||||
|
||||
// Out: customer withdrawals paid by this cashier
|
||||
final String sqlOut3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
|
||||
+ "FROM m_savings_txn_denomination std "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||
+ "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 = ?) "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Balance map: denomination_id → long[3] {denomValue, kindFlag (0=note/1=coin), netCount}
|
||||
Map<Long, long[]> balance = new LinkedHashMap<>();
|
||||
|
||||
addToBalance(balance, jdbcTemplate.query(sqlIn1, DENOM_ROW, cashierId, dateFilter), 1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlIn2, DENOM_ROW, cashierId, dateFilter), 1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlIn3, DENOM_ROW, dateFilter, cashierId), 1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlOut1, DENOM_ROW, cashierId, dateFilter), -1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlOut2, DENOM_ROW, cashierId, dateFilter), -1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlOut3, DENOM_ROW, dateFilter, cashierId), -1);
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
balance.entrySet().stream()
|
||||
.sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0]))
|
||||
.forEach(e -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("denominationId", e.getKey());
|
||||
row.put("denominationValue", (int) e.getValue()[0]);
|
||||
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
|
||||
row.put("expectedCount", e.getValue()[2]);
|
||||
row.put("expectedAmount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
|
||||
rows.add(row);
|
||||
});
|
||||
|
||||
BigDecimal grandTotal = rows.stream()
|
||||
.map(r -> (BigDecimal) r.get("expectedAmount"))
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("cashierId", cashierId);
|
||||
result.addProperty("asOfDate", dateFilter);
|
||||
result.addProperty("expectedTotal", grandTotal);
|
||||
result.add("denominations", gson.toJsonTree(rows));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Transactional
|
||||
public String create(String body) {
|
||||
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHRECONCILIATION");
|
||||
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
|
||||
final Long cashierId = json.has("cashierId") ? json.get("cashierId").getAsLong() : null;
|
||||
if (cashierId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
|
||||
"cashierId is required.");
|
||||
}
|
||||
final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now();
|
||||
final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull()
|
||||
? json.get("cashierName").getAsString() : null;
|
||||
final Long tellerId = json.has("tellerId") && !json.get("tellerId").isJsonNull()
|
||||
? json.get("tellerId").getAsLong() : null;
|
||||
final String note = json.has("note") && !json.get("note").isJsonNull()
|
||||
? json.get("note").getAsString() : null;
|
||||
|
||||
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
||||
? json.getAsJsonArray("denominations") : new JsonArray();
|
||||
|
||||
BigDecimal expectedTotal = BigDecimal.ZERO;
|
||||
BigDecimal physicalTotal = BigDecimal.ZERO;
|
||||
List<CashReconciliationDetail> details = new ArrayList<>();
|
||||
|
||||
for (JsonElement el : denominations) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
||||
int expectedCount = item.has("expectedCount") ? item.get("expectedCount").getAsInt() : 0;
|
||||
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
|
||||
int varianceCount = physicalCount - expectedCount;
|
||||
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
|
||||
expectedTotal = expectedTotal.add(BigDecimal.valueOf((long) denomValue * expectedCount));
|
||||
physicalTotal = physicalTotal.add(BigDecimal.valueOf((long) denomValue * physicalCount));
|
||||
|
||||
Long denomId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
|
||||
? item.get("denominationId").getAsLong() : null;
|
||||
|
||||
CashReconciliationDetail d = new CashReconciliationDetail();
|
||||
d.setDenominationId(denomId);
|
||||
d.setDenominationValue(denomValue);
|
||||
d.setExpectedCount(expectedCount);
|
||||
d.setPhysicalCount(physicalCount);
|
||||
d.setVarianceCount(varianceCount);
|
||||
d.setVarianceAmount(varianceAmount);
|
||||
details.add(d);
|
||||
}
|
||||
|
||||
BigDecimal variance = physicalTotal.subtract(expectedTotal);
|
||||
String status = variance.compareTo(BigDecimal.ZERO) == 0 ? "OK"
|
||||
: variance.compareTo(BigDecimal.ZERO) > 0 ? "EXCESS" : "SHORT";
|
||||
|
||||
CashReconciliation header = new CashReconciliation();
|
||||
header.setReconDate(reconDate);
|
||||
header.setCashierId(cashierId);
|
||||
header.setCashierName(cashierName);
|
||||
header.setTellerId(tellerId);
|
||||
header.setExpectedAmount(expectedTotal);
|
||||
header.setPhysicalAmount(physicalTotal);
|
||||
header.setVariance(variance);
|
||||
header.setStatus(status);
|
||||
header.setNote(note);
|
||||
header.setCreatedAt(LocalDateTime.now());
|
||||
try {
|
||||
AppUser user = this.context.authenticatedUser();
|
||||
header.setPerformedBy(user.getId());
|
||||
header.setPerformedByUsername(user.getUsername());
|
||||
} catch (Exception ignored) {}
|
||||
CashReconciliation saved = repository.saveAndFlush(header);
|
||||
|
||||
for (CashReconciliationDetail d : details) {
|
||||
d.setReconciliationId(saved.getId());
|
||||
detailRepository.save(d);
|
||||
}
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", saved.getId());
|
||||
result.addProperty("status", status);
|
||||
result.addProperty("variance", variance);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
public String getAll() {
|
||||
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
|
||||
.map(this::toMap).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{id}")
|
||||
public String getOne(@PathParam("id") Long id) {
|
||||
CashReconciliation header = repository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Cash reconciliation not found: " + id));
|
||||
Map<String, Object> map = toMap(header);
|
||||
map.put("details", detailRepository.findByReconciliationId(id).stream()
|
||||
.map(this::detailToMap).collect(Collectors.toList()));
|
||||
return gson.toJson(map);
|
||||
}
|
||||
|
||||
// Row mapper: Object[]{Long denomId, Integer denomValue, String kind, Long count}
|
||||
private static final org.springframework.jdbc.core.RowMapper<Object[]> DENOM_ROW =
|
||||
(rs, r) -> new Object[] {
|
||||
rs.getLong("denomination_id"),
|
||||
rs.getInt("denomination_value"),
|
||||
rs.getString("kind"),
|
||||
rs.getLong("total_count")
|
||||
};
|
||||
|
||||
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
|
||||
for (Object[] row : rows) {
|
||||
Long denomId = (Long) row[0];
|
||||
int denomValue = (Integer) row[1];
|
||||
long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
|
||||
long count = (Long) row[3];
|
||||
long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
|
||||
entry[2] += count * sign;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(CashReconciliation e) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", e.getId());
|
||||
m.put("reconDate", e.getReconDate() != null ? e.getReconDate().toString() : null);
|
||||
m.put("cashierId", e.getCashierId());
|
||||
m.put("cashierName", e.getCashierName());
|
||||
m.put("tellerId", e.getTellerId());
|
||||
m.put("expectedAmount", e.getExpectedAmount());
|
||||
m.put("physicalAmount", e.getPhysicalAmount());
|
||||
m.put("variance", e.getVariance());
|
||||
m.put("status", e.getStatus());
|
||||
m.put("note", e.getNote());
|
||||
m.put("performedByUsername", e.getPerformedByUsername());
|
||||
m.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> detailToMap(CashReconciliationDetail d) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", d.getId());
|
||||
m.put("denominationId", d.getDenominationId());
|
||||
m.put("denominationValue", d.getDenominationValue());
|
||||
m.put("expectedCount", d.getExpectedCount());
|
||||
m.put("physicalCount", d.getPhysicalCount());
|
||||
m.put("varianceCount", d.getVarianceCount());
|
||||
m.put("varianceAmount", d.getVarianceAmount());
|
||||
return m;
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package org.apache.fineract.portfolio.cashreconciliation.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_cash_reconciliation")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class CashReconciliation {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "recon_date", nullable = false)
|
||||
private LocalDate reconDate;
|
||||
|
||||
@Column(name = "cashier_id", nullable = false)
|
||||
private Long cashierId;
|
||||
|
||||
@Column(name = "cashier_name", length = 150)
|
||||
private String cashierName;
|
||||
|
||||
@Column(name = "teller_id")
|
||||
private Long tellerId;
|
||||
|
||||
@Column(name = "expected_amount")
|
||||
private BigDecimal expectedAmount = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "physical_amount")
|
||||
private BigDecimal physicalAmount = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "variance")
|
||||
private BigDecimal variance = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "status", length = 10)
|
||||
private String status = "OK";
|
||||
|
||||
@Column(name = "note", length = 500)
|
||||
private String note;
|
||||
|
||||
@Column(name = "performed_by")
|
||||
private Long performedBy;
|
||||
|
||||
@Column(name = "performed_by_username", length = 100)
|
||||
private String performedByUsername;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package org.apache.fineract.portfolio.cashreconciliation.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_cash_reconciliation_detail")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class CashReconciliationDetail {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "reconciliation_id", nullable = false)
|
||||
private Long reconciliationId;
|
||||
|
||||
@Column(name = "denomination_id")
|
||||
private Long denominationId;
|
||||
|
||||
@Column(name = "denomination_value", nullable = false)
|
||||
private Integer denominationValue;
|
||||
|
||||
@Column(name = "expected_count")
|
||||
private Integer expectedCount = 0;
|
||||
|
||||
@Column(name = "physical_count")
|
||||
private Integer physicalCount = 0;
|
||||
|
||||
@Column(name = "variance_count")
|
||||
private Integer varianceCount = 0;
|
||||
|
||||
@Column(name = "variance_amount")
|
||||
private BigDecimal varianceAmount = BigDecimal.ZERO;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package org.apache.fineract.portfolio.cashreconciliation.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface CashReconciliationDetailRepository extends JpaRepository<CashReconciliationDetail, Long> {
|
||||
List<CashReconciliationDetail> findByReconciliationId(Long reconciliationId);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package org.apache.fineract.portfolio.cashreconciliation.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface CashReconciliationRepository extends JpaRepository<CashReconciliation, Long> {
|
||||
List<CashReconciliation> findAllByOrderByCreatedAtDesc();
|
||||
List<CashReconciliation> findByCashierIdOrderByReconDateDesc(Long cashierId);
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
package org.apache.fineract.portfolio.cashreport.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/cashreports")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class CashReportApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
@Path("closing")
|
||||
public String closing(@QueryParam("date") String date) {
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
|
||||
|
||||
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
|
||||
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
|
||||
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, "
|
||||
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, "
|
||||
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
|
||||
+ "FROM m_savings_txn_denomination std "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||
+ "WHERE std.txn_date = CAST(? AS DATE) "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
|
||||
+ "ORDER BY std.denomination_value DESC, dm.kind";
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
long depositCount = rs.getLong("deposit_count");
|
||||
long withdrawalCount = rs.getLong("withdrawal_count");
|
||||
BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
|
||||
BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("denominationId", rs.getLong("denomination_id"));
|
||||
row.put("denominationValue", rs.getInt("denomination_value"));
|
||||
row.put("kind", rs.getString("kind"));
|
||||
row.put("depositCount", depositCount);
|
||||
row.put("depositAmount", depositAmount);
|
||||
row.put("withdrawalCount", withdrawalCount);
|
||||
row.put("withdrawalAmount", withdrawalAmount);
|
||||
row.put("netCount", depositCount - withdrawalCount);
|
||||
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
|
||||
return row;
|
||||
}, txnDate);
|
||||
|
||||
BigDecimal depositTotal = BigDecimal.ZERO;
|
||||
BigDecimal withdrawalTotal = BigDecimal.ZERO;
|
||||
for (Map<String, Object> row : rows) {
|
||||
depositTotal = depositTotal.add((BigDecimal) row.get("depositAmount"));
|
||||
withdrawalTotal = withdrawalTotal.add((BigDecimal) row.get("withdrawalAmount"));
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("date", txnDate);
|
||||
result.put("rows", rows);
|
||||
result.put("depositTotal", depositTotal);
|
||||
result.put("withdrawalTotal", withdrawalTotal);
|
||||
result.put("netTotal", depositTotal.subtract(withdrawalTotal));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("excess")
|
||||
public String excess(@QueryParam("threshold") BigDecimal threshold, @QueryParam("fromDate") String fromDate,
|
||||
@QueryParam("toDate") String toDate) {
|
||||
final BigDecimal effectiveThreshold = threshold != null ? threshold : BigDecimal.valueOf(50000);
|
||||
|
||||
final StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT txn_id, MAX(account_no) account_no, MAX(client_id) client_id, MAX(cif_no) cif_no, ");
|
||||
sql.append("MAX(txn_date) txn_date, SUM(total_amount) total_amount FROM m_savings_txn_denomination ");
|
||||
|
||||
final List<Object> args = new ArrayList<>();
|
||||
final boolean hasDateRange = fromDate != null && !fromDate.isBlank() && toDate != null && !toDate.isBlank();
|
||||
if (hasDateRange) {
|
||||
sql.append("WHERE txn_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE) ");
|
||||
args.add(fromDate);
|
||||
args.add(toDate);
|
||||
}
|
||||
sql.append("GROUP BY txn_id HAVING SUM(total_amount) >= ? ORDER BY total_amount DESC");
|
||||
args.add(effectiveThreshold);
|
||||
|
||||
final List<Map<String, Object>> result = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("txnId", rs.getLong("txn_id"));
|
||||
row.put("accountNo", rs.getString("account_no"));
|
||||
row.put("clientId", rs.getObject("client_id"));
|
||||
row.put("cifNo", rs.getString("cif_no"));
|
||||
final java.sql.Date txnDate = rs.getDate("txn_date");
|
||||
row.put("txnDate", txnDate != null ? txnDate.toString() : null);
|
||||
row.put("totalAmount", rs.getBigDecimal("total_amount"));
|
||||
return row;
|
||||
}, args.toArray());
|
||||
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /cashreports/cashier-position?cashierId=X&asOfDate=yyyy-MM-dd
|
||||
* Returns denomination-wise net position per cashier grouped by denomination_id+kind.
|
||||
*/
|
||||
@GET
|
||||
@Path("cashier-position")
|
||||
public String cashierPosition(@QueryParam("cashierId") Long cashierId,
|
||||
@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
|
||||
|
||||
if (cashierId != null) {
|
||||
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
|
||||
}
|
||||
|
||||
final String idSql = "SELECT DISTINCT cashier_id FROM ("
|
||||
+ " SELECT to_cashier_id AS cashier_id FROM m_cash_transfer WHERE to_cashier_id IS NOT NULL "
|
||||
+ " UNION SELECT from_cashier_id FROM m_cash_settlement WHERE from_cashier_id IS NOT NULL "
|
||||
+ " UNION SELECT from_cashier_id FROM m_cash_inter_cashier WHERE from_cashier_id IS NOT NULL "
|
||||
+ " UNION SELECT to_cashier_id FROM m_cash_inter_cashier WHERE to_cashier_id IS NOT NULL "
|
||||
+ ") ids";
|
||||
|
||||
List<Long> cashierIds = jdbcTemplate.queryForList(idSql, Long.class);
|
||||
List<Map<String, Object>> allPositions = new ArrayList<>();
|
||||
for (Long cid : cashierIds) {
|
||||
allPositions.add(buildPositionForCashier(cid, dateFilter));
|
||||
}
|
||||
return gson.toJson(allPositions);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("cashier-position/me")
|
||||
public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
|
||||
Long appUserId;
|
||||
try {
|
||||
appUserId = context.authenticatedUser().getId();
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> err = new LinkedHashMap<>();
|
||||
err.put("error", "Not authenticated");
|
||||
return gson.toJson(err);
|
||||
}
|
||||
|
||||
Long cashierId;
|
||||
try {
|
||||
cashierId = jdbcTemplate.queryForObject(
|
||||
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
|
||||
Long.class, appUserId);
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> empty = new LinkedHashMap<>();
|
||||
empty.put("cashierId", null);
|
||||
empty.put("asOfDate", dateFilter);
|
||||
empty.put("totalAmount", BigDecimal.ZERO);
|
||||
empty.put("denominations", new ArrayList<>());
|
||||
return gson.toJson(empty);
|
||||
}
|
||||
|
||||
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
|
||||
}
|
||||
|
||||
private Map<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) {
|
||||
// Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
|
||||
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
|
||||
+ "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id "
|
||||
+ "WHERE ct.to_cashier_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) "
|
||||
+ "AND ct.transfer_date <= CAST(? AS DATE) "
|
||||
+ "GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind";
|
||||
|
||||
// Inter-cashier IN
|
||||
final String sqlIn2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
|
||||
+ "FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
|
||||
+ "WHERE ic.to_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
|
||||
+ "AND ic.transfer_date <= CAST(? AS DATE) "
|
||||
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
|
||||
|
||||
// Customer deposits
|
||||
final String sqlIn3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
|
||||
+ "FROM m_savings_txn_denomination std "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||
+ "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 = ?) "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Settlements back to vault
|
||||
final String sqlOut1 = "SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count "
|
||||
+ "FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id "
|
||||
+ "WHERE cs.from_cashier_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) "
|
||||
+ "AND cs.settlement_date <= CAST(? AS DATE) "
|
||||
+ "GROUP BY csd.denomination_id, csd.denomination_value, dm.kind";
|
||||
|
||||
// Inter-cashier OUT
|
||||
final String sqlOut2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
|
||||
+ "FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
|
||||
+ "WHERE ic.from_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
|
||||
+ "AND ic.transfer_date <= CAST(? AS DATE) "
|
||||
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
|
||||
|
||||
// Customer withdrawals
|
||||
final String sqlOut3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
|
||||
+ "FROM m_savings_txn_denomination std "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||
+ "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 = ?) "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Balance map: denomination_id → long[3] {denomValue, kindFlag (0=note/1=coin), netCount}
|
||||
Map<Long, long[]> balance = new LinkedHashMap<>();
|
||||
|
||||
addToBalance(balance, jdbcTemplate.query(sqlIn1, DENOM_ROW, cashierId, dateFilter), 1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlIn2, DENOM_ROW, cashierId, dateFilter), 1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlIn3, DENOM_ROW, dateFilter, cashierId), 1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlOut1, DENOM_ROW, cashierId, dateFilter), -1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlOut2, DENOM_ROW, cashierId, dateFilter), -1);
|
||||
addToBalance(balance, jdbcTemplate.query(sqlOut3, DENOM_ROW, dateFilter, cashierId), -1);
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
balance.entrySet().stream()
|
||||
.filter(e -> e.getValue()[2] != 0)
|
||||
.sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0]))
|
||||
.forEach(e -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("denominationId", e.getKey());
|
||||
row.put("denominationValue", (int) e.getValue()[0]);
|
||||
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
|
||||
row.put("count", e.getValue()[2]);
|
||||
row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
|
||||
rows.add(row);
|
||||
});
|
||||
|
||||
BigDecimal totalAmount = rows.stream()
|
||||
.map(r -> (BigDecimal) r.get("amount"))
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
String cashierName = null;
|
||||
try {
|
||||
cashierName = jdbcTemplate.queryForObject(
|
||||
"SELECT ms.display_name FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id WHERE mc.id = ?",
|
||||
String.class, cashierId);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("cashierId", cashierId);
|
||||
result.put("cashierName", cashierName);
|
||||
result.put("asOfDate", dateFilter);
|
||||
result.put("totalAmount", totalAmount);
|
||||
result.put("denominations", rows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count}
|
||||
private static final org.springframework.jdbc.core.RowMapper<Object[]> DENOM_ROW =
|
||||
(rs, r) -> new Object[] {
|
||||
rs.getLong("denomination_id"),
|
||||
rs.getInt("denomination_value"),
|
||||
rs.getString("kind"),
|
||||
rs.getLong("total_count")
|
||||
};
|
||||
|
||||
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
|
||||
for (Object[] row : rows) {
|
||||
Long denomId = (Long) row[0];
|
||||
int denomValue = (Integer) row[1];
|
||||
long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
|
||||
long count = (Long) row[3];
|
||||
long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
|
||||
entry[2] += count * sign;
|
||||
}
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package org.apache.fineract.portfolio.cashsettlement.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlement;
|
||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
|
||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
|
||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
|
||||
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
@Path("/v1/cashsettlement")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class CashSettlementApiResource {
|
||||
|
||||
private final CashSettlementRepository repository;
|
||||
private final CashSettlementDetailRepository detailRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final GlDispatchService glDispatchService;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@POST
|
||||
public String create(String body) {
|
||||
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
|
||||
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
|
||||
final Long fromCashierId = json.has("fromCashierId") && !json.get("fromCashierId").isJsonNull()
|
||||
? json.get("fromCashierId").getAsLong() : null;
|
||||
final Long toTellerId = json.has("toTellerId") && !json.get("toTellerId").isJsonNull()
|
||||
? json.get("toTellerId").getAsLong() : null;
|
||||
|
||||
if (fromCashierId == null || toTellerId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashsettlement.parties.required",
|
||||
"fromCashierId and toTellerId are required.");
|
||||
}
|
||||
|
||||
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now();
|
||||
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
|
||||
? json.get("fromParty").getAsString() : null;
|
||||
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
|
||||
? json.get("toParty").getAsString() : null;
|
||||
final String note = json.has("note") && !json.get("note").isJsonNull()
|
||||
? json.get("note").getAsString() : null;
|
||||
final String currencyCode = json.has("currencyCode") && !json.get("currencyCode").isJsonNull()
|
||||
? json.get("currencyCode").getAsString() : "INR";
|
||||
|
||||
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
||||
? json.getAsJsonArray("denominations") : new JsonArray();
|
||||
|
||||
if (!hasPositiveCount(denominations)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashsettlement.empty",
|
||||
"At least one denomination with a positive count is required.");
|
||||
}
|
||||
|
||||
final BigDecimal total = sumTotal(denominations);
|
||||
|
||||
CashSettlement header = new CashSettlement();
|
||||
header.setSettlementDate(settlementDate);
|
||||
header.setFromCashierId(fromCashierId);
|
||||
header.setToTellerId(toTellerId);
|
||||
header.setFromParty(fromParty);
|
||||
header.setToParty(toParty);
|
||||
header.setSettlementAmount(total);
|
||||
header.setNote(note);
|
||||
header.setCreatedAt(LocalDateTime.now());
|
||||
header.setIsReversed(false);
|
||||
try {
|
||||
AppUser user = this.context.authenticatedUser();
|
||||
header.setPerformedBy(user.getId());
|
||||
header.setPerformedByUsername(user.getUsername());
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
// Commit header + details first, then GL dispatch separately to avoid
|
||||
// EclipseLink flushing pending detail rows inside Fineract's GL transaction.
|
||||
final Long savedId = new TransactionTemplate(transactionManager).execute(status -> {
|
||||
CashSettlement saved = repository.saveAndFlush(header);
|
||||
saveDetails(saved.getId(), denominations);
|
||||
return saved.getId();
|
||||
});
|
||||
|
||||
String glWarning = null;
|
||||
try {
|
||||
glDispatchService.settleCashFromCashier(toTellerId, fromCashierId, total,
|
||||
currencyCode, settlementDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
note != null ? note : "");
|
||||
} catch (Exception e) {
|
||||
glWarning = "Settlement saved but GL entry failed: " + e.getMessage();
|
||||
}
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", savedId);
|
||||
if (glWarning != null) result.addProperty("glWarning", glWarning);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/reverse")
|
||||
@Transactional
|
||||
public String reverse(@PathParam("id") Long id, String body) {
|
||||
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHSETTLEMENT");
|
||||
|
||||
CashSettlement settlement = repository.findById(id)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashsettlement.not.found",
|
||||
"Settlement not found: " + id));
|
||||
if (Boolean.TRUE.equals(settlement.getIsReversed())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashsettlement.already.reversed",
|
||||
"Settlement " + id + " is already reversed.");
|
||||
}
|
||||
|
||||
String reason = null;
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
AppUser user = null;
|
||||
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {}
|
||||
|
||||
settlement.setIsReversed(true);
|
||||
settlement.setReversedAt(LocalDateTime.now());
|
||||
settlement.setReverseReason(reason);
|
||||
if (user != null) settlement.setReversedBy(user.getId());
|
||||
repository.save(settlement);
|
||||
|
||||
if (settlement.getToTellerId() != null && settlement.getFromCashierId() != null) {
|
||||
try {
|
||||
glDispatchService.allocateCashToCashier(settlement.getToTellerId(),
|
||||
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
|
||||
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("reversed", true);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
public String getAll() {
|
||||
List<CashSettlement> list = repository.findAllByOrderByCreatedAtDesc();
|
||||
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{id}")
|
||||
public String getOne(@PathParam("id") Long id) {
|
||||
CashSettlement header = repository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Cash settlement not found: " + id));
|
||||
Map<String, Object> map = toMap(header);
|
||||
List<Map<String, Object>> details = detailRepository.findBySettlementId(id).stream()
|
||||
.map(this::detailToMap).collect(Collectors.toList());
|
||||
map.put("details", details);
|
||||
return gson.toJson(map);
|
||||
}
|
||||
|
||||
private BigDecimal sumTotal(JsonArray items) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
||||
int count = item.has("count") ? item.get("count").getAsInt() : 0;
|
||||
if (count > 0) total = total.add(BigDecimal.valueOf((long) value * count));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private boolean hasPositiveCount(JsonArray items) {
|
||||
for (JsonElement el : items) {
|
||||
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void saveDetails(Long settlementId, JsonArray items) {
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int count = item.has("count") ? item.get("count").getAsInt() : 0;
|
||||
if (count <= 0) continue;
|
||||
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
||||
Long denomId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
|
||||
? item.get("denominationId").getAsLong() : null;
|
||||
CashSettlementDetail d = new CashSettlementDetail();
|
||||
d.setSettlementId(settlementId);
|
||||
d.setDenominationId(denomId);
|
||||
d.setDenominationValue(value);
|
||||
d.setCount(count);
|
||||
d.setTotalAmount(BigDecimal.valueOf((long) value * count));
|
||||
detailRepository.save(d);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(CashSettlement e) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", e.getId());
|
||||
m.put("settlementDate", e.getSettlementDate() != null ? e.getSettlementDate().toString() : null);
|
||||
m.put("fromCashierId", e.getFromCashierId());
|
||||
m.put("toTellerId", e.getToTellerId());
|
||||
m.put("fromParty", e.getFromParty());
|
||||
m.put("toParty", e.getToParty());
|
||||
m.put("settlementAmount", e.getSettlementAmount());
|
||||
m.put("note", e.getNote());
|
||||
m.put("performedBy", e.getPerformedBy());
|
||||
m.put("performedByUsername", e.getPerformedByUsername());
|
||||
m.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
|
||||
m.put("isReversed", e.getIsReversed());
|
||||
m.put("reversedBy", e.getReversedBy());
|
||||
m.put("reversedAt", e.getReversedAt() != null ? e.getReversedAt().toString() : null);
|
||||
m.put("reverseReason", e.getReverseReason());
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> detailToMap(CashSettlementDetail d) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", d.getId());
|
||||
m.put("denominationId", d.getDenominationId());
|
||||
m.put("denominationValue", d.getDenominationValue());
|
||||
m.put("count", d.getCount());
|
||||
m.put("totalAmount", d.getTotalAmount());
|
||||
return m;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package org.apache.fineract.portfolio.cashsettlement.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_cash_settlement")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class CashSettlement {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "settlement_date")
|
||||
private LocalDate settlementDate;
|
||||
|
||||
@Column(name = "from_cashier_id")
|
||||
private Long fromCashierId;
|
||||
|
||||
@Column(name = "to_teller_id")
|
||||
private Long toTellerId;
|
||||
|
||||
@Column(name = "from_party", length = 100)
|
||||
private String fromParty;
|
||||
|
||||
@Column(name = "to_party", length = 100)
|
||||
private String toParty;
|
||||
|
||||
@Column(name = "settlement_amount", nullable = false)
|
||||
private BigDecimal settlementAmount;
|
||||
|
||||
@Column(name = "note", length = 500)
|
||||
private String note;
|
||||
|
||||
@Column(name = "performed_by")
|
||||
private Long performedBy;
|
||||
|
||||
@Column(name = "performed_by_username", length = 100)
|
||||
private String performedByUsername;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "is_reversed")
|
||||
private Boolean isReversed = false;
|
||||
|
||||
@Column(name = "reversed_by")
|
||||
private Long reversedBy;
|
||||
|
||||
@Column(name = "reversed_at")
|
||||
private LocalDateTime reversedAt;
|
||||
|
||||
@Column(name = "reverse_reason", length = 500)
|
||||
private String reverseReason;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package org.apache.fineract.portfolio.cashsettlement.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_cash_settlement_detail")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class CashSettlementDetail {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "settlement_id", nullable = false)
|
||||
private Long settlementId;
|
||||
|
||||
@Column(name = "denomination_id")
|
||||
private Long denominationId;
|
||||
|
||||
@Column(name = "denomination_value", nullable = false)
|
||||
private Integer denominationValue;
|
||||
|
||||
@Column(name = "count", nullable = false)
|
||||
private Integer count;
|
||||
|
||||
@Column(name = "total_amount", nullable = false)
|
||||
private BigDecimal totalAmount;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package org.apache.fineract.portfolio.cashsettlement.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface CashSettlementDetailRepository extends JpaRepository<CashSettlementDetail, Long> {
|
||||
List<CashSettlementDetail> findBySettlementId(Long settlementId);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package org.apache.fineract.portfolio.cashsettlement.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface CashSettlementRepository extends JpaRepository<CashSettlement, Long> {
|
||||
List<CashSettlement> findAllByOrderByCreatedAtDesc();
|
||||
List<CashSettlement> findByFromCashierIdOrderByCreatedAtDesc(Long cashierId);
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package org.apache.fineract.portfolio.cashtransfer.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
|
||||
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransferDetail;
|
||||
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransferDetailRepository;
|
||||
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransferRepository;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
@Path("/v1/cashtransfer")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class CashTransferApiResource {
|
||||
|
||||
private final CashTransferRepository repository;
|
||||
private final CashTransferDetailRepository detailRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final GlDispatchService glDispatchService;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@POST
|
||||
public String create(String body) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
|
||||
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("transferDate").getAsString())
|
||||
: null;
|
||||
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull() ? json.get("fromParty").getAsString()
|
||||
: null;
|
||||
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull() ? json.get("toParty").getAsString() : null;
|
||||
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
|
||||
|
||||
final Long fromTellerId = json.has("fromTellerId") && !json.get("fromTellerId").isJsonNull()
|
||||
? json.get("fromTellerId").getAsLong()
|
||||
: null;
|
||||
final Long toCashierId = json.has("toCashierId") && !json.get("toCashierId").isJsonNull()
|
||||
? json.get("toCashierId").getAsLong()
|
||||
: null;
|
||||
|
||||
if (fromTellerId == null || toCashierId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.parties.required",
|
||||
"fromTellerId and toCashierId are required.");
|
||||
}
|
||||
|
||||
final String currencyCode = json.has("currencyCode") && !json.get("currencyCode").isJsonNull()
|
||||
? json.get("currencyCode").getAsString()
|
||||
: "INR";
|
||||
|
||||
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
||||
? json.getAsJsonArray("denominations")
|
||||
: new JsonArray();
|
||||
|
||||
if (!hasPositiveCount(denominations)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.empty",
|
||||
"At least one denomination with a positive count is required.");
|
||||
}
|
||||
|
||||
final BigDecimal total = sumTotal(denominations);
|
||||
if (total.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.empty",
|
||||
"At least one denomination with a positive count is required.");
|
||||
}
|
||||
|
||||
CashTransfer header = new CashTransfer();
|
||||
header.setTransferDate(transferDate);
|
||||
header.setFromParty(fromParty);
|
||||
header.setToParty(toParty);
|
||||
header.setFromTellerId(fromTellerId);
|
||||
header.setToCashierId(toCashierId);
|
||||
header.setTransferAmount(total);
|
||||
header.setNote(note);
|
||||
header.setCreatedAt(LocalDateTime.now());
|
||||
try {
|
||||
AppUser user = this.context.authenticatedUser();
|
||||
if (user != null) {
|
||||
header.setPerformedBy(user.getId());
|
||||
header.setPerformedByUsername(user.getUsername());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// resolving the current user must never block recording the transfer
|
||||
}
|
||||
|
||||
// Save header + details in one committed transaction, then GL dispatch separately.
|
||||
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
|
||||
// ALLOCATECASHTOCASHIER transaction, which caused null transfer_id constraint errors.
|
||||
final Long savedId = new TransactionTemplate(transactionManager).execute(status -> {
|
||||
CashTransfer saved = repository.saveAndFlush(header);
|
||||
saveDetails(saved.getId(), denominations);
|
||||
return saved.getId();
|
||||
});
|
||||
|
||||
String glWarning = null;
|
||||
try {
|
||||
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now();
|
||||
glDispatchService.allocateCashToCashier(fromTellerId, toCashierId, total,
|
||||
currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
note != null ? note : "");
|
||||
} catch (Exception e) {
|
||||
glWarning = "Transfer saved but GL entry failed: " + e.getMessage();
|
||||
}
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", savedId);
|
||||
if (glWarning != null) result.addProperty("glWarning", glWarning);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/reverse")
|
||||
@Transactional
|
||||
public String reverse(@PathParam("id") Long id, String body) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHTRANSFER");
|
||||
|
||||
CashTransfer transfer = repository.findById(id)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashtransfer.not.found",
|
||||
"Cash transfer not found: " + id));
|
||||
if (Boolean.TRUE.equals(transfer.getIsReversed())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.already.reversed",
|
||||
"Cash transfer " + id + " is already reversed.");
|
||||
}
|
||||
|
||||
String reason = null;
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
try {
|
||||
AppUser user = this.context.authenticatedUser();
|
||||
transfer.setReversedBy(user.getId());
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
transfer.setIsReversed(true);
|
||||
transfer.setReversedAt(java.time.LocalDateTime.now());
|
||||
transfer.setReverseReason(reason);
|
||||
repository.save(transfer);
|
||||
|
||||
if (transfer.getFromTellerId() != null && transfer.getToCashierId() != null) {
|
||||
try {
|
||||
glDispatchService.settleCashFromCashier(transfer.getFromTellerId(),
|
||||
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
|
||||
java.time.LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("reversed", true);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
public String getAll() {
|
||||
List<CashTransfer> list = repository.findAllByOrderByCreatedAtDesc();
|
||||
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{id}")
|
||||
public String getOne(@PathParam("id") Long id) {
|
||||
CashTransfer header = repository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Cash transfer not found: " + id));
|
||||
Map<String, Object> map = toMap(header);
|
||||
List<Map<String, Object>> details = detailRepository.findByTransferId(id).stream().map(this::detailToMap)
|
||||
.collect(Collectors.toList());
|
||||
map.put("details", details);
|
||||
return gson.toJson(map);
|
||||
}
|
||||
|
||||
private BigDecimal sumTotal(JsonArray items) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
||||
? item.get("denominationValue").getAsInt()
|
||||
: 0;
|
||||
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
||||
if (count > 0) {
|
||||
total = total.add(BigDecimal.valueOf((long) value * count));
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private boolean hasPositiveCount(JsonArray items) {
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
||||
if (count > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void saveDetails(Long transferId, JsonArray items) {
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
||||
if (count <= 0) {
|
||||
continue;
|
||||
}
|
||||
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
||||
? item.get("denominationValue").getAsInt()
|
||||
: 0;
|
||||
Long denominationId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
|
||||
? item.get("denominationId").getAsLong()
|
||||
: null;
|
||||
CashTransferDetail detail = new CashTransferDetail();
|
||||
detail.setTransferId(transferId);
|
||||
detail.setDenominationId(denominationId);
|
||||
detail.setDenominationValue(value);
|
||||
detail.setCount(count);
|
||||
detail.setTotalAmount(BigDecimal.valueOf((long) value * count));
|
||||
detailRepository.save(detail);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(CashTransfer e) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("id", e.getId());
|
||||
map.put("transferDate", e.getTransferDate() != null ? e.getTransferDate().toString() : null);
|
||||
map.put("fromParty", e.getFromParty());
|
||||
map.put("toParty", e.getToParty());
|
||||
map.put("fromTellerId", e.getFromTellerId());
|
||||
map.put("toCashierId", e.getToCashierId());
|
||||
map.put("transferAmount", e.getTransferAmount());
|
||||
map.put("note", e.getNote());
|
||||
map.put("performedBy", e.getPerformedBy());
|
||||
map.put("performedByUsername", e.getPerformedByUsername());
|
||||
map.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> detailToMap(CashTransferDetail d) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("id", d.getId());
|
||||
map.put("denominationId", d.getDenominationId());
|
||||
map.put("denominationValue", d.getDenominationValue());
|
||||
map.put("count", d.getCount());
|
||||
map.put("totalAmount", d.getTotalAmount());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.cashtransfer.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_cash_transfer")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class CashTransfer {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "transfer_date")
|
||||
private LocalDate transferDate;
|
||||
|
||||
@Column(name = "from_party", length = 100)
|
||||
private String fromParty;
|
||||
|
||||
@Column(name = "to_party", length = 100)
|
||||
private String toParty;
|
||||
|
||||
@Column(name = "transfer_amount", nullable = false)
|
||||
private BigDecimal transferAmount;
|
||||
|
||||
@Column(name = "note", length = 500)
|
||||
private String note;
|
||||
|
||||
@Column(name = "performed_by")
|
||||
private Long performedBy;
|
||||
|
||||
@Column(name = "performed_by_username", length = 100)
|
||||
private String performedByUsername;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "from_teller_id")
|
||||
private Long fromTellerId;
|
||||
|
||||
@Column(name = "to_cashier_id")
|
||||
private Long toCashierId;
|
||||
|
||||
@Column(name = "is_reversed")
|
||||
private Boolean isReversed = false;
|
||||
|
||||
@Column(name = "reversed_by")
|
||||
private Long reversedBy;
|
||||
|
||||
@Column(name = "reversed_at")
|
||||
private java.time.LocalDateTime reversedAt;
|
||||
|
||||
@Column(name = "reverse_reason", length = 500)
|
||||
private String reverseReason;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.cashtransfer.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_cash_transfer_detail")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class CashTransferDetail {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "transfer_id", nullable = false)
|
||||
private Long transferId;
|
||||
|
||||
@Column(name = "denomination_id")
|
||||
private Long denominationId;
|
||||
|
||||
@Column(name = "denomination_value", nullable = false)
|
||||
private Integer denominationValue;
|
||||
|
||||
@Column(name = "count", nullable = false)
|
||||
private Integer count;
|
||||
|
||||
@Column(name = "total_amount", nullable = false)
|
||||
private BigDecimal totalAmount;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package org.apache.fineract.portfolio.cashtransfer.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface CashTransferDetailRepository extends JpaRepository<CashTransferDetail, Long> {
|
||||
List<CashTransferDetail> findByTransferId(Long transferId);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package org.apache.fineract.portfolio.cashtransfer.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface CashTransferRepository extends JpaRepository<CashTransfer, Long> {
|
||||
List<CashTransfer> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package org.apache.fineract.portfolio.teller.service;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GlDispatchService {
|
||||
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
* Vault → Cashier GL entry. Runs in its own transaction so a failure
|
||||
* never rolls back the caller's (already-committed) transfer record.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void allocateCashToCashier(Long tellerId, Long cashierId,
|
||||
BigDecimal amount, String currencyCode, String txnDate, String notes) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("locale", "en");
|
||||
body.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
body.addProperty("txnDate", txnDate);
|
||||
body.addProperty("txnAmount", amount);
|
||||
body.addProperty("currencyCode", currencyCode);
|
||||
body.addProperty("txnNote", notes != null ? notes : "");
|
||||
CommandWrapper wrapper = new CommandWrapperBuilder().withJson(gson.toJson(body))
|
||||
.allocateCashToCashier(tellerId, cashierId).build();
|
||||
commandsSourceWritePlatformService.logCommandSource(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashier → Vault GL entry (settlement / transfer reversal).
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void settleCashFromCashier(Long tellerId, Long cashierId,
|
||||
BigDecimal amount, String currencyCode, String txnDate, String notes) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("locale", "en");
|
||||
body.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
body.addProperty("txnDate", txnDate);
|
||||
body.addProperty("txnAmount", amount);
|
||||
body.addProperty("currencyCode", currencyCode);
|
||||
body.addProperty("txnNote", notes != null ? notes : "");
|
||||
CommandWrapper wrapper = new CommandWrapperBuilder().withJson(gson.toJson(body))
|
||||
.settleCashFromCashier(tellerId, cashierId).build();
|
||||
commandsSourceWritePlatformService.logCommandSource(wrapper);
|
||||
}
|
||||
}
|
||||
@@ -320,4 +320,11 @@
|
||||
<include file="parts/0301_add_current_account_to_transfer_details.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0012_denomination_master.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0013_cash_exchange.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0014_cash_transfer.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0015_cash_transfer_add_ids.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0016_cash_settlement.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0017_cash_inter_cashier.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0018_cash_reconciliation.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0019_cash_reversal_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0020_cash_permissions.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="cash_transfer_table_001" author="CBS">
|
||||
<createTable tableName="m_cash_transfer">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="transfer_date" type="DATE"/>
|
||||
<column name="from_party" type="VARCHAR(100)"/>
|
||||
<column name="to_party" type="VARCHAR(100)"/>
|
||||
<column name="transfer_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="note" type="VARCHAR(500)"/>
|
||||
<column name="performed_by" type="BIGINT"/>
|
||||
<column name="performed_by_username" type="VARCHAR(100)"/>
|
||||
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="cash_transfer_detail_table_001" author="CBS">
|
||||
<createTable tableName="m_cash_transfer_detail">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="transfer_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="denomination_id" type="BIGINT"/>
|
||||
<column name="denomination_value" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="count" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="total_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cash_transfer_detail" indexName="idx_cash_transfer_detail_tid">
|
||||
<column name="transfer_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="cash_transfer_add_party_ids_001" author="CBS">
|
||||
<addColumn tableName="m_cash_transfer">
|
||||
<column name="from_teller_id" type="BIGINT"/>
|
||||
<column name="to_cashier_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="cash_settlement_table_001" author="CBS">
|
||||
<createTable tableName="m_cash_settlement">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="settlement_date" type="DATE"/>
|
||||
<column name="from_cashier_id" type="BIGINT"/>
|
||||
<column name="to_teller_id" type="BIGINT"/>
|
||||
<column name="from_party" type="VARCHAR(100)"/>
|
||||
<column name="to_party" type="VARCHAR(100)"/>
|
||||
<column name="settlement_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="note" type="VARCHAR(500)"/>
|
||||
<column name="performed_by" type="BIGINT"/>
|
||||
<column name="performed_by_username" type="VARCHAR(100)"/>
|
||||
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
|
||||
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
|
||||
<column name="reversed_by" type="BIGINT"/>
|
||||
<column name="reversed_at" type="TIMESTAMP"/>
|
||||
<column name="reverse_reason" type="VARCHAR(500)"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="cash_settlement_detail_table_001" author="CBS">
|
||||
<createTable tableName="m_cash_settlement_detail">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="settlement_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="denomination_id" type="BIGINT"/>
|
||||
<column name="denomination_value" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="count" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="total_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cash_settlement_detail" indexName="idx_cash_settlement_detail_sid">
|
||||
<column name="settlement_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="cash_inter_cashier_table_001" author="CBS">
|
||||
<createTable tableName="m_cash_inter_cashier">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="transfer_date" type="DATE"/>
|
||||
<column name="teller_id" type="BIGINT"/>
|
||||
<column name="from_cashier_id" type="BIGINT"/>
|
||||
<column name="to_cashier_id" type="BIGINT"/>
|
||||
<column name="from_party" type="VARCHAR(100)"/>
|
||||
<column name="to_party" type="VARCHAR(100)"/>
|
||||
<column name="transfer_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="note" type="VARCHAR(500)"/>
|
||||
<column name="performed_by" type="BIGINT"/>
|
||||
<column name="performed_by_username" type="VARCHAR(100)"/>
|
||||
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
|
||||
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
|
||||
<column name="reversed_by" type="BIGINT"/>
|
||||
<column name="reversed_at" type="TIMESTAMP"/>
|
||||
<column name="reverse_reason" type="VARCHAR(500)"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="cash_inter_cashier_detail_table_001" author="CBS">
|
||||
<createTable tableName="m_cash_inter_cashier_detail">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="inter_cashier_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="denomination_id" type="BIGINT"/>
|
||||
<column name="denomination_value" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="count" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="total_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cash_inter_cashier_detail" indexName="idx_cash_inter_cashier_detail_pid">
|
||||
<column name="inter_cashier_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="cash_reconciliation_table_001" author="CBS">
|
||||
<createTable tableName="m_cash_reconciliation">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="recon_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="cashier_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="cashier_name" type="VARCHAR(150)"/>
|
||||
<column name="teller_id" type="BIGINT"/>
|
||||
<column name="expected_amount" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
|
||||
<column name="physical_amount" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
|
||||
<column name="variance" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
|
||||
<column name="status" type="VARCHAR(10)" defaultValue="OK"/>
|
||||
<column name="note" type="VARCHAR(500)"/>
|
||||
<column name="performed_by" type="BIGINT"/>
|
||||
<column name="performed_by_username" type="VARCHAR(100)"/>
|
||||
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cash_reconciliation" indexName="idx_cash_recon_cashier_date">
|
||||
<column name="cashier_id"/>
|
||||
<column name="recon_date"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="cash_reconciliation_detail_table_001" author="CBS">
|
||||
<createTable tableName="m_cash_reconciliation_detail">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="reconciliation_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="denomination_id" type="BIGINT"/>
|
||||
<column name="denomination_value" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="expected_count" type="INT" defaultValueNumeric="0"/>
|
||||
<column name="physical_count" type="INT" defaultValueNumeric="0"/>
|
||||
<column name="variance_count" type="INT" defaultValueNumeric="0"/>
|
||||
<column name="variance_amount" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cash_reconciliation_detail" indexName="idx_cash_recon_detail_rid">
|
||||
<column name="reconciliation_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="cash_transfer_reversal_cols_001" author="CBS">
|
||||
<addColumn tableName="m_cash_transfer">
|
||||
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
|
||||
<column name="reversed_by" type="BIGINT"/>
|
||||
<column name="reversed_at" type="TIMESTAMP"/>
|
||||
<column name="reverse_reason" type="VARCHAR(500)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="cash_module_permissions_001" author="CBS" runOnChange="false">
|
||||
<sql>
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'CREATE_CASHTRANSFER', 'CASHTRANSFER', 'CREATE', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHTRANSFER');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'READ_CASHTRANSFER', 'CASHTRANSFER', 'READ', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHTRANSFER');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'REVERSE_CASHTRANSFER', 'CASHTRANSFER', 'REVERSE', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVERSE_CASHTRANSFER');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'CREATE_CASHSETTLEMENT', 'CASHSETTLEMENT', 'CREATE', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHSETTLEMENT');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'READ_CASHSETTLEMENT', 'CASHSETTLEMENT', 'READ', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHSETTLEMENT');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'REVERSE_CASHSETTLEMENT', 'CASHSETTLEMENT', 'REVERSE', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVERSE_CASHSETTLEMENT');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'CREATE_CASHINTERCASHIER', 'CASHINTERCASHIER', 'CREATE', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHINTERCASHIER');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'READ_CASHINTERCASHIER', 'CASHINTERCASHIER', 'READ', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHINTERCASHIER');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'REVERSE_CASHINTERCASHIER', 'CASHINTERCASHIER', 'REVERSE', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVERSE_CASHINTERCASHIER');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'CREATE_CASHRECONCILIATION', 'CASHRECONCILIATION', 'CREATE', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHRECONCILIATION');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cashier', 'READ_CASHRECONCILIATION', 'CASHRECONCILIATION', 'READ', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHRECONCILIATION');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user