From e702ebd03400cfda42151c6c01bb96405ae93e9d Mon Sep 17 00:00:00 2001 From: default Date: Sat, 24 Oct 2026 15:34:03 +0000 Subject: [PATCH] Initial commit: autoVoucher console backend Spring Boot service covering OA expense document ingestion, voucher suggestion with rule engine and correction learning, reviewer workflow, and idempotent NCC push outbox. Co-Authored-By: Claude Code --- .gitignore | 6 + pom.xml | 103 +++ .../autovoucher/AutoVoucherApplication.java | 16 + .../com/autovoucher/common/ApiException.java | 47 ++ .../common/GlobalExceptionHandler.java | 53 ++ .../com/autovoucher/config/AppProperties.java | 65 ++ .../autovoucher/config/SecurityConfig.java | 88 +++ .../com/autovoucher/config/TenantContext.java | 22 + .../com/autovoucher/config/TenantFilter.java | 37 ++ .../java/com/autovoucher/domain/Account.java | 38 ++ .../com/autovoucher/domain/AccountGroup.java | 24 + .../java/com/autovoucher/domain/AuditLog.java | 24 + .../java/com/autovoucher/domain/AuxItem.java | 22 + .../java/com/autovoucher/domain/AuxValue.java | 30 + .../com/autovoucher/domain/BaseEntity.java | 50 ++ .../java/com/autovoucher/domain/Book.java | 29 + .../com/autovoucher/domain/CaseMemory.java | 30 + .../java/com/autovoucher/domain/Company.java | 21 + .../com/autovoucher/domain/Correction.java | 37 ++ .../autovoucher/domain/ExpenseCategory.java | 19 + .../com/autovoucher/domain/MappingRule.java | 29 + .../java/com/autovoucher/domain/Nonce.java | 22 + .../com/autovoucher/domain/OaViewLog.java | 28 + .../com/autovoucher/domain/OaViewTicket.java | 40 ++ .../com/autovoucher/domain/PushAttempt.java | 34 + .../java/com/autovoucher/domain/PushTask.java | 44 ++ .../com/autovoucher/domain/RuleCandidate.java | 34 + .../com/autovoucher/domain/SourceDoc.java | 55 ++ .../com/autovoucher/domain/SourceEvent.java | 34 + .../com/autovoucher/domain/SourceLine.java | 42 ++ .../com/autovoucher/domain/SourceSystem.java | 23 + .../com/autovoucher/domain/TargetSystem.java | 22 + .../com/autovoucher/domain/Transaction.java | 29 + .../autovoucher/domain/TransactionLine.java | 40 ++ .../java/com/autovoucher/domain/Voucher.java | 59 ++ .../com/autovoucher/domain/VoucherLine.java | 48 ++ .../com/autovoucher/domain/VoucherType.java | 26 + .../autovoucher/dto/DevBehaviorRequest.java | 4 + .../autovoucher/dto/DevOaEventRequest.java | 9 + .../autovoucher/dto/DevOaTicketRequest.java | 8 + .../com/autovoucher/dto/LoginRequest.java | 4 + .../autovoucher/dto/ManualVoucherRequest.java | 7 + .../com/autovoucher/dto/OaSignResponse.java | 11 + .../com/autovoucher/dto/OaStatusResponse.java | 10 + .../com/autovoucher/dto/OaTicketRequest.java | 16 + .../com/autovoucher/dto/OaTicketResponse.java | 13 + .../autovoucher/dto/RejectVoucherRequest.java | 4 + .../dto/RuleCandidateDecisionRequest.java | 8 + .../java/com/autovoucher/dto/RuleRequest.java | 13 + .../com/autovoucher/dto/VoucherLineInput.java | 15 + .../autovoucher/dto/VoucherUpdateRequest.java | 9 + .../java/com/autovoucher/job/NccPushJob.java | 75 +++ .../push/MockNccVoucherClient.java | 115 ++++ .../com/autovoucher/push/NccMockBehavior.java | 24 + .../java/com/autovoucher/push/PushLine.java | 18 + .../com/autovoucher/push/PushRequest.java | 24 + .../java/com/autovoucher/push/PushResult.java | 25 + .../autovoucher/push/TargetSystemAdapter.java | 15 + .../push/TargetSystemAdapterRegistry.java | 33 + .../repository/AccountGroupRepository.java | 13 + .../repository/AccountRepository.java | 15 + .../repository/AuditLogRepository.java | 12 + .../repository/AuxItemRepository.java | 13 + .../repository/AuxValueRepository.java | 15 + .../repository/BookRepository.java | 13 + .../repository/CaseMemoryRepository.java | 12 + .../repository/CompanyRepository.java | 13 + .../repository/CorrectionRepository.java | 12 + .../repository/ExpenseCategoryRepository.java | 13 + .../repository/MappingRuleRepository.java | 15 + .../repository/NonceRepository.java | 17 + .../repository/OaViewLogRepository.java | 12 + .../repository/OaViewTicketRepository.java | 13 + .../repository/PushAttemptRepository.java | 10 + .../repository/PushTaskRepository.java | 35 ++ .../repository/RuleCandidateRepository.java | 15 + .../repository/SourceDocRepository.java | 19 + .../repository/SourceEventRepository.java | 15 + .../repository/SourceLineRepository.java | 12 + .../repository/SourceSystemRepository.java | 13 + .../repository/TargetSystemRepository.java | 13 + .../repository/TransactionLineRepository.java | 12 + .../repository/TransactionRepository.java | 12 + .../repository/VoucherLineRepository.java | 12 + .../repository/VoucherRepository.java | 21 + .../repository/VoucherTypeRepository.java | 13 + .../java/com/autovoucher/rule/RuleEngine.java | 246 ++++++++ .../java/com/autovoucher/rule/RuleMatch.java | 18 + .../com/autovoucher/seed/SeedCatalog.java | 123 ++++ .../java/com/autovoucher/seed/SeedLoader.java | 379 +++++++++++ .../autovoucher/service/AccountResolver.java | 81 +++ .../com/autovoucher/service/AuditService.java | 37 ++ .../service/DocumentProcessingService.java | 253 ++++++++ .../com/autovoucher/service/IngestResult.java | 13 + .../service/OaViewAuditService.java | 62 ++ .../com/autovoucher/service/OaViewPage.java | 18 + .../autovoucher/service/OaViewService.java | 440 +++++++++++++ .../service/ProcessingOutcome.java | 14 + .../com/autovoucher/service/PushService.java | 312 +++++++++ .../autovoucher/service/ResolvedAccount.java | 6 + .../autovoucher/service/RuleAdminService.java | 287 +++++++++ .../service/SourceIngestService.java | 190 ++++++ .../autovoucher/service/SuggestedLine.java | 20 + .../service/SuggestionOutcome.java | 18 + .../service/VoucherNumberService.java | 37 ++ .../autovoucher/service/VoucherService.java | 593 ++++++++++++++++++ .../service/VoucherSuggestionService.java | 332 ++++++++++ .../source/MockSeeyonSourceAdapter.java | 48 ++ .../com/autovoucher/source/PayloadMapper.java | 132 ++++ .../com/autovoucher/source/SourceAdapter.java | 13 + .../source/SourceAdapterRegistry.java | 28 + .../autovoucher/source/SourceDocPayload.java | 60 ++ .../autovoucher/web/AuditLogController.java | 49 ++ .../com/autovoucher/web/AuthController.java | 84 +++ .../autovoucher/web/DashboardController.java | 92 +++ .../com/autovoucher/web/DevController.java | 210 +++++++ .../autovoucher/web/LearningController.java | 50 ++ .../autovoucher/web/MasterDataController.java | 147 +++++ .../com/autovoucher/web/OaOpenController.java | 100 +++ .../autovoucher/web/PushTaskController.java | 129 ++++ .../com/autovoucher/web/RuleController.java | 76 +++ .../autovoucher/web/SourceDocController.java | 234 +++++++ .../autovoucher/web/VoucherController.java | 197 ++++++ src/main/resources/application-h2.yml | 23 + src/main/resources/application.yml | 73 +++ .../resources/db/migration/V1__schema.sql | 462 ++++++++++++++ src/main/resources/templates/oa/error.html | 36 ++ src/main/resources/templates/oa/voucher.html | 193 ++++++ 128 files changed, 8135 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/com/autovoucher/AutoVoucherApplication.java create mode 100644 src/main/java/com/autovoucher/common/ApiException.java create mode 100644 src/main/java/com/autovoucher/common/GlobalExceptionHandler.java create mode 100644 src/main/java/com/autovoucher/config/AppProperties.java create mode 100644 src/main/java/com/autovoucher/config/SecurityConfig.java create mode 100644 src/main/java/com/autovoucher/config/TenantContext.java create mode 100644 src/main/java/com/autovoucher/config/TenantFilter.java create mode 100644 src/main/java/com/autovoucher/domain/Account.java create mode 100644 src/main/java/com/autovoucher/domain/AccountGroup.java create mode 100644 src/main/java/com/autovoucher/domain/AuditLog.java create mode 100644 src/main/java/com/autovoucher/domain/AuxItem.java create mode 100644 src/main/java/com/autovoucher/domain/AuxValue.java create mode 100644 src/main/java/com/autovoucher/domain/BaseEntity.java create mode 100644 src/main/java/com/autovoucher/domain/Book.java create mode 100644 src/main/java/com/autovoucher/domain/CaseMemory.java create mode 100644 src/main/java/com/autovoucher/domain/Company.java create mode 100644 src/main/java/com/autovoucher/domain/Correction.java create mode 100644 src/main/java/com/autovoucher/domain/ExpenseCategory.java create mode 100644 src/main/java/com/autovoucher/domain/MappingRule.java create mode 100644 src/main/java/com/autovoucher/domain/Nonce.java create mode 100644 src/main/java/com/autovoucher/domain/OaViewLog.java create mode 100644 src/main/java/com/autovoucher/domain/OaViewTicket.java create mode 100644 src/main/java/com/autovoucher/domain/PushAttempt.java create mode 100644 src/main/java/com/autovoucher/domain/PushTask.java create mode 100644 src/main/java/com/autovoucher/domain/RuleCandidate.java create mode 100644 src/main/java/com/autovoucher/domain/SourceDoc.java create mode 100644 src/main/java/com/autovoucher/domain/SourceEvent.java create mode 100644 src/main/java/com/autovoucher/domain/SourceLine.java create mode 100644 src/main/java/com/autovoucher/domain/SourceSystem.java create mode 100644 src/main/java/com/autovoucher/domain/TargetSystem.java create mode 100644 src/main/java/com/autovoucher/domain/Transaction.java create mode 100644 src/main/java/com/autovoucher/domain/TransactionLine.java create mode 100644 src/main/java/com/autovoucher/domain/Voucher.java create mode 100644 src/main/java/com/autovoucher/domain/VoucherLine.java create mode 100644 src/main/java/com/autovoucher/domain/VoucherType.java create mode 100644 src/main/java/com/autovoucher/dto/DevBehaviorRequest.java create mode 100644 src/main/java/com/autovoucher/dto/DevOaEventRequest.java create mode 100644 src/main/java/com/autovoucher/dto/DevOaTicketRequest.java create mode 100644 src/main/java/com/autovoucher/dto/LoginRequest.java create mode 100644 src/main/java/com/autovoucher/dto/ManualVoucherRequest.java create mode 100644 src/main/java/com/autovoucher/dto/OaSignResponse.java create mode 100644 src/main/java/com/autovoucher/dto/OaStatusResponse.java create mode 100644 src/main/java/com/autovoucher/dto/OaTicketRequest.java create mode 100644 src/main/java/com/autovoucher/dto/OaTicketResponse.java create mode 100644 src/main/java/com/autovoucher/dto/RejectVoucherRequest.java create mode 100644 src/main/java/com/autovoucher/dto/RuleCandidateDecisionRequest.java create mode 100644 src/main/java/com/autovoucher/dto/RuleRequest.java create mode 100644 src/main/java/com/autovoucher/dto/VoucherLineInput.java create mode 100644 src/main/java/com/autovoucher/dto/VoucherUpdateRequest.java create mode 100644 src/main/java/com/autovoucher/job/NccPushJob.java create mode 100644 src/main/java/com/autovoucher/push/MockNccVoucherClient.java create mode 100644 src/main/java/com/autovoucher/push/NccMockBehavior.java create mode 100644 src/main/java/com/autovoucher/push/PushLine.java create mode 100644 src/main/java/com/autovoucher/push/PushRequest.java create mode 100644 src/main/java/com/autovoucher/push/PushResult.java create mode 100644 src/main/java/com/autovoucher/push/TargetSystemAdapter.java create mode 100644 src/main/java/com/autovoucher/push/TargetSystemAdapterRegistry.java create mode 100644 src/main/java/com/autovoucher/repository/AccountGroupRepository.java create mode 100644 src/main/java/com/autovoucher/repository/AccountRepository.java create mode 100644 src/main/java/com/autovoucher/repository/AuditLogRepository.java create mode 100644 src/main/java/com/autovoucher/repository/AuxItemRepository.java create mode 100644 src/main/java/com/autovoucher/repository/AuxValueRepository.java create mode 100644 src/main/java/com/autovoucher/repository/BookRepository.java create mode 100644 src/main/java/com/autovoucher/repository/CaseMemoryRepository.java create mode 100644 src/main/java/com/autovoucher/repository/CompanyRepository.java create mode 100644 src/main/java/com/autovoucher/repository/CorrectionRepository.java create mode 100644 src/main/java/com/autovoucher/repository/ExpenseCategoryRepository.java create mode 100644 src/main/java/com/autovoucher/repository/MappingRuleRepository.java create mode 100644 src/main/java/com/autovoucher/repository/NonceRepository.java create mode 100644 src/main/java/com/autovoucher/repository/OaViewLogRepository.java create mode 100644 src/main/java/com/autovoucher/repository/OaViewTicketRepository.java create mode 100644 src/main/java/com/autovoucher/repository/PushAttemptRepository.java create mode 100644 src/main/java/com/autovoucher/repository/PushTaskRepository.java create mode 100644 src/main/java/com/autovoucher/repository/RuleCandidateRepository.java create mode 100644 src/main/java/com/autovoucher/repository/SourceDocRepository.java create mode 100644 src/main/java/com/autovoucher/repository/SourceEventRepository.java create mode 100644 src/main/java/com/autovoucher/repository/SourceLineRepository.java create mode 100644 src/main/java/com/autovoucher/repository/SourceSystemRepository.java create mode 100644 src/main/java/com/autovoucher/repository/TargetSystemRepository.java create mode 100644 src/main/java/com/autovoucher/repository/TransactionLineRepository.java create mode 100644 src/main/java/com/autovoucher/repository/TransactionRepository.java create mode 100644 src/main/java/com/autovoucher/repository/VoucherLineRepository.java create mode 100644 src/main/java/com/autovoucher/repository/VoucherRepository.java create mode 100644 src/main/java/com/autovoucher/repository/VoucherTypeRepository.java create mode 100644 src/main/java/com/autovoucher/rule/RuleEngine.java create mode 100644 src/main/java/com/autovoucher/rule/RuleMatch.java create mode 100644 src/main/java/com/autovoucher/seed/SeedCatalog.java create mode 100644 src/main/java/com/autovoucher/seed/SeedLoader.java create mode 100644 src/main/java/com/autovoucher/service/AccountResolver.java create mode 100644 src/main/java/com/autovoucher/service/AuditService.java create mode 100644 src/main/java/com/autovoucher/service/DocumentProcessingService.java create mode 100644 src/main/java/com/autovoucher/service/IngestResult.java create mode 100644 src/main/java/com/autovoucher/service/OaViewAuditService.java create mode 100644 src/main/java/com/autovoucher/service/OaViewPage.java create mode 100644 src/main/java/com/autovoucher/service/OaViewService.java create mode 100644 src/main/java/com/autovoucher/service/ProcessingOutcome.java create mode 100644 src/main/java/com/autovoucher/service/PushService.java create mode 100644 src/main/java/com/autovoucher/service/ResolvedAccount.java create mode 100644 src/main/java/com/autovoucher/service/RuleAdminService.java create mode 100644 src/main/java/com/autovoucher/service/SourceIngestService.java create mode 100644 src/main/java/com/autovoucher/service/SuggestedLine.java create mode 100644 src/main/java/com/autovoucher/service/SuggestionOutcome.java create mode 100644 src/main/java/com/autovoucher/service/VoucherNumberService.java create mode 100644 src/main/java/com/autovoucher/service/VoucherService.java create mode 100644 src/main/java/com/autovoucher/service/VoucherSuggestionService.java create mode 100644 src/main/java/com/autovoucher/source/MockSeeyonSourceAdapter.java create mode 100644 src/main/java/com/autovoucher/source/PayloadMapper.java create mode 100644 src/main/java/com/autovoucher/source/SourceAdapter.java create mode 100644 src/main/java/com/autovoucher/source/SourceAdapterRegistry.java create mode 100644 src/main/java/com/autovoucher/source/SourceDocPayload.java create mode 100644 src/main/java/com/autovoucher/web/AuditLogController.java create mode 100644 src/main/java/com/autovoucher/web/AuthController.java create mode 100644 src/main/java/com/autovoucher/web/DashboardController.java create mode 100644 src/main/java/com/autovoucher/web/DevController.java create mode 100644 src/main/java/com/autovoucher/web/LearningController.java create mode 100644 src/main/java/com/autovoucher/web/MasterDataController.java create mode 100644 src/main/java/com/autovoucher/web/OaOpenController.java create mode 100644 src/main/java/com/autovoucher/web/PushTaskController.java create mode 100644 src/main/java/com/autovoucher/web/RuleController.java create mode 100644 src/main/java/com/autovoucher/web/SourceDocController.java create mode 100644 src/main/java/com/autovoucher/web/VoucherController.java create mode 100644 src/main/resources/application-h2.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/V1__schema.sql create mode 100644 src/main/resources/templates/oa/error.html create mode 100644 src/main/resources/templates/oa/voucher.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..458d8f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +*.log +.idea/ +*.iml +.vscode/ +.DS_Store diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..893d0d8 --- /dev/null +++ b/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.autovoucher + auto-voucher-console + 0.1.0-SNAPSHOT + autoVoucher Console + autoVoucher backend: OA ingest, voucher suggestion, review and NCC push + + + 21 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + com.mysql + mysql-connector-j + runtime + + + com.h2database + h2 + runtime + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/src/main/java/com/autovoucher/AutoVoucherApplication.java b/src/main/java/com/autovoucher/AutoVoucherApplication.java new file mode 100644 index 0000000..316236a --- /dev/null +++ b/src/main/java/com/autovoucher/AutoVoucherApplication.java @@ -0,0 +1,16 @@ +package com.autovoucher; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@ConfigurationPropertiesScan +@SpringBootApplication +public class AutoVoucherApplication { + + public static void main(String[] args) { + SpringApplication.run(AutoVoucherApplication.class, args); + } +} diff --git a/src/main/java/com/autovoucher/common/ApiException.java b/src/main/java/com/autovoucher/common/ApiException.java new file mode 100644 index 0000000..db131cc --- /dev/null +++ b/src/main/java/com/autovoucher/common/ApiException.java @@ -0,0 +1,47 @@ +package com.autovoucher.common; + +import org.springframework.http.HttpStatus; + +public class ApiException extends RuntimeException { + + private final HttpStatus status; + private final String code; + + public ApiException(HttpStatus status, String code, String message) { + super(message); + this.status = status; + this.code = code; + } + + public static ApiException notFound(String message) { + return new ApiException(HttpStatus.NOT_FOUND, "NOT_FOUND", message); + } + + public static ApiException notFound(String code, String message) { + return new ApiException(HttpStatus.NOT_FOUND, code, message); + } + + public static ApiException badRequest(String message) { + return new ApiException(HttpStatus.BAD_REQUEST, "BAD_REQUEST", message); + } + + public static ApiException badRequest(String code, String message) { + return new ApiException(HttpStatus.BAD_REQUEST, code, message); + } + + public static ApiException conflict(String message) { + return new ApiException(HttpStatus.CONFLICT, "CONFLICT", message); + } + + public static ApiException conflict(String code, String message) { + return new ApiException(HttpStatus.CONFLICT, code, message); + } + + public HttpStatus getStatus() { + return status; + } + + public String getCode() { + return code; + } +} diff --git a/src/main/java/com/autovoucher/common/GlobalExceptionHandler.java b/src/main/java/com/autovoucher/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..422966e --- /dev/null +++ b/src/main/java/com/autovoucher/common/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package com.autovoucher.common; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(ApiException.class) + public ResponseEntity> handleApi(ApiException ex, HttpServletRequest request) { + return ResponseEntity.status(ex.getStatus()).body(body(ex.getCode(), ex.getMessage(), request.getRequestURI())); + } + + @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) + public ResponseEntity> handleValidation(Exception ex, HttpServletRequest request) { + String message = "请求参数不合法"; + if (ex instanceof MethodArgumentNotValidException manv && manv.getBindingResult().getFieldError() != null) { + message = manv.getBindingResult().getFieldError().getDefaultMessage(); + } else if (ex instanceof BindException be && be.getBindingResult().getFieldError() != null) { + message = be.getBindingResult().getFieldError().getDefaultMessage(); + } + return ResponseEntity.badRequest().body(body("VALIDATION_ERROR", message, request.getRequestURI())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleOther(Exception ex, HttpServletRequest request) { + log.error("Unhandled error on {}", request.getRequestURI(), ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(body("INTERNAL_ERROR", "系统处理失败,请查看服务端日志", request.getRequestURI())); + } + + private Map body(String code, String message, String path) { + Map body = new LinkedHashMap<>(); + body.put("code", code); + body.put("message", message); + body.put("path", path); + body.put("timestamp", OffsetDateTime.now().toString()); + return body; + } +} diff --git a/src/main/java/com/autovoucher/config/AppProperties.java b/src/main/java/com/autovoucher/config/AppProperties.java new file mode 100644 index 0000000..ed99998 --- /dev/null +++ b/src/main/java/com/autovoucher/config/AppProperties.java @@ -0,0 +1,65 @@ +package com.autovoucher.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@Data +@ConfigurationProperties(prefix = "av") +public class AppProperties { + + private Seed seed = new Seed(); + private Oa oa = new Oa(); + private Push push = new Push(); + private Ncc ncc = new Ncc(); + private Security security = new Security(); + private Cors cors = new Cors(); + + @Data + public static class Seed { + private boolean enabled = true; + private String path = "../testdata/seed.json"; + private boolean autoIngest = true; + } + + @Data + public static class Oa { + private String viewBaseUrl = "http://localhost:8080"; + private long ticketTtlSeconds = 900; + private long timestampSkewSeconds = 300; + private Seeyon seeyon = new Seeyon(); + } + + @Data + public static class Seeyon { + private String appId = "seeyon-demo"; + private String appSecret = "seeyon-demo-secret"; + private String allowedFrameAncestors = "http://localhost:5173"; + } + + @Data + public static class Push { + private boolean enabled = true; + private long intervalMs = 5000; + private int batchSize = 20; + } + + @Data + public static class Ncc { + private boolean dryRun = true; + private long timeoutMs = 5000; + } + + @Data + public static class Security { + private String adminUsername = "admin"; + private String adminPassword = "admin123"; + } + + @Data + public static class Cors { + private List allowedOrigins = new ArrayList<>(List.of("http://localhost:5173")); + } +} diff --git a/src/main/java/com/autovoucher/config/SecurityConfig.java b/src/main/java/com/autovoucher/config/SecurityConfig.java new file mode 100644 index 0000000..2e9146a --- /dev/null +++ b/src/main/java/com/autovoucher/config/SecurityConfig.java @@ -0,0 +1,88 @@ +package com.autovoucher.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public UserDetailsService userDetailsService(PasswordEncoder encoder, AppProperties properties) { + return new InMemoryUserDetailsManager( + User.withUsername(properties.getSecurity().getAdminUsername()) + .password(encoder.encode(properties.getSecurity().getAdminPassword())) + .roles("ADMIN", "REVIEWER") + .build() + ); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public SecurityContextRepository securityContextRepository() { + return new HttpSessionSecurityContextRepository(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource(AppProperties properties) { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(properties.getCors().getAllowedOrigins()); + configuration.setAllowedMethods(java.util.List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(java.util.List.of("*")); + configuration.setExposedHeaders(java.util.List.of("Content-Disposition")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + return source; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, SecurityContextRepository contextRepository) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> { + }) + // The OA view page must be embeddable in 致远 WebView; framing + // is restricted by the CSP frame-ancestors header instead. + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .securityContext(context -> context.securityContextRepository(contextRepository)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/favicon.ico", "/error").permitAll() + .requestMatchers("/actuator/health", "/actuator/info").permitAll() + .requestMatchers("/open/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll() + .requestMatchers("/api/**").authenticated() + .anyRequest().permitAll() + ) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .logout(logout -> logout.disable()); + return http.build(); + } +} diff --git a/src/main/java/com/autovoucher/config/TenantContext.java b/src/main/java/com/autovoucher/config/TenantContext.java new file mode 100644 index 0000000..f774064 --- /dev/null +++ b/src/main/java/com/autovoucher/config/TenantContext.java @@ -0,0 +1,22 @@ +package com.autovoucher.config; + +public final class TenantContext { + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private TenantContext() { + } + + public static Long getTenantId() { + Long value = CURRENT.get(); + return value == null ? 1L : value; + } + + public static void setTenantId(Long tenantId) { + CURRENT.set(tenantId == null ? 1L : tenantId); + } + + public static void clear() { + CURRENT.remove(); + } +} diff --git a/src/main/java/com/autovoucher/config/TenantFilter.java b/src/main/java/com/autovoucher/config/TenantFilter.java new file mode 100644 index 0000000..075d21c --- /dev/null +++ b/src/main/java/com/autovoucher/config/TenantFilter.java @@ -0,0 +1,37 @@ +package com.autovoucher.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TenantFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + String header = request.getHeader("X-Tenant-Id"); + if (header != null && !header.isBlank()) { + try { + TenantContext.setTenantId(Long.parseLong(header)); + } catch (NumberFormatException ignored) { + TenantContext.setTenantId(1L); + } + } else { + TenantContext.setTenantId(1L); + } + filterChain.doFilter(request, response); + } finally { + TenantContext.clear(); + } + } +} diff --git a/src/main/java/com/autovoucher/domain/Account.java b/src/main/java/com/autovoucher/domain/Account.java new file mode 100644 index 0000000..37bd64d --- /dev/null +++ b/src/main/java/com/autovoucher/domain/Account.java @@ -0,0 +1,38 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_account") +public class Account extends BaseEntity { + + private String accountId; + + @Column(nullable = false) + private String companyId; + + @Column(nullable = false) + private String bookId; + + @Column(nullable = false) + private String code; + + private String name; + private String parentCode; + private Integer level; + private Boolean isLeaf = true; + private String direction; + private String status = "ACTIVE"; + + @Column(columnDefinition = "TEXT") + private String auxRequiredJson; + + @Column(columnDefinition = "TEXT") + private String externalCodesJson; +} diff --git a/src/main/java/com/autovoucher/domain/AccountGroup.java b/src/main/java/com/autovoucher/domain/AccountGroup.java new file mode 100644 index 0000000..e94159b --- /dev/null +++ b/src/main/java/com/autovoucher/domain/AccountGroup.java @@ -0,0 +1,24 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_account_group") +public class AccountGroup extends BaseEntity { + + @Column(nullable = false) + private String groupCode; + + private String name; + + @Column(columnDefinition = "TEXT") + private String accountByDepartmentAttrJson; + + private String fixedAccount; +} diff --git a/src/main/java/com/autovoucher/domain/AuditLog.java b/src/main/java/com/autovoucher/domain/AuditLog.java new file mode 100644 index 0000000..6e58765 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/AuditLog.java @@ -0,0 +1,24 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_audit_log") +public class AuditLog extends BaseEntity { + + @Column(nullable = false) + private String action; + + private String entityType; + private String entityId; + private String operatorId; + + @Column(columnDefinition = "TEXT") + private String detailJson; +} diff --git a/src/main/java/com/autovoucher/domain/AuxItem.java b/src/main/java/com/autovoucher/domain/AuxItem.java new file mode 100644 index 0000000..2628b98 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/AuxItem.java @@ -0,0 +1,22 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_aux_item") +public class AuxItem extends BaseEntity { + + @Column(nullable = false) + private String auxType; + + private String name; + + @Column(columnDefinition = "TEXT") + private String externalCodesJson; +} diff --git a/src/main/java/com/autovoucher/domain/AuxValue.java b/src/main/java/com/autovoucher/domain/AuxValue.java new file mode 100644 index 0000000..c362361 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/AuxValue.java @@ -0,0 +1,30 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_aux_value") +public class AuxValue extends BaseEntity { + + @Column(nullable = false) + private String auxType; + + @Column(nullable = false) + private String code; + + private String name; + + @Column(columnDefinition = "TEXT") + private String attributesJson; + + @Column(columnDefinition = "TEXT") + private String externalCodesJson; + + private String status = "ACTIVE"; +} diff --git a/src/main/java/com/autovoucher/domain/BaseEntity.java b/src/main/java/com/autovoucher/domain/BaseEntity.java new file mode 100644 index 0000000..26d2e2d --- /dev/null +++ b/src/main/java/com/autovoucher/domain/BaseEntity.java @@ -0,0 +1,50 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@Setter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long tenantId = 1L; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + if (tenantId == null) { + tenantId = 1L; + } + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + updatedAt = createdAt; + } + + @PreUpdate + public void preUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/autovoucher/domain/Book.java b/src/main/java/com/autovoucher/domain/Book.java new file mode 100644 index 0000000..108735b --- /dev/null +++ b/src/main/java/com/autovoucher/domain/Book.java @@ -0,0 +1,29 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_book") +public class Book extends BaseEntity { + + @Column(nullable = false) + private String bookId; + + @Column(nullable = false) + private String companyId; + + @Column(nullable = false) + private String bookCode; + + private String bookName; + private String currency; + private String currentPeriod; + private String targetSystem; + private String status = "ACTIVE"; +} diff --git a/src/main/java/com/autovoucher/domain/CaseMemory.java b/src/main/java/com/autovoucher/domain/CaseMemory.java new file mode 100644 index 0000000..2e76907 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/CaseMemory.java @@ -0,0 +1,30 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@Entity +@Table(name = "av_case_memory") +public class CaseMemory extends BaseEntity { + + @Column(nullable = false) + private Long sourceDocId; + + @Column(columnDefinition = "TEXT") + private String contextJson; + + @Column(columnDefinition = "TEXT") + private String voucherJson; + + @Column(columnDefinition = "TEXT") + private String correctionDiffJson; + + private BigDecimal quality = BigDecimal.ONE; +} diff --git a/src/main/java/com/autovoucher/domain/Company.java b/src/main/java/com/autovoucher/domain/Company.java new file mode 100644 index 0000000..98d7ff4 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/Company.java @@ -0,0 +1,21 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_company") +public class Company extends BaseEntity { + + @Column(nullable = false) + private String companyId; + + private String companyCode; + private String companyName; + private String status = "ACTIVE"; +} diff --git a/src/main/java/com/autovoucher/domain/Correction.java b/src/main/java/com/autovoucher/domain/Correction.java new file mode 100644 index 0000000..ba970f2 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/Correction.java @@ -0,0 +1,37 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_correction") +public class Correction extends BaseEntity { + + @Column(nullable = false) + private Long voucherId; + + @Column(nullable = false) + private Long sourceDocId; + + @Column(columnDefinition = "TEXT") + private String originalJson; + + @Column(columnDefinition = "TEXT") + private String correctedJson; + + @Column(columnDefinition = "TEXT") + private String diffJson; + + @Column(nullable = false, length = 500) + private String reason; + + private String operatorId; + + @Column(nullable = false) + private String scope = "CASE"; +} diff --git a/src/main/java/com/autovoucher/domain/ExpenseCategory.java b/src/main/java/com/autovoucher/domain/ExpenseCategory.java new file mode 100644 index 0000000..41923c6 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/ExpenseCategory.java @@ -0,0 +1,19 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_expense_category") +public class ExpenseCategory extends BaseEntity { + + @Column(nullable = false) + private String code; + + private String name; +} diff --git a/src/main/java/com/autovoucher/domain/MappingRule.java b/src/main/java/com/autovoucher/domain/MappingRule.java new file mode 100644 index 0000000..071762f --- /dev/null +++ b/src/main/java/com/autovoucher/domain/MappingRule.java @@ -0,0 +1,29 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_mapping_rule") +public class MappingRule extends BaseEntity { + + @Column(nullable = false) + private String ruleCode; + + private String name; + private Integer priority = 0; + + @Column(nullable = false, columnDefinition = "TEXT") + private String conditionJson; + + @Column(nullable = false, columnDefinition = "TEXT") + private String resultJson; + + private Integer version = 1; + private String status = "ACTIVE"; +} diff --git a/src/main/java/com/autovoucher/domain/Nonce.java b/src/main/java/com/autovoucher/domain/Nonce.java new file mode 100644 index 0000000..4e4a568 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/Nonce.java @@ -0,0 +1,22 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "av_nonce") +public class Nonce extends BaseEntity { + + @Column(nullable = false) + private String nonce; + + @Column(nullable = false) + private LocalDateTime expiresAt; +} diff --git a/src/main/java/com/autovoucher/domain/OaViewLog.java b/src/main/java/com/autovoucher/domain/OaViewLog.java new file mode 100644 index 0000000..eca7f2b --- /dev/null +++ b/src/main/java/com/autovoucher/domain/OaViewLog.java @@ -0,0 +1,28 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_oa_view_log") +public class OaViewLog extends BaseEntity { + + private Long ticketId; + private String sourceSystem; + private String externalId; + private String operatorId; + + @Column(nullable = false) + private String action; + + private String result; + private String ip; + + @Column(length = 500) + private String userAgent; +} diff --git a/src/main/java/com/autovoucher/domain/OaViewTicket.java b/src/main/java/com/autovoucher/domain/OaViewTicket.java new file mode 100644 index 0000000..3e9667c --- /dev/null +++ b/src/main/java/com/autovoucher/domain/OaViewTicket.java @@ -0,0 +1,40 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "av_oa_view_ticket") +public class OaViewTicket extends BaseEntity { + + @Column(nullable = false) + private String sourceSystem; + + @Column(nullable = false) + private String externalId; + + @Column(nullable = false) + private Long sourceDocId; + + @Column(nullable = false) + private String operatorId; + + @Column(nullable = false) + private String tokenHash; + + @Column(nullable = false) + private String status; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + private LocalDateTime usedAt; + private String createdIp; +} diff --git a/src/main/java/com/autovoucher/domain/PushAttempt.java b/src/main/java/com/autovoucher/domain/PushAttempt.java new file mode 100644 index 0000000..611fb75 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/PushAttempt.java @@ -0,0 +1,34 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_push_attempt") +public class PushAttempt extends BaseEntity { + + @Column(nullable = false) + private Long taskId; + + @Column(nullable = false) + private Integer attemptNo; + + @Column(columnDefinition = "TEXT") + private String requestJson; + + @Column(columnDefinition = "TEXT") + private String responseJson; + + @Column(nullable = false) + private String status; + + @Column(length = 1000) + private String errorMessage; + + private Long durationMs; +} diff --git a/src/main/java/com/autovoucher/domain/PushTask.java b/src/main/java/com/autovoucher/domain/PushTask.java new file mode 100644 index 0000000..bf73ae5 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/PushTask.java @@ -0,0 +1,44 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "av_push_task") +public class PushTask extends BaseEntity { + + @Column(nullable = false) + private Long voucherId; + + @Column(nullable = false) + private String targetSystem; + + private String adapterCode; + + @Column(nullable = false) + private String status; + + private Integer attemptCount = 0; + private Integer maxAttempts = 5; + private LocalDateTime nextRetryAt; + + @Column(nullable = false) + private String idempotencyKey; + + @Column(columnDefinition = "TEXT") + private String requestSnapshotJson; + + private String externalVoucherId; + private String externalVoucherNo; + private String lastErrorCode; + + @Column(length = 1000) + private String lastErrorMessage; +} diff --git a/src/main/java/com/autovoucher/domain/RuleCandidate.java b/src/main/java/com/autovoucher/domain/RuleCandidate.java new file mode 100644 index 0000000..07f93fc --- /dev/null +++ b/src/main/java/com/autovoucher/domain/RuleCandidate.java @@ -0,0 +1,34 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_rule_candidate") +public class RuleCandidate extends BaseEntity { + + @Column(nullable = false) + private String ruleCode; + + private String name; + + @Column(columnDefinition = "TEXT") + private String conditionJson; + + @Column(columnDefinition = "TEXT") + private String resultJson; + + private Integer supportCount = 0; + private Integer conflictCount = 0; + + @Column(nullable = false) + private String status = "PENDING"; + + @Column(nullable = false) + private String source = "INDUCED"; +} diff --git a/src/main/java/com/autovoucher/domain/SourceDoc.java b/src/main/java/com/autovoucher/domain/SourceDoc.java new file mode 100644 index 0000000..682ff3b --- /dev/null +++ b/src/main/java/com/autovoucher/domain/SourceDoc.java @@ -0,0 +1,55 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "av_source_doc") +public class SourceDoc extends BaseEntity { + + @Column(nullable = false) + private String sourceSystem; + + @Column(nullable = false) + private String externalId; + + private String docNo; + private String docType; + private String title; + private String companyId; + private String bookId; + private String applicantId; + private String applicantName; + private String departmentId; + private String departmentName; + private String payeeType; + private String payeeId; + private String payeeName; + private String currency; + private BigDecimal totalAmount; + private BigDecimal taxAmount; + private String businessScene; + private Boolean isBusinessTrip = false; + private String tripId; + private String approvalNode; + private LocalDateTime approvedAt; + + @Column(nullable = false) + private String processingStatus; + + private String manualReason; + + @Column(columnDefinition = "TEXT") + private String candidatesJson; + + @Column(columnDefinition = "TEXT") + private String rawJson; +} diff --git a/src/main/java/com/autovoucher/domain/SourceEvent.java b/src/main/java/com/autovoucher/domain/SourceEvent.java new file mode 100644 index 0000000..1d93776 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/SourceEvent.java @@ -0,0 +1,34 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "av_source_event") +public class SourceEvent extends BaseEntity { + + @Column(nullable = false) + private String eventId; + + @Column(nullable = false) + private String sourceSystem; + + private String eventType; + + @Column(nullable = false) + private String externalId; + + @Column(nullable = false) + private String status; + + private String message; + private LocalDateTime receivedAt; + private LocalDateTime processedAt; +} diff --git a/src/main/java/com/autovoucher/domain/SourceLine.java b/src/main/java/com/autovoucher/domain/SourceLine.java new file mode 100644 index 0000000..262b07d --- /dev/null +++ b/src/main/java/com/autovoucher/domain/SourceLine.java @@ -0,0 +1,42 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Getter +@Setter +@Entity +@Table(name = "av_source_line") +public class SourceLine extends BaseEntity { + + @Column(nullable = false) + private Long sourceDocId; + + @Column(nullable = false) + private Integer lineNo; + + private String expenseCategory; + private String description; + private BigDecimal amount; + private BigDecimal amountExcludingTax; + private Boolean taxIncluded = true; + private BigDecimal taxRate; + private BigDecimal taxAmount; + private String invoiceType; + private LocalDate occurredAt; + private String merchantId; + private String merchantName; + private String customerId; + private String projectId; + private String costCenterId; + private String invoiceNo; + + @Column(columnDefinition = "TEXT") + private String rawJson; +} diff --git a/src/main/java/com/autovoucher/domain/SourceSystem.java b/src/main/java/com/autovoucher/domain/SourceSystem.java new file mode 100644 index 0000000..5297fc7 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/SourceSystem.java @@ -0,0 +1,23 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_source_system") +public class SourceSystem extends BaseEntity { + + @Column(nullable = false) + private String sourceSystem; + + private String name; + private String adapterCode; + private String status = "DISABLED"; + private Boolean mock = false; + private String note; +} diff --git a/src/main/java/com/autovoucher/domain/TargetSystem.java b/src/main/java/com/autovoucher/domain/TargetSystem.java new file mode 100644 index 0000000..4c4a1c9 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/TargetSystem.java @@ -0,0 +1,22 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_target_system") +public class TargetSystem extends BaseEntity { + + @Column(nullable = false) + private String targetSystem; + + private String name; + private String adapterCode; + private String status = "DISABLED"; + private Boolean dryRun = true; +} diff --git a/src/main/java/com/autovoucher/domain/Transaction.java b/src/main/java/com/autovoucher/domain/Transaction.java new file mode 100644 index 0000000..f008349 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/Transaction.java @@ -0,0 +1,29 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@Entity +@Table(name = "av_transaction") +public class Transaction extends BaseEntity { + + @Column(nullable = false) + private Long sourceDocId; + + private String businessScene; + private Boolean isBusinessTrip = false; + private String tripId; + private String departmentId; + private String applicantId; + private BigDecimal totalAmount; + + @Column(nullable = false) + private String status; +} diff --git a/src/main/java/com/autovoucher/domain/TransactionLine.java b/src/main/java/com/autovoucher/domain/TransactionLine.java new file mode 100644 index 0000000..bea26a0 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/TransactionLine.java @@ -0,0 +1,40 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@Entity +@Table(name = "av_transaction_line") +public class TransactionLine extends BaseEntity { + + @Column(nullable = false) + private Long transactionId; + + @Column(nullable = false) + private Integer lineNo; + + private String expenseCategory; + private BigDecimal amount; + private BigDecimal amountExcludingTax; + private BigDecimal taxAmount; + private String invoiceType; + private String projectId; + private String costCenterId; + private String merchantId; + private String customerId; + private String matchedRule; + private String accountGroup; + + @Column(columnDefinition = "TEXT") + private String auxJson; + + @Column(columnDefinition = "TEXT") + private String evidenceJson; +} diff --git a/src/main/java/com/autovoucher/domain/Voucher.java b/src/main/java/com/autovoucher/domain/Voucher.java new file mode 100644 index 0000000..fdc3a33 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/Voucher.java @@ -0,0 +1,59 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "av_voucher") +public class Voucher extends BaseEntity { + + @Column(nullable = false) + private String voucherNo; + + @Column(nullable = false) + private Long sourceDocId; + + private Long transactionId; + + @Column(nullable = false) + private String voucherTypeCode; + + @Column(nullable = false) + private String companyId; + + @Column(nullable = false) + private String bookId; + + private LocalDate voucherDate; + private String period; + private String currency; + private BigDecimal totalDebit; + private BigDecimal totalCredit; + + @Column(nullable = false) + private String status; + + private Integer version = 1; + private Integer currentFlag = 1; + private BigDecimal confidence; + + @Column(columnDefinition = "TEXT") + private String decisionTraceJson; + + private String targetSystem; + private String externalVoucherId; + private String externalVoucherNo; + private LocalDateTime pushedAt; + private String approvedBy; + private LocalDateTime approvedAt; + private String rejectReason; +} diff --git a/src/main/java/com/autovoucher/domain/VoucherLine.java b/src/main/java/com/autovoucher/domain/VoucherLine.java new file mode 100644 index 0000000..e63791d --- /dev/null +++ b/src/main/java/com/autovoucher/domain/VoucherLine.java @@ -0,0 +1,48 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@Entity +@Table(name = "av_voucher_line") +public class VoucherLine extends BaseEntity { + + @Column(nullable = false) + private Long voucherId; + + @Column(nullable = false) + private Integer lineNo; + + @Column(nullable = false) + private String direction; + + @Column(nullable = false) + private String accountCode; + + private String accountName; + private String accountGroup; + + @Column(nullable = false) + private BigDecimal amount; + + private String currency; + private BigDecimal taxRate; + private BigDecimal taxAmount; + + @Column(columnDefinition = "TEXT") + private String auxJson; + + private String summary; + private String decisionSource; + private BigDecimal confidence; + + @Column(columnDefinition = "TEXT") + private String evidenceJson; +} diff --git a/src/main/java/com/autovoucher/domain/VoucherType.java b/src/main/java/com/autovoucher/domain/VoucherType.java new file mode 100644 index 0000000..2094842 --- /dev/null +++ b/src/main/java/com/autovoucher/domain/VoucherType.java @@ -0,0 +1,26 @@ +package com.autovoucher.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "av_voucher_type") +public class VoucherType extends BaseEntity { + + private String voucherTypeId; + + @Column(nullable = false) + private String code; + + private String name; + + @Column(columnDefinition = "TEXT") + private String externalCodesJson; + + private String note; +} diff --git a/src/main/java/com/autovoucher/dto/DevBehaviorRequest.java b/src/main/java/com/autovoucher/dto/DevBehaviorRequest.java new file mode 100644 index 0000000..f627c7d --- /dev/null +++ b/src/main/java/com/autovoucher/dto/DevBehaviorRequest.java @@ -0,0 +1,4 @@ +package com.autovoucher.dto; + +public record DevBehaviorRequest(String behavior) { +} diff --git a/src/main/java/com/autovoucher/dto/DevOaEventRequest.java b/src/main/java/com/autovoucher/dto/DevOaEventRequest.java new file mode 100644 index 0000000..cc96a3d --- /dev/null +++ b/src/main/java/com/autovoucher/dto/DevOaEventRequest.java @@ -0,0 +1,9 @@ +package com.autovoucher.dto; + +public record DevOaEventRequest( + String eventId, + String sourceSystem, + String externalId, + String eventType +) { +} diff --git a/src/main/java/com/autovoucher/dto/DevOaTicketRequest.java b/src/main/java/com/autovoucher/dto/DevOaTicketRequest.java new file mode 100644 index 0000000..d3036b2 --- /dev/null +++ b/src/main/java/com/autovoucher/dto/DevOaTicketRequest.java @@ -0,0 +1,8 @@ +package com.autovoucher.dto; + +public record DevOaTicketRequest( + String sourceSystem, + String externalId, + String operatorId +) { +} diff --git a/src/main/java/com/autovoucher/dto/LoginRequest.java b/src/main/java/com/autovoucher/dto/LoginRequest.java new file mode 100644 index 0000000..1f92554 --- /dev/null +++ b/src/main/java/com/autovoucher/dto/LoginRequest.java @@ -0,0 +1,4 @@ +package com.autovoucher.dto; + +public record LoginRequest(String username, String password) { +} diff --git a/src/main/java/com/autovoucher/dto/ManualVoucherRequest.java b/src/main/java/com/autovoucher/dto/ManualVoucherRequest.java new file mode 100644 index 0000000..c359fc4 --- /dev/null +++ b/src/main/java/com/autovoucher/dto/ManualVoucherRequest.java @@ -0,0 +1,7 @@ +package com.autovoucher.dto; + +public record ManualVoucherRequest( + String accountCode, + String reason +) { +} diff --git a/src/main/java/com/autovoucher/dto/OaSignResponse.java b/src/main/java/com/autovoucher/dto/OaSignResponse.java new file mode 100644 index 0000000..4f275ea --- /dev/null +++ b/src/main/java/com/autovoucher/dto/OaSignResponse.java @@ -0,0 +1,11 @@ +package com.autovoucher.dto; + +/** Everything the OA integration test console needs to replay a signed call. */ +public record OaSignResponse( + String canonical, + String timestamp, + String nonce, + String signature, + OaTicketRequest request +) { +} diff --git a/src/main/java/com/autovoucher/dto/OaStatusResponse.java b/src/main/java/com/autovoucher/dto/OaStatusResponse.java new file mode 100644 index 0000000..2be2fb5 --- /dev/null +++ b/src/main/java/com/autovoucher/dto/OaStatusResponse.java @@ -0,0 +1,10 @@ +package com.autovoucher.dto; + +public record OaStatusResponse( + String status, + String message, + Long voucherId, + String voucherNo, + String voucherStatus +) { +} diff --git a/src/main/java/com/autovoucher/dto/OaTicketRequest.java b/src/main/java/com/autovoucher/dto/OaTicketRequest.java new file mode 100644 index 0000000..dcb45a5 --- /dev/null +++ b/src/main/java/com/autovoucher/dto/OaTicketRequest.java @@ -0,0 +1,16 @@ +package com.autovoucher.dto; + +/** + * Ticket issuance request sent by the OA backend. timestamp is epoch + * milliseconds (a 10-digit value is treated as epoch seconds); nonce must be + * unique for the timestamp window. + */ +public record OaTicketRequest( + String sourceSystem, + String externalId, + String operatorId, + String timestamp, + String nonce, + String signature +) { +} diff --git a/src/main/java/com/autovoucher/dto/OaTicketResponse.java b/src/main/java/com/autovoucher/dto/OaTicketResponse.java new file mode 100644 index 0000000..28b450f --- /dev/null +++ b/src/main/java/com/autovoucher/dto/OaTicketResponse.java @@ -0,0 +1,13 @@ +package com.autovoucher.dto; + +import java.time.LocalDateTime; + +public record OaTicketResponse( + Long ticketId, + String token, + String viewUrl, + String status, + LocalDateTime expiresAt, + String message +) { +} diff --git a/src/main/java/com/autovoucher/dto/RejectVoucherRequest.java b/src/main/java/com/autovoucher/dto/RejectVoucherRequest.java new file mode 100644 index 0000000..dbe5f81 --- /dev/null +++ b/src/main/java/com/autovoucher/dto/RejectVoucherRequest.java @@ -0,0 +1,4 @@ +package com.autovoucher.dto; + +public record RejectVoucherRequest(String reason) { +} diff --git a/src/main/java/com/autovoucher/dto/RuleCandidateDecisionRequest.java b/src/main/java/com/autovoucher/dto/RuleCandidateDecisionRequest.java new file mode 100644 index 0000000..6fa45ff --- /dev/null +++ b/src/main/java/com/autovoucher/dto/RuleCandidateDecisionRequest.java @@ -0,0 +1,8 @@ +package com.autovoucher.dto; + +public record RuleCandidateDecisionRequest( + String ruleCode, + String name, + Integer priority +) { +} diff --git a/src/main/java/com/autovoucher/dto/RuleRequest.java b/src/main/java/com/autovoucher/dto/RuleRequest.java new file mode 100644 index 0000000..65f8db0 --- /dev/null +++ b/src/main/java/com/autovoucher/dto/RuleRequest.java @@ -0,0 +1,13 @@ +package com.autovoucher.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +public record RuleRequest( + String ruleCode, + String name, + Integer priority, + JsonNode condition, + JsonNode result, + String status +) { +} diff --git a/src/main/java/com/autovoucher/dto/VoucherLineInput.java b/src/main/java/com/autovoucher/dto/VoucherLineInput.java new file mode 100644 index 0000000..40606aa --- /dev/null +++ b/src/main/java/com/autovoucher/dto/VoucherLineInput.java @@ -0,0 +1,15 @@ +package com.autovoucher.dto; + +import java.math.BigDecimal; +import java.util.Map; + +public record VoucherLineInput( + String direction, + String accountCode, + BigDecimal amount, + BigDecimal taxRate, + BigDecimal taxAmount, + Map aux, + String summary +) { +} diff --git a/src/main/java/com/autovoucher/dto/VoucherUpdateRequest.java b/src/main/java/com/autovoucher/dto/VoucherUpdateRequest.java new file mode 100644 index 0000000..83260fe --- /dev/null +++ b/src/main/java/com/autovoucher/dto/VoucherUpdateRequest.java @@ -0,0 +1,9 @@ +package com.autovoucher.dto; + +import java.util.List; + +public record VoucherUpdateRequest( + List lines, + String reason +) { +} diff --git a/src/main/java/com/autovoucher/job/NccPushJob.java b/src/main/java/com/autovoucher/job/NccPushJob.java new file mode 100644 index 0000000..c5e90bc --- /dev/null +++ b/src/main/java/com/autovoucher/job/NccPushJob.java @@ -0,0 +1,75 @@ +package com.autovoucher.job; + +import com.autovoucher.config.AppProperties; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.PushTask; +import com.autovoucher.repository.PushTaskRepository; +import com.autovoucher.service.PushService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * NCC-specific push job. Other targets get their own jobs and filter on their + * own targetSystem, so an NCC outage cannot block 金蝶 or NC65 pushes. The + * production version of this job is expected to run under Quartz with a + * cluster-safe lock; Spring @Scheduled is used for the MVP. + */ +@Component +@ConditionalOnProperty(prefix = "av.push", name = "enabled", havingValue = "true", matchIfMissing = true) +public class NccPushJob { + + private static final Logger log = LoggerFactory.getLogger(NccPushJob.class); + private static final String TARGET = "NCC"; + private static final Long TENANT_ID = 1L; + + private final PushTaskRepository pushTaskRepository; + private final PushService pushService; + private final AppProperties properties; + + public NccPushJob(PushTaskRepository pushTaskRepository, + PushService pushService, + AppProperties properties) { + this.pushTaskRepository = pushTaskRepository; + this.pushService = pushService; + this.properties = properties; + } + + @Scheduled(fixedDelayString = "${av.push.interval-ms:5000}") + public void run() { + TenantContext.setTenantId(TENANT_ID); + try { + int limit = Math.max(1, properties.getPush().getBatchSize()); + LocalDateTime now = LocalDateTime.now(); + + // A timed-out create may already have succeeded in NCC. Never retry + // blindly: query first, then decide. + List unknown = pushTaskRepository.findDueTasks( + TENANT_ID, TARGET, List.of("UNKNOWN"), now); + for (PushTask task : unknown.stream().limit(limit).toList()) { + try { + pushService.reconcile(task.getId()); + } catch (Exception e) { + log.error("NCC reconcile failed for task {}", task.getId(), e); + } + } + + List due = pushTaskRepository.findDueTasks( + TENANT_ID, TARGET, List.of("PENDING", "RETRY_WAIT"), LocalDateTime.now()); + for (PushTask task : due.stream().limit(limit).toList()) { + try { + pushService.push(task.getId(), null); + } catch (Exception e) { + log.error("NCC push failed for task {}", task.getId(), e); + } + } + } finally { + TenantContext.clear(); + } + } +} diff --git a/src/main/java/com/autovoucher/push/MockNccVoucherClient.java b/src/main/java/com/autovoucher/push/MockNccVoucherClient.java new file mode 100644 index 0000000..f3a5025 --- /dev/null +++ b/src/main/java/com/autovoucher/push/MockNccVoucherClient.java @@ -0,0 +1,115 @@ +package com.autovoucher.push; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Mock of the 用友 NCC voucher API. It mimics the important production + * semantics: idempotent create, duplicate detection, and timeouts whose true + * outcome is unknown until queried. + */ +@Component +public class MockNccVoucherClient implements TargetSystemAdapter { + + private final NccMockBehavior behavior; + private final ObjectMapper objectMapper; + private final ConcurrentMap accepted = new ConcurrentHashMap<>(); + + public MockNccVoucherClient(NccMockBehavior behavior, ObjectMapper objectMapper) { + this.behavior = behavior; + this.objectMapper = objectMapper; + } + + @Override + public String code() { + return "NCC"; + } + + @Override + public PushResult push(PushRequest request) { + String requestJson = toJson(request); + String[] existing = accepted.get(request.idempotencyKey()); + if (existing != null) { + return PushResult.of("DUPLICATE", "DUPLICATE", "voucher already exists", + existing[0], existing[1], requestJson, + response("DUPLICATE", "voucher already exists", existing[0], existing[1])); + } + + String mode = behavior.get(); + return switch (mode) { + case "TIMEOUT" -> PushResult.of("TIMEOUT", "NCC_TIMEOUT", + "NCC timeout: outcome unknown", null, null, requestJson, + response("TIMEOUT", "NCC timeout: outcome unknown", null, null)); + case "ERROR" -> PushResult.of("ERROR", "NCC_ERROR", + "NCC rejected the voucher", null, null, requestJson, + response("NCC_ERROR", "NCC rejected the voucher", null, null)); + case "DUPLICATE" -> { + String[] ids = externalIds(request); + accepted.put(request.idempotencyKey(), ids); + yield PushResult.of("DUPLICATE", "DUPLICATE", "voucher already exists", + ids[0], ids[1], requestJson, + response("DUPLICATE", "voucher already exists", ids[0], ids[1])); + } + default -> { + String[] ids = externalIds(request); + accepted.put(request.idempotencyKey(), ids); + yield PushResult.of("SUCCESS", "0", "success", ids[0], ids[1], requestJson, + response("0", "success", ids[0], ids[1])); + } + }; + } + + /** Test hook: forget accepted vouchers so the duplicate path can be replayed. */ + public void clearAccepted() { + accepted.clear(); + } + + @Override + public PushResult query(PushRequest request) { + String[] existing = accepted.get(request.idempotencyKey()); + if (existing != null) { + return PushResult.of("SUCCESS", "0", "voucher exists in NCC", + existing[0], existing[1], toJson(request), + response("0", "voucher exists in NCC", existing[0], existing[1])); + } + return PushResult.of("NOT_FOUND", "NOT_FOUND", "voucher not found in NCC", + null, null, toJson(request), + response("NOT_FOUND", "voucher not found in NCC", null, null)); + } + + private String[] externalIds(PushRequest request) { + String suffix = request.voucherNo(); + int dash = suffix.lastIndexOf('-'); + if (dash >= 0) { + suffix = suffix.substring(dash + 1); + } + int year = request.voucherDate() == null ? 2026 : request.voucherDate().getYear(); + int month = request.voucherDate() == null ? 1 : request.voucherDate().getMonthValue(); + String externalId = "NCC-VID-" + suffix; + String externalNo = String.format("记-%04d-%02d-%s", year, month, suffix); + return new String[]{externalId, externalNo}; + } + + private String response(String code, String message, String externalId, String externalNo) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("code", code); + node.put("message", message); + if (externalId != null) { + node.put("externalVoucherId", externalId); + node.put("externalVoucherNo", externalNo); + } + return node.toString(); + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + return "{}"; + } + } +} diff --git a/src/main/java/com/autovoucher/push/NccMockBehavior.java b/src/main/java/com/autovoucher/push/NccMockBehavior.java new file mode 100644 index 0000000..1d0ba38 --- /dev/null +++ b/src/main/java/com/autovoucher/push/NccMockBehavior.java @@ -0,0 +1,24 @@ +package com.autovoucher.push; + +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Test hook for the Mock NCC client. Runtime code never changes it; the dev + * endpoint /api/dev/ncc/mock-behavior does, so the four push scenarios can be + * demonstrated without a real NCC environment. + */ +@Component +public class NccMockBehavior { + + private final AtomicReference behavior = new AtomicReference<>("SUCCESS"); + + public String get() { + return behavior.get(); + } + + public void set(String value) { + behavior.set(value == null ? "SUCCESS" : value.toUpperCase()); + } +} diff --git a/src/main/java/com/autovoucher/push/PushLine.java b/src/main/java/com/autovoucher/push/PushLine.java new file mode 100644 index 0000000..332f1ab --- /dev/null +++ b/src/main/java/com/autovoucher/push/PushLine.java @@ -0,0 +1,18 @@ +package com.autovoucher.push; + +import java.math.BigDecimal; +import java.util.Map; + +public record PushLine( + int lineNo, + String direction, + String accountCode, + String accountName, + BigDecimal amount, + String currency, + BigDecimal taxRate, + BigDecimal taxAmount, + Map aux, + String summary +) { +} diff --git a/src/main/java/com/autovoucher/push/PushRequest.java b/src/main/java/com/autovoucher/push/PushRequest.java new file mode 100644 index 0000000..5d54261 --- /dev/null +++ b/src/main/java/com/autovoucher/push/PushRequest.java @@ -0,0 +1,24 @@ +package com.autovoucher.push; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public record PushRequest( + Long tenantId, + String targetSystem, + String adapterCode, + String idempotencyKey, + String voucherNo, + LocalDate voucherDate, + String period, + String companyId, + String bookId, + String currency, + BigDecimal totalDebit, + BigDecimal totalCredit, + String sourceDocNo, + boolean dryRun, + List lines +) { +} diff --git a/src/main/java/com/autovoucher/push/PushResult.java b/src/main/java/com/autovoucher/push/PushResult.java new file mode 100644 index 0000000..7f1c073 --- /dev/null +++ b/src/main/java/com/autovoucher/push/PushResult.java @@ -0,0 +1,25 @@ +package com.autovoucher.push; + +/** + * Result of a target-system call. + * + *

SUCCESS / DUPLICATE are terminal successes; TIMEOUT means the outcome is + * UNKNOWN and must be queried before any retry; ERROR is a definite failure + * that may be retried with backoff.

+ */ +public record PushResult( + String status, + String code, + String message, + String externalVoucherId, + String externalVoucherNo, + String requestJson, + String responseJson +) { + public static PushResult of(String status, String code, String message, + String externalVoucherId, String externalVoucherNo, + String requestJson, String responseJson) { + return new PushResult(status, code, message, externalVoucherId, externalVoucherNo, + requestJson, responseJson); + } +} diff --git a/src/main/java/com/autovoucher/push/TargetSystemAdapter.java b/src/main/java/com/autovoucher/push/TargetSystemAdapter.java new file mode 100644 index 0000000..9ce4125 --- /dev/null +++ b/src/main/java/com/autovoucher/push/TargetSystemAdapter.java @@ -0,0 +1,15 @@ +package com.autovoucher.push; + +/** + * Boundary between autoVoucher and a financial system (NCC / NC65 / 金蝶). + * A scheduled job must be pinned to one adapter so different targets can be + * rolled out independently. + */ +public interface TargetSystemAdapter { + + String code(); + + PushResult push(PushRequest request); + + PushResult query(PushRequest request); +} diff --git a/src/main/java/com/autovoucher/push/TargetSystemAdapterRegistry.java b/src/main/java/com/autovoucher/push/TargetSystemAdapterRegistry.java new file mode 100644 index 0000000..d062754 --- /dev/null +++ b/src/main/java/com/autovoucher/push/TargetSystemAdapterRegistry.java @@ -0,0 +1,33 @@ +package com.autovoucher.push; + +import com.autovoucher.common.ApiException; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class TargetSystemAdapterRegistry { + + private final Map adapters; + + public TargetSystemAdapterRegistry(List adapterList) { + this.adapters = adapterList.stream() + .collect(Collectors.toUnmodifiableMap(TargetSystemAdapter::code, Function.identity())); + } + + public TargetSystemAdapter get(String code) { + TargetSystemAdapter adapter = adapters.get(code); + if (adapter == null) { + throw ApiException.badRequest("UNSUPPORTED_TARGET_ADAPTER", + "未配置财务系统适配器: " + code); + } + return adapter; + } + + public boolean supports(String code) { + return adapters.containsKey(code); + } +} diff --git a/src/main/java/com/autovoucher/repository/AccountGroupRepository.java b/src/main/java/com/autovoucher/repository/AccountGroupRepository.java new file mode 100644 index 0000000..cb8e8d9 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/AccountGroupRepository.java @@ -0,0 +1,13 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.AccountGroup; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AccountGroupRepository extends JpaRepository { + Optional findByTenantIdAndGroupCode(Long tenantId, String groupCode); + + List findAllByTenantIdOrderByGroupCodeAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/AccountRepository.java b/src/main/java/com/autovoucher/repository/AccountRepository.java new file mode 100644 index 0000000..523672b --- /dev/null +++ b/src/main/java/com/autovoucher/repository/AccountRepository.java @@ -0,0 +1,15 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.Account; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AccountRepository extends JpaRepository { + Optional findByTenantIdAndCompanyIdAndBookIdAndCode(Long tenantId, String companyId, String bookId, String code); + + List findAllByTenantIdAndCompanyIdAndBookIdOrderByCodeAsc(Long tenantId, String companyId, String bookId); + + List findAllByTenantIdOrderByCodeAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/AuditLogRepository.java b/src/main/java/com/autovoucher/repository/AuditLogRepository.java new file mode 100644 index 0000000..c71d2b4 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/AuditLogRepository.java @@ -0,0 +1,12 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.AuditLog; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface AuditLogRepository extends JpaRepository { + List findAllByTenantIdAndEntityTypeAndEntityIdOrderByIdDesc(Long tenantId, String entityType, String entityId); + + List findAllByTenantIdOrderByIdDesc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/AuxItemRepository.java b/src/main/java/com/autovoucher/repository/AuxItemRepository.java new file mode 100644 index 0000000..c468e6f --- /dev/null +++ b/src/main/java/com/autovoucher/repository/AuxItemRepository.java @@ -0,0 +1,13 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.AuxItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AuxItemRepository extends JpaRepository { + Optional findByTenantIdAndAuxType(Long tenantId, String auxType); + + List findAllByTenantIdOrderByAuxTypeAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/AuxValueRepository.java b/src/main/java/com/autovoucher/repository/AuxValueRepository.java new file mode 100644 index 0000000..9ee8056 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/AuxValueRepository.java @@ -0,0 +1,15 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.AuxValue; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AuxValueRepository extends JpaRepository { + Optional findByTenantIdAndAuxTypeAndCode(Long tenantId, String auxType, String code); + + List findAllByTenantIdAndAuxTypeOrderByCodeAsc(Long tenantId, String auxType); + + List findAllByTenantIdOrderByAuxTypeAscCodeAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/BookRepository.java b/src/main/java/com/autovoucher/repository/BookRepository.java new file mode 100644 index 0000000..50c1089 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/BookRepository.java @@ -0,0 +1,13 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.Book; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface BookRepository extends JpaRepository { + Optional findByTenantIdAndBookId(Long tenantId, String bookId); + + List findAllByTenantIdOrderByIdAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/CaseMemoryRepository.java b/src/main/java/com/autovoucher/repository/CaseMemoryRepository.java new file mode 100644 index 0000000..cada944 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/CaseMemoryRepository.java @@ -0,0 +1,12 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.CaseMemory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CaseMemoryRepository extends JpaRepository { + List findAllByTenantIdOrderByIdDesc(Long tenantId); + + List findAllByTenantIdAndSourceDocIdOrderByIdDesc(Long tenantId, Long sourceDocId); +} diff --git a/src/main/java/com/autovoucher/repository/CompanyRepository.java b/src/main/java/com/autovoucher/repository/CompanyRepository.java new file mode 100644 index 0000000..e5146ec --- /dev/null +++ b/src/main/java/com/autovoucher/repository/CompanyRepository.java @@ -0,0 +1,13 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.Company; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CompanyRepository extends JpaRepository { + Optional findByTenantIdAndCompanyId(Long tenantId, String companyId); + + List findAllByTenantIdOrderByIdAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/CorrectionRepository.java b/src/main/java/com/autovoucher/repository/CorrectionRepository.java new file mode 100644 index 0000000..f2de636 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/CorrectionRepository.java @@ -0,0 +1,12 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.Correction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CorrectionRepository extends JpaRepository { + List findAllByTenantIdAndVoucherIdOrderByIdDesc(Long tenantId, Long voucherId); + + List findAllByTenantIdOrderByIdDesc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/ExpenseCategoryRepository.java b/src/main/java/com/autovoucher/repository/ExpenseCategoryRepository.java new file mode 100644 index 0000000..ab73ced --- /dev/null +++ b/src/main/java/com/autovoucher/repository/ExpenseCategoryRepository.java @@ -0,0 +1,13 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.ExpenseCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ExpenseCategoryRepository extends JpaRepository { + Optional findByTenantIdAndCode(Long tenantId, String code); + + List findAllByTenantIdOrderByIdAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/MappingRuleRepository.java b/src/main/java/com/autovoucher/repository/MappingRuleRepository.java new file mode 100644 index 0000000..e1e6e88 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/MappingRuleRepository.java @@ -0,0 +1,15 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.MappingRule; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MappingRuleRepository extends JpaRepository { + Optional findByTenantIdAndRuleCodeAndVersion(Long tenantId, String ruleCode, Integer version); + + List findAllByTenantIdAndStatusOrderByPriorityDesc(Long tenantId, String status); + + List findAllByTenantIdOrderByPriorityDesc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/NonceRepository.java b/src/main/java/com/autovoucher/repository/NonceRepository.java new file mode 100644 index 0000000..ee50e68 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/NonceRepository.java @@ -0,0 +1,17 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.Nonce; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; + +public interface NonceRepository extends JpaRepository { + boolean existsByTenantIdAndNonce(Long tenantId, String nonce); + + @Modifying + @Query("delete from Nonce n where n.tenantId = :tenantId and n.expiresAt < :now") + int deleteExpired(@Param("tenantId") Long tenantId, @Param("now") LocalDateTime now); +} diff --git a/src/main/java/com/autovoucher/repository/OaViewLogRepository.java b/src/main/java/com/autovoucher/repository/OaViewLogRepository.java new file mode 100644 index 0000000..bb32742 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/OaViewLogRepository.java @@ -0,0 +1,12 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.OaViewLog; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OaViewLogRepository extends JpaRepository { + List findAllByTenantIdAndTicketIdOrderByIdAsc(Long tenantId, Long ticketId); + + List findAllByTenantIdOrderByIdDesc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/OaViewTicketRepository.java b/src/main/java/com/autovoucher/repository/OaViewTicketRepository.java new file mode 100644 index 0000000..9bbce35 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/OaViewTicketRepository.java @@ -0,0 +1,13 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.OaViewTicket; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface OaViewTicketRepository extends JpaRepository { + Optional findByTenantIdAndTokenHash(Long tenantId, String tokenHash); + + List findAllByTenantIdOrderByIdDesc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/PushAttemptRepository.java b/src/main/java/com/autovoucher/repository/PushAttemptRepository.java new file mode 100644 index 0000000..1c4d1d5 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/PushAttemptRepository.java @@ -0,0 +1,10 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.PushAttempt; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PushAttemptRepository extends JpaRepository { + List findAllByTenantIdAndTaskIdOrderByAttemptNoAsc(Long tenantId, Long taskId); +} diff --git a/src/main/java/com/autovoucher/repository/PushTaskRepository.java b/src/main/java/com/autovoucher/repository/PushTaskRepository.java new file mode 100644 index 0000000..98f6b60 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/PushTaskRepository.java @@ -0,0 +1,35 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.PushTask; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface PushTaskRepository extends JpaRepository { + Optional findByTenantIdAndIdempotencyKey(Long tenantId, String idempotencyKey); + + Optional findByTenantIdAndVoucherId(Long tenantId, Long voucherId); + + List findAllByTenantIdOrderByIdDesc(Long tenantId); + + List findAllByTenantIdAndStatusInOrderByIdDesc(Long tenantId, List statuses); + + @Query(""" + select t from PushTask t + where t.tenantId = :tenantId + and t.targetSystem = :targetSystem + and t.status in :statuses + and (t.nextRetryAt is null or t.nextRetryAt <= :now) + order by t.id asc + """) + List findDueTasks(@Param("tenantId") Long tenantId, + @Param("targetSystem") String targetSystem, + @Param("statuses") List statuses, + @Param("now") LocalDateTime now); + + long countByTenantIdAndStatus(Long tenantId, String status); +} diff --git a/src/main/java/com/autovoucher/repository/RuleCandidateRepository.java b/src/main/java/com/autovoucher/repository/RuleCandidateRepository.java new file mode 100644 index 0000000..a4995f4 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/RuleCandidateRepository.java @@ -0,0 +1,15 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.RuleCandidate; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface RuleCandidateRepository extends JpaRepository { + Optional findByTenantIdAndRuleCode(Long tenantId, String ruleCode); + + List findAllByTenantIdAndStatusOrderBySupportCountDesc(Long tenantId, String status); + + List findAllByTenantIdOrderByIdDesc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/SourceDocRepository.java b/src/main/java/com/autovoucher/repository/SourceDocRepository.java new file mode 100644 index 0000000..be292cc --- /dev/null +++ b/src/main/java/com/autovoucher/repository/SourceDocRepository.java @@ -0,0 +1,19 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.SourceDoc; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SourceDocRepository extends JpaRepository { + Optional findByTenantIdAndSourceSystemAndExternalId(Long tenantId, String sourceSystem, String externalId); + + Optional findByTenantIdAndDocNo(Long tenantId, String docNo); + + List findAllByTenantIdOrderByIdDesc(Long tenantId); + + List findAllByTenantIdAndProcessingStatusOrderByIdDesc(Long tenantId, String processingStatus); + + long countByTenantIdAndProcessingStatus(Long tenantId, String processingStatus); +} diff --git a/src/main/java/com/autovoucher/repository/SourceEventRepository.java b/src/main/java/com/autovoucher/repository/SourceEventRepository.java new file mode 100644 index 0000000..d577ed7 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/SourceEventRepository.java @@ -0,0 +1,15 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.SourceEvent; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SourceEventRepository extends JpaRepository { + Optional findByTenantIdAndEventId(Long tenantId, String eventId); + + boolean existsByTenantIdAndEventId(Long tenantId, String eventId); + + List findAllByTenantIdOrderByIdDesc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/SourceLineRepository.java b/src/main/java/com/autovoucher/repository/SourceLineRepository.java new file mode 100644 index 0000000..53a5ccd --- /dev/null +++ b/src/main/java/com/autovoucher/repository/SourceLineRepository.java @@ -0,0 +1,12 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.SourceLine; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SourceLineRepository extends JpaRepository { + List findAllByTenantIdAndSourceDocIdOrderByLineNoAsc(Long tenantId, Long sourceDocId); + + void deleteAllByTenantIdAndSourceDocId(Long tenantId, Long sourceDocId); +} diff --git a/src/main/java/com/autovoucher/repository/SourceSystemRepository.java b/src/main/java/com/autovoucher/repository/SourceSystemRepository.java new file mode 100644 index 0000000..3583199 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/SourceSystemRepository.java @@ -0,0 +1,13 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.SourceSystem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SourceSystemRepository extends JpaRepository { + Optional findByTenantIdAndSourceSystem(Long tenantId, String sourceSystem); + + List findAllByTenantIdOrderByIdAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/TargetSystemRepository.java b/src/main/java/com/autovoucher/repository/TargetSystemRepository.java new file mode 100644 index 0000000..1eb54dd --- /dev/null +++ b/src/main/java/com/autovoucher/repository/TargetSystemRepository.java @@ -0,0 +1,13 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.TargetSystem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TargetSystemRepository extends JpaRepository { + Optional findByTenantIdAndTargetSystem(Long tenantId, String targetSystem); + + List findAllByTenantIdOrderByIdAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/repository/TransactionLineRepository.java b/src/main/java/com/autovoucher/repository/TransactionLineRepository.java new file mode 100644 index 0000000..4462fbb --- /dev/null +++ b/src/main/java/com/autovoucher/repository/TransactionLineRepository.java @@ -0,0 +1,12 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.TransactionLine; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TransactionLineRepository extends JpaRepository { + List findAllByTenantIdAndTransactionIdOrderByLineNoAsc(Long tenantId, Long transactionId); + + void deleteAllByTenantIdAndTransactionId(Long tenantId, Long transactionId); +} diff --git a/src/main/java/com/autovoucher/repository/TransactionRepository.java b/src/main/java/com/autovoucher/repository/TransactionRepository.java new file mode 100644 index 0000000..8de0a0e --- /dev/null +++ b/src/main/java/com/autovoucher/repository/TransactionRepository.java @@ -0,0 +1,12 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.Transaction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TransactionRepository extends JpaRepository { + Optional findByTenantIdAndSourceDocId(Long tenantId, Long sourceDocId); + + void deleteAllByTenantIdAndSourceDocId(Long tenantId, Long sourceDocId); +} diff --git a/src/main/java/com/autovoucher/repository/VoucherLineRepository.java b/src/main/java/com/autovoucher/repository/VoucherLineRepository.java new file mode 100644 index 0000000..987e832 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/VoucherLineRepository.java @@ -0,0 +1,12 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.VoucherLine; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface VoucherLineRepository extends JpaRepository { + List findAllByTenantIdAndVoucherIdOrderByLineNoAsc(Long tenantId, Long voucherId); + + void deleteAllByTenantIdAndVoucherId(Long tenantId, Long voucherId); +} diff --git a/src/main/java/com/autovoucher/repository/VoucherRepository.java b/src/main/java/com/autovoucher/repository/VoucherRepository.java new file mode 100644 index 0000000..ec008a2 --- /dev/null +++ b/src/main/java/com/autovoucher/repository/VoucherRepository.java @@ -0,0 +1,21 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.Voucher; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface VoucherRepository extends JpaRepository { + Optional findByTenantIdAndVoucherNo(Long tenantId, String voucherNo); + + Optional findByTenantIdAndSourceDocIdAndCurrentFlag(Long tenantId, Long sourceDocId, Integer currentFlag); + + List findAllByTenantIdOrderByIdDesc(Long tenantId); + + List findAllByTenantIdAndStatusOrderByIdDesc(Long tenantId, String status); + + List findAllByTenantIdAndSourceDocIdOrderByIdDesc(Long tenantId, Long sourceDocId); + + long countByTenantIdAndStatus(Long tenantId, String status); +} diff --git a/src/main/java/com/autovoucher/repository/VoucherTypeRepository.java b/src/main/java/com/autovoucher/repository/VoucherTypeRepository.java new file mode 100644 index 0000000..21b8fcd --- /dev/null +++ b/src/main/java/com/autovoucher/repository/VoucherTypeRepository.java @@ -0,0 +1,13 @@ +package com.autovoucher.repository; + +import com.autovoucher.domain.VoucherType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface VoucherTypeRepository extends JpaRepository { + Optional findByTenantIdAndCode(Long tenantId, String code); + + List findAllByTenantIdOrderByIdAsc(Long tenantId); +} diff --git a/src/main/java/com/autovoucher/rule/RuleEngine.java b/src/main/java/com/autovoucher/rule/RuleEngine.java new file mode 100644 index 0000000..8491463 --- /dev/null +++ b/src/main/java/com/autovoucher/rule/RuleEngine.java @@ -0,0 +1,246 @@ +package com.autovoucher.rule; + +import com.autovoucher.common.ApiException; +import com.autovoucher.domain.MappingRule; +import com.autovoucher.repository.MappingRuleRepository; +import com.autovoucher.config.TenantContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Deterministic evaluator for mapping rules. The LLM never invents account + * codes directly: it may propose a category/context, but the final account is + * chosen from rules like these and validated against master data. + */ +@Component +public class RuleEngine { + + private final MappingRuleRepository mappingRuleRepository; + private final ObjectMapper objectMapper; + + public RuleEngine(MappingRuleRepository mappingRuleRepository, ObjectMapper objectMapper) { + this.mappingRuleRepository = mappingRuleRepository; + this.objectMapper = objectMapper; + } + + /** + * Returns the highest-priority fully matching rule. Ties are broken by the + * number of matched conditions and then by rule code for stable results. + */ + public RuleMatch matchFirst(Map facts) { + return rules().stream() + .map(rule -> match(rule, facts)) + .filter(Objects::nonNull) + .sorted((a, b) -> { + int byPriority = Integer.compare(b.rule().getPriority(), a.rule().getPriority()); + if (byPriority != 0) { + return byPriority; + } + int byCount = Integer.compare(b.matchedConditionCount(), a.matchedConditionCount()); + if (byCount != 0) { + return byCount; + } + return a.rule().getRuleCode().compareTo(b.rule().getRuleCode()); + }) + .findFirst() + .orElse(null); + } + + /** + * Rules that reference the same expense category but do not fully match. + * Their target accounts become reviewer candidates when context is missing. + */ + public List partialMatchesByCategory(String expenseCategory, Map facts) { + if (expenseCategory == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (MappingRule rule : rules()) { + JsonNode condition = parse(rule.getConditionJson()); + List predicates = flatten(condition); + boolean mentionsCategory = predicates.stream().anyMatch(p -> { + String field = p.path("field").asText(); + return "expenseCategory".equals(field) && predicateMatches(p, facts); + }); + if (!mentionsCategory) { + continue; + } + List matchedFields = new ArrayList<>(); + int matched = countMatched(condition, facts, matchedFields); + result.add(new RuleMatch(rule, matched, matchedFields, facts)); + } + return result; + } + + public boolean evaluate(String conditionJson, Map facts) { + return evaluate(parse(conditionJson), facts); + } + + public boolean evaluate(JsonNode condition, Map facts) { + if (condition == null || condition.isNull() || condition.isEmpty()) { + return true; + } + if (condition.has("all")) { + for (JsonNode predicate : condition.get("all")) { + if (!evaluate(predicate, facts)) { + return false; + } + } + return true; + } + if (condition.has("any")) { + for (JsonNode predicate : condition.get("any")) { + if (evaluate(predicate, facts)) { + return true; + } + } + return false; + } + if (condition.has("field")) { + return predicateMatches(condition, facts); + } + return false; + } + + private RuleMatch match(MappingRule rule, Map facts) { + JsonNode condition = parse(rule.getConditionJson()); + List matchedFields = new ArrayList<>(); + int matched = countMatched(condition, facts, matchedFields); + if (!evaluate(condition, facts)) { + return null; + } + return new RuleMatch(rule, matched, matchedFields, facts); + } + + private int countMatched(JsonNode condition, Map facts, List matchedFields) { + int count = 0; + for (JsonNode predicate : flatten(condition)) { + if (predicateMatches(predicate, facts)) { + count++; + matchedFields.add(predicate.path("field").asText()); + } + } + return count; + } + + private List flatten(JsonNode condition) { + List predicates = new ArrayList<>(); + if (condition == null) { + return predicates; + } + if (condition.has("all")) { + condition.get("all").forEach(child -> predicates.addAll(flatten(child))); + } else if (condition.has("any")) { + condition.get("any").forEach(child -> predicates.addAll(flatten(child))); + } else if (condition.has("field")) { + predicates.add(condition); + } + return predicates; + } + + private boolean predicateMatches(JsonNode predicate, Map facts) { + String field = predicate.path("field").asText(); + if (!facts.containsKey(field)) { + return false; + } + Object actual = facts.get(field); + String op = predicate.path("op").asText("eq"); + JsonNode expected = predicate.get("value"); + + return switch (op) { + case "eq" -> equalsValue(actual, expected); + case "ne" -> !equalsValue(actual, expected); + case "in" -> { + boolean found = false; + if (expected != null && expected.isArray()) { + for (JsonNode item : expected) { + if (equalsValue(actual, item)) { + found = true; + break; + } + } + } + yield found; + } + case "notIn" -> { + boolean found = false; + if (expected != null && expected.isArray()) { + for (JsonNode item : expected) { + if (equalsValue(actual, item)) { + found = true; + break; + } + } + } + yield !found; + } + case "exists" -> expected == null || expected.asBoolean(true) == (actual != null); + case "gt", "gte", "lt", "lte" -> { + int cmp = compare(actual, expected); + yield switch (op) { + case "gt" -> cmp > 0; + case "gte" -> cmp >= 0; + case "lt" -> cmp < 0; + default -> cmp <= 0; + }; + } + case "contains" -> actual != null && expected != null + && String.valueOf(actual).contains(expected.asText()); + default -> throw ApiException.badRequest("UNSUPPORTED_RULE_OP", "不支持的规则运算符: " + op); + }; + } + + private boolean equalsValue(Object actual, JsonNode expected) { + if (actual == null) { + return expected == null || expected.isNull(); + } + if (expected == null || expected.isNull()) { + return false; + } + if (actual instanceof Boolean b) { + return expected.isBoolean() && expected.asBoolean() == b; + } + if (actual instanceof Number || actual instanceof BigDecimal) { + try { + return new BigDecimal(actual.toString()).compareTo(expected.decimalValue()) == 0; + } catch (Exception ignored) { + return Objects.equals(actual.toString(), expected.asText()); + } + } + return Objects.equals(String.valueOf(actual), expected.asText()); + } + + private int compare(Object actual, JsonNode expected) { + try { + return new BigDecimal(String.valueOf(actual)).compareTo(expected.decimalValue()); + } catch (Exception e) { + return String.valueOf(actual).compareTo(expected == null ? "" : expected.asText()); + } + } + + private List rules() { + return mappingRuleRepository.findAllByTenantIdAndStatusOrderByPriorityDesc( + TenantContext.getTenantId(), "ACTIVE"); + } + + private JsonNode parse(String json) { + try { + return json == null ? null : objectMapper.readTree(json); + } catch (Exception e) { + throw ApiException.badRequest("INVALID_RULE_JSON", "规则 JSON 无法解析: " + e.getMessage()); + } + } + + public Map factsView(Map facts) { + return new LinkedHashMap<>(facts); + } +} diff --git a/src/main/java/com/autovoucher/rule/RuleMatch.java b/src/main/java/com/autovoucher/rule/RuleMatch.java new file mode 100644 index 0000000..f4a32bf --- /dev/null +++ b/src/main/java/com/autovoucher/rule/RuleMatch.java @@ -0,0 +1,18 @@ +package com.autovoucher.rule; + +import com.autovoucher.domain.MappingRule; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +public record RuleMatch( + MappingRule rule, + int matchedConditionCount, + List matchedFields, + Map facts +) { + public BigDecimal priorityScore() { + return BigDecimal.valueOf(rule.getPriority() == null ? 0 : rule.getPriority()); + } +} diff --git a/src/main/java/com/autovoucher/seed/SeedCatalog.java b/src/main/java/com/autovoucher/seed/SeedCatalog.java new file mode 100644 index 0000000..532795a --- /dev/null +++ b/src/main/java/com/autovoucher/seed/SeedCatalog.java @@ -0,0 +1,123 @@ +package com.autovoucher.seed; + +import com.autovoucher.config.AppProperties; +import com.autovoucher.source.PayloadMapper; +import com.autovoucher.source.SourceDocPayload; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Read-only access to testdata/seed.json. Used both to bootstrap the database + * and to let the Mock OA adapter answer a fetch for a document that was not + * preloaded (the OA_VIEW_TRIGGERS_GENERATION scenario). + */ +@Component +public class SeedCatalog { + + private static final Logger log = LoggerFactory.getLogger(SeedCatalog.class); + + private final AppProperties properties; + private final ObjectMapper objectMapper; + private final PayloadMapper payloadMapper; + private volatile JsonNode root; + + public SeedCatalog(AppProperties properties, ObjectMapper objectMapper, PayloadMapper payloadMapper) { + this.properties = properties; + this.objectMapper = objectMapper; + this.payloadMapper = payloadMapper; + } + + public boolean available() { + return load() != null; + } + + public List master(String section) { + JsonNode root = load(); + if (root == null) { + return List.of(); + } + return toList(root.path("masterData").path(section)); + } + + public List sourceDocs() { + JsonNode root = load(); + return root == null ? List.of() : toList(root.path("sourceDocs")); + } + + public List sourceEvents() { + JsonNode root = load(); + return root == null ? List.of() : toList(root.path("sourceEvents")); + } + + public Optional document(String externalId) { + return sourceDocs().stream() + .filter(doc -> externalId.equals(doc.path("externalId").asText())) + .findFirst(); + } + + public Optional documentPayload(String externalId) { + return document(externalId).map(payloadMapper::fromJson); + } + + public int tenantId() { + JsonNode root = load(); + return root == null ? 1 : root.path("tenantId").asInt(1); + } + + private List toList(JsonNode array) { + List nodes = new ArrayList<>(); + if (array != null && array.isArray()) { + array.forEach(nodes::add); + } + return nodes; + } + + private JsonNode load() { + JsonNode local = root; + if (local != null) { + return local; + } + synchronized (this) { + if (root != null) { + return root; + } + Path path = resolvePath(); + if (path == null) { + log.warn("Seed file not found at '{}'; mock catalog is unavailable", + properties.getSeed().getPath()); + return null; + } + try { + root = objectMapper.readTree(Files.readString(path)); + log.info("Loaded seed catalog from {}", path.toAbsolutePath()); + return root; + } catch (Exception e) { + log.error("Unable to read seed file {}", path, e); + return null; + } + } + } + + private Path resolvePath() { + String configured = properties.getSeed().getPath(); + List candidates = new ArrayList<>(); + if (configured != null && !configured.isBlank()) { + candidates.add(Path.of(configured)); + if (!Path.of(configured).isAbsolute()) { + candidates.add(Path.of("..").resolve(configured)); + } + } + candidates.add(Path.of("../testdata/seed.json")); + candidates.add(Path.of("testdata/seed.json")); + return candidates.stream().filter(Files::isRegularFile).findFirst().orElse(null); + } +} diff --git a/src/main/java/com/autovoucher/seed/SeedLoader.java b/src/main/java/com/autovoucher/seed/SeedLoader.java new file mode 100644 index 0000000..3f5326e --- /dev/null +++ b/src/main/java/com/autovoucher/seed/SeedLoader.java @@ -0,0 +1,379 @@ +package com.autovoucher.seed; + +import com.autovoucher.config.AppProperties; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Account; +import com.autovoucher.domain.AccountGroup; +import com.autovoucher.domain.AuxItem; +import com.autovoucher.domain.AuxValue; +import com.autovoucher.domain.Book; +import com.autovoucher.domain.Company; +import com.autovoucher.domain.ExpenseCategory; +import com.autovoucher.domain.MappingRule; +import com.autovoucher.domain.SourceEvent; +import com.autovoucher.domain.SourceSystem; +import com.autovoucher.domain.TargetSystem; +import com.autovoucher.domain.VoucherType; +import com.autovoucher.repository.AccountGroupRepository; +import com.autovoucher.repository.AccountRepository; +import com.autovoucher.repository.AuxItemRepository; +import com.autovoucher.repository.AuxValueRepository; +import com.autovoucher.repository.BookRepository; +import com.autovoucher.repository.CompanyRepository; +import com.autovoucher.repository.ExpenseCategoryRepository; +import com.autovoucher.repository.MappingRuleRepository; +import com.autovoucher.repository.SourceDocRepository; +import com.autovoucher.repository.SourceEventRepository; +import com.autovoucher.repository.SourceSystemRepository; +import com.autovoucher.repository.TargetSystemRepository; +import com.autovoucher.repository.VoucherTypeRepository; +import com.autovoucher.service.SourceIngestService; +import com.autovoucher.source.PayloadMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; + +/** + * Imports the local master data and demo OA documents described by + * testdata/seed.json. Every insert is keyed by business code, so restarting the + * application is safe. Real deployments disable this via av.seed.enabled=false + * and sync master data from NCC instead. + */ +@Component +@ConditionalOnProperty(prefix = "av.seed", name = "enabled", havingValue = "true", matchIfMissing = true) +public class SeedLoader implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(SeedLoader.class); + private static final Long TENANT_ID = 1L; + + private final SeedCatalog catalog; + private final AppProperties properties; + private final ObjectMapper objectMapper; + private final PayloadMapper payloadMapper; + private final CompanyRepository companyRepository; + private final BookRepository bookRepository; + private final TargetSystemRepository targetSystemRepository; + private final VoucherTypeRepository voucherTypeRepository; + private final AccountRepository accountRepository; + private final AccountGroupRepository accountGroupRepository; + private final AuxItemRepository auxItemRepository; + private final AuxValueRepository auxValueRepository; + private final ExpenseCategoryRepository expenseCategoryRepository; + private final MappingRuleRepository mappingRuleRepository; + private final SourceSystemRepository sourceSystemRepository; + private final SourceDocRepository sourceDocRepository; + private final SourceEventRepository sourceEventRepository; + private final SourceIngestService sourceIngestService; + + public SeedLoader(SeedCatalog catalog, + AppProperties properties, + ObjectMapper objectMapper, + PayloadMapper payloadMapper, + CompanyRepository companyRepository, + BookRepository bookRepository, + TargetSystemRepository targetSystemRepository, + VoucherTypeRepository voucherTypeRepository, + AccountRepository accountRepository, + AccountGroupRepository accountGroupRepository, + AuxItemRepository auxItemRepository, + AuxValueRepository auxValueRepository, + ExpenseCategoryRepository expenseCategoryRepository, + MappingRuleRepository mappingRuleRepository, + SourceSystemRepository sourceSystemRepository, + SourceDocRepository sourceDocRepository, + SourceEventRepository sourceEventRepository, + SourceIngestService sourceIngestService) { + this.catalog = catalog; + this.properties = properties; + this.objectMapper = objectMapper; + this.payloadMapper = payloadMapper; + this.companyRepository = companyRepository; + this.bookRepository = bookRepository; + this.targetSystemRepository = targetSystemRepository; + this.voucherTypeRepository = voucherTypeRepository; + this.accountRepository = accountRepository; + this.accountGroupRepository = accountGroupRepository; + this.auxItemRepository = auxItemRepository; + this.auxValueRepository = auxValueRepository; + this.expenseCategoryRepository = expenseCategoryRepository; + this.mappingRuleRepository = mappingRuleRepository; + this.sourceSystemRepository = sourceSystemRepository; + this.sourceDocRepository = sourceDocRepository; + this.sourceEventRepository = sourceEventRepository; + this.sourceIngestService = sourceIngestService; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (!catalog.available()) { + log.warn("Seed enabled but no seed file is readable; skipping seed"); + return; + } + TenantContext.setTenantId(TENANT_ID); + try { + seedMasterData(); + seedDocuments(); + seedEvents(); + log.info("Seed import finished"); + } finally { + TenantContext.clear(); + } + } + + private void seedMasterData() { + for (JsonNode node : catalog.master("companies")) { + String id = node.path("companyId").asText(); + if (companyRepository.findByTenantIdAndCompanyId(TENANT_ID, id).isPresent()) { + continue; + } + Company entity = new Company(); + entity.setTenantId(TENANT_ID); + entity.setCompanyId(id); + entity.setCompanyCode(node.path("companyCode").asText()); + entity.setCompanyName(node.path("companyName").asText()); + entity.setStatus(node.path("status").asText("ACTIVE")); + companyRepository.save(entity); + } + + for (JsonNode node : catalog.master("books")) { + String id = node.path("bookId").asText(); + if (bookRepository.findByTenantIdAndBookId(TENANT_ID, id).isPresent()) { + continue; + } + Book entity = new Book(); + entity.setTenantId(TENANT_ID); + entity.setBookId(id); + entity.setCompanyId(node.path("companyId").asText()); + entity.setBookCode(node.path("bookCode").asText()); + entity.setBookName(node.path("bookName").asText()); + entity.setCurrency(node.path("currency").asText("CNY")); + entity.setCurrentPeriod(node.path("currentPeriod").asText()); + entity.setTargetSystem(node.path("targetSystem").asText()); + entity.setStatus(node.path("status").asText("ACTIVE")); + bookRepository.save(entity); + } + + for (JsonNode node : catalog.master("targetSystems")) { + String code = node.path("targetSystem").asText(); + if (targetSystemRepository.findByTenantIdAndTargetSystem(TENANT_ID, code).isPresent()) { + continue; + } + TargetSystem entity = new TargetSystem(); + entity.setTenantId(TENANT_ID); + entity.setTargetSystem(code); + entity.setName(node.path("name").asText()); + entity.setAdapterCode(node.path("adapterCode").asText(code)); + entity.setStatus(node.path("status").asText("DISABLED")); + entity.setDryRun(node.path("dryRun").asBoolean(true)); + targetSystemRepository.save(entity); + } + + for (JsonNode node : catalog.master("voucherTypes")) { + String code = node.path("code").asText(); + if (voucherTypeRepository.findByTenantIdAndCode(TENANT_ID, code).isPresent()) { + continue; + } + VoucherType entity = new VoucherType(); + entity.setTenantId(TENANT_ID); + entity.setVoucherTypeId(node.path("voucherTypeId").asText()); + entity.setCode(code); + entity.setName(node.path("name").asText()); + entity.setExternalCodesJson(json(node.get("externalCodes"))); + entity.setNote(text(node, "note")); + voucherTypeRepository.save(entity); + } + + for (JsonNode node : catalog.master("accounts")) { + String code = node.path("code").asText(); + String companyId = node.path("companyId").asText(); + String bookId = node.path("bookId").asText(); + if (accountRepository.findByTenantIdAndCompanyIdAndBookIdAndCode( + TENANT_ID, companyId, bookId, code).isPresent()) { + continue; + } + Account entity = new Account(); + entity.setTenantId(TENANT_ID); + entity.setAccountId(node.path("accountId").asText()); + entity.setCompanyId(companyId); + entity.setBookId(bookId); + entity.setCode(code); + entity.setName(node.path("name").asText()); + entity.setParentCode(text(node, "parentCode")); + entity.setLevel(node.path("level").asInt()); + entity.setIsLeaf(node.path("isLeaf").asBoolean(true)); + entity.setDirection(node.path("direction").asText("DEBIT")); + entity.setStatus(node.path("status").asText("ACTIVE")); + entity.setAuxRequiredJson(json(node.get("auxRequired"))); + entity.setExternalCodesJson(json(node.get("externalCodes"))); + accountRepository.save(entity); + } + + for (JsonNode node : catalog.master("accountGroups")) { + String code = node.path("groupCode").asText(); + if (accountGroupRepository.findByTenantIdAndGroupCode(TENANT_ID, code).isPresent()) { + continue; + } + AccountGroup entity = new AccountGroup(); + entity.setTenantId(TENANT_ID); + entity.setGroupCode(code); + entity.setName(node.path("name").asText()); + entity.setAccountByDepartmentAttrJson(json(node.get("accountByDepartmentAttr"))); + entity.setFixedAccount(text(node, "fixedAccount")); + accountGroupRepository.save(entity); + } + + for (JsonNode node : catalog.master("auxItems")) { + String type = node.path("auxType").asText(); + if (auxItemRepository.findByTenantIdAndAuxType(TENANT_ID, type).isPresent()) { + continue; + } + AuxItem entity = new AuxItem(); + entity.setTenantId(TENANT_ID); + entity.setAuxType(type); + entity.setName(node.path("name").asText()); + entity.setExternalCodesJson(json(node.get("externalCodes"))); + auxItemRepository.save(entity); + } + + for (JsonNode node : catalog.master("auxValues")) { + String type = node.path("auxType").asText(); + String code = node.path("code").asText(); + if (auxValueRepository.findByTenantIdAndAuxTypeAndCode(TENANT_ID, type, code).isPresent()) { + continue; + } + AuxValue entity = new AuxValue(); + entity.setTenantId(TENANT_ID); + entity.setAuxType(type); + entity.setCode(code); + entity.setName(node.path("name").asText()); + entity.setAttributesJson(json(node.get("attributes"))); + entity.setExternalCodesJson(json(node.get("externalCodes"))); + entity.setStatus(node.path("status").asText("ACTIVE")); + auxValueRepository.save(entity); + } + + for (JsonNode node : catalog.master("expenseCategories")) { + String code = node.path("code").asText(); + if (expenseCategoryRepository.findByTenantIdAndCode(TENANT_ID, code).isPresent()) { + continue; + } + ExpenseCategory entity = new ExpenseCategory(); + entity.setTenantId(TENANT_ID); + entity.setCode(code); + entity.setName(node.path("name").asText()); + expenseCategoryRepository.save(entity); + } + + for (JsonNode node : catalog.master("mappingRules")) { + String code = node.path("ruleCode").asText(); + int version = node.path("version").asInt(1); + if (mappingRuleRepository.findByTenantIdAndRuleCodeAndVersion(TENANT_ID, code, version).isPresent()) { + continue; + } + MappingRule entity = new MappingRule(); + entity.setTenantId(TENANT_ID); + entity.setRuleCode(code); + entity.setName(node.path("name").asText()); + entity.setPriority(node.path("priority").asInt(0)); + entity.setConditionJson(json(node.get("condition"))); + entity.setResultJson(json(node.get("result"))); + entity.setVersion(version); + entity.setStatus(node.path("status").asText("ACTIVE")); + mappingRuleRepository.save(entity); + } + + for (JsonNode node : catalog.master("sourceSystems")) { + String code = node.path("sourceSystem").asText(); + if (sourceSystemRepository.findByTenantIdAndSourceSystem(TENANT_ID, code).isPresent()) { + continue; + } + SourceSystem entity = new SourceSystem(); + entity.setTenantId(TENANT_ID); + entity.setSourceSystem(code); + entity.setName(node.path("name").asText()); + entity.setAdapterCode(node.path("adapterCode").asText(code)); + entity.setStatus(node.path("status").asText("DISABLED")); + entity.setMock(node.path("mock").asBoolean(false)); + entity.setNote(text(node, "note")); + sourceSystemRepository.save(entity); + } + } + + private void seedDocuments() { + for (JsonNode node : catalog.sourceDocs()) { + String sourceSystem = node.path("sourceSystem").asText(); + String externalId = node.path("externalId").asText(); + if (sourceDocRepository.findByTenantIdAndSourceSystemAndExternalId( + TENANT_ID, sourceSystem, externalId).isPresent()) { + continue; + } + sourceIngestService.upsert(payloadMapper.fromJson(node)); + } + } + + private void seedEvents() { + for (JsonNode node : catalog.sourceEvents()) { + String eventId = node.path("eventId").asText(); + SourceEvent event = sourceEventRepository.findByTenantIdAndEventId(TENANT_ID, eventId) + .orElseGet(() -> { + SourceEvent created = new SourceEvent(); + created.setTenantId(TENANT_ID); + created.setEventId(eventId); + created.setSourceSystem(node.path("sourceSystem").asText()); + created.setEventType(node.path("eventType").asText()); + created.setExternalId(node.path("externalId").asText()); + created.setStatus("RECEIVED"); + created.setReceivedAt(parseTime(node.path("receivedAt").asText(null))); + return sourceEventRepository.save(created); + }); + if (properties.getSeed().isAutoIngest() && "RECEIVED".equals(event.getStatus())) { + try { + sourceIngestService.processEvent(event); + } catch (Exception e) { + event.setStatus("FAILED"); + event.setMessage(e.getMessage()); + event.setProcessedAt(LocalDateTime.now()); + sourceEventRepository.save(event); + log.error("Failed to ingest seed event {}", eventId, e); + } + } + } + } + + private String json(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + try { + return objectMapper.writeValueAsString(node); + } catch (Exception e) { + return null; + } + } + + private String text(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } + + private LocalDateTime parseTime(String value) { + if (value == null) { + return null; + } + try { + return OffsetDateTime.parse(value).toLocalDateTime(); + } catch (Exception e) { + return LocalDateTime.parse(value); + } + } +} diff --git a/src/main/java/com/autovoucher/service/AccountResolver.java b/src/main/java/com/autovoucher/service/AccountResolver.java new file mode 100644 index 0000000..0595fd7 --- /dev/null +++ b/src/main/java/com/autovoucher/service/AccountResolver.java @@ -0,0 +1,81 @@ +package com.autovoucher.service; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Account; +import com.autovoucher.domain.AccountGroup; +import com.autovoucher.domain.AuxValue; +import com.autovoucher.repository.AccountGroupRepository; +import com.autovoucher.repository.AccountRepository; +import com.autovoucher.repository.AuxValueRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class AccountResolver { + + private final AccountGroupRepository accountGroupRepository; + private final AccountRepository accountRepository; + private final AuxValueRepository auxValueRepository; + private final ObjectMapper objectMapper; + + public AccountResolver(AccountGroupRepository accountGroupRepository, + AccountRepository accountRepository, + AuxValueRepository auxValueRepository, + ObjectMapper objectMapper) { + this.accountGroupRepository = accountGroupRepository; + this.accountRepository = accountRepository; + this.auxValueRepository = auxValueRepository; + this.objectMapper = objectMapper; + } + + public ResolvedAccount resolve(String groupCode, String companyId, String bookId, String departmentId) { + Long tenantId = TenantContext.getTenantId(); + AccountGroup group = accountGroupRepository.findByTenantIdAndGroupCode(tenantId, groupCode) + .orElseThrow(() -> ApiException.badRequest("ACCOUNT_GROUP_NOT_FOUND", "科目组不存在: " + groupCode)); + + String accountCode; + if (group.getFixedAccount() != null && !group.getFixedAccount().isBlank()) { + accountCode = group.getFixedAccount(); + } else { + accountCode = resolveByDepartment(group, departmentId); + } + if (accountCode == null) { + throw ApiException.badRequest("ACCOUNT_NOT_RESOLVED", + "无法根据部门解析科目组 " + groupCode + " 的末级科目"); + } + + Account account = accountRepository + .findByTenantIdAndCompanyIdAndBookIdAndCode(tenantId, companyId, bookId, accountCode) + .orElseThrow(() -> ApiException.badRequest("ACCOUNT_NOT_FOUND", + "科目不存在: " + accountCode + "(公司 " + companyId + " / 账簿 " + bookId + ")")); + return new ResolvedAccount(account, groupCode); + } + + private String resolveByDepartment(AccountGroup group, String departmentId) { + if (group.getAccountByDepartmentAttrJson() == null || departmentId == null) { + return null; + } + Optional department = auxValueRepository + .findByTenantIdAndAuxTypeAndCode(TenantContext.getTenantId(), "DEPT", departmentId); + if (department.isEmpty()) { + return null; + } + try { + JsonNode attrs = objectMapper.readTree(department.get().getAttributesJson()); + String expenseAttr = attrs.path("expenseAttr").asText(null); + if (expenseAttr == null) { + return null; + } + JsonNode mapping = objectMapper.readTree(group.getAccountByDepartmentAttrJson()); + JsonNode account = mapping.get(expenseAttr); + return account == null ? null : account.asText(); + } catch (Exception e) { + throw ApiException.badRequest("ACCOUNT_GROUP_JSON_INVALID", + "科目组部门映射配置无法解析: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/autovoucher/service/AuditService.java b/src/main/java/com/autovoucher/service/AuditService.java new file mode 100644 index 0000000..5d1bfab --- /dev/null +++ b/src/main/java/com/autovoucher/service/AuditService.java @@ -0,0 +1,37 @@ +package com.autovoucher.service; + +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.AuditLog; +import com.autovoucher.repository.AuditLogRepository; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +public class AuditService { + + private final AuditLogRepository auditLogRepository; + + public AuditService(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + public void log(String action, String entityType, Object entityId, String detailJson) { + AuditLog log = new AuditLog(); + log.setTenantId(TenantContext.getTenantId()); + log.setAction(action); + log.setEntityType(entityType); + log.setEntityId(entityId == null ? null : String.valueOf(entityId)); + log.setOperatorId(currentOperator()); + log.setDetailJson(detailJson); + auditLogRepository.save(log); + } + + public String currentOperator() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return "system"; + } + return authentication.getName(); + } +} diff --git a/src/main/java/com/autovoucher/service/DocumentProcessingService.java b/src/main/java/com/autovoucher/service/DocumentProcessingService.java new file mode 100644 index 0000000..193af08 --- /dev/null +++ b/src/main/java/com/autovoucher/service/DocumentProcessingService.java @@ -0,0 +1,253 @@ +package com.autovoucher.service; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Book; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.SourceLine; +import com.autovoucher.domain.Transaction; +import com.autovoucher.domain.TransactionLine; +import com.autovoucher.domain.Voucher; +import com.autovoucher.domain.VoucherLine; +import com.autovoucher.domain.VoucherType; +import com.autovoucher.repository.BookRepository; +import com.autovoucher.repository.SourceDocRepository; +import com.autovoucher.repository.SourceLineRepository; +import com.autovoucher.repository.TransactionLineRepository; +import com.autovoucher.repository.TransactionRepository; +import com.autovoucher.repository.VoucherLineRepository; +import com.autovoucher.repository.VoucherRepository; +import com.autovoucher.repository.VoucherTypeRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +@Service +public class DocumentProcessingService { + + public static final String RECEIVED = "RECEIVED"; + public static final String IGNORED = "IGNORED"; + public static final String MANUAL_REQUIRED = "MANUAL_REQUIRED"; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + + private final SourceDocRepository sourceDocRepository; + private final SourceLineRepository sourceLineRepository; + private final VoucherRepository voucherRepository; + private final VoucherLineRepository voucherLineRepository; + private final VoucherTypeRepository voucherTypeRepository; + private final TransactionRepository transactionRepository; + private final TransactionLineRepository transactionLineRepository; + private final BookRepository bookRepository; + private final VoucherSuggestionService suggestionService; + private final VoucherNumberService voucherNumberService; + private final AuditService auditService; + private final ObjectMapper objectMapper; + + public DocumentProcessingService(SourceDocRepository sourceDocRepository, + SourceLineRepository sourceLineRepository, + VoucherRepository voucherRepository, + VoucherLineRepository voucherLineRepository, + VoucherTypeRepository voucherTypeRepository, + TransactionRepository transactionRepository, + TransactionLineRepository transactionLineRepository, + BookRepository bookRepository, + VoucherSuggestionService suggestionService, + VoucherNumberService voucherNumberService, + AuditService auditService, + ObjectMapper objectMapper) { + this.sourceDocRepository = sourceDocRepository; + this.sourceLineRepository = sourceLineRepository; + this.voucherRepository = voucherRepository; + this.voucherLineRepository = voucherLineRepository; + this.voucherTypeRepository = voucherTypeRepository; + this.transactionRepository = transactionRepository; + this.transactionLineRepository = transactionLineRepository; + this.bookRepository = bookRepository; + this.suggestionService = suggestionService; + this.voucherNumberService = voucherNumberService; + this.auditService = auditService; + this.objectMapper = objectMapper; + } + + @Transactional + public ProcessingOutcome process(SourceDoc requestedDoc, boolean forceReprocess) { + Long tenantId = TenantContext.getTenantId(); + // Reprocess passes a document loaded outside this transaction; with + // open-in-view disabled it is detached, so re-attach it before mutating + // its processing status. + SourceDoc doc = sourceDocRepository.findById(requestedDoc.getId()) + .filter(d -> tenantId.equals(d.getTenantId())) + .orElseThrow(() -> ApiException.notFound("SOURCE_DOC_NOT_FOUND", + "源单据不存在: " + requestedDoc.getId())); + + if (doc.getDocType() != null && doc.getDocType().startsWith("PAYMENT_")) { + doc.setProcessingStatus(IGNORED); + doc.setManualReason("PAYMENT_DOCUMENT_HANDLED_IN_FINANCE_SYSTEM"); + return ProcessingOutcome.ignored("付款单据由财务系统处理,不生成费用凭证"); + } + + var current = voucherRepository.findByTenantIdAndSourceDocIdAndCurrentFlag(tenantId, doc.getId(), 1); + if (current.isPresent() && !forceReprocess) { + Voucher voucher = current.get(); + return new ProcessingOutcome("DUPLICATE_IGNORED", voucher.getId(), + "当前单据已有有效凭证: " + voucher.getVoucherNo(), List.of()); + } + if (current.isPresent()) { + Voucher existing = current.get(); + if (List.of("APPROVED", "PUSHED", "SUCCESS", "SUCCESS_WITH_EXISTING").contains(existing.getStatus())) { + throw ApiException.conflict("VOUCHER_ALREADY_APPROVED", + "凭证 " + existing.getVoucherNo() + " 已审核或已推送,不能重新生成"); + } + existing.setCurrentFlag(null); + existing.setStatus("SUPERSEDED"); + voucherRepository.save(existing); + } + + List sourceLines = sourceLineRepository + .findAllByTenantIdAndSourceDocIdOrderByLineNoAsc(tenantId, doc.getId()); + if (sourceLines.isEmpty()) { + doc.setProcessingStatus(MANUAL_REQUIRED); + doc.setManualReason("NO_SOURCE_LINES"); + return new ProcessingOutcome(MANUAL_REQUIRED, null, "单据没有费用明细", List.of()); + } + + Book book = bookRepository.findByTenantIdAndBookId(tenantId, doc.getBookId()) + .orElseThrow(() -> ApiException.badRequest("BOOK_NOT_FOUND", "账簿不存在: " + doc.getBookId())); + + Transaction transaction = rebuildTransaction(doc, sourceLines); + SuggestionOutcome suggestion = suggestionService.suggest(doc, sourceLines); + + if (suggestion.manualRequired()) { + transaction.setStatus(MANUAL_REQUIRED); + transactionRepository.save(transaction); + doc.setProcessingStatus(MANUAL_REQUIRED); + doc.setManualReason(suggestion.manualReason()); + doc.setCandidatesJson(toJson(suggestion.candidates())); + auditService.log("VOUCHER_MANUAL_REQUIRED", "SOURCE_DOC", doc.getId(), + "{\"reason\":\"" + suggestion.manualReason() + "\"}"); + return new ProcessingOutcome(MANUAL_REQUIRED, null, + suggestion.manualReason(), suggestion.candidates()); + } + + int version = voucherRepository + .findAllByTenantIdAndSourceDocIdOrderByIdDesc(tenantId, doc.getId()).size() + 1; + VoucherType voucherType = voucherTypeRepository.findByTenantIdAndCode(tenantId, "JZ") + .or(() -> voucherTypeRepository.findAllByTenantIdOrderByIdAsc(tenantId).stream().findFirst()) + .orElseThrow(() -> ApiException.badRequest("VOUCHER_TYPE_NOT_FOUND", "未配置凭证类型")); + + Voucher voucher = new Voucher(); + voucher.setTenantId(tenantId); + voucher.setVoucherNo(voucherNumberService.next(doc, book, version)); + voucher.setSourceDocId(doc.getId()); + voucher.setTransactionId(transaction.getId()); + voucher.setVoucherTypeCode(voucherType.getCode()); + voucher.setCompanyId(doc.getCompanyId()); + voucher.setBookId(doc.getBookId()); + voucher.setVoucherDate(doc.getApprovedAt() == null + ? LocalDate.now() : doc.getApprovedAt().toLocalDate()); + voucher.setPeriod(book.getCurrentPeriod()); + voucher.setCurrency(doc.getCurrency() == null ? "CNY" : doc.getCurrency()); + voucher.setStatus(PENDING_REVIEW); + voucher.setVersion(version); + voucher.setCurrentFlag(1); + voucher.setConfidence(suggestion.confidence()); + voucher.setDecisionTraceJson(suggestion.traceJson()); + voucher.setTargetSystem(book.getTargetSystem()); + voucherRepository.save(voucher); + + BigDecimal debit = BigDecimal.ZERO; + BigDecimal credit = BigDecimal.ZERO; + int lineNo = 1; + for (SuggestedLine suggested : suggestion.lines()) { + VoucherLine line = new VoucherLine(); + line.setTenantId(tenantId); + line.setVoucherId(voucher.getId()); + line.setLineNo(lineNo++); + line.setDirection(suggested.direction()); + line.setAccountCode(suggested.accountCode()); + line.setAccountName(suggested.accountName()); + line.setAccountGroup(suggested.accountGroup()); + line.setAmount(suggested.amount()); + line.setCurrency(voucher.getCurrency()); + line.setTaxRate(suggested.taxRate()); + line.setTaxAmount(suggested.taxAmount()); + line.setAuxJson(toJson(suggested.aux())); + line.setSummary(suggested.summary()); + line.setDecisionSource(suggested.decisionSource()); + line.setConfidence(suggested.confidence()); + line.setEvidenceJson(suggested.evidenceJson()); + voucherLineRepository.save(line); + + if ("DEBIT".equals(suggested.direction())) { + debit = debit.add(suggested.amount()); + } else { + credit = credit.add(suggested.amount()); + } + } + voucher.setTotalDebit(debit); + voucher.setTotalCredit(credit); + voucherRepository.save(voucher); + + transaction.setStatus("GENERATED"); + transactionRepository.save(transaction); + + doc.setProcessingStatus(PENDING_REVIEW); + doc.setManualReason(null); + doc.setCandidatesJson(null); + + auditService.log("VOUCHER_GENERATED", "VOUCHER", voucher.getId(), + "{\"voucherNo\":\"" + voucher.getVoucherNo() + "\",\"sourceDocId\":" + doc.getId() + "}"); + return new ProcessingOutcome(PENDING_REVIEW, voucher.getId(), + "已生成凭证建议 " + voucher.getVoucherNo(), List.of()); + } + + private Transaction rebuildTransaction(SourceDoc doc, List lines) { + Long tenantId = TenantContext.getTenantId(); + transactionRepository.findByTenantIdAndSourceDocId(tenantId, doc.getId()).ifPresent(existing -> { + transactionLineRepository.deleteAllByTenantIdAndTransactionId(tenantId, existing.getId()); + transactionRepository.delete(existing); + }); + + Transaction transaction = new Transaction(); + transaction.setTenantId(tenantId); + transaction.setSourceDocId(doc.getId()); + transaction.setBusinessScene(doc.getBusinessScene()); + transaction.setIsBusinessTrip(doc.getIsBusinessTrip()); + transaction.setTripId(doc.getTripId()); + transaction.setDepartmentId(doc.getDepartmentId()); + transaction.setApplicantId(doc.getApplicantId()); + transaction.setTotalAmount(doc.getTotalAmount()); + transaction.setStatus("EXTRACTED"); + transactionRepository.save(transaction); + + for (SourceLine sourceLine : lines) { + TransactionLine line = new TransactionLine(); + line.setTenantId(tenantId); + line.setTransactionId(transaction.getId()); + line.setLineNo(sourceLine.getLineNo()); + line.setExpenseCategory(sourceLine.getExpenseCategory()); + line.setAmount(sourceLine.getAmount()); + line.setAmountExcludingTax(sourceLine.getAmountExcludingTax()); + line.setTaxAmount(sourceLine.getTaxAmount()); + line.setInvoiceType(sourceLine.getInvoiceType()); + line.setProjectId(sourceLine.getProjectId()); + line.setCostCenterId(sourceLine.getCostCenterId()); + line.setMerchantId(sourceLine.getMerchantId()); + line.setCustomerId(sourceLine.getCustomerId()); + transactionLineRepository.save(line); + } + return transaction; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + return "[]"; + } + } +} diff --git a/src/main/java/com/autovoucher/service/IngestResult.java b/src/main/java/com/autovoucher/service/IngestResult.java new file mode 100644 index 0000000..8b30317 --- /dev/null +++ b/src/main/java/com/autovoucher/service/IngestResult.java @@ -0,0 +1,13 @@ +package com.autovoucher.service; + +import java.util.List; + +public record IngestResult( + String eventId, + String status, + Long sourceDocId, + Long voucherId, + String message, + List candidates +) { +} diff --git a/src/main/java/com/autovoucher/service/OaViewAuditService.java b/src/main/java/com/autovoucher/service/OaViewAuditService.java new file mode 100644 index 0000000..f079bef --- /dev/null +++ b/src/main/java/com/autovoucher/service/OaViewAuditService.java @@ -0,0 +1,62 @@ +package com.autovoucher.service; + +import com.autovoucher.domain.OaViewLog; +import com.autovoucher.domain.OaViewTicket; +import com.autovoucher.repository.OaViewLogRepository; +import com.autovoucher.repository.OaViewTicketRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * Denials and expirations must survive the exception that ends the request, so + * they are written in a separate transaction. + */ +@Service +public class OaViewAuditService { + + private final OaViewLogRepository logRepository; + private final OaViewTicketRepository ticketRepository; + + public OaViewAuditService(OaViewLogRepository logRepository, + OaViewTicketRepository ticketRepository) { + this.logRepository = logRepository; + this.ticketRepository = ticketRepository; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void log(Long tenantId, Long ticketId, String sourceSystem, String externalId, + String operatorId, String action, String result, String ip, String userAgent) { + OaViewLog entry = new OaViewLog(); + entry.setTenantId(tenantId); + entry.setTicketId(ticketId); + entry.setSourceSystem(sourceSystem); + entry.setExternalId(externalId); + entry.setOperatorId(operatorId); + entry.setAction(action); + entry.setResult(result); + entry.setIp(ip); + entry.setUserAgent(truncate(userAgent, 500)); + logRepository.save(entry); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markExpired(Long tenantId, Long ticketId) { + ticketRepository.findById(ticketId) + .filter(t -> tenantId.equals(t.getTenantId())) + .ifPresent(ticket -> { + ticket.setStatus("EXPIRED"); + ticket.setUpdatedAt(LocalDateTime.now()); + ticketRepository.save(ticket); + }); + } + + private String truncate(String value, int max) { + if (value == null || value.length() <= max) { + return value; + } + return value.substring(0, max); + } +} diff --git a/src/main/java/com/autovoucher/service/OaViewPage.java b/src/main/java/com/autovoucher/service/OaViewPage.java new file mode 100644 index 0000000..cb562c2 --- /dev/null +++ b/src/main/java/com/autovoucher/service/OaViewPage.java @@ -0,0 +1,18 @@ +package com.autovoucher.service; + +import com.autovoucher.domain.OaViewTicket; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.Voucher; +import com.autovoucher.domain.VoucherLine; + +import java.util.List; + +public record OaViewPage( + OaViewTicket ticket, + SourceDoc doc, + Voucher voucher, + List lines, + List candidates, + String message +) { +} diff --git a/src/main/java/com/autovoucher/service/OaViewService.java b/src/main/java/com/autovoucher/service/OaViewService.java new file mode 100644 index 0000000..be1e4e0 --- /dev/null +++ b/src/main/java/com/autovoucher/service/OaViewService.java @@ -0,0 +1,440 @@ +package com.autovoucher.service; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.AppProperties; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Nonce; +import com.autovoucher.domain.OaViewLog; +import com.autovoucher.domain.OaViewTicket; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.Voucher; +import com.autovoucher.domain.VoucherLine; +import com.autovoucher.dto.OaStatusResponse; +import com.autovoucher.dto.OaTicketRequest; +import com.autovoucher.dto.OaTicketResponse; +import com.autovoucher.repository.NonceRepository; +import com.autovoucher.repository.OaViewLogRepository; +import com.autovoucher.repository.OaViewTicketRepository; +import com.autovoucher.repository.VoucherLineRepository; +import com.autovoucher.repository.VoucherRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; + +/** + * Implements the OA 【查看凭证】 exchange. + * + * The OA backend signs a short canonical string with the shared app secret and + * calls POST /open/oa/view-ticket. The response contains an opaque, short-lived + * token; only its SHA-256 hash is stored, so neither the database nor the logs + * can be replayed to open another user's voucher. + */ +@Service +public class OaViewService { + + private static final Logger log = LoggerFactory.getLogger(OaViewService.class); + private static final String SEEYON = "SEEYON"; + private static final int TOKEN_BYTES = 32; + + private final OaViewTicketRepository ticketRepository; + private final OaViewLogRepository logRepository; + private final OaViewAuditService auditService; + private final NonceRepository nonceRepository; + private final VoucherRepository voucherRepository; + private final VoucherLineRepository voucherLineRepository; + private final SourceIngestService sourceIngestService; + private final DocumentProcessingService documentProcessingService; + private final AppProperties properties; + private final ObjectMapper objectMapper; + private final SecureRandom secureRandom = new SecureRandom(); + + public OaViewService(OaViewTicketRepository ticketRepository, + OaViewLogRepository logRepository, + OaViewAuditService auditService, + NonceRepository nonceRepository, + VoucherRepository voucherRepository, + VoucherLineRepository voucherLineRepository, + SourceIngestService sourceIngestService, + DocumentProcessingService documentProcessingService, + AppProperties properties, + ObjectMapper objectMapper) { + this.ticketRepository = ticketRepository; + this.logRepository = logRepository; + this.auditService = auditService; + this.nonceRepository = nonceRepository; + this.voucherRepository = voucherRepository; + this.voucherLineRepository = voucherLineRepository; + this.sourceIngestService = sourceIngestService; + this.documentProcessingService = documentProcessingService; + this.properties = properties; + this.objectMapper = objectMapper; + } + + /** + * Signed entry point used by the OA backend (致远). + */ + @Transactional + public OaTicketResponse issueSigned(OaTicketRequest request, String ip, String userAgent) { + Long tenantId = TenantContext.getTenantId(); + String sourceSystem = normalize(request.sourceSystem()); + String operatorId = request.operatorId(); + if (sourceSystem == null || request.externalId() == null || operatorId == null + || request.timestamp() == null || request.nonce() == null || request.signature() == null) { + auditService.log(tenantId, null, sourceSystem, request.externalId(), operatorId, + "ISSUE", "DENIED_INVALID_REQUEST", ip, userAgent); + throw ApiException.badRequest("OA_REQUEST_INVALID", "查看凭证请求缺少必要参数"); + } + + String secret = secretFor(sourceSystem); + if (secret == null) { + auditService.log(tenantId, null, sourceSystem, request.externalId(), operatorId, + "ISSUE", "DENIED_UNKNOWN_SOURCE", ip, userAgent); + throw ApiException.badRequest("OA_SOURCE_NOT_SUPPORTED", "不支持的OA来源: " + sourceSystem); + } + + Instant requestTime; + try { + requestTime = parseTimestamp(request.timestamp()); + } catch (RuntimeException e) { + auditService.log(tenantId, null, sourceSystem, request.externalId(), operatorId, + "ISSUE", "DENIED_INVALID_TIMESTAMP", ip, userAgent); + throw new ApiException(HttpStatus.UNAUTHORIZED, "OA_SIGNATURE_INVALID", "签名时间戳不合法"); + } + + long skewSeconds = properties.getOa().getTimestampSkewSeconds(); + if (Math.abs(Duration.between(requestTime, Instant.now()).getSeconds()) > skewSeconds) { + auditService.log(tenantId, null, sourceSystem, request.externalId(), operatorId, + "ISSUE", "DENIED_CLOCK_SKEW", ip, userAgent); + throw new ApiException(HttpStatus.UNAUTHORIZED, "OA_SIGNATURE_EXPIRED", + "签名已过期,请检查OA与本系统时钟"); + } + + String canonical = canonical(sourceSystem, request.externalId(), operatorId, + request.timestamp(), request.nonce()); + if (!constantTimeEquals(sign(canonical, secret), request.signature())) { + auditService.log(tenantId, null, sourceSystem, request.externalId(), operatorId, + "ISSUE", "DENIED_SIGNATURE", ip, userAgent); + throw new ApiException(HttpStatus.UNAUTHORIZED, "OA_SIGNATURE_INVALID", "签名校验失败"); + } + + if (nonceRepository.existsByTenantIdAndNonce(tenantId, request.nonce())) { + auditService.log(tenantId, null, sourceSystem, request.externalId(), operatorId, + "ISSUE", "DENIED_REPLAY", ip, userAgent); + throw new ApiException(HttpStatus.UNAUTHORIZED, "OA_REQUEST_REPLAYED", "请求已使用,请勿重放"); + } + Nonce nonce = new Nonce(); + nonce.setTenantId(tenantId); + nonce.setNonce(request.nonce()); + nonce.setExpiresAt(LocalDateTime.now().plusSeconds(skewSeconds * 2)); + nonceRepository.save(nonce); + + return issue(tenantId, sourceSystem, request.externalId(), operatorId, ip, userAgent); + } + + /** + * Trusted entry point for the internal console/dev tooling. It still runs + * the same authorization and idempotency logic. + */ + @Transactional + public OaTicketResponse issueTrusted(String sourceSystem, String externalId, + String operatorId, String ip, String userAgent) { + Long tenantId = TenantContext.getTenantId(); + return issue(tenantId, normalize(sourceSystem), externalId, operatorId, ip, userAgent); + } + + private OaTicketResponse issue(Long tenantId, String sourceSystem, String externalId, + String operatorId, String ip, String userAgent) { + if (sourceSystem == null || externalId == null || operatorId == null) { + throw ApiException.badRequest("OA_REQUEST_INVALID", "sourceSystem、externalId、operatorId 不能为空"); + } + if (secretFor(sourceSystem) == null) { + throw ApiException.badRequest("OA_SOURCE_NOT_SUPPORTED", "不支持的OA来源: " + sourceSystem); + } + + SourceDoc doc; + try { + doc = sourceIngestService.ensureDocument(sourceSystem, externalId); + } catch (ApiException e) { + auditService.log(tenantId, null, sourceSystem, externalId, operatorId, + "ISSUE", "DENIED_NOT_FOUND", ip, userAgent); + throw e; + } + + if (!authorized(doc, operatorId)) { + auditService.log(tenantId, null, sourceSystem, externalId, operatorId, + "ISSUE", "DENIED_OPERATOR", ip, userAgent); + throw new ApiException(HttpStatus.FORBIDDEN, "OA_VIEW_FORBIDDEN", + "当前用户无权查看该单据的凭证"); + } + + String token = newToken(); + LocalDateTime expiresAt = LocalDateTime.now() + .plusSeconds(Math.max(60, properties.getOa().getTicketTtlSeconds())); + OaViewTicket ticket = new OaViewTicket(); + ticket.setTenantId(tenantId); + ticket.setSourceSystem(sourceSystem); + ticket.setExternalId(externalId); + ticket.setSourceDocId(doc.getId()); + ticket.setOperatorId(operatorId); + ticket.setTokenHash(hashToken(token)); + ticket.setExpiresAt(expiresAt); + ticket.setCreatedIp(ip); + + Voucher voucher = currentVoucher(tenantId, doc.getId()); + String status; + String message; + if (isPaymentDocument(doc)) { + status = "READY"; + message = "NO_VOUCHER_PAYMENT_DOC"; + } else if (voucher != null) { + status = "READY"; + message = "VOUCHER_READY"; + } else { + status = "GENERATING"; + message = "AI_GENERATING"; + } + ticket.setStatus(status); + ticketRepository.save(ticket); + + logRepository.save(logEntry(tenantId, ticket.getId(), sourceSystem, externalId, + operatorId, "ISSUE", status, ip, userAgent)); + + return new OaTicketResponse(ticket.getId(), token, viewUrl(token), status, + expiresAt, message); + } + + @Transactional + public OaViewPage view(String token, String ip, String userAgent) { + Long tenantId = TenantContext.getTenantId(); + OaViewTicket ticket = requireTicket(tenantId, token); + if (expired(ticket)) { + auditService.markExpired(tenantId, ticket.getId()); + auditService.log(tenantId, ticket.getId(), ticket.getSourceSystem(), ticket.getExternalId(), + ticket.getOperatorId(), "VIEW", "EXPIRED", ip, userAgent); + throw new ApiException(HttpStatus.GONE, "OA_TOKEN_EXPIRED", "查看链接已过期,请在OA中重新打开"); + } + + SourceDoc doc = sourceIngestService.ensureDocument(ticket.getSourceSystem(), ticket.getExternalId()); + String message = finalizeAndDescribe(ticket, doc); + + if (ticket.getUsedAt() == null) { + ticket.setUsedAt(LocalDateTime.now()); + } + ticketRepository.save(ticket); + + Voucher voucher = currentVoucher(tenantId, doc.getId()); + List lines = voucher == null ? List.of() : voucherLineRepository + .findAllByTenantIdAndVoucherIdOrderByLineNoAsc(tenantId, voucher.getId()); + logRepository.save(logEntry(tenantId, ticket.getId(), ticket.getSourceSystem(), + ticket.getExternalId(), ticket.getOperatorId(), "VIEW", ticket.getStatus(), ip, userAgent)); + return new OaViewPage(ticket, doc, voucher, lines, candidates(doc), message); + } + + @Transactional + public OaStatusResponse status(String token) { + Long tenantId = TenantContext.getTenantId(); + OaViewTicket ticket = requireTicket(tenantId, token); + if (expired(ticket)) { + auditService.markExpired(tenantId, ticket.getId()); + return new OaStatusResponse("EXPIRED", "OA_TOKEN_EXPIRED", null, null, null); + } + SourceDoc doc = sourceIngestService.ensureDocument(ticket.getSourceSystem(), ticket.getExternalId()); + String message = finalizeAndDescribe(ticket, doc); + ticketRepository.save(ticket); + Voucher voucher = currentVoucher(tenantId, doc.getId()); + return new OaStatusResponse(ticket.getStatus(), message, + voucher == null ? null : voucher.getId(), + voucher == null ? null : voucher.getVoucherNo(), + voucher == null ? null : voucher.getStatus()); + } + + /** + * A GENERATING ticket becomes READY once a voucher (or a manual-required / + * payment-document outcome) exists. Processing is attempted only while the + * document is still RECEIVED, so repeated clicks do not rerun the AI. + */ + private String finalizeAndDescribe(OaViewTicket ticket, SourceDoc doc) { + if (isPaymentDocument(doc)) { + ticket.setStatus("READY"); + return "NO_VOUCHER_PAYMENT_DOC"; + } + Voucher voucher = currentVoucher(ticket.getTenantId(), doc.getId()); + if (voucher != null) { + ticket.setStatus("READY"); + return "VOUCHER_READY"; + } + if (DocumentProcessingService.MANUAL_REQUIRED.equals(doc.getProcessingStatus())) { + ticket.setStatus("READY"); + return doc.getManualReason() == null ? "MANUAL_REQUIRED" : doc.getManualReason(); + } + if (DocumentProcessingService.RECEIVED.equals(doc.getProcessingStatus())) { + try { + ProcessingOutcome outcome = documentProcessingService.process(doc, false); + voucher = currentVoucher(ticket.getTenantId(), doc.getId()); + if (voucher != null) { + ticket.setStatus("READY"); + return "VOUCHER_READY"; + } + if (DocumentProcessingService.MANUAL_REQUIRED.equals(outcome.status())) { + ticket.setStatus("READY"); + return outcome.message(); + } + if (DocumentProcessingService.IGNORED.equals(outcome.status())) { + ticket.setStatus("READY"); + return "NO_VOUCHER_PAYMENT_DOC"; + } + } catch (RuntimeException e) { + log.error("OA view generation failed for ticket {}", ticket.getId(), e); + return "AI_GENERATION_FAILED"; + } + } + return "AI_GENERATING"; + } + + private boolean authorized(SourceDoc doc, String operatorId) { + List viewers = sourceIngestService.viewerIds(doc); + if (!viewers.isEmpty()) { + return viewers.contains(operatorId); + } + return operatorId.equals(doc.getApplicantId()) || operatorId.equals(doc.getPayeeId()); + } + + private OaViewTicket requireTicket(Long tenantId, String token) { + if (token == null || token.isBlank()) { + throw ApiException.notFound("查看链接无效"); + } + return ticketRepository.findByTenantIdAndTokenHash(tenantId, hashToken(token)) + .orElseThrow(() -> new ApiException(HttpStatus.GONE, "OA_TOKEN_INVALID", "查看链接无效或已失效")); + } + + private boolean expired(OaViewTicket ticket) { + return ticket.getExpiresAt() == null || !ticket.getExpiresAt().isAfter(LocalDateTime.now()); + } + + private boolean isPaymentDocument(SourceDoc doc) { + return doc.getDocType() != null && doc.getDocType().startsWith("PAYMENT_"); + } + + private Voucher currentVoucher(Long tenantId, Long sourceDocId) { + return voucherRepository.findByTenantIdAndSourceDocIdAndCurrentFlag(tenantId, sourceDocId, 1) + .orElse(null); + } + + private List candidates(SourceDoc doc) { + if (doc.getCandidatesJson() == null || doc.getCandidatesJson().isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue(doc.getCandidatesJson(), new TypeReference>() { + }); + } catch (Exception e) { + return List.of(); + } + } + + private OaViewLog logEntry(Long tenantId, Long ticketId, String sourceSystem, String externalId, + String operatorId, String action, String result, + String ip, String userAgent) { + OaViewLog entry = new OaViewLog(); + entry.setTenantId(tenantId); + entry.setTicketId(ticketId); + entry.setSourceSystem(sourceSystem); + entry.setExternalId(externalId); + entry.setOperatorId(operatorId); + entry.setAction(action); + entry.setResult(result); + entry.setIp(ip); + entry.setUserAgent(userAgent != null && userAgent.length() > 500 + ? userAgent.substring(0, 500) : userAgent); + return entry; + } + + private String secretFor(String sourceSystem) { + if (SEEYON.equals(sourceSystem)) { + return properties.getOa().getSeeyon().getAppSecret(); + } + return null; + } + + public static String canonical(String sourceSystem, String externalId, String operatorId, + String timestamp, String nonce) { + return sourceSystem + "\n" + externalId + "\n" + operatorId + "\n" + timestamp + "\n" + nonce; + } + + public String sign(String canonical, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("HMAC-SHA256 not available", e); + } + } + + private boolean constantTimeEquals(String expected, String provided) { + if (expected == null || provided == null) { + return false; + } + return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), + provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8)); + } + + private Instant parseTimestamp(String timestamp) { + String value = timestamp.trim(); + if (value.matches("\\d+")) { + long numeric = Long.parseLong(value); + return value.length() <= 10 ? Instant.ofEpochSecond(numeric) : Instant.ofEpochMilli(numeric); + } + try { + return OffsetDateTime.parse(value).toInstant(); + } catch (DateTimeParseException e) { + return LocalDateTime.parse(value).atZone(ZoneId.systemDefault()).toInstant(); + } + } + + private String newToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public static String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(token.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private String viewUrl(String token) { + String base = properties.getOa().getViewBaseUrl(); + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base + "/open/oa/view/" + token; + } + + private String normalize(String sourceSystem) { + return sourceSystem == null ? null : sourceSystem.trim().toUpperCase(); + } +} diff --git a/src/main/java/com/autovoucher/service/ProcessingOutcome.java b/src/main/java/com/autovoucher/service/ProcessingOutcome.java new file mode 100644 index 0000000..11d4867 --- /dev/null +++ b/src/main/java/com/autovoucher/service/ProcessingOutcome.java @@ -0,0 +1,14 @@ +package com.autovoucher.service; + +import java.util.List; + +public record ProcessingOutcome( + String status, + Long voucherId, + String message, + List candidates +) { + public static ProcessingOutcome ignored(String message) { + return new ProcessingOutcome("IGNORED", null, message, List.of()); + } +} diff --git a/src/main/java/com/autovoucher/service/PushService.java b/src/main/java/com/autovoucher/service/PushService.java new file mode 100644 index 0000000..7d31098 --- /dev/null +++ b/src/main/java/com/autovoucher/service/PushService.java @@ -0,0 +1,312 @@ +package com.autovoucher.service; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.AppProperties; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.PushAttempt; +import com.autovoucher.domain.PushTask; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.Voucher; +import com.autovoucher.domain.VoucherLine; +import com.autovoucher.push.NccMockBehavior; +import com.autovoucher.push.PushLine; +import com.autovoucher.push.PushRequest; +import com.autovoucher.push.PushResult; +import com.autovoucher.push.TargetSystemAdapter; +import com.autovoucher.push.TargetSystemAdapterRegistry; +import com.autovoucher.repository.PushAttemptRepository; +import com.autovoucher.repository.PushTaskRepository; +import com.autovoucher.repository.SourceDocRepository; +import com.autovoucher.repository.VoucherLineRepository; +import com.autovoucher.repository.VoucherRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class PushService { + + private final PushTaskRepository pushTaskRepository; + private final PushAttemptRepository pushAttemptRepository; + private final VoucherRepository voucherRepository; + private final VoucherLineRepository voucherLineRepository; + private final SourceDocRepository sourceDocRepository; + private final TargetSystemAdapterRegistry adapterRegistry; + private final NccMockBehavior nccMockBehavior; + private final AppProperties properties; + private final AuditService auditService; + private final ObjectMapper objectMapper; + + public PushService(PushTaskRepository pushTaskRepository, + PushAttemptRepository pushAttemptRepository, + VoucherRepository voucherRepository, + VoucherLineRepository voucherLineRepository, + SourceDocRepository sourceDocRepository, + TargetSystemAdapterRegistry adapterRegistry, + NccMockBehavior nccMockBehavior, + AppProperties properties, + AuditService auditService, + ObjectMapper objectMapper) { + this.pushTaskRepository = pushTaskRepository; + this.pushAttemptRepository = pushAttemptRepository; + this.voucherRepository = voucherRepository; + this.voucherLineRepository = voucherLineRepository; + this.sourceDocRepository = sourceDocRepository; + this.adapterRegistry = adapterRegistry; + this.nccMockBehavior = nccMockBehavior; + this.properties = properties; + this.auditService = auditService; + this.objectMapper = objectMapper; + } + + public PushTask requireTask(Long id) { + return pushTaskRepository.findById(id) + .filter(t -> TenantContext.getTenantId().equals(t.getTenantId())) + .orElseThrow(() -> ApiException.notFound("PUSH_TASK_NOT_FOUND", "推送任务不存在: " + id)); + } + + public List attempts(Long taskId) { + return pushAttemptRepository.findAllByTenantIdAndTaskIdOrderByAttemptNoAsc( + TenantContext.getTenantId(), taskId); + } + + @Transactional + public PushTask push(Long taskId, String behaviorOverride) { + PushTask task = requireTask(taskId); + if (!List.of("PENDING", "RETRY_WAIT").contains(task.getStatus())) { + throw ApiException.conflict("PUSH_TASK_NOT_PUSHABLE", + "推送任务当前状态不可推送: " + task.getStatus()); + } + if (behaviorOverride != null && !behaviorOverride.isBlank() + && "NCC".equals(task.getTargetSystem())) { + nccMockBehavior.set(behaviorOverride); + } + return doPush(task); + } + + @Transactional + public PushTask reconcile(Long taskId) { + PushTask task = requireTask(taskId); + if (!"UNKNOWN".equals(task.getStatus())) { + throw ApiException.conflict("PUSH_TASK_NOT_UNKNOWN", + "只有结果未知的任务需要先查询,当前状态: " + task.getStatus()); + } + Voucher voucher = voucherRepository.findById(task.getVoucherId()) + .orElseThrow(() -> ApiException.badRequest("VOUCHER_NOT_FOUND", "凭证不存在")); + PushRequest request = buildRequest(task, voucher); + TargetSystemAdapter adapter = adapterRegistry.get(adapterCode(task)); + long started = System.nanoTime(); + PushResult result = adapter.query(request); + recordAttempt(task, result, started); + + if ("SUCCESS".equals(result.status()) || "DUPLICATE".equals(result.status())) { + markSuccess(task, voucher, result); + } else { + // The query confirmed the voucher never reached the target system, so + // both task and voucher are safe to retry from a clean state. + task.setStatus("PENDING"); + task.setNextRetryAt(LocalDateTime.now()); + task.setLastErrorCode(result.code()); + task.setLastErrorMessage(result.message()); + pushTaskRepository.save(task); + if ("PUSH_UNKNOWN".equals(voucher.getStatus())) { + voucher.setStatus("APPROVED"); + voucherRepository.save(voucher); + } + auditService.log("PUSH_RECONCILED_NOT_FOUND", "PUSH_TASK", task.getId(), + "{\"voucherNo\":\"" + voucher.getVoucherNo() + "\"}"); + } + return task; + } + + @Transactional + public PushTask retry(Long taskId) { + PushTask task = requireTask(taskId); + if ("UNKNOWN".equals(task.getStatus())) { + return reconcile(taskId); + } + if (!List.of("FAILED", "RETRY_WAIT", "PENDING").contains(task.getStatus())) { + throw ApiException.conflict("PUSH_TASK_NOT_RETRYABLE", + "推送任务当前状态不可重试: " + task.getStatus()); + } + task.setStatus("PENDING"); + task.setNextRetryAt(LocalDateTime.now()); + pushTaskRepository.save(task); + voucherRepository.findById(task.getVoucherId()) + .filter(v -> "PUSH_FAILED".equals(v.getStatus())) + .ifPresent(v -> { + v.setStatus("APPROVED"); + voucherRepository.save(v); + }); + return pushTaskRepository.save(task); + } + + private PushTask doPush(PushTask task) { + Voucher voucher = voucherRepository.findById(task.getVoucherId()) + .orElseThrow(() -> ApiException.badRequest("VOUCHER_NOT_FOUND", "凭证不存在")); + if (!"APPROVED".equals(voucher.getStatus()) && !"PUSH_UNKNOWN".equals(voucher.getStatus())) { + throw ApiException.conflict("VOUCHER_NOT_APPROVED_FOR_PUSH", + "凭证未审核通过,不能推送: " + voucher.getStatus()); + } + PushRequest request = buildRequest(task, voucher); + TargetSystemAdapter adapter = adapterRegistry.get(adapterCode(task)); + long started = System.nanoTime(); + PushResult result = adapter.push(request); + recordAttempt(task, result, started); + task.setAttemptCount(task.getAttemptCount() == null ? 1 : task.getAttemptCount() + 1); + task.setRequestSnapshotJson(result.requestJson()); + + switch (result.status()) { + case "SUCCESS" -> markSuccess(task, voucher, result); + case "DUPLICATE" -> markSuccessWithExisting(task, voucher, result); + case "TIMEOUT" -> { + task.setStatus("UNKNOWN"); + task.setLastErrorCode("NCC_TIMEOUT"); + task.setLastErrorMessage("NCC 调用超时,结果未知;重试前必须先查询"); + task.setNextRetryAt(LocalDateTime.now().plusSeconds(5)); + voucher.setStatus("PUSH_UNKNOWN"); + voucherRepository.save(voucher); + pushTaskRepository.save(task); + auditService.log("PUSH_UNKNOWN", "PUSH_TASK", task.getId(), + "{\"voucherNo\":\"" + voucher.getVoucherNo() + "\"}"); + } + default -> { + task.setLastErrorCode(result.code()); + task.setLastErrorMessage(result.message()); + if (task.getAttemptCount() >= task.getMaxAttempts()) { + task.setStatus("FAILED"); + task.setNextRetryAt(null); + voucher.setStatus("PUSH_FAILED"); + voucherRepository.save(voucher); + } else { + task.setStatus("RETRY_WAIT"); + task.setNextRetryAt(LocalDateTime.now().plusSeconds(backoffSeconds(task.getAttemptCount()))); + } + pushTaskRepository.save(task); + auditService.log("PUSH_FAILED", "PUSH_TASK", task.getId(), + "{\"code\":\"" + result.code() + "\",\"message\":" + toJson(result.message()) + "}"); + } + } + return task; + } + + private void markSuccess(PushTask task, Voucher voucher, PushResult result) { + completeTask(task, voucher, result, "SUCCESS", "PUSHED"); + auditService.log("PUSH_SUCCESS", "PUSH_TASK", task.getId(), + "{\"voucherNo\":\"" + voucher.getVoucherNo() + "\",\"externalVoucherNo\":" + + toJson(result.externalVoucherNo()) + "}"); + } + + private void markSuccessWithExisting(PushTask task, Voucher voucher, PushResult result) { + completeTask(task, voucher, result, "SUCCESS_WITH_EXISTING", "PUSHED"); + auditService.log("PUSH_DUPLICATE_ACCEPTED", "PUSH_TASK", task.getId(), + "{\"voucherNo\":\"" + voucher.getVoucherNo() + "\",\"externalVoucherNo\":" + + toJson(result.externalVoucherNo()) + "}"); + } + + private void completeTask(PushTask task, Voucher voucher, PushResult result, + String taskStatus, String voucherStatus) { + task.setStatus(taskStatus); + task.setExternalVoucherId(result.externalVoucherId()); + task.setExternalVoucherNo(result.externalVoucherNo()); + task.setLastErrorCode(null); + task.setLastErrorMessage(null); + task.setNextRetryAt(null); + pushTaskRepository.save(task); + + voucher.setStatus(voucherStatus); + voucher.setExternalVoucherId(result.externalVoucherId()); + voucher.setExternalVoucherNo(result.externalVoucherNo()); + voucher.setPushedAt(LocalDateTime.now()); + voucherRepository.save(voucher); + } + + private void recordAttempt(PushTask task, PushResult result, long startedNanos) { + PushAttempt attempt = new PushAttempt(); + attempt.setTenantId(TenantContext.getTenantId()); + attempt.setTaskId(task.getId()); + attempt.setAttemptNo((task.getAttemptCount() == null ? 0 : task.getAttemptCount()) + 1); + attempt.setRequestJson(result.requestJson()); + attempt.setResponseJson(result.responseJson()); + attempt.setStatus(result.status()); + attempt.setErrorMessage("ERROR".equals(result.status()) || "TIMEOUT".equals(result.status()) + ? result.message() : null); + attempt.setDurationMs(Duration.ofNanos(System.nanoTime() - startedNanos).toMillis()); + pushAttemptRepository.save(attempt); + } + + private PushRequest buildRequest(PushTask task, Voucher voucher) { + List voucherLines = voucherLineRepository + .findAllByTenantIdAndVoucherIdOrderByLineNoAsc(TenantContext.getTenantId(), voucher.getId()); + List lines = new ArrayList<>(); + for (VoucherLine line : voucherLines) { + lines.add(new PushLine( + line.getLineNo(), + line.getDirection(), + line.getAccountCode(), + line.getAccountName(), + line.getAmount(), + line.getCurrency(), + line.getTaxRate(), + line.getTaxAmount(), + parseAux(line.getAuxJson()), + line.getSummary() + )); + } + String sourceDocNo = sourceDocRepository.findById(voucher.getSourceDocId()) + .map(SourceDoc::getDocNo).orElse(null); + return new PushRequest( + voucher.getTenantId(), + task.getTargetSystem(), + task.getAdapterCode(), + task.getIdempotencyKey(), + voucher.getVoucherNo(), + voucher.getVoucherDate(), + voucher.getPeriod(), + voucher.getCompanyId(), + voucher.getBookId(), + voucher.getCurrency(), + voucher.getTotalDebit(), + voucher.getTotalCredit(), + sourceDocNo, + properties.getNcc().isDryRun(), + lines + ); + } + + private String adapterCode(PushTask task) { + return task.getAdapterCode() == null ? task.getTargetSystem() : task.getAdapterCode(); + } + + private Map parseAux(String json) { + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (Exception e) { + return Map.of(); + } + } + + private long backoffSeconds(int attempt) { + return (long) Math.min(300, 5 * Math.pow(2, Math.max(0, attempt - 1))); + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + return "null"; + } + } +} diff --git a/src/main/java/com/autovoucher/service/ResolvedAccount.java b/src/main/java/com/autovoucher/service/ResolvedAccount.java new file mode 100644 index 0000000..d25391d --- /dev/null +++ b/src/main/java/com/autovoucher/service/ResolvedAccount.java @@ -0,0 +1,6 @@ +package com.autovoucher.service; + +import com.autovoucher.domain.Account; + +public record ResolvedAccount(Account account, String accountGroup) { +} diff --git a/src/main/java/com/autovoucher/service/RuleAdminService.java b/src/main/java/com/autovoucher/service/RuleAdminService.java new file mode 100644 index 0000000..4a66bff --- /dev/null +++ b/src/main/java/com/autovoucher/service/RuleAdminService.java @@ -0,0 +1,287 @@ +package com.autovoucher.service; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Account; +import com.autovoucher.domain.AccountGroup; +import com.autovoucher.domain.MappingRule; +import com.autovoucher.domain.RuleCandidate; +import com.autovoucher.dto.RuleCandidateDecisionRequest; +import com.autovoucher.dto.RuleRequest; +import com.autovoucher.repository.AccountGroupRepository; +import com.autovoucher.repository.AccountRepository; +import com.autovoucher.repository.MappingRuleRepository; +import com.autovoucher.repository.RuleCandidateRepository; +import com.autovoucher.rule.RuleEngine; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +/** + * CRUD and approval workflow for mapping rules. Rules are versioned rather + * than edited in place: every change creates a new version and retires the + * previous one, which keeps generated vouchers explainable against the exact + * rule version that produced them. + */ +@Service +public class RuleAdminService { + + private final MappingRuleRepository ruleRepository; + private final RuleCandidateRepository candidateRepository; + private final AccountRepository accountRepository; + private final AccountGroupRepository accountGroupRepository; + private final RuleEngine ruleEngine; + private final AuditService auditService; + private final ObjectMapper objectMapper; + + public RuleAdminService(MappingRuleRepository ruleRepository, + RuleCandidateRepository candidateRepository, + AccountRepository accountRepository, + AccountGroupRepository accountGroupRepository, + RuleEngine ruleEngine, + AuditService auditService, + ObjectMapper objectMapper) { + this.ruleRepository = ruleRepository; + this.candidateRepository = candidateRepository; + this.accountRepository = accountRepository; + this.accountGroupRepository = accountGroupRepository; + this.ruleEngine = ruleEngine; + this.auditService = auditService; + this.objectMapper = objectMapper; + } + + public List rules() { + return ruleRepository.findAllByTenantIdOrderByPriorityDesc(TenantContext.getTenantId()); + } + + public List candidates(String status) { + Long tenantId = TenantContext.getTenantId(); + if (status == null || status.isBlank()) { + return candidateRepository.findAllByTenantIdOrderByIdDesc(tenantId); + } + return candidateRepository.findAllByTenantIdAndStatusOrderBySupportCountDesc(tenantId, status); + } + + @Transactional + public MappingRule create(RuleRequest request) { + validate(request); + List versions = versionsOf(request.ruleCode()); + if (!versions.isEmpty()) { + throw ApiException.conflict("RULE_CODE_EXISTS", + "规则编码已存在,请通过修改生成新版本: " + request.ruleCode()); + } + MappingRule rule = new MappingRule(); + rule.setTenantId(TenantContext.getTenantId()); + rule.setRuleCode(request.ruleCode()); + rule.setName(defaultName(request, request.ruleCode())); + rule.setPriority(request.priority() == null ? 0 : request.priority()); + rule.setConditionJson(toJson(request.condition())); + rule.setResultJson(toJson(request.result())); + rule.setVersion(1); + rule.setStatus(normalizeStatus(request.status(), "ACTIVE")); + ruleRepository.save(rule); + auditService.log("RULE_CREATED", "MAPPING_RULE", rule.getId(), + "{\"ruleCode\":\"" + rule.getRuleCode() + "\",\"version\":1}"); + return rule; + } + + @Transactional + public MappingRule update(Long id, RuleRequest request) { + MappingRule current = requireRule(id); + String ruleCode = request.ruleCode() == null || request.ruleCode().isBlank() + ? current.getRuleCode() : request.ruleCode(); + if (!current.getRuleCode().equals(ruleCode)) { + throw ApiException.badRequest("RULE_CODE_IMMUTABLE", "规则编码不能修改"); + } + + RuleRequest merged = new RuleRequest( + ruleCode, + request.name() == null ? current.getName() : request.name(), + request.priority() == null ? current.getPriority() : request.priority(), + request.condition() == null ? readTree(current.getConditionJson()) : request.condition(), + request.result() == null ? readTree(current.getResultJson()) : request.result(), + request.status() + ); + validate(merged); + + int nextVersion = versionsOf(ruleCode).stream() + .map(MappingRule::getVersion) + .filter(Objects::nonNull) + .max(Integer::compareTo) + .orElse(0) + 1; + + retireActiveVersions(ruleCode); + + MappingRule rule = new MappingRule(); + rule.setTenantId(TenantContext.getTenantId()); + rule.setRuleCode(ruleCode); + rule.setName(merged.name()); + rule.setPriority(merged.priority()); + rule.setConditionJson(toJson(merged.condition())); + rule.setResultJson(toJson(merged.result())); + rule.setVersion(nextVersion); + rule.setStatus(normalizeStatus(merged.status(), "ACTIVE")); + ruleRepository.save(rule); + auditService.log("RULE_UPDATED", "MAPPING_RULE", rule.getId(), + "{\"ruleCode\":\"" + ruleCode + "\",\"version\":" + nextVersion + "}"); + return rule; + } + + @Transactional + public MappingRule approveCandidate(Long candidateId, RuleCandidateDecisionRequest request) { + RuleCandidate candidate = requireCandidate(candidateId); + if (!"PENDING".equals(candidate.getStatus())) { + throw ApiException.conflict("RULE_CANDIDATE_NOT_PENDING", + "候选规则已处理,当前状态: " + candidate.getStatus()); + } + String ruleCode = request != null && request.ruleCode() != null && !request.ruleCode().isBlank() + ? request.ruleCode() : candidate.getRuleCode(); + JsonNode condition = readTree(candidate.getConditionJson()); + JsonNode result = readTree(candidate.getResultJson()); + RuleRequest ruleRequest = new RuleRequest( + ruleCode, + request != null && request.name() != null && !request.name().isBlank() + ? request.name() : candidate.getName(), + request != null && request.priority() != null ? request.priority() : 50, + condition, + result, + "ACTIVE" + ); + validate(ruleRequest); + + int nextVersion = versionsOf(ruleCode).stream() + .map(MappingRule::getVersion) + .filter(Objects::nonNull) + .max(Integer::compareTo) + .orElse(0) + 1; + retireActiveVersions(ruleCode); + + MappingRule rule = new MappingRule(); + rule.setTenantId(TenantContext.getTenantId()); + rule.setRuleCode(ruleCode); + rule.setName(defaultName(ruleRequest, ruleCode)); + rule.setPriority(ruleRequest.priority()); + rule.setConditionJson(toJson(condition)); + rule.setResultJson(toJson(result)); + rule.setVersion(nextVersion); + rule.setStatus("ACTIVE"); + ruleRepository.save(rule); + + candidate.setRuleCode(ruleCode); + candidate.setStatus("APPROVED"); + candidateRepository.save(candidate); + auditService.log("RULE_CANDIDATE_APPROVED", "RULE_CANDIDATE", candidateId, + "{\"ruleCode\":\"" + ruleCode + "\",\"version\":" + nextVersion + "}"); + return rule; + } + + @Transactional + public RuleCandidate rejectCandidate(Long candidateId) { + RuleCandidate candidate = requireCandidate(candidateId); + if (!"PENDING".equals(candidate.getStatus())) { + throw ApiException.conflict("RULE_CANDIDATE_NOT_PENDING", + "候选规则已处理,当前状态: " + candidate.getStatus()); + } + candidate.setStatus("REJECTED"); + candidateRepository.save(candidate); + auditService.log("RULE_CANDIDATE_REJECTED", "RULE_CANDIDATE", candidateId, + "{\"ruleCode\":\"" + candidate.getRuleCode() + "\"}"); + return candidate; + } + + private void validate(RuleRequest request) { + if (request.ruleCode() == null || request.ruleCode().isBlank()) { + throw ApiException.badRequest("RULE_CODE_REQUIRED", "规则编码不能为空"); + } + if (request.condition() == null || request.condition().isNull() || request.condition().isEmpty()) { + throw ApiException.badRequest("RULE_CONDITION_REQUIRED", "规则条件不能为空"); + } + JsonNode condition = request.condition(); + if (!condition.has("all") && !condition.has("any") && !condition.has("field")) { + throw ApiException.badRequest("RULE_CONDITION_INVALID", + "规则条件必须包含 all、any 或 field"); + } + if (request.result() == null || request.result().isNull() || !request.result().isObject()) { + throw ApiException.badRequest("RULE_RESULT_REQUIRED", "规则结果必须是一个 JSON 对象"); + } + String accountGroup = request.result().path("accountGroup").asText(null); + String accountCode = request.result().path("accountCode").asText(null); + if ((accountGroup == null || accountGroup.isBlank()) + && (accountCode == null || accountCode.isBlank())) { + throw ApiException.badRequest("RULE_RESULT_ACCOUNT_REQUIRED", + "规则结果必须指定 accountGroup 或 accountCode"); + } + if (accountGroup != null && !accountGroup.isBlank() + && accountGroupRepository.findByTenantIdAndGroupCode( + TenantContext.getTenantId(), accountGroup).isEmpty()) { + throw ApiException.badRequest("RULE_ACCOUNT_GROUP_NOT_FOUND", + "科目组不存在: " + accountGroup); + } + if (accountCode != null && !accountCode.isBlank()) { + List accounts = accountRepository + .findAllByTenantIdOrderByCodeAsc(TenantContext.getTenantId()); + boolean exists = accounts.stream().anyMatch(a -> accountCode.equals(a.getCode())); + if (!exists) { + throw ApiException.badRequest("RULE_ACCOUNT_NOT_FOUND", "科目不存在: " + accountCode); + } + } + // Fail fast on unsupported operators instead of at suggestion time. + ruleEngine.evaluate(condition, java.util.Map.of()); + } + + private MappingRule requireRule(Long id) { + return ruleRepository.findById(id) + .filter(r -> TenantContext.getTenantId().equals(r.getTenantId())) + .orElseThrow(() -> ApiException.notFound("RULE_NOT_FOUND", "规则不存在: " + id)); + } + + private RuleCandidate requireCandidate(Long id) { + return candidateRepository.findById(id) + .filter(c -> TenantContext.getTenantId().equals(c.getTenantId())) + .orElseThrow(() -> ApiException.notFound("RULE_CANDIDATE_NOT_FOUND", "候选规则不存在: " + id)); + } + + private List versionsOf(String ruleCode) { + return ruleRepository.findAllByTenantIdOrderByPriorityDesc(TenantContext.getTenantId()) + .stream() + .filter(r -> ruleCode.equals(r.getRuleCode())) + .toList(); + } + + private void retireActiveVersions(String ruleCode) { + for (MappingRule rule : versionsOf(ruleCode)) { + if ("ACTIVE".equals(rule.getStatus())) { + rule.setStatus("INACTIVE"); + ruleRepository.save(rule); + } + } + } + + private String defaultName(RuleRequest request, String ruleCode) { + return request.name() == null || request.name().isBlank() ? ruleCode : request.name(); + } + + private String normalizeStatus(String status, String fallback) { + return status == null || status.isBlank() ? fallback : status.toUpperCase(); + } + + private JsonNode readTree(String json) { + try { + return json == null ? null : objectMapper.readTree(json); + } catch (Exception e) { + throw ApiException.badRequest("INVALID_RULE_JSON", "规则 JSON 无法解析: " + e.getMessage()); + } + } + + private String toJson(JsonNode node) { + try { + return objectMapper.writeValueAsString(node); + } catch (Exception e) { + throw ApiException.badRequest("INVALID_RULE_JSON", "规则 JSON 无法序列化"); + } + } +} diff --git a/src/main/java/com/autovoucher/service/SourceIngestService.java b/src/main/java/com/autovoucher/service/SourceIngestService.java new file mode 100644 index 0000000..d3a1287 --- /dev/null +++ b/src/main/java/com/autovoucher/service/SourceIngestService.java @@ -0,0 +1,190 @@ +package com.autovoucher.service; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.SourceEvent; +import com.autovoucher.domain.SourceLine; +import com.autovoucher.repository.SourceDocRepository; +import com.autovoucher.repository.SourceEventRepository; +import com.autovoucher.repository.SourceLineRepository; +import com.autovoucher.source.SourceAdapterRegistry; +import com.autovoucher.source.SourceDocPayload; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +public class SourceIngestService { + + private final SourceDocRepository sourceDocRepository; + private final SourceLineRepository sourceLineRepository; + private final SourceEventRepository sourceEventRepository; + private final SourceAdapterRegistry adapterRegistry; + private final DocumentProcessingService documentProcessingService; + private final ObjectMapper objectMapper; + + public SourceIngestService(SourceDocRepository sourceDocRepository, + SourceLineRepository sourceLineRepository, + SourceEventRepository sourceEventRepository, + SourceAdapterRegistry adapterRegistry, + DocumentProcessingService documentProcessingService, + ObjectMapper objectMapper) { + this.sourceDocRepository = sourceDocRepository; + this.sourceLineRepository = sourceLineRepository; + this.sourceEventRepository = sourceEventRepository; + this.adapterRegistry = adapterRegistry; + this.documentProcessingService = documentProcessingService; + this.objectMapper = objectMapper; + } + + /** + * Idempotent document upsert keyed by (source system, external id). + * Re-delivery replaces the source lines but never implicitly regenerates a + * voucher; generation is driven by events or an explicit reprocess request. + */ + @Transactional + public SourceDoc upsert(SourceDocPayload payload) { + Long tenantId = TenantContext.getTenantId(); + if (payload.sourceSystem() == null || payload.externalId() == null) { + throw ApiException.badRequest("INVALID_SOURCE_DOC", "sourceSystem 和 externalId 不能为空"); + } + SourceDoc doc = sourceDocRepository + .findByTenantIdAndSourceSystemAndExternalId(tenantId, payload.sourceSystem(), payload.externalId()) + .orElseGet(() -> { + SourceDoc created = new SourceDoc(); + created.setTenantId(tenantId); + created.setProcessingStatus(DocumentProcessingService.RECEIVED); + return created; + }); + + doc.setSourceSystem(payload.sourceSystem()); + doc.setExternalId(payload.externalId()); + doc.setDocNo(payload.docNo()); + doc.setDocType(payload.docType()); + doc.setTitle(payload.title()); + doc.setCompanyId(payload.companyId()); + doc.setBookId(payload.bookId()); + doc.setApplicantId(payload.applicantId()); + doc.setApplicantName(payload.applicantName()); + doc.setDepartmentId(payload.departmentId()); + doc.setDepartmentName(payload.departmentName()); + doc.setPayeeType(payload.payeeType()); + doc.setPayeeId(payload.payeeId()); + doc.setPayeeName(payload.payeeName()); + doc.setCurrency(payload.currency()); + doc.setTotalAmount(payload.totalAmount()); + doc.setTaxAmount(payload.taxAmount()); + doc.setBusinessScene(payload.businessScene()); + doc.setIsBusinessTrip(Boolean.TRUE.equals(payload.isBusinessTrip())); + doc.setTripId(payload.tripId()); + doc.setApprovalNode(payload.approvalNode()); + doc.setApprovedAt(payload.approvedAt()); + doc.setRawJson(payload.rawJson()); + sourceDocRepository.save(doc); + + sourceLineRepository.deleteAllByTenantIdAndSourceDocId(tenantId, doc.getId()); + // av_source_line has a unique key on (tenant_id, source_doc_id, line_no) + // and Hibernate flushes inserts before deletes, so force the delete out + // before re-inserting lines when a document is ingested again. + sourceLineRepository.flush(); + int fallbackNo = 1; + for (SourceDocPayload.Line linePayload : payload.lines()) { + SourceLine line = new SourceLine(); + line.setTenantId(tenantId); + line.setSourceDocId(doc.getId()); + line.setLineNo(linePayload.lineNo() == null ? fallbackNo : linePayload.lineNo()); + line.setExpenseCategory(linePayload.expenseCategory()); + line.setDescription(linePayload.description()); + line.setAmount(linePayload.amount()); + line.setAmountExcludingTax(linePayload.amountExcludingTax()); + line.setTaxIncluded(linePayload.taxIncluded() == null || linePayload.taxIncluded()); + line.setTaxRate(linePayload.taxRate()); + line.setTaxAmount(linePayload.taxAmount()); + line.setInvoiceType(linePayload.invoiceType()); + line.setOccurredAt(linePayload.occurredAt()); + line.setMerchantId(linePayload.merchantId()); + line.setMerchantName(linePayload.merchantName()); + line.setCustomerId(linePayload.customerId()); + line.setProjectId(linePayload.projectId()); + line.setCostCenterId(linePayload.costCenterId()); + line.setInvoiceNo(linePayload.invoiceNo()); + line.setRawJson(linePayload.rawJson()); + sourceLineRepository.save(line); + fallbackNo++; + } + return doc; + } + + @Transactional + public IngestResult receiveAndProcess(String eventId, + String sourceSystem, + String externalId, + String eventType, + LocalDateTime receivedAt) { + Long tenantId = TenantContext.getTenantId(); + if (sourceEventRepository.existsByTenantIdAndEventId(tenantId, eventId)) { + SourceEvent existing = sourceEventRepository.findByTenantIdAndEventId(tenantId, eventId).orElseThrow(); + return new IngestResult(eventId, "DUPLICATE_EVENT", null, null, + "事件已处理过: " + existing.getStatus(), List.of()); + } + SourceEvent event = new SourceEvent(); + event.setTenantId(tenantId); + event.setEventId(eventId); + event.setSourceSystem(sourceSystem); + event.setEventType(eventType); + event.setExternalId(externalId); + event.setStatus("RECEIVED"); + event.setReceivedAt(receivedAt == null ? LocalDateTime.now() : receivedAt); + sourceEventRepository.save(event); + return processEvent(event); + } + + @Transactional + public IngestResult processEvent(SourceEvent event) { + Long tenantId = TenantContext.getTenantId(); + if ("PROCESSED".equals(event.getStatus()) || "IGNORED".equals(event.getStatus()) + || "DUPLICATE_IGNORED".equals(event.getStatus())) { + return new IngestResult(event.getEventId(), event.getStatus(), null, null, + "事件已处理", List.of()); + } + + SourceDoc doc = sourceDocRepository + .findByTenantIdAndSourceSystemAndExternalId(tenantId, event.getSourceSystem(), event.getExternalId()) + .orElseGet(() -> upsert(adapterRegistry.get(event.getSourceSystem()).fetch(event.getExternalId()))); + + ProcessingOutcome outcome = documentProcessingService.process(doc, false); + event.setStatus(outcome.status()); + event.setMessage(outcome.message()); + event.setProcessedAt(LocalDateTime.now()); + sourceEventRepository.save(event); + + return new IngestResult(event.getEventId(), outcome.status(), doc.getId(), outcome.voucherId(), + outcome.message(), outcome.candidates()); + } + + @Transactional + public SourceDoc ensureDocument(String sourceSystem, String externalId) { + Long tenantId = TenantContext.getTenantId(); + return sourceDocRepository + .findByTenantIdAndSourceSystemAndExternalId(tenantId, sourceSystem, externalId) + .orElseGet(() -> upsert(adapterRegistry.get(sourceSystem).fetch(externalId))); + } + + public List viewerIds(SourceDoc doc) { + // Viewer ids are part of the source payload; parse them from raw JSON so + // the authorization rule stays vendor-neutral. + try { + var node = objectMapper.readTree(doc.getRawJson()); + List ids = new ArrayList<>(); + node.path("viewerIds").forEach(v -> ids.add(v.asText())); + return ids; + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/src/main/java/com/autovoucher/service/SuggestedLine.java b/src/main/java/com/autovoucher/service/SuggestedLine.java new file mode 100644 index 0000000..51550b5 --- /dev/null +++ b/src/main/java/com/autovoucher/service/SuggestedLine.java @@ -0,0 +1,20 @@ +package com.autovoucher.service; + +import java.math.BigDecimal; +import java.util.Map; + +public record SuggestedLine( + String direction, + String accountCode, + String accountName, + String accountGroup, + BigDecimal amount, + BigDecimal taxRate, + BigDecimal taxAmount, + Map aux, + String summary, + String decisionSource, + BigDecimal confidence, + String evidenceJson +) { +} diff --git a/src/main/java/com/autovoucher/service/SuggestionOutcome.java b/src/main/java/com/autovoucher/service/SuggestionOutcome.java new file mode 100644 index 0000000..7b1e2d8 --- /dev/null +++ b/src/main/java/com/autovoucher/service/SuggestionOutcome.java @@ -0,0 +1,18 @@ +package com.autovoucher.service; + +import java.math.BigDecimal; +import java.util.List; + +public record SuggestionOutcome( + boolean manualRequired, + String manualReason, + List candidates, + List lines, + String matchedRule, + BigDecimal confidence, + String traceJson +) { + public static SuggestionOutcome manual(String reason, List candidates, String traceJson) { + return new SuggestionOutcome(true, reason, candidates, List.of(), null, BigDecimal.ZERO, traceJson); + } +} diff --git a/src/main/java/com/autovoucher/service/VoucherNumberService.java b/src/main/java/com/autovoucher/service/VoucherNumberService.java new file mode 100644 index 0000000..3590c94 --- /dev/null +++ b/src/main/java/com/autovoucher/service/VoucherNumberService.java @@ -0,0 +1,37 @@ +package com.autovoucher.service; + +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Book; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.repository.VoucherRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Service +public class VoucherNumberService { + + private final VoucherRepository voucherRepository; + + public VoucherNumberService(VoucherRepository voucherRepository) { + this.voucherRepository = voucherRepository; + } + + public String next(SourceDoc doc, Book book, int version) { + String period = (book.getCurrentPeriod() == null + ? LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")) + : book.getCurrentPeriod()).replace("-", ""); + String suffix = doc.getDocNo() == null ? String.valueOf(doc.getId()) : doc.getDocNo(); + int dash = suffix.lastIndexOf('-'); + if (dash >= 0) { + suffix = suffix.substring(dash + 1); + } + String base = "JZ-" + period + "-" + suffix; + if (version <= 1 && voucherRepository + .findByTenantIdAndVoucherNo(TenantContext.getTenantId(), base).isEmpty()) { + return base; + } + return base + "-R" + version; + } +} diff --git a/src/main/java/com/autovoucher/service/VoucherService.java b/src/main/java/com/autovoucher/service/VoucherService.java new file mode 100644 index 0000000..315467e --- /dev/null +++ b/src/main/java/com/autovoucher/service/VoucherService.java @@ -0,0 +1,593 @@ +package com.autovoucher.service; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Account; +import com.autovoucher.domain.Book; +import com.autovoucher.domain.CaseMemory; +import com.autovoucher.domain.Correction; +import com.autovoucher.domain.PushTask; +import com.autovoucher.domain.RuleCandidate; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.SourceLine; +import com.autovoucher.domain.TargetSystem; +import com.autovoucher.domain.Transaction; +import com.autovoucher.domain.Voucher; +import com.autovoucher.domain.VoucherLine; +import com.autovoucher.domain.VoucherType; +import com.autovoucher.dto.ManualVoucherRequest; +import com.autovoucher.dto.VoucherLineInput; +import com.autovoucher.dto.VoucherUpdateRequest; +import com.autovoucher.repository.AccountRepository; +import com.autovoucher.repository.BookRepository; +import com.autovoucher.repository.CaseMemoryRepository; +import com.autovoucher.repository.CorrectionRepository; +import com.autovoucher.repository.PushTaskRepository; +import com.autovoucher.repository.RuleCandidateRepository; +import com.autovoucher.repository.SourceDocRepository; +import com.autovoucher.repository.SourceLineRepository; +import com.autovoucher.repository.TargetSystemRepository; +import com.autovoucher.repository.TransactionLineRepository; +import com.autovoucher.repository.TransactionRepository; +import com.autovoucher.repository.VoucherLineRepository; +import com.autovoucher.repository.VoucherRepository; +import com.autovoucher.repository.VoucherTypeRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +public class VoucherService { + + private final VoucherRepository voucherRepository; + private final VoucherLineRepository voucherLineRepository; + private final SourceDocRepository sourceDocRepository; + private final SourceLineRepository sourceLineRepository; + private final TransactionRepository transactionRepository; + private final TransactionLineRepository transactionLineRepository; + private final AccountRepository accountRepository; + private final BookRepository bookRepository; + private final VoucherTypeRepository voucherTypeRepository; + private final TargetSystemRepository targetSystemRepository; + private final PushTaskRepository pushTaskRepository; + private final CorrectionRepository correctionRepository; + private final CaseMemoryRepository caseMemoryRepository; + private final RuleCandidateRepository ruleCandidateRepository; + private final VoucherNumberService voucherNumberService; + private final AuditService auditService; + private final ObjectMapper objectMapper; + + public VoucherService(VoucherRepository voucherRepository, + VoucherLineRepository voucherLineRepository, + SourceDocRepository sourceDocRepository, + SourceLineRepository sourceLineRepository, + TransactionRepository transactionRepository, + TransactionLineRepository transactionLineRepository, + AccountRepository accountRepository, + BookRepository bookRepository, + VoucherTypeRepository voucherTypeRepository, + TargetSystemRepository targetSystemRepository, + PushTaskRepository pushTaskRepository, + CorrectionRepository correctionRepository, + CaseMemoryRepository caseMemoryRepository, + RuleCandidateRepository ruleCandidateRepository, + VoucherNumberService voucherNumberService, + AuditService auditService, + ObjectMapper objectMapper) { + this.voucherRepository = voucherRepository; + this.voucherLineRepository = voucherLineRepository; + this.sourceDocRepository = sourceDocRepository; + this.sourceLineRepository = sourceLineRepository; + this.transactionRepository = transactionRepository; + this.transactionLineRepository = transactionLineRepository; + this.accountRepository = accountRepository; + this.bookRepository = bookRepository; + this.voucherTypeRepository = voucherTypeRepository; + this.targetSystemRepository = targetSystemRepository; + this.pushTaskRepository = pushTaskRepository; + this.correctionRepository = correctionRepository; + this.caseMemoryRepository = caseMemoryRepository; + this.ruleCandidateRepository = ruleCandidateRepository; + this.voucherNumberService = voucherNumberService; + this.auditService = auditService; + this.objectMapper = objectMapper; + } + + public Voucher requireVoucher(Long id) { + return voucherRepository.findById(id) + .filter(v -> TenantContext.getTenantId().equals(v.getTenantId())) + .orElseThrow(() -> ApiException.notFound("VOUCHER_NOT_FOUND", "凭证不存在: " + id)); + } + + public List lines(Long voucherId) { + return voucherLineRepository.findAllByTenantIdAndVoucherIdOrderByLineNoAsc( + TenantContext.getTenantId(), voucherId); + } + + @Transactional + public Voucher updateLines(Long voucherId, VoucherUpdateRequest request) { + Voucher voucher = requireVoucher(voucherId); + if (!"PENDING_REVIEW".equals(voucher.getStatus())) { + throw ApiException.conflict("VOUCHER_NOT_EDITABLE", + "只有待审核凭证可以修改,当前状态: " + voucher.getStatus()); + } + if (request.lines() == null || request.lines().isEmpty()) { + throw ApiException.badRequest("VOUCHER_LINES_EMPTY", "凭证至少需要两条分录"); + } + + List oldLines = lines(voucherId); + List> before = snapshot(oldLines); + + voucherLineRepository.deleteAllByTenantIdAndVoucherId(TenantContext.getTenantId(), voucherId); + // Hibernate flushes inserts before deletes. av_voucher_line has a unique + // key on (tenant_id, voucher_id, line_no), so re-inserted lines with the + // same numbers would collide with rows still pending deletion. Flush the + // deletes to the database first. + voucherLineRepository.flush(); + + BigDecimal debit = BigDecimal.ZERO; + BigDecimal credit = BigDecimal.ZERO; + int lineNo = 1; + for (VoucherLineInput input : request.lines()) { + Account account = resolveAccount(voucher, input.accountCode()); + validateAux(account, input.aux()); + BigDecimal amount = scale(input.amount()); + if (amount.signum() <= 0) { + throw ApiException.badRequest("VOUCHER_AMOUNT_INVALID", "第 " + lineNo + " 行金额必须大于 0"); + } + String direction = input.direction() == null ? "" : input.direction().toUpperCase(); + if (!"DEBIT".equals(direction) && !"CREDIT".equals(direction)) { + throw ApiException.badRequest("VOUCHER_DIRECTION_INVALID", + "第 " + lineNo + " 行借贷方向必须为 DEBIT 或 CREDIT"); + } + + VoucherLine line = new VoucherLine(); + line.setTenantId(TenantContext.getTenantId()); + line.setVoucherId(voucherId); + line.setLineNo(lineNo++); + line.setDirection(direction); + line.setAccountCode(account.getCode()); + line.setAccountName(account.getName()); + line.setAccountGroup(null); + line.setAmount(amount); + line.setCurrency(voucher.getCurrency()); + line.setTaxRate(input.taxRate()); + line.setTaxAmount(input.taxAmount()); + line.setAuxJson(toJson(input.aux() == null ? Map.of() : input.aux())); + line.setSummary(input.summary()); + line.setDecisionSource("MANUAL"); + line.setConfidence(BigDecimal.ONE); + voucherLineRepository.save(line); + + if ("DEBIT".equals(direction)) { + debit = debit.add(amount); + } else { + credit = credit.add(amount); + } + } + if (debit.compareTo(credit) != 0) { + throw ApiException.badRequest("VOUCHER_NOT_BALANCED", + "借贷不平衡: 借方 " + debit + ",贷方 " + credit); + } + voucher.setTotalDebit(debit); + voucher.setTotalCredit(credit); + voucherRepository.save(voucher); + + List newLines = lines(voucherId); + List> after = snapshot(newLines); + if (!before.equals(after)) { + recordCorrection(voucher, oldLines, newLines, request.reason()); + } + auditService.log("VOUCHER_LINES_UPDATED", "VOUCHER", voucherId, + "{\"debit\":" + debit + ",\"credit\":" + credit + "}"); + return voucher; + } + + @Transactional + public Voucher approve(Long voucherId) { + Voucher voucher = requireVoucher(voucherId); + if (!"PENDING_REVIEW".equals(voucher.getStatus())) { + throw ApiException.conflict("VOUCHER_NOT_APPROVABLE", + "只有待审核凭证可以审核,当前状态: " + voucher.getStatus()); + } + List lines = lines(voucherId); + if (lines.isEmpty()) { + throw ApiException.badRequest("VOUCHER_LINES_EMPTY", "凭证没有分录"); + } + BigDecimal debit = lines.stream().filter(l -> "DEBIT".equals(l.getDirection())) + .map(VoucherLine::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal credit = lines.stream().filter(l -> "CREDIT".equals(l.getDirection())) + .map(VoucherLine::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + if (debit.compareTo(credit) != 0) { + throw ApiException.badRequest("VOUCHER_NOT_BALANCED", + "借贷不平衡: 借方 " + debit + ",贷方 " + credit); + } + + voucher.setStatus("APPROVED"); + voucher.setApprovedBy(auditService.currentOperator()); + voucher.setApprovedAt(LocalDateTime.now()); + voucherRepository.save(voucher); + createPushTask(voucher); + auditService.log("VOUCHER_APPROVED", "VOUCHER", voucherId, + "{\"voucherNo\":\"" + voucher.getVoucherNo() + "\"}"); + return voucher; + } + + @Transactional + public Voucher reject(Long voucherId, String reason) { + Voucher voucher = requireVoucher(voucherId); + if (!"PENDING_REVIEW".equals(voucher.getStatus())) { + throw ApiException.conflict("VOUCHER_NOT_REJECTABLE", + "只有待审核凭证可以驳回,当前状态: " + voucher.getStatus()); + } + voucher.setStatus("REJECTED"); + voucher.setRejectReason(reason); + voucherRepository.save(voucher); + auditService.log("VOUCHER_REJECTED", "VOUCHER", voucherId, + "{\"reason\":" + toJson(reason) + "}"); + return voucher; + } + + /** + * Generates the first voucher for a MANUAL_REQUIRED document using the + * account the reviewer selected. The selection is remembered as a case and + * as a pending rule candidate, so the next identical document is suggested + * automatically after the candidate is approved. + */ + @Transactional + public Voucher createFromManualCandidate(Long sourceDocId, ManualVoucherRequest request) { + SourceDoc doc = sourceDocRepository.findById(sourceDocId) + .filter(d -> TenantContext.getTenantId().equals(d.getTenantId())) + .orElseThrow(() -> ApiException.notFound("SOURCE_DOC_NOT_FOUND", "源单据不存在: " + sourceDocId)); + if (!DocumentProcessingService.MANUAL_REQUIRED.equals(doc.getProcessingStatus())) { + throw ApiException.conflict("DOC_NOT_MANUAL_REQUIRED", + "单据当前不是待人工判断状态: " + doc.getProcessingStatus()); + } + Account debitAccount = accountRepository + .findByTenantIdAndCompanyIdAndBookIdAndCode(TenantContext.getTenantId(), + doc.getCompanyId(), doc.getBookId(), request.accountCode()) + .orElseThrow(() -> ApiException.badRequest("ACCOUNT_NOT_FOUND", + "科目不存在: " + request.accountCode())); + Account payable = accountRepository + .findByTenantIdAndCompanyIdAndBookIdAndCode(TenantContext.getTenantId(), + doc.getCompanyId(), doc.getBookId(), "2241.01") + .orElseThrow(() -> ApiException.badRequest("ACCOUNT_NOT_FOUND", "其他应付款科目 2241.01 未配置")); + Book book = bookRepository.findByTenantIdAndBookId(TenantContext.getTenantId(), doc.getBookId()) + .orElseThrow(() -> ApiException.badRequest("BOOK_NOT_FOUND", "账簿不存在: " + doc.getBookId())); + VoucherType voucherType = voucherTypeRepository + .findByTenantIdAndCode(TenantContext.getTenantId(), "JZ") + .orElseThrow(() -> ApiException.badRequest("VOUCHER_TYPE_NOT_FOUND", "未配置记账凭证类型")); + + int version = voucherRepository + .findAllByTenantIdAndSourceDocIdOrderByIdDesc(TenantContext.getTenantId(), doc.getId()).size() + 1; + Voucher voucher = new Voucher(); + voucher.setTenantId(TenantContext.getTenantId()); + voucher.setVoucherNo(voucherNumberService.next(doc, book, version)); + voucher.setSourceDocId(doc.getId()); + voucher.setVoucherTypeCode(voucherType.getCode()); + voucher.setCompanyId(doc.getCompanyId()); + voucher.setBookId(doc.getBookId()); + voucher.setVoucherDate(doc.getApprovedAt() == null ? LocalDate.now() : doc.getApprovedAt().toLocalDate()); + voucher.setPeriod(book.getCurrentPeriod()); + voucher.setCurrency(doc.getCurrency() == null ? "CNY" : doc.getCurrency()); + voucher.setStatus("PENDING_REVIEW"); + voucher.setVersion(version); + voucher.setCurrentFlag(1); + voucher.setConfidence(BigDecimal.valueOf(0.6)); + voucher.setTargetSystem(book.getTargetSystem()); + voucher.setDecisionTraceJson("{\"source\":\"MANUAL_CANDIDATE\",\"accountCode\":\"" + + request.accountCode() + "\",\"reason\":" + toJson(request.reason()) + "}"); + voucherRepository.save(voucher); + + List sourceLines = sourceLineRepository + .findAllByTenantIdAndSourceDocIdOrderByLineNoAsc(TenantContext.getTenantId(), doc.getId()); + BigDecimal debitTotal = BigDecimal.ZERO; + int lineNo = 1; + for (SourceLine sourceLine : sourceLines) { + BigDecimal amount = scale(sourceLine.getAmount()); + BigDecimal tax = scale(sourceLine.getTaxAmount()); + boolean split = "SPECIAL".equalsIgnoreCase(sourceLine.getInvoiceType()) && tax.signum() > 0; + BigDecimal base = split + ? (sourceLine.getAmountExcludingTax() != null + ? scale(sourceLine.getAmountExcludingTax()) : amount.subtract(tax)) + : amount; + Map aux = new LinkedHashMap<>(); + if (doc.getDepartmentId() != null) { + aux.put("DEPT", doc.getDepartmentId()); + } + if (doc.getApplicantId() != null) { + aux.put("EMP", doc.getApplicantId()); + } + if (sourceLine.getProjectId() != null) { + aux.put("PROJ", sourceLine.getProjectId()); + } + saveLine(voucher, lineNo++, "DEBIT", debitAccount.getCode(), debitAccount.getName(), + base, sourceLine.getTaxRate(), split ? tax : BigDecimal.ZERO, aux, + sourceLine.getDescription()); + debitTotal = debitTotal.add(base); + if (split) { + Account vat = accountRepository.findByTenantIdAndCompanyIdAndBookIdAndCode( + TenantContext.getTenantId(), doc.getCompanyId(), doc.getBookId(), "2221.01.01") + .orElseThrow(() -> ApiException.badRequest("ACCOUNT_NOT_FOUND", "进项税额科目未配置")); + saveLine(voucher, lineNo++, "DEBIT", vat.getCode(), vat.getName(), + tax, null, null, Map.of(), "进项税额 - " + sourceLine.getDescription()); + debitTotal = debitTotal.add(tax); + } + } + Map creditAux = doc.getApplicantId() == null + ? Map.of() : Map.of("EMP", doc.getApplicantId()); + saveLine(voucher, lineNo, "CREDIT", payable.getCode(), payable.getName(), + debitTotal, null, null, creditAux, "员工报销款 - " + doc.getApplicantName()); + voucher.setTotalDebit(debitTotal); + voucher.setTotalCredit(debitTotal); + voucherRepository.save(voucher); + + doc.setProcessingStatus("PENDING_REVIEW"); + doc.setManualReason(null); + doc.setCandidatesJson(null); + sourceDocRepository.save(doc); + + rememberManualChoice(doc, sourceLines, request, voucher); + auditService.log("MANUAL_CANDIDATE_SELECTED", "VOUCHER", voucher.getId(), + "{\"accountCode\":\"" + request.accountCode() + "\",\"reason\":" + toJson(request.reason()) + "}"); + return voucher; + } + + private void saveLine(Voucher voucher, int lineNo, String direction, String accountCode, String accountName, + BigDecimal amount, BigDecimal taxRate, BigDecimal taxAmount, + Map aux, String summary) { + VoucherLine line = new VoucherLine(); + line.setTenantId(TenantContext.getTenantId()); + line.setVoucherId(voucher.getId()); + line.setLineNo(lineNo); + line.setDirection(direction); + line.setAccountCode(accountCode); + line.setAccountName(accountName); + line.setAmount(scale(amount)); + line.setCurrency(voucher.getCurrency()); + line.setTaxRate(taxRate); + line.setTaxAmount(taxAmount); + line.setAuxJson(toJson(aux)); + line.setSummary(summary); + line.setDecisionSource("MANUAL"); + line.setConfidence(BigDecimal.ONE); + voucherLineRepository.save(line); + } + + private void rememberManualChoice(SourceDoc doc, List lines, + ManualVoucherRequest request, Voucher voucher) { + String category = lines.isEmpty() ? "UNKNOWN" : lines.get(0).getExpenseCategory(); + String ruleCode = "CAND-" + category + "-" + request.accountCode().replace('.', '_'); + RuleCandidate candidate = ruleCandidateRepository + .findByTenantIdAndRuleCode(TenantContext.getTenantId(), ruleCode) + .orElseGet(RuleCandidate::new); + if (candidate.getId() == null) { + candidate.setTenantId(TenantContext.getTenantId()); + candidate.setRuleCode(ruleCode); + candidate.setName("学习规则: " + category + " -> " + request.accountCode()); + candidate.setConditionJson("{\"all\":[{\"field\":\"expenseCategory\",\"op\":\"eq\",\"value\":" + + toJson(category) + "}]}"); + candidate.setResultJson("{\"accountCode\":" + toJson(request.accountCode()) + "}"); + candidate.setSupportCount(1); + candidate.setStatus("PENDING"); + candidate.setSource("REVIEWER_CORRECTION"); + } else { + candidate.setSupportCount(candidate.getSupportCount() + 1); + } + ruleCandidateRepository.save(candidate); + + CaseMemory memory = new CaseMemory(); + memory.setTenantId(TenantContext.getTenantId()); + memory.setSourceDocId(doc.getId()); + memory.setContextJson(contextJson(doc, lines)); + memory.setVoucherJson(toJson(snapshot(lines(voucher.getId())))); + memory.setCorrectionDiffJson("{\"selectedAccount\":" + toJson(request.accountCode()) + + ",\"reason\":" + toJson(request.reason()) + "}"); + memory.setQuality(BigDecimal.valueOf(1.0)); + caseMemoryRepository.save(memory); + } + + private void createPushTask(Voucher voucher) { + if (voucher.getTargetSystem() == null) { + return; + } + TargetSystem target = targetSystemRepository + .findByTenantIdAndTargetSystem(TenantContext.getTenantId(), voucher.getTargetSystem()) + .orElse(null); + if (target == null) { + auditService.log("PUSH_TARGET_MISSING", "VOUCHER", voucher.getId(), + "{\"targetSystem\":\"" + voucher.getTargetSystem() + "\"}"); + return; + } + String idempotencyKey = "AV:" + voucher.getTenantId() + ":" + target.getTargetSystem() + + ":" + voucher.getVoucherNo(); + if (pushTaskRepository.findByTenantIdAndIdempotencyKey(TenantContext.getTenantId(), idempotencyKey).isPresent()) { + return; + } + PushTask task = new PushTask(); + task.setTenantId(TenantContext.getTenantId()); + task.setVoucherId(voucher.getId()); + task.setTargetSystem(target.getTargetSystem()); + task.setAdapterCode(target.getAdapterCode()); + task.setStatus("PENDING"); + task.setAttemptCount(0); + task.setMaxAttempts(5); + task.setNextRetryAt(LocalDateTime.now()); + task.setIdempotencyKey(idempotencyKey); + pushTaskRepository.save(task); + } + + private Account resolveAccount(Voucher voucher, String code) { + if (code == null || code.isBlank()) { + throw ApiException.badRequest("ACCOUNT_CODE_REQUIRED", "科目编码不能为空"); + } + Account account = accountRepository + .findByTenantIdAndCompanyIdAndBookIdAndCode(TenantContext.getTenantId(), + voucher.getCompanyId(), voucher.getBookId(), code) + .orElseThrow(() -> ApiException.badRequest("ACCOUNT_NOT_FOUND", "科目不存在: " + code)); + if (Boolean.FALSE.equals(account.getIsLeaf())) { + throw ApiException.badRequest("ACCOUNT_NOT_LEAF", "只能选择末级科目: " + code); + } + return account; + } + + private void validateAux(Account account, Map aux) { + if (account.getAuxRequiredJson() == null) { + return; + } + try { + JsonNode required = objectMapper.readTree(account.getAuxRequiredJson()); + if (!required.isArray()) { + return; + } + for (JsonNode item : required) { + String key = item.asText(); + if (aux == null || aux.get(key) == null || aux.get(key).isBlank()) { + throw ApiException.badRequest("AUX_REQUIRED", + "科目 " + account.getCode() + " 必须填写辅助核算: " + key); + } + } + } catch (ApiException e) { + throw e; + } catch (Exception ignored) { + // A broken master-data attribute must not block review; report via audit elsewhere. + } + } + + private void recordCorrection(Voucher voucher, List beforeLines, + List afterLines, String reason) { + Correction correction = new Correction(); + correction.setTenantId(TenantContext.getTenantId()); + correction.setVoucherId(voucher.getId()); + correction.setSourceDocId(voucher.getSourceDocId()); + correction.setOriginalJson(toJson(snapshot(beforeLines))); + correction.setCorrectedJson(toJson(snapshot(afterLines))); + correction.setDiffJson(diffJson(beforeLines, afterLines)); + correction.setReason(reason == null || reason.isBlank() ? "REVIEWER_ADJUSTMENT" : reason); + correction.setOperatorId(auditService.currentOperator()); + correction.setScope("CASE"); + correctionRepository.save(correction); + + SourceDoc doc = sourceDocRepository.findById(voucher.getSourceDocId()).orElse(null); + if (doc != null) { + CaseMemory memory = new CaseMemory(); + memory.setTenantId(TenantContext.getTenantId()); + memory.setSourceDocId(doc.getId()); + List sourceLines = sourceLineRepository + .findAllByTenantIdAndSourceDocIdOrderByLineNoAsc(TenantContext.getTenantId(), doc.getId()); + memory.setContextJson(contextJson(doc, sourceLines)); + memory.setVoucherJson(toJson(snapshot(afterLines))); + memory.setCorrectionDiffJson(correction.getDiffJson()); + memory.setQuality(BigDecimal.valueOf(0.8)); + caseMemoryRepository.save(memory); + createRuleCandidate(doc, sourceLines, beforeLines, afterLines); + } + } + + private void createRuleCandidate(SourceDoc doc, List sourceLines, + List beforeLines, List afterLines) { + String beforeDebit = beforeLines.stream().filter(l -> "DEBIT".equals(l.getDirection())) + .map(VoucherLine::getAccountCode).findFirst().orElse(null); + String afterDebit = afterLines.stream().filter(l -> "DEBIT".equals(l.getDirection())) + .map(VoucherLine::getAccountCode).findFirst().orElse(null); + if (afterDebit == null || Objects.equals(beforeDebit, afterDebit)) { + return; + } + String category = sourceLines.isEmpty() ? "UNKNOWN" : sourceLines.get(0).getExpenseCategory(); + String ruleCode = "CAND-" + category + "-" + afterDebit.replace('.', '_'); + RuleCandidate candidate = ruleCandidateRepository + .findByTenantIdAndRuleCode(TenantContext.getTenantId(), ruleCode) + .orElseGet(RuleCandidate::new); + if (candidate.getId() == null) { + candidate.setTenantId(TenantContext.getTenantId()); + candidate.setRuleCode(ruleCode); + candidate.setName("学习规则: " + category + " -> " + afterDebit); + candidate.setConditionJson("{\"all\":[{\"field\":\"expenseCategory\",\"op\":\"eq\",\"value\":" + + toJson(category) + "}]}"); + candidate.setResultJson("{\"accountCode\":" + toJson(afterDebit) + "}"); + candidate.setSupportCount(1); + candidate.setStatus("PENDING"); + candidate.setSource("REVIEWER_CORRECTION"); + } else { + candidate.setSupportCount(candidate.getSupportCount() + 1); + } + ruleCandidateRepository.save(candidate); + } + + private String contextJson(SourceDoc doc, List lines) { + Map context = new LinkedHashMap<>(); + context.put("docNo", doc.getDocNo()); + context.put("docType", doc.getDocType()); + context.put("businessScene", doc.getBusinessScene()); + context.put("isBusinessTrip", doc.getIsBusinessTrip()); + context.put("departmentId", doc.getDepartmentId()); + context.put("applicantId", doc.getApplicantId()); + List> lineFacts = new ArrayList<>(); + for (SourceLine line : lines) { + Map fact = new LinkedHashMap<>(); + fact.put("expenseCategory", line.getExpenseCategory()); + fact.put("description", line.getDescription()); + fact.put("amount", line.getAmount()); + fact.put("projectId", line.getProjectId()); + fact.put("merchantId", line.getMerchantId()); + lineFacts.add(fact); + } + context.put("lines", lineFacts); + return toJson(context); + } + + private List> snapshot(List lines) { + List> result = new ArrayList<>(); + for (VoucherLine line : lines) { + Map item = new LinkedHashMap<>(); + item.put("lineNo", line.getLineNo()); + item.put("direction", line.getDirection()); + item.put("accountCode", line.getAccountCode()); + item.put("amount", line.getAmount()); + item.put("aux", parseJson(line.getAuxJson())); + result.add(item); + } + return result; + } + + private String diffJson(List before, List after) { + Map diff = new LinkedHashMap<>(); + diff.put("before", snapshot(before)); + diff.put("after", snapshot(after)); + return toJson(diff); + } + + private Object parseJson(String json) { + if (json == null) { + return Map.of(); + } + try { + return objectMapper.readValue(json, Object.class); + } catch (Exception e) { + return json; + } + } + + private BigDecimal scale(BigDecimal value) { + return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP); + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + return "null"; + } + } +} diff --git a/src/main/java/com/autovoucher/service/VoucherSuggestionService.java b/src/main/java/com/autovoucher/service/VoucherSuggestionService.java new file mode 100644 index 0000000..519e4b1 --- /dev/null +++ b/src/main/java/com/autovoucher/service/VoucherSuggestionService.java @@ -0,0 +1,332 @@ +package com.autovoucher.service; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Account; +import com.autovoucher.domain.Book; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.SourceLine; +import com.autovoucher.repository.AccountRepository; +import com.autovoucher.repository.BookRepository; +import com.autovoucher.rule.RuleEngine; +import com.autovoucher.rule.RuleMatch; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Turns structured transaction facts into a balanced voucher proposal. + * + *

The sequence is: deterministic rule match -> account group -> master-data + * account resolution -> auxiliary value resolution -> VAT split -> validation. + * The AI layer in production only supplies/augments facts and ranks ambiguous + * candidates; account selection stays rule- and master-data-driven.

+ */ +@Service +public class VoucherSuggestionService { + + private final RuleEngine ruleEngine; + private final AccountResolver accountResolver; + private final AccountRepository accountRepository; + private final BookRepository bookRepository; + private final ObjectMapper objectMapper; + + public VoucherSuggestionService(RuleEngine ruleEngine, + AccountResolver accountResolver, + AccountRepository accountRepository, + BookRepository bookRepository, + ObjectMapper objectMapper) { + this.ruleEngine = ruleEngine; + this.accountResolver = accountResolver; + this.accountRepository = accountRepository; + this.bookRepository = bookRepository; + this.objectMapper = objectMapper; + } + + public SuggestionOutcome suggest(SourceDoc doc, List sourceLines) { + Book book = bookRepository.findByTenantIdAndBookId(TenantContext.getTenantId(), doc.getBookId()) + .orElseThrow(() -> ApiException.badRequest("BOOK_NOT_FOUND", "账簿不存在: " + doc.getBookId())); + + Map docFacts = documentFacts(doc); + List suggested = new ArrayList<>(); + Set matchedRules = new LinkedHashSet<>(); + ArrayNode trace = objectMapper.createArrayNode(); + BigDecimal debitTotal = BigDecimal.ZERO; + + for (SourceLine sourceLine : sourceLines) { + Map facts = new LinkedHashMap<>(docFacts); + facts.putAll(lineFacts(sourceLine)); + + RuleMatch match = ruleEngine.matchFirst(facts); + if (match == null) { + List candidates = candidates(doc, sourceLine, facts); + ObjectNode manualTrace = objectMapper.createObjectNode(); + manualTrace.put("lineNo", sourceLine.getLineNo()); + manualTrace.put("reason", "NO_RULE_MATCH"); + manualTrace.set("facts", objectMapper.valueToTree(facts)); + manualTrace.set("candidates", objectMapper.valueToTree(candidates)); + trace.add(manualTrace); + return SuggestionOutcome.manual("MISSING_BUSINESS_CONTEXT", candidates, trace.toString()); + } + matchedRules.add(match.rule().getRuleCode()); + + JsonNode result = parse(match.rule().getResultJson()); + String accountGroup = result.path("accountGroup").asText(null); + String explicitAccount = result.path("accountCode").asText(null); + if (accountGroup == null && explicitAccount == null) { + return SuggestionOutcome.manual("RULE_RESULT_INVALID", + List.of(), trace.toString()); + } + + ResolvedAccount resolvedAccount; + try { + resolvedAccount = explicitAccount != null + ? resolveAccount(doc, explicitAccount, accountGroup) + : accountResolver.resolve(accountGroup, doc.getCompanyId(), doc.getBookId(), + doc.getDepartmentId()); + } catch (ApiException e) { + ObjectNode error = objectMapper.createObjectNode(); + error.put("lineNo", sourceLine.getLineNo()); + error.put("reason", e.getCode()); + error.put("message", e.getMessage()); + trace.add(error); + return SuggestionOutcome.manual("ACCOUNT_NOT_RESOLVED", List.of(), trace.toString()); + } + + Map aux = resolveAux(result.get("aux"), facts); + BigDecimal amount = nvl(sourceLine.getAmount()); + BigDecimal taxAmount = nvl(sourceLine.getTaxAmount()); + boolean vatDeductible = result.has("vatDeductible") + ? result.get("vatDeductible").asBoolean() + : "SPECIAL".equalsIgnoreCase(sourceLine.getInvoiceType()) && taxAmount.signum() > 0; + + BigDecimal baseAmount = amount; + if (vatDeductible && taxAmount.signum() > 0) { + baseAmount = sourceLine.getAmountExcludingTax() != null + ? sourceLine.getAmountExcludingTax() + : amount.subtract(taxAmount); + ResolvedAccount vatAccount = accountResolver.resolve("INPUT_VAT", + doc.getCompanyId(), doc.getBookId(), doc.getDepartmentId()); + suggested.add(new SuggestedLine( + "DEBIT", + vatAccount.account().getCode(), + vatAccount.account().getName(), + vatAccount.accountGroup(), + scale(taxAmount), + null, + null, + Map.of(), + "进项税额 - " + nvlText(sourceLine.getDescription()), + "RULE", + BigDecimal.valueOf(0.95), + evidence(match, sourceLine, taxAmount, true) + )); + debitTotal = debitTotal.add(scale(taxAmount)); + } + + suggested.add(new SuggestedLine( + "DEBIT", + resolvedAccount.account().getCode(), + resolvedAccount.account().getName(), + resolvedAccount.accountGroup(), + scale(baseAmount), + sourceLine.getTaxRate(), + vatDeductible ? scale(taxAmount) : BigDecimal.ZERO, + aux, + nvlText(sourceLine.getDescription()), + "RULE", + BigDecimal.valueOf(0.92), + evidence(match, sourceLine, baseAmount, false) + )); + debitTotal = debitTotal.add(scale(baseAmount)); + + ObjectNode lineTrace = objectMapper.createObjectNode(); + lineTrace.put("lineNo", sourceLine.getLineNo()); + lineTrace.put("matchedRule", match.rule().getRuleCode()); + lineTrace.put("accountGroup", accountGroup); + lineTrace.put("accountCode", resolvedAccount.account().getCode()); + lineTrace.set("facts", objectMapper.valueToTree(facts)); + trace.add(lineTrace); + } + + // Expense reimbursement is credited to the employee payable account. + ResolvedAccount payable = accountResolver.resolve("EMPLOYEE_PAYABLE", + doc.getCompanyId(), doc.getBookId(), doc.getDepartmentId()); + String employee = doc.getApplicantId(); + Map creditAux = employee == null ? Map.of() : Map.of("EMP", employee); + suggested.add(new SuggestedLine( + "CREDIT", + payable.account().getCode(), + payable.account().getName(), + payable.accountGroup(), + scale(debitTotal), + null, + null, + creditAux, + "员工报销款 - " + nvlText(doc.getApplicantName()), + "RULE", + BigDecimal.valueOf(0.95), + "{\"rule\":\"EMPLOYEE_PAYABLE\"}" + )); + + ObjectNode summary = objectMapper.createObjectNode(); + summary.put("book", book.getBookId()); + summary.put("period", book.getCurrentPeriod()); + summary.put("lineCount", suggested.size()); + summary.put("debitTotal", scale(debitTotal)); + trace.add(summary); + + return new SuggestionOutcome(false, null, List.of(), suggested, + String.join(",", matchedRules), BigDecimal.valueOf(0.92), trace.toString()); + } + + private List candidates(SourceDoc doc, SourceLine line, Map facts) { + List partial = new ArrayList<>(ruleEngine.partialMatchesByCategory( + line.getExpenseCategory(), facts)); + // Lower rule priority first: those broad-context rules are the usual + // fallback choices when the scene is unknown (e.g. local transport + // before travel), while the reviewer still sees every candidate. + partial.sort((a, b) -> { + int byPriority = Integer.compare( + a.rule().getPriority() == null ? 0 : a.rule().getPriority(), + b.rule().getPriority() == null ? 0 : b.rule().getPriority()); + return byPriority != 0 ? byPriority : a.rule().getRuleCode().compareTo(b.rule().getRuleCode()); + }); + Set codes = new LinkedHashSet<>(); + for (RuleMatch match : partial) { + try { + JsonNode result = parse(match.rule().getResultJson()); + String group = result.path("accountGroup").asText(null); + String explicit = result.path("accountCode").asText(null); + if (group == null && explicit == null) { + continue; + } + ResolvedAccount account = explicit != null + ? resolveAccount(doc, explicit, group) + : accountResolver.resolve(group, doc.getCompanyId(), + doc.getBookId(), doc.getDepartmentId()); + codes.add(account.account().getCode()); + } catch (ApiException ignored) { + // A broken candidate must not hide the remaining suggestions. + } + } + return List.copyOf(codes); + } + + private ResolvedAccount resolveAccount(SourceDoc doc, String accountCode, String accountGroup) { + Account account = accountRepository + .findByTenantIdAndCompanyIdAndBookIdAndCode(TenantContext.getTenantId(), + doc.getCompanyId(), doc.getBookId(), accountCode) + .orElseThrow(() -> ApiException.badRequest("ACCOUNT_NOT_FOUND", + "科目不存在: " + accountCode)); + return new ResolvedAccount(account, accountGroup); + } + + private Map documentFacts(SourceDoc doc) { + Map facts = new LinkedHashMap<>(); + facts.put("sourceSystem", doc.getSourceSystem()); + facts.put("docNo", doc.getDocNo()); + facts.put("docType", doc.getDocType()); + facts.put("companyId", doc.getCompanyId()); + facts.put("bookId", doc.getBookId()); + facts.put("applicantId", doc.getApplicantId()); + facts.put("departmentId", doc.getDepartmentId()); + facts.put("payeeType", doc.getPayeeType()); + facts.put("payeeId", doc.getPayeeId()); + facts.put("businessScene", doc.getBusinessScene()); + facts.put("isBusinessTrip", doc.getIsBusinessTrip()); + facts.put("tripId", doc.getTripId()); + facts.put("currency", doc.getCurrency()); + facts.put("totalAmount", doc.getTotalAmount()); + facts.put("taxAmount", doc.getTaxAmount()); + return facts; + } + + private Map lineFacts(SourceLine line) { + Map facts = new LinkedHashMap<>(); + facts.put("lineNo", line.getLineNo()); + facts.put("expenseCategory", line.getExpenseCategory()); + facts.put("description", line.getDescription()); + facts.put("amount", line.getAmount()); + facts.put("amountExcludingTax", line.getAmountExcludingTax()); + facts.put("taxIncluded", line.getTaxIncluded()); + facts.put("taxRate", line.getTaxRate()); + facts.put("taxAmount", line.getTaxAmount()); + facts.put("invoiceType", line.getInvoiceType()); + facts.put("occurredAt", line.getOccurredAt() == null ? null : line.getOccurredAt().toString()); + facts.put("merchantId", line.getMerchantId()); + facts.put("merchantName", line.getMerchantName()); + facts.put("customerId", line.getCustomerId()); + facts.put("projectId", line.getProjectId()); + facts.put("costCenterId", line.getCostCenterId()); + facts.put("invoiceNo", line.getInvoiceNo()); + return facts; + } + + private Map resolveAux(JsonNode auxNode, Map facts) { + Map aux = new LinkedHashMap<>(); + if (auxNode == null || !auxNode.isObject()) { + return aux; + } + auxNode.fields().forEachRemaining(entry -> { + String raw = entry.getValue().asText(); + String value; + if (raw.startsWith("$")) { + Object fact = facts.get(raw.substring(1)); + value = fact == null ? null : String.valueOf(fact); + } else { + value = raw; + } + if (value != null && !value.isBlank() && !"null".equals(value)) { + aux.put(entry.getKey(), value); + } + }); + return aux; + } + + private String evidence(RuleMatch match, SourceLine line, BigDecimal amount, boolean taxLine) { + try { + ObjectNode node = objectMapper.createObjectNode(); + node.put("ruleCode", match.rule().getRuleCode()); + node.put("ruleName", match.rule().getName()); + node.put("lineNo", line.getLineNo()); + node.put("expenseCategory", line.getExpenseCategory()); + node.put("amount", scale(amount)); + node.put("taxLine", taxLine); + return node.toString(); + } catch (Exception e) { + return "{}"; + } + } + + private JsonNode parse(String json) { + try { + return objectMapper.readTree(json); + } catch (Exception e) { + throw ApiException.badRequest("INVALID_RULE_JSON", "规则结果 JSON 无法解析: " + e.getMessage()); + } + } + + private BigDecimal nvl(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private BigDecimal scale(BigDecimal value) { + return nvl(value).setScale(2, RoundingMode.HALF_UP); + } + + private String nvlText(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/autovoucher/source/MockSeeyonSourceAdapter.java b/src/main/java/com/autovoucher/source/MockSeeyonSourceAdapter.java new file mode 100644 index 0000000..7ff1efe --- /dev/null +++ b/src/main/java/com/autovoucher/source/MockSeeyonSourceAdapter.java @@ -0,0 +1,48 @@ +package com.autovoucher.source; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.repository.SourceDocRepository; +import com.autovoucher.seed.SeedCatalog; +import org.springframework.stereotype.Component; + +/** + * Mock 致远 OA adapter. It prefers the payload stored in av_source_doc.raw_json + * and falls back to testdata/seed.json so a document that was never ingested can + * still be fetched (the OA_VIEW_TRIGGERS_GENERATION scenario). The real adapter + * will call 致远's document API with the same interface. + */ +@Component +public class MockSeeyonSourceAdapter implements SourceAdapter { + + private final SourceDocRepository sourceDocRepository; + private final PayloadMapper payloadMapper; + private final SeedCatalog seedCatalog; + + public MockSeeyonSourceAdapter(SourceDocRepository sourceDocRepository, + PayloadMapper payloadMapper, + SeedCatalog seedCatalog) { + this.sourceDocRepository = sourceDocRepository; + this.payloadMapper = payloadMapper; + this.seedCatalog = seedCatalog; + } + + @Override + public String code() { + return "SEEYON"; + } + + @Override + public SourceDocPayload fetch(String externalId) { + SourceDoc doc = sourceDocRepository + .findByTenantIdAndSourceSystemAndExternalId(TenantContext.getTenantId(), "SEEYON", externalId) + .orElse(null); + if (doc != null && doc.getRawJson() != null) { + return payloadMapper.parse(doc.getRawJson()); + } + return seedCatalog.documentPayload(externalId) + .orElseThrow(() -> ApiException.notFound("OA_DOCUMENT_NOT_FOUND", + "致远 OA 未找到单据: " + externalId)); + } +} diff --git a/src/main/java/com/autovoucher/source/PayloadMapper.java b/src/main/java/com/autovoucher/source/PayloadMapper.java new file mode 100644 index 0000000..3cdc73b --- /dev/null +++ b/src/main/java/com/autovoucher/source/PayloadMapper.java @@ -0,0 +1,132 @@ +package com.autovoucher.source; + +import com.autovoucher.common.ApiException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +@Component +public class PayloadMapper { + + private final ObjectMapper objectMapper; + + public PayloadMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public SourceDocPayload parse(String json) { + try { + return fromJson(objectMapper.readTree(json)); + } catch (ApiException e) { + throw e; + } catch (Exception e) { + throw ApiException.badRequest("INVALID_SOURCE_PAYLOAD", "OA 单据报文解析失败: " + e.getMessage()); + } + } + + public SourceDocPayload fromJson(JsonNode node) { + List lines = new ArrayList<>(); + JsonNode lineNodes = node.path("lines"); + if (lineNodes.isArray()) { + for (JsonNode line : lineNodes) { + lines.add(new SourceDocPayload.Line( + intOrNull(line, "lineNo"), + text(line, "expenseCategory"), + text(line, "description"), + decimal(line, "amount"), + decimal(line, "amountExcludingTax"), + bool(line, "taxIncluded"), + decimal(line, "taxRate"), + decimal(line, "taxAmount"), + text(line, "invoiceType"), + localDate(line, "occurredAt"), + text(line, "merchantId"), + text(line, "merchantName"), + text(line, "customerId"), + text(line, "projectId"), + text(line, "costCenterId"), + text(line, "invoiceNo"), + line.toString() + )); + } + } + + List viewerIds = new ArrayList<>(); + JsonNode viewerNodes = node.path("viewerIds"); + if (viewerNodes.isArray()) { + viewerNodes.forEach(v -> viewerIds.add(v.asText())); + } + + return new SourceDocPayload( + text(node, "sourceSystem"), + text(node, "externalId"), + text(node, "docNo"), + text(node, "docType"), + text(node, "title"), + text(node, "companyId"), + text(node, "bookId"), + text(node, "applicantId"), + text(node, "applicantName"), + text(node, "departmentId"), + text(node, "departmentName"), + text(node, "payeeType"), + text(node, "payeeId"), + text(node, "payeeName"), + text(node, "currency"), + decimal(node, "totalAmount"), + decimal(node, "taxAmount"), + text(node, "businessScene"), + bool(node, "isBusinessTrip"), + text(node, "tripId"), + text(node, "approvalNode"), + localDateTime(node, "approvedAt"), + viewerIds, + node.toString(), + lines + ); + } + + private String text(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } + + private BigDecimal decimal(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.decimalValue(); + } + + private Integer intOrNull(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asInt(); + } + + private Boolean bool(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asBoolean(); + } + + private LocalDate localDate(JsonNode node, String field) { + String value = text(node, field); + return value == null ? null : LocalDate.parse(value); + } + + private LocalDateTime localDateTime(JsonNode node, String field) { + String value = text(node, field); + if (value == null) { + return null; + } + try { + return OffsetDateTime.parse(value).toLocalDateTime(); + } catch (Exception ignored) { + return LocalDateTime.parse(value); + } + } +} diff --git a/src/main/java/com/autovoucher/source/SourceAdapter.java b/src/main/java/com/autovoucher/source/SourceAdapter.java new file mode 100644 index 0000000..91da982 --- /dev/null +++ b/src/main/java/com/autovoucher/source/SourceAdapter.java @@ -0,0 +1,13 @@ +package com.autovoucher.source; + +/** + * Boundary between autoVoucher and an OA product (致远 / 泛微 / 蓝凌 / ...). + * Real adapters will call the vendor API or consume its push payload; the Mock + * adapter replays the local seed data. + */ +public interface SourceAdapter { + + String code(); + + SourceDocPayload fetch(String externalId); +} diff --git a/src/main/java/com/autovoucher/source/SourceAdapterRegistry.java b/src/main/java/com/autovoucher/source/SourceAdapterRegistry.java new file mode 100644 index 0000000..b5914bc --- /dev/null +++ b/src/main/java/com/autovoucher/source/SourceAdapterRegistry.java @@ -0,0 +1,28 @@ +package com.autovoucher.source; + +import com.autovoucher.common.ApiException; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class SourceAdapterRegistry { + + private final Map adapters; + + public SourceAdapterRegistry(List adapterList) { + this.adapters = adapterList.stream() + .collect(Collectors.toUnmodifiableMap(SourceAdapter::code, Function.identity())); + } + + public SourceAdapter get(String code) { + SourceAdapter adapter = adapters.get(code); + if (adapter == null) { + throw ApiException.badRequest("UNSUPPORTED_SOURCE_ADAPTER", "未配置 OA 适配器: " + code); + } + return adapter; + } +} diff --git a/src/main/java/com/autovoucher/source/SourceDocPayload.java b/src/main/java/com/autovoucher/source/SourceDocPayload.java new file mode 100644 index 0000000..eacc30d --- /dev/null +++ b/src/main/java/com/autovoucher/source/SourceDocPayload.java @@ -0,0 +1,60 @@ +package com.autovoucher.source; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Normalized document as delivered by an OA source adapter. The seed loader and + * the Mock 致远 adapter both produce this shape, so the ingestion pipeline never + * depends on a specific OA vendor. + */ +public record SourceDocPayload( + String sourceSystem, + String externalId, + String docNo, + String docType, + String title, + String companyId, + String bookId, + String applicantId, + String applicantName, + String departmentId, + String departmentName, + String payeeType, + String payeeId, + String payeeName, + String currency, + BigDecimal totalAmount, + BigDecimal taxAmount, + String businessScene, + Boolean isBusinessTrip, + String tripId, + String approvalNode, + LocalDateTime approvedAt, + List viewerIds, + String rawJson, + List lines +) { + public record Line( + Integer lineNo, + String expenseCategory, + String description, + BigDecimal amount, + BigDecimal amountExcludingTax, + Boolean taxIncluded, + BigDecimal taxRate, + BigDecimal taxAmount, + String invoiceType, + LocalDate occurredAt, + String merchantId, + String merchantName, + String customerId, + String projectId, + String costCenterId, + String invoiceNo, + String rawJson + ) { + } +} diff --git a/src/main/java/com/autovoucher/web/AuditLogController.java b/src/main/java/com/autovoucher/web/AuditLogController.java new file mode 100644 index 0000000..45f7969 --- /dev/null +++ b/src/main/java/com/autovoucher/web/AuditLogController.java @@ -0,0 +1,49 @@ +package com.autovoucher.web; + +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.AuditLog; +import com.autovoucher.repository.AuditLogRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Locale; + +@RestController +@RequestMapping("/api/audit-logs") +public class AuditLogController { + + private final AuditLogRepository auditLogRepository; + + public AuditLogController(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + @GetMapping + public List list(@RequestParam(required = false) String action, + @RequestParam(required = false) String entityType, + @RequestParam(required = false) String entityId, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "200") int limit) { + String kw = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + return auditLogRepository.findAllByTenantIdOrderByIdDesc(TenantContext.getTenantId()).stream() + .filter(l -> action == null || action.isBlank() || action.equalsIgnoreCase(l.getAction())) + .filter(l -> entityType == null || entityType.isBlank() + || entityType.equalsIgnoreCase(l.getEntityType())) + .filter(l -> entityId == null || entityId.isBlank() || entityId.equals(l.getEntityId())) + .filter(l -> kw.isEmpty() + || contains(l.getAction(), kw) + || contains(l.getEntityType(), kw) + || contains(l.getEntityId(), kw) + || contains(l.getOperatorId(), kw) + || contains(l.getDetailJson(), kw)) + .limit(Math.max(1, Math.min(limit, 2000))) + .toList(); + } + + private boolean contains(String value, String keyword) { + return value != null && value.toLowerCase(Locale.ROOT).contains(keyword); + } +} diff --git a/src/main/java/com/autovoucher/web/AuthController.java b/src/main/java/com/autovoucher/web/AuthController.java new file mode 100644 index 0000000..9579c48 --- /dev/null +++ b/src/main/java/com/autovoucher/web/AuthController.java @@ -0,0 +1,84 @@ +package com.autovoucher.web; + +import com.autovoucher.dto.LoginRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.SecurityContextRepository; +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.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final SecurityContextRepository securityContextRepository; + + public AuthController(AuthenticationManager authenticationManager, + SecurityContextRepository securityContextRepository) { + this.authenticationManager = authenticationManager; + this.securityContextRepository = securityContextRepository; + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request, + HttpServletRequest servletRequest, + HttpServletResponse servletResponse) { + if (request.username() == null || request.password() == null) { + return ResponseEntity.badRequest().body(Map.of("code", "LOGIN_INVALID", + "message", "请输入用户名和密码")); + } + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.username(), request.password())); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, servletRequest, servletResponse); + return ResponseEntity.ok(principal(authentication)); + } catch (BadCredentialsException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("code", "LOGIN_FAILED", "message", "用户名或密码错误")); + } + } + + @GetMapping("/me") + public Map me(Authentication authentication) { + return principal(authentication); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + SecurityContextHolder.clearContext(); + return ResponseEntity.noContent().build(); + } + + private Map principal(Authentication authentication) { + List roles = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority).toList(); + return Map.of( + "username", authentication.getName(), + "roles", roles + ); + } +} diff --git a/src/main/java/com/autovoucher/web/DashboardController.java b/src/main/java/com/autovoucher/web/DashboardController.java new file mode 100644 index 0000000..782ba55 --- /dev/null +++ b/src/main/java/com/autovoucher/web/DashboardController.java @@ -0,0 +1,92 @@ +package com.autovoucher.web; + +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.PushTask; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.Voucher; +import com.autovoucher.repository.CorrectionRepository; +import com.autovoucher.repository.PushTaskRepository; +import com.autovoucher.repository.SourceDocRepository; +import com.autovoucher.repository.VoucherRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/dashboard") +public class DashboardController { + + private final SourceDocRepository sourceDocRepository; + private final VoucherRepository voucherRepository; + private final PushTaskRepository pushTaskRepository; + private final CorrectionRepository correctionRepository; + + public DashboardController(SourceDocRepository sourceDocRepository, + VoucherRepository voucherRepository, + PushTaskRepository pushTaskRepository, + CorrectionRepository correctionRepository) { + this.sourceDocRepository = sourceDocRepository; + this.voucherRepository = voucherRepository; + this.pushTaskRepository = pushTaskRepository; + this.correctionRepository = correctionRepository; + } + + @GetMapping + public Map dashboard() { + Long tenantId = TenantContext.getTenantId(); + List docs = sourceDocRepository.findAllByTenantIdOrderByIdDesc(tenantId); + List vouchers = voucherRepository.findAllByTenantIdOrderByIdDesc(tenantId); + List tasks = pushTaskRepository.findAllByTenantIdOrderByIdDesc(tenantId); + + Map body = new LinkedHashMap<>(); + body.put("sourceDocs", counts(docs.stream().map(SourceDoc::getProcessingStatus).toList())); + body.put("vouchers", counts(vouchers.stream().map(Voucher::getStatus).toList())); + body.put("pushTasks", counts(tasks.stream().map(PushTask::getStatus).toList())); + body.put("corrections", correctionRepository.findAllByTenantIdOrderByIdDesc(tenantId).size()); + + body.put("recentVouchers", vouchers.stream().limit(8).map(v -> { + Map item = new LinkedHashMap<>(); + item.put("id", v.getId()); + item.put("voucherNo", v.getVoucherNo()); + item.put("status", v.getStatus()); + item.put("period", v.getPeriod()); + item.put("confidence", v.getConfidence()); + item.put("totalDebit", v.getTotalDebit()); + item.put("currency", v.getCurrency()); + item.put("sourceDocId", v.getSourceDocId()); + item.put("createdAt", v.getCreatedAt()); + return item; + }).toList()); + + body.put("recentDocs", docs.stream().limit(8).map(d -> { + Map item = new LinkedHashMap<>(); + item.put("id", d.getId()); + item.put("docNo", d.getDocNo()); + item.put("title", d.getTitle()); + item.put("applicantName", d.getApplicantName()); + item.put("totalAmount", d.getTotalAmount()); + item.put("processingStatus", d.getProcessingStatus()); + item.put("approvedAt", d.getApprovedAt()); + item.put("createdAt", d.getCreatedAt()); + return item; + }).toList()); + + return body; + } + + private Map counts(List statuses) { + Map byStatus = statuses.stream() + .filter(s -> s != null) + .collect(Collectors.groupingBy(s -> s, TreeMap::new, Collectors.counting())); + Map map = new LinkedHashMap<>(); + map.put("total", statuses.size()); + map.put("byStatus", byStatus); + return map; + } +} diff --git a/src/main/java/com/autovoucher/web/DevController.java b/src/main/java/com/autovoucher/web/DevController.java new file mode 100644 index 0000000..c3945d0 --- /dev/null +++ b/src/main/java/com/autovoucher/web/DevController.java @@ -0,0 +1,210 @@ +package com.autovoucher.web; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.AppProperties; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.OaViewLog; +import com.autovoucher.domain.OaViewTicket; +import com.autovoucher.dto.DevBehaviorRequest; +import com.autovoucher.dto.DevOaEventRequest; +import com.autovoucher.dto.DevOaTicketRequest; +import com.autovoucher.dto.OaSignResponse; +import com.autovoucher.dto.OaTicketRequest; +import com.autovoucher.dto.OaTicketResponse; +import com.autovoucher.push.MockNccVoucherClient; +import com.autovoucher.push.NccMockBehavior; +import com.autovoucher.repository.OaViewLogRepository; +import com.autovoucher.repository.OaViewTicketRepository; +import com.autovoucher.service.IngestResult; +import com.autovoucher.service.OaViewService; +import com.autovoucher.service.SourceIngestService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Demo/test console endpoints. They exercise the same services used by the + * real OA and NCC adapters; no production integration is implemented here. + */ +@RestController +@RequestMapping("/api/dev") +public class DevController { + + private static final Set MOCK_BEHAVIORS = Set.of("SUCCESS", "DUPLICATE", "TIMEOUT", "ERROR"); + + private final AppProperties properties; + private final OaViewService oaViewService; + private final SourceIngestService sourceIngestService; + private final OaViewTicketRepository ticketRepository; + private final OaViewLogRepository logRepository; + private final NccMockBehavior nccMockBehavior; + private final MockNccVoucherClient nccClient; + + public DevController(AppProperties properties, + OaViewService oaViewService, + SourceIngestService sourceIngestService, + OaViewTicketRepository ticketRepository, + OaViewLogRepository logRepository, + NccMockBehavior nccMockBehavior, + MockNccVoucherClient nccClient) { + this.properties = properties; + this.oaViewService = oaViewService; + this.sourceIngestService = sourceIngestService; + this.ticketRepository = ticketRepository; + this.logRepository = logRepository; + this.nccMockBehavior = nccMockBehavior; + this.nccClient = nccClient; + } + + /** + * Builds a canonical string and HMAC signature exactly as the 致远 side + * would. The result can be replayed against POST /open/oa/view-ticket. + */ + @GetMapping("/oa/sign") + public OaSignResponse sign(@RequestParam(defaultValue = "SEEYON") String sourceSystem, + @RequestParam String externalId, + @RequestParam String operatorId) { + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = UUID.randomUUID().toString().replace("-", ""); + String canonical = OaViewService.canonical(sourceSystem, externalId, operatorId, timestamp, nonce); + String signature = oaViewService.sign(canonical, + properties.getOa().getSeeyon().getAppSecret()); + OaTicketRequest request = new OaTicketRequest(sourceSystem, externalId, operatorId, + timestamp, nonce, signature); + return new OaSignResponse(canonical, timestamp, nonce, signature, request); + } + + /** One-click signed exchange for the demo console. */ + @PostMapping("/oa/ticket") + public Map signedTicket(@RequestBody DevOaTicketRequest request, + HttpServletRequest servletRequest) { + requireTicketRequest(request); + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = UUID.randomUUID().toString().replace("-", ""); + String canonical = OaViewService.canonical(request.sourceSystem(), request.externalId(), + request.operatorId(), timestamp, nonce); + String signature = oaViewService.sign(canonical, + properties.getOa().getSeeyon().getAppSecret()); + OaTicketRequest signed = new OaTicketRequest(request.sourceSystem(), request.externalId(), + request.operatorId(), timestamp, nonce, signature); + OaTicketResponse ticket = oaViewService.issueSigned(signed, + servletRequest.getRemoteAddr(), servletRequest.getHeader("User-Agent")); + + Map body = new LinkedHashMap<>(); + body.put("request", signed); + body.put("ticket", ticket); + return body; + } + + /** Replays a pre-built signed request through the real verification path. */ + @PostMapping("/oa/exchange") + public OaTicketResponse exchange(@RequestBody OaTicketRequest request, + HttpServletRequest servletRequest) { + return oaViewService.issueSigned(request, + servletRequest.getRemoteAddr(), servletRequest.getHeader("User-Agent")); + } + + /** Issues a ticket without a signature; internal console convenience only. */ + @PostMapping("/oa/issue") + public OaTicketResponse issue(@RequestBody DevOaTicketRequest request, + HttpServletRequest servletRequest) { + requireTicketRequest(request); + return oaViewService.issueTrusted(request.sourceSystem(), request.externalId(), + request.operatorId(), servletRequest.getRemoteAddr(), + servletRequest.getHeader("User-Agent")); + } + + /** Simulates an OA document-approved event. */ + @PostMapping("/oa/event") + public IngestResult event(@RequestBody DevOaEventRequest request) { + if (request.externalId() == null || request.externalId().isBlank()) { + throw ApiException.badRequest("EXTERNAL_ID_REQUIRED", "externalId 不能为空"); + } + String eventId = request.eventId() == null || request.eventId().isBlank() + ? "EVT-DEV-" + UUID.randomUUID() : request.eventId(); + String sourceSystem = request.sourceSystem() == null || request.sourceSystem().isBlank() + ? "SEEYON" : request.sourceSystem(); + String eventType = request.eventType() == null || request.eventType().isBlank() + ? "APPROVED" : request.eventType(); + return sourceIngestService.receiveAndProcess(eventId, sourceSystem, request.externalId(), + eventType, LocalDateTime.now()); + } + + @PostMapping("/oa/expire/{ticketId}") + public Map expire(@PathVariable Long ticketId) { + OaViewTicket ticket = requireTicket(ticketId); + ticket.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + ticketRepository.save(ticket); + Map body = new LinkedHashMap<>(); + body.put("ticketId", ticket.getId()); + body.put("status", ticket.getStatus()); + body.put("expiresAt", ticket.getExpiresAt()); + return body; + } + + @GetMapping("/oa/tickets") + public List tickets() { + return ticketRepository.findAllByTenantIdOrderByIdDesc(TenantContext.getTenantId()); + } + + @GetMapping("/oa/logs") + public List logs(@RequestParam(required = false) Long ticketId) { + Long tenantId = TenantContext.getTenantId(); + if (ticketId != null) { + return logRepository.findAllByTenantIdAndTicketIdOrderByIdAsc(tenantId, ticketId); + } + return logRepository.findAllByTenantIdOrderByIdDesc(tenantId); + } + + @GetMapping("/ncc/behavior") + public Map behavior() { + return Map.of("behavior", nccMockBehavior.get()); + } + + @PostMapping("/ncc/behavior") + public Map setBehavior(@RequestBody DevBehaviorRequest request) { + String behavior = request.behavior() == null ? "" : request.behavior().trim().toUpperCase(Locale.ROOT); + if (!MOCK_BEHAVIORS.contains(behavior)) { + throw ApiException.badRequest("INVALID_MOCK_BEHAVIOR", + "behavior 仅支持: " + String.join(", ", MOCK_BEHAVIORS)); + } + nccMockBehavior.set(behavior); + return Map.of("behavior", nccMockBehavior.get()); + } + + @PostMapping("/ncc/reset") + public Map resetNcc() { + nccClient.clearAccepted(); + nccMockBehavior.set("SUCCESS"); + return Map.of("behavior", nccMockBehavior.get(), "accepted", 0); + } + + private OaViewTicket requireTicket(Long ticketId) { + return ticketRepository.findById(ticketId) + .filter(t -> TenantContext.getTenantId().equals(t.getTenantId())) + .orElseThrow(() -> ApiException.notFound("OA_TICKET_NOT_FOUND", "凭证查看票据不存在: " + ticketId)); + } + + private void requireTicketRequest(DevOaTicketRequest request) { + if (request == null || request.externalId() == null || request.externalId().isBlank() + || request.operatorId() == null || request.operatorId().isBlank()) { + throw ApiException.badRequest("OA_REQUEST_INVALID", "externalId 和 operatorId 不能为空"); + } + if (request.sourceSystem() == null || request.sourceSystem().isBlank()) { + throw ApiException.badRequest("OA_REQUEST_INVALID", "sourceSystem 不能为空"); + } + } +} diff --git a/src/main/java/com/autovoucher/web/LearningController.java b/src/main/java/com/autovoucher/web/LearningController.java new file mode 100644 index 0000000..6a7bcc6 --- /dev/null +++ b/src/main/java/com/autovoucher/web/LearningController.java @@ -0,0 +1,50 @@ +package com.autovoucher.web; + +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.CaseMemory; +import com.autovoucher.domain.Correction; +import com.autovoucher.repository.CaseMemoryRepository; +import com.autovoucher.repository.CorrectionRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/learning") +public class LearningController { + + private final CorrectionRepository correctionRepository; + private final CaseMemoryRepository caseMemoryRepository; + + public LearningController(CorrectionRepository correctionRepository, + CaseMemoryRepository caseMemoryRepository) { + this.correctionRepository = correctionRepository; + this.caseMemoryRepository = caseMemoryRepository; + } + + @GetMapping("/corrections") + public List corrections(@RequestParam(required = false) Long voucherId, + @RequestParam(required = false) Long sourceDocId) { + Long tenantId = TenantContext.getTenantId(); + if (voucherId != null) { + return correctionRepository.findAllByTenantIdAndVoucherIdOrderByIdDesc(tenantId, voucherId); + } + List corrections = correctionRepository.findAllByTenantIdOrderByIdDesc(tenantId); + if (sourceDocId != null) { + return corrections.stream().filter(c -> sourceDocId.equals(c.getSourceDocId())).toList(); + } + return corrections; + } + + @GetMapping("/cases") + public List cases(@RequestParam(required = false) Long sourceDocId) { + Long tenantId = TenantContext.getTenantId(); + if (sourceDocId != null) { + return caseMemoryRepository.findAllByTenantIdAndSourceDocIdOrderByIdDesc(tenantId, sourceDocId); + } + return caseMemoryRepository.findAllByTenantIdOrderByIdDesc(tenantId); + } +} diff --git a/src/main/java/com/autovoucher/web/MasterDataController.java b/src/main/java/com/autovoucher/web/MasterDataController.java new file mode 100644 index 0000000..9d323ee --- /dev/null +++ b/src/main/java/com/autovoucher/web/MasterDataController.java @@ -0,0 +1,147 @@ +package com.autovoucher.web; + +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Account; +import com.autovoucher.domain.AccountGroup; +import com.autovoucher.domain.AuxItem; +import com.autovoucher.domain.AuxValue; +import com.autovoucher.domain.Book; +import com.autovoucher.domain.Company; +import com.autovoucher.domain.ExpenseCategory; +import com.autovoucher.domain.SourceSystem; +import com.autovoucher.domain.TargetSystem; +import com.autovoucher.domain.VoucherType; +import com.autovoucher.repository.AccountGroupRepository; +import com.autovoucher.repository.AccountRepository; +import com.autovoucher.repository.AuxItemRepository; +import com.autovoucher.repository.AuxValueRepository; +import com.autovoucher.repository.BookRepository; +import com.autovoucher.repository.CompanyRepository; +import com.autovoucher.repository.ExpenseCategoryRepository; +import com.autovoucher.repository.SourceSystemRepository; +import com.autovoucher.repository.TargetSystemRepository; +import com.autovoucher.repository.VoucherTypeRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/master-data") +public class MasterDataController { + + private final CompanyRepository companyRepository; + private final BookRepository bookRepository; + private final AccountRepository accountRepository; + private final AccountGroupRepository accountGroupRepository; + private final AuxItemRepository auxItemRepository; + private final AuxValueRepository auxValueRepository; + private final ExpenseCategoryRepository expenseCategoryRepository; + private final VoucherTypeRepository voucherTypeRepository; + private final SourceSystemRepository sourceSystemRepository; + private final TargetSystemRepository targetSystemRepository; + + public MasterDataController(CompanyRepository companyRepository, + BookRepository bookRepository, + AccountRepository accountRepository, + AccountGroupRepository accountGroupRepository, + AuxItemRepository auxItemRepository, + AuxValueRepository auxValueRepository, + ExpenseCategoryRepository expenseCategoryRepository, + VoucherTypeRepository voucherTypeRepository, + SourceSystemRepository sourceSystemRepository, + TargetSystemRepository targetSystemRepository) { + this.companyRepository = companyRepository; + this.bookRepository = bookRepository; + this.accountRepository = accountRepository; + this.accountGroupRepository = accountGroupRepository; + this.auxItemRepository = auxItemRepository; + this.auxValueRepository = auxValueRepository; + this.expenseCategoryRepository = expenseCategoryRepository; + this.voucherTypeRepository = voucherTypeRepository; + this.sourceSystemRepository = sourceSystemRepository; + this.targetSystemRepository = targetSystemRepository; + } + + @GetMapping + public Map all(@RequestParam(required = false) String companyId, + @RequestParam(required = false) String bookId) { + Long tenantId = TenantContext.getTenantId(); + Map body = new LinkedHashMap<>(); + body.put("companies", companyRepository.findAllByTenantIdOrderByIdAsc(tenantId)); + body.put("books", bookRepository.findAllByTenantIdOrderByIdAsc(tenantId)); + body.put("accounts", accounts(companyId, bookId)); + body.put("accountGroups", accountGroupRepository.findAllByTenantIdOrderByGroupCodeAsc(tenantId)); + body.put("auxItems", auxItemRepository.findAllByTenantIdOrderByAuxTypeAsc(tenantId)); + body.put("auxValues", auxValueRepository.findAllByTenantIdOrderByAuxTypeAscCodeAsc(tenantId)); + body.put("expenseCategories", expenseCategoryRepository.findAllByTenantIdOrderByIdAsc(tenantId)); + body.put("voucherTypes", voucherTypeRepository.findAllByTenantIdOrderByIdAsc(tenantId)); + body.put("sourceSystems", sourceSystemRepository.findAllByTenantIdOrderByIdAsc(tenantId)); + body.put("targetSystems", targetSystemRepository.findAllByTenantIdOrderByIdAsc(tenantId)); + return body; + } + + @GetMapping("/accounts") + public List accounts(@RequestParam(required = false) String companyId, + @RequestParam(required = false) String bookId) { + Long tenantId = TenantContext.getTenantId(); + if (companyId != null && !companyId.isBlank() && bookId != null && !bookId.isBlank()) { + return accountRepository.findAllByTenantIdAndCompanyIdAndBookIdOrderByCodeAsc( + tenantId, companyId, bookId); + } + return accountRepository.findAllByTenantIdOrderByCodeAsc(tenantId); + } + + @GetMapping("/aux-values") + public List auxValues(@RequestParam(required = false) String auxType) { + Long tenantId = TenantContext.getTenantId(); + if (auxType == null || auxType.isBlank()) { + return auxValueRepository.findAllByTenantIdOrderByAuxTypeAscCodeAsc(tenantId); + } + return auxValueRepository.findAllByTenantIdAndAuxTypeOrderByCodeAsc(tenantId, auxType); + } + + @GetMapping("/companies") + public List companies() { + return companyRepository.findAllByTenantIdOrderByIdAsc(TenantContext.getTenantId()); + } + + @GetMapping("/books") + public List books() { + return bookRepository.findAllByTenantIdOrderByIdAsc(TenantContext.getTenantId()); + } + + @GetMapping("/account-groups") + public List accountGroups() { + return accountGroupRepository.findAllByTenantIdOrderByGroupCodeAsc(TenantContext.getTenantId()); + } + + @GetMapping("/aux-items") + public List auxItems() { + return auxItemRepository.findAllByTenantIdOrderByAuxTypeAsc(TenantContext.getTenantId()); + } + + @GetMapping("/expense-categories") + public List expenseCategories() { + return expenseCategoryRepository.findAllByTenantIdOrderByIdAsc(TenantContext.getTenantId()); + } + + @GetMapping("/voucher-types") + public List voucherTypes() { + return voucherTypeRepository.findAllByTenantIdOrderByIdAsc(TenantContext.getTenantId()); + } + + @GetMapping("/source-systems") + public List sourceSystems() { + return sourceSystemRepository.findAllByTenantIdOrderByIdAsc(TenantContext.getTenantId()); + } + + @GetMapping("/target-systems") + public List targetSystems() { + return targetSystemRepository.findAllByTenantIdOrderByIdAsc(TenantContext.getTenantId()); + } +} diff --git a/src/main/java/com/autovoucher/web/OaOpenController.java b/src/main/java/com/autovoucher/web/OaOpenController.java new file mode 100644 index 0000000..e479830 --- /dev/null +++ b/src/main/java/com/autovoucher/web/OaOpenController.java @@ -0,0 +1,100 @@ +package com.autovoucher.web; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.AppProperties; +import com.autovoucher.dto.OaStatusResponse; +import com.autovoucher.dto.OaTicketRequest; +import com.autovoucher.dto.OaTicketResponse; +import com.autovoucher.service.OaViewPage; +import com.autovoucher.service.OaViewService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.ResponseBody; + +/** + * Public (no console login) endpoints used by the OA 【查看凭证】 button. The + * caller is authenticated by the signed ticket request; the page itself is + * protected by an opaque, short-lived token. + */ +@Controller +@RequestMapping("/open/oa") +public class OaOpenController { + + private final OaViewService oaViewService; + private final AppProperties properties; + + public OaOpenController(OaViewService oaViewService, AppProperties properties) { + this.oaViewService = oaViewService; + this.properties = properties; + } + + @PostMapping(value = "/view-ticket", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public OaTicketResponse createTicket(@RequestBody OaTicketRequest request, + HttpServletRequest servletRequest, + HttpServletResponse response) { + secureHeaders(response); + return oaViewService.issueSigned(request, clientIp(servletRequest), + servletRequest.getHeader("User-Agent")); + } + + @GetMapping("/view/{token}") + public String view(@PathVariable String token, Model model, + HttpServletRequest servletRequest, HttpServletResponse response) { + secureHeaders(response); + try { + OaViewPage page = oaViewService.view(token, clientIp(servletRequest), + servletRequest.getHeader("User-Agent")); + model.addAttribute("page", page); + model.addAttribute("ticket", page.ticket()); + model.addAttribute("doc", page.doc()); + model.addAttribute("voucher", page.voucher()); + model.addAttribute("lines", page.lines()); + model.addAttribute("candidates", page.candidates()); + model.addAttribute("message", page.message()); + return "oa/voucher"; + } catch (ApiException e) { + response.setStatus(e.getStatus().value()); + model.addAttribute("code", e.getCode()); + model.addAttribute("message", e.getMessage()); + return "oa/error"; + } + } + + @GetMapping(value = "/view/{token}/status", produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public OaStatusResponse status(@PathVariable String token, HttpServletResponse response) { + secureHeaders(response); + return oaViewService.status(token); + } + + private void secureHeaders(HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + response.setHeader("Pragma", "no-cache"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("X-Content-Type-Options", "nosniff"); + // This page is intentionally embeddable in the OA WebView. Do not set + // X-Frame-Options: DENY; restrict embedding with CSP instead. + response.setHeader("Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " + + "script-src 'none'; frame-ancestors " + + properties.getOa().getSeeyon().getAllowedFrameAncestors() + ";"); + } + + private String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/autovoucher/web/PushTaskController.java b/src/main/java/com/autovoucher/web/PushTaskController.java new file mode 100644 index 0000000..370a7a6 --- /dev/null +++ b/src/main/java/com/autovoucher/web/PushTaskController.java @@ -0,0 +1,129 @@ +package com.autovoucher.web; + +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.PushTask; +import com.autovoucher.domain.Voucher; +import com.autovoucher.repository.PushTaskRepository; +import com.autovoucher.repository.VoucherRepository; +import com.autovoucher.service.PushService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@RestController +@RequestMapping("/api/push-tasks") +public class PushTaskController { + + private final PushTaskRepository pushTaskRepository; + private final VoucherRepository voucherRepository; + private final PushService pushService; + + public PushTaskController(PushTaskRepository pushTaskRepository, + VoucherRepository voucherRepository, + PushService pushService) { + this.pushTaskRepository = pushTaskRepository; + this.voucherRepository = voucherRepository; + this.pushService = pushService; + } + + @GetMapping + public List> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String targetSystem, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "200") int limit) { + Long tenantId = TenantContext.getTenantId(); + String kw = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + return pushTaskRepository.findAllByTenantIdOrderByIdDesc(tenantId).stream() + .filter(t -> status == null || status.isBlank() + || status.equalsIgnoreCase(t.getStatus())) + .filter(t -> targetSystem == null || targetSystem.isBlank() + || targetSystem.equalsIgnoreCase(t.getTargetSystem())) + .filter(t -> kw.isEmpty() || taskMatches(t, kw)) + .limit(Math.max(1, Math.min(limit, 1000))) + .map(this::view) + .toList(); + } + + @GetMapping("/{id}") + public Map detail(@PathVariable Long id) { + PushTask task = pushService.requireTask(id); + Map body = view(task); + body.put("attempts", pushService.attempts(id)); + voucherRepository.findById(task.getVoucherId()).ifPresent(v -> body.put("voucher", voucherSummary(v))); + return body; + } + + @PostMapping("/{id}/push") + public Map push(@PathVariable Long id, + @RequestParam(required = false) String behavior) { + return view(pushService.push(id, behavior)); + } + + @PostMapping("/{id}/retry") + public Map retry(@PathVariable Long id) { + return view(pushService.retry(id)); + } + + @PostMapping("/{id}/reconcile") + public Map reconcile(@PathVariable Long id) { + return view(pushService.reconcile(id)); + } + + private Map view(PushTask task) { + Map item = new LinkedHashMap<>(); + item.put("id", task.getId()); + item.put("voucherId", task.getVoucherId()); + item.put("targetSystem", task.getTargetSystem()); + item.put("adapterCode", task.getAdapterCode()); + item.put("status", task.getStatus()); + item.put("attemptCount", task.getAttemptCount()); + item.put("maxAttempts", task.getMaxAttempts()); + item.put("nextRetryAt", task.getNextRetryAt()); + item.put("idempotencyKey", task.getIdempotencyKey()); + item.put("externalVoucherId", task.getExternalVoucherId()); + item.put("externalVoucherNo", task.getExternalVoucherNo()); + item.put("lastErrorCode", task.getLastErrorCode()); + item.put("lastErrorMessage", task.getLastErrorMessage()); + item.put("createdAt", task.getCreatedAt()); + voucherRepository.findById(task.getVoucherId()).ifPresent(v -> { + item.put("voucherNo", v.getVoucherNo()); + item.put("voucherStatus", v.getStatus()); + }); + return item; + } + + private Map voucherSummary(Voucher voucher) { + Map item = new LinkedHashMap<>(); + item.put("id", voucher.getId()); + item.put("voucherNo", voucher.getVoucherNo()); + item.put("status", voucher.getStatus()); + item.put("totalDebit", voucher.getTotalDebit()); + item.put("totalCredit", voucher.getTotalCredit()); + item.put("currency", voucher.getCurrency()); + item.put("externalVoucherNo", voucher.getExternalVoucherNo()); + return item; + } + + private boolean taskMatches(PushTask task, String keyword) { + if (String.valueOf(task.getId()).equals(keyword) + || contains(task.getIdempotencyKey(), keyword) + || contains(task.getExternalVoucherNo(), keyword)) { + return true; + } + return voucherRepository.findById(task.getVoucherId()) + .map(v -> contains(v.getVoucherNo(), keyword)) + .orElse(false); + } + + private boolean contains(String value, String keyword) { + return value != null && value.toLowerCase(Locale.ROOT).contains(keyword); + } +} diff --git a/src/main/java/com/autovoucher/web/RuleController.java b/src/main/java/com/autovoucher/web/RuleController.java new file mode 100644 index 0000000..5f06f37 --- /dev/null +++ b/src/main/java/com/autovoucher/web/RuleController.java @@ -0,0 +1,76 @@ +package com.autovoucher.web; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.MappingRule; +import com.autovoucher.domain.RuleCandidate; +import com.autovoucher.dto.RuleCandidateDecisionRequest; +import com.autovoucher.dto.RuleRequest; +import com.autovoucher.repository.MappingRuleRepository; +import com.autovoucher.service.RuleAdminService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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; + +import java.util.List; + +@RestController +@RequestMapping("/api") +public class RuleController { + + private final RuleAdminService ruleAdminService; + private final MappingRuleRepository mappingRuleRepository; + + public RuleController(RuleAdminService ruleAdminService, + MappingRuleRepository mappingRuleRepository) { + this.ruleAdminService = ruleAdminService; + this.mappingRuleRepository = mappingRuleRepository; + } + + @GetMapping("/rules") + public List rules(@RequestParam(required = false) String status) { + List rules = ruleAdminService.rules(); + if (status == null || status.isBlank()) { + return rules; + } + return rules.stream().filter(r -> status.equalsIgnoreCase(r.getStatus())).toList(); + } + + @GetMapping("/rules/{id}") + public MappingRule rule(@PathVariable Long id) { + return mappingRuleRepository.findById(id) + .filter(r -> TenantContext.getTenantId().equals(r.getTenantId())) + .orElseThrow(() -> ApiException.notFound("RULE_NOT_FOUND", "规则不存在: " + id)); + } + + @PostMapping("/rules") + public MappingRule create(@RequestBody RuleRequest request) { + return ruleAdminService.create(request); + } + + @PutMapping("/rules/{id}") + public MappingRule update(@PathVariable Long id, @RequestBody RuleRequest request) { + return ruleAdminService.update(id, request); + } + + @GetMapping("/rule-candidates") + public List candidates(@RequestParam(required = false) String status) { + return ruleAdminService.candidates(status); + } + + @PostMapping("/rule-candidates/{id}/approve") + public MappingRule approve(@PathVariable Long id, + @RequestBody(required = false) RuleCandidateDecisionRequest request) { + return ruleAdminService.approveCandidate(id, request); + } + + @PostMapping("/rule-candidates/{id}/reject") + public RuleCandidate reject(@PathVariable Long id) { + return ruleAdminService.rejectCandidate(id); + } +} diff --git a/src/main/java/com/autovoucher/web/SourceDocController.java b/src/main/java/com/autovoucher/web/SourceDocController.java new file mode 100644 index 0000000..59febae --- /dev/null +++ b/src/main/java/com/autovoucher/web/SourceDocController.java @@ -0,0 +1,234 @@ +package com.autovoucher.web; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Account; +import com.autovoucher.domain.PushTask; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.SourceLine; +import com.autovoucher.domain.Voucher; +import com.autovoucher.dto.ManualVoucherRequest; +import com.autovoucher.repository.AccountRepository; +import com.autovoucher.repository.PushTaskRepository; +import com.autovoucher.repository.SourceDocRepository; +import com.autovoucher.repository.SourceLineRepository; +import com.autovoucher.repository.VoucherRepository; +import com.autovoucher.service.DocumentProcessingService; +import com.autovoucher.service.ProcessingOutcome; +import com.autovoucher.service.VoucherService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@RestController +@RequestMapping("/api/source-docs") +public class SourceDocController { + + private final SourceDocRepository sourceDocRepository; + private final SourceLineRepository sourceLineRepository; + private final VoucherRepository voucherRepository; + private final PushTaskRepository pushTaskRepository; + private final AccountRepository accountRepository; + private final DocumentProcessingService documentProcessingService; + private final VoucherService voucherService; + private final ObjectMapper objectMapper; + + public SourceDocController(SourceDocRepository sourceDocRepository, + SourceLineRepository sourceLineRepository, + VoucherRepository voucherRepository, + PushTaskRepository pushTaskRepository, + AccountRepository accountRepository, + DocumentProcessingService documentProcessingService, + VoucherService voucherService, + ObjectMapper objectMapper) { + this.sourceDocRepository = sourceDocRepository; + this.sourceLineRepository = sourceLineRepository; + this.voucherRepository = voucherRepository; + this.pushTaskRepository = pushTaskRepository; + this.accountRepository = accountRepository; + this.documentProcessingService = documentProcessingService; + this.voucherService = voucherService; + this.objectMapper = objectMapper; + } + + @GetMapping + public List> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "200") int limit) { + Long tenantId = TenantContext.getTenantId(); + List docs = status == null || status.isBlank() + ? sourceDocRepository.findAllByTenantIdOrderByIdDesc(tenantId) + : sourceDocRepository.findAllByTenantIdAndProcessingStatusOrderByIdDesc( + tenantId, status.toUpperCase(Locale.ROOT)); + String kw = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + return docs.stream() + .filter(doc -> kw.isEmpty() || matches(doc, kw)) + .limit(Math.max(1, Math.min(limit, 1000))) + .map(this::summary) + .toList(); + } + + @GetMapping("/{id}") + public Map detail(@PathVariable Long id) { + SourceDoc doc = requireDoc(id); + List lines = sourceLines(id); + List vouchers = voucherRepository.findAllByTenantIdAndSourceDocIdOrderByIdDesc( + TenantContext.getTenantId(), id); + Voucher current = vouchers.stream() + .filter(v -> Integer.valueOf(1).equals(v.getCurrentFlag())) + .findFirst().orElse(null); + + Map body = new LinkedHashMap<>(); + body.put("doc", doc); + body.put("lines", lines); + body.put("candidates", candidates(doc)); + body.put("vouchers", vouchers.stream().map(this::voucherSummary).toList()); + body.put("currentVoucher", current == null ? null : voucherDetail(current)); + body.put("pushTasks", current == null ? List.of() + : pushTaskRepository.findAllByTenantIdOrderByIdDesc(TenantContext.getTenantId()) + .stream().filter(t -> current.getId().equals(t.getVoucherId())).toList()); + return body; + } + + @PostMapping("/{id}/reprocess") + public Map reprocess(@PathVariable Long id) { + SourceDoc doc = requireDoc(id); + ProcessingOutcome outcome = documentProcessingService.process(doc, true); + return outcomeView(outcome); + } + + @PostMapping("/{id}/manual-voucher") + public Map manualVoucher(@PathVariable Long id, + @RequestBody ManualVoucherRequest request) { + if (request.accountCode() == null || request.accountCode().isBlank()) { + throw ApiException.badRequest("ACCOUNT_CODE_REQUIRED", "请选择费用科目"); + } + Voucher voucher = voucherService.createFromManualCandidate(id, request); + return voucherDetail(voucher); + } + + private Map outcomeView(ProcessingOutcome outcome) { + Map body = new LinkedHashMap<>(); + body.put("status", outcome.status()); + body.put("message", outcome.message()); + body.put("voucherId", outcome.voucherId()); + body.put("candidates", outcome.candidates()); + if (outcome.voucherId() != null) { + voucherRepository.findById(outcome.voucherId()).ifPresent(v -> { + body.put("voucherNo", v.getVoucherNo()); + body.put("voucherStatus", v.getStatus()); + }); + } + return body; + } + + private Map summary(SourceDoc doc) { + Map item = new LinkedHashMap<>(); + item.put("id", doc.getId()); + item.put("sourceSystem", doc.getSourceSystem()); + item.put("externalId", doc.getExternalId()); + item.put("docNo", doc.getDocNo()); + item.put("docType", doc.getDocType()); + item.put("title", doc.getTitle()); + item.put("applicantId", doc.getApplicantId()); + item.put("applicantName", doc.getApplicantName()); + item.put("departmentName", doc.getDepartmentName()); + item.put("totalAmount", doc.getTotalAmount()); + item.put("currency", doc.getCurrency()); + item.put("businessScene", doc.getBusinessScene()); + item.put("isBusinessTrip", doc.getIsBusinessTrip()); + item.put("processingStatus", doc.getProcessingStatus()); + item.put("manualReason", doc.getManualReason()); + item.put("createdAt", doc.getCreatedAt()); + voucherRepository.findByTenantIdAndSourceDocIdAndCurrentFlag( + TenantContext.getTenantId(), doc.getId(), 1) + .ifPresent(v -> { + item.put("voucherId", v.getId()); + item.put("voucherNo", v.getVoucherNo()); + item.put("voucherStatus", v.getStatus()); + }); + return item; + } + + private Map voucherSummary(Voucher voucher) { + Map item = new LinkedHashMap<>(); + item.put("id", voucher.getId()); + item.put("voucherNo", voucher.getVoucherNo()); + item.put("status", voucher.getStatus()); + item.put("version", voucher.getVersion()); + item.put("currentFlag", voucher.getCurrentFlag()); + item.put("totalDebit", voucher.getTotalDebit()); + item.put("totalCredit", voucher.getTotalCredit()); + item.put("createdAt", voucher.getCreatedAt()); + return item; + } + + private Map voucherDetail(Voucher voucher) { + Map item = voucherSummary(voucher); + item.put("lines", voucherService.lines(voucher.getId())); + return item; + } + + private List> candidates(SourceDoc doc) { + if (doc.getCandidatesJson() == null || doc.getCandidatesJson().isBlank()) { + return List.of(); + } + List> result = new ArrayList<>(); + try { + JsonNode node = objectMapper.readTree(doc.getCandidatesJson()); + if (!node.isArray()) { + return List.of(); + } + for (JsonNode codeNode : node) { + String code = codeNode.asText(); + Map item = new LinkedHashMap<>(); + item.put("accountCode", code); + Account account = accountRepository + .findByTenantIdAndCompanyIdAndBookIdAndCode(TenantContext.getTenantId(), + doc.getCompanyId(), doc.getBookId(), code) + .orElse(null); + item.put("accountName", account == null ? null : account.getName()); + item.put("direction", account == null ? null : account.getDirection()); + result.add(item); + } + } catch (Exception e) { + return List.of(); + } + return result; + } + + private List sourceLines(Long docId) { + return sourceLineRepository.findAllByTenantIdAndSourceDocIdOrderByLineNoAsc( + TenantContext.getTenantId(), docId); + } + + private SourceDoc requireDoc(Long id) { + return sourceDocRepository.findById(id) + .filter(d -> TenantContext.getTenantId().equals(d.getTenantId())) + .orElseThrow(() -> ApiException.notFound("SOURCE_DOC_NOT_FOUND", "源单据不存在: " + id)); + } + + private boolean matches(SourceDoc doc, String keyword) { + return contains(doc.getDocNo(), keyword) + || contains(doc.getExternalId(), keyword) + || contains(doc.getTitle(), keyword) + || contains(doc.getApplicantName(), keyword) + || contains(doc.getDepartmentName(), keyword); + } + + private boolean contains(String value, String keyword) { + return value != null && value.toLowerCase(Locale.ROOT).contains(keyword); + } +} diff --git a/src/main/java/com/autovoucher/web/VoucherController.java b/src/main/java/com/autovoucher/web/VoucherController.java new file mode 100644 index 0000000..1059836 --- /dev/null +++ b/src/main/java/com/autovoucher/web/VoucherController.java @@ -0,0 +1,197 @@ +package com.autovoucher.web; + +import com.autovoucher.common.ApiException; +import com.autovoucher.config.TenantContext; +import com.autovoucher.domain.Correction; +import com.autovoucher.domain.SourceDoc; +import com.autovoucher.domain.Voucher; +import com.autovoucher.dto.RejectVoucherRequest; +import com.autovoucher.dto.VoucherUpdateRequest; +import com.autovoucher.repository.CorrectionRepository; +import com.autovoucher.repository.PushTaskRepository; +import com.autovoucher.repository.SourceDocRepository; +import com.autovoucher.repository.SourceLineRepository; +import com.autovoucher.repository.VoucherRepository; +import com.autovoucher.service.DocumentProcessingService; +import com.autovoucher.service.ProcessingOutcome; +import com.autovoucher.service.VoucherService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@RestController +@RequestMapping("/api/vouchers") +public class VoucherController { + + private final VoucherRepository voucherRepository; + private final VoucherService voucherService; + private final DocumentProcessingService documentProcessingService; + private final SourceDocRepository sourceDocRepository; + private final SourceLineRepository sourceLineRepository; + private final PushTaskRepository pushTaskRepository; + private final CorrectionRepository correctionRepository; + private final ObjectMapper objectMapper; + + public VoucherController(VoucherRepository voucherRepository, + VoucherService voucherService, + DocumentProcessingService documentProcessingService, + SourceDocRepository sourceDocRepository, + SourceLineRepository sourceLineRepository, + PushTaskRepository pushTaskRepository, + CorrectionRepository correctionRepository, + ObjectMapper objectMapper) { + this.voucherRepository = voucherRepository; + this.voucherService = voucherService; + this.documentProcessingService = documentProcessingService; + this.sourceDocRepository = sourceDocRepository; + this.sourceLineRepository = sourceLineRepository; + this.pushTaskRepository = pushTaskRepository; + this.correctionRepository = correctionRepository; + this.objectMapper = objectMapper; + } + + @GetMapping + public List> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Long sourceDocId, + @RequestParam(defaultValue = "200") int limit) { + Long tenantId = TenantContext.getTenantId(); + List vouchers = sourceDocId != null + ? voucherRepository.findAllByTenantIdAndSourceDocIdOrderByIdDesc(tenantId, sourceDocId) + : status == null || status.isBlank() + ? voucherRepository.findAllByTenantIdOrderByIdDesc(tenantId) + : voucherRepository.findAllByTenantIdAndStatusOrderByIdDesc(tenantId, + status.toUpperCase(Locale.ROOT)); + String kw = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + return vouchers.stream() + .filter(v -> kw.isEmpty() + || contains(v.getVoucherNo(), kw) + || contains(v.getExternalVoucherNo(), kw)) + .limit(Math.max(1, Math.min(limit, 1000))) + .map(this::summary) + .toList(); + } + + @GetMapping("/{id}") + public Map detail(@PathVariable Long id) { + return detail(voucherService.requireVoucher(id)); + } + + @PutMapping("/{id}/lines") + public Map updateLines(@PathVariable Long id, + @RequestBody VoucherUpdateRequest request) { + return detail(voucherService.updateLines(id, request)); + } + + @PostMapping("/{id}/approve") + public Map approve(@PathVariable Long id) { + return detail(voucherService.approve(id)); + } + + @PostMapping("/{id}/reject") + public Map reject(@PathVariable Long id, + @RequestBody RejectVoucherRequest request) { + if (request.reason() == null || request.reason().isBlank()) { + throw ApiException.badRequest("REJECT_REASON_REQUIRED", "请填写驳回原因"); + } + return detail(voucherService.reject(id, request.reason())); + } + + @PostMapping("/{id}/regenerate") + public Map regenerate(@PathVariable Long id) { + Voucher voucher = voucherService.requireVoucher(id); + if (List.of("APPROVED", "PUSHED", "SUCCESS", "SUCCESS_WITH_EXISTING", "PUSH_UNKNOWN") + .contains(voucher.getStatus())) { + throw ApiException.conflict("VOUCHER_NOT_REGENERABLE", + "凭证已审核或已推送,不能重新生成: " + voucher.getStatus()); + } + SourceDoc doc = sourceDocRepository.findById(voucher.getSourceDocId()) + .orElseThrow(() -> ApiException.badRequest("SOURCE_DOC_NOT_FOUND", "源单据不存在")); + ProcessingOutcome outcome = documentProcessingService.process(doc, true); + + Map body = new LinkedHashMap<>(); + body.put("status", outcome.status()); + body.put("message", outcome.message()); + body.put("voucherId", outcome.voucherId()); + if (outcome.voucherId() != null) { + body.put("voucher", detail(voucherService.requireVoucher(outcome.voucherId()))); + } + return body; + } + + private Map summary(Voucher voucher) { + Map item = new LinkedHashMap<>(); + item.put("id", voucher.getId()); + item.put("voucherNo", voucher.getVoucherNo()); + item.put("sourceDocId", voucher.getSourceDocId()); + item.put("voucherTypeCode", voucher.getVoucherTypeCode()); + item.put("companyId", voucher.getCompanyId()); + item.put("bookId", voucher.getBookId()); + item.put("voucherDate", voucher.getVoucherDate()); + item.put("period", voucher.getPeriod()); + item.put("currency", voucher.getCurrency()); + item.put("totalDebit", voucher.getTotalDebit()); + item.put("totalCredit", voucher.getTotalCredit()); + item.put("status", voucher.getStatus()); + item.put("version", voucher.getVersion()); + item.put("currentFlag", voucher.getCurrentFlag()); + item.put("confidence", voucher.getConfidence()); + item.put("targetSystem", voucher.getTargetSystem()); + item.put("externalVoucherId", voucher.getExternalVoucherId()); + item.put("externalVoucherNo", voucher.getExternalVoucherNo()); + item.put("pushedAt", voucher.getPushedAt()); + item.put("approvedBy", voucher.getApprovedBy()); + item.put("approvedAt", voucher.getApprovedAt()); + item.put("rejectReason", voucher.getRejectReason()); + item.put("createdAt", voucher.getCreatedAt()); + return item; + } + + private Map detail(Voucher voucher) { + Map body = summary(voucher); + body.put("lines", voucherService.lines(voucher.getId())); + + SourceDoc doc = sourceDocRepository.findById(voucher.getSourceDocId()).orElse(null); + body.put("sourceDoc", doc); + body.put("sourceLines", doc == null ? List.of() + : sourceLineRepository.findAllByTenantIdAndSourceDocIdOrderByLineNoAsc( + TenantContext.getTenantId(), doc.getId())); + + List corrections = correctionRepository + .findAllByTenantIdAndVoucherIdOrderByIdDesc(TenantContext.getTenantId(), voucher.getId()); + body.put("corrections", corrections); + body.put("decisionTrace", parse(voucher.getDecisionTraceJson())); + body.put("pushTasks", pushTaskRepository.findAllByTenantIdOrderByIdDesc(TenantContext.getTenantId()) + .stream() + .filter(t -> voucher.getId().equals(t.getVoucherId())) + .toList()); + return body; + } + + private JsonNode parse(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readTree(json); + } catch (Exception e) { + return objectMapper.getNodeFactory().textNode(json); + } + } + + private boolean contains(String value, String keyword) { + return value != null && value.toLowerCase(Locale.ROOT).contains(keyword); + } +} diff --git a/src/main/resources/application-h2.yml b/src/main/resources/application-h2.yml new file mode 100644 index 0000000..5a72431 --- /dev/null +++ b/src/main/resources/application-h2.yml @@ -0,0 +1,23 @@ +spring: + datasource: + url: jdbc:h2:mem:autovoucher;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1 + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + flyway: + enabled: false + +av: + seed: + enabled: true + path: ${AV_SEED_PATH:../testdata/seed.json} + auto-ingest: true + push: + enabled: false + +logging: + level: + com.autovoucher: DEBUG diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d5b117a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,73 @@ +server: + port: ${PORT:8080} + forward-headers-strategy: framework + +spring: + application: + name: auto-voucher-console + datasource: + url: ${DB_URL:jdbc:mysql://192.168.5.24:3306/auto_voucher?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: ${DB_POOL_SIZE:10} + connection-timeout: 10000 + jpa: + open-in-view: false + hibernate: + ddl-auto: validate + properties: + hibernate: + jdbc: + time_zone: Asia/Shanghai + format_sql: false + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + thymeleaf: + cache: false + jackson: + serialization: + write-dates-as-timestamps: false + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: never + +av: + seed: + enabled: ${AV_SEED_ENABLED:true} + path: ${AV_SEED_PATH:../testdata/seed.json} + auto-ingest: ${AV_SEED_AUTO_INGEST:true} + oa: + view-base-url: ${AV_VIEW_BASE_URL:http://localhost:8080} + ticket-ttl-seconds: ${AV_VIEW_TOKEN_TTL:900} + timestamp-skew-seconds: ${AV_VIEW_CLOCK_SKEW:300} + seeyon: + app-id: ${SEEYON_APP_ID:seeyon-demo} + app-secret: ${SEEYON_APP_SECRET:seeyon-demo-secret} + allowed-frame-ancestors: ${SEEYON_FRAME_ANCESTORS:http://localhost:5173} + push: + enabled: ${AV_PUSH_ENABLED:true} + interval-ms: ${AV_PUSH_INTERVAL_MS:5000} + batch-size: ${AV_PUSH_BATCH_SIZE:20} + ncc: + dry-run: ${AV_NCC_DRY_RUN:true} + timeout-ms: ${AV_NCC_TIMEOUT_MS:5000} + security: + admin-username: ${AV_ADMIN_USER:admin} + admin-password: ${AV_ADMIN_PASSWORD:admin123} + cors: + allowed-origins: ${AV_CORS_ORIGINS:http://localhost:5173,http://127.0.0.1:5173} + +logging: + level: + com.autovoucher: INFO + org.hibernate.SQL: WARN diff --git a/src/main/resources/db/migration/V1__schema.sql b/src/main/resources/db/migration/V1__schema.sql new file mode 100644 index 0000000..01c46cc --- /dev/null +++ b/src/main/resources/db/migration/V1__schema.sql @@ -0,0 +1,462 @@ +-- autoVoucher MVP schema (MySQL 8 / H2 MySQL mode compatible) +-- All business tables carry tenant_id. External master-data codes are mapped +-- through *_external_codes_json / mapping tables so NC65 and Kingdee can be +-- added without changing the voucher core. + +CREATE TABLE IF NOT EXISTS av_company ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + company_id VARCHAR(64) NOT NULL, + company_code VARCHAR(64), + company_name VARCHAR(255), + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_company UNIQUE (tenant_id, company_id) +); + +CREATE TABLE IF NOT EXISTS av_book ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + book_id VARCHAR(64) NOT NULL, + company_id VARCHAR(64) NOT NULL, + book_code VARCHAR(64) NOT NULL, + book_name VARCHAR(255), + currency VARCHAR(16) NOT NULL DEFAULT 'CNY', + current_period VARCHAR(16), + target_system VARCHAR(32), + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_book UNIQUE (tenant_id, book_id) +); + +CREATE TABLE IF NOT EXISTS av_target_system ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + target_system VARCHAR(32) NOT NULL, + name VARCHAR(100), + adapter_code VARCHAR(64), + status VARCHAR(32) NOT NULL DEFAULT 'DISABLED', + dry_run BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_target_system UNIQUE (tenant_id, target_system) +); + +CREATE TABLE IF NOT EXISTS av_voucher_type ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + voucher_type_id VARCHAR(64), + code VARCHAR(32) NOT NULL, + name VARCHAR(100), + external_codes_json TEXT, + note VARCHAR(255), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_voucher_type UNIQUE (tenant_id, code) +); + +CREATE TABLE IF NOT EXISTS av_account ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + account_id VARCHAR(64), + company_id VARCHAR(64) NOT NULL, + book_id VARCHAR(64) NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(255), + parent_code VARCHAR(64), + level INT, + is_leaf BOOLEAN NOT NULL DEFAULT TRUE, + direction VARCHAR(16), + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + aux_required_json TEXT, + external_codes_json TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_account UNIQUE (tenant_id, company_id, book_id, code) +); + +CREATE TABLE IF NOT EXISTS av_account_group ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + group_code VARCHAR(64) NOT NULL, + name VARCHAR(100), + account_by_department_attr_json TEXT, + fixed_account VARCHAR(64), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_account_group UNIQUE (tenant_id, group_code) +); + +CREATE TABLE IF NOT EXISTS av_aux_item ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + aux_type VARCHAR(32) NOT NULL, + name VARCHAR(100), + external_codes_json TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_aux_item UNIQUE (tenant_id, aux_type) +); + +CREATE TABLE IF NOT EXISTS av_aux_value ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + aux_type VARCHAR(32) NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(255), + attributes_json TEXT, + external_codes_json TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_aux_value UNIQUE (tenant_id, aux_type, code) +); + +CREATE TABLE IF NOT EXISTS av_expense_category ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + code VARCHAR(32) NOT NULL, + name VARCHAR(100), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_expense_category UNIQUE (tenant_id, code) +); + +CREATE TABLE IF NOT EXISTS av_mapping_rule ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + rule_code VARCHAR(64) NOT NULL, + name VARCHAR(255), + priority INT NOT NULL DEFAULT 0, + condition_json TEXT NOT NULL, + result_json TEXT NOT NULL, + version INT NOT NULL DEFAULT 1, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_mapping_rule UNIQUE (tenant_id, rule_code, version) +); + +CREATE TABLE IF NOT EXISTS av_source_system ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + source_system VARCHAR(32) NOT NULL, + name VARCHAR(100), + adapter_code VARCHAR(64), + status VARCHAR(32) NOT NULL DEFAULT 'DISABLED', + mock BOOLEAN NOT NULL DEFAULT FALSE, + note VARCHAR(255), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_source_system UNIQUE (tenant_id, source_system) +); + +CREATE TABLE IF NOT EXISTS av_source_doc ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + source_system VARCHAR(32) NOT NULL, + external_id VARCHAR(128) NOT NULL, + doc_no VARCHAR(64), + doc_type VARCHAR(64), + title VARCHAR(255), + company_id VARCHAR(64), + book_id VARCHAR(64), + applicant_id VARCHAR(64), + applicant_name VARCHAR(100), + department_id VARCHAR(64), + department_name VARCHAR(100), + payee_type VARCHAR(32), + payee_id VARCHAR(64), + payee_name VARCHAR(100), + currency VARCHAR(16), + total_amount DECIMAL(18,2), + tax_amount DECIMAL(18,2), + business_scene VARCHAR(64), + is_business_trip BOOLEAN NOT NULL DEFAULT FALSE, + trip_id VARCHAR(64), + approval_node VARCHAR(64), + approved_at TIMESTAMP, + processing_status VARCHAR(32) NOT NULL, + manual_reason VARCHAR(128), + candidates_json TEXT, + raw_json TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_source_doc UNIQUE (tenant_id, source_system, external_id) +); + +CREATE TABLE IF NOT EXISTS av_source_line ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + source_doc_id BIGINT NOT NULL, + line_no INT NOT NULL, + expense_category VARCHAR(32), + description VARCHAR(500), + amount DECIMAL(18,2), + amount_excluding_tax DECIMAL(18,2), + tax_included BOOLEAN NOT NULL DEFAULT TRUE, + tax_rate DECIMAL(10,6), + tax_amount DECIMAL(18,2), + invoice_type VARCHAR(32), + occurred_at DATE, + merchant_id VARCHAR(64), + merchant_name VARCHAR(255), + customer_id VARCHAR(64), + project_id VARCHAR(64), + cost_center_id VARCHAR(64), + invoice_no VARCHAR(128), + raw_json TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_source_line UNIQUE (tenant_id, source_doc_id, line_no) +); + +CREATE TABLE IF NOT EXISTS av_source_event ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + event_id VARCHAR(128) NOT NULL, + source_system VARCHAR(32) NOT NULL, + event_type VARCHAR(64), + external_id VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + message VARCHAR(500), + received_at TIMESTAMP, + processed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_source_event UNIQUE (tenant_id, event_id) +); + +CREATE TABLE IF NOT EXISTS av_transaction ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + source_doc_id BIGINT NOT NULL, + business_scene VARCHAR(64), + is_business_trip BOOLEAN NOT NULL DEFAULT FALSE, + trip_id VARCHAR(64), + department_id VARCHAR(64), + applicant_id VARCHAR(64), + total_amount DECIMAL(18,2), + status VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS av_transaction_line ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + transaction_id BIGINT NOT NULL, + line_no INT NOT NULL, + expense_category VARCHAR(32), + amount DECIMAL(18,2), + amount_excluding_tax DECIMAL(18,2), + tax_amount DECIMAL(18,2), + invoice_type VARCHAR(32), + project_id VARCHAR(64), + cost_center_id VARCHAR(64), + merchant_id VARCHAR(64), + customer_id VARCHAR(64), + matched_rule VARCHAR(64), + account_group VARCHAR(64), + aux_json TEXT, + evidence_json TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS av_voucher ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + voucher_no VARCHAR(64) NOT NULL, + source_doc_id BIGINT NOT NULL, + transaction_id BIGINT, + voucher_type_code VARCHAR(32) NOT NULL, + company_id VARCHAR(64) NOT NULL, + book_id VARCHAR(64) NOT NULL, + voucher_date DATE, + period VARCHAR(16), + currency VARCHAR(16), + total_debit DECIMAL(18,2), + total_credit DECIMAL(18,2), + status VARCHAR(32) NOT NULL, + version INT NOT NULL DEFAULT 1, + current_flag INT, + confidence DECIMAL(5,4), + decision_trace_json TEXT, + target_system VARCHAR(32), + external_voucher_id VARCHAR(128), + external_voucher_no VARCHAR(128), + pushed_at TIMESTAMP, + approved_by VARCHAR(64), + approved_at TIMESTAMP, + reject_reason VARCHAR(500), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_voucher_no UNIQUE (tenant_id, voucher_no), + CONSTRAINT uk_voucher_current UNIQUE (tenant_id, source_doc_id, voucher_type_code, current_flag) +); + +CREATE TABLE IF NOT EXISTS av_voucher_line ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + voucher_id BIGINT NOT NULL, + line_no INT NOT NULL, + direction VARCHAR(16) NOT NULL, + account_code VARCHAR(64) NOT NULL, + account_name VARCHAR(255), + account_group VARCHAR(64), + amount DECIMAL(18,2) NOT NULL, + currency VARCHAR(16), + tax_rate DECIMAL(10,6), + tax_amount DECIMAL(18,2), + aux_json TEXT, + summary VARCHAR(500), + decision_source VARCHAR(32), + confidence DECIMAL(5,4), + evidence_json TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_voucher_line UNIQUE (tenant_id, voucher_id, line_no) +); + +CREATE TABLE IF NOT EXISTS av_push_task ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + voucher_id BIGINT NOT NULL, + target_system VARCHAR(32) NOT NULL, + adapter_code VARCHAR(64), + status VARCHAR(32) NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 5, + next_retry_at TIMESTAMP, + idempotency_key VARCHAR(255) NOT NULL, + request_snapshot_json TEXT, + external_voucher_id VARCHAR(128), + external_voucher_no VARCHAR(128), + last_error_code VARCHAR(64), + last_error_message VARCHAR(1000), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_push_idempotency UNIQUE (tenant_id, idempotency_key) +); + +CREATE TABLE IF NOT EXISTS av_push_attempt ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + attempt_no INT NOT NULL, + request_json TEXT, + response_json TEXT, + status VARCHAR(32) NOT NULL, + error_message VARCHAR(1000), + duration_ms BIGINT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS av_oa_view_ticket ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + source_system VARCHAR(32) NOT NULL, + external_id VARCHAR(128) NOT NULL, + source_doc_id BIGINT NOT NULL, + operator_id VARCHAR(64) NOT NULL, + token_hash VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + created_ip VARCHAR(64), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_oa_view_token UNIQUE (tenant_id, token_hash) +); + +CREATE TABLE IF NOT EXISTS av_oa_view_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + ticket_id BIGINT, + source_system VARCHAR(32), + external_id VARCHAR(128), + operator_id VARCHAR(64), + action VARCHAR(32) NOT NULL, + result VARCHAR(64), + ip VARCHAR(64), + user_agent VARCHAR(500), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS av_nonce ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + nonce VARCHAR(128) NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + CONSTRAINT uk_nonce UNIQUE (tenant_id, nonce) +); + +CREATE TABLE IF NOT EXISTS av_correction ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + voucher_id BIGINT NOT NULL, + source_doc_id BIGINT NOT NULL, + original_json TEXT, + corrected_json TEXT, + diff_json TEXT, + reason VARCHAR(500) NOT NULL, + operator_id VARCHAR(64), + scope VARCHAR(32) NOT NULL DEFAULT 'CASE', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS av_case_memory ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + source_doc_id BIGINT NOT NULL, + context_json TEXT, + voucher_json TEXT, + correction_diff_json TEXT, + quality DECIMAL(5,4) NOT NULL DEFAULT 1.0000, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS av_rule_candidate ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + rule_code VARCHAR(64) NOT NULL, + name VARCHAR(255), + condition_json TEXT, + result_json TEXT, + support_count INT NOT NULL DEFAULT 0, + conflict_count INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + source VARCHAR(32) NOT NULL DEFAULT 'INDUCED', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS av_audit_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + action VARCHAR(64) NOT NULL, + entity_type VARCHAR(64), + entity_id VARCHAR(64), + operator_id VARCHAR(64), + detail_json TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP +); + +CREATE INDEX idx_source_doc_status ON av_source_doc (tenant_id, processing_status); +CREATE INDEX idx_source_line_doc ON av_source_line (tenant_id, source_doc_id); +CREATE INDEX idx_transaction_doc ON av_transaction (tenant_id, source_doc_id); +CREATE INDEX idx_voucher_status ON av_voucher (tenant_id, status); +CREATE INDEX idx_voucher_line_voucher ON av_voucher_line (tenant_id, voucher_id); +CREATE INDEX idx_push_task_status ON av_push_task (tenant_id, target_system, status, next_retry_at); +CREATE INDEX idx_oa_view_ticket_expiry ON av_oa_view_ticket (tenant_id, expires_at); +CREATE INDEX idx_audit_entity ON av_audit_log (tenant_id, entity_type, entity_id); diff --git a/src/main/resources/templates/oa/error.html b/src/main/resources/templates/oa/error.html new file mode 100644 index 0000000..9b62be9 --- /dev/null +++ b/src/main/resources/templates/oa/error.html @@ -0,0 +1,36 @@ + + + + + + 无法查看凭证 + + + +
+
!
+

无法查看凭证

+

查看链接无效或已失效。

+
OA_TOKEN_INVALID
+
+ + diff --git a/src/main/resources/templates/oa/voucher.html b/src/main/resources/templates/oa/voucher.html new file mode 100644 index 0000000..74a9def --- /dev/null +++ b/src/main/resources/templates/oa/voucher.html @@ -0,0 +1,193 @@ + + + + + + + 费用凭证预览 + + + +
+
+

费用凭证预览

+
+ 单据号 - + · 凭证号 - +
+ +
+
申请人
-
+
部门
-
+
单据金额
+
-
+
+
公司 / 账簿
+
-
+
+
+
+ + +
+
+ 该单据为付款申请单,付款凭证由财务系统内部生成,本系统不重复生成费用凭证。 +
+
+ + +
+
+ +  正在根据单据内容生成凭证建议,页面将自动刷新… +
+
+
+
凭证生成暂时失败,请稍后重新打开或在财务系统中查询。
+
+ + +
+

待财务人工处理

+
+ 系统无法从单据中确定唯一科目,需由财务人员确认。 + 原因:- +
+
+
建议科目(供财务参考):
+ - +
+
+ + +
+

+ 凭证建议 + PENDING_REVIEW +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#摘要科目辅助核算借方贷方
1- +
-
+
-
+
-0.00-0.00-
合计0.000.00
+
+
+ 财务系统凭证号:- + +
+
+ 本页面为只读预览;如需修改,请在财务审核界面处理。 +
+
+ + +
+ +