Browse Source
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
commit
e702ebd034
128 changed files with 8135 additions and 0 deletions
@ -0,0 +1,6 @@ |
|||
target/ |
|||
*.log |
|||
.idea/ |
|||
*.iml |
|||
.vscode/ |
|||
.DS_Store |
|||
@ -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> |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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")); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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"; |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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"; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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"; |
|||
} |
|||
@ -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"; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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"; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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"; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,4 @@ |
|||
package com.autovoucher.dto; |
|||
|
|||
public record DevBehaviorRequest(String behavior) { |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
package com.autovoucher.dto; |
|||
|
|||
public record DevOaEventRequest( |
|||
String eventId, |
|||
String sourceSystem, |
|||
String externalId, |
|||
String eventType |
|||
) { |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
package com.autovoucher.dto; |
|||
|
|||
public record DevOaTicketRequest( |
|||
String sourceSystem, |
|||
String externalId, |
|||
String operatorId |
|||
) { |
|||
} |
|||
@ -0,0 +1,4 @@ |
|||
package com.autovoucher.dto; |
|||
|
|||
public record LoginRequest(String username, String password) { |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
package com.autovoucher.dto; |
|||
|
|||
public record ManualVoucherRequest( |
|||
String accountCode, |
|||
String reason |
|||
) { |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
package com.autovoucher.dto; |
|||
|
|||
public record OaStatusResponse( |
|||
String status, |
|||
String message, |
|||
Long voucherId, |
|||
String voucherNo, |
|||
String voucherStatus |
|||
) { |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -0,0 +1,4 @@ |
|||
package com.autovoucher.dto; |
|||
|
|||
public record RejectVoucherRequest(String reason) { |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
package com.autovoucher.dto; |
|||
|
|||
public record RuleCandidateDecisionRequest( |
|||
String ruleCode, |
|||
String name, |
|||
Integer priority |
|||
) { |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
package com.autovoucher.dto; |
|||
|
|||
import java.util.List; |
|||
|
|||
public record VoucherUpdateRequest( |
|||
List<VoucherLineInput> lines, |
|||
String reason |
|||
) { |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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 "{}"; |
|||
} |
|||
} |
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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 "[]"; |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
@ -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"; |
|||
} |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue