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