The loan book is the training label and the one input no public feed
supplies. Integrating with a lender's core is not a technical unknown —
it is an onboarding process per bank, with a schema agreed in a room.
What can be built before that room is the seam, and this is it.
CoreBankingConnector fetches and returns rows; it never writes. Mapped
rows go to LoanBookEntryService.stage — the same validator an uploaded
spreadsheet goes through, landing in the same manual_entry_batch with
the same preview. A parallel validator for the automated path would be
the obvious shape and would eventually enforce the no-PII rule
differently.
FileDropConnector is real: it reads the newest matching file from a
directory a bank delivers to, and leaves it there so a failed run is
repeatable. Finacle, FLEXCUBE, BaNCS and an in-house REST LMS are
simulated, each emitting the same portfolio in its own column dialect —
DPD_DAYS, OVERDUE_DAYS, DAYS_OVERDUE, daysPastDue — which is what makes
the case for a mapping layer rather than asserting it.
The mapping is {canonical: vendor}, keyed by canonical name, so an
unmapped vendor column has no route through and is dropped at the
boundary. CUST_NAME and MOBILE_NO stay out structurally rather than by
anyone remembering. Auto-commit is off on every profile: a sync stages
and stops, keeping the rule that nothing is written until someone has
seen what will be. simulated is stamped per run, not read from the
profile, so switching a profile live cannot make old runs look real.
Fixes a real defect this surfaced: FORBIDDEN_COLUMNS matched on the
exact normalised name, so CUST_NAME — what Finacle actually calls it —
was missed, and a directly uploaded bank file would have kept the
borrower's name in manual_entry_line.raw_row. Matching now splits
separators and camelCase, catching borrowerMobile too, while
loan_account_ref still splits clean.
Verified against a running instance: Finacle 44/44 accepted with 4
columns dropped, the file drop 2 accepted and 1 UNKNOWN_ENTERPRISE
rejected from a real CSV, and both CUST_NAME and borrowerMobile refused
at mapping save.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
2.5 KiB
Java
85 lines
2.5 KiB
Java
package com.lincfusion.udyamlinc.manualentry;
|
|
|
|
import jakarta.persistence.*;
|
|
import lombok.*;
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
/**
|
|
* One upload. The audit anchor for every manually-sourced number in the system:
|
|
* a supervisor asking "where did Bhopal's March milk rate come from" gets a
|
|
* filename, a person and a timestamp, not a shrug.
|
|
*
|
|
* <p>A batch is staged as {@code PREVIEW} and writes nothing. Only
|
|
* {@code COMMITTED} has touched {@code market_signal}. Uploading four hundred
|
|
* rows and discovering the district column was misspelled afterwards is not a
|
|
* recoverable situation, so nothing lands until someone has seen what will.
|
|
*/
|
|
@Entity
|
|
@Table(name = "manual_entry_batch", indexes = {
|
|
@Index(name = "ix_manual_batch_status", columnList = "status"),
|
|
@Index(name = "ix_manual_batch_uploaded", columnList = "uploadedAt"),
|
|
})
|
|
@Getter
|
|
@Setter
|
|
@NoArgsConstructor
|
|
@AllArgsConstructor
|
|
@Builder
|
|
public class ManualEntryBatch {
|
|
|
|
@Id
|
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
private Long id;
|
|
|
|
/** MARKET_SIGNAL | LOAN_RECORD — which template was uploaded. */
|
|
@Column(nullable = false, length = 32)
|
|
private String entryType;
|
|
|
|
@Column(length = 260)
|
|
private String filename;
|
|
|
|
/**
|
|
* {@code UPLOAD}, or the connector code the rows arrived through.
|
|
*
|
|
* <p>Kept on the batch rather than inferred from the filename, because once a
|
|
* CBS connector runs nightly, "who put this number here" stops being answerable
|
|
* by "someone uploaded a file" — and that question is the reason this table
|
|
* exists.
|
|
*/
|
|
@Column(length = 40)
|
|
private String source;
|
|
|
|
/** The username from the JWT. Never a display name — this is an audit field. */
|
|
@Column(nullable = false, length = 64)
|
|
private String uploadedBy;
|
|
|
|
@Column(nullable = false)
|
|
private LocalDateTime uploadedAt;
|
|
|
|
/** PREVIEW | COMMITTED | DISCARDED. */
|
|
@Column(nullable = false, length = 16)
|
|
private String status;
|
|
|
|
private LocalDateTime committedAt;
|
|
|
|
@Column(length = 64)
|
|
private String committedBy;
|
|
|
|
private Integer rowsParsed;
|
|
private Integer rowsAccepted;
|
|
private Integer rowsRejected;
|
|
|
|
/** How many accepted rows would replace a value that already exists. */
|
|
private Integer rowsOverwriting;
|
|
|
|
@Column(length = 500)
|
|
private String note;
|
|
|
|
@PrePersist
|
|
void onCreate() {
|
|
if (uploadedAt == null) uploadedAt = LocalDateTime.now();
|
|
if (status == null) status = "PREVIEW";
|
|
if (source == null) source = "UPLOAD";
|
|
}
|
|
}
|