feat(manual-entry): bulk and single entry for the feeds no API serves

Egg, milk and fodder have no usable API for the pilot districts — NECC
publishes HTML, dairy rates exist only as annual state averages, and every
recent Agmarknet fodder row is one market in Ambala. This is how a bank
gets that data in.

Upload stages a PREVIEW batch and writes nothing. It reports what would
land, what was rejected and why, and which rows would overwrite an
existing value. Only commit touches market_signal.

Effective dating: effective_date is the date the fact describes and comes
from the file; observedAt is when we learned it and is stamped at commit.
The uploader cannot reach observedAt — a March rate uploaded in August
must stay invisible to a March scoring run, or the model trains on data
that did not exist yet.

Rejections name themselves: UNKNOWN_DISTRICT, UNKNOWN_COMMODITY,
FUTURE_DATE, BAD_DATE, BAD_NUMBER, UNIT_MISMATCH, PRICE_ORDER,
DUPLICATE_IN_FILE, MISSING_REQUIRED. A price far from last week warns but
is accepted — a genuine price shock is the event this system exists to
catch, and rejecting it would be backwards.

Rows are written with source=MANUAL, which the unique index keeps separate
from a feed's own row for the same week, so manual data never silently
replaces a government feed.

Also distinguishes INSUFFICIENT_HISTORY from NO_SIGNAL in the price
derivations. A newly onboarded manual feed spends its first weeks below
the four-week minimum, and reporting NO_SIGNAL there reads as a broken
integration rather than a series still filling up.

Result: margin_squeeze and output_price_change compute for the first time
— 57 of 142 enterprises, exactly the DAIRY (30) and POULTRY (27) sectors
that have an output commodity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-06 14:12:45 +05:30
co-authored by Claude Opus 5
parent 20879b5904
commit 5401496a64
11 changed files with 943 additions and 1 deletions
@@ -0,0 +1,102 @@
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;
// ── 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;
}