Files
udyamlinc-api/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryLine.java
T
LincPayandClaude Opus 5 a18c2f335f feat(manual-entry): loan-book upload, with DPD history the schema could not hold
The CBS/LMS extract is the deepest gap in the system: emi_delay and
emi_to_inflow are computed from the loan book, and DPD history is the
training label without which the model cannot be trained at all. This is
how a real book gets in before a lender builds a nightly feed.

Two schema problems had to be fixed first.

loan_record had a unique index on accountRef alone, so the table could
hold only each loan's current state — every monthly extract overwriting
the evidence the model is meant to learn from. The key is now
(accountRef, asOfDate): one row per loan per extract. The old constraint
must be dropped by hand, as ddl-auto adds indexes but never removes them.

emi_delay and emi_to_inflow took the worst DPD across every row at or
before the scoring date, which with snapshots present would report a loan
sixty days down in March as sixty days down today. Both now read the
newest extract per accountRef. The history is there to train on, not to
be summarised as the present.

The no-PII promise is enforced at the parse boundary. A file whose headers
carry a name, phone, Aadhaar, PAN, account number or address is rejected
outright with the offending columns named, rather than having the column
quietly dropped — a bank must learn the data is unwanted before it builds
a nightly job that keeps sending it.

ManualEntryLine now stores the row as supplied, so commit reads the file's
own columns rather than a re-parse of a file nobody kept, and an auditor
sees what the bank sent rather than our reading of it.

Verified: a six-month, two-loan book uploads as 12 snapshots; a file with
customer_name and mobile_no is refused whole; and emi_delay reports 2 for
a loan that peaked at 3 and is now 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:05:44 +05:30

111 lines
3.2 KiB
Java

package com.lincfusion.udyamlinc.manualentry;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* One row of an upload, kept whether or not it was accepted.
*
* <p>A rejected row is data too: "we uploaded four hundred rows and three
* hundred silently vanished" is the failure this table exists to prevent. Each
* rejection carries the reason that produced it.
*
* <p>{@code priorModalPrice} records what an accepted row displaced, because a
* correction is only auditable if the thing it corrected is still legible.
*/
@Entity
@Table(name = "manual_entry_line", indexes = {
@Index(name = "ix_manual_line_batch", columnList = "batchId"),
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ManualEntryLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long batchId;
/** 1-based row number in the uploaded file, so a reject points somewhere. */
@Column(nullable = false)
private Integer rowNumber;
// ── as supplied, before resolution ──────────────────────
@Column(length = 64)
private String rawDistrict;
@Column(length = 64)
private String rawCommodity;
@Column(length = 32)
private String rawEffectiveDate;
@Column(length = 32)
private String rawModalPrice;
/**
* The whole row as supplied, as JSON. Commit reads its columns from here
* rather than from a re-parse of a file nobody kept, and an auditor asking
* what the bank actually sent gets the row rather than our reading of it.
*/
@Column(columnDefinition = "text")
private String rawRow;
// ── as resolved ─────────────────────────────────────────
private Long districtId;
private Long commodityId;
/** The date the fact describes, as entered. Never the upload time. */
private LocalDate effectiveDate;
/** Monday of the ISO week {@link #effectiveDate} falls in. */
private LocalDate weekStart;
@Column(precision = 12, scale = 2)
private BigDecimal modalPrice;
@Column(precision = 12, scale = 2)
private BigDecimal minPrice;
@Column(precision = 12, scale = 2)
private BigDecimal maxPrice;
@Column(length = 16)
private String unit;
// ── verdict ─────────────────────────────────────────────
@Column(nullable = false)
private boolean accepted;
/**
* UNKNOWN_DISTRICT | UNKNOWN_COMMODITY | FUTURE_DATE | BAD_DATE | BAD_NUMBER
* | UNIT_MISMATCH | PRICE_ORDER | DUPLICATE_IN_FILE | MISSING_REQUIRED
*/
@Column(length = 32)
private String rejectReason;
@Column(length = 300)
private String rejectDetail;
/** Set when the row is accepted but sits outside the sanity band. Not a rejection. */
@Column(length = 300)
private String warning;
/** True when committing this row replaces an existing manual value. */
private boolean overwriting;
@Column(precision = 12, scale = 2)
private BigDecimal priorModalPrice;
}