findFirstByDistrictIdAndCommodityIdAndWeekStartLessThanOrderByWeekStartDesc(
+ Long districtId, Long commodityId, LocalDate weekStart);
}
diff --git a/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryBatch.java b/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryBatch.java
new file mode 100644
index 0000000..921d4d7
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryBatch.java
@@ -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.
+ *
+ * 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";
+ }
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryBatchRepository.java b/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryBatchRepository.java
new file mode 100644
index 0000000..6706696
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryBatchRepository.java
@@ -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 {
+
+ List findAllByOrderByUploadedAtDesc(Pageable pageable);
+
+ List findByEntryTypeOrderByUploadedAtDesc(String entryType, Pageable pageable);
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryController.java b/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryController.java
new file mode 100644
index 0000000..12ad7a8
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/manualentry/ManualEntryController.java
@@ -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.
+ *
+ * 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 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