A dedicated thread pool ({@code docProcessingPool}) is used for batch
+ * document processing so that parallel uploads do not starve the servlet
+ * container threads. Pool sizes are externally configurable via application
+ * properties.
+ *
+ *
+ * doc.processing.pool.size — core pool size (default 5)
+ * doc.processing.pool.max — max pool size (default 10)
+ * doc.processing.pool.queue — queue capacity (default 100)
+ *
+ */
+@Configuration
+@EnableAsync
+public class AsyncConfig {
+
+ @Bean("docProcessingPool")
+ public Executor docProcessingPool(
+ @Value("${doc.processing.pool.size:5}") int coreSize,
+ @Value("${doc.processing.pool.max:10}") int maxSize,
+ @Value("${doc.processing.pool.queue:100}") int queueCapacity) {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(coreSize);
+ executor.setMaxPoolSize(maxSize);
+ executor.setQueueCapacity(queueCapacity);
+ executor.setThreadNamePrefix("doc-proc-");
+ executor.setWaitForTasksToCompleteOnShutdown(true);
+ executor.setAwaitTerminationSeconds(60);
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/services/am-document-processor/src/main/java/org/am/mypotrfolio/controller/DocumentProcessorController.java b/services/am-document-processor/src/main/java/org/am/mypotrfolio/controller/DocumentProcessorController.java
index f531fe1..800c54f 100644
--- a/services/am-document-processor/src/main/java/org/am/mypotrfolio/controller/DocumentProcessorController.java
+++ b/services/am-document-processor/src/main/java/org/am/mypotrfolio/controller/DocumentProcessorController.java
@@ -12,10 +12,12 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.am.mypotrfolio.domain.common.DocumentType;
-import org.am.mypotrfolio.model.DocumentProcessResponse;
-import org.am.mypotrfolio.model.ProcessingStatus;
+import org.am.mypotrfolio.model.*;
+import org.am.mypotrfolio.service.BatchSyncEventPublisher;
import org.am.mypotrfolio.service.DocumentProcessorService;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.env.Environment;
+import org.springframework.core.env.Profiles;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -25,7 +27,9 @@
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -45,6 +49,12 @@ public class DocumentProcessorController {
@Autowired
private DocumentProcessorService documentProcessorService;
+ @Autowired
+ private BatchSyncEventPublisher batchSyncEventPublisher;
+
+ @Autowired
+ private Environment environment;
+
@Operation(summary = "Get supported document types", description = "Public endpoint — no authentication required")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Document types retrieved successfully", content = @Content(array = @ArraySchema(schema = @Schema(implementation = String.class)))),
@@ -97,45 +107,186 @@ public ResponseEntity> processDocument(
}
}
- @Operation(summary = "Process multiple documents", security = @SecurityRequirement(name = "Bearer"))
+ /**
+ * @deprecated Use {@code POST /v1/documents/sync} instead. This endpoint forces a single
+ * brokerType for all files and processes them sequentially.
+ */
+ @Deprecated
+ @Operation(summary = "[Deprecated] Process multiple documents with one shared broker type",
+ description = "Deprecated — use POST /v1/documents/sync for multi-broker parallel processing.",
+ security = @SecurityRequirement(name = "Bearer"))
@ApiResponses({
- @ApiResponse(responseCode = "200", description = "Documents processed successfully", content = @Content(array = @ArraySchema(schema = @Schema(implementation = DocumentProcessResponse.class)))),
+ @ApiResponse(responseCode = "200", description = "Documents processed successfully"),
@ApiResponse(responseCode = "401", description = "Unauthorized"),
@ApiResponse(responseCode = "400", description = "Invalid input parameters"),
@ApiResponse(responseCode = "500", description = "Internal server error")
})
@PostMapping(value = "/batch-process", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity> processBatchDocuments(
- @Parameter(description = "List of portfolio document files to process", required = true) @RequestParam("files") List files,
- @Parameter(description = "Type of documents being processed", required = true) @RequestParam("documentType") DocumentType documentType,
- @Parameter(description = "Portfolio ID (optional)", required = false) @RequestParam(value = "portfolioId", required = false) String portfolioId,
- @Parameter(description = "Explicit Broker Type (optional)", required = false) @RequestParam(value = "brokerType", required = false) String brokerTypeStr) {
+ @Parameter(description = "List of portfolio document files to process", required = true)
+ @RequestParam("files") List files,
+ @Parameter(description = "Type of documents being processed", required = true)
+ @RequestParam("documentType") DocumentType documentType,
+ @Parameter(description = "Portfolio ID (optional)", required = false)
+ @RequestParam(value = "portfolioId", required = false) String portfolioId,
+ @Parameter(description = "Explicit Broker Type (optional)", required = false)
+ @RequestParam(value = "brokerType", required = false) String brokerTypeStr) {
String userId = resolveUserId();
-
- log.info("Batch processing {} documents for user: {}, type: {}, portfolio: {}, broker: {}",
- files.size(), userId, documentType, portfolioId, brokerTypeStr);
+ log.warn("Deprecated /batch-process called by user: {} — recommend migrating to /sync", userId);
try {
List responses = documentProcessorService.processBatchDocuments(
- files,
- documentType,
- portfolioId,
- brokerTypeStr,
- userId
- );
+ files, documentType, portfolioId, brokerTypeStr, userId);
return ResponseEntity.ok(responses);
} catch (IllegalArgumentException e) {
- log.warn("Invalid batch parameters: {}", e.getMessage());
- return ResponseEntity.badRequest()
- .body(new ErrorResponse("Invalid parameters: " + e.getMessage()));
+ return ResponseEntity.badRequest().body(new ErrorResponse("Invalid parameters: " + e.getMessage()));
} catch (Exception e) {
- log.error("Error batch processing documents for user: {}", userId, e);
+ log.error("Error in deprecated batch processing for user: {}", userId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Failed to process documents"));
}
}
+ // =========================================================================
+ // Multi-broker sync endpoints (new)
+ // =========================================================================
+
+ @Operation(
+ summary = "Submit a multi-broker batch sync",
+ description = "Accepts N files from N different brokers. Broker type and document type are " +
+ "auto-detected per file. Processing is parallel and non-blocking — the response " +
+ "is returned immediately with a batchId. Poll GET /sync/{batchId}/status or " +
+ "stream progress via GET /sync/{batchId}/stream.",
+ security = @SecurityRequirement(name = "Bearer"))
+ @ApiResponses({
+ @ApiResponse(responseCode = "202", description = "Batch accepted and processing started",
+ content = @Content(schema = @Schema(implementation = BatchSyncStatus.class))),
+ @ApiResponse(responseCode = "401", description = "Unauthorized"),
+ @ApiResponse(responseCode = "400", description = "No valid files provided"),
+ @ApiResponse(responseCode = "500", description = "Internal server error")
+ })
+ @PostMapping(value = "/sync", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity> submitBatchSync(
+ @Parameter(description = "Files to process (one or more, different brokers allowed)", required = true)
+ @RequestParam("files") List files,
+ @Parameter(description = "Optional per-file broker hints (same order as files, may be sparse)")
+ @RequestParam(value = "brokerTypes", required = false) List brokerTypes,
+ @Parameter(description = "Optional per-file document type hints (same order as files)")
+ @RequestParam(value = "documentTypes", required = false) List documentTypes,
+ @Parameter(description = "Optional per-file passwords for encrypted files")
+ @RequestParam(value = "passwords", required = false) List passwords,
+ @Parameter(description = "Optional per-file portfolio names/IDs (same order as files)")
+ @RequestParam(value = "portfolioIds", required = false) List portfolioIds,
+ @Parameter(description = "Optional batch-level portfolio ID used when a file has no per-file value")
+ @RequestParam(value = "portfolioId", required = false) String portfolioId) {
+
+ String userId = resolveUserId();
+
+ if (files == null || files.isEmpty()) {
+ return ResponseEntity.badRequest().body(new ErrorResponse("At least one file is required"));
+ }
+ if (files.size() > 5) {
+ return ResponseEntity.badRequest().body(new ErrorResponse("Maximum 5 files allowed per batch"));
+ }
+ for (MultipartFile file : files) {
+ if (file == null || file.isEmpty()) {
+ return ResponseEntity.badRequest().body(new ErrorResponse("Empty files are not allowed"));
+ }
+ }
+
+ log.info("Multi-broker sync request: {} files, user: {}", files.size(), userId);
+
+ List entries = new ArrayList<>();
+ for (int i = 0; i < files.size(); i++) {
+ entries.add(BatchSyncEntry.builder()
+ .file(files.get(i))
+ .brokerType(blankToNull(getOrNull(brokerTypes, i)))
+ .documentType(blankToNull(getOrNull(documentTypes, i)))
+ .password(blankToNull(getOrNull(passwords, i)))
+ .portfolioId(blankToNull(getOrNull(portfolioIds, i)))
+ .build());
+ }
+
+ try {
+ BatchSyncStatus status = documentProcessorService.submitBatchSync(
+ entries, userId, blankToNull(portfolioId));
+ return ResponseEntity.accepted().body(status);
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
+ } catch (Exception e) {
+ log.error("Error submitting batch sync for user: {}", userId, e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(new ErrorResponse("Failed to submit batch sync"));
+ }
+ }
+
+ @Operation(
+ summary = "Get batch sync status",
+ description = "Returns the current status of a multi-broker batch sync, including per-file results.",
+ security = @SecurityRequirement(name = "Bearer"))
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "Status retrieved",
+ content = @Content(schema = @Schema(implementation = BatchSyncStatus.class))),
+ @ApiResponse(responseCode = "401", description = "Unauthorized"),
+ @ApiResponse(responseCode = "404", description = "Batch not found")
+ })
+ @GetMapping("/sync/{batchId}/status")
+ public ResponseEntity> getBatchSyncStatus(
+ @Parameter(description = "Batch ID returned by POST /sync", required = true)
+ @PathVariable String batchId) {
+
+ String userId = resolveUserId();
+ try {
+ BatchSyncStatus status = documentProcessorService.getBatchSyncStatus(batchId, userId);
+ return ResponseEntity.ok(status);
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(new ErrorResponse("Batch not found: " + batchId));
+ } catch (Exception e) {
+ log.error("Error retrieving batch status for batchId: {}", batchId, e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(new ErrorResponse("Failed to retrieve batch status"));
+ }
+ }
+
+ @Operation(
+ summary = "Stream batch sync progress via SSE",
+ description = "Opens a Server-Sent Events stream that pushes a \"file-update\" event each time " +
+ "a file in the batch completes or fails, and a terminal \"batch-complete\" event " +
+ "when all files are done. The stream closes automatically when the batch finishes.",
+ security = @SecurityRequirement(name = "Bearer"))
+ @GetMapping(value = "/sync/{batchId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public SseEmitter streamBatchSyncProgress(
+ @Parameter(description = "Batch ID returned by POST /sync", required = true)
+ @PathVariable String batchId) {
+
+ String userId = resolveUserId();
+ final UUID batchUuid;
+ try {
+ batchUuid = UUID.fromString(batchId);
+ } catch (IllegalArgumentException e) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid batchId");
+ }
+ try {
+ documentProcessorService.getBatchSyncStatus(batchId, userId);
+ } catch (IllegalArgumentException e) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Batch not found: " + batchId);
+ }
+ log.info("SSE stream requested for batchId: {} by user: {}", batchId, userId);
+ return batchSyncEventPublisher.subscribe(batchUuid);
+ }
+
+ private static T getOrNull(List list, int index) {
+ return (list != null && index < list.size()) ? list.get(index) : null;
+ }
+
+ private static String blankToNull(String value) {
+ if (value == null) return null;
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
@Operation(summary = "Get document processing status", security = @SecurityRequirement(name = "Bearer"))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Processing status retrieved successfully", content = @Content(schema = @Schema(implementation = ProcessingStatus.class))),
@@ -164,8 +315,12 @@ public ResponseEntity> getProcessingStatus(
* Prefer am-security-lib {@link UserContext} (set by UserContextFilter).
* Fall back to OIDC {@link JwtAuthenticationToken} subject when the filter
* has not populated the ThreadLocal yet.
+ *
+ *
Unauthenticated requests return 401. A {@code local-dev-user} bypass is
+ * allowed only when profile {@code local} or {@code local-dev} is active and
+ * neither {@code prod} nor {@code preprod} is active.
*/
- private static String resolveUserId() {
+ private String resolveUserId() {
String userId = UserContext.getUserId();
if (userId != null && !userId.isBlank()) {
return userId;
@@ -177,7 +332,18 @@ private static String resolveUserId() {
return sub;
}
}
- throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated or token missing");
+ if (isLocalDevAuthBypass()) {
+ log.warn("No authenticated user found. Using local-dev-user (local/local-dev profile only)");
+ return "local-dev-user";
+ }
+ throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized");
+ }
+
+ private boolean isLocalDevAuthBypass() {
+ if (environment.acceptsProfiles(Profiles.of("prod | preprod"))) {
+ return false;
+ }
+ return environment.acceptsProfiles(Profiles.of("local | local-dev"));
}
public static class ErrorResponse {
diff --git a/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchProcessingStatus.java b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchProcessingStatus.java
new file mode 100644
index 0000000..73712d3
--- /dev/null
+++ b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchProcessingStatus.java
@@ -0,0 +1,17 @@
+package org.am.mypotrfolio.model;
+
+/**
+ * Overall status of a multi-file batch sync operation.
+ */
+public enum BatchProcessingStatus {
+ /** Batch has been accepted but no file has started processing yet. */
+ QUEUED,
+ /** At least one file is being actively processed. */
+ PROCESSING,
+ /** All files finished successfully. */
+ COMPLETED,
+ /** All files failed. */
+ FAILED,
+ /** Some files succeeded, some failed. */
+ PARTIAL
+}
diff --git a/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchSyncEntry.java b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchSyncEntry.java
new file mode 100644
index 0000000..91a44cf
--- /dev/null
+++ b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchSyncEntry.java
@@ -0,0 +1,47 @@
+package org.am.mypotrfolio.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * Represents a single file in a multi-broker batch sync request.
+ * All fields except {@code file} are optional — broker, document type, and
+ * password will be auto-detected when not provided.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class BatchSyncEntry {
+
+ /** The uploaded file. Required. */
+ private MultipartFile file;
+
+ /**
+ * Optional explicit broker type hint (e.g. "ZERODHA", "DHAN").
+ * When provided it overrides auto-detection. Useful when the user knows
+ * the broker but the filename/content detection might be ambiguous.
+ */
+ private String brokerType;
+
+ /**
+ * Optional explicit document type (e.g. "STOCK_PORTFOLIO", "MUTUAL_FUND").
+ * When provided it overrides auto-detection.
+ */
+ private String documentType;
+
+ /**
+ * Optional password for encrypted files (e.g. Angel One password-protected Excel).
+ * Per-file so each file in a batch can have a different password.
+ */
+ private String password;
+
+ /**
+ * Optional portfolio ID override for this specific file.
+ * When null, falls back to the batch-level portfolioId.
+ */
+ private String portfolioId;
+}
diff --git a/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchSyncRecord.java b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchSyncRecord.java
new file mode 100644
index 0000000..c1ab8f5
--- /dev/null
+++ b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchSyncRecord.java
@@ -0,0 +1,85 @@
+package org.am.mypotrfolio.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.Indexed;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Durable MongoDB record for a multi-broker batch sync operation.
+ *
+ *
Replaces the volatile in-memory {@code ConcurrentHashMap}
+ * that was in {@code DocumentProcessorService}. This record survives pod restarts and
+ * is queryable for audit / support purposes.
+ *
+ *
Collection: {@code batch_sync_records}
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Document(collection = "batch_sync_records")
+public class BatchSyncRecord {
+
+ @Id
+ private String batchId;
+
+ @Indexed
+ private String userId;
+
+ /** Overall batch status — recomputed from file statuses on each update. */
+ private BatchProcessingStatus overallStatus;
+
+ @Builder.Default
+ private List files = new ArrayList<>();
+
+ private int totalFiles;
+
+ @Builder.Default
+ private LocalDateTime createdAt = LocalDateTime.now();
+
+ private LocalDateTime updatedAt;
+
+ // -------------------------------------------------------------------------
+ // Convenience counters (computed on demand — not persisted separately)
+ // -------------------------------------------------------------------------
+
+ public long countByStatus(ProcessingStatus status) {
+ return files.stream().filter(f -> f.getStatus() == status).count();
+ }
+
+ public int getCompleted() {
+ return (int) countByStatus(ProcessingStatus.COMPLETED);
+ }
+
+ public int getFailed() {
+ return (int) countByStatus(ProcessingStatus.FAILED);
+ }
+
+ /** Recomputes {@link #overallStatus} from child file statuses. */
+ public void recomputeOverallStatus() {
+ long total = files.size();
+ long done = countByStatus(ProcessingStatus.COMPLETED);
+ long failed = countByStatus(ProcessingStatus.FAILED);
+ long active = countByStatus(ProcessingStatus.PROCESSING) + countByStatus(ProcessingStatus.QUEUED);
+
+ if (active > 0) {
+ overallStatus = BatchProcessingStatus.PROCESSING;
+ } else if (failed == total) {
+ overallStatus = BatchProcessingStatus.FAILED;
+ } else if (done == total) {
+ overallStatus = BatchProcessingStatus.COMPLETED;
+ } else {
+ overallStatus = BatchProcessingStatus.PARTIAL;
+ }
+ updatedAt = LocalDateTime.now();
+ }
+}
diff --git a/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchSyncStatus.java b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchSyncStatus.java
new file mode 100644
index 0000000..0554ba7
--- /dev/null
+++ b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/BatchSyncStatus.java
@@ -0,0 +1,32 @@
+package org.am.mypotrfolio.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Overall status response for a batch sync operation.
+ * Returned by {@code GET /v1/documents/sync/{batchId}/status}.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class BatchSyncStatus {
+
+ private UUID batchId;
+ private int total;
+ private int completed;
+ private int failed;
+ private BatchProcessingStatus overallStatus;
+ private List files;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/FileSyncRecord.java b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/FileSyncRecord.java
new file mode 100644
index 0000000..4f29f58
--- /dev/null
+++ b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/FileSyncRecord.java
@@ -0,0 +1,37 @@
+package org.am.mypotrfolio.model;
+
+import com.am.common.amcommondata.model.enums.BrokerType;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * Per-file tracking record stored inside {@link BatchSyncRecord}.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class FileSyncRecord {
+
+ private UUID fileId;
+ private String fileName;
+
+ /** Auto-detected or user-supplied broker. */
+ private BrokerType detectedBroker;
+ /** Auto-detected or user-supplied document type. */
+ private String detectedDocumentType;
+
+ private ProcessingStatus status;
+ private String errorMessage;
+ private int recordsProcessed;
+
+ private LocalDateTime startedAt;
+ private LocalDateTime completedAt;
+}
diff --git a/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/FileSyncStatus.java b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/FileSyncStatus.java
new file mode 100644
index 0000000..510dae4
--- /dev/null
+++ b/services/am-document-processor/src/main/java/org/am/mypotrfolio/model/FileSyncStatus.java
@@ -0,0 +1,37 @@
+package org.am.mypotrfolio.model;
+
+import com.am.common.amcommondata.model.enums.BrokerType;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * Per-file status DTO returned in {@link BatchSyncStatus#getFiles()}.
+ * This is the API-facing projection of {@link FileSyncRecord}.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class FileSyncStatus {
+
+ private UUID fileId;
+ private String fileName;
+ private BrokerType detectedBroker;
+ private String detectedDocumentType;
+ private ProcessingStatus status;
+ private String errorMessage;
+ private int recordsProcessed;
+ private LocalDateTime startedAt;
+ private LocalDateTime completedAt;
+
+ public boolean isTerminal() {
+ return status == ProcessingStatus.COMPLETED || status == ProcessingStatus.FAILED;
+ }
+}
diff --git a/services/am-document-processor/src/main/java/org/am/mypotrfolio/processor/ExcelFileProcessor.java b/services/am-document-processor/src/main/java/org/am/mypotrfolio/processor/ExcelFileProcessor.java
index 79cf1e7..4acc2e4 100644
--- a/services/am-document-processor/src/main/java/org/am/mypotrfolio/processor/ExcelFileProcessor.java
+++ b/services/am-document-processor/src/main/java/org/am/mypotrfolio/processor/ExcelFileProcessor.java
@@ -60,7 +60,25 @@ protected List