feat(dds): approvals review data, agent/party APIs, period-end interest, six-digit DDS GLs
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
- Post DDS interest at period end; drop Auto Interest Post Day (migration 0618) - DDS approval review data for product, agent and party (fields, guarantors, nominees) - Route DDS product/account/agent approvals to their own counters; add agent-approvals - Party next code and last account no endpoint; customer search by clientId and mobileNo - DDS GL codes to six digits with an agent GL (migration 0619) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+37
-6
@@ -29,6 +29,32 @@ public final class ApprovalCounterRegistry {
|
||||
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
|
||||
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
|
||||
|
||||
/** DDS commands held for a checker, as the DDS approval queue reads them. Aliased {@code c}. */
|
||||
private static final String DDS_PENDING = "FROM m_portfolio_command_source c JOIN m_permission p "
|
||||
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true WHERE c.status = 2 ";
|
||||
|
||||
/**
|
||||
* DDS product and DDS account requests, listed under Product Approval and Pending Account with every
|
||||
* other product and account rather than under DDS Approvals or the Checker Inbox. Written for any
|
||||
* alias, since the Checker Inbox counts with {@code aud} and the DDS counts with {@code c}.
|
||||
*/
|
||||
private static String ddsProductRequest(final String alias) {
|
||||
return "(" + alias + ".entity_name = 'DDSACCOUNTTYPE' AND " + alias + ".action_name IN ('CREATE','UPDATE'))";
|
||||
}
|
||||
|
||||
private static String ddsAccountRequest(final String alias) {
|
||||
return "(" + alias + ".entity_name = 'PARTY' AND " + alias + ".action_name IN ('CREATE','UPDATE'))";
|
||||
}
|
||||
|
||||
/**
|
||||
* A DDS agent request of any kind - opening, editing or closing one. An agent has no voucher and no
|
||||
* amount, so these wait under their own Agent Approval tab rather than among the money entries of
|
||||
* DDS Approvals or the Checker Inbox.
|
||||
*/
|
||||
private static String ddsAgentRequest(final String alias) {
|
||||
return "(" + alias + ".entity_name = 'AGENT')";
|
||||
}
|
||||
|
||||
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
|
||||
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
|
||||
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
|
||||
@@ -49,7 +75,9 @@ public final class ApprovalCounterRegistry {
|
||||
// ── Fineract's own maker-checker and pending lists ────────────────────────────
|
||||
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"),
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE') "
|
||||
+ "AND NOT " + ddsProductRequest("aud") + " AND NOT " + ddsAccountRequest("aud")
|
||||
+ " AND NOT " + ddsAgentRequest("aud")),
|
||||
new ApprovalCounter("client-approval", "READ_CLIENT",
|
||||
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
|
||||
@@ -67,14 +95,16 @@ public final class ApprovalCounterRegistry {
|
||||
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
|
||||
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
|
||||
+ "AND COALESCE(ca.terminate_true, false) = false "
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"),
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h)) "
|
||||
+ "+ (SELECT count(*) " + DDS_PENDING + "AND " + ddsAccountRequest("c") + ")"),
|
||||
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
|
||||
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
|
||||
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
|
||||
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
|
||||
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"),
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')) "
|
||||
+ "+ (SELECT count(*) " + DDS_PENDING + "AND " + ddsProductRequest("c") + ")"),
|
||||
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
|
||||
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
|
||||
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
|
||||
@@ -104,9 +134,10 @@ public final class ApprovalCounterRegistry {
|
||||
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("dds-approvals", null,
|
||||
"SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p "
|
||||
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true "
|
||||
+ "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"),
|
||||
"SELECT count(*) " + DDS_PENDING + "AND c.entity_name IN (" + DDS_ENTITIES + ") "
|
||||
+ "AND NOT " + ddsProductRequest("c") + " AND NOT " + ddsAccountRequest("c")
|
||||
+ " AND NOT " + ddsAgentRequest("c")),
|
||||
new ApprovalCounter("agent-approvals", null, "SELECT count(*) " + DDS_PENDING + "AND " + ddsAgentRequest("c")),
|
||||
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
|
||||
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
|
||||
|
||||
+5
-3
@@ -53,14 +53,16 @@ public class CustomerSearchApiResource {
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Search for a customer", description = "AND-combines whichever of name/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).")
|
||||
public Collection<CustomerSearchResultData> search(@QueryParam("name") final String name,
|
||||
@Operation(summary = "Search for a customer", description = "AND-combines whichever of clientId/name/mobileNo/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).")
|
||||
public Collection<CustomerSearchResultData> search(@QueryParam("clientId") final String clientId,
|
||||
@QueryParam("name") final String name, @QueryParam("mobileNo") final String mobileNo,
|
||||
@QueryParam("accountNumber") final String accountNumber, @QueryParam("aadhaarNumber") final String aadhaarNumber,
|
||||
@QueryParam("panNumber") final String panNumber, @QueryParam("address") final String address,
|
||||
@QueryParam("accountTypes") final String accountTypes) {
|
||||
final List<String> accountTypeList = accountTypes == null || accountTypes.isBlank() ? List.of()
|
||||
: Arrays.asList(accountTypes.split(","));
|
||||
return safely(() -> this.readPlatformService.search(name, accountNumber, aadhaarNumber, panNumber, address, accountTypeList));
|
||||
return safely(() -> this.readPlatformService.search(clientId, name, mobileNo, accountNumber, aadhaarNumber, panNumber, address,
|
||||
accountTypeList));
|
||||
}
|
||||
|
||||
/** Surfaces IllegalArgumentException as a clean 400 instead of an opaque 500. */
|
||||
|
||||
+4
-3
@@ -27,12 +27,13 @@ public interface CustomerSearchReadPlatformService {
|
||||
/**
|
||||
* Multi-field AND search - only non-blank parameters are applied. At least one must be
|
||||
* supplied. name/address are partial, case-insensitive matches; accountNumber/aadhaarNumber/
|
||||
* panNumber are exact matches (they're precise identifiers, not free text). accountTypes, if
|
||||
* panNumber are exact matches (they're precise identifiers, not free text). clientId is an exact match
|
||||
* on the client's id and mobileNo a partial match on their mobile number. accountTypes, if
|
||||
* non-empty, restricts results to those account types only (SAVINGS/CURRENT/FIXED_DEPOSIT/
|
||||
* RECURRING_DEPOSIT) - a client with no account, or only accounts of other types, is excluded
|
||||
* entirely when this filter is active (unlike the unfiltered case, where a client with no
|
||||
* account at all still appears once for informational purposes).
|
||||
*/
|
||||
Collection<CustomerSearchResultData> search(String name, String accountNumber, String aadhaarNumber, String panNumber,
|
||||
String address, List<String> accountTypes);
|
||||
Collection<CustomerSearchResultData> search(String clientId, String name, String mobileNo, String accountNumber, String aadhaarNumber,
|
||||
String panNumber, String address, List<String> accountTypes);
|
||||
}
|
||||
|
||||
+28
-4
@@ -69,8 +69,11 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
""";
|
||||
|
||||
@Override
|
||||
public Collection<CustomerSearchResultData> search(final String name, final String accountNumber, final String aadhaarNumber,
|
||||
final String panNumber, final String address, final List<String> accountTypes) {
|
||||
public Collection<CustomerSearchResultData> search(final String clientId, final String name, final String mobileNo,
|
||||
final String accountNumber, final String aadhaarNumber, final String panNumber, final String address,
|
||||
final List<String> accountTypes) {
|
||||
final boolean hasClientId = clientId != null && !clientId.isBlank();
|
||||
final boolean hasMobile = mobileNo != null && !mobileNo.isBlank();
|
||||
final boolean hasName = name != null && !name.isBlank();
|
||||
final boolean hasAccountNumber = accountNumber != null && !accountNumber.isBlank();
|
||||
final boolean hasAadhaar = aadhaarNumber != null && !aadhaarNumber.isBlank();
|
||||
@@ -79,9 +82,12 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
final List<String> effectiveAccountTypes = accountTypes == null ? List.of()
|
||||
: accountTypes.stream().filter(t -> t != null && !t.isBlank()).map(String::trim).map(String::toUpperCase).toList();
|
||||
|
||||
if (!hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress && effectiveAccountTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException("Enter at least one search field - name, account number, Aadhaar, PAN, or address.");
|
||||
if (!hasClientId && !hasMobile && !hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress
|
||||
&& effectiveAccountTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Enter at least one search field - client ID, name, mobile number, account number, Aadhaar, PAN, or address.");
|
||||
}
|
||||
final Long clientIdValue = hasClientId ? parseClientId(clientId) : null;
|
||||
for (final String type : effectiveAccountTypes) {
|
||||
if (!VALID_ACCOUNT_TYPES.contains(type)) {
|
||||
throw new IllegalArgumentException(
|
||||
@@ -92,10 +98,19 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
final StringBuilder sql = new StringBuilder(BASE_SELECT).append(" WHERE 1=1 ");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
|
||||
if (hasClientId) {
|
||||
sql.append(" AND c.id = ? ");
|
||||
params.add(clientIdValue);
|
||||
}
|
||||
if (hasName) {
|
||||
sql.append(" AND LOWER(c.display_name) LIKE LOWER(?) ");
|
||||
params.add("%" + name.trim() + "%");
|
||||
}
|
||||
if (hasMobile) {
|
||||
// Partial, so the last digits of a number find it as well as the whole of it.
|
||||
sql.append(" AND c.mobile_no LIKE ? ");
|
||||
params.add("%" + mobileNo.trim() + "%");
|
||||
}
|
||||
if (hasAccountNumber) {
|
||||
sql.append(" AND acct.account_no = ? ");
|
||||
params.add(accountNumber.trim());
|
||||
@@ -131,6 +146,15 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
return this.jdbcTemplate.query(sql.toString(), new CustomerSearchResultMapper(), params.toArray());
|
||||
}
|
||||
|
||||
/** A client id is a whole number; anything else is the caller's mistake to be told about, not a server error. */
|
||||
private static Long parseClientId(final String clientId) {
|
||||
try {
|
||||
return Long.valueOf(clientId.trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new IllegalArgumentException("Client ID must be a number.");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CustomerSearchResultMapper implements RowMapper<CustomerSearchResultData> {
|
||||
@Override
|
||||
public CustomerSearchResultData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
|
||||
+2
-6
@@ -27,11 +27,7 @@ public interface DdsAccountTypeRepository extends JpaRepository<DdsAccountType,
|
||||
|
||||
Optional<DdsAccountType> findByAccountTypeCode(String accountTypeCode);
|
||||
|
||||
/**
|
||||
* Schemes that post interest on their own. A posting day is required to act on one, so a scheme
|
||||
* left without a day is not picked up - it would otherwise mean "post monthly, some day".
|
||||
*/
|
||||
@Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true "
|
||||
+ "and t.autoInterestPostDay is not null order by t.accountTypeCode")
|
||||
/** Schemes that post interest on their own, at the end of each posting period. */
|
||||
@Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true order by t.accountTypeCode")
|
||||
List<DdsAccountType> findDueForAutoInterestPost();
|
||||
}
|
||||
|
||||
+5
-5
@@ -39,6 +39,11 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
|
||||
/*
|
||||
* "autoInterestPostDay" is still accepted, and ignored, so a change held for approval before the
|
||||
* posting day was retired - or an older screen still sending it - is not refused. Interest now
|
||||
* posts at the end of each period, so there is no day to set.
|
||||
*/
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
|
||||
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
|
||||
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate",
|
||||
@@ -121,11 +126,6 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("autoInterestPostWhen", element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(20);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("autoInterestPostDay", element)) {
|
||||
v.reset().parameter("autoInterestPostDay")
|
||||
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("autoInterestPostDay", element)).ignoreIfNull()
|
||||
.inMinMaxRange(1, 31);
|
||||
}
|
||||
|
||||
// Premature closure terms. Zero is allowed throughout - a scheme may close early from day
|
||||
// one, or charge the customer or the agent nothing - so these are floored at zero, not at one.
|
||||
|
||||
+1
-2
@@ -43,7 +43,7 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
|
||||
public String schema() {
|
||||
return " t.id as id, t.account_type_group as accountTypeGroup, t.account_type_code as accountTypeCode, "
|
||||
+ " t.account_type_description as accountTypeDescription, t.auto_interest_post as autoInterestPost, "
|
||||
+ " t.auto_interest_post_when as autoInterestPostWhen, t.auto_interest_post_day as autoInterestPostDay, "
|
||||
+ " t.auto_interest_post_when as autoInterestPostWhen, "
|
||||
+ " t.maturity_auto_close as maturityAutoClose, "
|
||||
+ " t.premature_lockin_months as prematureLockinMonths, "
|
||||
+ " t.premature_interest_reduction as prematureInterestReduction, "
|
||||
@@ -63,7 +63,6 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
|
||||
.accountTypeDescription(rs.getString("accountTypeDescription")) //
|
||||
.autoInterestPost(rs.getBoolean("autoInterestPost")) //
|
||||
.autoInterestPostWhen(rs.getString("autoInterestPostWhen")) //
|
||||
.autoInterestPostDay(JdbcSupport.getInteger(rs, "autoInterestPostDay")) //
|
||||
.maturityAutoClose(rs.getBoolean("maturityAutoClose")) //
|
||||
.prematureLockinMonths(JdbcSupport.getInteger(rs, "prematureLockinMonths")) //
|
||||
.prematureInterestReduction(rs.getBigDecimal("prematureInterestReduction")) //
|
||||
|
||||
+444
-1
@@ -26,6 +26,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException;
|
||||
import org.apache.fineract.commands.exception.CommandNotFoundException;
|
||||
@@ -95,9 +96,408 @@ public class DdsApprovalReadService {
|
||||
final Map<String, Object> row = describe(found.get(0));
|
||||
row.put("description", text(jsonOf(found.get(0).get("json")), "withdrawDescription"));
|
||||
row.put("lines", linesFor(row, found.get(0)));
|
||||
row.put("fields", fieldsFor(row, found.get(0)));
|
||||
if (isAgentRequest(row)) {
|
||||
row.put("guarantors", guarantorsOf(jsonOf(found.get(0).get("json"))));
|
||||
}
|
||||
if (isPartyRequest(row)) {
|
||||
final JsonObject partyJson = jsonOf(found.get(0).get("json"));
|
||||
row.put("nominees", nomineesOf(partyJson));
|
||||
row.put("nomineeSplit", text(partyJson, "nomineeSplitMode"));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** A DDS account (party) being opened or changed - the entries whose review shows the account and its nominees. */
|
||||
private static boolean isPartyRequest(final Map<String, Object> row) {
|
||||
return "PARTY".equals(row.get("entity")) && ("CREATE".equals(row.get("action")) || "UPDATE".equals(row.get("action")));
|
||||
}
|
||||
|
||||
/** What a DDS account request carries, in the order the Party Master Entry screen asks for it. */
|
||||
private record PartyField(String parameter, String label, String group, String kind) {}
|
||||
|
||||
private static final String PARTY_GROUP_CUSTOMER = "Customer & Scheme";
|
||||
private static final String PARTY_GROUP_PERSONAL = "Personal & Contact";
|
||||
private static final String PARTY_GROUP_MINOR = "Minor / Guardian";
|
||||
private static final String PARTY_GROUP_TERMS = "Scheme Terms";
|
||||
private static final String PARTY_GROUP_STATUS = "Balances & Status";
|
||||
private static final String PARTY_GROUP_PAYOUT = "Maturity Payout";
|
||||
|
||||
private static final List<PartyField> PARTY_FIELDS = List.of( //
|
||||
new PartyField("clientId", "Client", PARTY_GROUP_CUSTOMER, "client"), //
|
||||
new PartyField("subType", "Scheme", PARTY_GROUP_CUSTOMER, "scheme"), //
|
||||
new PartyField("agentId", "Agent", PARTY_GROUP_CUSTOMER, "agent"), //
|
||||
new PartyField("officeId", "Branch", PARTY_GROUP_CUSTOMER, "office"), //
|
||||
new PartyField("nominal", "Nominal", PARTY_GROUP_CUSTOMER, "yesno"), //
|
||||
new PartyField("partyName", "Party Name", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("partyName1", "Party Name 1", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("address1", "Address 1", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("address2", "Address 2", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("address3", "Address 3", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("city", "City", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("pincode", "Pincode", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("gender", "Gender", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("telephone", "Telephone", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("mobileNoSms", "Mobile No (SMS)", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("aadharNo", "Aadhar No", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("panCardNo", "PAN Card No", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("ckycId", "CKYC ID", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("operation", "Operation", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("birthDate", "Birth Date", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("occupation", "Occupation", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("risk", "Risk", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("adultMinor", "Adult / Minor", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("guardianClientId", "Guardian", PARTY_GROUP_MINOR, "client"), //
|
||||
new PartyField("relation", "Relation", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("minorDate", "Comes of Age On", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("minAccountOpt", "Minor Account Option", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("openingDate", "Opening Date", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("dailyDepositAmount", "Daily Deposit", PARTY_GROUP_TERMS, "money"), //
|
||||
new PartyField("days", "Days", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("month", "Months", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("depositAmount", "Deposit Amount", PARTY_GROUP_TERMS, "money"), //
|
||||
new PartyField("maturityDate", "Maturity Date", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("ledgerNo", "Ledger No", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("reopenDate", "Reopen Date", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("freezeCode", "Freeze Code", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("lien", "Lien", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("lienReason", "Lien Reason", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("lienReasonOther", "Lien Reason (Other)", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("isSmsActive", "SMS Active", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("isEmailActive", "Email Active", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("od1Flag", "OD 1", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("od2Flag", "OD 2", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("payDate", "Pay Date", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("interestDate", "Interest Date", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("spDescription", "Description", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("advCode", "Adv Code", PARTY_GROUP_PAYOUT, "text"), //
|
||||
new PartyField("creditToBranch", "Credit To Branch", PARTY_GROUP_PAYOUT, "text"), //
|
||||
new PartyField("creditToAccount", "Credit To A/C", PARTY_GROUP_PAYOUT, "text"), //
|
||||
new PartyField("creditToAccountType", "Credit To A/C Type", PARTY_GROUP_PAYOUT, "text"));
|
||||
|
||||
/**
|
||||
* A DDS account request laid out for the checker: the client, scheme, agent and branch by name
|
||||
* rather than by number, in the sections the screen has. Only what the request carries is shown, so
|
||||
* an edit lists just the fields it changes, and a field left empty is left out.
|
||||
*/
|
||||
private List<Map<String, Object>> partyFieldsFor(final JsonObject json) {
|
||||
final List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for (final PartyField field : PARTY_FIELDS) {
|
||||
if (!json.has(field.parameter()) || json.get(field.parameter()).isJsonNull()) {
|
||||
continue;
|
||||
}
|
||||
final String raw = json.get(field.parameter()).getAsString();
|
||||
if (raw.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
final String value = switch (field.kind()) {
|
||||
case "client" -> clientLabel(raw);
|
||||
case "scheme" -> nameOr(first("select account_type_code || ' · ' || account_type_description from m_dds_account_type "
|
||||
+ "where account_type_code = ?", raw), raw);
|
||||
case "agent" -> nameOr(first("select agent_code || ' · ' || coalesce(agent_name, '') from m_dds_agent where id = ?",
|
||||
parseLong(raw)), "Agent #" + raw);
|
||||
case "office" -> nameOr(first("select name from m_office where id = ?", parseLong(raw)), "Branch #" + raw);
|
||||
case "yesno" -> Boolean.parseBoolean(raw.trim()) ? "Yes" : "No";
|
||||
case "money" -> "₹" + plain(raw);
|
||||
default -> raw.trim();
|
||||
};
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("key", field.parameter());
|
||||
out.put("label", field.label());
|
||||
out.put("group", field.group());
|
||||
out.put("format", "yesno".equals(field.kind()) ? "yesno" : "text");
|
||||
out.put("value", value);
|
||||
fields.add(out);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** The nominees a DDS account request names, with what each is to receive. */
|
||||
private List<Map<String, Object>> nomineesOf(final JsonObject json) {
|
||||
final List<Map<String, Object>> nominees = new ArrayList<>();
|
||||
if (!json.has("nominees") || !json.get("nominees").isJsonArray()) {
|
||||
return nominees;
|
||||
}
|
||||
for (final JsonElement element : json.getAsJsonArray("nominees")) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject n = element.getAsJsonObject();
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("name", text(n, "name"));
|
||||
out.put("relation", text(n, "relation"));
|
||||
out.put("gender", text(n, "gender"));
|
||||
out.put("mobileNo", text(n, "mobileNo"));
|
||||
out.put("birthDate", text(n, "birthDate"));
|
||||
out.put("nominationDate", text(n, "nominationDate"));
|
||||
out.put("guardianName", text(n, "guardianName"));
|
||||
out.put("share", text(n, "sharePercent") != null ? plain(text(n, "sharePercent")) + "%"
|
||||
: text(n, "sequenceNo") != null ? "Order " + text(n, "sequenceNo") : null);
|
||||
nominees.add(out);
|
||||
}
|
||||
return nominees;
|
||||
}
|
||||
|
||||
/** An agent being opened or changed - the entries whose review shows the agent and their guarantors. */
|
||||
private static boolean isAgentRequest(final Map<String, Object> row) {
|
||||
return "AGENT".equals(row.get("entity")) && ("CREATE".equals(row.get("action")) || "UPDATE".equals(row.get("action")));
|
||||
}
|
||||
|
||||
/** What an agent request carries, in the order the Agent Master Entry screen asks for it. */
|
||||
private record AgentField(String parameter, String label, String group, String kind) {}
|
||||
|
||||
private static final String AGENT_GROUP = "Agent";
|
||||
private static final String AGENT_ADDRESS_GROUP = "Address & Contact";
|
||||
private static final String AGENT_ACCOUNTS_GROUP = "Branch & Accounts";
|
||||
private static final String AGENT_BALANCES_GROUP = "Balances";
|
||||
|
||||
private static final List<AgentField> AGENT_FIELDS = List.of( //
|
||||
new AgentField("clientId", "Client", AGENT_GROUP, "client"), //
|
||||
new AgentField("agentNameId", "Agent Name", AGENT_GROUP, "text"), //
|
||||
new AgentField("agentBirthDate", "Birth Date", AGENT_GROUP, "text"), //
|
||||
new AgentField("agentOpeningDate", "Opening Date", AGENT_GROUP, "text"), //
|
||||
new AgentField("agentAddress1", "Address (Line 1)", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentAddress2", "Address (Line 2)", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentCity", "City", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentPinCode", "PinCode", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentTelephone", "Telephone", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("officeId", "Home Branch", AGENT_ACCOUNTS_GROUP, "office"), //
|
||||
new AgentField("glAccountId", "GL Account", AGENT_ACCOUNTS_GROUP, "gl"), //
|
||||
new AgentField("agentSavingAccountType", "Saving A/C Type", AGENT_ACCOUNTS_GROUP, "text"), //
|
||||
new AgentField("agentSavingAccountNo", "Saving A/C No", AGENT_ACCOUNTS_GROUP, "text"), //
|
||||
new AgentField("agentOpeningBalance", "Opening Balance", AGENT_BALANCES_GROUP, "money"));
|
||||
|
||||
/**
|
||||
* An agent request laid out for the checker: the client, the branch and the ledger account by name
|
||||
* rather than by number, in the sections the screen has. Only what the request carries is shown, so
|
||||
* an edit lists just the fields it changes.
|
||||
*/
|
||||
private List<Map<String, Object>> agentFieldsFor(final JsonObject json) {
|
||||
final List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for (final AgentField field : AGENT_FIELDS) {
|
||||
if (!json.has(field.parameter()) || json.get(field.parameter()).isJsonNull()) {
|
||||
continue;
|
||||
}
|
||||
final String raw = json.get(field.parameter()).getAsString();
|
||||
// A new agent starts with nothing, so a zero opening balance is not something to approve and
|
||||
// is left out. One that is not zero - an edit that sets it - is shown.
|
||||
if ("money".equals(field.kind()) && isZero(raw)) {
|
||||
continue;
|
||||
}
|
||||
final String value = switch (field.kind()) {
|
||||
case "client" -> clientLabel(raw);
|
||||
case "office" -> nameOr(first("select name from m_office where id = ?", parseLong(raw)), "Branch #" + raw);
|
||||
case "gl" -> nameOr(first("select gl_code || ' · ' || name from acc_gl_account where id = ?", parseLong(raw)),
|
||||
"Account #" + raw);
|
||||
case "money" -> "₹" + plain(raw);
|
||||
default -> raw.trim();
|
||||
};
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("key", field.parameter());
|
||||
out.put("label", field.label());
|
||||
out.put("group", field.group());
|
||||
out.put("format", "text");
|
||||
out.put("value", value);
|
||||
fields.add(out);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** The guarantors an agent request names, each with the client they are by number and name. */
|
||||
private List<Map<String, Object>> guarantorsOf(final JsonObject json) {
|
||||
final List<Map<String, Object>> guarantors = new ArrayList<>();
|
||||
if (!json.has("guarantors") || !json.get("guarantors").isJsonArray()) {
|
||||
return guarantors;
|
||||
}
|
||||
for (final JsonElement element : json.getAsJsonArray("guarantors")) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject g = element.getAsJsonObject();
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("client", text(g, "clientId") == null ? null : clientLabel(text(g, "clientId")));
|
||||
out.put("name", text(g, "guarantorName"));
|
||||
out.put("telephone", text(g, "telephone"));
|
||||
out.put("address", joined(", ", text(g, "address1"), text(g, "address2"), text(g, "city"), text(g, "pinCode")));
|
||||
guarantors.add(out);
|
||||
}
|
||||
return guarantors;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's code, name, client, branch, savings account and guarantor count. An agent already on
|
||||
* the books is read as they stand; one still being opened does not exist yet, so is read from the
|
||||
* request and has no code until approval gives it one.
|
||||
*/
|
||||
private Map<String, Object> agentColumns(final Long agentId, final JsonObject json) {
|
||||
final Map<String, Object> columns = new LinkedHashMap<>();
|
||||
if (agentId != null) {
|
||||
columns.putAll(this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select a.agent_code as \"agentCode\", a.agent_name as \"agentName\", c.display_name as \"clientName\", "
|
||||
+ "o.name as \"officeName\", a.agent_saving_account_no as \"savingAccountNo\", "
|
||||
+ "(select count(*) from m_dds_agent_guarantor g where g.agent_id = a.id) as \"guarantorCount\" "
|
||||
+ "from m_dds_agent a left join m_client c on c.id = a.client_id left join m_office o on o.id = a.office_id "
|
||||
+ "where a.id = ?",
|
||||
agentId).stream().findFirst().orElse(Map.of()));
|
||||
return columns;
|
||||
}
|
||||
final String clientId = text(json, "clientId");
|
||||
columns.put("agentCode", null);
|
||||
columns.put("agentName", text(json, "agentNameId"));
|
||||
columns.put("clientName", clientId == null ? null : first("select display_name from m_client where id = ?", parseLong(clientId)));
|
||||
final String officeId = text(json, "officeId");
|
||||
columns.put("officeName", officeId == null ? null : first("select name from m_office where id = ?", parseLong(officeId)));
|
||||
columns.put("savingAccountNo", text(json, "agentSavingAccountNo"));
|
||||
columns.put("guarantorCount", json.has("guarantors") && json.get("guarantors").isJsonArray() ? json.getAsJsonArray("guarantors").size() : 0);
|
||||
return columns;
|
||||
}
|
||||
|
||||
private String clientLabel(final String clientId) {
|
||||
final String name = first("select display_name from m_client where id = ?", parseLong(clientId));
|
||||
return name == null ? "Client #" + clientId : clientId + " · " + name;
|
||||
}
|
||||
|
||||
/** The name an agent request gives, or the client it is for when none was typed. */
|
||||
private String agentNameOf(final JsonObject json) {
|
||||
final String name = text(json, "agentNameId");
|
||||
if (name != null && !name.isBlank()) {
|
||||
return name.trim();
|
||||
}
|
||||
final String clientId = text(json, "clientId");
|
||||
return clientId == null ? "" : clientLabel(clientId);
|
||||
}
|
||||
|
||||
private static boolean isZero(final String amount) {
|
||||
try {
|
||||
return new BigDecimal(amount.trim()).signum() == 0;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String nameOr(final String found, final String fallback) {
|
||||
return found == null ? fallback : found;
|
||||
}
|
||||
|
||||
private static String joined(final String separator, final String... parts) {
|
||||
final List<String> present = new ArrayList<>();
|
||||
for (final String part : parts) {
|
||||
if (part != null && !part.isBlank()) {
|
||||
present.add(part.trim());
|
||||
}
|
||||
}
|
||||
return String.join(separator, present);
|
||||
}
|
||||
|
||||
/** A number an agent request carried as text, or -1 - which no row has - when it was not one. */
|
||||
private static Long parseLong(final String value) {
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A DDS product's terms, in the order the Account Type screen asks for them: the column it is
|
||||
* stored in, the parameter the request carries it as, its label, how it reads, and the section of
|
||||
* the review it is shown in.
|
||||
*/
|
||||
private record ProductField(String column, String parameter, String label, String format, String group) {}
|
||||
|
||||
private static final String GROUP_PRODUCT = "Product";
|
||||
private static final String GROUP_INTEREST = "Interest";
|
||||
private static final String GROUP_CLOSURE = "Maturity & Premature Closure";
|
||||
|
||||
private static final List<ProductField> PRODUCT_FIELDS = List.of( //
|
||||
new ProductField("account_type_group", "accountTypeGroup", "Account Type Group", "text", GROUP_PRODUCT), //
|
||||
new ProductField("account_type_code", "accountTypeCode", "Account Type Code", "text", GROUP_PRODUCT), //
|
||||
new ProductField("account_type_description", "accountTypeDescription", "Description", "text", GROUP_PRODUCT), //
|
||||
new ProductField("is_active", "active", "Active", "yesno", GROUP_PRODUCT), //
|
||||
new ProductField("interest_rate", "interestRate", "Interest Rate", "percent", GROUP_INTEREST), //
|
||||
new ProductField("auto_interest_post", "autoInterestPost", "Auto Interest Post", "yesno", GROUP_INTEREST), //
|
||||
new ProductField("auto_interest_post_when", "autoInterestPostWhen", "Auto Interest Post When", "text", GROUP_INTEREST), //
|
||||
new ProductField("maturity_auto_close", "maturityAutoClose", "Maturity Auto Close", "yesno", GROUP_CLOSURE), //
|
||||
new ProductField("premature_lockin_months", "prematureLockinMonths", "Premature Lock-in", "months", GROUP_CLOSURE), //
|
||||
new ProductField("premature_interest_reduction", "prematureInterestReduction", "Premature Interest Reduction", "percent",
|
||||
GROUP_CLOSURE), //
|
||||
new ProductField("premature_agent_commission_penalty", "prematureAgentCommissionPenalty", "Agent Commission Penalty",
|
||||
"percent", GROUP_CLOSURE));
|
||||
|
||||
/**
|
||||
* What an entry that moves no money actually asks for, field by field, so the checker can read the
|
||||
* terms they are approving rather than only a code.
|
||||
*
|
||||
* An edit still waiting is shown against the product as it stands, with each changed field marked,
|
||||
* because what matters there is what will change. Once actioned, the product has already moved on,
|
||||
* so only the values the request carried are shown.
|
||||
*/
|
||||
private List<Map<String, Object>> fieldsFor(final Map<String, Object> row, final Map<String, Object> entry) {
|
||||
if (isAgentRequest(row)) {
|
||||
return agentFieldsFor(jsonOf(entry.get("json")));
|
||||
}
|
||||
if (isPartyRequest(row)) {
|
||||
return partyFieldsFor(jsonOf(entry.get("json")));
|
||||
}
|
||||
if (!"DDSACCOUNTTYPE".equals(row.get("entity"))) {
|
||||
return List.of();
|
||||
}
|
||||
final JsonObject json = jsonOf(entry.get("json"));
|
||||
final Long resourceId = (Long) row.get("resourceId");
|
||||
final boolean compare = "UPDATE".equals(row.get("action")) && "PENDING".equals(row.get("status")) && resourceId != null;
|
||||
final Map<String, Object> current = compare
|
||||
? this.jdbcTemplate.queryForList("select * from m_dds_account_type where id = ?", resourceId).stream().findFirst() // NOSONAR
|
||||
.orElse(Map.of())
|
||||
: Map.of();
|
||||
|
||||
final List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for (final ProductField field : PRODUCT_FIELDS) {
|
||||
final boolean requested = json.has(field.parameter()) && !json.get(field.parameter()).isJsonNull();
|
||||
final String proposed = requested ? format(json.get(field.parameter()).getAsString(), field.format()) : null;
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("key", field.parameter());
|
||||
out.put("label", field.label());
|
||||
out.put("group", field.group());
|
||||
out.put("format", field.format());
|
||||
if (compare) {
|
||||
final Object stored = current.get(field.column());
|
||||
final String was = stored == null ? null : format(String.valueOf(stored), field.format());
|
||||
final String now = requested ? proposed : was;
|
||||
out.put("current", was);
|
||||
out.put("value", now);
|
||||
out.put("changed", requested && !Objects.equals(was, now));
|
||||
} else if (requested) {
|
||||
out.put("value", proposed);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
fields.add(out);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** A stored or requested value as the screen shows it: Yes/No, 2%, 12 months. */
|
||||
private static String format(final String value, final String format) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return switch (format) {
|
||||
case "yesno" -> Boolean.parseBoolean(value.trim()) ? "Yes" : "No";
|
||||
case "percent" -> plain(value) + "%";
|
||||
case "months" -> plain(value) + " month" + ("1".equals(plain(value)) ? "" : "s");
|
||||
default -> value.trim();
|
||||
};
|
||||
}
|
||||
|
||||
/** 2.000000 reads as 2, and 1.50 as 1.5, so a stored rate and a typed one compare equal. */
|
||||
private static String plain(final String value) {
|
||||
try {
|
||||
return new BigDecimal(value.trim()).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException e) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The debit and credit sides of the entry, as its voucher shows them. Once the money has moved they
|
||||
* are the voucher's own lines; while it waits they are what approving it will move, worked out from
|
||||
@@ -314,11 +714,15 @@ public class DdsApprovalReadService {
|
||||
case "CLOSE" -> "Close Agent";
|
||||
default -> "Edit Agent";
|
||||
};
|
||||
details = resourceId != null ? agent(resourceId) : String.valueOf(text(json, "agentName"));
|
||||
details = resourceId != null ? agent(resourceId) : agentNameOf(json);
|
||||
}
|
||||
case "DDSACCOUNTTYPE:CREATE", "DDSACCOUNTTYPE:UPDATE" -> {
|
||||
type = "CREATE".equals(action) ? "New DDS Product" : "Edit DDS Product";
|
||||
details = text(json, "accountTypeCode") != null ? text(json, "accountTypeCode") : scheme(resourceId);
|
||||
final String description = text(json, "accountTypeDescription");
|
||||
if (description != null && !description.isBlank()) {
|
||||
details = details == null || details.isBlank() ? description : details + " · " + description;
|
||||
}
|
||||
}
|
||||
case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> {
|
||||
type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab";
|
||||
@@ -342,6 +746,14 @@ public class DdsApprovalReadService {
|
||||
row.put("type", type);
|
||||
row.put("details", details);
|
||||
row.put("amount", amount);
|
||||
if ("PARTY".equals(entity) && ("CREATE".equals(action) || "UPDATE".equals(action))) {
|
||||
// The account in the columns Approvals -> Pending Account lists every other account by.
|
||||
row.putAll(accountColumns(resourceId, json));
|
||||
}
|
||||
if ("AGENT".equals(entity)) {
|
||||
// The agent in the columns Approvals -> Agent Approval lists them by.
|
||||
row.putAll(agentColumns(resourceId, json));
|
||||
}
|
||||
final Long commandId = ((Number) entry.get("id")).longValue();
|
||||
final String voucherNo = switch (status) {
|
||||
case STATUS_PROCESSED -> voucherFor(commandId, entity, action, resourceId, json, (String) entry.get("madeOn"));
|
||||
@@ -428,6 +840,37 @@ public class DdsApprovalReadService {
|
||||
return found == null ? "Agent #" + agentId : found.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A DDS account's number, holder, scheme, branch and agent. An edit reads the account as it
|
||||
* stands; a new account does not exist until approved, so it is read from the request, and has
|
||||
* no number yet.
|
||||
*/
|
||||
private Map<String, Object> accountColumns(final Long partyId, final JsonObject json) {
|
||||
final Map<String, Object> columns = new LinkedHashMap<>();
|
||||
if (partyId != null) {
|
||||
final Map<String, Object> stored = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select p.account_no as \"accountNo\", p.party_name as \"clientName\", p.sub_type as \"subType\", "
|
||||
+ "o.name as \"officeName\", trim(a.agent_code || ' ' || coalesce(a.agent_name, '')) as \"agentName\" "
|
||||
+ "from m_dds_party p left join m_office o on o.id = p.office_id "
|
||||
+ "left join m_dds_agent a on a.id = p.agent_id where p.id = ?",
|
||||
partyId).stream().findFirst().orElse(Map.of());
|
||||
columns.putAll(stored);
|
||||
} else {
|
||||
columns.put("accountNo", null);
|
||||
columns.put("clientName", text(json, "partyName"));
|
||||
columns.put("subType", text(json, "subType"));
|
||||
final Long officeId = longOf(json, "officeId");
|
||||
columns.put("officeName", officeId == null ? null : first("select name from m_office where id = ?", officeId));
|
||||
final Long agentId = longOf(json, "agentId");
|
||||
columns.put("agentName", agentId == null ? null : agent(agentId));
|
||||
}
|
||||
final String subType = (String) columns.remove("subType");
|
||||
final String description = subType == null ? null
|
||||
: first("select account_type_description from m_dds_account_type where account_type_code = ?", subType);
|
||||
columns.put("productName", description == null ? subType : subType + " · " + description);
|
||||
return columns;
|
||||
}
|
||||
|
||||
private String scheme(final Long accountTypeId) {
|
||||
return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId);
|
||||
}
|
||||
|
||||
+17
-17
@@ -810,28 +810,36 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Posts interest for every scheme that is due today.
|
||||
* Posts interest for every scheme whose posting period ended yesterday.
|
||||
*
|
||||
* A scheme that posts automatically has decided it needs no review, so this does both halves in
|
||||
* one go: it calculates the interest since the last run and then posts it into the customers'
|
||||
* balances. Both halves go through the same code the screen's Post button uses, and both leave a
|
||||
* run behind, so an automatic posting is as visible and as auditable as a manual one.
|
||||
*
|
||||
* Runs are scoped to one scheme at a time, because the posting day belongs to the scheme.
|
||||
* Interest posts at the end of the period - the last day of the month, quarter, half-year or
|
||||
* year - as it does on savings and fixed deposits, so there is no posting day to configure and
|
||||
* no month whose length can trip one up. The job runs just after midnight, so it looks back at
|
||||
* the day that has just closed: posting on the 1st through the last day of the previous month
|
||||
* takes in everything collected on that last day, which a run at 00:20 on the day itself would
|
||||
* not yet have seen.
|
||||
*
|
||||
* Runs are scoped to one scheme at a time, because the posting frequency belongs to the scheme.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int postDueAutoInterest(final LocalDate businessDate) {
|
||||
final LocalDate periodEnd = businessDate.minusDays(1);
|
||||
int posted = 0;
|
||||
|
||||
for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findDueForAutoInterestPost()) {
|
||||
if (!isDueOn(accountType, businessDate)) {
|
||||
if (!isPeriodEnd(accountType, periodEnd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String subType = accountType.getAccountTypeCode();
|
||||
calculateAndPost(INTEREST_CALCULATION, businessDate, subType);
|
||||
calculateAndPost(INTEREST_POSTING, businessDate, subType);
|
||||
calculateAndPost(INTEREST_CALCULATION, periodEnd, subType);
|
||||
calculateAndPost(INTEREST_POSTING, periodEnd, subType);
|
||||
posted++;
|
||||
}
|
||||
|
||||
@@ -839,19 +847,11 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this scheme's posting day falls on the given date.
|
||||
*
|
||||
* A day beyond the length of the month means the end of that month: a scheme set to 31 posts on
|
||||
* 31 January, 28 February and 30 April. Without that, February would simply be skipped and the
|
||||
* account would lose a month's interest every year.
|
||||
* Whether the given date closes one of this scheme's posting periods: the last day of a month
|
||||
* the scheme's frequency posts in. February's end is the 28th or 29th, whichever the year has.
|
||||
*/
|
||||
private boolean isDueOn(final DdsAccountType accountType, final LocalDate date) {
|
||||
if (!monthIsDue(accountType.getAutoInterestPostWhen(), date)) {
|
||||
return false;
|
||||
}
|
||||
final int configuredDay = accountType.getAutoInterestPostDay();
|
||||
final int effectiveDay = Math.min(configuredDay, date.lengthOfMonth());
|
||||
return date.getDayOfMonth() == effectiveDay;
|
||||
private boolean isPeriodEnd(final DdsAccountType accountType, final LocalDate date) {
|
||||
return monthIsDue(accountType.getAutoInterestPostWhen(), date) && date.getDayOfMonth() == date.lengthOfMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+17
@@ -110,6 +110,23 @@ public class PartiesApiResource {
|
||||
return readPlatformService.retrieveCreditableAccounts(clientId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("nextcode")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "What the Party Master Entry screen shows before saving",
|
||||
description = "partyCode: the code the next DDS party will be given - worked out the way saving does it (PT and one "
|
||||
+ "more than the highest number on the books), but only an estimate: another party saved first, or one "
|
||||
+ "waiting for approval, can take it. lastAccountNo: the number of the DDS account opened most recently, "
|
||||
+ "null when there is none. Both are for display; nothing is saved from them.")
|
||||
public Map<String, Object> retrieveNextPartyCode() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("partyCode", readPlatformService.nextPartyCode());
|
||||
result.put("lastAccountNo", readPlatformService.lastAccountNo());
|
||||
return result;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("sundryaccounts")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
+13
@@ -35,6 +35,19 @@ public interface PartyReadPlatformService {
|
||||
*/
|
||||
Collection<Map<String, Object>> retrieveCreditableAccounts(Long clientId);
|
||||
|
||||
/**
|
||||
* The code the next party saved will be given, for a screen to show before it is saved. It is worked
|
||||
* out the way saving does it - "PT" and one more than the highest number on the books - but only an
|
||||
* estimate: another party saved first, or one waiting for approval, can take it.
|
||||
*/
|
||||
String nextPartyCode();
|
||||
|
||||
/**
|
||||
* The account number of the DDS account opened most recently, or null when none has been - what the
|
||||
* Party Master Entry screen shows as Last A/c. Read only: nothing is saved from it.
|
||||
*/
|
||||
String lastAccountNo();
|
||||
|
||||
/**
|
||||
* How a closed account was closed - early or at maturity, by whom, what the balance came to and
|
||||
* where it went - gathered from its transactions, the ledger and the command that closed it.
|
||||
|
||||
+19
@@ -334,6 +334,25 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
+ "where c.client_id = ? and c.status_enum = 300"
|
||||
+ ") accounts order by \"accountNo\"";
|
||||
|
||||
@Override
|
||||
public String nextPartyCode() {
|
||||
// The same figure saving a party works from - the highest number among every code on the books,
|
||||
// closed parties included - so what a screen shows is what saving would give it right now.
|
||||
final Long highest = this.jdbcTemplate.queryForObject( // NOSONAR
|
||||
"select coalesce(max(cast(substring(party_code, 3) as bigint)), 0) from m_dds_party where party_code like 'PT%'",
|
||||
Long.class);
|
||||
return String.format("PT%04d", (highest == null ? 0L : highest) + 1L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String lastAccountNo() {
|
||||
// The newest account by id - an account's number is given from its id, so the highest id is the
|
||||
// one opened last. Closed accounts count: this says what was opened, not what is still open.
|
||||
final List<String> found = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select account_no from m_dds_party where account_no is not null order by id desc limit 1", String.class);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Map<String, Object>> retrieveCreditableAccounts(final Long clientId) {
|
||||
return this.jdbcTemplate.queryForList(CREDITABLE_ACCOUNTS_SQL, clientId, clientId); // NOSONAR
|
||||
|
||||
@@ -189,4 +189,6 @@
|
||||
<include file="parts/0606_add_ctr_monthly_report_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0607_cc_relevant_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0618_drop_dds_account_type_post_day.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0619_dds_gl_six_digit_codes_and_agent_gl.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Retires the DDS account type's posting day.
|
||||
|
||||
Automatic interest now posts at the end of each period - the last day of the month, quarter,
|
||||
half-year or year - the way savings and fixed deposits post, so a scheme no longer names a day.
|
||||
A fixed day could not mean the same thing every month: 31 does not exist in February, and a
|
||||
mid-month day split the month-end balance across two postings. Nothing reads the column any
|
||||
more, so it goes rather than being left to look as though it still does something.
|
||||
-->
|
||||
<changeSet id="0618_drop_dds_account_type_post_day" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_dds_account_type" columnName="auto_interest_post_day"/>
|
||||
</preConditions>
|
||||
<dropColumn tableName="m_dds_account_type" columnName="auto_interest_post_day"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Brings the four ledger accounts DDS added early on into the chart's own numbering.
|
||||
|
||||
Every other account in the chart carries a six-digit code - the first digit says what kind it is
|
||||
(1 asset, 2 liability, 3 equity, 4 income, 5 expense) - but these four were given 2.1, 9.7, 11.2
|
||||
and 11.3, which sort among nothing and read as nothing on a trial balance.
|
||||
|
||||
Only the code changes. DDS finds each account through the setting that holds its id, never by
|
||||
its code, and journal entries hold the id as well, so nothing posted or configured is touched.
|
||||
An account is renumbered only while it still has the code it was created with, and only if the
|
||||
new code is free, so a code the bank has already changed by hand - or taken for something else -
|
||||
is left alone rather than overwritten or doubled.
|
||||
-->
|
||||
<changeSet id="0619_dds_agent_cash_six_digit_code" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set gl_code = '100701'
|
||||
where gl_code = '2.1'
|
||||
and id = (select value from c_configuration where name = 'dds-agent-cash-account')
|
||||
and not exists (select 1 from acc_gl_account where gl_code = '100701');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0619_dds_customer_deposits_six_digit_code" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set gl_code = '200601'
|
||||
where gl_code = '9.7'
|
||||
and id = (select value from c_configuration where name = 'dds-deposit-liability-account')
|
||||
and not exists (select 1 from acc_gl_account where gl_code = '200601');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0619_dds_commission_expense_six_digit_code" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set gl_code = '500601'
|
||||
where gl_code = '11.2'
|
||||
and id = (select value from c_configuration where name = 'dds-agent-commission-expense-account')
|
||||
and not exists (select 1 from acc_gl_account where gl_code = '500601');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0619_dds_interest_expense_six_digit_code" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set gl_code = '500602'
|
||||
where gl_code = '11.3'
|
||||
and id = (select value from c_configuration where name = 'dds-interest-expense-account')
|
||||
and not exists (select 1 from acc_gl_account where gl_code = '500602');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The same four accounts, named in capitals like every other account in the chart
|
||||
(DDS Agent Cash becomes DDS AGENT CASH). Found through the same settings as above, so only DDS's
|
||||
own accounts are touched, and whether or not the code above had to be left alone.
|
||||
-->
|
||||
<changeSet id="0619_dds_gl_account_names_in_capitals" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set name = upper(name)
|
||||
where id in (select value from c_configuration
|
||||
where name in ('dds-agent-cash-account', 'dds-deposit-liability-account',
|
||||
'dds-agent-commission-expense-account', 'dds-interest-expense-account')
|
||||
and value is not null)
|
||||
and name <> upper(name);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
A ledger account for an agent, ready to be named on the Agent Master Entry screen.
|
||||
|
||||
An agent's cash passes through an account of their own: the cashier credits it when the agent
|
||||
hands the day's collection over, and Apply DDS Receipt debits it again as the money is placed
|
||||
on the customers' accounts, so what is standing on it is the agent's, not yet allocated. A credit
|
||||
balance held for others is a liability, and that is where it is put - which is also where the
|
||||
screens that must never offer an agent's account for anything else already look for it.
|
||||
|
||||
One agent to an account, so this one is for the first agent and the next ones get 200603 on.
|
||||
Created as a detail account with no parent, like the other DDS accounts, so it can take entries.
|
||||
-->
|
||||
<changeSet id="0619_add_dds_agent_collection_gl_account" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from acc_gl_account where gl_code = '200602'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed,
|
||||
account_usage, classification_enum, description)
|
||||
values ('DDS AGENT COLLECTION - AG0001', null, '.', '200602', false, true, 1, 2,
|
||||
'Collections handed over by one DDS agent and not yet placed on the customers'' accounts. Credited when the cashier receipts the agent''s cash, debited when Apply DDS Receipt allocates it. One agent to an account.');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user