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
+8
View File
@@ -93,6 +93,14 @@
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!-- Excel upload for manually-sourced signals. poi-ooxml reads .xlsx;
CSV is parsed without a library. -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version>
</dependency>
<!-- Testing --> <!-- Testing -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -139,10 +139,12 @@ public class FeatureEngineeringService {
BigDecimal weightedSum = BigDecimal.ZERO; BigDecimal weightedSum = BigDecimal.ZERO;
int totalWeight = 0; int totalWeight = 0;
boolean anyHistory = false; boolean anyHistory = false;
boolean anyRows = false;
for (SectorCommodity link : commodities) { for (SectorCommodity link : commodities) {
List<MarketSignal> series = marketSignalRepository.findTrailing( List<MarketSignal> series = marketSignalRepository.findTrailing(
districtId, link.getCommodityId(), asOfDate, observedAsOf); districtId, link.getCommodityId(), asOfDate, observedAsOf);
if (!series.isEmpty()) anyRows = true;
BigDecimal change = changeVsTrailingMean(series); BigDecimal change = changeVsTrailingMean(series);
if (change == null) continue; if (change == null) continue;
anyHistory = true; anyHistory = true;
@@ -152,7 +154,14 @@ public class FeatureEngineeringService {
} }
if (!anyHistory || totalWeight == 0) { if (!anyHistory || totalWeight == 0) {
return Computed.missing(FeatureCatalog.NO_SIGNAL); // "Nobody has ever sent us a price" and "the series has started but is
// still too short to mean anything" are different facts, and only the
// second one gets better on its own. A newly onboarded manual feed
// reporting NO_SIGNAL reads as a broken integration for the weeks it
// spends filling up.
return Computed.missing(anyRows
? FeatureCatalog.INSUFFICIENT_HISTORY
: FeatureCatalog.NO_SIGNAL);
} }
return Computed.of(weightedSum.divide(BigDecimal.valueOf(totalWeight), 4, RoundingMode.HALF_UP), staleness); return Computed.of(weightedSum.divide(BigDecimal.valueOf(totalWeight), 4, RoundingMode.HALF_UP), staleness);
} }
@@ -35,4 +35,11 @@ public interface MarketSignalRepository extends JpaRepository<MarketSignal, Long
/** Freshness: the newest observation we hold for a district, whatever the commodity. */ /** Freshness: the newest observation we hold for a district, whatever the commodity. */
Optional<MarketSignal> findFirstByDistrictIdOrderByValidForDateDesc(Long districtId); Optional<MarketSignal> findFirstByDistrictIdOrderByValidForDateDesc(Long districtId);
/**
* The last value before a given week, from any source — what a manually
* entered price is sanity-checked against.
*/
Optional<MarketSignal> findFirstByDistrictIdAndCommodityIdAndWeekStartLessThanOrderByWeekStartDesc(
Long districtId, Long commodityId, LocalDate weekStart);
} }
@@ -0,0 +1,72 @@
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;
/** 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";
}
}
@@ -0,0 +1,13 @@
package com.lincfusion.udyamlinc.manualentry;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ManualEntryBatchRepository extends JpaRepository<ManualEntryBatch, Long> {
List<ManualEntryBatch> findAllByOrderByUploadedAtDesc(Pageable pageable);
List<ManualEntryBatch> findByEntryTypeOrderByUploadedAtDesc(String entryType, Pageable pageable);
}
@@ -0,0 +1,132 @@
package com.lincfusion.udyamlinc.manualentry;
import com.lincfusion.udyamlinc.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* Manual and bulk entry of the signals no API serves.
*
* <p>Mounted under {@code /admin}, so the existing rule applies unchanged: any
* officer may read the batch history, only a supervisor may upload or commit.
* That is deliberate — the audit trail is worth more if the people who can read
* it outnumber the people who can write to it.
*/
@RestController
@RequestMapping("/admin/manual")
@RequiredArgsConstructor
public class ManualEntryController {
private final ManualMarketEntryService marketEntryService;
private final ManualEntryBatchRepository batchRepository;
private final ManualEntryLineRepository lineRepository;
// ── template ────────────────────────────────────────────
/**
* The column names the parser accepts, with one filled-in example row.
* Served as CSV because every spreadsheet opens it and nothing has to be
* generated.
*/
@GetMapping(value = "/template/market", produces = "text/csv")
public ResponseEntity<byte[]> marketTemplate() {
String csv = """
district_code,commodity_code,effective_date,modal_price,min_price,max_price,unit,note
BHOPAL,EGG,2026-08-05,610,600,620,HUNDRED,NECC Bhopal zone rate
SEHORE,MILK,2026-07-31,38.50,,,LITRE,Sanchi procurement rate for July
""";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"udyamlinc-market-template.csv\"")
.contentType(MediaType.parseMediaType("text/csv"))
.body(csv.getBytes(StandardCharsets.UTF_8));
}
// ── upload → preview → commit ───────────────────────────
/** Stages the file and reports what it would do. Writes no market signal. */
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<Map<String, Object>> upload(@RequestParam("file") MultipartFile file,
Authentication authentication) throws IOException {
if (file == null || file.isEmpty()) {
return ApiResponse.fail("No file uploaded");
}
ManualEntryPreview preview = marketEntryService.upload(file, username(authentication));
return ApiResponse.ok(
"%d parsed · %d accepted · %d rejected · %d would overwrite".formatted(
preview.lines().size(), preview.accepted().size(),
preview.rejected().size(), preview.overwriting().size()),
describe(preview));
}
@PostMapping("/{batchId}/commit")
public ApiResponse<Map<String, Object>> commit(@PathVariable Long batchId, Authentication authentication) {
ManualEntryPreview committed = marketEntryService.commit(batchId, username(authentication));
return ApiResponse.ok(
"%d market signals written".formatted(committed.accepted().size()),
describe(committed));
}
@DeleteMapping("/{batchId}")
public ApiResponse<ManualEntryBatch> discard(@PathVariable Long batchId, Authentication authentication) {
return ApiResponse.ok("Batch discarded", marketEntryService.discard(batchId, username(authentication)));
}
/** One row, validated and committed in a single call. For the monthly milk rate. */
@PostMapping("/entry")
public ApiResponse<Map<String, Object>> enterOne(@RequestBody Map<String, String> row,
Authentication authentication) {
ManualEntryPreview result = marketEntryService.enterOne(row, username(authentication));
return result.accepted().isEmpty()
? ApiResponse.ok("Rejected — see the line for the reason", describe(result))
: ApiResponse.ok("Entry recorded", describe(result));
}
// ── history ─────────────────────────────────────────────
@GetMapping("/batches")
public ApiResponse<List<ManualEntryBatch>> batches(@RequestParam(required = false) String entryType,
@RequestParam(defaultValue = "30") int limit) {
PageRequest page = PageRequest.of(0, Math.min(limit, 200));
return ApiResponse.ok(entryType == null
? batchRepository.findAllByOrderByUploadedAtDesc(page)
: batchRepository.findByEntryTypeOrderByUploadedAtDesc(entryType, page));
}
@GetMapping("/batches/{batchId}")
public ApiResponse<Map<String, Object>> batch(@PathVariable Long batchId) {
ManualEntryBatch batch = batchRepository.findById(batchId)
.orElseThrow(() -> new IllegalArgumentException("No such batch: " + batchId));
return ApiResponse.ok(describe(
new ManualEntryPreview(batch, lineRepository.findByBatchIdOrderByRowNumber(batchId))));
}
// ── shaping ─────────────────────────────────────────────
/**
* Accepted, rejected and overwriting stay separate all the way to the UI —
* see {@link ManualEntryPreview}.
*/
private Map<String, Object> describe(ManualEntryPreview preview) {
return Map.of(
"batch", preview.batch(),
"accepted", preview.accepted(),
"rejected", preview.rejected(),
"overwriting", preview.overwriting(),
"warnings", preview.warnings());
}
private String username(Authentication authentication) {
return authentication == null ? "unknown" : authentication.getName();
}
}
@@ -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;
}
@@ -0,0 +1,14 @@
package com.lincfusion.udyamlinc.manualentry;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ManualEntryLineRepository extends JpaRepository<ManualEntryLine, Long> {
List<ManualEntryLine> findByBatchIdOrderByRowNumber(Long batchId);
List<ManualEntryLine> findByBatchIdAndAcceptedOrderByRowNumber(Long batchId, boolean accepted);
void deleteByBatchId(Long batchId);
}
@@ -0,0 +1,30 @@
package com.lincfusion.udyamlinc.manualentry;
import java.util.List;
/**
* What an upload would do, before it does it.
*
* <p>The three groups are kept apart deliberately. A console that merges them
* into one list of "rows" hides the only two questions the person clicking
* Commit actually has: what will not land, and what will land on top of
* something that is already there.
*/
public record ManualEntryPreview(ManualEntryBatch batch, List<ManualEntryLine> lines) {
public List<ManualEntryLine> accepted() {
return lines.stream().filter(ManualEntryLine::isAccepted).toList();
}
public List<ManualEntryLine> rejected() {
return lines.stream().filter(line -> !line.isAccepted()).toList();
}
public List<ManualEntryLine> overwriting() {
return lines.stream().filter(line -> line.isAccepted() && line.isOverwriting()).toList();
}
public List<ManualEntryLine> warnings() {
return lines.stream().filter(line -> line.isAccepted() && line.getWarning() != null).toList();
}
}
@@ -0,0 +1,368 @@
package com.lincfusion.udyamlinc.manualentry;
import com.lincfusion.udyamlinc.ingestion.MarketSignal;
import com.lincfusion.udyamlinc.ingestion.MarketSignalRepository;
import com.lincfusion.udyamlinc.ingestion.Weeks;
import com.lincfusion.udyamlinc.master.Commodity;
import com.lincfusion.udyamlinc.master.CommodityRepository;
import com.lincfusion.udyamlinc.master.District;
import com.lincfusion.udyamlinc.master.DistrictRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* Manual and bulk entry of market signals, for the commodities no API serves:
* NECC egg rates, dairy-federation milk procurement, green fodder.
*
* <p><b>Two dates, and only one of them is the uploader's.</b>
* {@code effective_date} is the date the fact describes and is entered.
* {@code observedAt} is when we learned it, and is always the commit time — a
* bank uploading March's milk rate in August has told us something in August.
* Setting {@code observedAt} from the file would let a backdated upload appear
* inside a March scoring run, which is how a model learns from data that did not
* exist yet. The uploader cannot reach that field.
*
* <p><b>Nothing is written until it has been seen.</b> An upload is staged as a
* PREVIEW batch and returns what would land, what was rejected and why, and
* which rows would overwrite an existing value. Only {@code commit} touches
* {@code market_signal}.
*
* <p>Rows are written with {@code source = MANUAL}, which the unique index on
* {@code (district, commodity, week, source)} keeps separate from a feed's own
* row for the same week. Manual data never silently replaces a government feed.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ManualMarketEntryService {
public static final String SOURCE = "MANUAL";
public static final String ENTRY_TYPE = "MARKET_SIGNAL";
private static final List<DateTimeFormatter> DATE_FORMATS = List.of(
DateTimeFormatter.ISO_LOCAL_DATE,
DateTimeFormatter.ofPattern("dd/MM/yyyy"),
DateTimeFormatter.ofPattern("d/M/yyyy"),
DateTimeFormatter.ofPattern("dd-MM-yyyy"),
DateTimeFormatter.ofPattern("dd/MM/yy"));
private final TabularParser parser;
private final ManualEntryBatchRepository batchRepository;
private final ManualEntryLineRepository lineRepository;
private final MarketSignalRepository signalRepository;
private final DistrictRepository districtRepository;
private final CommodityRepository commodityRepository;
/**
* How far a price may sit from the last known value before the row is
* flagged. A warning, never a rejection: a genuine price shock is the event
* this whole system exists to catch, and rejecting it would be precisely
* backwards.
*/
@Value("${udyam.manual.outlier-ratio:3.0}")
private double outlierRatio;
// ── upload → preview ────────────────────────────────────
@Transactional
public ManualEntryPreview upload(MultipartFile file, String username) throws IOException {
TabularParser.Sheet sheet = parser.parse(file);
ManualEntryBatch batch = batchRepository.save(ManualEntryBatch.builder()
.entryType(ENTRY_TYPE)
.filename(file.getOriginalFilename())
.uploadedBy(username)
.status("PREVIEW")
.rowsParsed(sheet.rows().size())
.build());
Map<String, District> districts = new HashMap<>();
districtRepository.findByActiveTrue().forEach(d -> districts.put(key(d.getCode()), d));
Map<String, Commodity> commodities = new HashMap<>();
commodityRepository.findAll().forEach(c -> commodities.put(key(c.getCode()), c));
Set<String> seenInFile = new HashSet<>();
List<ManualEntryLine> lines = new ArrayList<>();
int rowNumber = 1;
for (Map<String, String> row : sheet.rows()) {
lines.add(validate(row, ++rowNumber, batch.getId(), districts, commodities, seenInFile));
}
lineRepository.saveAll(lines);
int accepted = (int) lines.stream().filter(ManualEntryLine::isAccepted).count();
int overwriting = (int) lines.stream().filter(l -> l.isAccepted() && l.isOverwriting()).count();
batch.setRowsAccepted(accepted);
batch.setRowsRejected(lines.size() - accepted);
batch.setRowsOverwriting(overwriting);
batchRepository.save(batch);
log.info("Manual upload {} staged — {} parsed, {} accepted, {} rejected, {} overwriting",
batch.getId(), lines.size(), accepted, lines.size() - accepted, overwriting);
return new ManualEntryPreview(batch, lines);
}
// ── validation ──────────────────────────────────────────
private ManualEntryLine validate(Map<String, String> row, int rowNumber, Long batchId,
Map<String, District> districts, Map<String, Commodity> commodities,
Set<String> seenInFile) {
String rawDistrict = TabularParser.get(row, "district_code", "district");
String rawCommodity = TabularParser.get(row, "commodity_code", "commodity");
String rawDate = TabularParser.get(row, "effective_date", "date", "as_of_date");
String rawModal = TabularParser.get(row, "modal_price", "price", "rate");
ManualEntryLine line = ManualEntryLine.builder()
.batchId(batchId)
.rowNumber(rowNumber)
.rawDistrict(rawDistrict)
.rawCommodity(rawCommodity)
.rawEffectiveDate(rawDate)
.rawModalPrice(rawModal)
.accepted(false)
.build();
if (rawDistrict == null || rawCommodity == null || rawDate == null || rawModal == null) {
return reject(line, "MISSING_REQUIRED",
"district_code, commodity_code, effective_date and modal_price are all required");
}
District district = districts.get(key(rawDistrict));
if (district == null) {
return reject(line, "UNKNOWN_DISTRICT", "no active district with code '" + rawDistrict + "'");
}
Commodity commodity = commodities.get(key(rawCommodity));
if (commodity == null) {
return reject(line, "UNKNOWN_COMMODITY", "no commodity with code '" + rawCommodity + "'");
}
LocalDate effective = parseDate(rawDate);
if (effective == null) {
return reject(line, "BAD_DATE", "could not read '" + rawDate + "' as a date");
}
// A price for a date that has not happened is a forecast. This table holds
// observations, and the difference is the system's whole premise.
if (effective.isAfter(LocalDate.now())) {
return reject(line, "FUTURE_DATE", effective + " is in the future");
}
BigDecimal modal = parseNumber(rawModal);
if (modal == null) {
return reject(line, "BAD_NUMBER", "could not read '" + rawModal + "' as a price");
}
BigDecimal min = parseNumber(TabularParser.get(row, "min_price", "minimum_price"));
BigDecimal max = parseNumber(TabularParser.get(row, "max_price", "maximum_price"));
if (min != null && max != null && min.compareTo(max) > 0) {
return reject(line, "PRICE_ORDER", "min_price " + min + " exceeds max_price " + max);
}
if (min != null && modal.compareTo(min) < 0 || max != null && modal.compareTo(max) > 0) {
return reject(line, "PRICE_ORDER", "modal_price " + modal + " sits outside min/max");
}
String unit = TabularParser.get(row, "unit");
if (unit != null && commodity.getUnit() != null
&& !unit.trim().equalsIgnoreCase(commodity.getUnit())) {
// ₹/litre landing in a ₹/quintal column is not a small error — it is a
// hundredfold one, and every derived feature would inherit it.
return reject(line, "UNIT_MISMATCH",
commodity.getCode() + " is recorded in " + commodity.getUnit() + ", not " + unit);
}
LocalDate weekStart = Weeks.startOf(effective);
String fileKey = district.getId() + ":" + commodity.getId() + ":" + weekStart;
if (!seenInFile.add(fileKey)) {
return reject(line, "DUPLICATE_IN_FILE",
"another row already covers " + commodity.getCode() + " / " + district.getCode()
+ " for the week of " + weekStart);
}
line.setDistrictId(district.getId());
line.setCommodityId(commodity.getId());
line.setEffectiveDate(effective);
line.setWeekStart(weekStart);
line.setModalPrice(modal);
line.setMinPrice(min);
line.setMaxPrice(max);
line.setUnit(commodity.getUnit());
line.setAccepted(true);
signalRepository
.findByDistrictIdAndCommodityIdAndWeekStartAndSource(
district.getId(), commodity.getId(), weekStart, SOURCE)
.ifPresent(existing -> {
line.setOverwriting(true);
line.setPriorModalPrice(existing.getModalPrice());
});
flagOutlier(line, district, commodity, modal, weekStart);
return line;
}
/**
* Compares against the most recent earlier value from any source, so a
* manual egg rate is sanity-checked against the last manual egg rate rather
* than against nothing at all.
*/
private void flagOutlier(ManualEntryLine line, District district, Commodity commodity,
BigDecimal modal, LocalDate weekStart) {
signalRepository
.findFirstByDistrictIdAndCommodityIdAndWeekStartLessThanOrderByWeekStartDesc(
district.getId(), commodity.getId(), weekStart)
.ifPresent(previous -> {
BigDecimal before = previous.getModalPrice();
if (before == null || before.signum() == 0) return;
double ratio = modal.doubleValue() / before.doubleValue();
if (ratio > outlierRatio || ratio < 1 / outlierRatio) {
line.setWarning("modal_price " + modal + " is " + Math.round(ratio * 100)
+ "% of the previous week's " + before + " — accepted, but check the unit");
}
});
}
private ManualEntryLine reject(ManualEntryLine line, String reason, String detail) {
line.setAccepted(false);
line.setRejectReason(reason);
line.setRejectDetail(detail);
return line;
}
// ── commit ──────────────────────────────────────────────
/**
* Writes the accepted rows. {@code observedAt} is stamped here, once, for the
* whole batch — the moment the institution accepted the data.
*/
@Transactional
public ManualEntryPreview commit(Long batchId, String username) {
ManualEntryBatch batch = batchRepository.findById(batchId)
.orElseThrow(() -> new IllegalArgumentException("No such batch: " + batchId));
if (!"PREVIEW".equals(batch.getStatus())) {
throw new IllegalStateException("Batch " + batchId + " is " + batch.getStatus()
+ ", and only a PREVIEW batch can be committed");
}
List<ManualEntryLine> accepted = lineRepository.findByBatchIdAndAcceptedOrderByRowNumber(batchId, true);
LocalDateTime observedAt = LocalDateTime.now();
for (ManualEntryLine line : accepted) {
MarketSignal signal = signalRepository
.findByDistrictIdAndCommodityIdAndWeekStartAndSource(
line.getDistrictId(), line.getCommodityId(), line.getWeekStart(), SOURCE)
.orElseGet(() -> MarketSignal.builder()
.districtId(line.getDistrictId())
.commodityId(line.getCommodityId())
.weekStart(line.getWeekStart())
.source(SOURCE)
.build());
signal.setModalPrice(line.getModalPrice());
signal.setMinPrice(line.getMinPrice());
signal.setMaxPrice(line.getMaxPrice());
signal.setUnit(line.getUnit());
// One hand-entered observation for the week, not an aggregate of daily
// arrivals — the count says so rather than implying a depth it lacks.
signal.setArrivalCount(1);
signal.setValidForDate(line.getEffectiveDate());
signal.setObservedAt(observedAt);
signalRepository.save(signal);
}
batch.setStatus("COMMITTED");
batch.setCommittedAt(observedAt);
batch.setCommittedBy(username);
batchRepository.save(batch);
log.info("Manual batch {} committed by {} — {} market signals written",
batchId, username, accepted.size());
return new ManualEntryPreview(batch, lineRepository.findByBatchIdOrderByRowNumber(batchId));
}
@Transactional
public ManualEntryBatch discard(Long batchId, String username) {
ManualEntryBatch batch = batchRepository.findById(batchId)
.orElseThrow(() -> new IllegalArgumentException("No such batch: " + batchId));
if ("COMMITTED".equals(batch.getStatus())) {
throw new IllegalStateException("Batch " + batchId + " is already committed and cannot be discarded");
}
batch.setStatus("DISCARDED");
batch.setCommittedBy(username);
batchRepository.save(batch);
return batch;
}
// ── single-row entry ────────────────────────────────────
/**
* The monthly milk rate does not deserve a spreadsheet. Same validation, same
* audit trail — a one-row batch, committed in the same call.
*/
@Transactional
public ManualEntryPreview enterOne(Map<String, String> row, String username) {
ManualEntryBatch batch = batchRepository.save(ManualEntryBatch.builder()
.entryType(ENTRY_TYPE)
.filename("(single entry)")
.uploadedBy(username)
.status("PREVIEW")
.rowsParsed(1)
.build());
Map<String, District> districts = new HashMap<>();
districtRepository.findByActiveTrue().forEach(d -> districts.put(key(d.getCode()), d));
Map<String, Commodity> commodities = new HashMap<>();
commodityRepository.findAll().forEach(c -> commodities.put(key(c.getCode()), c));
ManualEntryLine line = validate(row, 1, batch.getId(), districts, commodities, new HashSet<>());
lineRepository.save(line);
batch.setRowsAccepted(line.isAccepted() ? 1 : 0);
batch.setRowsRejected(line.isAccepted() ? 0 : 1);
batch.setRowsOverwriting(line.isOverwriting() ? 1 : 0);
batchRepository.save(batch);
if (!line.isAccepted()) {
return new ManualEntryPreview(batch, List.of(line));
}
return commit(batch.getId(), username);
}
// ── helpers ─────────────────────────────────────────────
private LocalDate parseDate(String raw) {
for (DateTimeFormatter format : DATE_FORMATS) {
try {
return LocalDate.parse(raw.trim(), format);
} catch (Exception ignored) {
// try the next shape
}
}
return null;
}
private BigDecimal parseNumber(String raw) {
if (raw == null || raw.isBlank()) return null;
try {
return new BigDecimal(raw.replace(",", "").replace("₹", "").trim());
} catch (NumberFormatException e) {
return null;
}
}
private String key(String code) {
return code == null ? "" : code.trim().toUpperCase(Locale.ROOT);
}
}
@@ -0,0 +1,187 @@
package com.lincfusion.udyamlinc.manualentry;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* Reads an uploaded .xlsx or .csv into rows keyed by column header.
*
* <p>Header driven rather than positional, because a bank exporting from its own
* system reorders columns and inserts its own. Lookup is case-, space- and
* underscore-insensitive for the same reason {@code DataGovClient} reads that
* way: {@code Effective Date}, {@code effective_date} and {@code EFFECTIVEDATE}
* are the same intent, and rejecting two of them teaches the uploader nothing.
*
* <p>Every value is returned as trimmed text. Nothing is coerced here — parsing
* a date or a price is a validation decision, and validation owns the reason a
* bad one is rejected.
*/
@Component
@Slf4j
public class TabularParser {
/** A parsed file: the header names as supplied, and one map per data row. */
public record Sheet(List<String> headers, List<Map<String, String>> rows) {
}
public Sheet parse(MultipartFile file) throws IOException {
String name = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase(Locale.ROOT);
return name.endsWith(".csv") ? parseCsv(file) : parseWorkbook(file);
}
// ── xlsx ────────────────────────────────────────────────
private Sheet parseWorkbook(MultipartFile file) throws IOException {
try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) {
org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
if (sheet == null) return new Sheet(List.of(), List.of());
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
if (headerRow == null) return new Sheet(List.of(), List.of());
List<String> headers = new ArrayList<>();
for (int c = 0; c < headerRow.getLastCellNum(); c++) {
headers.add(text(headerRow.getCell(c)));
}
DataFormatter formatter = new DataFormatter();
List<Map<String, String>> rows = new ArrayList<>();
for (int r = headerRow.getRowNum() + 1; r <= sheet.getLastRowNum(); r++) {
Row row = sheet.getRow(r);
if (row == null) continue;
Map<String, String> values = new LinkedHashMap<>();
boolean anyValue = false;
for (int c = 0; c < headers.size(); c++) {
String header = headers.get(c);
if (header == null || header.isBlank()) continue;
String value = cellText(row.getCell(c), formatter);
values.put(header, value);
if (value != null && !value.isBlank()) anyValue = true;
}
// A row of empty cells is spreadsheet residue, not a rejected row —
// reporting it would bury the rejections that matter.
if (anyValue) rows.add(values);
}
return new Sheet(headers, rows);
}
}
private String cellText(Cell cell, DataFormatter formatter) {
if (cell == null) return null;
// A date cell must not become "45231" — the effective date is the whole
// point of the upload, and a serial number would parse as a bad date
// rather than the date the uploader typed.
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
return cell.getLocalDateTimeCellValue().toLocalDate().toString();
}
if (cell.getCellType() == CellType.NUMERIC) {
double d = cell.getNumericCellValue();
return d == Math.floor(d) && !Double.isInfinite(d)
? String.valueOf((long) d)
: BigDecimal.valueOf(d).stripTrailingZeros().toPlainString();
}
String value = formatter.formatCellValue(cell);
return value == null ? null : value.trim();
}
private String text(Cell cell) {
if (cell == null) return null;
String value = new DataFormatter().formatCellValue(cell);
return value == null ? null : value.trim();
}
// ── csv ─────────────────────────────────────────────────
private Sheet parseCsv(MultipartFile file) throws IOException {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
String headerLine = reader.readLine();
if (headerLine == null) return new Sheet(List.of(), List.of());
// A UTF-8 BOM would otherwise ride along on the first header name and
// stop it matching anything.
if (!headerLine.isEmpty() && headerLine.charAt(0) == '') {
headerLine = headerLine.substring(1);
}
List<String> headers = splitCsv(headerLine);
List<Map<String, String>> rows = new ArrayList<>();
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) continue;
List<String> cells = splitCsv(line);
Map<String, String> values = new LinkedHashMap<>();
for (int i = 0; i < headers.size(); i++) {
String header = headers.get(i);
if (header == null || header.isBlank()) continue;
values.put(header, i < cells.size() ? cells.get(i) : null);
}
rows.add(values);
}
return new Sheet(headers, rows);
}
}
/** Minimal RFC-4180 split: quoted fields, doubled quotes, commas inside quotes. */
private List<String> splitCsv(String line) {
List<String> out = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < line.length(); i++) {
char ch = line.charAt(i);
if (inQuotes) {
if (ch == '"') {
if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
current.append('"');
i++;
} else {
inQuotes = false;
}
} else {
current.append(ch);
}
} else if (ch == '"') {
inQuotes = true;
} else if (ch == ',') {
out.add(current.toString().trim());
current.setLength(0);
} else {
current.append(ch);
}
}
out.add(current.toString().trim());
return out;
}
// ── header lookup ───────────────────────────────────────
/**
* Reads a column by any of its accepted names. Returns null for a blank cell
* so "absent" and "empty" reach validation as the same thing.
*/
public static String get(Map<String, String> row, String... names) {
for (String name : names) {
for (Map.Entry<String, String> entry : row.entrySet()) {
if (entry.getKey() != null && normalise(entry.getKey()).equals(normalise(name))) {
String value = entry.getValue();
return value == null || value.isBlank() ? null : value.trim();
}
}
}
return null;
}
private static String normalise(String s) {
return s.replace("_", "").replace(" ", "").replace("-", "").toLowerCase(Locale.ROOT);
}
}