Browse Source

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 <noreply@anthropic.com>
master
default 3 weeks ago
commit
e702ebd034
  1. 6
      .gitignore
  2. 103
      pom.xml
  3. 16
      src/main/java/com/autovoucher/AutoVoucherApplication.java
  4. 47
      src/main/java/com/autovoucher/common/ApiException.java
  5. 53
      src/main/java/com/autovoucher/common/GlobalExceptionHandler.java
  6. 65
      src/main/java/com/autovoucher/config/AppProperties.java
  7. 88
      src/main/java/com/autovoucher/config/SecurityConfig.java
  8. 22
      src/main/java/com/autovoucher/config/TenantContext.java
  9. 37
      src/main/java/com/autovoucher/config/TenantFilter.java
  10. 38
      src/main/java/com/autovoucher/domain/Account.java
  11. 24
      src/main/java/com/autovoucher/domain/AccountGroup.java
  12. 24
      src/main/java/com/autovoucher/domain/AuditLog.java
  13. 22
      src/main/java/com/autovoucher/domain/AuxItem.java
  14. 30
      src/main/java/com/autovoucher/domain/AuxValue.java
  15. 50
      src/main/java/com/autovoucher/domain/BaseEntity.java
  16. 29
      src/main/java/com/autovoucher/domain/Book.java
  17. 30
      src/main/java/com/autovoucher/domain/CaseMemory.java
  18. 21
      src/main/java/com/autovoucher/domain/Company.java
  19. 37
      src/main/java/com/autovoucher/domain/Correction.java
  20. 19
      src/main/java/com/autovoucher/domain/ExpenseCategory.java
  21. 29
      src/main/java/com/autovoucher/domain/MappingRule.java
  22. 22
      src/main/java/com/autovoucher/domain/Nonce.java
  23. 28
      src/main/java/com/autovoucher/domain/OaViewLog.java
  24. 40
      src/main/java/com/autovoucher/domain/OaViewTicket.java
  25. 34
      src/main/java/com/autovoucher/domain/PushAttempt.java
  26. 44
      src/main/java/com/autovoucher/domain/PushTask.java
  27. 34
      src/main/java/com/autovoucher/domain/RuleCandidate.java
  28. 55
      src/main/java/com/autovoucher/domain/SourceDoc.java
  29. 34
      src/main/java/com/autovoucher/domain/SourceEvent.java
  30. 42
      src/main/java/com/autovoucher/domain/SourceLine.java
  31. 23
      src/main/java/com/autovoucher/domain/SourceSystem.java
  32. 22
      src/main/java/com/autovoucher/domain/TargetSystem.java
  33. 29
      src/main/java/com/autovoucher/domain/Transaction.java
  34. 40
      src/main/java/com/autovoucher/domain/TransactionLine.java
  35. 59
      src/main/java/com/autovoucher/domain/Voucher.java
  36. 48
      src/main/java/com/autovoucher/domain/VoucherLine.java
  37. 26
      src/main/java/com/autovoucher/domain/VoucherType.java
  38. 4
      src/main/java/com/autovoucher/dto/DevBehaviorRequest.java
  39. 9
      src/main/java/com/autovoucher/dto/DevOaEventRequest.java
  40. 8
      src/main/java/com/autovoucher/dto/DevOaTicketRequest.java
  41. 4
      src/main/java/com/autovoucher/dto/LoginRequest.java
  42. 7
      src/main/java/com/autovoucher/dto/ManualVoucherRequest.java
  43. 11
      src/main/java/com/autovoucher/dto/OaSignResponse.java
  44. 10
      src/main/java/com/autovoucher/dto/OaStatusResponse.java
  45. 16
      src/main/java/com/autovoucher/dto/OaTicketRequest.java
  46. 13
      src/main/java/com/autovoucher/dto/OaTicketResponse.java
  47. 4
      src/main/java/com/autovoucher/dto/RejectVoucherRequest.java
  48. 8
      src/main/java/com/autovoucher/dto/RuleCandidateDecisionRequest.java
  49. 13
      src/main/java/com/autovoucher/dto/RuleRequest.java
  50. 15
      src/main/java/com/autovoucher/dto/VoucherLineInput.java
  51. 9
      src/main/java/com/autovoucher/dto/VoucherUpdateRequest.java
  52. 75
      src/main/java/com/autovoucher/job/NccPushJob.java
  53. 115
      src/main/java/com/autovoucher/push/MockNccVoucherClient.java
  54. 24
      src/main/java/com/autovoucher/push/NccMockBehavior.java
  55. 18
      src/main/java/com/autovoucher/push/PushLine.java
  56. 24
      src/main/java/com/autovoucher/push/PushRequest.java
  57. 25
      src/main/java/com/autovoucher/push/PushResult.java
  58. 15
      src/main/java/com/autovoucher/push/TargetSystemAdapter.java
  59. 33
      src/main/java/com/autovoucher/push/TargetSystemAdapterRegistry.java
  60. 13
      src/main/java/com/autovoucher/repository/AccountGroupRepository.java
  61. 15
      src/main/java/com/autovoucher/repository/AccountRepository.java
  62. 12
      src/main/java/com/autovoucher/repository/AuditLogRepository.java
  63. 13
      src/main/java/com/autovoucher/repository/AuxItemRepository.java
  64. 15
      src/main/java/com/autovoucher/repository/AuxValueRepository.java
  65. 13
      src/main/java/com/autovoucher/repository/BookRepository.java
  66. 12
      src/main/java/com/autovoucher/repository/CaseMemoryRepository.java
  67. 13
      src/main/java/com/autovoucher/repository/CompanyRepository.java
  68. 12
      src/main/java/com/autovoucher/repository/CorrectionRepository.java
  69. 13
      src/main/java/com/autovoucher/repository/ExpenseCategoryRepository.java
  70. 15
      src/main/java/com/autovoucher/repository/MappingRuleRepository.java
  71. 17
      src/main/java/com/autovoucher/repository/NonceRepository.java
  72. 12
      src/main/java/com/autovoucher/repository/OaViewLogRepository.java
  73. 13
      src/main/java/com/autovoucher/repository/OaViewTicketRepository.java
  74. 10
      src/main/java/com/autovoucher/repository/PushAttemptRepository.java
  75. 35
      src/main/java/com/autovoucher/repository/PushTaskRepository.java
  76. 15
      src/main/java/com/autovoucher/repository/RuleCandidateRepository.java
  77. 19
      src/main/java/com/autovoucher/repository/SourceDocRepository.java
  78. 15
      src/main/java/com/autovoucher/repository/SourceEventRepository.java
  79. 12
      src/main/java/com/autovoucher/repository/SourceLineRepository.java
  80. 13
      src/main/java/com/autovoucher/repository/SourceSystemRepository.java
  81. 13
      src/main/java/com/autovoucher/repository/TargetSystemRepository.java
  82. 12
      src/main/java/com/autovoucher/repository/TransactionLineRepository.java
  83. 12
      src/main/java/com/autovoucher/repository/TransactionRepository.java
  84. 12
      src/main/java/com/autovoucher/repository/VoucherLineRepository.java
  85. 21
      src/main/java/com/autovoucher/repository/VoucherRepository.java
  86. 13
      src/main/java/com/autovoucher/repository/VoucherTypeRepository.java
  87. 246
      src/main/java/com/autovoucher/rule/RuleEngine.java
  88. 18
      src/main/java/com/autovoucher/rule/RuleMatch.java
  89. 123
      src/main/java/com/autovoucher/seed/SeedCatalog.java
  90. 379
      src/main/java/com/autovoucher/seed/SeedLoader.java
  91. 81
      src/main/java/com/autovoucher/service/AccountResolver.java
  92. 37
      src/main/java/com/autovoucher/service/AuditService.java
  93. 253
      src/main/java/com/autovoucher/service/DocumentProcessingService.java
  94. 13
      src/main/java/com/autovoucher/service/IngestResult.java
  95. 62
      src/main/java/com/autovoucher/service/OaViewAuditService.java
  96. 18
      src/main/java/com/autovoucher/service/OaViewPage.java
  97. 440
      src/main/java/com/autovoucher/service/OaViewService.java
  98. 14
      src/main/java/com/autovoucher/service/ProcessingOutcome.java
  99. 312
      src/main/java/com/autovoucher/service/PushService.java
  100. 6
      src/main/java/com/autovoucher/service/ResolvedAccount.java

6
.gitignore

@ -0,0 +1,6 @@
target/
*.log
.idea/
*.iml
.vscode/
.DS_Store

103
pom.xml

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.autovoucher</groupId>
<artifactId>auto-voucher-console</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>autoVoucher Console</name>
<description>autoVoucher backend: OA ingest, voucher suggestion, review and NCC push</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

16
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);
}
}

47
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;
}
}

53
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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> body(String code, String message, String path) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", code);
body.put("message", message);
body.put("path", path);
body.put("timestamp", OffsetDateTime.now().toString());
return body;
}
}

65
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<String> allowedOrigins = new ArrayList<>(List.of("http://localhost:5173"));
}
}

88
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();
}
}

22
src/main/java/com/autovoucher/config/TenantContext.java

@ -0,0 +1,22 @@
package com.autovoucher.config;
public final class TenantContext {
private static final ThreadLocal<Long> 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();
}
}

37
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();
}
}
}

38
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;
}

24
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;
}

24
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;
}

22
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;
}

30
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";
}

50
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();
}
}

29
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";
}

30
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;
}

21
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";
}

37
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";
}

19
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;
}

29
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";
}

22
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;
}

28
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;
}

40
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;
}

34
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;
}

44
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;
}

34
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";
}

55
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;
}

34
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;
}

42
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;
}

23
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;
}

22
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;
}

29
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;
}

40
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;
}

59
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;
}

48
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;
}

26
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;
}

4
src/main/java/com/autovoucher/dto/DevBehaviorRequest.java

@ -0,0 +1,4 @@
package com.autovoucher.dto;
public record DevBehaviorRequest(String behavior) {
}

9
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
) {
}

8
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
) {
}

4
src/main/java/com/autovoucher/dto/LoginRequest.java

@ -0,0 +1,4 @@
package com.autovoucher.dto;
public record LoginRequest(String username, String password) {
}

7
src/main/java/com/autovoucher/dto/ManualVoucherRequest.java

@ -0,0 +1,7 @@
package com.autovoucher.dto;
public record ManualVoucherRequest(
String accountCode,
String reason
) {
}

11
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
) {
}

10
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
) {
}

16
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
) {
}

13
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
) {
}

4
src/main/java/com/autovoucher/dto/RejectVoucherRequest.java

@ -0,0 +1,4 @@
package com.autovoucher.dto;
public record RejectVoucherRequest(String reason) {
}

8
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
) {
}

13
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
) {
}

15
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<String, String> aux,
String summary
) {
}

9
src/main/java/com/autovoucher/dto/VoucherUpdateRequest.java

@ -0,0 +1,9 @@
package com.autovoucher.dto;
import java.util.List;
public record VoucherUpdateRequest(
List<VoucherLineInput> lines,
String reason
) {
}

75
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<PushTask> 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<PushTask> 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();
}
}
}

115
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<String, String[]> 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 "{}";
}
}
}

24
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<String> behavior = new AtomicReference<>("SUCCESS");
public String get() {
return behavior.get();
}
public void set(String value) {
behavior.set(value == null ? "SUCCESS" : value.toUpperCase());
}
}

18
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<String, String> aux,
String summary
) {
}

24
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<PushLine> lines
) {
}

25
src/main/java/com/autovoucher/push/PushResult.java

@ -0,0 +1,25 @@
package com.autovoucher.push;
/**
* Result of a target-system call.
*
* <p>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.</p>
*/
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);
}
}

15
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);
}

33
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<String, TargetSystemAdapter> adapters;
public TargetSystemAdapterRegistry(List<TargetSystemAdapter> 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);
}
}

13
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<AccountGroup, Long> {
Optional<AccountGroup> findByTenantIdAndGroupCode(Long tenantId, String groupCode);
List<AccountGroup> findAllByTenantIdOrderByGroupCodeAsc(Long tenantId);
}

15
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<Account, Long> {
Optional<Account> findByTenantIdAndCompanyIdAndBookIdAndCode(Long tenantId, String companyId, String bookId, String code);
List<Account> findAllByTenantIdAndCompanyIdAndBookIdOrderByCodeAsc(Long tenantId, String companyId, String bookId);
List<Account> findAllByTenantIdOrderByCodeAsc(Long tenantId);
}

12
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<AuditLog, Long> {
List<AuditLog> findAllByTenantIdAndEntityTypeAndEntityIdOrderByIdDesc(Long tenantId, String entityType, String entityId);
List<AuditLog> findAllByTenantIdOrderByIdDesc(Long tenantId);
}

13
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<AuxItem, Long> {
Optional<AuxItem> findByTenantIdAndAuxType(Long tenantId, String auxType);
List<AuxItem> findAllByTenantIdOrderByAuxTypeAsc(Long tenantId);
}

15
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<AuxValue, Long> {
Optional<AuxValue> findByTenantIdAndAuxTypeAndCode(Long tenantId, String auxType, String code);
List<AuxValue> findAllByTenantIdAndAuxTypeOrderByCodeAsc(Long tenantId, String auxType);
List<AuxValue> findAllByTenantIdOrderByAuxTypeAscCodeAsc(Long tenantId);
}

13
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<Book, Long> {
Optional<Book> findByTenantIdAndBookId(Long tenantId, String bookId);
List<Book> findAllByTenantIdOrderByIdAsc(Long tenantId);
}

12
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<CaseMemory, Long> {
List<CaseMemory> findAllByTenantIdOrderByIdDesc(Long tenantId);
List<CaseMemory> findAllByTenantIdAndSourceDocIdOrderByIdDesc(Long tenantId, Long sourceDocId);
}

13
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<Company, Long> {
Optional<Company> findByTenantIdAndCompanyId(Long tenantId, String companyId);
List<Company> findAllByTenantIdOrderByIdAsc(Long tenantId);
}

12
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<Correction, Long> {
List<Correction> findAllByTenantIdAndVoucherIdOrderByIdDesc(Long tenantId, Long voucherId);
List<Correction> findAllByTenantIdOrderByIdDesc(Long tenantId);
}

13
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<ExpenseCategory, Long> {
Optional<ExpenseCategory> findByTenantIdAndCode(Long tenantId, String code);
List<ExpenseCategory> findAllByTenantIdOrderByIdAsc(Long tenantId);
}

15
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<MappingRule, Long> {
Optional<MappingRule> findByTenantIdAndRuleCodeAndVersion(Long tenantId, String ruleCode, Integer version);
List<MappingRule> findAllByTenantIdAndStatusOrderByPriorityDesc(Long tenantId, String status);
List<MappingRule> findAllByTenantIdOrderByPriorityDesc(Long tenantId);
}

17
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<Nonce, Long> {
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);
}

12
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<OaViewLog, Long> {
List<OaViewLog> findAllByTenantIdAndTicketIdOrderByIdAsc(Long tenantId, Long ticketId);
List<OaViewLog> findAllByTenantIdOrderByIdDesc(Long tenantId);
}

13
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<OaViewTicket, Long> {
Optional<OaViewTicket> findByTenantIdAndTokenHash(Long tenantId, String tokenHash);
List<OaViewTicket> findAllByTenantIdOrderByIdDesc(Long tenantId);
}

10
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<PushAttempt, Long> {
List<PushAttempt> findAllByTenantIdAndTaskIdOrderByAttemptNoAsc(Long tenantId, Long taskId);
}

35
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<PushTask, Long> {
Optional<PushTask> findByTenantIdAndIdempotencyKey(Long tenantId, String idempotencyKey);
Optional<PushTask> findByTenantIdAndVoucherId(Long tenantId, Long voucherId);
List<PushTask> findAllByTenantIdOrderByIdDesc(Long tenantId);
List<PushTask> findAllByTenantIdAndStatusInOrderByIdDesc(Long tenantId, List<String> 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<PushTask> findDueTasks(@Param("tenantId") Long tenantId,
@Param("targetSystem") String targetSystem,
@Param("statuses") List<String> statuses,
@Param("now") LocalDateTime now);
long countByTenantIdAndStatus(Long tenantId, String status);
}

15
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<RuleCandidate, Long> {
Optional<RuleCandidate> findByTenantIdAndRuleCode(Long tenantId, String ruleCode);
List<RuleCandidate> findAllByTenantIdAndStatusOrderBySupportCountDesc(Long tenantId, String status);
List<RuleCandidate> findAllByTenantIdOrderByIdDesc(Long tenantId);
}

19
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<SourceDoc, Long> {
Optional<SourceDoc> findByTenantIdAndSourceSystemAndExternalId(Long tenantId, String sourceSystem, String externalId);
Optional<SourceDoc> findByTenantIdAndDocNo(Long tenantId, String docNo);
List<SourceDoc> findAllByTenantIdOrderByIdDesc(Long tenantId);
List<SourceDoc> findAllByTenantIdAndProcessingStatusOrderByIdDesc(Long tenantId, String processingStatus);
long countByTenantIdAndProcessingStatus(Long tenantId, String processingStatus);
}

15
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<SourceEvent, Long> {
Optional<SourceEvent> findByTenantIdAndEventId(Long tenantId, String eventId);
boolean existsByTenantIdAndEventId(Long tenantId, String eventId);
List<SourceEvent> findAllByTenantIdOrderByIdDesc(Long tenantId);
}

12
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<SourceLine, Long> {
List<SourceLine> findAllByTenantIdAndSourceDocIdOrderByLineNoAsc(Long tenantId, Long sourceDocId);
void deleteAllByTenantIdAndSourceDocId(Long tenantId, Long sourceDocId);
}

13
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<SourceSystem, Long> {
Optional<SourceSystem> findByTenantIdAndSourceSystem(Long tenantId, String sourceSystem);
List<SourceSystem> findAllByTenantIdOrderByIdAsc(Long tenantId);
}

13
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<TargetSystem, Long> {
Optional<TargetSystem> findByTenantIdAndTargetSystem(Long tenantId, String targetSystem);
List<TargetSystem> findAllByTenantIdOrderByIdAsc(Long tenantId);
}

12
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<TransactionLine, Long> {
List<TransactionLine> findAllByTenantIdAndTransactionIdOrderByLineNoAsc(Long tenantId, Long transactionId);
void deleteAllByTenantIdAndTransactionId(Long tenantId, Long transactionId);
}

12
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<Transaction, Long> {
Optional<Transaction> findByTenantIdAndSourceDocId(Long tenantId, Long sourceDocId);
void deleteAllByTenantIdAndSourceDocId(Long tenantId, Long sourceDocId);
}

12
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<VoucherLine, Long> {
List<VoucherLine> findAllByTenantIdAndVoucherIdOrderByLineNoAsc(Long tenantId, Long voucherId);
void deleteAllByTenantIdAndVoucherId(Long tenantId, Long voucherId);
}

21
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<Voucher, Long> {
Optional<Voucher> findByTenantIdAndVoucherNo(Long tenantId, String voucherNo);
Optional<Voucher> findByTenantIdAndSourceDocIdAndCurrentFlag(Long tenantId, Long sourceDocId, Integer currentFlag);
List<Voucher> findAllByTenantIdOrderByIdDesc(Long tenantId);
List<Voucher> findAllByTenantIdAndStatusOrderByIdDesc(Long tenantId, String status);
List<Voucher> findAllByTenantIdAndSourceDocIdOrderByIdDesc(Long tenantId, Long sourceDocId);
long countByTenantIdAndStatus(Long tenantId, String status);
}

13
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<VoucherType, Long> {
Optional<VoucherType> findByTenantIdAndCode(Long tenantId, String code);
List<VoucherType> findAllByTenantIdOrderByIdAsc(Long tenantId);
}

246
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<String, Object> 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<RuleMatch> partialMatchesByCategory(String expenseCategory, Map<String, Object> facts) {
if (expenseCategory == null) {
return List.of();
}
List<RuleMatch> result = new ArrayList<>();
for (MappingRule rule : rules()) {
JsonNode condition = parse(rule.getConditionJson());
List<JsonNode> 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<String> 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<String, Object> facts) {
return evaluate(parse(conditionJson), facts);
}
public boolean evaluate(JsonNode condition, Map<String, Object> 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<String, Object> facts) {
JsonNode condition = parse(rule.getConditionJson());
List<String> 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<String, Object> facts, List<String> matchedFields) {
int count = 0;
for (JsonNode predicate : flatten(condition)) {
if (predicateMatches(predicate, facts)) {
count++;
matchedFields.add(predicate.path("field").asText());
}
}
return count;
}
private List<JsonNode> flatten(JsonNode condition) {
List<JsonNode> 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<String, Object> 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<MappingRule> 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<String, Object> factsView(Map<String, Object> facts) {
return new LinkedHashMap<>(facts);
}
}

18
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<String> matchedFields,
Map<String, Object> facts
) {
public BigDecimal priorityScore() {
return BigDecimal.valueOf(rule.getPriority() == null ? 0 : rule.getPriority());
}
}

123
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<JsonNode> master(String section) {
JsonNode root = load();
if (root == null) {
return List.of();
}
return toList(root.path("masterData").path(section));
}
public List<JsonNode> sourceDocs() {
JsonNode root = load();
return root == null ? List.of() : toList(root.path("sourceDocs"));
}
public List<JsonNode> sourceEvents() {
JsonNode root = load();
return root == null ? List.of() : toList(root.path("sourceEvents"));
}
public Optional<JsonNode> document(String externalId) {
return sourceDocs().stream()
.filter(doc -> externalId.equals(doc.path("externalId").asText()))
.findFirst();
}
public Optional<SourceDocPayload> 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<JsonNode> toList(JsonNode array) {
List<JsonNode> 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<Path> 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);
}
}

379
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);
}
}
}

81
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<AuxValue> 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());
}
}
}

37
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();
}
}

253
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<SourceLine> 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<SourceLine> 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 "[]";
}
}
}

13
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<String> candidates
) {
}

62
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);
}
}

18
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<VoucherLine> lines,
List<String> candidates,
String message
) {
}

440
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<VoucherLine> 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<String> 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<String> candidates(SourceDoc doc) {
if (doc.getCandidatesJson() == null || doc.getCandidatesJson().isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(doc.getCandidatesJson(), new TypeReference<List<String>>() {
});
} 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();
}
}

14
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<String> candidates
) {
public static ProcessingOutcome ignored(String message) {
return new ProcessingOutcome("IGNORED", null, message, List.of());
}
}

312
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<PushAttempt> 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<VoucherLine> voucherLines = voucherLineRepository
.findAllByTenantIdAndVoucherIdOrderByLineNoAsc(TenantContext.getTenantId(), voucher.getId());
List<PushLine> 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<String, String> parseAux(String json) {
if (json == null || json.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<Map<String, String>>() {
});
} 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";
}
}
}

6
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) {
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save