Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public enum AuditAction {
FILE_UPLOADED,
FILE_DOWNLOADED,
WORKER_DOCUMENT_FILE_LINKED,
DOCUMENT_ARCHIVED,
DOCUMENT_REQUEST_DRAFT_SAVED,
AI_RUN_CREATED,
AI_RUN_ANSWERS_SUBMITTED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ SELECT COUNT(*)
AND document.company_id = document_task.company_id
WHERE document_task.case_id = c.case_id
AND document_task.company_id = c.company_id
AND document.archived_at IS NULL
AND document.submission_status = 'VERIFIED'
) AS verified_documents
,(
Expand All @@ -119,6 +120,7 @@ SELECT COUNT(*)
AND document.company_id = document_task.company_id
WHERE document_task.case_id = c.case_id
AND document_task.company_id = c.company_id
AND document.archived_at IS NULL
) AS total_documents
,(
SELECT COUNT(*)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.fowoco.server.document.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record DocumentArchiveRequest(
@JsonProperty("expected_version")
@NotNull(message = "expected_version은 필수입니다.")
@Min(value = 0, message = "expected_version은 0 이상이어야 합니다.")
@Schema(description = "마지막으로 조회한 문서 version", example = "1")
Long expectedVersion,

@NotBlank(message = "보관 사유를 입력해 주세요.")
@Size(max = 300, message = "보관 사유는 300자 이하여야 합니다.")
@Schema(description = "문서를 일반 문서함에서 숨기는 이유", example = "잘못 등록한 중복 서류")
String reason
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.auth.application.port.ActorContextProvider;
import com.fowoco.server.common.web.RequestMetadata;
import com.fowoco.server.document.application.DocumentDetailResult;
import com.fowoco.server.document.application.DocumentPageResult;
import com.fowoco.server.document.application.DocumentService;
Expand All @@ -16,16 +17,21 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand Down Expand Up @@ -124,4 +130,36 @@ public DocumentDetailResponse findById(@Parameter(description = "서류 ID") @Pa
DocumentDetailResult result = documentService.findById(documentId, actor);
return DocumentDetailResponse.from(result);
}

@Operation(
operationId = "archiveDocument",
summary = "문서 보관",
description = "문서를 일반 조회와 업무 계산에서 제외합니다. 원본 파일·OCR·감사 이력은 삭제하지 않습니다. "
+ "이미 보관된 문서에 같은 요청을 다시 보내도 204를 반환합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "204", description = "보관 성공 또는 이미 보관됨"),
@ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"),
@ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"),
@ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
@ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict")
})
@PostMapping(path = "/{documentId}/archive", consumes = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasAnyRole('ADMIN', 'HR')")
public ResponseEntity<Void> archive(
@Parameter(description = "서류 ID") @PathVariable UUID documentId,
@Valid @RequestBody DocumentArchiveRequest request,
HttpServletRequest servletRequest
) {
ActorContext actor = actorContextProvider.requireCurrentActor();
documentService.archive(
documentId,
request.expectedVersion(),
request.reason(),
actor,
RequestMetadata.from(servletRequest)
);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
package com.fowoco.server.document.application;

import com.fowoco.server.audit.application.port.AuditEventRepository;
import com.fowoco.server.audit.domain.ActorType;
import com.fowoco.server.audit.domain.AuditAction;
import com.fowoco.server.audit.domain.AuditEvent;
import com.fowoco.server.audit.domain.AuditTargetType;
import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.auth.domain.UserRole;
import com.fowoco.server.common.error.ApiException;
import com.fowoco.server.common.id.UuidGenerator;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.common.time.DatabaseTimestamp;
import com.fowoco.server.common.web.RequestMetadata;
import com.fowoco.server.document.application.error.DocumentErrorCode;
import com.fowoco.server.file.application.port.StoredFileRepository;
import com.fowoco.server.file.domain.StoredFile;
Expand All @@ -11,6 +20,9 @@
import com.fowoco.server.worker.application.port.WorkerRepository;
import com.fowoco.server.worker.domain.Worker;
import com.fowoco.server.worker.domain.WorkerDocument;
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand All @@ -23,21 +35,32 @@
@Service
public class DocumentService {

private static final String AUDIT_EVENT_VERSION = "1";

private final WorkerDocumentRepository workerDocumentRepository;
private final WorkerRepository workerRepository;
private final StoredFileRepository storedFileRepository;
private final TenantDatabaseContext tenantDatabaseContext;
private final AuditEventRepository auditEventRepository;
private final UuidGenerator uuidGenerator;
private final Clock clock;

public DocumentService(
WorkerDocumentRepository workerDocumentRepository,
WorkerRepository workerRepository,
StoredFileRepository storedFileRepository,
TenantDatabaseContext tenantDatabaseContext
TenantDatabaseContext tenantDatabaseContext,
AuditEventRepository auditEventRepository,
UuidGenerator uuidGenerator,
Clock clock
) {
this.workerDocumentRepository = workerDocumentRepository;
this.workerRepository = workerRepository;
this.storedFileRepository = storedFileRepository;
this.tenantDatabaseContext = tenantDatabaseContext;
this.auditEventRepository = auditEventRepository;
this.uuidGenerator = uuidGenerator;
this.clock = clock;
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -74,4 +97,58 @@ public DocumentPageResult findPage(ActorContext actor, WorkerDocumentSearchQuery

return new DocumentPageResult(items, workerDisplayNames, query.page(), query.size(), totalElements);
}

@Transactional
public void archive(
UUID workerDocumentId,
long expectedVersion,
String reason,
ActorContext actor,
RequestMetadata metadata
) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId());
WorkerDocument existing = workerDocumentRepository
.findByIdAndCompanyIdIncludingArchived(workerDocumentId, actor.companyId())
.orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_NOT_FOUND));
if (existing.isArchived()) {
return;
}
if (existing.version() != expectedVersion) {
throw new ApiException(DocumentErrorCode.DOCUMENT_VERSION_CONFLICT);
}

Instant now = DatabaseTimestamp.nowNotBefore(clock, existing.updatedAt());
WorkerDocument archived = workerDocumentRepository.update(
existing.archive(actor.actorId(), reason, now)
);
auditEventRepository.append(new AuditEvent(
uuidGenerator.generate(),
actor.companyId(),
ActorType.HR_USER,
actor.actorId(),
effectiveRole(actor),
AuditAction.DOCUMENT_ARCHIVED,
AuditTargetType.WORKER_DOCUMENT,
archived.workerDocumentId(),
metadata.requestId(),
metadata.traceId(),
AUDIT_EVENT_VERSION,
"문서 보관 처리",
now
));
}

private UserRole effectiveRole(ActorContext actor) {
return actor.roles().stream()
.min(Comparator.comparingInt(this::rolePriority))
.orElseThrow();
}

private int rolePriority(UserRole role) {
return switch (role) {
case ADMIN -> 0;
case HR -> 1;
case VIEWER -> 2;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ public enum DocumentErrorCode implements ApiErrorCode {
HttpStatus.NOT_FOUND,
"문서를 찾을 수 없습니다."
),
DOCUMENT_VERSION_CONFLICT(
HttpStatus.CONFLICT,
"다른 사용자가 문서를 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."
),
DOCUMENT_OCR_DISABLED(
HttpStatus.SERVICE_UNAVAILABLE,
"OCR 기능이 아직 활성화되지 않았습니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Optional<WorkerDocument> findByIdAndWorkerIdAndCompanyId(

Optional<WorkerDocument> findByIdAndCompanyId(UUID workerDocumentId, UUID companyId);

Optional<WorkerDocument> findByIdAndCompanyIdIncludingArchived(UUID workerDocumentId, UUID companyId);

WorkerDocument update(WorkerDocument document);

List<WorkerDocument> findPage(UUID companyId, WorkerDocumentSearchQuery query);
Expand Down
98 changes: 98 additions & 0 deletions src/main/java/com/fowoco/server/worker/domain/WorkerDocument.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public final class WorkerDocument {
private final String destination;
private final String note;
private final UUID fileId;
private final Instant archivedAt;
private final UUID archivedBy;
private final String archiveReason;
private final Instant createdAt;
private final Instant updatedAt;
private final long version;
Expand All @@ -38,6 +41,44 @@ public WorkerDocument(
Instant createdAt,
Instant updatedAt,
long version
) {
this(
workerDocumentId,
workerId,
companyId,
taskId,
documentType,
submissionStatus,
expiryDate,
destination,
note,
fileId,
null,
null,
null,
createdAt,
updatedAt,
version
);
}

public WorkerDocument(
UUID workerDocumentId,
UUID workerId,
UUID companyId,
UUID taskId,
DocumentType documentType,
SubmissionStatus submissionStatus,
LocalDate expiryDate,
String destination,
String note,
UUID fileId,
Instant archivedAt,
UUID archivedBy,
String archiveReason,
Instant createdAt,
Instant updatedAt,
long version
) {
this.workerDocumentId = Objects.requireNonNull(workerDocumentId, "workerDocumentId must not be null");
this.workerId = Objects.requireNonNull(workerId, "workerId must not be null");
Expand All @@ -49,8 +90,15 @@ public WorkerDocument(
this.destination = requireMaxLength(destination, MAX_DESTINATION_LENGTH, "destination");
this.note = requireMaxLength(note, MAX_NOTE_LENGTH, "note");
this.fileId = fileId;
this.archivedAt = archivedAt;
this.archivedBy = archivedBy;
this.archiveReason = requireMaxLength(archiveReason, MAX_NOTE_LENGTH, "archiveReason");
validateArchiveMetadata();
this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null");
this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt must not be null");
if (archivedAt != null && archivedAt.isBefore(createdAt)) {
throw new IllegalArgumentException("archivedAt must not be before createdAt");
}
if (updatedAt.isBefore(createdAt)) {
throw new IllegalArgumentException("updatedAt must not be before createdAt");
}
Expand Down Expand Up @@ -159,6 +207,48 @@ public UUID fileId() {
return fileId;
}

public Instant archivedAt() {
return archivedAt;
}

public UUID archivedBy() {
return archivedBy;
}

public String archiveReason() {
return archiveReason;
}

public boolean isArchived() {
return archivedAt != null;
}

public WorkerDocument archive(UUID actorId, String reason, Instant now) {
Objects.requireNonNull(actorId, "actorId must not be null");
Objects.requireNonNull(now, "now must not be null");
if (isArchived()) {
return this;
}
return new WorkerDocument(
workerDocumentId,
workerId,
companyId,
taskId,
documentType,
submissionStatus,
expiryDate,
destination,
note,
fileId,
now,
actorId,
reason,
createdAt,
now,
version
);
}

public Instant createdAt() {
return createdAt;
}
Expand All @@ -171,6 +261,14 @@ public long version() {
return version;
}

private void validateArchiveMetadata() {
boolean allAbsent = archivedAt == null && archivedBy == null && archiveReason == null;
boolean allPresent = archivedAt != null && archivedBy != null && archiveReason != null;
if (!allAbsent && !allPresent) {
throw new IllegalArgumentException("archive metadata must be all present or all absent");
}
}

private static String requireMaxLength(String value, int maxLength, String fieldName) {
if (value == null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ public WorkerIdentityDocumentStatuses findCurrentStatuses(UUID companyId, UUID w
from WorkerDocumentJpaEntity document
where document.companyId = :companyId
and document.workerId = :workerId
and document.archivedAt is null
and document.documentType in :documentTypes
order by document.updatedAt desc,
document.createdAt desc,
Expand Down
Loading
Loading