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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user