Files
udyamlinc-api/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryBatch.java
T
LincPayandClaude Opus 5 b79b92fe8d feat(connector): core banking / LMS seam, one real adapter and four dialects
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>
2026-08-07 18:20:00 +05:30

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";
}
}