优化
This commit is contained in:
@@ -94,4 +94,13 @@ baiyue:
|
|||||||
max-rows: 200000
|
max-rows: 200000
|
||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
batch-size: 500
|
||||||
|
FINANCE_RECEIVABLE_STATISTICS:
|
||||||
|
provider-service: finance-service
|
||||||
|
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
|
||||||
|
required-permission: platform:finance:receivable-statistics:export
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 500
|
||||||
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
|
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
|
||||||
|
|||||||
@@ -90,5 +90,14 @@ baiyue:
|
|||||||
max-rows: 200000
|
max-rows: 200000
|
||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
batch-size: 500
|
||||||
|
FINANCE_RECEIVABLE_STATISTICS:
|
||||||
|
provider-service: finance-service
|
||||||
|
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
|
||||||
|
required-permission: platform:finance:receivable-statistics:export
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 500
|
||||||
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
|
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
|
||||||
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
|
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
|
||||||
|
|||||||
@@ -4850,6 +4850,13 @@ DELIMITER ;
|
|||||||
CALL phase3_add_allocation_picked_count();
|
CALL phase3_add_allocation_picked_count();
|
||||||
DROP PROCEDURE phase3_add_allocation_picked_count;
|
DROP PROCEDURE phase3_add_allocation_picked_count;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||||
|
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818',
|
||||||
|
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
|
PRIMARY KEY (sequence_key),
|
||||||
|
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
|
||||||
|
) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
||||||
@@ -10954,6 +10961,13 @@ CREATE TABLE IF NOT EXISTS fin_idempotency_record (
|
|||||||
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
|
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
|
||||||
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
|
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fin_document_sequence (
|
||||||
|
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如REC:20260818',
|
||||||
|
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
|
PRIMARY KEY (sequence_key),
|
||||||
|
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
|
||||||
|
) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
|||||||
@@ -173,6 +173,15 @@ baiyue:
|
|||||||
max-rows: 200000
|
max-rows: 200000
|
||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
batch-size: 500
|
||||||
|
FINANCE_RECEIVABLE_STATISTICS:
|
||||||
|
provider-service: finance-service
|
||||||
|
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
|
||||||
|
required-permission: platform:finance:receivable-statistics:export
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 500
|
||||||
management.endpoints.web.exposure.include: health,info,prometheus
|
management.endpoints.web.exposure.include: health,info,prometheus
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ class ExportDefinitionRegistryTest {
|
|||||||
registry.setDefinitions(Map.of("FINANCE_RECONCILIATION_STATEMENT_LIST", finance));
|
registry.setDefinitions(Map.of("FINANCE_RECONCILIATION_STATEMENT_LIST", finance));
|
||||||
assertDoesNotThrow(registry::validate);
|
assertDoesNotThrow(registry::validate);
|
||||||
}
|
}
|
||||||
|
@Test void acceptsTheReceivableStatisticsProvider() {
|
||||||
|
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
|
||||||
|
var finance=definition("finance-service","/internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches");
|
||||||
|
finance.setRequiredPermission("platform:finance:receivable-statistics:export");
|
||||||
|
registry.setDefinitions(Map.of("FINANCE_RECEIVABLE_STATISTICS", finance));
|
||||||
|
assertDoesNotThrow(registry::validate);
|
||||||
|
}
|
||||||
@Test void rejectsSsrfAndPathTypeMismatch() {
|
@Test void rejectsSsrfAndPathTypeMismatch() {
|
||||||
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
|
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
|
||||||
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches"))));
|
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches"))));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.springframework.cloud.openfeign.FeignClient;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -15,20 +16,23 @@ import java.time.LocalDateTime;
|
|||||||
public interface FirstlegFeeSourceClient {
|
public interface FirstlegFeeSourceClient {
|
||||||
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
|
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
|
||||||
ApiResponse<Snapshot> inbound(@RequestHeader("X-Internal-Service-Token") String token,
|
ApiResponse<Snapshot> inbound(@RequestHeader("X-Internal-Service-Token") String token,
|
||||||
@PathVariable long inboundId, @PathVariable long feeId);
|
@PathVariable long inboundId, @PathVariable long feeId,
|
||||||
|
@RequestParam int sourceVersion);
|
||||||
|
|
||||||
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
|
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
|
||||||
ApiResponse<Snapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
|
ApiResponse<Snapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
|
||||||
@PathVariable long containerId, @PathVariable long feeId);
|
@PathVariable long containerId, @PathVariable long feeId,
|
||||||
|
@RequestParam int sourceVersion);
|
||||||
|
|
||||||
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
|
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
|
||||||
Long inboundId, String inboundCode, Long orderId, Long containerId,
|
Long inboundId, String inboundCode, Long orderId, Long containerId,
|
||||||
Long containerBookingId, String containerNo, long customerId,
|
Long containerBookingId, String containerNo, long customerId,
|
||||||
String customerName, Long partnerId, String partnerName,
|
String customerName, Long supplierId, String supplierName,
|
||||||
|
String supplierCategory,
|
||||||
Long feeItemId, String feeItemCode,
|
Long feeItemId, String feeItemCode,
|
||||||
String feeItemName, String direction, LocalDate businessDate,
|
String feeItemName, String direction, LocalDate businessDate,
|
||||||
String currency, BigDecimal totalAmount, String reviewStatus,
|
String currency, BigDecimal totalAmount, String reviewStatus,
|
||||||
String publicationStatus, boolean settlementIncluded,
|
String publicationStatus,
|
||||||
LocalDateTime publishedAt, BigDecimal chargeableQuantity,
|
LocalDateTime publishedAt, BigDecimal chargeableQuantity,
|
||||||
String chargeUnit, BigDecimal actualGrossWeightKg,
|
String chargeUnit, BigDecimal actualGrossWeightKg,
|
||||||
BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {}
|
BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import com.baiyue.common.core.api.ApiResponse;
|
|||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.finance.service.FinanceService;
|
import com.baiyue.finance.service.FinanceService;
|
||||||
import com.baiyue.finance.service.FinanceQueryService;
|
import com.baiyue.finance.service.FinanceQueryService;
|
||||||
|
import com.baiyue.finance.service.ReconciliationStatementService;
|
||||||
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -16,9 +19,12 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
public class ClientFinanceController {
|
public class ClientFinanceController {
|
||||||
private final FinanceService service;
|
private final FinanceService service;
|
||||||
private final FinanceQueryService queryService;
|
private final FinanceQueryService queryService;
|
||||||
public ClientFinanceController(FinanceService service, FinanceQueryService queryService) {
|
private final ReconciliationStatementService reconciliationStatementService;
|
||||||
|
public ClientFinanceController(FinanceService service, FinanceQueryService queryService,
|
||||||
|
ReconciliationStatementService reconciliationStatementService) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
this.queryService = queryService;
|
this.queryService = queryService;
|
||||||
|
this.reconciliationStatementService = reconciliationStatementService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/bills")
|
@GetMapping("/bills")
|
||||||
@@ -45,6 +51,17 @@ public class ClientFinanceController {
|
|||||||
return FinanceResponses.success(queryService.statements(user.tenantId(), true));
|
return FinanceResponses.success(queryService.statements(user.tenantId(), true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/statements/{statementId}/confirm")
|
||||||
|
@PreAuthorize("hasAuthority('merchant:finance:statement:confirm')")
|
||||||
|
@Operation(summary = "客户确认本人已出账对账单")
|
||||||
|
public ApiResponse<?> confirmStatement(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable long statementId,
|
||||||
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||||
|
@Valid @RequestBody CustomerConfirmationRequest request) {
|
||||||
|
return FinanceResponses.success(reconciliationStatementService.confirmForCustomer(
|
||||||
|
user.tenantId(), user.userId(), statementId, idempotencyKey, request));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/receivable-settlements")
|
@GetMapping("/receivable-settlements")
|
||||||
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
|
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
|
||||||
@Operation(summary = "客户只读查询结算应收")
|
@Operation(summary = "客户只读查询结算应收")
|
||||||
|
|||||||
+37
-10
@@ -3,7 +3,8 @@ package com.baiyue.finance.controller;
|
|||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
|
||||||
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
||||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||||
import com.baiyue.finance.service.FinanceCustomerAccountService;
|
import com.baiyue.finance.service.FinanceCustomerAccountService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -64,13 +65,12 @@ public class FinancePhase1OperationController {
|
|||||||
@RequestParam(required = false) String direction,
|
@RequestParam(required = false) String direction,
|
||||||
@RequestParam(required = false) String status,
|
@RequestParam(required = false) String status,
|
||||||
@RequestParam(required = false) String reviewStatus,
|
@RequestParam(required = false) String reviewStatus,
|
||||||
@RequestParam(required = false) Boolean settlementIncluded,
|
|
||||||
@RequestParam(required = false) Boolean statementGenerated,
|
@RequestParam(required = false) Boolean statementGenerated,
|
||||||
@RequestParam(required = false) LocalDate businessDateFrom,
|
@RequestParam(required = false) LocalDate businessDateFrom,
|
||||||
@RequestParam(required = false) LocalDate businessDateTo,
|
@RequestParam(required = false) LocalDate businessDateTo,
|
||||||
@RequestParam(required = false) String keyword) {
|
@RequestParam(required = false) String keyword) {
|
||||||
return service.listFinanceFeeSources(page, pageSize, customerId, direction, status, reviewStatus,
|
return service.listFinanceFeeSources(page, pageSize, customerId, direction, status, reviewStatus,
|
||||||
settlementIncluded, statementGenerated, businessDateFrom, businessDateTo, keyword);
|
statementGenerated, businessDateFrom, businessDateTo, keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/fee-sources/{feeSourceId}")
|
@GetMapping("/fee-sources/{feeSourceId}")
|
||||||
@@ -88,12 +88,39 @@ public class FinancePhase1OperationController {
|
|||||||
return FinanceResponses.success(service.reviewFinanceFeeSource(feeSourceId, user.userId(), key, request));
|
return FinanceResponses.success(service.reviewFinanceFeeSource(feeSourceId, user.userId(), key, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/fee-sources/{feeSourceId}/settlement-inclusion")
|
@PutMapping("/fee-sources/{feeSourceId}")
|
||||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:settlement')")
|
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||||
public ApiResponse<?> updateFeeSourceSettlementInclusion(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<?> editFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable long feeSourceId,
|
@PathVariable long feeSourceId,
|
||||||
@RequestHeader("Idempotency-Key") String key,
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
@Valid @RequestBody SettlementInclusionRequest request) {
|
@Valid @RequestBody FeeSourceEditRequest request) {
|
||||||
return FinanceResponses.success(service.updateFeeSourceSettlementInclusion(feeSourceId, user.userId(), key, request));
|
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/fee-sources/{feeSourceId}/submit")
|
||||||
|
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||||
|
public ApiResponse<?> submitFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable long feeSourceId,
|
||||||
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@Valid @RequestBody FeeSourceActionRequest request) {
|
||||||
|
return FinanceResponses.success(service.submitFinanceFeeSource(feeSourceId, user.userId(), key, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/fee-sources/{feeSourceId}/adjust")
|
||||||
|
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||||
|
public ApiResponse<?> adjustFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable long feeSourceId,
|
||||||
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@Valid @RequestBody FeeSourceEditRequest request) {
|
||||||
|
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/fee-sources/{feeSourceId}/void")
|
||||||
|
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||||
|
public ApiResponse<?> voidFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable long feeSourceId,
|
||||||
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@Valid @RequestBody FeeSourceActionRequest request) {
|
||||||
|
return FinanceResponses.success(service.voidFinanceFeeSource(feeSourceId, user.userId(), key, request));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,22 +105,6 @@ public class PlatformFinanceController {
|
|||||||
return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)));
|
return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/bills")
|
|
||||||
@PreAuthorize("hasAuthority('platform:finance:bill:create')")
|
|
||||||
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
|
|
||||||
public ApiResponse<?> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
|
|
||||||
var lines = request.lines().stream().map(line -> new FinanceService.BillLine("FIRSTLEG_FEE", line.sourceFeeId(), line.sourceFeeVersion(), null, line.orderId(), line.inboundId(), "MANUAL", line.feeItemName(), line.amount())).toList();
|
|
||||||
var internal = new FinanceService.BillRequest(null, 1L, request.billDate(), null, lines);
|
|
||||||
return FinanceResponses.success(resourceView(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/bills/{billId}/issue")
|
|
||||||
@PreAuthorize("hasAuthority('platform:finance:bill:issue')")
|
|
||||||
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
|
|
||||||
public ApiResponse<?> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
|
|
||||||
return FinanceResponses.success(resourceView(service.issueBillById(user.userId(), billId, request.expectedVersion(), key)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/bills/{billId}")
|
@GetMapping("/bills/{billId}")
|
||||||
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
|
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
|
||||||
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
|
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
|
||||||
@@ -228,7 +212,6 @@ public class PlatformFinanceController {
|
|||||||
private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; }
|
private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; }
|
||||||
|
|
||||||
public record TermsRequest(String customerName, Integer creditDays) {}
|
public record TermsRequest(String customerName, Integer creditDays) {}
|
||||||
public record CreateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List<BillLineInput> lines) {}
|
|
||||||
public record BillLineInput(long sourceFeeId, int sourceFeeVersion, Long orderId, Long inboundId, String feeItemName, java.math.BigDecimal amount) {}
|
public record BillLineInput(long sourceFeeId, int sourceFeeVersion, Long orderId, Long inboundId, String feeItemName, java.math.BigDecimal amount) {}
|
||||||
public record VersionRequest(int expectedVersion) {}
|
public record VersionRequest(int expectedVersion) {}
|
||||||
public record ReasonRequest(int expectedVersion, String reason) {}
|
public record ReasonRequest(int expectedVersion, String reason) {}
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package com.baiyue.finance.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
|
||||||
|
import com.baiyue.finance.service.ReceivableStatisticsService;
|
||||||
|
import com.baiyue.finance.service.FinanceEventException;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.YearMonth;
|
||||||
|
|
||||||
|
@Tag(name = "应收款统计")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/finance/v1/receivable-statistics")
|
||||||
|
public class ReceivableStatisticsController {
|
||||||
|
private final ReceivableStatisticsService service;
|
||||||
|
|
||||||
|
public ReceivableStatisticsController(ReceivableStatisticsService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:finance:receivable-statistics:list')")
|
||||||
|
@Operation(summary = "按生成月份和客户汇总CNY正式应收结算单")
|
||||||
|
public ApiResponse<ReceivableStatisticsResponse> query(
|
||||||
|
@RequestParam @DateTimeFormat(pattern = "yyyy-MM") String month,
|
||||||
|
@RequestParam(required = false) Long customerId,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
try {
|
||||||
|
return FinanceResponses.success(service.query(YearMonth.parse(month), customerId, page, pageSize));
|
||||||
|
} catch (java.time.format.DateTimeParseException exception) {
|
||||||
|
throw new FinanceEventException("FIN-STAT-400", "统计月份格式必须为yyyy-MM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ public final class FinancePhase1Dtos {
|
|||||||
Long inboundId, String inboundCode, Long containerId,
|
Long inboundId, String inboundCode, Long containerId,
|
||||||
String containerNo, LocalDate businessDate, String feeItemName,
|
String containerNo, LocalDate businessDate, String feeItemName,
|
||||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
|
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
|
||||||
String currency, String reviewStatus, boolean settlementIncluded,
|
String currency, String reviewStatus,
|
||||||
String sourceStatus, boolean statementGenerated) {}
|
String sourceStatus, boolean statementGenerated) {}
|
||||||
|
|
||||||
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
|
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
|
||||||
@@ -54,15 +54,16 @@ public final class FinancePhase1Dtos {
|
|||||||
Long orderId, Long inboundId, String inboundCode, Long containerId,
|
Long orderId, Long inboundId, String inboundCode, Long containerId,
|
||||||
String containerNo, LocalDate businessDate, String feeItemName,
|
String containerNo, LocalDate businessDate, String feeItemName,
|
||||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
|
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
|
||||||
String currency, String reviewStatus, boolean settlementIncluded,
|
String currency, String reviewStatus,
|
||||||
String sourceStatus, boolean statementGenerated,
|
String sourceStatus, boolean statementGenerated,
|
||||||
String reviewReason, String settlementReason, String sourceHash,
|
String reviewReason, String sourceHash,
|
||||||
int operationVersion, List<OperationLog> operationLogs) {}
|
int operationVersion, List<OperationLog> operationLogs) {}
|
||||||
|
|
||||||
public record OperationLog(LocalDateTime occurredAt, String action, String operatorName,
|
public record OperationLog(LocalDateTime occurredAt, String action, String operatorName,
|
||||||
String result, String remark) {}
|
String result, String remark) {}
|
||||||
public record ReviewRequest(String action, String reason, int expectedVersion) {}
|
public record ReviewRequest(String action, String reason, int expectedVersion) {}
|
||||||
public record SettlementInclusionRequest(boolean included, String reason, int expectedVersion) {}
|
public record FeeSourceEditRequest(BigDecimal amount, String reason, int expectedVersion) {}
|
||||||
|
public record FeeSourceActionRequest(String reason, int expectedVersion) {}
|
||||||
|
|
||||||
private static String correlation(String first, String second) {
|
private static String correlation(String first, String second) {
|
||||||
String value = MDC.get(first);
|
String value = MDC.get(first);
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ public final class FinanceReadModels {
|
|||||||
String currency,
|
String currency,
|
||||||
String status,
|
String status,
|
||||||
int version,
|
int version,
|
||||||
|
String customerConfirmationStatus,
|
||||||
|
Long customerConfirmedBy,
|
||||||
|
String customerConfirmedByName,
|
||||||
|
LocalDateTime customerConfirmedAt,
|
||||||
long createdBy,
|
long createdBy,
|
||||||
String createdByName,
|
String createdByName,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
@@ -99,6 +103,10 @@ public final class FinanceReadModels {
|
|||||||
LocalDateTime reviewedAt,
|
LocalDateTime reviewedAt,
|
||||||
LocalDateTime issuedAt,
|
LocalDateTime issuedAt,
|
||||||
int version,
|
int version,
|
||||||
|
String customerConfirmationStatus,
|
||||||
|
Long customerConfirmedBy,
|
||||||
|
String customerConfirmedByName,
|
||||||
|
LocalDateTime customerConfirmedAt,
|
||||||
String remark,
|
String remark,
|
||||||
long createdBy,
|
long createdBy,
|
||||||
String createdByName,
|
String createdByName,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public final class FinanceSettlementDtos {
|
|||||||
String inboundCode, Long containerId, String containerNo,
|
String inboundCode, Long containerId, String containerNo,
|
||||||
LocalDate businessDate, String feeItemName,
|
LocalDate businessDate, String feeItemName,
|
||||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
||||||
String reviewStatus, boolean settlementIncluded, String sourceStatus,
|
String reviewStatus, String sourceStatus,
|
||||||
boolean statementGenerated) {}
|
boolean statementGenerated) {}
|
||||||
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
||||||
BigDecimal amount, String method, String referenceNo,
|
BigDecimal amount, String method, String referenceNo,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.baiyue.finance.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class ReceivableStatisticsDtos {
|
||||||
|
private ReceivableStatisticsDtos() {}
|
||||||
|
|
||||||
|
public record ReceivableStatisticsRow(long customerId, String customerName, String currency,
|
||||||
|
BigDecimal totalReceivable, BigDecimal totalReceived,
|
||||||
|
BigDecimal totalOutstanding) {}
|
||||||
|
|
||||||
|
public record ReceivableStatisticsResponse(YearMonth statisticsMonth, String currency,
|
||||||
|
BigDecimal totalReceivable, BigDecimal totalReceived,
|
||||||
|
BigDecimal totalOutstanding, long totalCustomers,
|
||||||
|
int page, int pageSize,
|
||||||
|
List<ReceivableStatisticsRow> records) {}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ public final class ReconciliationStatementDtos {
|
|||||||
public record ReconciliationActionRequest(String action, String reason, int expectedVersion,
|
public record ReconciliationActionRequest(String action, String reason, int expectedVersion,
|
||||||
List<StatementAdjustmentLineRequest> adjustmentLines) {}
|
List<StatementAdjustmentLineRequest> adjustmentLines) {}
|
||||||
|
|
||||||
|
public record CustomerConfirmationRequest(int expectedVersion) {}
|
||||||
|
|
||||||
|
public record CustomerConfirmationResult(long statementId, String confirmationStatus,
|
||||||
|
long confirmedBy, String confirmedByName,
|
||||||
|
LocalDateTime confirmedAt, int version) {}
|
||||||
|
|
||||||
public record StatementAdjustmentLineRequest(long statementLineId, BigDecimal adjustedAmount,
|
public record StatementAdjustmentLineRequest(long statementLineId, BigDecimal adjustedAmount,
|
||||||
String reason) {}
|
String reason) {}
|
||||||
|
|
||||||
@@ -23,6 +29,8 @@ public final class ReconciliationStatementDtos {
|
|||||||
int creditDays, String currency, BigDecimal totalReceivable,
|
int creditDays, String currency, BigDecimal totalReceivable,
|
||||||
BigDecimal receivedAmount, BigDecimal outstandingAmount,
|
BigDecimal receivedAmount, BigDecimal outstandingAmount,
|
||||||
boolean settled, String status, int version,
|
boolean settled, String status, int version,
|
||||||
|
String customerConfirmationStatus, Long customerConfirmedBy,
|
||||||
|
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
|
||||||
long createdBy, String createdByName, LocalDateTime createdAt,
|
long createdBy, String createdByName, LocalDateTime createdAt,
|
||||||
long updatedBy, String updatedByName, LocalDateTime updatedAt, String remark,
|
long updatedBy, String updatedByName, LocalDateTime updatedAt, String remark,
|
||||||
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
|
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class FinanceDocumentNumberGenerator {
|
||||||
|
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
||||||
|
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP");
|
||||||
|
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
|
||||||
|
this(jdbc, Clock.system(BUSINESS_ZONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.MANDATORY)
|
||||||
|
public String next(String prefix) {
|
||||||
|
if (!SUPPORTED_PREFIXES.contains(prefix)) {
|
||||||
|
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
|
||||||
|
}
|
||||||
|
LocalDate sequenceDate = LocalDate.now(clock);
|
||||||
|
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
|
||||||
|
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
|
||||||
|
"current_value=LAST_INSERT_ID(current_value+1)",
|
||||||
|
sequenceKey);
|
||||||
|
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||||
|
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
|
||||||
|
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
|
||||||
|
}
|
||||||
|
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
||||||
|
String.format(Locale.ROOT, "%04d", sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
|
|||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
||||||
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
|
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
|
||||||
|
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -25,6 +26,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.security.GeneralSecurityException;
|
import java.security.GeneralSecurityException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -45,6 +47,7 @@ public class FinanceExportProviderService {
|
|||||||
public static final String RECEIVABLE_SETTLEMENT_DETAIL = "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL";
|
public static final String RECEIVABLE_SETTLEMENT_DETAIL = "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL";
|
||||||
public static final String PAYABLE_SETTLEMENT_LIST = "FINANCE_PAYABLE_SETTLEMENT_LIST";
|
public static final String PAYABLE_SETTLEMENT_LIST = "FINANCE_PAYABLE_SETTLEMENT_LIST";
|
||||||
public static final String PAYABLE_SETTLEMENT_DETAIL = "FINANCE_PAYABLE_SETTLEMENT_DETAIL";
|
public static final String PAYABLE_SETTLEMENT_DETAIL = "FINANCE_PAYABLE_SETTLEMENT_DETAIL";
|
||||||
|
public static final String RECEIVABLE_STATISTICS = "FINANCE_RECEIVABLE_STATISTICS";
|
||||||
|
|
||||||
private static final int SCHEMA_VERSION = 1;
|
private static final int SCHEMA_VERSION = 1;
|
||||||
private static final int MAX_BATCH_SIZE = 500;
|
private static final int MAX_BATCH_SIZE = 500;
|
||||||
@@ -52,9 +55,10 @@ public class FinanceExportProviderService {
|
|||||||
private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512;
|
private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512;
|
||||||
private static final Set<String> TYPES = Set.of(CUSTOMER_ACCOUNT_LIST, CUSTOMER_ACCOUNT_LEDGER,
|
private static final Set<String> TYPES = Set.of(CUSTOMER_ACCOUNT_LIST, CUSTOMER_ACCOUNT_LEDGER,
|
||||||
FEE_SOURCE_LIST, RECONCILIATION_STATEMENT_LIST, RECEIVABLE_SETTLEMENT_LIST,
|
FEE_SOURCE_LIST, RECONCILIATION_STATEMENT_LIST, RECEIVABLE_SETTLEMENT_LIST,
|
||||||
RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL);
|
RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL,
|
||||||
|
RECEIVABLE_STATISTICS);
|
||||||
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
|
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
|
||||||
private static final Set<String> LEDGER_TYPES = Set.of("FEE_INCLUDED", "FEE_EXCLUDED", "RECONCILIATION_CREATED",
|
private static final Set<String> LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED",
|
||||||
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID");
|
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID");
|
||||||
|
|
||||||
private static final List<ExportColumn> ACCOUNT_COLUMNS = List.of(
|
private static final List<ExportColumn> ACCOUNT_COLUMNS = List.of(
|
||||||
@@ -76,7 +80,7 @@ public class FinanceExportProviderService {
|
|||||||
column("containerNo", "柜次号", ExportColumnType.STRING, 20), column("businessDate", "业务发生日", ExportColumnType.DATE, 14),
|
column("containerNo", "柜次号", ExportColumnType.STRING, 20), column("businessDate", "业务发生日", ExportColumnType.DATE, 14),
|
||||||
column("feeItemName", "费用项目", ExportColumnType.STRING, 22), column("quantity", "计费量", ExportColumnType.DECIMAL, 14),
|
column("feeItemName", "费用项目", ExportColumnType.STRING, 22), column("quantity", "计费量", ExportColumnType.DECIMAL, 14),
|
||||||
column("unitPrice", "单价(CNY)", ExportColumnType.DECIMAL, 14), column("amount", "金额(CNY)", ExportColumnType.DECIMAL, 16),
|
column("unitPrice", "单价(CNY)", ExportColumnType.DECIMAL, 14), column("amount", "金额(CNY)", ExportColumnType.DECIMAL, 16),
|
||||||
column("reviewStatus", "审核状态", ExportColumnType.STRING, 14), column("settlementIncluded", "参与结算", ExportColumnType.BOOLEAN, 12),
|
column("reviewStatus", "审核状态", ExportColumnType.STRING, 14),
|
||||||
column("sourceStatus", "来源状态", ExportColumnType.STRING, 14), column("statementGenerated", "已生成对账单", ExportColumnType.BOOLEAN, 16));
|
column("sourceStatus", "来源状态", ExportColumnType.STRING, 14), column("statementGenerated", "已生成对账单", ExportColumnType.BOOLEAN, 16));
|
||||||
private static final List<ExportColumn> STATEMENT_COLUMNS = List.of(
|
private static final List<ExportColumn> STATEMENT_COLUMNS = List.of(
|
||||||
column("statementNo", "对账单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
|
column("statementNo", "对账单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
|
||||||
@@ -101,12 +105,20 @@ public class FinanceExportProviderService {
|
|||||||
column("businessDate", "业务发生日", ExportColumnType.DATE, 14), column("lineAmount", "明细金额(CNY)", ExportColumnType.DECIMAL, 18),
|
column("businessDate", "业务发生日", ExportColumnType.DATE, 14), column("lineAmount", "明细金额(CNY)", ExportColumnType.DECIMAL, 18),
|
||||||
column("totalAmount", "结算总额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20),
|
column("totalAmount", "结算总额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20),
|
||||||
column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("status", "状态", ExportColumnType.STRING, 16));
|
column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("status", "状态", ExportColumnType.STRING, 16));
|
||||||
|
private static final List<ExportColumn> RECEIVABLE_STATISTICS_COLUMNS = List.of(
|
||||||
|
column("statisticsMonth", "统计月份", ExportColumnType.STRING, 14),
|
||||||
|
column("customerName", "应收单位", ExportColumnType.STRING, 28),
|
||||||
|
column("currency", "币种", ExportColumnType.STRING, 10),
|
||||||
|
column("totalReceivable", "应收总额(CNY)", ExportColumnType.DECIMAL, 18),
|
||||||
|
column("totalReceived", "已收合计(CNY)", ExportColumnType.DECIMAL, 18),
|
||||||
|
column("totalOutstanding", "余额未收(CNY)", ExportColumnType.DECIMAL, 18));
|
||||||
|
|
||||||
private final FinanceCustomerAccountService accountService;
|
private final FinanceCustomerAccountService accountService;
|
||||||
private final FinancePhase1OperationService operationService;
|
private final FinancePhase1OperationService operationService;
|
||||||
private final FinanceQueryService queryService;
|
private final FinanceQueryService queryService;
|
||||||
private final ReconciliationStatementService reconciliationService;
|
private final ReconciliationStatementService reconciliationService;
|
||||||
private final FinanceSettlementService settlementService;
|
private final FinanceSettlementService settlementService;
|
||||||
|
private final ReceivableStatisticsService receivableStatisticsService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final String internalServiceToken;
|
private final String internalServiceToken;
|
||||||
private byte[] snapshotSigningKey;
|
private byte[] snapshotSigningKey;
|
||||||
@@ -116,6 +128,7 @@ public class FinanceExportProviderService {
|
|||||||
FinanceQueryService queryService,
|
FinanceQueryService queryService,
|
||||||
ReconciliationStatementService reconciliationService,
|
ReconciliationStatementService reconciliationService,
|
||||||
FinanceSettlementService settlementService,
|
FinanceSettlementService settlementService,
|
||||||
|
ReceivableStatisticsService receivableStatisticsService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@Value("${baiyue.security.internal-service-token}") String internalServiceToken) {
|
@Value("${baiyue.security.internal-service-token}") String internalServiceToken) {
|
||||||
this.accountService = accountService;
|
this.accountService = accountService;
|
||||||
@@ -123,6 +136,7 @@ public class FinanceExportProviderService {
|
|||||||
this.queryService = queryService;
|
this.queryService = queryService;
|
||||||
this.reconciliationService = reconciliationService;
|
this.reconciliationService = reconciliationService;
|
||||||
this.settlementService = settlementService;
|
this.settlementService = settlementService;
|
||||||
|
this.receivableStatisticsService = receivableStatisticsService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.internalServiceToken = internalServiceToken;
|
this.internalServiceToken = internalServiceToken;
|
||||||
}
|
}
|
||||||
@@ -168,6 +182,7 @@ public class FinanceExportProviderService {
|
|||||||
case PAYABLE_SETTLEMENT_LIST -> settlementRows("PAYABLE", p);
|
case PAYABLE_SETTLEMENT_LIST -> settlementRows("PAYABLE", p);
|
||||||
case RECEIVABLE_SETTLEMENT_DETAIL -> settlementDetailRows("RECEIVABLE", p);
|
case RECEIVABLE_SETTLEMENT_DETAIL -> settlementDetailRows("RECEIVABLE", p);
|
||||||
case PAYABLE_SETTLEMENT_DETAIL -> settlementDetailRows("PAYABLE", p);
|
case PAYABLE_SETTLEMENT_DETAIL -> settlementDetailRows("PAYABLE", p);
|
||||||
|
case RECEIVABLE_STATISTICS -> receivableStatisticsRows(p);
|
||||||
default -> throw invalid("Unsupported Finance export type");
|
default -> throw invalid("Unsupported Finance export type");
|
||||||
};
|
};
|
||||||
int from = Math.min(offset, all.size());
|
int from = Math.min(offset, all.size());
|
||||||
@@ -175,12 +190,18 @@ public class FinanceExportProviderService {
|
|||||||
return new BatchRows(List.copyOf(all.subList(from, to)), (long) all.size());
|
return new BatchRows(List.copyOf(all.subList(from, to)), (long) all.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> receivableStatisticsRows(Map<String, Object> parameters) {
|
||||||
|
YearMonth month = statisticsMonth(parameters);
|
||||||
|
Long customerId = longValue(parameters, "customerId", false);
|
||||||
|
return receivableStatisticsService.exportRows(month, customerId).stream()
|
||||||
|
.map(value -> receivableStatisticsRow(month, value)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
|
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
|
||||||
Long customerId = longValue(p, "customerId", false);
|
Long customerId = longValue(p, "customerId", false);
|
||||||
String direction = text(p, "direction");
|
String direction = text(p, "direction");
|
||||||
String status = text(p, "status");
|
String status = text(p, "status");
|
||||||
String reviewStatus = text(p, "reviewStatus");
|
String reviewStatus = text(p, "reviewStatus");
|
||||||
Boolean included = bool(p, "settlementIncluded");
|
|
||||||
Boolean generated = bool(p, "statementGenerated");
|
Boolean generated = bool(p, "statementGenerated");
|
||||||
LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo");
|
LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo");
|
||||||
String keyword = lower(text(p, "keyword"));
|
String keyword = lower(text(p, "keyword"));
|
||||||
@@ -189,13 +210,11 @@ public class FinanceExportProviderService {
|
|||||||
FeeSourceDetail detail = detail(item);
|
FeeSourceDetail detail = detail(item);
|
||||||
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
|
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
|
||||||
String actualReview = detail == null ? null : detail.reviewStatus();
|
String actualReview = detail == null ? null : detail.reviewStatus();
|
||||||
Boolean actualIncluded = detail == null ? null : detail.settlementIncluded();
|
|
||||||
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
|
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
|
||||||
LocalDate businessDate = detail == null || detail.businessDate() == null
|
LocalDate businessDate = detail == null || detail.businessDate() == null
|
||||||
? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate();
|
? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate();
|
||||||
if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue;
|
if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue;
|
||||||
if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue;
|
if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue;
|
||||||
if (included != null && !included.equals(actualIncluded)) continue;
|
|
||||||
if (generated != null && !generated.equals(actualGenerated)) continue;
|
if (generated != null && !generated.equals(actualGenerated)) continue;
|
||||||
if (from != null && (businessDate == null || businessDate.isBefore(from))) continue;
|
if (from != null && (businessDate == null || businessDate.isBefore(from))) continue;
|
||||||
if (to != null && (businessDate == null || businessDate.isAfter(to))) continue;
|
if (to != null && (businessDate == null || businessDate.isAfter(to))) continue;
|
||||||
@@ -298,7 +317,7 @@ public class FinanceExportProviderService {
|
|||||||
if (direction != null && !Set.of("RECEIVABLE", "PAYABLE").contains(direction)) throw invalid("fee direction is invalid");
|
if (direction != null && !Set.of("RECEIVABLE", "PAYABLE").contains(direction)) throw invalid("fee direction is invalid");
|
||||||
String review = text(p, "reviewStatus");
|
String review = text(p, "reviewStatus");
|
||||||
if (review != null && !Set.of("PENDING", "APPROVED", "REJECTED").contains(review)) throw invalid("reviewStatus is invalid");
|
if (review != null && !Set.of("PENDING", "APPROVED", "REJECTED").contains(review)) throw invalid("reviewStatus is invalid");
|
||||||
bool(p, "settlementIncluded"); bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo");
|
bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo");
|
||||||
max(text(p, "keyword"), 128, "keyword"); max(status, 64, "status");
|
max(text(p, "keyword"), 128, "keyword"); max(status, 64, "status");
|
||||||
}
|
}
|
||||||
if (RECONCILIATION_STATEMENT_LIST.equals(type)) {
|
if (RECONCILIATION_STATEMENT_LIST.equals(type)) {
|
||||||
@@ -313,6 +332,7 @@ public class FinanceExportProviderService {
|
|||||||
max(text(p, "settlementNo"), 128, "settlementNo"); max(text(p, "supplierName"), 128, "supplierName");
|
max(text(p, "settlementNo"), 128, "settlementNo"); max(text(p, "supplierName"), 128, "supplierName");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (RECEIVABLE_STATISTICS.equals(type)) statisticsMonth(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String signSnapshot(String type, ValidatedRequest request) {
|
private String signSnapshot(String type, ValidatedRequest request) {
|
||||||
@@ -358,7 +378,7 @@ public class FinanceExportProviderService {
|
|||||||
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
|
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
|
||||||
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
|
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
|
||||||
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
|
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
|
||||||
"reviewStatus", detail == null ? null : detail.reviewStatus(), "settlementIncluded", detail == null ? null : detail.settlementIncluded(),
|
"reviewStatus", detail == null ? null : detail.reviewStatus(),
|
||||||
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
|
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
|
||||||
}
|
}
|
||||||
private Map<String, Object> statementRow(ReconciliationDetail value) {
|
private Map<String, Object> statementRow(ReconciliationDetail value) {
|
||||||
@@ -379,6 +399,11 @@ public class FinanceExportProviderService {
|
|||||||
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
|
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
|
||||||
"outstandingAmount", summary.outstandingAmount(), "status", summary.status());
|
"outstandingAmount", summary.outstandingAmount(), "status", summary.status());
|
||||||
}
|
}
|
||||||
|
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
|
||||||
|
return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
|
||||||
|
"currency", value.currency(), "totalReceivable", value.totalReceivable(),
|
||||||
|
"totalReceived", value.totalReceived(), "totalOutstanding", value.totalOutstanding());
|
||||||
|
}
|
||||||
|
|
||||||
private static List<ExportColumn> columns(String type) {
|
private static List<ExportColumn> columns(String type) {
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
@@ -388,6 +413,7 @@ public class FinanceExportProviderService {
|
|||||||
case RECONCILIATION_STATEMENT_LIST -> STATEMENT_COLUMNS;
|
case RECONCILIATION_STATEMENT_LIST -> STATEMENT_COLUMNS;
|
||||||
case RECEIVABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_LIST -> SETTLEMENT_LIST_COLUMNS;
|
case RECEIVABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_LIST -> SETTLEMENT_LIST_COLUMNS;
|
||||||
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> SETTLEMENT_DETAIL_COLUMNS;
|
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> SETTLEMENT_DETAIL_COLUMNS;
|
||||||
|
case RECEIVABLE_STATISTICS -> RECEIVABLE_STATISTICS_COLUMNS;
|
||||||
default -> throw invalid("Unsupported Finance export type");
|
default -> throw invalid("Unsupported Finance export type");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -395,11 +421,12 @@ public class FinanceExportProviderService {
|
|||||||
return switch (type) {
|
return switch (type) {
|
||||||
case CUSTOMER_ACCOUNT_LIST -> Set.of("customerId", "status");
|
case CUSTOMER_ACCOUNT_LIST -> Set.of("customerId", "status");
|
||||||
case CUSTOMER_ACCOUNT_LEDGER -> Set.of("customerId", "businessType", "startDate", "endDate");
|
case CUSTOMER_ACCOUNT_LEDGER -> Set.of("customerId", "businessType", "startDate", "endDate");
|
||||||
case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "settlementIncluded", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword");
|
case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword");
|
||||||
case RECONCILIATION_STATEMENT_LIST -> Set.of("customerId", "status", "billNo", "containerNo", "periodStartFrom", "periodStartTo", "periodEndFrom", "periodEndTo", "settled", "lastPaymentFrom", "lastPaymentTo", "createdFrom", "createdTo");
|
case RECONCILIATION_STATEMENT_LIST -> Set.of("customerId", "status", "billNo", "containerNo", "periodStartFrom", "periodStartTo", "periodEndFrom", "periodEndTo", "settled", "lastPaymentFrom", "lastPaymentTo", "createdFrom", "createdTo");
|
||||||
case RECEIVABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo");
|
case RECEIVABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo");
|
||||||
case PAYABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo", "supplierName");
|
case PAYABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo", "supplierName");
|
||||||
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> Set.of("settlementId");
|
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> Set.of("settlementId");
|
||||||
|
case RECEIVABLE_STATISTICS -> Set.of("month", "customerId");
|
||||||
default -> throw invalid("Unsupported Finance export type");
|
default -> throw invalid("Unsupported Finance export type");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -452,6 +479,12 @@ public class FinanceExportProviderService {
|
|||||||
try { return LocalDate.parse(value); }
|
try { return LocalDate.parse(value); }
|
||||||
catch (RuntimeException exception) { throw invalid(key + " must use YYYY-MM-DD"); }
|
catch (RuntimeException exception) { throw invalid(key + " must use YYYY-MM-DD"); }
|
||||||
}
|
}
|
||||||
|
private static YearMonth statisticsMonth(Map<String, Object> values) {
|
||||||
|
String value = text(values, "month");
|
||||||
|
if (value == null) throw invalid("month is required");
|
||||||
|
try { return YearMonth.parse(value); }
|
||||||
|
catch (RuntimeException exception) { throw invalid("month must use YYYY-MM"); }
|
||||||
|
}
|
||||||
private static void range(Map<String, Object> values, String fromKey, String toKey) {
|
private static void range(Map<String, Object> values, String fromKey, String toKey) {
|
||||||
LocalDate from = date(values, fromKey), to = date(values, toKey);
|
LocalDate from = date(values, fromKey), to = date(values, toKey);
|
||||||
if (from != null && to != null && from.isAfter(to)) throw invalid(fromKey + " cannot be after " + toKey);
|
if (from != null && to != null && from.isAfter(to)) throw invalid(fromKey + " cannot be after " + toKey);
|
||||||
|
|||||||
@@ -94,8 +94,10 @@ public class FinanceFeeSourceEventService {
|
|||||||
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
|
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
|
||||||
FinanceFeeSourceEvent.Payload payload = event.payload();
|
FinanceFeeSourceEvent.Payload payload = event.payload();
|
||||||
var response = payload.inboundId() != null
|
var response = payload.inboundId() != null
|
||||||
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"), positive(payload.feeId(), "feeId"))
|
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"),
|
||||||
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"), positive(payload.feeId(), "feeId"));
|
positive(payload.feeId(), "feeId"), payload.feeVersion())
|
||||||
|
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"),
|
||||||
|
positive(payload.feeId(), "feeId"), payload.feeVersion());
|
||||||
if (response == null || response.data() == null || !"200".equals(response.code())) {
|
if (response == null || response.data() == null || !"200".equals(response.code())) {
|
||||||
throw error("FIN-EVT-006", "Firstleg费用来源快照不可用");
|
throw error("FIN-EVT-006", "Firstleg费用来源快照不可用");
|
||||||
}
|
}
|
||||||
@@ -120,7 +122,7 @@ public class FinanceFeeSourceEventService {
|
|||||||
long feeId = positive(event.payload().feeId(), "feeId");
|
long feeId = positive(event.payload().feeId(), "feeId");
|
||||||
if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致");
|
if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致");
|
||||||
if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致");
|
if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致");
|
||||||
if (!"APPROVED".equals(value.reviewStatus()) || !value.settlementIncluded()) throw error("FIN-EVT-009", "费用未审核通过或未参与结算");
|
if (!"APPROVED".equals(value.reviewStatus())) throw error("FIN-EVT-009", "费用未审核通过");
|
||||||
if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) {
|
if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) {
|
||||||
throw error("FIN-EVT-015", "费用来源尚未发布");
|
throw error("FIN-EVT-015", "费用来源尚未发布");
|
||||||
}
|
}
|
||||||
@@ -128,11 +130,17 @@ public class FinanceFeeSourceEventService {
|
|||||||
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
|
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
|
||||||
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
|
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
|
||||||
if ("PAYABLE".equals(value.direction())) {
|
if ("PAYABLE".equals(value.direction())) {
|
||||||
if (value.partnerId() == null || value.partnerId() <= 0 || blank(value.partnerName())) {
|
if (value.supplierId() == null || value.supplierId() <= 0
|
||||||
|
|| blank(value.supplierName()) || blank(value.supplierCategory())) {
|
||||||
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
|
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
|
||||||
}
|
}
|
||||||
String category = supplierValidator.categoryFor(value.feeItemCode(), value.feeItemName());
|
var supplier = supplierValidator.requireActive(value.supplierId(), value.supplierCategory());
|
||||||
supplierValidator.requireActive(value.partnerId(), category);
|
if (!value.supplierName().equals(supplier.name())) {
|
||||||
|
throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致");
|
||||||
|
}
|
||||||
|
} else if (value.supplierId() != null || !blank(value.supplierName())
|
||||||
|
|| !blank(value.supplierCategory())) {
|
||||||
|
throw error("FIN-EVT-012", "应收费用不得携带供应商快照");
|
||||||
}
|
}
|
||||||
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
|
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
|
||||||
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
|
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
|
||||||
|
|||||||
+69
-21
@@ -7,7 +7,8 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
|||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
|
||||||
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
@@ -89,7 +90,7 @@ public class FinancePhase1OperationService {
|
|||||||
|
|
||||||
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
||||||
String direction, String sourceStatus,
|
String direction, String sourceStatus,
|
||||||
String reviewStatus, Boolean settlementIncluded,
|
String reviewStatus,
|
||||||
Boolean statementGenerated, LocalDate businessDateFrom,
|
Boolean statementGenerated, LocalDate businessDateFrom,
|
||||||
LocalDate businessDateTo, String keyword) {
|
LocalDate businessDateTo, String keyword) {
|
||||||
page = page(page); pageSize = pageSize(pageSize);
|
page = page(page); pageSize = pageSize(pageSize);
|
||||||
@@ -110,7 +111,6 @@ public class FinancePhase1OperationService {
|
|||||||
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
|
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
|
||||||
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
|
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
|
||||||
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
|
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
|
||||||
if (settlementIncluded != null) { where.append(" AND f.settlement_included=?"); args.add(settlementIncluded); }
|
|
||||||
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
|
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
|
||||||
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
|
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
|
||||||
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
|
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
|
||||||
@@ -120,9 +120,8 @@ public class FinancePhase1OperationService {
|
|||||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
|
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
|
||||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
|
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
|
||||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
|
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
|
||||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.partnerName')) LIKE CONCAT('%',?,'%')")
|
|
||||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
|
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
|
||||||
for (int i = 0; i < 7; i++) args.add(normalizedKeyword);
|
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
|
||||||
}
|
}
|
||||||
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
|
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
|
||||||
List<Object> queryArgs = new ArrayList<>(args);
|
List<Object> queryArgs = new ArrayList<>(args);
|
||||||
@@ -148,7 +147,7 @@ public class FinancePhase1OperationService {
|
|||||||
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, "POST", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/review", request);
|
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, "POST", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/review", request);
|
||||||
if (replay != null) return getFinanceFeeSource(replay);
|
if (replay != null) return getFinanceFeeSource(replay);
|
||||||
String review = "APPROVE".equals(request.action()) ? "APPROVED" : "REJECTED";
|
String review = "APPROVE".equals(request.action()) ? "APPROVED" : "REJECTED";
|
||||||
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
|
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status='PENDING'",
|
||||||
review, request.reason(), feeSourceId, request.expectedVersion());
|
review, request.reason(), feeSourceId, request.expectedVersion());
|
||||||
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许审核");
|
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许审核");
|
||||||
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
|
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
|
||||||
@@ -158,19 +157,68 @@ public class FinancePhase1OperationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public FeeSourceDetail updateFeeSourceSettlementInclusion(long feeSourceId, long operatorId, String key, SettlementInclusionRequest request) {
|
public FeeSourceDetail editFinanceFeeSource(long feeSourceId, long operatorId, String key,
|
||||||
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) throw error("FIN-REQ-400", "参与结算请求无效");
|
FeeSourceEditRequest request, boolean adjustment) {
|
||||||
|
if (request == null || request.expectedVersion() < 0 || request.amount() == null
|
||||||
|
|| request.amount().signum() <= 0 || !StringUtils.hasText(request.reason())) {
|
||||||
|
throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效");
|
||||||
|
}
|
||||||
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
|
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
|
||||||
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, "PUT", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/settlement-inclusion", request);
|
String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT";
|
||||||
|
String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : "");
|
||||||
|
Long replay = reserve(before.customerId(), operatorId, operation, key,
|
||||||
|
adjustment ? "POST" : "PUT", path, request);
|
||||||
if (replay != null) return getFinanceFeeSource(replay);
|
if (replay != null) return getFinanceFeeSource(replay);
|
||||||
if (request.included() && !"APPROVED".equals(before.reviewStatus())) throw error("FIN-FEE-409", "只有审核通过的费用来源可参与结算");
|
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
|
||||||
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成对账单的费用来源不可修改参与结算状态");
|
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
|
||||||
int changed = jdbc.update("UPDATE fin_fee_source SET settlement_included=?,settlement_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
|
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
|
||||||
request.included(), request.reason(), feeSourceId, request.expectedVersion());
|
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
|
||||||
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许修改参与结算状态");
|
}
|
||||||
|
int changed = jdbc.update("UPDATE fin_fee_source SET amount=?,review_status='DRAFT',review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
|
||||||
|
request.amount(), feeSourceId, request.expectedVersion(), requiredStatus);
|
||||||
|
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许编辑或调整");
|
||||||
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
|
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
|
||||||
audit(after.customerId(), feeSourceId, request.included() ? "FEE_INCLUDED" : "FEE_EXCLUDED", request.reason(), before, after, operatorId);
|
audit(after.customerId(), feeSourceId, adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT", request.reason(), before, after, operatorId);
|
||||||
complete(after.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, feeSourceId);
|
complete(after.customerId(), operatorId, operation, key, feeSourceId);
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public FeeSourceDetail submitFinanceFeeSource(long feeSourceId, long operatorId, String key,
|
||||||
|
FeeSourceActionRequest request) {
|
||||||
|
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_SUBMIT",
|
||||||
|
"PENDING", List.of("DRAFT", "REJECTED"), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId, String key,
|
||||||
|
FeeSourceActionRequest request) {
|
||||||
|
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID",
|
||||||
|
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeeSourceDetail transitionFeeSource(long feeSourceId, long operatorId, String key,
|
||||||
|
FeeSourceActionRequest request, String operation,
|
||||||
|
String target, List<String> allowedReviews,
|
||||||
|
boolean voidSource) {
|
||||||
|
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) {
|
||||||
|
throw error("FIN-REQ-400", "费用操作请求无效");
|
||||||
|
}
|
||||||
|
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
|
||||||
|
Long replay = reserve(before.customerId(), operatorId, operation, key, "POST",
|
||||||
|
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
|
||||||
|
if (replay != null) return getFinanceFeeSource(replay);
|
||||||
|
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作");
|
||||||
|
if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作");
|
||||||
|
int changed = voidSource
|
||||||
|
? jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
|
||||||
|
feeSourceId, request.expectedVersion(), before.reviewStatus())
|
||||||
|
: jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
|
||||||
|
target, feeSourceId, request.expectedVersion(), before.reviewStatus());
|
||||||
|
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本已变化");
|
||||||
|
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
|
||||||
|
audit(after.customerId(), feeSourceId, operation, request.reason(), before, after, operatorId);
|
||||||
|
complete(after.customerId(), operatorId, operation, key, feeSourceId);
|
||||||
return after;
|
return after;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,9 +231,9 @@ public class FinancePhase1OperationService {
|
|||||||
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
|
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
|
||||||
BigDecimal amount = rs.getBigDecimal("amount");
|
BigDecimal amount = rs.getBigDecimal("amount");
|
||||||
BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
|
BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
|
||||||
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")),
|
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"),
|
||||||
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "PENDING"), rs.getBoolean("settlement_included"), status, generated,
|
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
|
||||||
rs.getString("review_reason"), rs.getString("settlement_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
|
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
|
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
|
||||||
@@ -198,11 +246,11 @@ public class FinancePhase1OperationService {
|
|||||||
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
|
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
|
||||||
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
|
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
|
||||||
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
|
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
|
||||||
firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")), nullableLong(rs, "order_id"),
|
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
|
||||||
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
|
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
|
||||||
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
|
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
|
||||||
quantity, unitPrice, amount, rs.getString("currency_code"),
|
quantity, unitPrice, amount, rs.getString("currency_code"),
|
||||||
defaultString(rs.getString("review_status"), "PENDING"), rs.getBoolean("settlement_included"),
|
defaultString(rs.getString("review_status"), "DRAFT"),
|
||||||
rs.getString("effective_status"), generated);
|
rs.getString("effective_status"), generated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public class FinanceQueryService {
|
|||||||
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
|
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
|
||||||
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
|
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
|
||||||
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
|
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
|
||||||
"AND f.review_status='APPROVED' AND f.settlement_included=1 " +
|
"AND f.review_status='APPROVED' AND f.amount>0 " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
|
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
|
||||||
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
|
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
|
||||||
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
|
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
|
||||||
@@ -126,13 +126,15 @@ public class FinanceQueryService {
|
|||||||
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
|
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
|
||||||
validateOptionalCustomer(customerId);
|
validateOptionalCustomer(customerId);
|
||||||
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
||||||
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
|
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
|
||||||
Object[] args = new Object[0];
|
Object[] args = new Object[0];
|
||||||
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
|
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
|
||||||
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
|
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
|
||||||
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
|
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
|
||||||
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
|
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
|
||||||
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
||||||
|
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
||||||
|
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
|
||||||
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
|
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
|
||||||
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
|
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
|
||||||
}
|
}
|
||||||
@@ -140,7 +142,7 @@ public class FinanceQueryService {
|
|||||||
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
|
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
|
||||||
validateOptionalCustomer(customerId);
|
validateOptionalCustomer(customerId);
|
||||||
String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," +
|
String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," +
|
||||||
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
|
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.customer_confirmation_status,b.customer_confirmed_by,b.customer_confirmed_by_name,b.customer_confirmed_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
|
||||||
"COALESCE(p.credit_days,30) credit_days," +
|
"COALESCE(p.credit_days,30) credit_days," +
|
||||||
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodStart')) AS DATE) period_start," +
|
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodStart')) AS DATE) period_start," +
|
||||||
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodEnd')) AS DATE) period_end," +
|
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodEnd')) AS DATE) period_end," +
|
||||||
@@ -160,7 +162,10 @@ public class FinanceQueryService {
|
|||||||
rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"),
|
rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"),
|
||||||
rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"),
|
rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"),
|
||||||
time(rs, "last_receipt_at"), rs.getBigDecimal("outstanding_amount").signum() == 0, rs.getString("status"),
|
time(rs, "last_receipt_at"), rs.getBigDecimal("outstanding_amount").signum() == 0, rs.getString("status"),
|
||||||
time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"), rs.getString("remark"),
|
time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"),
|
||||||
|
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
||||||
|
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
|
||||||
|
rs.getString("remark"),
|
||||||
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
|
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
|
||||||
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
|
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
|
||||||
}
|
}
|
||||||
@@ -168,10 +173,12 @@ public class FinanceQueryService {
|
|||||||
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
|
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
|
||||||
requireCustomer(customerId);
|
requireCustomer(customerId);
|
||||||
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
||||||
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
|
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
|
||||||
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
|
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
|
||||||
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
|
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
|
||||||
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
||||||
|
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
||||||
|
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
|
||||||
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
|
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
|
||||||
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), customerId, billId);
|
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), customerId, billId);
|
||||||
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
|
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import java.sql.PreparedStatement;
|
|||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/** 首期人工财务台账:账单、收款和成本,不承载钱包、发票或复杂核销。 */
|
/** 首期人工财务台账:账单、收款和成本,不承载钱包、发票或复杂核销。 */
|
||||||
@Service
|
@Service
|
||||||
@@ -82,13 +80,6 @@ public class FinanceService {
|
|||||||
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId);
|
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Map<String, Object> issueBillById(long operatorId, long billId, int expectedVersion, String idempotencyKey) {
|
|
||||||
Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId);
|
|
||||||
if (tenantId == null) throw bad("账单不存在");
|
|
||||||
return issueBill(tenantId, operatorId, billId, expectedVersion, idempotencyKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Map<String, Object>> listReceipts(Long tenantId) {
|
public List<Map<String, Object>> listReceipts(Long tenantId) {
|
||||||
String sql = "SELECT r.id,r.tenant_id,r.receipt_no,r.received_amount,r.received_at,r.payment_method,r.voucher_no,r.voucher_file_ids,r.status,rb.bill_id,rb.applied_amount FROM fin_receipt r LEFT JOIN fin_receipt_bill rb ON rb.receipt_id=r.id";
|
String sql = "SELECT r.id,r.tenant_id,r.receipt_no,r.received_amount,r.received_at,r.payment_method,r.voucher_no,r.voucher_file_ids,r.status,rb.bill_id,rb.applied_amount FROM fin_receipt r LEFT JOIN fin_receipt_bill rb ON rb.receipt_id=r.id";
|
||||||
return tenantId == null ? jdbc.queryForList(sql + " ORDER BY r.received_at DESC,r.id DESC") : jdbc.queryForList(sql + " WHERE r.tenant_id=? ORDER BY r.received_at DESC,r.id DESC", tenantId);
|
return tenantId == null ? jdbc.queryForList(sql + " ORDER BY r.received_at DESC,r.id DESC") : jdbc.queryForList(sql + " WHERE r.tenant_id=? ORDER BY r.received_at DESC,r.id DESC", tenantId);
|
||||||
@@ -114,91 +105,6 @@ public class FinanceService {
|
|||||||
return customerTerms(tenantId);
|
return customerTerms(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Map<String, Object> createBill(long tenantId, long operatorId, String customerName, BillRequest request,
|
|
||||||
String idempotencyKey) {
|
|
||||||
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey, request);
|
|
||||||
if (replay != null) return bill(tenantId, replay);
|
|
||||||
if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细");
|
|
||||||
request.lines().forEach(line -> { if (line.sourceId() <= 0 || line.sourceVersion() <= 0) throw bad("费用来源版本无效"); if (line.amount() == null || line.amount().signum() < 0) throw bad("费用金额不能为负数"); });
|
|
||||||
validateBillSources(tenantId, request.lines());
|
|
||||||
BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
if (total.signum() < 0) throw bad("账单金额不能为负数");
|
|
||||||
Object credit = customerTerms(tenantId).get("credit_days");
|
|
||||||
if (credit == null) credit = customerTerms(tenantId).get("creditDays");
|
|
||||||
int days = credit == null ? 30 : ((Number) credit).intValue();
|
|
||||||
LocalDate billDate = request.billDate() == null ? LocalDate.now() : request.billDate();
|
|
||||||
KeyHolder holder = new GeneratedKeyHolder();
|
|
||||||
jdbc.update(connection -> {
|
|
||||||
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,total_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
|
|
||||||
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("BILL")); ps.setLong(3, request.subjectId()); ps.setString(4, customerName);
|
|
||||||
ps.setString(5, "CNY"); ps.setObject(6, billDate); ps.setObject(7, billDate.plusDays(days)); ps.setBigDecimal(8, total); ps.setBigDecimal(9, total);
|
|
||||||
ps.setString(10, request.remark()); ps.setLong(11, operatorId); ps.setString(12, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(13, operatorId); ps.setString(14, FinanceOperatorSnapshot.currentName(operatorId)); return ps;
|
|
||||||
}, holder);
|
|
||||||
long billId = holder.getKey().longValue();
|
|
||||||
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo("BILL", billId), billId);
|
|
||||||
for (BillLine line : request.lines()) {
|
|
||||||
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
tenantId, billId, line.sourceType(), line.sourceId(), line.sourceVersion(), line.sourceNo(), line.orderId(), line.inboundId(), line.feeItemCode(), line.feeItemName(), line.amount());
|
|
||||||
}
|
|
||||||
complete(tenantId, operatorId, "BILL_CREATE", idempotencyKey, billId);
|
|
||||||
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=?", billId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Initial statements may only snapshot approved, included Finance sources; the client cannot choose an amount. */
|
|
||||||
private void validateBillSources(long tenantId, List<BillLine> lines) {
|
|
||||||
Set<String> seen = new HashSet<>();
|
|
||||||
for (BillLine line : lines) {
|
|
||||||
if (!seen.add(line.sourceId() + ":" + line.sourceVersion())) throw bad("同一费用来源版本不能重复加入账单");
|
|
||||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
|
||||||
"SELECT customer_id,source_fee_version,direction,currency_code,amount,status,review_status,settlement_included "
|
|
||||||
+ "FROM fin_fee_source WHERE id=? FOR UPDATE", line.sourceId());
|
|
||||||
if (rows.size() != 1) throw bad("费用来源不存在");
|
|
||||||
Map<String, Object> source = rows.get(0);
|
|
||||||
if (number(source, "customer_id") != tenantId
|
|
||||||
|| number(source, "source_fee_version") != line.sourceVersion()
|
|
||||||
|| !"RECEIVABLE".equals(source.get("direction"))
|
|
||||||
|| !"CNY".equals(source.get("currency_code"))
|
|
||||||
|| !"AVAILABLE".equals(source.get("status"))
|
|
||||||
|| !"APPROVED".equals(source.get("review_status"))
|
|
||||||
|| !truth(source.get("settlement_included"))) {
|
|
||||||
throw bad("费用来源未审核、未参与结算或不属于当前客户");
|
|
||||||
}
|
|
||||||
BigDecimal sourceAmount = decimal(source, "amount");
|
|
||||||
if (sourceAmount.compareTo(line.amount()) != 0) throw bad("账单金额必须等于费用来源金额");
|
|
||||||
Integer used = jdbc.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id "
|
|
||||||
+ "WHERE l.tenant_id=? AND l.source_id=? AND l.source_version=? AND b.status<>'VOIDED'",
|
|
||||||
Integer.class, tenantId, line.sourceId(), line.sourceVersion());
|
|
||||||
if (used != null && used > 0) throw bad("费用来源已被有效账单占用");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long number(Map<String, Object> row, String key) {
|
|
||||||
Object value = row.get(key);
|
|
||||||
return value instanceof Number n ? n.longValue() : Long.parseLong(String.valueOf(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BigDecimal decimal(Map<String, Object> row, String key) {
|
|
||||||
Object value = row.get(key);
|
|
||||||
return value instanceof BigDecimal decimal ? decimal : new BigDecimal(String.valueOf(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean truth(Object value) {
|
|
||||||
return Boolean.TRUE.equals(value) || "1".equals(String.valueOf(value)) || "true".equalsIgnoreCase(String.valueOf(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Map<String, Object> issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) {
|
|
||||||
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, Map.of("billId",billId,"expectedVersion",expectedVersion));
|
|
||||||
if (replay != null) return bill(tenantId, replay);
|
|
||||||
int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, updated_by_name=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?",
|
|
||||||
LocalDateTime.now(), operatorId, operatorId, FinanceOperatorSnapshot.currentName(operatorId), billId, tenantId, expectedVersion);
|
|
||||||
if (updated != 1) throw bad("账单不存在或已出账");
|
|
||||||
complete(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, billId);
|
|
||||||
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=? AND tenant_id=?", billId, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) {
|
public Map<String, Object> createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) {
|
||||||
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, request);
|
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, request);
|
||||||
@@ -271,8 +177,7 @@ public class FinanceService {
|
|||||||
String supplierName = null;
|
String supplierName = null;
|
||||||
if (request.supplierId() != null) {
|
if (request.supplierId() != null) {
|
||||||
if (request.supplierId() <= 0) throw bad("供应商ID无效");
|
if (request.supplierId() <= 0) throw bad("供应商ID无效");
|
||||||
String category = supplierValidator.categoryFor(request.feeItemCode(), request.feeItemName());
|
throw bad("人工成本缺少显式供应商类别,无法校验供应商");
|
||||||
supplierName = supplierValidator.requireActive(request.supplierId(), category).name();
|
|
||||||
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
|
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
|
||||||
throw bad("供应商名称必须来自Warehouse供应商主数据");
|
throw bad("供应商名称必须来自Warehouse供应商主数据");
|
||||||
}
|
}
|
||||||
@@ -393,8 +298,6 @@ public class FinanceService {
|
|||||||
private static String requestPath(String operation) {
|
private static String requestPath(String operation) {
|
||||||
return switch (operation) {
|
return switch (operation) {
|
||||||
case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms";
|
case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms";
|
||||||
case "BILL_CREATE" -> "/api/platform/finance/v1/bills";
|
|
||||||
case "BILL_ISSUE" -> "/api/platform/finance/v1/bills/{billId}/issue";
|
|
||||||
case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts";
|
case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts";
|
||||||
case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries";
|
case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries";
|
||||||
case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}";
|
case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}";
|
||||||
@@ -437,8 +340,6 @@ public class FinanceService {
|
|||||||
private void replaceDraftLines(long tenantId, long billId, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { lineTotal(lines); Map<String,Object> current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); }
|
private void replaceDraftLines(long tenantId, long billId, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { lineTotal(lines); Map<String,Object> current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); }
|
||||||
private void insertLines(long tenantId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { for (var line: lines) jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?)",tenantId,billId,"FIRSTLEG_FEE",line.sourceFeeId(),line.sourceFeeVersion(),line.orderId(),line.inboundId(),"MANUAL",line.feeItemName(),line.amount()); }
|
private void insertLines(long tenantId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { for (var line: lines) jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?)",tenantId,billId,"FIRSTLEG_FEE",line.sourceFeeId(),line.sourceFeeVersion(),line.orderId(),line.inboundId(),"MANUAL",line.feeItemName(),line.amount()); }
|
||||||
|
|
||||||
public record BillRequest(String billNo, long subjectId, LocalDate billDate, String remark, List<BillLine> lines) {}
|
|
||||||
public record BillLine(String sourceType, long sourceId, int sourceVersion, String sourceNo, Long orderId, Long inboundId, String feeItemCode, String feeItemName, BigDecimal amount) {}
|
|
||||||
public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List<String> voucherFileIds, String remark, List<ReceiptAllocation> allocations) {}
|
public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List<String> voucherFileIds, String remark, List<ReceiptAllocation> allocations) {}
|
||||||
public record ReceiptAllocation(long billId, BigDecimal amount) {}
|
public record ReceiptAllocation(long billId, BigDecimal amount) {}
|
||||||
public record CostRequest(String costNo, String costType, Long orderId, Long inboundId, Long containerId, Long supplierId, String supplierName, String feeItemCode, String feeItemName, BigDecimal amount, List<String> voucherFileIds, String remark) {}
|
public record CostRequest(String costNo, String costType, Long orderId, Long inboundId, Long containerId, Long supplierId, String supplierName, String feeItemCode, String feeItemName, BigDecimal amount, List<String> voucherFileIds, String remark) {}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.slf4j.MDC;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
import org.springframework.jdbc.support.KeyHolder;
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -63,13 +64,22 @@ public class FinanceSettlementService {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FinanceVoucherValidator voucherValidator;
|
private final FinanceVoucherValidator voucherValidator;
|
||||||
private final FinanceSupplierValidator supplierValidator;
|
private final FinanceSupplierValidator supplierValidator;
|
||||||
|
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||||
FinanceSupplierValidator supplierValidator) {
|
FinanceSupplierValidator supplierValidator) {
|
||||||
|
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||||
|
FinanceSupplierValidator supplierValidator,
|
||||||
|
FinanceDocumentNumberGenerator documentNumbers) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.voucherValidator = voucherValidator;
|
this.voucherValidator = voucherValidator;
|
||||||
this.supplierValidator = supplierValidator;
|
this.supplierValidator = supplierValidator;
|
||||||
|
this.documentNumbers = documentNumbers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -101,7 +111,7 @@ public class FinanceSettlementService {
|
|||||||
if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细");
|
if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细");
|
||||||
|
|
||||||
LocalDate generatedDate = LocalDate.now();
|
LocalDate generatedDate = LocalDate.now();
|
||||||
long settlementId = insertSettlement(customerId, documentNo("AR"), "RECEIVABLE",
|
long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE",
|
||||||
text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(),
|
text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(),
|
||||||
date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
|
date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
|
||||||
generatedDate, date(bill, "due_date"), operatorId);
|
generatedDate, date(bill, "due_date"), operatorId);
|
||||||
@@ -131,12 +141,10 @@ public class FinanceSettlementService {
|
|||||||
args.addAll(ids);
|
args.addAll(ids);
|
||||||
List<Map<String, Object>> sources = jdbc.queryForList(
|
List<Map<String, Object>> sources = jdbc.queryForList(
|
||||||
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,source_snapshot," +
|
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,source_snapshot," +
|
||||||
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus')) review_status," +
|
"review_status," +
|
||||||
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded')) settlement_included," +
|
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," +
|
||||||
"CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId'))," +
|
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," +
|
||||||
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerId'))) AS UNSIGNED) supplier_id," +
|
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierCategory')) supplier_category " +
|
||||||
"COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName'))," +
|
|
||||||
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerName'))) supplier_name " +
|
|
||||||
"FROM fin_fee_source WHERE id IN (" + placeholders + ") FOR UPDATE", args.toArray());
|
"FROM fin_fee_source WHERE id IN (" + placeholders + ") FOR UPDATE", args.toArray());
|
||||||
if (sources.size() != ids.size() || sources.stream().anyMatch(row -> number(row, "customer_id").longValue() != customerId)) {
|
if (sources.size() != ids.size() || sources.stream().anyMatch(row -> number(row, "customer_id").longValue() != customerId)) {
|
||||||
throw error("FIN_404", "费用来源不存在或不属于客户");
|
throw error("FIN_404", "费用来源不存在或不属于客户");
|
||||||
@@ -145,8 +153,8 @@ public class FinanceSettlementService {
|
|||||||
Long supplierId = nullableNumber(source, "supplier_id");
|
Long supplierId = nullableNumber(source, "supplier_id");
|
||||||
if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商");
|
if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商");
|
||||||
if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|
if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|
||||||
|| !"APPROVED".equals(text(source, "review_status")) || !truth(source.get("settlement_included"))) {
|
|| !"APPROVED".equals(text(source, "review_status")) || decimal(source, "amount").signum() <= 0) {
|
||||||
throw error("FIN_409", "费用来源未审核、未参与结算或状态不可用");
|
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
|
||||||
}
|
}
|
||||||
if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY");
|
if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY");
|
||||||
Integer used = jdbc.queryForObject(
|
Integer used = jdbc.queryForObject(
|
||||||
@@ -156,15 +164,17 @@ public class FinanceSettlementService {
|
|||||||
}
|
}
|
||||||
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
|
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
|
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
|
||||||
Set<String> supplierCategories = sources.stream()
|
if (sources.stream().anyMatch(row -> !StringUtils.hasText(text(row, "supplier_category")))) {
|
||||||
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
|
throw error("FIN_409", "应付费用来源缺少供应商类别快照");
|
||||||
|
}
|
||||||
|
Set<String> supplierCategories = sources.stream().map(row -> text(row, "supplier_category"))
|
||||||
.collect(java.util.stream.Collectors.toSet());
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
if (supplierCategories.size() != 1) throw error("FIN_409", "应付结算费用来源必须属于同一供应商类别");
|
if (supplierCategories.size() != 1) throw error("FIN_409", "应付结算费用来源必须属于同一供应商类别");
|
||||||
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategories.iterator().next()).name();
|
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategories.iterator().next()).name();
|
||||||
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
|
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
|
||||||
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
|
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
|
||||||
LocalDate generatedDate = LocalDate.now();
|
LocalDate generatedDate = LocalDate.now();
|
||||||
long settlementId = insertSettlement(customerId, documentNo("AP"), "PAYABLE", customerName,
|
long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName,
|
||||||
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
|
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
|
||||||
CNY, total, generatedDate, request.dueDate(), operatorId);
|
CNY, total, generatedDate, request.dueDate(), operatorId);
|
||||||
for (Map<String, Object> source : sources) {
|
for (Map<String, Object> source : sources) {
|
||||||
@@ -185,7 +195,7 @@ public class FinanceSettlementService {
|
|||||||
SettlementSummary summary = summary(row);
|
SettlementSummary summary = summary(row);
|
||||||
List<SettlementLine> lines = jdbc.queryForList(
|
List<SettlementLine> lines = jdbc.queryForList(
|
||||||
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
|
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
|
||||||
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " +
|
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
|
||||||
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
|
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
|
||||||
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
|
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
|
||||||
.stream().map(source -> line(source, summary)).toList();
|
.stream().map(source -> line(source, summary)).toList();
|
||||||
@@ -330,7 +340,7 @@ public class FinanceSettlementService {
|
|||||||
jdbc.update("INSERT INTO fin_settlement_payment(tenant_id,settlement_id,transaction_type,transaction_no," +
|
jdbc.update("INSERT INTO fin_settlement_payment(tenant_id,settlement_id,transaction_type,transaction_no," +
|
||||||
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_by_name,created_at) " +
|
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_by_name,created_at) " +
|
||||||
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))",
|
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))",
|
||||||
customerId, settlementId, documentNo("PAY"), request.transactionDate(), request.amount(), CNY,
|
customerId, settlementId, transactionNo(), request.transactionDate(), request.amount(), CNY,
|
||||||
request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()),
|
request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()),
|
||||||
operatorId, FinanceOperatorSnapshot.currentName(operatorId));
|
operatorId, FinanceOperatorSnapshot.currentName(operatorId));
|
||||||
}
|
}
|
||||||
@@ -375,11 +385,11 @@ public class FinanceSettlementService {
|
|||||||
|
|
||||||
private String payableSupplierCategory(long customerId, long settlementId) {
|
private String payableSupplierCategory(long customerId, long settlementId) {
|
||||||
Set<String> categories = jdbc.queryForList(
|
Set<String> categories = jdbc.queryForList(
|
||||||
"SELECT f.fee_item_code,f.fee_item_name FROM fin_settlement_line sl " +
|
"SELECT JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierCategory')) supplier_category FROM fin_settlement_line sl " +
|
||||||
"JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " +
|
"JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " +
|
||||||
"WHERE sl.tenant_id=? AND sl.settlement_id=?",
|
"WHERE sl.tenant_id=? AND sl.settlement_id=?",
|
||||||
customerId, settlementId).stream()
|
customerId, settlementId).stream()
|
||||||
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
|
.map(row -> text(row, "supplier_category"))
|
||||||
.collect(java.util.stream.Collectors.toSet());
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
if (categories.size() != 1) throw error("FIN_409", "结算单费用来源供应商类别不一致");
|
if (categories.size() != 1) throw error("FIN_409", "结算单费用来源供应商类别不一致");
|
||||||
return categories.iterator().next();
|
return categories.iterator().next();
|
||||||
@@ -454,17 +464,16 @@ public class FinanceSettlementService {
|
|||||||
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
|
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
|
||||||
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
|
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
|
||||||
Long supplierId = jsonLong(snapshot, "supplierId");
|
Long supplierId = jsonLong(snapshot, "supplierId");
|
||||||
if (supplierId == null) supplierId = jsonLong(snapshot, "partnerId");
|
if (supplierId == null) throw error("FIN_409", "应付结算缺少供应商快照");
|
||||||
return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"),
|
return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"),
|
||||||
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
|
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
|
||||||
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
|
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
|
||||||
supplierId, firstText(jsonText(snapshot, "supplierName"),
|
supplierId, jsonText(snapshot, "supplierName"),
|
||||||
firstText(jsonText(snapshot, "partnerName"), settlement.supplierName())),
|
|
||||||
nullableNumber(row, "order_id"), jsonText(snapshot, "orderNo"),
|
nullableNumber(row, "order_id"), jsonText(snapshot, "orderNo"),
|
||||||
nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"),
|
nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"),
|
||||||
jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"),
|
jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"),
|
||||||
quantity, unitPrice, amount, text(row, "currency_code"), firstText(jsonText(snapshot, "reviewStatus"), "PENDING"),
|
quantity, unitPrice, amount, text(row, "currency_code"), text(row, "review_status"),
|
||||||
jsonBoolean(snapshot, "settlementIncluded"), text(row, "status"), true);
|
text(row, "status"), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SettlementTransaction transaction(Map<String, Object> row) {
|
private SettlementTransaction transaction(Map<String, Object> row) {
|
||||||
@@ -509,8 +518,8 @@ public class FinanceSettlementService {
|
|||||||
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String documentNo(String prefix) {
|
private String transactionNo() {
|
||||||
return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
|
return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
|
||||||
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,21 +41,6 @@ public class FinanceSupplierValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String categoryFor(String feeItemCode, String feeItemName) {
|
|
||||||
String value = ((feeItemCode == null ? "" : feeItemCode) + " "
|
|
||||||
+ (feeItemName == null ? "" : feeItemName)).toUpperCase(Locale.ROOT);
|
|
||||||
if (containsAny(value, "TRUCK", "TRAILER", "DRAYAGE", "拖车", "卡车")) return "TRUCKING";
|
|
||||||
if (containsAny(value, "CUSTOMS", "CLEARANCE", "DECLARATION", "报关", "清关")) return "CUSTOMS";
|
|
||||||
if (containsAny(value, "CERTIFICATE", "CERTIFICATION", "CERT_", "认证", "证书")) return "CERTIFICATE";
|
|
||||||
if (containsAny(value, "BOOKING", "FREIGHT", "OCEAN", "SHIPPING", "订舱", "海运", "运费")) return "BOOKING";
|
|
||||||
throw new FinanceEventException("FIN_409", "费用来源缺少可验证的供应商类别");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean containsAny(String value, String... candidates) {
|
|
||||||
for (String candidate : candidates) if (value.contains(candidate)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeCategory(String category) {
|
private static String normalizeCategory(String category) {
|
||||||
String normalized = category == null ? "" : category.trim().toUpperCase(Locale.ROOT);
|
String normalized = category == null ? "" : category.trim().toUpperCase(Locale.ROOT);
|
||||||
if (!CATEGORIES.contains(normalized)) {
|
if (!CATEGORIES.contains(normalized)) {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
|
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
|
||||||
|
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ReceivableStatisticsService {
|
||||||
|
private static final String CURRENCY = "CNY";
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public ReceivableStatisticsService(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) {
|
||||||
|
validate(month, customerId, page, pageSize);
|
||||||
|
List<ReceivableStatisticsRow> all = rows(month, customerId);
|
||||||
|
Totals totals = totals(all);
|
||||||
|
int from = (int) Math.min((long) (page - 1) * pageSize, all.size());
|
||||||
|
int to = Math.min(from + pageSize, all.size());
|
||||||
|
return new ReceivableStatisticsResponse(month, CURRENCY, totals.receivable(), totals.received(),
|
||||||
|
totals.outstanding(), all.size(), page, pageSize, List.copyOf(all.subList(from, to)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ReceivableStatisticsRow> exportRows(YearMonth month, Long customerId) {
|
||||||
|
validate(month, customerId, 1, 1);
|
||||||
|
List<ReceivableStatisticsRow> rows = rows(month, customerId);
|
||||||
|
totals(rows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ReceivableStatisticsRow> rows(YearMonth month, Long customerId) {
|
||||||
|
LocalDate from = month.atDay(1);
|
||||||
|
LocalDate to = month.plusMonths(1).atDay(1);
|
||||||
|
StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT tenant_id, customer_name_snapshot,
|
||||||
|
SUM(total_amount) total_receivable,
|
||||||
|
SUM(paid_amount) total_received,
|
||||||
|
SUM(outstanding_amount) total_outstanding
|
||||||
|
FROM fin_settlement
|
||||||
|
WHERE direction='RECEIVABLE'
|
||||||
|
AND status<>'VOIDED'
|
||||||
|
AND currency_code='CNY'
|
||||||
|
AND generated_date>=?
|
||||||
|
AND generated_date<?
|
||||||
|
""");
|
||||||
|
List<Object> args = new ArrayList<>(List.of(from, to));
|
||||||
|
if (customerId != null) {
|
||||||
|
sql.append(" AND tenant_id=?");
|
||||||
|
args.add(customerId);
|
||||||
|
}
|
||||||
|
sql.append(" GROUP BY tenant_id,customer_name_snapshot ORDER BY customer_name_snapshot,tenant_id");
|
||||||
|
return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow(
|
||||||
|
rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY,
|
||||||
|
rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"),
|
||||||
|
rs.getBigDecimal("total_outstanding"))), args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Totals totals(List<ReceivableStatisticsRow> rows) {
|
||||||
|
BigDecimal receivable = BigDecimal.ZERO;
|
||||||
|
BigDecimal received = BigDecimal.ZERO;
|
||||||
|
BigDecimal outstanding = BigDecimal.ZERO;
|
||||||
|
for (ReceivableStatisticsRow row : rows) {
|
||||||
|
checked(row);
|
||||||
|
receivable = receivable.add(row.totalReceivable());
|
||||||
|
received = received.add(row.totalReceived());
|
||||||
|
outstanding = outstanding.add(row.totalOutstanding());
|
||||||
|
}
|
||||||
|
if (receivable.compareTo(received.add(outstanding)) != 0) inconsistent();
|
||||||
|
return new Totals(receivable, received, outstanding);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReceivableStatisticsRow checked(ReceivableStatisticsRow row) {
|
||||||
|
if (row.customerId() <= 0 || row.customerName() == null || row.customerName().isBlank()
|
||||||
|
|| row.totalReceivable() == null || row.totalReceived() == null || row.totalOutstanding() == null
|
||||||
|
|| row.totalReceivable().signum() < 0 || row.totalReceived().signum() < 0
|
||||||
|
|| row.totalOutstanding().signum() < 0
|
||||||
|
|| row.totalReceivable().compareTo(row.totalReceived().add(row.totalOutstanding())) != 0) {
|
||||||
|
inconsistent();
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validate(YearMonth month, Long customerId, int page, int pageSize) {
|
||||||
|
if (month == null) throw new FinanceEventException("FIN-STAT-400", "统计月份不能为空");
|
||||||
|
if (customerId != null && customerId <= 0) throw new FinanceEventException("FIN-STAT-400", "客户ID必须为正整数");
|
||||||
|
if (page <= 0 || pageSize <= 0 || pageSize > 500) throw new FinanceEventException("FIN-STAT-400", "分页参数无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void inconsistent() {
|
||||||
|
throw new FinanceEventException("FIN-STAT-001", "应收统计金额勾稽异常,请核对正式结算数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Totals(BigDecimal receivable, BigDecimal received, BigDecimal outstanding) {}
|
||||||
|
}
|
||||||
+68
-8
@@ -1,6 +1,8 @@
|
|||||||
package com.baiyue.finance.service;
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
|
||||||
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
|
||||||
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationResult;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
||||||
@@ -12,6 +14,7 @@ import org.slf4j.MDC;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
import org.springframework.jdbc.support.KeyHolder;
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -33,7 +36,6 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ReconciliationStatementService {
|
public class ReconciliationStatementService {
|
||||||
@@ -42,10 +44,18 @@ public class ReconciliationStatementService {
|
|||||||
private static final Set<String> REASON_REQUIRED_ACTIONS = Set.of("REJECT", "ADJUST", "VOID", "REOPEN");
|
private static final Set<String> REASON_REQUIRED_ACTIONS = Set.of("REJECT", "ADJUST", "VOID", "REOPEN");
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||||
|
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||||
|
FinanceDocumentNumberGenerator documentNumbers) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.documentNumbers = documentNumbers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -61,6 +71,7 @@ public class ReconciliationStatementService {
|
|||||||
if (sources.isEmpty()) throw error("FIN_409", "没有符合结算条件的CNY应收费用来源");
|
if (sources.isEmpty()) throw error("FIN_409", "没有符合结算条件的CNY应收费用来源");
|
||||||
BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
|
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
|
||||||
|
String statementNo = documentNumbers.next("REC");
|
||||||
|
|
||||||
KeyHolder holder = new GeneratedKeyHolder();
|
KeyHolder holder = new GeneratedKeyHolder();
|
||||||
jdbc.update(connection -> {
|
jdbc.update(connection -> {
|
||||||
@@ -68,7 +79,7 @@ public class ReconciliationStatementService {
|
|||||||
"INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?,?,?)",
|
"INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?,?,?)",
|
||||||
Statement.RETURN_GENERATED_KEYS);
|
Statement.RETURN_GENERATED_KEYS);
|
||||||
statement.setLong(1, request.customerId());
|
statement.setLong(1, request.customerId());
|
||||||
statement.setString(2, "REC-PENDING-" + UUID.randomUUID());
|
statement.setString(2, statementNo);
|
||||||
statement.setLong(3, subjectId);
|
statement.setLong(3, subjectId);
|
||||||
statement.setString(4, customer.name());
|
statement.setString(4, customer.name());
|
||||||
statement.setString(5, "CNY");
|
statement.setString(5, "CNY");
|
||||||
@@ -85,7 +96,6 @@ public class ReconciliationStatementService {
|
|||||||
}, holder);
|
}, holder);
|
||||||
if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
|
if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
|
||||||
long statementId = holder.getKey().longValue();
|
long statementId = holder.getKey().longValue();
|
||||||
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo(statementId), statementId);
|
|
||||||
for (Source source : sources) {
|
for (Source source : sources) {
|
||||||
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY')",
|
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY')",
|
||||||
request.customerId(), statementId, "FIRSTLEG_FEE", source.id(), source.sourceVersion(),
|
request.customerId(), statementId, "FIRSTLEG_FEE", source.id(), source.sourceVersion(),
|
||||||
@@ -123,11 +133,18 @@ public class ReconciliationStatementService {
|
|||||||
statementId, before.customerId(), transition.from(), request.expectedVersion());
|
statementId, before.customerId(), transition.from(), request.expectedVersion());
|
||||||
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
|
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
|
||||||
if ("ISSUE".equals(action)) {
|
if ("ISSUE".equals(action)) {
|
||||||
|
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
|
||||||
|
statementId, before.customerId());
|
||||||
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'",
|
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'",
|
||||||
before.customerId(), statementId, before.customerId());
|
before.customerId(), statementId, before.customerId());
|
||||||
} else if ("VOID".equals(action)) {
|
} else if ("VOID".equals(action)) {
|
||||||
|
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
|
||||||
|
statementId, before.customerId());
|
||||||
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='AVAILABLE' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status IN ('LOCKED','BILLED')",
|
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='AVAILABLE' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status IN ('LOCKED','BILLED')",
|
||||||
before.customerId(), statementId, before.customerId());
|
before.customerId(), statementId, before.customerId());
|
||||||
|
} else if ("REOPEN".equals(action)) {
|
||||||
|
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
|
||||||
|
statementId, before.customerId());
|
||||||
}
|
}
|
||||||
ReconciliationDetail after = detail(before.customerId(), statementId);
|
ReconciliationDetail after = detail(before.customerId(), statementId);
|
||||||
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
|
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
|
||||||
@@ -149,7 +166,7 @@ public class ReconciliationStatementService {
|
|||||||
BigDecimal.class, before.customerId(), statementId);
|
BigDecimal.class, before.customerId(), statementId);
|
||||||
if (total == null) total = BigDecimal.ZERO;
|
if (total == null) total = BigDecimal.ZERO;
|
||||||
if (total.compareTo(before.received()) < 0) throw error("FIN_409", "调整后金额不得低于已收金额");
|
if (total.compareTo(before.received()) < 0) throw error("FIN_409", "调整后金额不得低于已收金额");
|
||||||
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
|
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
|
||||||
total, total, operatorId, FinanceOperatorSnapshot.currentName(operatorId), statementId,
|
total, total, operatorId, FinanceOperatorSnapshot.currentName(operatorId), statementId,
|
||||||
before.customerId(), request.expectedVersion());
|
before.customerId(), request.expectedVersion());
|
||||||
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
|
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
|
||||||
@@ -175,22 +192,60 @@ public class ReconciliationStatementService {
|
|||||||
"SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
|
"SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
|
||||||
(rs, row) -> payment(rs), customerId, statementId);
|
(rs, row) -> payment(rs), customerId, statementId);
|
||||||
List<ReconciliationRevision> revisions = jdbc.query(
|
List<ReconciliationRevision> revisions = jdbc.query(
|
||||||
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND before_data IS NOT NULL ORDER BY created_at,id",
|
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action<>'RECONCILIATION_CUSTOMER_CONFIRM' AND before_data IS NOT NULL ORDER BY created_at,id",
|
||||||
(rs, row) -> revision(rs, row + 1), customerId, statementId);
|
(rs, row) -> revision(rs, row + 1), customerId, statementId);
|
||||||
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
|
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
|
||||||
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
|
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
|
||||||
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
|
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
|
||||||
|
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
|
||||||
|
bill.customerConfirmedAt(),
|
||||||
bill.createdBy(), bill.createdByName(), bill.createdAt(), bill.updatedBy(), bill.updatedByName(),
|
bill.createdBy(), bill.createdByName(), bill.createdAt(), bill.updatedBy(), bill.updatedByName(),
|
||||||
bill.updatedAt(), bill.remark(), lines, payments, revisions);
|
bill.updatedAt(), bill.remark(), lines, payments, revisions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CustomerConfirmationResult confirmForCustomer(long customerId, long operatorId, long statementId,
|
||||||
|
String key, CustomerConfirmationRequest request) {
|
||||||
|
if (customerId <= 0 || operatorId <= 0 || statementId <= 0 || request == null || request.expectedVersion() < 0) {
|
||||||
|
throw error("FIN_400", "客户确认请求无效");
|
||||||
|
}
|
||||||
|
Bill before = bill(customerId, statementId);
|
||||||
|
Long replay = reserve(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key,
|
||||||
|
"POST", "/api/client/finance/v1/statements/" + statementId + "/confirm", request);
|
||||||
|
if (replay != null) return customerConfirmation(customerId, replay);
|
||||||
|
if (!Set.of("ISSUED", "PARTIALLY_PAID", "PAID").contains(before.status())) {
|
||||||
|
throw error("FIN_409", "仅已出账对账单允许客户确认");
|
||||||
|
}
|
||||||
|
if (!"PENDING_CONFIRMATION".equals(before.customerConfirmationStatus())) {
|
||||||
|
throw error("FIN_409", "对账单已确认或当前无需确认");
|
||||||
|
}
|
||||||
|
String operatorName = FinanceOperatorSnapshot.requiredName(operatorId);
|
||||||
|
int changed = jdbc.update("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED',customer_confirmed_at=CURRENT_TIMESTAMP(3),customer_confirmed_by=?,customer_confirmed_by_name=?,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND customer_confirmation_status='PENDING_CONFIRMATION' AND version=?",
|
||||||
|
operatorId, operatorName, operatorId, operatorName, statementId, customerId, request.expectedVersion());
|
||||||
|
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
|
||||||
|
CustomerConfirmationResult after = customerConfirmation(customerId, statementId);
|
||||||
|
audit(customerId, statementId, "RECONCILIATION_CUSTOMER_CONFIRM", null, before, after, operatorId);
|
||||||
|
complete(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key, statementId);
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerConfirmationResult customerConfirmation(long customerId, long statementId) {
|
||||||
|
Bill current = bill(customerId, statementId);
|
||||||
|
if (!"CONFIRMED".equals(current.customerConfirmationStatus()) || current.customerConfirmedBy() == null
|
||||||
|
|| current.customerConfirmedAt() == null) {
|
||||||
|
throw error("FIN_409", "对账单尚未完成客户确认");
|
||||||
|
}
|
||||||
|
return new CustomerConfirmationResult(current.id(), current.customerConfirmationStatus(),
|
||||||
|
current.customerConfirmedBy(), current.customerConfirmedByName(), current.customerConfirmedAt(), current.version());
|
||||||
|
}
|
||||||
|
|
||||||
public ReconciliationDetail detailForPlatform(long statementId) {
|
public ReconciliationDetail detailForPlatform(long statementId) {
|
||||||
return detail(bill(statementId).customerId(), statementId);
|
return detail(bill(statementId).customerId(), statementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Source> eligibleSources(CreateReconciliationRequest request) {
|
private List<Source> eligibleSources(CreateReconciliationRequest request) {
|
||||||
List<Object> args = new ArrayList<>();
|
List<Object> args = new ArrayList<>();
|
||||||
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus'))='APPROVED' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded'))='true' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
|
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND review_status='APPROVED' AND amount>0 AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.publicationStatus'))='PUBLISHED' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
|
||||||
args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd());
|
args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd());
|
||||||
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
|
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
|
||||||
List<Map<String, Object>> selected = jdbc.queryForList(
|
List<Map<String, Object>> selected = jdbc.queryForList(
|
||||||
@@ -453,6 +508,8 @@ public class ReconciliationStatementService {
|
|||||||
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
|
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
|
||||||
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
|
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
|
||||||
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
|
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
|
||||||
|
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
||||||
|
rs.getString("customer_confirmed_by_name"), timestamp(rs, "customer_confirmed_at"),
|
||||||
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
|
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
|
||||||
rs.getString("created_by_name"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
|
rs.getString("created_by_name"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
|
||||||
rs.getString("updated_by_name"),
|
rs.getString("updated_by_name"),
|
||||||
@@ -480,8 +537,9 @@ public class ReconciliationStatementService {
|
|||||||
return rs.wasNull() ? null : value;
|
return rs.wasNull() ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String stableNo(long id) {
|
private static LocalDateTime timestamp(ResultSet rs, String column) throws SQLException {
|
||||||
return "REC-" + String.format(Locale.ROOT, "%020d", id);
|
Timestamp value = rs.getTimestamp(column);
|
||||||
|
return value == null ? null : value.toLocalDateTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FinanceEventException error(String code, String message) {
|
private static FinanceEventException error(String code, String message) {
|
||||||
@@ -496,6 +554,8 @@ public class ReconciliationStatementService {
|
|||||||
String feeItemName, BigDecimal amount) {}
|
String feeItemName, BigDecimal amount) {}
|
||||||
private record Bill(long id, long customerId, String number, String customerName, String status,
|
private record Bill(long id, long customerId, String number, String customerName, String status,
|
||||||
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
|
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
|
||||||
|
String customerConfirmationStatus, Long customerConfirmedBy,
|
||||||
|
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
|
||||||
int creditDays, String remark, long createdBy, String createdByName,
|
int creditDays, String remark, long createdBy, String createdByName,
|
||||||
LocalDateTime createdAt, long updatedBy, String updatedByName, LocalDateTime updatedAt) {}
|
LocalDateTime createdAt, long updatedBy, String updatedByName, LocalDateTime updatedAt) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,15 +34,11 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
|
|||||||
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
|
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
|
||||||
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
|
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
|
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
|
||||||
"VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot");
|
"VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot");
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
|
CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
|
||||||
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
|
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_included',
|
|
||||||
"TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是' AFTER review_reason");
|
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_reason',
|
|
||||||
"VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明' AFTER settlement_included");
|
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
|
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
|
||||||
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号' AFTER settlement_reason");
|
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
|
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
|
||||||
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
|
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
|
||||||
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash',
|
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash',
|
||||||
@@ -62,8 +58,7 @@ UPDATE fin_fee_source
|
|||||||
source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)),
|
source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)),
|
||||||
source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)),
|
source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)),
|
||||||
updated_at = COALESCE(updated_at, created_at),
|
updated_at = COALESCE(updated_at, created_at),
|
||||||
review_status = COALESCE(review_status, 'PENDING'),
|
review_status = COALESCE(review_status, 'DRAFT'),
|
||||||
settlement_included = COALESCE(settlement_included, 0),
|
|
||||||
operation_version = COALESCE(operation_version, 0);
|
operation_version = COALESCE(operation_version, 0);
|
||||||
UPDATE fin_inbox_event
|
UPDATE fin_inbox_event
|
||||||
SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256));
|
SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256));
|
||||||
@@ -88,7 +83,7 @@ BEGIN
|
|||||||
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
|
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
|
||||||
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
|
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
|
||||||
'source_hash','source_snapshot','review_status','review_reason',
|
'source_hash','source_snapshot','review_status','review_reason',
|
||||||
'settlement_included','settlement_reason','operation_version','updated_at')) <> 13 THEN
|
'operation_version','updated_at')) <> 11 THEN
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
|
||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
|||||||
@@ -20,11 +20,9 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
|
|||||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
|
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
|
||||||
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
|
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
|
||||||
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
|
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
|
||||||
review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因',
|
review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因',
|
||||||
settlement_included TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是',
|
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号',
|
||||||
settlement_reason VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明',
|
|
||||||
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号',
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
|
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间',
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
|
||||||
@@ -284,6 +282,13 @@ CREATE TABLE IF NOT EXISTS fin_idempotency_record (
|
|||||||
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
|
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
|
||||||
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
|
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fin_document_sequence (
|
||||||
|
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如REC:20260818',
|
||||||
|
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
|
PRIMARY KEY (sequence_key),
|
||||||
|
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
|
||||||
|
) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class CustomerAccountSummaryBehaviorTest {
|
|||||||
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
||||||
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
|
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
|
||||||
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
|
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
|
||||||
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.settlement_included=1")
|
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
|
||||||
.contains("b.status<>'VOIDED'")
|
.contains("b.status<>'VOIDED'")
|
||||||
.doesNotContain("JSON_EXTRACT")
|
.doesNotContain("JSON_EXTRACT")
|
||||||
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
|
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ class FinanceContractTest {
|
|||||||
assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo"));
|
assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo"));
|
||||||
assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求"));
|
assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求"));
|
||||||
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
|
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
|
||||||
assertTrue(service.contains("validateBillSources(tenantId, request.lines())"));
|
|
||||||
assertTrue(service.contains("账单金额必须等于费用来源金额"));
|
|
||||||
assertTrue(service.contains("source_fee_version") && service.contains("settlement_included"));
|
|
||||||
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
|
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
|
||||||
|
assertFalse(controller.contains("@PostMapping(\"/bills\")"));
|
||||||
|
assertFalse(controller.contains("/bills/{billId}/issue"));
|
||||||
|
assertFalse(service.contains("BILL_CREATE"));
|
||||||
|
assertFalse(service.contains("BILL_ISSUE"));
|
||||||
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
|
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
|
||||||
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
||||||
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
|
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
|
||||||
@@ -38,7 +39,6 @@ class FinanceContractTest {
|
|||||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||||
assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance"));
|
assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance"));
|
||||||
assertTrue(unified.contains("assert_finance_permissions"));
|
assertTrue(unified.contains("assert_finance_permissions"));
|
||||||
assertTrue(unified.contains("platform:finance:bill:issue"));
|
|
||||||
assertTrue(unified.contains("merchant:finance:statement:view"));
|
assertTrue(unified.contains("merchant:finance:statement:view"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class FinanceFeeSourceEventServiceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
ReflectionTestUtils.setField(service, "internalToken", "test-token");
|
ReflectionTestUtils.setField(service, "internalToken", "test-token");
|
||||||
lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("TRUCKING");
|
|
||||||
lenient().when(supplierValidator.requireActive(anyLong(), any())).thenReturn(
|
lenient().when(supplierValidator.requireActive(anyLong(), any())).thenReturn(
|
||||||
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
|
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
|
||||||
71L, "现有供应商", "TRUCKING", "ACTIVE"));
|
71L, "现有供应商", "TRUCKING", "ACTIVE"));
|
||||||
@@ -71,20 +70,19 @@ class FinanceFeeSourceEventServiceTest {
|
|||||||
givenNewEvent();
|
givenNewEvent();
|
||||||
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
|
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
|
||||||
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有往来单位",
|
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有往来单位",
|
||||||
61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
|
"TRUCKING", 61L, "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
|
||||||
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED", true,
|
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
|
||||||
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
|
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
|
||||||
"source-hash-payable", 1);
|
"source-hash-payable", 1);
|
||||||
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot));
|
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot));
|
||||||
|
|
||||||
assertThat(service.consume(event)).isEqualTo(101L);
|
assertThat(service.consume(event)).isEqualTo(101L);
|
||||||
|
|
||||||
verify(supplierValidator).categoryFor("TRUCKING", "拖车费");
|
|
||||||
verify(supplierValidator).requireActive(71L, "TRUCKING");
|
verify(supplierValidator).requireActive(71L, "TRUCKING");
|
||||||
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
||||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
||||||
assertThat(values.getValue()[14].toString())
|
assertThat(values.getValue()[14].toString())
|
||||||
.contains("\"partnerId\":71", "\"partnerName\":\"现有往来单位\"");
|
.contains("\"supplierId\":71", "\"supplierName\":\"现有往来单位\"", "\"supplierCategory\":\"TRUCKING\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -202,9 +200,9 @@ class FinanceFeeSourceEventServiceTest {
|
|||||||
String publicationStatus, BigDecimal amount) {
|
String publicationStatus, BigDecimal amount) {
|
||||||
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
|
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
|
||||||
inboundId == null ? null : "IB-21", 51L, containerId, null,
|
inboundId == null ? null : "IB-21", 51L, containerId, null,
|
||||||
containerId == null ? null : "C-41", customerId, "客户", null, null, 61L, "CUSTOMS",
|
containerId == null ? null : "C-41", customerId, "客户", null, null, null, 61L, "CUSTOMS",
|
||||||
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
|
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
|
||||||
"APPROVED", publicationStatus, true, LocalDateTime.of(2026, 8, 11, 1, 0),
|
"APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0),
|
||||||
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
|
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,23 +48,23 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService, customerAccountService);
|
FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService, customerAccountService);
|
||||||
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of());
|
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of());
|
||||||
var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0);
|
var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0);
|
||||||
var inclusion = new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入本期结算", 1);
|
var action = new FinancePhase1Dtos.FeeSourceActionRequest("提交审核", 1);
|
||||||
|
|
||||||
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L);
|
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L);
|
||||||
controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
|
controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
|
||||||
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED", true,
|
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED",
|
||||||
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
||||||
controller.getFinanceFeeSource(88L);
|
controller.getFinanceFeeSource(88L);
|
||||||
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
|
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
|
||||||
controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion);
|
controller.submitFinanceFeeSource(user, 88L, "submit-key", action);
|
||||||
|
|
||||||
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
|
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
|
||||||
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
|
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
|
||||||
verify(operationService).listFinanceFeeSources(eq(1), eq(20), eq(9L), eq("RECEIVABLE"),
|
verify(operationService).listFinanceFeeSources(eq(1), eq(20), eq(9L), eq("RECEIVABLE"),
|
||||||
eq("AVAILABLE"), eq("APPROVED"), eq(true), eq(false), any(), any(), eq("IN-9"));
|
eq("AVAILABLE"), eq("APPROVED"), eq(false), any(), any(), eq("IN-9"));
|
||||||
verify(operationService).getFinanceFeeSource(88L);
|
verify(operationService).getFinanceFeeSource(88L);
|
||||||
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
|
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
|
||||||
verify(operationService).updateFeeSourceSettlementInclusion(88L, 71L, "include-key", inclusion);
|
verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -141,14 +141,13 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
|
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
|
||||||
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"partnerId\":79,\"partnerName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
|
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"partnerId\":79,\"partnerName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
|
||||||
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
|
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
|
||||||
when(rs.getBoolean("settlement_included")).thenReturn(true);
|
|
||||||
when(rs.getBoolean("statement_generated")).thenReturn(false);
|
when(rs.getBoolean("statement_generated")).thenReturn(false);
|
||||||
return List.of(mapper.mapRow(rs, 0));
|
return List.of(mapper.mapRow(rs, 0));
|
||||||
});
|
});
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
|
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
|
||||||
true, false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
||||||
|
|
||||||
assertThat(result.page()).satisfies(meta -> {
|
assertThat(result.page()).satisfies(meta -> {
|
||||||
assertThat(meta.page()).isEqualTo(2);
|
assertThat(meta.page()).isEqualTo(2);
|
||||||
@@ -163,7 +162,6 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
assertThat(source.supplierName()).isEqualTo("供应商A");
|
assertThat(source.supplierName()).isEqualTo("供应商A");
|
||||||
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
|
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
|
||||||
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
|
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
|
||||||
assertThat(source.settlementIncluded()).isTrue();
|
|
||||||
assertThat(source.statementGenerated()).isFalse();
|
assertThat(source.statementGenerated()).isFalse();
|
||||||
});
|
});
|
||||||
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
|
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
|
||||||
@@ -201,14 +199,11 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectedFeeCannotBeIncludedInSettlement() throws Exception {
|
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
||||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
|
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
||||||
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 2)))
|
|
||||||
.isInstanceOf(FinanceEventException.class)
|
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-FEE-409");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -222,33 +217,32 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
assertThat(detail.customerId()).isEqualTo(9L);
|
assertThat(detail.customerId()).isEqualTo(9L);
|
||||||
assertThat(detail.currency()).isEqualTo("CNY");
|
assertThat(detail.currency()).isEqualTo("CNY");
|
||||||
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
|
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
|
||||||
assertThat(detail.settlementIncluded()).isTrue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void approvedFeeCanBeIncludedWithExpectedVersion() throws Exception {
|
void rejectedFeeCanBeEditedBackToDraftWithExpectedVersion() throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
|
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
|
||||||
"update".equals(invocation.getMethod().getName())
|
"update".equals(invocation.getMethod().getName())
|
||||||
? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation));
|
? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation));
|
||||||
AtomicInteger reads = new AtomicInteger();
|
AtomicInteger reads = new AtomicInteger();
|
||||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
||||||
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
|
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
|
||||||
boolean included = reads.getAndIncrement() > 0;
|
boolean changed = reads.getAndIncrement() > 0;
|
||||||
return List.of(mapper.mapRow(resultSet("APPROVED", included, included ? 2 : 1), 0));
|
return List.of(mapper.mapRow(resultSet(changed ? "DRAFT" : "REJECTED", false, changed ? 2 : 1), 0));
|
||||||
});
|
});
|
||||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
||||||
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||||
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
|
eq("FEE_SOURCE_EDIT"), eq("edit-key"))).thenReturn(List.of());
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
FeeSourceDetail result = service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
|
FeeSourceDetail result = service.editFinanceFeeSource(88L, 71L, "edit-key",
|
||||||
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 1));
|
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "更正金额", 1), false);
|
||||||
|
|
||||||
assertThat(result.settlementIncluded()).isTrue();
|
assertThat(result.reviewStatus()).isEqualTo("DRAFT");
|
||||||
assertThat(result.operationVersion()).isEqualTo(2);
|
assertThat(result.operationVersion()).isEqualTo(2);
|
||||||
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET settlement_included="),
|
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET amount="),
|
||||||
eq(true), eq("进入结算"), eq(88L), eq(1));
|
eq(new BigDecimal("120.00")), eq(88L), eq(1), eq("REJECTED"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -316,11 +310,9 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
when(rs.getString("source_hash")).thenReturn("a".repeat(64));
|
when(rs.getString("source_hash")).thenReturn("a".repeat(64));
|
||||||
when(rs.getString("review_status")).thenReturn(reviewStatus);
|
when(rs.getString("review_status")).thenReturn(reviewStatus);
|
||||||
when(rs.getString("review_reason")).thenReturn(null);
|
when(rs.getString("review_reason")).thenReturn(null);
|
||||||
when(rs.getString("settlement_reason")).thenReturn(null);
|
|
||||||
when(rs.getBoolean("settlement_included")).thenReturn(included);
|
|
||||||
when(rs.getInt("operation_version")).thenReturn(version);
|
when(rs.getInt("operation_version")).thenReturn(version);
|
||||||
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
|
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
|
||||||
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"settlementIncluded\":" + included + ",\"operationVersion\":" + version + "}");
|
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}");
|
||||||
return rs;
|
return rs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ class FinancePhase1PermissionContractTest {
|
|||||||
"platform:finance:fee-source:review",
|
"platform:finance:fee-source:review",
|
||||||
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
|
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
|
||||||
com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class);
|
com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class);
|
||||||
assertEndpoint("updateFeeSourceSettlementInclusion", PutMapping.class,
|
assertEndpoint("editFinanceFeeSource", PutMapping.class,
|
||||||
"platform:finance:fee-source:settlement",
|
"platform:finance:fee-source:edit",
|
||||||
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
|
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
|
||||||
com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest.class);
|
com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertEndpoint(String methodName,
|
private static void assertEndpoint(String methodName,
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
|
|||||||
import com.baiyue.finance.service.FinanceQueryService;
|
import com.baiyue.finance.service.FinanceQueryService;
|
||||||
import com.baiyue.finance.service.FinanceService;
|
import com.baiyue.finance.service.FinanceService;
|
||||||
import com.baiyue.finance.service.FinanceSettlementService;
|
import com.baiyue.finance.service.FinanceSettlementService;
|
||||||
|
import com.baiyue.finance.service.ReconciliationStatementService;
|
||||||
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -34,6 +37,7 @@ class FinanceReadApiBehaviorTest {
|
|||||||
@Mock FinanceFileClient fileClient;
|
@Mock FinanceFileClient fileClient;
|
||||||
@Mock FirstlegCostSnapshotClient snapshotClient;
|
@Mock FirstlegCostSnapshotClient snapshotClient;
|
||||||
@Mock FinanceSettlementService settlementService;
|
@Mock FinanceSettlementService settlementService;
|
||||||
|
@Mock ReconciliationStatementService reconciliationStatementService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
|
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
|
||||||
@@ -42,7 +46,7 @@ class FinanceReadApiBehaviorTest {
|
|||||||
"BANK_TRANSFER", "CNY", "CONFIRMED", List.of(new ClientReceiptAllocation(8L, "BILL-8", new BigDecimal("100.00"))));
|
"BANK_TRANSFER", "CNY", "CONFIRMED", List.of(new ClientReceiptAllocation(8L, "BILL-8", new BigDecimal("100.00"))));
|
||||||
when(queryService.clientReceipts(23L)).thenReturn(List.of(row));
|
when(queryService.clientReceipts(23L)).thenReturn(List.of(row));
|
||||||
|
|
||||||
var response = new ClientFinanceController(service, queryService).receipts(user);
|
var response = new ClientFinanceController(service, queryService, reconciliationStatementService).receipts(user);
|
||||||
|
|
||||||
verify(queryService).clientReceipts(23L);
|
verify(queryService).clientReceipts(23L);
|
||||||
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response.data());
|
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response.data());
|
||||||
@@ -50,7 +54,7 @@ class FinanceReadApiBehaviorTest {
|
|||||||
assertThat(response.requestId()).isNotBlank();
|
assertThat(response.requestId()).isNotBlank();
|
||||||
assertThat(response.traceId()).isNotBlank();
|
assertThat(response.traceId()).isNotBlank();
|
||||||
|
|
||||||
new ClientFinanceController(service, queryService).bill(user, 88L);
|
new ClientFinanceController(service, queryService, reconciliationStatementService).bill(user, 88L);
|
||||||
verify(queryService).statement(23L, 88L, true);
|
verify(queryService).statement(23L, 88L, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +106,7 @@ class FinanceReadApiBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void clientListsAlwaysUseAuthenticatedCustomerScope() {
|
void clientListsAlwaysUseAuthenticatedCustomerScope() {
|
||||||
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
|
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
|
||||||
var controller = new ClientFinanceController(service, queryService);
|
var controller = new ClientFinanceController(service, queryService, reconciliationStatementService);
|
||||||
|
|
||||||
controller.bills(user);
|
controller.bills(user);
|
||||||
controller.statements(user);
|
controller.statements(user);
|
||||||
@@ -112,4 +116,23 @@ class FinanceReadApiBehaviorTest {
|
|||||||
verify(queryService).receivableSettlements(23L, true);
|
verify(queryService).receivableSettlements(23L, true);
|
||||||
verifyNoMoreInteractions(queryService);
|
verifyNoMoreInteractions(queryService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientConfirmationUsesOnlyAuthenticatedCustomerAndOperatorScope() {
|
||||||
|
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
|
||||||
|
CustomerConfirmationRequest request = new CustomerConfirmationRequest(4);
|
||||||
|
var controller = new ClientFinanceController(service, queryService, reconciliationStatementService);
|
||||||
|
|
||||||
|
controller.confirmStatement(user, 88L, "confirm-key", request);
|
||||||
|
|
||||||
|
verify(reconciliationStatementService).confirmForCustomer(23L, 7L, 88L, "confirm-key", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientConfirmationHasIndependentWriteAuthority() throws Exception {
|
||||||
|
PreAuthorize rule = ClientFinanceController.class.getMethod("confirmStatement", AuthenticatedUser.class,
|
||||||
|
long.class, String.class, CustomerConfirmationRequest.class).getAnnotation(PreAuthorize.class);
|
||||||
|
|
||||||
|
assertThat(rule.value()).isEqualTo("hasAuthority('merchant:finance:statement:confirm')");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,10 @@ import com.baiyue.common.core.exception.BizException;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@@ -25,45 +22,6 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class FinanceServiceValidationTest {
|
class FinanceServiceValidationTest {
|
||||||
@Test
|
|
||||||
void acceptsOnlyApprovedIncludedReceivableSourceAtItsRecordedAmount() {
|
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
||||||
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
|
|
||||||
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50")));
|
|
||||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class),
|
|
||||||
eq(9L), eq(81L), eq(3))).thenReturn(0);
|
|
||||||
FinanceService service = service(jdbc);
|
|
||||||
|
|
||||||
assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "validateBillSources", 9L,
|
|
||||||
List.of(billLine(81L, 3, "125.50"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsClientAmountThatDiffersFromFinanceSourceSnapshot() {
|
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
||||||
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
|
|
||||||
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50")));
|
|
||||||
FinanceService service = service(jdbc);
|
|
||||||
|
|
||||||
BizException error = assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service,
|
|
||||||
"validateBillSources", 9L, List.of(billLine(81L, 3, "99.00"))));
|
|
||||||
|
|
||||||
assertEquals("账单金额必须等于费用来源金额", error.getMessage());
|
|
||||||
verify(jdbc, never()).queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"),
|
|
||||||
eq(Integer.class), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsSourceThatIsNotApprovedAndIncludedForSettlement() {
|
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
||||||
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
|
|
||||||
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "PENDING", false, "125.50")));
|
|
||||||
FinanceService service = service(jdbc);
|
|
||||||
|
|
||||||
assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service,
|
|
||||||
"validateBillSources", 9L, List.of(billLine(81L, 3, "125.50"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void containerCostAllocatesByVolumeAndPutsRoundingOnLastOrder() {
|
void containerCostAllocatesByVolumeAndPutsRoundingOnLastOrder() {
|
||||||
var rows = List.of(
|
var rows = List.of(
|
||||||
@@ -284,15 +242,4 @@ class FinanceServiceValidationTest {
|
|||||||
mock(FinanceVoucherValidator.class), mock(FinanceSupplierValidator.class));
|
mock(FinanceVoucherValidator.class), mock(FinanceSupplierValidator.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FinanceService.BillLine billLine(long sourceId, int version, String amount) {
|
|
||||||
return new FinanceService.BillLine("FIRSTLEG_FEE", sourceId, version, null, 31L, 41L,
|
|
||||||
"FREIGHT", "运费", new BigDecimal(amount));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, Object> source(long customerId, int version, String status,
|
|
||||||
String reviewStatus, boolean included, String amount) {
|
|
||||||
return Map.of("customer_id", customerId, "source_fee_version", version,
|
|
||||||
"direction", "RECEIVABLE", "currency_code", "CNY", "amount", new BigDecimal(amount),
|
|
||||||
"status", status, "review_status", reviewStatus, "settlement_included", included);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
|||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||||
import com.baiyue.finance.service.FinanceEventException;
|
import com.baiyue.finance.service.FinanceEventException;
|
||||||
|
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
|
||||||
import com.baiyue.finance.service.FinanceSettlementService;
|
import com.baiyue.finance.service.FinanceSettlementService;
|
||||||
import com.baiyue.finance.service.FinanceSupplierValidator;
|
import com.baiyue.finance.service.FinanceSupplierValidator;
|
||||||
import com.baiyue.finance.service.FinanceVoucherValidator;
|
import com.baiyue.finance.service.FinanceVoucherValidator;
|
||||||
@@ -542,8 +543,11 @@ class FinanceSettlementBehaviorTest {
|
|||||||
FinanceSupplierValidator supplierValidator) {
|
FinanceSupplierValidator supplierValidator) {
|
||||||
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT f.fee_item_code"), anyLong(), anyLong()))
|
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT f.fee_item_code"), anyLong(), anyLong()))
|
||||||
.thenReturn(List.of(Map.of("fee_item_code", "FBA_HEAD_FREIGHT", "fee_item_name", "头程运费")));
|
.thenReturn(List.of(Map.of("fee_item_code", "FBA_HEAD_FREIGHT", "fee_item_name", "头程运费")));
|
||||||
|
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
|
||||||
|
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
|
||||||
|
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
|
||||||
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator);
|
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FinanceSupplierValidator supplierValidator() {
|
private static FinanceSupplierValidator supplierValidator() {
|
||||||
@@ -586,7 +590,7 @@ class FinanceSettlementBehaviorTest {
|
|||||||
Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"),
|
Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"),
|
||||||
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
|
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
|
||||||
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
|
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
|
||||||
Map.entry("settlement_included", "true"), Map.entry("supplier_id", supplierId),
|
Map.entry("supplier_id", supplierId),
|
||||||
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
|
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
|
||||||
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
|
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
|
||||||
"{\"customerName\":\"客户10\",\"supplierId\":20,\"supplierName\":\"供应商20\"}")));
|
"{\"customerName\":\"客户10\",\"supplierId\":20,\"supplierName\":\"供应商20\"}")));
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class FinanceSqlCommentContractTest {
|
|||||||
missing.add(currentTable + "." + column.group(1));
|
missing.add(currentTable + "." + column.group(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertThat(tableCount).isEqualTo(16);
|
assertThat(tableCount).isEqualTo(17);
|
||||||
assertThat(columnCount).isEqualTo(239);
|
assertThat(columnCount).isEqualTo(239);
|
||||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,9 @@ class FinanceSupplierValidatorTest {
|
|||||||
private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token");
|
private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token");
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resolvesSupportedFeeCategories() {
|
void rejectsMissingOrInvalidExplicitFeeCategory() {
|
||||||
assertThat(validator.categoryFor("TRUCKING", "拖车费")).isEqualTo("TRUCKING");
|
assertCode(() -> validator.requireActive(20L, null), "FIN_409");
|
||||||
assertThat(validator.categoryFor("CUSTOMS_CLEARANCE", "报关费")).isEqualTo("CUSTOMS");
|
assertCode(() -> validator.requireActive(20L, "UNKNOWN"), "FIN_409");
|
||||||
assertThat(validator.categoryFor("FBA_HEAD_FREIGHT", "头程运费")).isEqualTo("BOOKING");
|
|
||||||
assertThat(validator.categoryFor("CERTIFICATE_FEE", "证书费")).isEqualTo("CERTIFICATE");
|
|
||||||
assertCode(() -> validator.categoryFor("ACPT_UNKNOWN", "操作费"), "FIN_409");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.baiyue.finance;
|
||||||
|
|
||||||
|
import com.baiyue.finance.controller.ReceivableStatisticsController;
|
||||||
|
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
|
||||||
|
import com.baiyue.finance.service.FinanceEventException;
|
||||||
|
import com.baiyue.finance.service.ReceivableStatisticsService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ReceivableStatisticsControllerTest {
|
||||||
|
@Test
|
||||||
|
void exposesTheDedicatedReadEndpointAndRejectsNonMonthInput() throws Exception {
|
||||||
|
ReceivableStatisticsService service = mock(ReceivableStatisticsService.class);
|
||||||
|
ReceivableStatisticsResponse response = new ReceivableStatisticsResponse(YearMonth.of(2026, 7), "CNY",
|
||||||
|
BigDecimal.TEN, BigDecimal.ONE, BigDecimal.valueOf(9), 0, 1, 20, List.of());
|
||||||
|
when(service.query(YearMonth.of(2026, 7), null, 1, 20)).thenReturn(response);
|
||||||
|
ReceivableStatisticsController controller = new ReceivableStatisticsController(service);
|
||||||
|
|
||||||
|
assertThat(ReceivableStatisticsController.class.getAnnotation(RequestMapping.class).value())
|
||||||
|
.containsExactly("/api/platform/finance/v1/receivable-statistics");
|
||||||
|
assertThat(ReceivableStatisticsController.class.getMethod("query", String.class, Long.class, int.class, int.class)
|
||||||
|
.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:receivable-statistics:list')");
|
||||||
|
assertThat(controller.query("2026-07", null, 1, 20).data()).isSameAs(response);
|
||||||
|
verify(service).query(YearMonth.of(2026, 7), null, 1, 20);
|
||||||
|
assertThatThrownBy(() -> controller.query("2026/07", null, 1, 20))
|
||||||
|
.isInstanceOf(FinanceEventException.class).hasMessageContaining("yyyy-MM");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.baiyue.finance;
|
||||||
|
|
||||||
|
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
|
||||||
|
import com.baiyue.finance.service.FinanceEventException;
|
||||||
|
import com.baiyue.finance.service.ReceivableStatisticsService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ReceivableStatisticsServiceTest {
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void aggregatesOnlyTheApprovedFormalCnyDimensionsAndPaginatesCustomers() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
List<ReceivableStatisticsRow> rows = List.of(
|
||||||
|
row(10001, "客户A", "300.00", "100.00", "200.00"),
|
||||||
|
row(10002, "客户B", "80.00", "80.00", "0.00"));
|
||||||
|
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(rows);
|
||||||
|
ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc);
|
||||||
|
|
||||||
|
var result = service.query(YearMonth.of(2026, 7), null, 1, 1);
|
||||||
|
|
||||||
|
assertThat(result.currency()).isEqualTo("CNY");
|
||||||
|
assertThat(result.totalReceivable()).isEqualByComparingTo("380.00");
|
||||||
|
assertThat(result.totalReceived()).isEqualByComparingTo("180.00");
|
||||||
|
assertThat(result.totalOutstanding()).isEqualByComparingTo("200.00");
|
||||||
|
assertThat(result.totalCustomers()).isEqualTo(2);
|
||||||
|
assertThat(result.records()).containsExactly(rows.get(0));
|
||||||
|
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(jdbc).query(sql.capture(), any(RowMapper.class), any(Object[].class));
|
||||||
|
assertThat(sql.getValue()).contains("direction='RECEIVABLE'", "status<>'VOIDED'", "currency_code='CNY'",
|
||||||
|
"generated_date>=?", "generated_date<?", "GROUP BY tenant_id,customer_name_snapshot");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void failsClosedWhenPersistedTotalsDoNotReconcile() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
|
||||||
|
.thenReturn(List.of(row(10001, "客户A", "300.00", "100.00", "199.99")));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc)
|
||||||
|
.query(YearMonth.of(2026, 7), null, 1, 20))
|
||||||
|
.isInstanceOf(FinanceEventException.class)
|
||||||
|
.hasMessageContaining("勾稽异常");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReceivableStatisticsRow row(long id, String name, String total, String received, String outstanding) {
|
||||||
|
return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total),
|
||||||
|
new BigDecimal(received), new BigDecimal(outstanding));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package com.baiyue.finance;
|
|||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.finance.controller.ReconciliationStatementController;
|
import com.baiyue.finance.controller.ReconciliationStatementController;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
|
||||||
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.StatementAdjustmentLineRequest;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.StatementAdjustmentLineRequest;
|
||||||
import com.baiyue.finance.service.FinanceEventException;
|
import com.baiyue.finance.service.FinanceEventException;
|
||||||
|
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
|
||||||
import com.baiyue.finance.service.ReconciliationStatementService;
|
import com.baiyue.finance.service.ReconciliationStatementService;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -27,6 +29,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
@@ -80,7 +83,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
|
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
|
||||||
JdbcTemplate jdbc = createJdbc();
|
JdbcTemplate jdbc = createJdbc();
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
|
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
|
||||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
|
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
|
||||||
|
|
||||||
@@ -95,14 +98,14 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception {
|
void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception {
|
||||||
JdbcTemplate jdbc = createJdbc();
|
JdbcTemplate jdbc = createJdbc();
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
|
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
|
||||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账");
|
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账");
|
||||||
|
|
||||||
var result = target.create(71L, "create-key", request);
|
var result = target.create(71L, "create-key", request);
|
||||||
|
|
||||||
assertThat(result.statementId()).isEqualTo(501L);
|
assertThat(result.statementId()).isEqualTo(501L);
|
||||||
assertThat(result.statementNo()).isEqualTo("REC-00000000000000000501");
|
assertThat(result.statementNo()).isEqualTo("REC-260818-0001");
|
||||||
assertThat(result.currency()).isEqualTo("CNY");
|
assertThat(result.currency()).isEqualTo("CNY");
|
||||||
assertThat(result.createdByName()).isEqualTo("platform_admin");
|
assertThat(result.createdByName()).isEqualTo("platform_admin");
|
||||||
assertThat(result.updatedByName()).isEqualTo("platform_admin");
|
assertThat(result.updatedByName()).isEqualTo("platform_admin");
|
||||||
@@ -181,9 +184,99 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
|
|
||||||
assertThat(result.status()).isEqualTo("ISSUED");
|
assertThat(result.status()).isEqualTo("ISSUED");
|
||||||
assertThat(result.version()).isEqualTo(3);
|
assertThat(result.version()).isEqualTo(3);
|
||||||
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION'"), eq(501L), eq(9L));
|
||||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void customerConfirmationChecksScopeStateVersionAndWritesAudit() throws Exception {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
|
||||||
|
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||||
|
AtomicInteger billReads = new AtomicInteger();
|
||||||
|
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||||
|
boolean confirmed = billReads.getAndIncrement() > 0;
|
||||||
|
return List.of(mapper.mapRow(billResultSet("ISSUED", confirmed ? 4 : 3,
|
||||||
|
confirmed ? "CONFIRMED" : "PENDING_CONFIRMATION", confirmed ? 71L : null), 0));
|
||||||
|
});
|
||||||
|
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
|
||||||
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
|
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
|
||||||
|
|
||||||
|
assertThat(result.confirmationStatus()).isEqualTo("CONFIRMED");
|
||||||
|
assertThat(result.confirmedBy()).isEqualTo(71L);
|
||||||
|
assertThat(result.version()).isEqualTo(4);
|
||||||
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"),
|
||||||
|
eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3));
|
||||||
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_audit_log"),
|
||||||
|
eq(9L), eq("RECONCILIATION_STATEMENT"), eq(501L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), isNull(),
|
||||||
|
anyString(), anyString(), eq(71L), eq("操作人 71"), isNull(), isNull());
|
||||||
|
|
||||||
|
ArgumentCaptor<String> requestHash = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_idempotency_record"),
|
||||||
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"), requestHash.capture());
|
||||||
|
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of(Map.of(
|
||||||
|
"request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 501L,
|
||||||
|
"updated_at", LocalDateTime.now())));
|
||||||
|
|
||||||
|
var replay = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
|
||||||
|
|
||||||
|
assertThat(replay.confirmationStatus()).isEqualTo("CONFIRMED");
|
||||||
|
verify(jdbc, org.mockito.Mockito.times(1)).update(
|
||||||
|
org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"),
|
||||||
|
eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void customerConfirmationHidesCrossCustomerAndRejectsInvalidState() throws Exception {
|
||||||
|
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
|
||||||
|
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer(
|
||||||
|
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
|
||||||
|
.isInstanceOf(FinanceEventException.class)
|
||||||
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
|
||||||
|
verify(crossCustomer, never()).queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), (Object[]) any());
|
||||||
|
|
||||||
|
JdbcTemplate draft = mock(JdbcTemplate.class, invocation ->
|
||||||
|
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||||
|
when(draft.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||||
|
return List.of(mapper.mapRow(billResultSet("DRAFT", 0, "NOT_REQUIRED", null), 0));
|
||||||
|
});
|
||||||
|
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
|
||||||
|
assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer(
|
||||||
|
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
|
||||||
|
.isInstanceOf(FinanceEventException.class)
|
||||||
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
|
verify(draft, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"), (Object[]) any());
|
||||||
|
|
||||||
|
JdbcTemplate stale = mock(JdbcTemplate.class, invocation -> {
|
||||||
|
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) {
|
||||||
|
Object sql = invocation.getArguments()[0];
|
||||||
|
return sql instanceof String value && value.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'") ? 0 : 1;
|
||||||
|
}
|
||||||
|
return RETURNS_DEFAULTS.answer(invocation);
|
||||||
|
});
|
||||||
|
when(stale.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||||
|
return List.of(mapper.mapRow(billResultSet("ISSUED", 3, "PENDING_CONFIRMATION", null), 0));
|
||||||
|
});
|
||||||
|
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
|
||||||
|
assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer(
|
||||||
|
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
|
||||||
|
.isInstanceOf(FinanceEventException.class)
|
||||||
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sameIdempotencyKeyWithDifferentActionBodyIsRejected() throws Exception {
|
void sameIdempotencyKeyWithDifferentActionBodyIsRejected() throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
@@ -456,16 +549,24 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ResultSet billResultSet(String status, int version) throws Exception {
|
private static ResultSet billResultSet(String status, int version) throws Exception {
|
||||||
|
return billResultSet(status, version, "ISSUED".equals(status) ? "PENDING_CONFIRMATION" : "NOT_REQUIRED", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResultSet billResultSet(String status, int version, String confirmationStatus, Long confirmedBy) throws Exception {
|
||||||
ResultSet rs = mock(ResultSet.class);
|
ResultSet rs = mock(ResultSet.class);
|
||||||
when(rs.getLong("id")).thenReturn(501L);
|
when(rs.getLong("id")).thenReturn(501L);
|
||||||
when(rs.getLong("tenant_id")).thenReturn(9L);
|
when(rs.getLong("tenant_id")).thenReturn(9L);
|
||||||
when(rs.getString("bill_no")).thenReturn("REC-00000000000000000501");
|
when(rs.getString("bill_no")).thenReturn("REC-260818-0001");
|
||||||
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
|
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
|
||||||
when(rs.getString("status")).thenReturn(status);
|
when(rs.getString("status")).thenReturn(status);
|
||||||
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
|
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
|
||||||
when(rs.getBigDecimal("received_amount")).thenReturn(BigDecimal.ZERO);
|
when(rs.getBigDecimal("received_amount")).thenReturn(BigDecimal.ZERO);
|
||||||
when(rs.getBigDecimal("outstanding_amount")).thenReturn(new BigDecimal("300.00"));
|
when(rs.getBigDecimal("outstanding_amount")).thenReturn(new BigDecimal("300.00"));
|
||||||
when(rs.getInt("version")).thenReturn(version);
|
when(rs.getInt("version")).thenReturn(version);
|
||||||
|
when(rs.getString("customer_confirmation_status")).thenReturn(confirmationStatus);
|
||||||
|
when(rs.getLong("customer_confirmed_by")).thenReturn(confirmedBy == null ? 0L : confirmedBy);
|
||||||
|
when(rs.wasNull()).thenReturn(confirmedBy == null);
|
||||||
|
when(rs.getString("customer_confirmed_by_name")).thenReturn(confirmedBy == null ? null : "操作人 71");
|
||||||
when(rs.getInt("credit_days")).thenReturn(30);
|
when(rs.getInt("credit_days")).thenReturn(30);
|
||||||
when(rs.getString("remark")).thenReturn("八月对账");
|
when(rs.getString("remark")).thenReturn("八月对账");
|
||||||
when(rs.getString("created_by_name")).thenReturn("platform_admin");
|
when(rs.getString("created_by_name")).thenReturn("platform_admin");
|
||||||
@@ -473,6 +574,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
Timestamp timestamp = Timestamp.valueOf("2026-08-11 10:00:00");
|
Timestamp timestamp = Timestamp.valueOf("2026-08-11 10:00:00");
|
||||||
when(rs.getTimestamp("created_at")).thenReturn(timestamp);
|
when(rs.getTimestamp("created_at")).thenReturn(timestamp);
|
||||||
when(rs.getTimestamp("updated_at")).thenReturn(timestamp);
|
when(rs.getTimestamp("updated_at")).thenReturn(timestamp);
|
||||||
|
when(rs.getTimestamp("customer_confirmed_at")).thenReturn(confirmedBy == null ? null : timestamp);
|
||||||
return rs;
|
return rs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,4 +588,10 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
default -> 1;
|
default -> 1;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
|
||||||
|
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
||||||
|
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001");
|
||||||
|
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class FinanceDocumentNumberGeneratorTest {
|
||||||
|
private static final Clock CLOCK = Clock.fixed(
|
||||||
|
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatsShortDailySequenceForAllThreeDocuments() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L);
|
||||||
|
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||||
|
|
||||||
|
assertThat(generator.next("REC")).isEqualTo("REC-260818-0001");
|
||||||
|
assertThat(generator.next("AR")).isEqualTo("AR-260818-0002");
|
||||||
|
assertThat(generator.next("AP")).isEqualTo("AP-260818-0038");
|
||||||
|
|
||||||
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"),
|
||||||
|
eq("REC:20260818"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsupportedTypeAndOverflow() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||||
|
assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class);
|
||||||
|
|
||||||
|
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
||||||
|
assertThatThrownBy(() -> generator.next("REC"))
|
||||||
|
.isInstanceOf(FinanceEventException.class)
|
||||||
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-4
@@ -8,6 +8,7 @@ import com.baiyue.finance.dto.FinanceReadModels.Statement;
|
|||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
||||||
|
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -18,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.YearMonth;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -33,12 +35,13 @@ class FinanceExportProviderServiceTest {
|
|||||||
@Mock FinanceQueryService queryService;
|
@Mock FinanceQueryService queryService;
|
||||||
@Mock ReconciliationStatementService reconciliationService;
|
@Mock ReconciliationStatementService reconciliationService;
|
||||||
@Mock FinanceSettlementService settlementService;
|
@Mock FinanceSettlementService settlementService;
|
||||||
|
@Mock ReceivableStatisticsService receivableStatisticsService;
|
||||||
private FinanceExportProviderService service;
|
private FinanceExportProviderService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new FinanceExportProviderService(accountService, operationService, queryService,
|
service = new FinanceExportProviderService(accountService, operationService, queryService,
|
||||||
reconciliationService, settlementService, new ObjectMapper(), "12345678901234567890123456789012");
|
reconciliationService, settlementService, receivableStatisticsService, new ObjectMapper(), "12345678901234567890123456789012");
|
||||||
service.initializeSigningKey();
|
service.initializeSigningKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ class FinanceExportProviderServiceTest {
|
|||||||
void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() {
|
void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() {
|
||||||
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
|
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
|
||||||
null, new BigDecimal("300"), new BigDecimal("100"), new BigDecimal("200"), "CNY", "ISSUED", 3,
|
null, new BigDecimal("300"), new BigDecimal("100"), new BigDecimal("200"), "CNY", "ISSUED", 3,
|
||||||
|
"CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"),
|
||||||
7L, "创建人A", LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"));
|
7L, "创建人A", LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"));
|
||||||
ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", "RECEIVABLE", "CONTAINER_FEE",
|
ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", "RECEIVABLE", "CONTAINER_FEE",
|
||||||
"CONT-31", 11L, 21L, 31L, "IN-21", "CONT-31", LocalDate.parse("2026-08-15"), BigDecimal.TEN,
|
"CONT-31", 11L, 21L, 31L, "IN-21", "CONT-31", LocalDate.parse("2026-08-15"), BigDecimal.TEN,
|
||||||
@@ -53,14 +57,15 @@ class FinanceExportProviderServiceTest {
|
|||||||
new BigDecimal("320"), new BigDecimal("300"), "CNY", "优惠调整");
|
new BigDecimal("320"), new BigDecimal("300"), "CNY", "优惠调整");
|
||||||
ReconciliationDetail detail = new ReconciliationDetail(501L, "REC-501", 10001L, "客户A",
|
ReconciliationDetail detail = new ReconciliationDetail(501L, "REC-501", 10001L, "客户A",
|
||||||
LocalDate.parse("2026-08-01"), LocalDate.parse("2026-08-31"), 30, "CNY", new BigDecimal("300"),
|
LocalDate.parse("2026-08-01"), LocalDate.parse("2026-08-31"), 30, "CNY", new BigDecimal("300"),
|
||||||
new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3, 7L, "创建人A",
|
new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3,
|
||||||
|
"CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"), 7L, "创建人A",
|
||||||
LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"),
|
LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"),
|
||||||
"八月账单", List.of(line), List.of(new PaymentHistory(701L, "RECEIPT", LocalDate.parse("2026-08-20"),
|
"八月账单", List.of(line), List.of(new PaymentHistory(701L, "RECEIPT", LocalDate.parse("2026-08-20"),
|
||||||
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", List.of("9001"), 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
|
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", List.of("9001"), 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
|
||||||
FeeSourceDetail source = new FeeSourceDetail(81L, "FIRSTLEG", "CONTAINER_FEE", "CONT-31", 9001L, 2, "RECEIVABLE", 10001L,
|
FeeSourceDetail source = new FeeSourceDetail(81L, "FIRSTLEG", "CONTAINER_FEE", "CONT-31", 9001L, 2, "RECEIVABLE", 10001L,
|
||||||
"客户A", null, null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
|
"客户A", null, null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
|
||||||
BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED", true,
|
BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED",
|
||||||
"AVAILABLE", true, null, null, "hash", 2, List.of());
|
"AVAILABLE", true, null, "hash", 2, List.of());
|
||||||
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
|
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
|
||||||
when(reconciliationService.detailForPlatform(501L)).thenReturn(detail);
|
when(reconciliationService.detailForPlatform(501L)).thenReturn(detail);
|
||||||
when(operationService.getFinanceFeeSource(81L)).thenReturn(source);
|
when(operationService.getFinanceFeeSource(81L)).thenReturn(source);
|
||||||
@@ -107,6 +112,23 @@ class FinanceExportProviderServiceTest {
|
|||||||
verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null);
|
verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() {
|
||||||
|
when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of(
|
||||||
|
new ReceivableStatisticsRow(10001L, "客户A", "CNY", new BigDecimal("300"),
|
||||||
|
new BigDecimal("100"), new BigDecimal("200"))));
|
||||||
|
|
||||||
|
var result = service.exportBatch(FinanceExportProviderService.RECEIVABLE_STATISTICS,
|
||||||
|
request(Map.of("month", "2026-07", "customerId", "10001"), null, null));
|
||||||
|
|
||||||
|
assertThat(result.rows()).singleElement().satisfies(row -> assertThat(row)
|
||||||
|
.containsEntry("statisticsMonth", "2026-07")
|
||||||
|
.containsEntry("customerName", "客户A")
|
||||||
|
.containsEntry("currency", "CNY")
|
||||||
|
.doesNotContainKeys("fbaDirect", "fbaSpecialLine", "roundingDiscount"));
|
||||||
|
verify(receivableStatisticsService).exportRows(YearMonth.of(2026, 7), 10001L);
|
||||||
|
}
|
||||||
|
|
||||||
private ExportBatchRequest request(Map<String, Object> parameters, String cursor, String snapshot) {
|
private ExportBatchRequest request(Map<String, Object> parameters, String cursor, String snapshot) {
|
||||||
return request(parameters, cursor, snapshot, 100);
|
return request(parameters, cursor, snapshot, 100);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -27,17 +28,19 @@ public class InternalFinanceFeeSourceController {
|
|||||||
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
|
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
|
||||||
public ApiResponse<FinanceFeeSourceSnapshot> inbound(
|
public ApiResponse<FinanceFeeSourceSnapshot> inbound(
|
||||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
@PathVariable long inboundId, @PathVariable long feeId) {
|
@PathVariable long inboundId, @PathVariable long feeId,
|
||||||
|
@RequestParam @jakarta.validation.constraints.Min(1) int sourceVersion) {
|
||||||
authenticate(token);
|
authenticate(token);
|
||||||
return ApiResponse.success(service.inbound(inboundId, feeId));
|
return ApiResponse.success(service.inbound(inboundId, feeId, sourceVersion));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
|
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
|
||||||
public ApiResponse<FinanceFeeSourceSnapshot> container(
|
public ApiResponse<FinanceFeeSourceSnapshot> container(
|
||||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
@PathVariable long containerId, @PathVariable long feeId) {
|
@PathVariable long containerId, @PathVariable long feeId,
|
||||||
|
@RequestParam @jakarta.validation.constraints.Min(1) int sourceVersion) {
|
||||||
authenticate(token);
|
authenticate(token);
|
||||||
return ApiResponse.success(service.container(containerId, feeId));
|
return ApiResponse.success(service.container(containerId, feeId, sourceVersion));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void authenticate(String token) {
|
private void authenticate(String token) {
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ public final class ContainerFeeDetailDtos {
|
|||||||
@NotNull LocalDate businessDate,
|
@NotNull LocalDate businessDate,
|
||||||
@NotNull @DecimalMin("0.00") BigDecimal amount,
|
@NotNull @DecimalMin("0.00") BigDecimal amount,
|
||||||
@NotBlank @Pattern(regexp = "CNY") String currency,
|
@NotBlank @Pattern(regexp = "CNY") String currency,
|
||||||
@NotNull Boolean settlementIncluded,
|
Long supplierId,
|
||||||
Long partnerId,
|
|
||||||
@Size(max = 200) String partnerName,
|
|
||||||
@Size(max = 500) String remark
|
@Size(max = 500) String remark
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public final class FeeItemDtos {
|
|||||||
@NotBlank @Size(max = 64) String category,
|
@NotBlank @Size(max = 64) String category,
|
||||||
@NotBlank @Pattern(regexp = "DOMESTIC|INTERNATIONAL") String tradeScope,
|
@NotBlank @Pattern(regexp = "DOMESTIC|INTERNATIONAL") String tradeScope,
|
||||||
@Size(max = 50) @Pattern(regexp = "[A-Za-z0-9_-]{1,50}") String feeCode,
|
@Size(max = 50) @Pattern(regexp = "[A-Za-z0-9_-]{1,50}") String feeCode,
|
||||||
|
@Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String supplierCategory,
|
||||||
@NotBlank @Size(max = 128) String feeTypeName,
|
@NotBlank @Size(max = 128) String feeTypeName,
|
||||||
@NotNull Boolean pickupPayable,
|
@NotNull Boolean pickupPayable,
|
||||||
@NotNull Boolean pickupReceivable,
|
@NotNull Boolean pickupReceivable,
|
||||||
@@ -22,12 +23,22 @@ public final class FeeItemDtos {
|
|||||||
@NotNull Boolean allocationFee,
|
@NotNull Boolean allocationFee,
|
||||||
@Size(max = 500) String description
|
@Size(max = 500) String description
|
||||||
) {
|
) {
|
||||||
|
public SaveRequest(String category, String tradeScope, String feeCode,
|
||||||
|
String feeTypeName, Boolean pickupPayable,
|
||||||
|
Boolean pickupReceivable, Boolean warehousePayable,
|
||||||
|
Boolean warehouseReceivable, Boolean allocationFee,
|
||||||
|
String description) {
|
||||||
|
this(category, tradeScope, feeCode, null, feeTypeName, pickupPayable,
|
||||||
|
pickupReceivable, warehousePayable, warehouseReceivable,
|
||||||
|
allocationFee, description);
|
||||||
|
}
|
||||||
|
|
||||||
/** Backward-compatible constructor for callers predating the stable fee code. */
|
/** Backward-compatible constructor for callers predating the stable fee code. */
|
||||||
public SaveRequest(String category, String tradeScope, String feeTypeName,
|
public SaveRequest(String category, String tradeScope, String feeTypeName,
|
||||||
Boolean pickupPayable, Boolean pickupReceivable,
|
Boolean pickupPayable, Boolean pickupReceivable,
|
||||||
Boolean warehousePayable, Boolean warehouseReceivable,
|
Boolean warehousePayable, Boolean warehouseReceivable,
|
||||||
Boolean allocationFee, String description) {
|
Boolean allocationFee, String description) {
|
||||||
this(category, tradeScope, null, feeTypeName, pickupPayable, pickupReceivable,
|
this(category, tradeScope, null, null, feeTypeName, pickupPayable, pickupReceivable,
|
||||||
warehousePayable, warehouseReceivable, allocationFee, description);
|
warehousePayable, warehouseReceivable, allocationFee, description);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ public record FinanceFeeSourceSnapshot(
|
|||||||
String containerNo,
|
String containerNo,
|
||||||
long customerId,
|
long customerId,
|
||||||
String customerName,
|
String customerName,
|
||||||
Long partnerId,
|
Long supplierId,
|
||||||
String partnerName,
|
String supplierName,
|
||||||
|
String supplierCategory,
|
||||||
Long feeItemId,
|
Long feeItemId,
|
||||||
String feeItemCode,
|
String feeItemCode,
|
||||||
String feeItemName,
|
String feeItemName,
|
||||||
@@ -29,7 +30,6 @@ public record FinanceFeeSourceSnapshot(
|
|||||||
BigDecimal totalAmount,
|
BigDecimal totalAmount,
|
||||||
String reviewStatus,
|
String reviewStatus,
|
||||||
String publicationStatus,
|
String publicationStatus,
|
||||||
boolean settlementIncluded,
|
|
||||||
LocalDateTime publishedAt,
|
LocalDateTime publishedAt,
|
||||||
BigDecimal chargeableQuantity,
|
BigDecimal chargeableQuantity,
|
||||||
String chargeUnit,
|
String chargeUnit,
|
||||||
|
|||||||
@@ -21,15 +21,13 @@ public final class InboundFeeDetailDtos {
|
|||||||
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
|
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
|
||||||
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
|
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
|
||||||
@NotNull @DecimalMin("0.00") BigDecimal amount,
|
@NotNull @DecimalMin("0.00") BigDecimal amount,
|
||||||
@NotNull Boolean settlementIncluded,
|
Long supplierId,
|
||||||
Long partnerId,
|
|
||||||
@Size(max = 200) String partnerName,
|
|
||||||
@Size(max = 500) String remark
|
@Size(max = 500) String remark
|
||||||
) {
|
) {
|
||||||
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
|
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
|
||||||
BigDecimal unitPrice, String currency, BigDecimal amount,
|
BigDecimal unitPrice, String currency, BigDecimal amount,
|
||||||
String remark) {
|
String remark) {
|
||||||
this(feeItemId, direction, businessDate, unitPrice, currency, amount, false, null, null, remark);
|
this(feeItemId, direction, businessDate, unitPrice, currency, amount, null, remark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
|
|||||||
BigDecimal chargeableQuantity, String chargeUnit,
|
BigDecimal chargeableQuantity, String chargeUnit,
|
||||||
String feeTypeName, BigDecimal unitPrice, String currency, BigDecimal amount,
|
String feeTypeName, BigDecimal unitPrice, String currency, BigDecimal amount,
|
||||||
String reviewStatus, String publicationStatus,
|
String reviewStatus, String publicationStatus,
|
||||||
LocalDateTime publishedAt, LocalDateTime inboundedAt, Boolean settlementIncluded
|
LocalDateTime publishedAt, LocalDateTime inboundedAt
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ public class ContainerFeeDetailEntity {
|
|||||||
private String feeItemCodeSnapshot;
|
private String feeItemCodeSnapshot;
|
||||||
private String feeItemNameSnapshot;
|
private String feeItemNameSnapshot;
|
||||||
private String direction;
|
private String direction;
|
||||||
private Long partnerId;
|
private Long supplierId;
|
||||||
private String partnerName;
|
private String supplierName;
|
||||||
|
private String supplierCategory;
|
||||||
private LocalDate businessDate;
|
private LocalDate businessDate;
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
private String currency;
|
private String currency;
|
||||||
private Boolean settlementIncluded;
|
|
||||||
private String reviewStatus;
|
private String reviewStatus;
|
||||||
private String sourceStatus;
|
private String sourceStatus;
|
||||||
private Integer sourceVersion;
|
private Integer sourceVersion;
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package com.baiyue.firstleg.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 柜次费用审核发布时固化的不可变版本快照。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("fl_container_fee_source_snapshot")
|
||||||
|
public class ContainerFeeSourceSnapshotEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
private Long sourceFeeId;
|
||||||
|
private Integer sourceVersion;
|
||||||
|
private Long tenantId;
|
||||||
|
private Long loadingPlanId;
|
||||||
|
private Long containerBookingId;
|
||||||
|
private String containerNo;
|
||||||
|
private Long feeItemId;
|
||||||
|
private String feeItemCode;
|
||||||
|
private String feeItemName;
|
||||||
|
private String direction;
|
||||||
|
private Long supplierId;
|
||||||
|
private String supplierName;
|
||||||
|
private String supplierCategory;
|
||||||
|
private LocalDate businessDate;
|
||||||
|
private BigDecimal amount;
|
||||||
|
private String currency;
|
||||||
|
private String reviewStatus;
|
||||||
|
private LocalDateTime reviewedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public class FeeItemEntity extends BaseEntity {
|
|||||||
private String category;
|
private String category;
|
||||||
private String tradeScope;
|
private String tradeScope;
|
||||||
private String feeCode;
|
private String feeCode;
|
||||||
|
private String supplierCategory;
|
||||||
private String feeTypeName;
|
private String feeTypeName;
|
||||||
private Boolean pickupPayable;
|
private Boolean pickupPayable;
|
||||||
private Boolean pickupReceivable;
|
private Boolean pickupReceivable;
|
||||||
@@ -28,4 +29,5 @@ public class FeeItemEntity extends BaseEntity {
|
|||||||
private String status;
|
private String status;
|
||||||
private String createdByName;
|
private String createdByName;
|
||||||
private String updatedByName;
|
private String updatedByName;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ public class InboundFeeDetailEntity extends BaseEntity {
|
|||||||
private String sourceFeeNo;
|
private String sourceFeeNo;
|
||||||
private String inboundCode;
|
private String inboundCode;
|
||||||
private String direction;
|
private String direction;
|
||||||
private Long partnerId;
|
private Long supplierId;
|
||||||
private String partnerName;
|
private String supplierName;
|
||||||
|
private String supplierCategory;
|
||||||
private LocalDate businessDate;
|
private LocalDate businessDate;
|
||||||
private Long customerId;
|
private Long customerId;
|
||||||
private String customerName;
|
private String customerName;
|
||||||
@@ -62,5 +63,4 @@ public class InboundFeeDetailEntity extends BaseEntity {
|
|||||||
private String publishedByName;
|
private String publishedByName;
|
||||||
private LocalDateTime publishedAt;
|
private LocalDateTime publishedAt;
|
||||||
private LocalDateTime inboundedAt;
|
private LocalDateTime inboundedAt;
|
||||||
private Boolean settlementIncluded;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.firstleg.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ContainerFeeSourceSnapshotMapper extends BaseMapper<ContainerFeeSourceSnapshotEntity> {
|
||||||
|
}
|
||||||
+3
-1
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
|||||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
|
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
|
||||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||||
|
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -42,6 +43,7 @@ public class OceanBookingService {
|
|||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
@Resource private WarehouseSupplierSnapshotService suppliers;
|
@Resource private WarehouseSupplierSnapshotService suppliers;
|
||||||
@Resource private WarehouseMasterDataClient warehouses;
|
@Resource private WarehouseMasterDataClient warehouses;
|
||||||
|
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ public class OceanBookingService {
|
|||||||
ResolvedDraft resolved = resolveDraft(request.data());
|
ResolvedDraft resolved = resolveDraft(request.data());
|
||||||
OceanBookingEntity value = new OceanBookingEntity();
|
OceanBookingEntity value = new OceanBookingEntity();
|
||||||
value.setId(IdWorker.getId());
|
value.setId(IdWorker.getId());
|
||||||
value.setBookingNo("OB" + value.getId());
|
value.setBookingNo(documentNumbers.next("OB"));
|
||||||
value.setStatus(OceanBookingStatus.DRAFT.name());
|
value.setStatus(OceanBookingStatus.DRAFT.name());
|
||||||
copy(value, request.data(), resolved);
|
copy(value, request.data(), resolved);
|
||||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||||
|
|||||||
+16
@@ -42,6 +42,22 @@ public class ContainerFeeDetailRepository {
|
|||||||
mapper.insert(value);
|
mapper.insert(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int updateSupplierSnapshot(long tenantId, long loadingPlanId, long feeId,
|
||||||
|
Long supplierId, String supplierName,
|
||||||
|
String supplierCategory, long operatorId) {
|
||||||
|
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
|
||||||
|
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
|
||||||
|
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
|
||||||
|
.eq(ContainerFeeDetailEntity::getId, feeId)
|
||||||
|
.eq(ContainerFeeDetailEntity::getReviewStatus, "PENDING")
|
||||||
|
.eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE")
|
||||||
|
.set(ContainerFeeDetailEntity::getSupplierId, supplierId)
|
||||||
|
.set(ContainerFeeDetailEntity::getSupplierName, supplierName)
|
||||||
|
.set(ContainerFeeDetailEntity::getSupplierCategory, supplierCategory)
|
||||||
|
.set(ContainerFeeDetailEntity::getUpdatedBy, operatorId)
|
||||||
|
.set(ContainerFeeDetailEntity::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
}
|
||||||
|
|
||||||
public int review(long tenantId, long loadingPlanId, long feeId, String status,
|
public int review(long tenantId, long loadingPlanId, long feeId, String status,
|
||||||
long reviewerId, String reviewerName, String reason) {
|
long reviewerId, String reviewerName, String reason) {
|
||||||
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
|
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.baiyue.firstleg.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
||||||
|
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
|
||||||
|
import com.baiyue.firstleg.mapper.ContainerFeeSourceSnapshotMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContainerFeeSourceSnapshotRepository {
|
||||||
|
private final ContainerFeeSourceSnapshotMapper mapper;
|
||||||
|
|
||||||
|
public void saveApproved(ContainerFeeDetailEntity source) {
|
||||||
|
ContainerFeeSourceSnapshotEntity value = new ContainerFeeSourceSnapshotEntity();
|
||||||
|
value.setSourceFeeId(source.getId());
|
||||||
|
value.setSourceVersion(source.getSourceVersion());
|
||||||
|
value.setTenantId(source.getTenantId());
|
||||||
|
value.setLoadingPlanId(source.getLoadingPlanId());
|
||||||
|
value.setContainerBookingId(source.getContainerBookingId());
|
||||||
|
value.setContainerNo(source.getContainerNo());
|
||||||
|
value.setFeeItemId(source.getFeeItemId());
|
||||||
|
value.setFeeItemCode(source.getFeeItemCodeSnapshot());
|
||||||
|
value.setFeeItemName(source.getFeeItemNameSnapshot());
|
||||||
|
value.setDirection(source.getDirection());
|
||||||
|
value.setSupplierId(source.getSupplierId());
|
||||||
|
value.setSupplierName(source.getSupplierName());
|
||||||
|
value.setSupplierCategory(source.getSupplierCategory());
|
||||||
|
value.setBusinessDate(source.getBusinessDate());
|
||||||
|
value.setAmount(source.getAmount());
|
||||||
|
value.setCurrency(source.getCurrency());
|
||||||
|
value.setReviewStatus(source.getReviewStatus());
|
||||||
|
value.setReviewedAt(source.getReviewedAt());
|
||||||
|
value.setCreatedAt(LocalDateTime.now());
|
||||||
|
mapper.insert(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ContainerFeeSourceSnapshotEntity find(long loadingPlanId, long sourceFeeId,
|
||||||
|
int sourceVersion) {
|
||||||
|
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeSourceSnapshotEntity>()
|
||||||
|
.eq(ContainerFeeSourceSnapshotEntity::getLoadingPlanId, loadingPlanId)
|
||||||
|
.eq(ContainerFeeSourceSnapshotEntity::getSourceFeeId, sourceFeeId)
|
||||||
|
.eq(ContainerFeeSourceSnapshotEntity::getSourceVersion, sourceVersion));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,15 @@ public class FeeItemRepository {
|
|||||||
.eq(InboundFeeDetailEntity::getId, detailId));
|
.eq(InboundFeeDetailEntity::getId, detailId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public InboundFeeDetailEntity findSourceVersion(long inboundId, long sourceFeeId,
|
||||||
|
int sourceVersion) {
|
||||||
|
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
|
||||||
|
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
|
||||||
|
.eq(InboundFeeDetailEntity::getSourceFeeVersion, sourceVersion)
|
||||||
|
.and(scope -> scope.eq(InboundFeeDetailEntity::getId, sourceFeeId)
|
||||||
|
.or().eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId)));
|
||||||
|
}
|
||||||
|
|
||||||
public InboundFeeDetailEntity findBySourceFee(String sourceType, long sourceFeeId) {
|
public InboundFeeDetailEntity findBySourceFee(String sourceType, long sourceFeeId) {
|
||||||
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
|
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
|
||||||
.eq(InboundFeeDetailEntity::getSourceType, sourceType)
|
.eq(InboundFeeDetailEntity::getSourceType, sourceType)
|
||||||
@@ -166,7 +175,6 @@ public class FeeItemRepository {
|
|||||||
.in(InboundFeeDetailEntity::getPublicationStatus, List.of("DRAFT", "PUBLISHED"))
|
.in(InboundFeeDetailEntity::getPublicationStatus, List.of("DRAFT", "PUBLISHED"))
|
||||||
.ne(InboundFeeDetailEntity::getSourceFeeId, retainedSourceFeeId)
|
.ne(InboundFeeDetailEntity::getSourceFeeId, retainedSourceFeeId)
|
||||||
.set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED")
|
.set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED")
|
||||||
.set(InboundFeeDetailEntity::getSettlementIncluded, false)
|
|
||||||
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
|
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
|
||||||
.set(InboundFeeDetailEntity::getUpdatedAt, now));
|
.set(InboundFeeDetailEntity::getUpdatedAt, now));
|
||||||
}
|
}
|
||||||
@@ -186,7 +194,6 @@ public class FeeItemRepository {
|
|||||||
.set(InboundFeeDetailEntity::getPublishedBy, operatorId)
|
.set(InboundFeeDetailEntity::getPublishedBy, operatorId)
|
||||||
.set(InboundFeeDetailEntity::getPublishedByName, operatorName)
|
.set(InboundFeeDetailEntity::getPublishedByName, operatorName)
|
||||||
.set(InboundFeeDetailEntity::getPublishedAt, now)
|
.set(InboundFeeDetailEntity::getPublishedAt, now)
|
||||||
.set(InboundFeeDetailEntity::getSettlementIncluded, true)
|
|
||||||
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
|
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
|
||||||
.set(InboundFeeDetailEntity::getUpdatedAt, now));
|
.set(InboundFeeDetailEntity::getUpdatedAt, now));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
|||||||
import com.baiyue.firstleg.entity.FeeItemEntity;
|
import com.baiyue.firstleg.entity.FeeItemEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||||
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
|
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
|
||||||
|
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
|
||||||
import com.baiyue.firstleg.repository.FeeItemRepository;
|
import com.baiyue.firstleg.repository.FeeItemRepository;
|
||||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
|
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -27,6 +27,8 @@ public class ContainerFeeDetailService {
|
|||||||
private final SmartLoadingRepository loading;
|
private final SmartLoadingRepository loading;
|
||||||
private final FeeItemRepository feeItems;
|
private final FeeItemRepository feeItems;
|
||||||
private final FinanceFeeSourceOutboxService financeOutbox;
|
private final FinanceFeeSourceOutboxService financeOutbox;
|
||||||
|
private final WarehouseSupplierSnapshotService suppliers;
|
||||||
|
private final ContainerFeeSourceSnapshotRepository sourceSnapshots;
|
||||||
|
|
||||||
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
|
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
|
||||||
requireOwnedPlan(tenantId, loadingPlanId);
|
requireOwnedPlan(tenantId, loadingPlanId);
|
||||||
@@ -56,18 +58,19 @@ public class ContainerFeeDetailService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void approve(long tenantId, long loadingPlanId, long feeId,
|
public void approve(long tenantId, long loadingPlanId, long feeId,
|
||||||
long reviewerId, String reviewerName) {
|
long reviewerId, String reviewerName) {
|
||||||
required(tenantId, loadingPlanId, feeId);
|
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
|
||||||
|
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
|
||||||
|
refreshSupplierSnapshot(pending, item, reviewerId);
|
||||||
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
|
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
|
||||||
reviewerId, reviewerName, null) != 1) {
|
reviewerId, reviewerName, null) != 1) {
|
||||||
throw conflict("只有待审核柜次费用可以审核通过");
|
throw conflict("只有待审核柜次费用可以审核通过");
|
||||||
}
|
}
|
||||||
ContainerFeeDetailEntity approved = required(tenantId, loadingPlanId, feeId);
|
ContainerFeeDetailEntity approved = required(tenantId, loadingPlanId, feeId);
|
||||||
if (Boolean.TRUE.equals(approved.getSettlementIncluded())) {
|
sourceSnapshots.saveApproved(approved);
|
||||||
String eventType = approved.getSourceVersion() > 1
|
String eventType = approved.getSourceVersion() > 1
|
||||||
? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED;
|
? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED;
|
||||||
financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId,
|
financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId,
|
||||||
approved.getSourceVersion(), LocalDateTime.now());
|
approved.getSourceVersion(), LocalDateTime.now());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -111,10 +114,8 @@ public class ContainerFeeDetailService {
|
|||||||
if (repository.voidApproved(tenantId, loadingPlanId, feeId, expectedVersion, operatorId) != 1) {
|
if (repository.voidApproved(tenantId, loadingPlanId, feeId, expectedVersion, operatorId) != 1) {
|
||||||
throw conflict("只有当前版本的已审核柜次费用可以作废");
|
throw conflict("只有当前版本的已审核柜次费用可以作废");
|
||||||
}
|
}
|
||||||
if (Boolean.TRUE.equals(current.getSettlementIncluded())) {
|
financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId,
|
||||||
financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId,
|
loadingPlanId, feeId, expectedVersion, LocalDateTime.now());
|
||||||
loadingPlanId, feeId, expectedVersion, LocalDateTime.now());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
|
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
|
||||||
@@ -144,28 +145,49 @@ public class ContainerFeeDetailService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void apply(ContainerFeeDetailEntity value, FeeItemEntity item,
|
private void apply(ContainerFeeDetailEntity value, FeeItemEntity item,
|
||||||
SaveRequest request, long operatorId) {
|
SaveRequest request, long operatorId) {
|
||||||
if ("PAYABLE".equals(request.direction())
|
|
||||||
&& (request.partnerId() == null || request.partnerId() <= 0
|
|
||||||
|| !StringUtils.hasText(request.partnerName()))) {
|
|
||||||
throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效往来单位");
|
|
||||||
}
|
|
||||||
value.setFeeItemId(item.getId());
|
value.setFeeItemId(item.getId());
|
||||||
value.setFeeItemCodeSnapshot(item.getFeeCode());
|
value.setFeeItemCodeSnapshot(item.getFeeCode());
|
||||||
value.setFeeItemNameSnapshot(item.getFeeTypeName());
|
value.setFeeItemNameSnapshot(item.getFeeTypeName());
|
||||||
value.setDirection(request.direction());
|
value.setDirection(request.direction());
|
||||||
value.setPartnerId("PAYABLE".equals(request.direction()) ? request.partnerId() : null);
|
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
|
||||||
value.setPartnerName("PAYABLE".equals(request.direction()) ? request.partnerName().trim() : null);
|
|
||||||
value.setBusinessDate(request.businessDate());
|
value.setBusinessDate(request.businessDate());
|
||||||
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
|
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
|
||||||
value.setCurrency(request.currency());
|
value.setCurrency(request.currency());
|
||||||
value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded()));
|
|
||||||
value.setRemark(request.remark() == null ? null : request.remark().trim());
|
value.setRemark(request.remark() == null ? null : request.remark().trim());
|
||||||
value.setUpdatedBy(operatorId);
|
value.setUpdatedBy(operatorId);
|
||||||
value.setUpdatedAt(LocalDateTime.now());
|
value.setUpdatedAt(LocalDateTime.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applySupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
|
||||||
|
String direction, Long supplierId) {
|
||||||
|
if (!"PAYABLE".equals(direction)) {
|
||||||
|
value.setSupplierId(null);
|
||||||
|
value.setSupplierName(null);
|
||||||
|
value.setSupplierCategory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (supplierId == null || supplierId <= 0) {
|
||||||
|
throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效供应商");
|
||||||
|
}
|
||||||
|
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory());
|
||||||
|
var snapshot = suppliers.fee(supplierId, category);
|
||||||
|
value.setSupplierId(snapshot.supplierId());
|
||||||
|
value.setSupplierName(snapshot.name());
|
||||||
|
value.setSupplierCategory(snapshot.category());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshSupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
|
||||||
|
long operatorId) {
|
||||||
|
applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId());
|
||||||
|
if (repository.updateSupplierSnapshot(value.getTenantId(), value.getLoadingPlanId(),
|
||||||
|
value.getId(), value.getSupplierId(), value.getSupplierName(),
|
||||||
|
value.getSupplierCategory(), operatorId) != 1) {
|
||||||
|
throw conflict("柜次费用供应商快照已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static BizException conflict(String message) {
|
private static BizException conflict(String message) {
|
||||||
return new BizException(ResultCode.CONFLICT, message);
|
return new BizException(ResultCode.CONFLICT, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public class ContainerService {
|
|||||||
@Resource private PriceBookQueryRepository priceBooks;
|
@Resource private PriceBookQueryRepository priceBooks;
|
||||||
@Resource private OceanBookingRepository oceanBookings;
|
@Resource private OceanBookingRepository oceanBookings;
|
||||||
@Resource private WarehouseSupplierSnapshotService suppliers;
|
@Resource private WarehouseSupplierSnapshotService suppliers;
|
||||||
|
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
|
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
|
||||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||||
@@ -66,7 +67,7 @@ public class ContainerService {
|
|||||||
.filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
|
.filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
|
||||||
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
|
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
|
||||||
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
|
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
|
||||||
value.setBookingNo("CB" + value.getId()); value.setCabinetNo("CAB-" + value.getId());
|
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB"));
|
||||||
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
|
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
|
||||||
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
|
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
|
||||||
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
|
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
|
||||||
|
|||||||
@@ -102,10 +102,19 @@ public class FeeItemService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void copy(FeeItemEntity value, SaveRequest request) {
|
private static void copy(FeeItemEntity value, SaveRequest request) {
|
||||||
|
boolean payable = Boolean.TRUE.equals(request.pickupPayable())
|
||||||
|
|| Boolean.TRUE.equals(request.warehousePayable());
|
||||||
|
if (payable && (request.supplierCategory() == null
|
||||||
|
|| request.supplierCategory().isBlank())) {
|
||||||
|
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||||
|
"启用应付方向时必须配置供应商类别");
|
||||||
|
}
|
||||||
value.setCategory(request.category().trim());
|
value.setCategory(request.category().trim());
|
||||||
value.setTradeScope(request.tradeScope());
|
value.setTradeScope(request.tradeScope());
|
||||||
value.setFeeCode(request.feeCode() == null || request.feeCode().isBlank()
|
value.setFeeCode(request.feeCode() == null || request.feeCode().isBlank()
|
||||||
? value.getFeeCode() : request.feeCode().trim().toUpperCase());
|
? value.getFeeCode() : request.feeCode().trim().toUpperCase());
|
||||||
|
value.setSupplierCategory(payable
|
||||||
|
? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null);
|
||||||
value.setFeeTypeName(request.feeTypeName().trim());
|
value.setFeeTypeName(request.feeTypeName().trim());
|
||||||
value.setPickupPayable(request.pickupPayable());
|
value.setPickupPayable(request.pickupPayable());
|
||||||
value.setPickupReceivable(request.pickupReceivable());
|
value.setPickupReceivable(request.pickupReceivable());
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 费用项目显式供应商类别校验;未配置的应付费用失败关闭。 */
|
||||||
|
public final class FeeItemSupplierCategory {
|
||||||
|
private static final Set<String> ALLOWED = Set.of(
|
||||||
|
"BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE");
|
||||||
|
|
||||||
|
private FeeItemSupplierCategory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalize(String supplierCategory) {
|
||||||
|
if (supplierCategory == null || supplierCategory.isBlank()) return null;
|
||||||
|
String normalized = supplierCategory.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (!ALLOWED.contains(normalized)) {
|
||||||
|
throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别无效");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String requirePayable(String supplierCategory) {
|
||||||
|
String category = normalize(supplierCategory);
|
||||||
|
if (category == null) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT,
|
||||||
|
"费用项目未配置供应商类别,不能用于应付费用");
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-23
@@ -3,11 +3,11 @@ package com.baiyue.firstleg.service;
|
|||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
|
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
|
||||||
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
|
||||||
import com.baiyue.firstleg.entity.FeeItemEntity;
|
import com.baiyue.firstleg.entity.FeeItemEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
|
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||||
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
|
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
|
||||||
import com.baiyue.firstleg.repository.FeeItemRepository;
|
import com.baiyue.firstleg.repository.FeeItemRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -21,11 +21,10 @@ import java.util.HexFormat;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class FinanceFeeSourceSnapshotService {
|
public class FinanceFeeSourceSnapshotService {
|
||||||
private final FeeItemRepository inboundFees;
|
private final FeeItemRepository inboundFees;
|
||||||
private final ContainerFeeDetailRepository containerFees;
|
private final ContainerFeeSourceSnapshotRepository containerSnapshots;
|
||||||
|
|
||||||
public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId) {
|
public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId, int sourceVersion) {
|
||||||
InboundFeeDetailEntity detail = inboundFees.findDetail(inboundId, feeId);
|
InboundFeeDetailEntity detail = inboundFees.findSourceVersion(inboundId, feeId, sourceVersion);
|
||||||
if (detail == null) detail = inboundFees.findBySourceFee("FBA_PRICING", feeId);
|
|
||||||
if (detail == null || !Long.valueOf(inboundId).equals(detail.getInboundId())) {
|
if (detail == null || !Long.valueOf(inboundId).equals(detail.getInboundId())) {
|
||||||
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源不存在");
|
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源不存在");
|
||||||
}
|
}
|
||||||
@@ -34,43 +33,44 @@ public class FinanceFeeSourceSnapshotService {
|
|||||||
if (inbound == null || item == null) {
|
if (inbound == null || item == null) {
|
||||||
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源关联快照不完整");
|
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源关联快照不完整");
|
||||||
}
|
}
|
||||||
long sourceFeeId = "FBA_PRICING".equals(detail.getSourceType())
|
long sourceFeeId = detail.getSourceFeeId() == null ? detail.getId() : detail.getSourceFeeId();
|
||||||
? detail.getSourceFeeId() : detail.getId();
|
|
||||||
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
|
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
|
||||||
String canonical = String.join("|", "INBOUND_FEE", String.valueOf(sourceFeeId),
|
String canonical = String.join("|", "INBOUND_FEE", String.valueOf(sourceFeeId),
|
||||||
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(inboundId),
|
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(inboundId),
|
||||||
String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()),
|
String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()),
|
||||||
nullSafe(detail.getDirection()), detail.getAmount().toPlainString(),
|
nullSafe(detail.getDirection()), nullSafe(detail.getSupplierId()),
|
||||||
nullSafe(detail.getCurrency()), String.valueOf(detail.getSettlementIncluded()));
|
nullSafe(detail.getSupplierName()), nullSafe(detail.getSupplierCategory()),
|
||||||
|
detail.getAmount().toPlainString(),
|
||||||
|
nullSafe(detail.getCurrency()));
|
||||||
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
|
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
|
||||||
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
|
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
|
||||||
detail.getTenantId(), inbound.getCustomerName(), detail.getPartnerId(), detail.getPartnerName(), item.getId(), item.getFeeCode(),
|
detail.getTenantId(), inbound.getCustomerName(), detail.getSupplierId(),
|
||||||
|
detail.getSupplierName(), detail.getSupplierCategory(), item.getId(), item.getFeeCode(),
|
||||||
detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
|
detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
|
||||||
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
|
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
|
||||||
detail.getPublicationStatus(), Boolean.TRUE.equals(detail.getSettlementIncluded()),
|
detail.getPublicationStatus(),
|
||||||
detail.getPublishedAt(), detail.getChargeableQuantity(), detail.getChargeUnit(),
|
detail.getPublishedAt(), detail.getChargeableQuantity(), detail.getChargeUnit(),
|
||||||
detail.getActualGrossWeightKg(), detail.getActualVolumeCbm(), sha256(canonical), 1);
|
detail.getActualGrossWeightKg(), detail.getActualVolumeCbm(), sha256(canonical), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FinanceFeeSourceSnapshot container(long containerId, long feeId) {
|
public FinanceFeeSourceSnapshot container(long containerId, long feeId, int sourceVersion) {
|
||||||
ContainerFeeDetailEntity detail = containerFees.find(containerId, feeId);
|
ContainerFeeSourceSnapshotEntity detail = containerSnapshots.find(containerId, feeId, sourceVersion);
|
||||||
if (detail == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用来源不存在");
|
if (detail == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用来源不存在");
|
||||||
int version = detail.getSourceVersion();
|
int version = detail.getSourceVersion();
|
||||||
String publicationStatus = "VOIDED".equals(detail.getSourceStatus()) ? "VOIDED"
|
|
||||||
: "APPROVED".equals(detail.getReviewStatus()) ? "PUBLISHED" : "DRAFT";
|
|
||||||
String canonical = String.join("|", "CONTAINER_FEE", String.valueOf(feeId),
|
String canonical = String.join("|", "CONTAINER_FEE", String.valueOf(feeId),
|
||||||
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(containerId),
|
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(containerId),
|
||||||
nullSafe(detail.getFeeItemCodeSnapshot()), nullSafe(detail.getDirection()),
|
nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()),
|
||||||
nullSafe(detail.getPartnerId()), nullSafe(detail.getPartnerName()),
|
nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()),
|
||||||
|
nullSafe(detail.getSupplierCategory()),
|
||||||
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
|
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
|
||||||
String.valueOf(detail.getSettlementIncluded()));
|
nullSafe(detail.getReviewStatus()));
|
||||||
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
|
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
|
||||||
version, null, null, null, containerId, detail.getContainerBookingId(),
|
version, null, null, null, containerId, detail.getContainerBookingId(),
|
||||||
detail.getContainerNo(), detail.getTenantId(), null, detail.getPartnerId(),
|
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
|
||||||
detail.getPartnerName(), detail.getFeeItemId(),
|
detail.getSupplierName(), detail.getSupplierCategory(), detail.getFeeItemId(),
|
||||||
detail.getFeeItemCodeSnapshot(), detail.getFeeItemNameSnapshot(), detail.getDirection(),
|
detail.getFeeItemCode(), detail.getFeeItemName(), detail.getDirection(),
|
||||||
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
|
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
|
||||||
publicationStatus, Boolean.TRUE.equals(detail.getSettlementIncluded()),
|
"PUBLISHED",
|
||||||
detail.getReviewedAt(), null, null, null, null, sha256(canonical), 1);
|
detail.getReviewedAt(), null, null, null, null, sha256(canonical), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class FirstlegDocumentNumberGenerator {
|
||||||
|
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
||||||
|
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "CAB", "LP");
|
||||||
|
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) {
|
||||||
|
this(jdbc, Clock.system(BUSINESS_ZONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
FirstlegDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.MANDATORY)
|
||||||
|
public String next(String prefix) {
|
||||||
|
if (!SUPPORTED_PREFIXES.contains(prefix)) {
|
||||||
|
throw new IllegalArgumentException("unsupported firstleg document prefix: " + prefix);
|
||||||
|
}
|
||||||
|
LocalDate sequenceDate = LocalDate.now(clock);
|
||||||
|
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO fl_document_sequence(sequence_key,current_value) " +
|
||||||
|
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
|
||||||
|
"current_value=LAST_INSERT_ID(current_value+1)",
|
||||||
|
sequenceKey);
|
||||||
|
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||||
|
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽");
|
||||||
|
}
|
||||||
|
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
||||||
|
String.format(Locale.ROOT, "%04d", sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ public class InboundFeeDetailService {
|
|||||||
private FbaPricingRepository pricingRepository;
|
private FbaPricingRepository pricingRepository;
|
||||||
@Resource
|
@Resource
|
||||||
private FinanceFeeSourceOutboxService financeOutbox;
|
private FinanceFeeSourceOutboxService financeOutbox;
|
||||||
|
@Resource
|
||||||
|
private WarehouseSupplierSnapshotService suppliers;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
|
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
|
||||||
@@ -130,7 +132,6 @@ public class InboundFeeDetailService {
|
|||||||
detail.setPublishedByName(operatorName);
|
detail.setPublishedByName(operatorName);
|
||||||
detail.setPublishedAt(now);
|
detail.setPublishedAt(now);
|
||||||
detail.setInboundedAt(inbound.getInboundedAt());
|
detail.setInboundedAt(inbound.getInboundedAt());
|
||||||
detail.setSettlementIncluded(true);
|
|
||||||
detail.setCreatedBy(operatorId);
|
detail.setCreatedBy(operatorId);
|
||||||
detail.setUpdatedBy(operatorId);
|
detail.setUpdatedBy(operatorId);
|
||||||
try {
|
try {
|
||||||
@@ -199,7 +200,7 @@ public class InboundFeeDetailService {
|
|||||||
detail.setRemark("来源费用单:" + fee.getFeeNo());
|
detail.setRemark("来源费用单:" + fee.getFeeNo());
|
||||||
detail.setReviewStatus(published ? "APPROVED" : "PENDING");
|
detail.setReviewStatus(published ? "APPROVED" : "PENDING");
|
||||||
detail.setPublicationStatus(published ? "PUBLISHED" : "DRAFT");
|
detail.setPublicationStatus(published ? "PUBLISHED" : "DRAFT");
|
||||||
detail.setSettlementIncluded(published); detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId);
|
detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId);
|
||||||
if (published) {
|
if (published) {
|
||||||
detail.setReviewerId(operatorId); detail.setReviewerName(operatorName); detail.setReviewedAt(now);
|
detail.setReviewerId(operatorId); detail.setReviewerName(operatorName); detail.setReviewedAt(now);
|
||||||
detail.setPublishedBy(operatorId); detail.setPublishedByName(operatorName); detail.setPublishedAt(now);
|
detail.setPublishedBy(operatorId); detail.setPublishedByName(operatorName); detail.setPublishedAt(now);
|
||||||
@@ -252,7 +253,9 @@ public class InboundFeeDetailService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void approve(long tenantId, long inboundId, long detailId,
|
public void approve(long tenantId, long inboundId, long detailId,
|
||||||
long reviewerId, String reviewerName) {
|
long reviewerId, String reviewerName) {
|
||||||
requiredManual(tenantId, inboundId, detailId);
|
InboundFeeDetailEntity pending = requiredManual(tenantId, inboundId, detailId);
|
||||||
|
FeeItemEntity item = requiredActiveItem(pending.getFeeItemId());
|
||||||
|
refreshSupplierSnapshot(pending, item, reviewerId);
|
||||||
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
|
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
|
||||||
reviewerId, reviewerName, null) != 1) {
|
reviewerId, reviewerName, null) != 1) {
|
||||||
throw conflict("只有待审核费用明细可以审核通过");
|
throw conflict("只有待审核费用明细可以审核通过");
|
||||||
@@ -301,10 +304,8 @@ public class InboundFeeDetailService {
|
|||||||
if (repository.voidApproved(tenantId, inboundId, detailId, expectedSourceVersion, operatorId) != 1) {
|
if (repository.voidApproved(tenantId, inboundId, detailId, expectedSourceVersion, operatorId) != 1) {
|
||||||
throw conflict("只有当前版本的已审核费用明细可以作废");
|
throw conflict("只有当前版本的已审核费用明细可以作废");
|
||||||
}
|
}
|
||||||
if (Boolean.TRUE.equals(current.getSettlementIncluded())) {
|
current.setPublicationStatus("VOIDED");
|
||||||
current.setPublicationStatus("VOIDED");
|
publishFinanceSource(current, "VOIDED", LocalDateTime.now());
|
||||||
publishFinanceSource(current, "VOIDED", LocalDateTime.now());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -373,14 +374,13 @@ public class InboundFeeDetailService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
|
private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
|
||||||
FeeItemEntity item, SaveRequest request, long operatorId) {
|
FeeItemEntity item, SaveRequest request, long operatorId) {
|
||||||
ensureWarehouseApplicable(item, request.direction());
|
ensureWarehouseApplicable(item, request.direction());
|
||||||
value.setFeeItemId(item.getId());
|
value.setFeeItemId(item.getId());
|
||||||
value.setInboundCode(inbound.getInboundCode());
|
value.setInboundCode(inbound.getInboundCode());
|
||||||
value.setDirection(request.direction());
|
value.setDirection(request.direction());
|
||||||
value.setPartnerId("PAYABLE".equals(request.direction()) ? request.partnerId() : null);
|
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
|
||||||
value.setPartnerName("PAYABLE".equals(request.direction()) ? request.partnerName() : null);
|
|
||||||
value.setBusinessDate(request.businessDate());
|
value.setBusinessDate(request.businessDate());
|
||||||
value.setFeeCategorySnapshot(item.getCategory());
|
value.setFeeCategorySnapshot(item.getCategory());
|
||||||
value.setTradeScopeSnapshot(item.getTradeScope());
|
value.setTradeScopeSnapshot(item.getTradeScope());
|
||||||
@@ -399,18 +399,43 @@ public class InboundFeeDetailService {
|
|||||||
value.setUnitPrice(request.unitPrice().setScale(4, RoundingMode.HALF_UP));
|
value.setUnitPrice(request.unitPrice().setScale(4, RoundingMode.HALF_UP));
|
||||||
value.setCurrency(request.currency());
|
value.setCurrency(request.currency());
|
||||||
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
|
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
|
||||||
value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded()));
|
|
||||||
value.setRemark(request.remark() == null ? null : request.remark().trim());
|
value.setRemark(request.remark() == null ? null : request.remark().trim());
|
||||||
value.setUpdatedBy(operatorId);
|
value.setUpdatedBy(operatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applySupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
|
||||||
|
String direction, Long supplierId) {
|
||||||
|
if (!"PAYABLE".equals(direction)) {
|
||||||
|
value.setSupplierId(null);
|
||||||
|
value.setSupplierName(null);
|
||||||
|
value.setSupplierCategory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (supplierId == null || supplierId <= 0) {
|
||||||
|
throw new BizException(ResultCode.VALIDATION_FAILED, "应付入库费用必须选择有效供应商");
|
||||||
|
}
|
||||||
|
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory());
|
||||||
|
var snapshot = suppliers.fee(supplierId, category);
|
||||||
|
value.setSupplierId(snapshot.supplierId());
|
||||||
|
value.setSupplierName(snapshot.name());
|
||||||
|
value.setSupplierCategory(snapshot.category());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshSupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
|
||||||
|
long operatorId) {
|
||||||
|
applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId());
|
||||||
|
value.setUpdatedBy(operatorId);
|
||||||
|
if (repository.updateDetail(value) != 1) {
|
||||||
|
throw conflict("费用明细供应商快照已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static BizException conflict(String message) {
|
private static BizException conflict(String message) {
|
||||||
return new BizException(ResultCode.CONFLICT, message);
|
return new BizException(ResultCode.CONFLICT, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void publishFinanceSource(InboundFeeDetailEntity detail, String status,
|
private void publishFinanceSource(InboundFeeDetailEntity detail, String status,
|
||||||
LocalDateTime occurredAt) {
|
LocalDateTime occurredAt) {
|
||||||
if (!Boolean.TRUE.equals(detail.getSettlementIncluded())) return;
|
|
||||||
if ("FBA_PRICING".equals(detail.getSourceType())) return;
|
if ("FBA_PRICING".equals(detail.getSourceType())) return;
|
||||||
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
|
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
|
||||||
String eventType = "VOIDED".equals(status) ? FinanceFeeSourceOutboxService.VOIDED
|
String eventType = "VOIDED".equals(status) ? FinanceFeeSourceOutboxService.VOIDED
|
||||||
|
|||||||
+1
-1
@@ -149,7 +149,7 @@ public class MerchantInboundBillingService {
|
|||||||
detail.getChargeableVolumeCbm(), detail.getChargeableQuantity(), detail.getChargeUnit(),
|
detail.getChargeableVolumeCbm(), detail.getChargeableQuantity(), detail.getChargeUnit(),
|
||||||
detail.getFeeTypeNameSnapshot(), detail.getUnitPrice(), detail.getCurrency(), detail.getAmount(),
|
detail.getFeeTypeNameSnapshot(), detail.getUnitPrice(), detail.getCurrency(), detail.getAmount(),
|
||||||
detail.getReviewStatus(), detail.getPublicationStatus(), detail.getPublishedAt(),
|
detail.getReviewStatus(), detail.getPublicationStatus(), detail.getPublishedAt(),
|
||||||
detail.getInboundedAt(), detail.getSettlementIncluded());
|
detail.getInboundedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ImageDownload(byte[] content, String contentType) {
|
public record ImageDownload(byte[] content, String contentType) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class SmartLoadingService {
|
|||||||
@Resource private LoadingPickService loadingPickService;
|
@Resource private LoadingPickService loadingPickService;
|
||||||
@Resource private LoadingModelBridgeService loadingModelBridge;
|
@Resource private LoadingModelBridgeService loadingModelBridge;
|
||||||
@Resource private SplitPreloadService splitPreloadService;
|
@Resource private SplitPreloadService splitPreloadService;
|
||||||
|
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||||
public SmartLoadingService() { }
|
public SmartLoadingService() { }
|
||||||
@@ -77,7 +78,7 @@ public class SmartLoadingService {
|
|||||||
LoadingPlanEntity existing = repository.planByBooking(bookingId);
|
LoadingPlanEntity existing = repository.planByBooking(bookingId);
|
||||||
if (existing != null) return draftView(existing, booking);
|
if (existing != null) return draftView(existing, booking);
|
||||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||||
plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20));
|
plan.setPlanNo(documentNumbers.next("LP"));
|
||||||
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark));
|
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark));
|
||||||
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
|
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
|
||||||
plan.setUpdatedByName(operatorName); plan.setVersion(0);
|
plan.setUpdatedByName(operatorName); plan.setVersion(0);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class TruckAppointmentService {
|
|||||||
@Resource TruckAppointmentFileClient files;
|
@Resource TruckAppointmentFileClient files;
|
||||||
@Resource WarehouseSupplierSnapshotService suppliers;
|
@Resource WarehouseSupplierSnapshotService suppliers;
|
||||||
@Resource IamCustomerSnapshotService customers;
|
@Resource IamCustomerSnapshotService customers;
|
||||||
|
@Resource FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
@Resource ObjectMapper objectMapper;
|
@Resource ObjectMapper objectMapper;
|
||||||
@Value("${baiyue.security.internal-service-token}") String internalToken;
|
@Value("${baiyue.security.internal-service-token}") String internalToken;
|
||||||
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
|
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
|
||||||
@@ -47,7 +48,7 @@ public class TruckAppointmentService {
|
|||||||
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
|
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
|
||||||
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
|
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
|
||||||
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
|
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
|
||||||
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
|
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo(documentNumbers.next("TA")); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
|
||||||
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
|
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
|
||||||
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||||
|
|||||||
+8
@@ -26,6 +26,14 @@ public class WarehouseSupplierSnapshotService {
|
|||||||
return resolve(supplierId, suppliedName, "CUSTOMS");
|
return resolve(supplierId, suppliedName, "CUSTOMS");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 费用来源只提交供应商 ID,名称与类别均以 Warehouse 返回快照为准。 */
|
||||||
|
public WarehouseSupplierClient.Snapshot fee(Long supplierId, String category) {
|
||||||
|
if (!StringUtils.hasText(category)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "费用项目未配置供应商类别");
|
||||||
|
}
|
||||||
|
return resolve(supplierId, null, category);
|
||||||
|
}
|
||||||
|
|
||||||
private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName,
|
private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName,
|
||||||
String category) {
|
String category) {
|
||||||
if (supplierId == null || supplierId <= 0) {
|
if (supplierId == null || supplierId <= 0) {
|
||||||
|
|||||||
@@ -71,12 +71,12 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
|
|||||||
fee_item_code_snapshot VARCHAR(50) NOT NULL COMMENT '费用项目稳定编码快照',
|
fee_item_code_snapshot VARCHAR(50) NOT NULL COMMENT '费用项目稳定编码快照',
|
||||||
fee_item_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
|
fee_item_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
|
||||||
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
|
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
|
||||||
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空',
|
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
|
||||||
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空',
|
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
||||||
|
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
|
||||||
business_date DATE NOT NULL COMMENT '费用业务日期',
|
business_date DATE NOT NULL COMMENT '费用业务日期',
|
||||||
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
||||||
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
|
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
|
||||||
settlement_included TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与财务结算来源池',
|
|
||||||
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回',
|
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回',
|
||||||
source_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废',
|
source_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废',
|
||||||
source_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用来源版本;调整递增,作废携带当前版本',
|
source_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用来源版本;调整递增,作废携带当前版本',
|
||||||
@@ -91,22 +91,46 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
|
|||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY idx_container_fee_customer_plan (tenant_id, loading_plan_id, source_status),
|
KEY idx_container_fee_customer_plan (tenant_id, loading_plan_id, source_status),
|
||||||
KEY idx_container_fee_settlement (tenant_id, review_status, settlement_included, source_status),
|
KEY idx_container_fee_review (tenant_id, review_status, source_status),
|
||||||
CONSTRAINT chk_container_fee_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
|
CONSTRAINT chk_container_fee_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
|
||||||
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
|
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
|
||||||
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
|
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
|
||||||
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
|
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
|
||||||
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
|
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
|
||||||
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance';
|
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
|
||||||
|
|
||||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_id',
|
CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
|
||||||
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
|
id BIGINT UNSIGNED NOT NULL,
|
||||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_name',
|
source_fee_id BIGINT UNSIGNED NOT NULL,
|
||||||
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
|
source_version INT UNSIGNED NOT NULL,
|
||||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id',
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
|
loading_plan_id BIGINT UNSIGNED NOT NULL,
|
||||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name',
|
container_booking_id BIGINT UNSIGNED NULL,
|
||||||
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
|
container_no VARCHAR(64) NULL,
|
||||||
|
fee_item_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
fee_item_code VARCHAR(50) NOT NULL,
|
||||||
|
fee_item_name VARCHAR(128) NOT NULL,
|
||||||
|
direction VARCHAR(16) NOT NULL,
|
||||||
|
supplier_id BIGINT UNSIGNED NULL,
|
||||||
|
supplier_name VARCHAR(200) NULL,
|
||||||
|
supplier_category VARCHAR(16) NULL,
|
||||||
|
business_date DATE NOT NULL,
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
currency CHAR(3) NOT NULL DEFAULT 'CNY',
|
||||||
|
review_status VARCHAR(16) NOT NULL,
|
||||||
|
reviewed_at DATETIME(3) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_container_fee_source_version (loading_plan_id, source_fee_id, source_version),
|
||||||
|
KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id)
|
||||||
|
) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照';
|
||||||
|
|
||||||
|
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
|
||||||
|
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
||||||
|
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空' AFTER supplier_name");
|
||||||
|
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
|
||||||
|
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
||||||
|
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空' AFTER supplier_name");
|
||||||
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
|
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
|
||||||
|
|
||||||
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
|
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
|
||||||
@@ -125,13 +149,13 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
|
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
|
||||||
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
|
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用往来单位快照字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
|
||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
|
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
|
||||||
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
|
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用往来单位快照字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
|
||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
DELIMITER ;
|
DELIMITER ;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
|
|||||||
category VARCHAR(64) NOT NULL COMMENT '费用分类',
|
category VARCHAR(64) NOT NULL COMMENT '费用分类',
|
||||||
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
|
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
|
||||||
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
|
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
|
||||||
|
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭',
|
||||||
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
|
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
|
||||||
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
|
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
|
||||||
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
|
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
|
||||||
@@ -36,8 +37,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
|
|||||||
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
|
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
|
||||||
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
|
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
|
||||||
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付',
|
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付',
|
||||||
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空',
|
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
|
||||||
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空',
|
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
||||||
|
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
|
||||||
business_date DATE NOT NULL COMMENT '业务发生日',
|
business_date DATE NOT NULL COMMENT '业务发生日',
|
||||||
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
|
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
|
||||||
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
|
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ DELIMITER ;
|
|||||||
|
|
||||||
CALL phase2_publish_add_column('fl_fee_item', 'fee_code',
|
CALL phase2_publish_add_column('fl_fee_item', 'fee_code',
|
||||||
"VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope");
|
"VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope");
|
||||||
|
CALL phase2_publish_add_column('fl_fee_item', 'supplier_category',
|
||||||
|
"VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭' AFTER fee_code");
|
||||||
ALTER TABLE fl_fee_item
|
ALTER TABLE fl_fee_item
|
||||||
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id',
|
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id',
|
||||||
COMMENT = '平台公共费用项目主数据';
|
COMMENT = '平台公共费用项目主数据';
|
||||||
@@ -431,9 +433,6 @@ CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_at',
|
|||||||
"DATETIME(3) NULL COMMENT '发布时间' AFTER published_by_name");
|
"DATETIME(3) NULL COMMENT '发布时间' AFTER published_by_name");
|
||||||
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'inbounded_at',
|
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'inbounded_at',
|
||||||
"DATETIME(3) NULL COMMENT '入库完成时间快照' AFTER published_at");
|
"DATETIME(3) NULL COMMENT '入库完成时间快照' AFTER published_at");
|
||||||
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'settlement_included',
|
|
||||||
"TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与结算' AFTER inbounded_at");
|
|
||||||
|
|
||||||
DROP PROCEDURE IF EXISTS phase2_publish_add_column;
|
DROP PROCEDURE IF EXISTS phase2_publish_add_column;
|
||||||
|
|
||||||
DROP PROCEDURE IF EXISTS phase2_publish_add_index;
|
DROP PROCEDURE IF EXISTS phase2_publish_add_index;
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ DELIMITER ;
|
|||||||
CALL phase3_add_allocation_picked_count();
|
CALL phase3_add_allocation_picked_count();
|
||||||
DROP PROCEDURE phase3_add_allocation_picked_count;
|
DROP PROCEDURE phase3_add_allocation_picked_count;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||||
|
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818',
|
||||||
|
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
|
PRIMARY KEY (sequence_key),
|
||||||
|
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
|
||||||
|
) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ components:
|
|||||||
nextProcessStatus: {type: string, nullable: true, description: 费用动作后的入库主流程状态;审核完成后保持 WAIT_SPLIT}
|
nextProcessStatus: {type: string, nullable: true, description: 费用动作后的入库主流程状态;审核完成后保持 WAIT_SPLIT}
|
||||||
InboundFeeDetailSaveRequest:
|
InboundFeeDetailSaveRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [feeItemId, direction, businessDate, unitPrice, currency, amount, settlementIncluded]
|
required: [feeItemId, direction, businessDate, unitPrice, currency, amount]
|
||||||
properties:
|
properties:
|
||||||
feeItemId: {type: string, pattern: '^[1-9][0-9]*$'}
|
feeItemId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
||||||
@@ -303,9 +303,9 @@ components:
|
|||||||
unitPrice: {type: number, minimum: 0}
|
unitPrice: {type: number, minimum: 0}
|
||||||
currency: {type: string, pattern: '^[A-Z]{3}$'}
|
currency: {type: string, pattern: '^[A-Z]{3}$'}
|
||||||
amount: {type: number, minimum: 0}
|
amount: {type: number, minimum: 0}
|
||||||
settlementIncluded: {type: boolean}
|
supplierId: {type: string, nullable: true}
|
||||||
partnerId: {type: string, nullable: true}
|
supplierName: {type: string, maxLength: 200, nullable: true, readOnly: true}
|
||||||
partnerName: {type: string, maxLength: 200, nullable: true}
|
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
|
||||||
remark: {type: string, maxLength: 500, nullable: true}
|
remark: {type: string, maxLength: 500, nullable: true}
|
||||||
InboundFeeDetailVersionRequest:
|
InboundFeeDetailVersionRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -318,7 +318,7 @@ components:
|
|||||||
InboundFeeDetailResource:
|
InboundFeeDetailResource:
|
||||||
type: object
|
type: object
|
||||||
description: 入仓费用展示记录。sourceType=FBA_PRICING 时是只读派生;sourceType=MANUAL 时按 sourceFeeId/sourceFeeVersion 独立版本化。
|
description: 入仓费用展示记录。sourceType=FBA_PRICING 时是只读派生;sourceType=MANUAL 时按 sourceFeeId/sourceFeeVersion 独立版本化。
|
||||||
required: [id, tenantId, inboundId, feeItemId, sourceType, sourceFeeVersion, direction, businessDate, unitPrice, currency, amount, reviewStatus, publicationStatus, settlementIncluded]
|
required: [id, tenantId, inboundId, feeItemId, sourceType, sourceFeeVersion, direction, businessDate, unitPrice, currency, amount, reviewStatus, publicationStatus]
|
||||||
properties:
|
properties:
|
||||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
|
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
@@ -330,8 +330,9 @@ components:
|
|||||||
sourceFeeNo: {type: string, nullable: true}
|
sourceFeeNo: {type: string, nullable: true}
|
||||||
inboundCode: {type: string, nullable: true}
|
inboundCode: {type: string, nullable: true}
|
||||||
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
||||||
partnerId: {type: string, nullable: true}
|
supplierId: {type: string, nullable: true}
|
||||||
partnerName: {type: string, nullable: true}
|
supplierName: {type: string, nullable: true, readOnly: true}
|
||||||
|
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
|
||||||
businessDate: {type: string, format: date}
|
businessDate: {type: string, format: date}
|
||||||
customerId: {type: string, nullable: true}
|
customerId: {type: string, nullable: true}
|
||||||
customerName: {type: string, nullable: true}
|
customerName: {type: string, nullable: true}
|
||||||
@@ -365,7 +366,6 @@ components:
|
|||||||
publishedByName: {type: string, nullable: true}
|
publishedByName: {type: string, nullable: true}
|
||||||
publishedAt: {type: string, format: date-time, nullable: true}
|
publishedAt: {type: string, format: date-time, nullable: true}
|
||||||
inboundedAt: {type: string, format: date-time, nullable: true}
|
inboundedAt: {type: string, format: date-time, nullable: true}
|
||||||
settlementIncluded: {type: boolean}
|
|
||||||
createdBy: {type: string, nullable: true}
|
createdBy: {type: string, nullable: true}
|
||||||
createdAt: {type: string, format: date-time, nullable: true}
|
createdAt: {type: string, format: date-time, nullable: true}
|
||||||
updatedBy: {type: string, nullable: true}
|
updatedBy: {type: string, nullable: true}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ components:
|
|||||||
category: {type: string, maxLength: 64}
|
category: {type: string, maxLength: 64}
|
||||||
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
|
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
|
||||||
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
|
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
|
||||||
|
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: pickupPayable或warehousePayable为true时必填}
|
||||||
feeTypeName: {type: string, maxLength: 128}
|
feeTypeName: {type: string, maxLength: 128}
|
||||||
pickupPayable: {type: boolean}
|
pickupPayable: {type: boolean}
|
||||||
pickupReceivable: {type: boolean}
|
pickupReceivable: {type: boolean}
|
||||||
@@ -115,6 +116,7 @@ components:
|
|||||||
category: {type: string}
|
category: {type: string}
|
||||||
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
|
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
|
||||||
feeCode: {type: string}
|
feeCode: {type: string}
|
||||||
|
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
|
||||||
feeTypeName: {type: string}
|
feeTypeName: {type: string}
|
||||||
pickupPayable: {type: boolean}
|
pickupPayable: {type: boolean}
|
||||||
pickupReceivable: {type: boolean}
|
pickupReceivable: {type: boolean}
|
||||||
|
|||||||
@@ -838,7 +838,7 @@ paths:
|
|||||||
summary: 调整已审核柜次费用的日期和金额
|
summary: 调整已审核柜次费用的日期和金额
|
||||||
description: >-
|
description: >-
|
||||||
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
|
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
|
||||||
PENDING;费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照。身份或结算字段需要变化时,
|
PENDING;费用方向、费用项目、供应商、币种和备注保持原快照。业务字段需要变化时,
|
||||||
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
|
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
|
||||||
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
|
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
|
||||||
parameters:
|
parameters:
|
||||||
@@ -1446,10 +1446,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||||
planNo: {type: string, nullable: true, description: 装柜清单号;未创建计划时为空}
|
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
||||||
bookingNo: {type: string, maxLength: 40}
|
bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号}
|
||||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||||
cabinetNo: {type: string, maxLength: 64}
|
cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 平台稳定柜次号}
|
||||||
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
||||||
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
|
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
|
||||||
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
|
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
|
||||||
@@ -1580,7 +1580,7 @@ components:
|
|||||||
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
|
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
|
||||||
properties:
|
properties:
|
||||||
id: {type: string}
|
id: {type: string}
|
||||||
planNo: {type: string}
|
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
|
||||||
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
||||||
bookingId: {type: string}
|
bookingId: {type: string}
|
||||||
containerNo: {type: string, nullable: true}
|
containerNo: {type: string, nullable: true}
|
||||||
@@ -1692,7 +1692,6 @@ components:
|
|||||||
publicationStatus: {type: string, enum: [PUBLISHED]}
|
publicationStatus: {type: string, enum: [PUBLISHED]}
|
||||||
publishedAt: {type: string, format: date-time}
|
publishedAt: {type: string, format: date-time}
|
||||||
inboundedAt: {type: string, format: date-time, nullable: true}
|
inboundedAt: {type: string, format: date-time, nullable: true}
|
||||||
settlementIncluded: {type: boolean, example: true}
|
|
||||||
InboundOrderPageEnvelope:
|
InboundOrderPageEnvelope:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -294,9 +294,9 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
planId: {$ref: '#/components/schemas/LongId'}
|
planId: {$ref: '#/components/schemas/LongId'}
|
||||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||||
preloadNo: {type: string, description: 预装单号}
|
preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
||||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||||
cabinetNo: {type: string, nullable: true, description: 稳定柜次号;不得与真实集装箱号混用}
|
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号;不得与真实集装箱号混用}
|
||||||
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
||||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||||
@@ -351,11 +351,11 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
planId: {$ref: '#/components/schemas/LongId'}
|
planId: {$ref: '#/components/schemas/LongId'}
|
||||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||||
loadingListNo: {type: string, description: 装柜清单号}
|
loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号}
|
||||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||||
cabinetNo: {type: string, nullable: true, description: 稳定柜次号}
|
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号}
|
||||||
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
|
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
|
||||||
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
|
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
|
||||||
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
|
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
|
||||||
|
|||||||
@@ -94,4 +94,15 @@ components:
|
|||||||
channelName: {type: string}
|
channelName: {type: string}
|
||||||
priceModel: {type: string}
|
priceModel: {type: string}
|
||||||
pricingTaxModes: {type: array, items: {type: string}}
|
pricingTaxModes: {type: array, items: {type: string}}
|
||||||
Detail: {type: object, required: [oceanBooking, parties, containerRequirements, containers, histories], properties: {oceanBooking: {type: object}, parties: {type: array}, containerRequirements: {type: array}, containers: {type: array, description: Existing containers allocated after port pickup linking}, histories: {type: array}}}
|
Detail:
|
||||||
|
type: object
|
||||||
|
required: [oceanBooking, parties, containerRequirements, containers, histories]
|
||||||
|
properties:
|
||||||
|
oceanBooking:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号}
|
||||||
|
parties: {type: array}
|
||||||
|
containerRequirements: {type: array}
|
||||||
|
containers: {type: array, description: Existing containers allocated after port pickup linking}
|
||||||
|
histories: {type: array}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ components:
|
|||||||
exception: {$ref: '#/components/schemas/ExceptionState'}
|
exception: {$ref: '#/components/schemas/ExceptionState'}
|
||||||
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
|
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
|
||||||
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
|
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
|
||||||
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}}
|
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}}
|
||||||
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
|
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
|
||||||
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
|
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
|
||||||
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
|
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: {$ref: '#/components/schemas/SaveRequest'}
|
schema: {$ref: '#/components/schemas/SaveRequest'}
|
||||||
responses:
|
responses:
|
||||||
'200': {description: Created appointment}
|
'200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}}
|
||||||
/api/platform/v1/truck-appointments/{id}:
|
/api/platform/v1/truck-appointments/{id}:
|
||||||
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
|
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
|
||||||
get:
|
get:
|
||||||
@@ -182,13 +182,14 @@ components:
|
|||||||
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
|
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
|
||||||
DetailStableReferences:
|
DetailStableReferences:
|
||||||
type: object
|
type: object
|
||||||
required: [customerId, customerName, carrierId, carrierName, attachmentCount]
|
required: [appointmentNo, customerId, customerName, carrierId, carrierName, attachmentCount]
|
||||||
description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Fulfillment associations remain null for an independently created appointment.
|
description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Fulfillment associations remain null for an independently created appointment.
|
||||||
properties:
|
properties:
|
||||||
|
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
|
||||||
oceanBookingId: {type: integer, format: int64, nullable: true}
|
oceanBookingId: {type: integer, format: int64, nullable: true}
|
||||||
oceanBookingNo: {type: string, nullable: true}
|
oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001}
|
||||||
containerBookingId: {type: integer, format: int64, nullable: true}
|
containerBookingId: {type: integer, format: int64, nullable: true}
|
||||||
cabinetNo: {type: string, nullable: true}
|
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}
|
||||||
containerNo: {type: string, nullable: true}
|
containerNo: {type: string, nullable: true}
|
||||||
loadingPlanId: {type: integer, format: int64, nullable: true}
|
loadingPlanId: {type: integer, format: int64, nullable: true}
|
||||||
warehouseId: {type: integer, format: int64, nullable: true}
|
warehouseId: {type: integer, format: int64, nullable: true}
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
|
|||||||
.contains("ContainerFeeAdjustRequest:")
|
.contains("ContainerFeeAdjustRequest:")
|
||||||
.contains("required: [expectedSourceVersion, businessDate, amount]")
|
.contains("required: [expectedSourceVersion, businessDate, amount]")
|
||||||
.contains("additionalProperties: false")
|
.contains("additionalProperties: false")
|
||||||
.contains("费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照")
|
.contains("费用方向、费用项目、往来单位、币种和备注保持原快照")
|
||||||
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
|
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
|||||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||||
|
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -35,6 +36,7 @@ class OceanBookingServiceTest {
|
|||||||
@Mock private PriceBookQueryRepository priceBooks;
|
@Mock private PriceBookQueryRepository priceBooks;
|
||||||
@Mock private WarehouseSupplierSnapshotService suppliers;
|
@Mock private WarehouseSupplierSnapshotService suppliers;
|
||||||
@Mock private WarehouseMasterDataClient warehouses;
|
@Mock private WarehouseMasterDataClient warehouses;
|
||||||
|
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
@InjectMocks private OceanBookingService service;
|
@InjectMocks private OceanBookingService service;
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ class OceanBookingServiceTest {
|
|||||||
void setUp() throws Exception {
|
void setUp() throws Exception {
|
||||||
MockitoAnnotations.openMocks(this);
|
MockitoAnnotations.openMocks(this);
|
||||||
objectMapper = new ObjectMapper().findAndRegisterModules();
|
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
|
||||||
var field = OceanBookingService.class.getDeclaredField("objectMapper");
|
var field = OceanBookingService.class.getDeclaredField("objectMapper");
|
||||||
field.setAccessible(true);
|
field.setAccessible(true);
|
||||||
field.set(service, objectMapper);
|
field.set(service, objectMapper);
|
||||||
@@ -60,6 +63,7 @@ class OceanBookingServiceTest {
|
|||||||
new CreateRequest(0, partialDraft(null)));
|
new CreateRequest(0, partialDraft(null)));
|
||||||
|
|
||||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
|
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
|
||||||
|
assertThat(result.oceanBooking().getBookingNo()).isEqualTo("OB-260818-0001");
|
||||||
assertThat(inserted.get().getCustomerId()).isNull();
|
assertThat(inserted.get().getCustomerId()).isNull();
|
||||||
verify(repository).replaceRequirements(inserted.get().getId(), List.of());
|
verify(repository).replaceRequirements(inserted.get().getId(), List.of());
|
||||||
verify(repository, never()).insertContainer(any());
|
verify(repository, never()).insertContainer(any());
|
||||||
|
|||||||
+1
-1
@@ -22,6 +22,6 @@ class ContainerFeeDetailRepositoryTest {
|
|||||||
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
|
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
|
||||||
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
|
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
|
||||||
"getDirection", "getPartnerId", "getPartnerName", "getCurrency",
|
"getDirection", "getPartnerId", "getPartnerName", "getCurrency",
|
||||||
"getSettlementIncluded", "getRemark");
|
"getRemark");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ class FinanceFeeSourceMigrationContractTest {
|
|||||||
assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash",
|
assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash",
|
||||||
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
|
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
|
||||||
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
|
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
|
||||||
"settlement_included", "source_version", "partner_id", "partner_name",
|
"source_version", "partner_id", "partner_name",
|
||||||
"firstleg_finance_add_column('fl_container_fee_detail', 'partner_id'", "RECEIVABLE", "PAYABLE",
|
"firstleg_finance_add_column('fl_container_fee_detail', 'partner_id'", "RECEIVABLE", "PAYABLE",
|
||||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'",
|
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'",
|
||||||
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整",
|
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class SmartLoadingSqlContractTest {
|
|||||||
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
|
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
|
||||||
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
|
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
|
||||||
assertThat(migration())
|
assertThat(migration())
|
||||||
|
.contains("CREATE TABLE IF NOT EXISTS fl_document_sequence")
|
||||||
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
|
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
|
||||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
|
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
|
||||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
|
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
|
||||||
|
|||||||
+4
-7
@@ -59,7 +59,6 @@ class ContainerFeeDetailServiceTest {
|
|||||||
approved.setTenantId(10001L);
|
approved.setTenantId(10001L);
|
||||||
approved.setLoadingPlanId(41L);
|
approved.setLoadingPlanId(41L);
|
||||||
approved.setSourceVersion(1);
|
approved.setSourceVersion(1);
|
||||||
approved.setSettlementIncluded(true);
|
|
||||||
when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
|
when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
|
||||||
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
|
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
|
||||||
|
|
||||||
@@ -92,7 +91,6 @@ class ContainerFeeDetailServiceTest {
|
|||||||
assertThat(result.getDirection()).isEqualTo("PAYABLE");
|
assertThat(result.getDirection()).isEqualTo("PAYABLE");
|
||||||
assertThat(result.getPartnerId()).isEqualTo(71L);
|
assertThat(result.getPartnerId()).isEqualTo(71L);
|
||||||
assertThat(result.getCurrency()).isEqualTo("CNY");
|
assertThat(result.getCurrency()).isEqualTo("CNY");
|
||||||
assertThat(result.getSettlementIncluded()).isTrue();
|
|
||||||
assertThat(result.getRemark()).isEqualTo("原费用备注");
|
assertThat(result.getRemark()).isEqualTo("原费用备注");
|
||||||
verify(feeItems, never()).findItem(anyLong());
|
verify(feeItems, never()).findItem(anyLong());
|
||||||
}
|
}
|
||||||
@@ -170,7 +168,7 @@ class ContainerFeeDetailServiceTest {
|
|||||||
|
|
||||||
var missingPartner = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
|
var missingPartner = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
|
||||||
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
|
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
|
||||||
true, null, null, "整柜拖车费");
|
null, null, "整柜拖车费");
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingPartner))
|
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingPartner))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效往来单位");
|
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效往来单位");
|
||||||
@@ -184,7 +182,7 @@ class ContainerFeeDetailServiceTest {
|
|||||||
|
|
||||||
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
|
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
|
||||||
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
|
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
|
||||||
true, 71L, "不应保留", "整柜操作费");
|
71L, "不应保留", "整柜操作费");
|
||||||
service.create(10001L, 41L, 9L, request);
|
service.create(10001L, 41L, 9L, request);
|
||||||
|
|
||||||
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
|
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
|
||||||
@@ -225,7 +223,6 @@ class ContainerFeeDetailServiceTest {
|
|||||||
value.setPartnerId(71L);
|
value.setPartnerId(71L);
|
||||||
value.setPartnerName("现有往来单位");
|
value.setPartnerName("现有往来单位");
|
||||||
value.setCurrency("CNY");
|
value.setCurrency("CNY");
|
||||||
value.setSettlementIncluded(true);
|
|
||||||
value.setRemark("原费用备注");
|
value.setRemark("原费用备注");
|
||||||
value.setBusinessDate(LocalDate.of(2026, 8, 11));
|
value.setBusinessDate(LocalDate.of(2026, 8, 11));
|
||||||
value.setAmount(new BigDecimal("300.00"));
|
value.setAmount(new BigDecimal("300.00"));
|
||||||
@@ -239,9 +236,9 @@ class ContainerFeeDetailServiceTest {
|
|||||||
return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount));
|
return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
|
private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) {
|
||||||
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
|
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
|
||||||
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
|
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
|
||||||
settlementIncluded, 71L, " 现有往来单位 ", "整柜拖车费");
|
71L, " 现有往来单位 ", "整柜拖车费");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,11 +54,14 @@ class ContainerServiceTest {
|
|||||||
@MockBean private PriceBookQueryRepository priceBooks;
|
@MockBean private PriceBookQueryRepository priceBooks;
|
||||||
@MockBean private OceanBookingRepository oceanBookings;
|
@MockBean private OceanBookingRepository oceanBookings;
|
||||||
@MockBean private WarehouseSupplierSnapshotService suppliers;
|
@MockBean private WarehouseSupplierSnapshotService suppliers;
|
||||||
|
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
@MockBean private ContainerTransportHistoryMapper transportHistories;
|
@MockBean private ContainerTransportHistoryMapper transportHistories;
|
||||||
@Resource private ContainerService service;
|
@Resource private ContainerService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUpWarehouse() {
|
void setUpWarehouse() {
|
||||||
|
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
|
||||||
|
when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001");
|
||||||
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
|
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
|
||||||
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
|
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
|
||||||
when(suppliers.trucking(202L, "拖车公司A"))
|
when(suppliers.trucking(202L, "拖车公司A"))
|
||||||
@@ -77,8 +80,8 @@ class ContainerServiceTest {
|
|||||||
verify(repository).insert(captor.capture());
|
verify(repository).insert(captor.capture());
|
||||||
ContainerBookingEntity saved = captor.getValue();
|
ContainerBookingEntity saved = captor.getValue();
|
||||||
assertThat(created).isSameAs(saved);
|
assertThat(created).isSameAs(saved);
|
||||||
assertThat(saved.getBookingNo()).startsWith("CB");
|
assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001");
|
||||||
assertThat(saved.getCabinetNo()).startsWith("CAB-");
|
assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001");
|
||||||
assertThat(saved.getContainerNo()).isNull();
|
assertThat(saved.getContainerNo()).isNull();
|
||||||
assertThat(saved.getOceanBookingId()).isNull();
|
assertThat(saved.getOceanBookingId()).isNull();
|
||||||
assertThat(saved.getChannelName()).isEqualTo("美西海运");
|
assertThat(saved.getChannelName()).isEqualTo("美西海运");
|
||||||
|
|||||||
@@ -220,7 +220,6 @@ class FeeManagementServiceTest {
|
|||||||
assertThat(saved.getUnitPrice()).isEqualByComparingTo("2.1198");
|
assertThat(saved.getUnitPrice()).isEqualByComparingTo("2.1198");
|
||||||
assertThat(saved.getReviewStatus()).isEqualTo("APPROVED");
|
assertThat(saved.getReviewStatus()).isEqualTo("APPROVED");
|
||||||
assertThat(saved.getPublicationStatus()).isEqualTo("PUBLISHED");
|
assertThat(saved.getPublicationStatus()).isEqualTo("PUBLISHED");
|
||||||
assertThat(saved.getSettlementIncluded()).isTrue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -324,16 +323,24 @@ class FeeManagementServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("审核只更新待审核明细")
|
@DisplayName("审核只更新待审核明细")
|
||||||
void approvesPendingDetail() {
|
void approvesPendingDetail() {
|
||||||
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(new InboundFeeDetailEntity());
|
InboundFeeDetailEntity pending = new InboundFeeDetailEntity();
|
||||||
|
pending.setId(31L);
|
||||||
|
pending.setTenantId(10001L);
|
||||||
|
pending.setInboundId(21L);
|
||||||
|
pending.setSourceType("MANUAL");
|
||||||
|
pending.setSourceFeeVersion(1);
|
||||||
|
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending);
|
||||||
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
|
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
|
||||||
|
|
||||||
details.approve(10001L, 21L, 31L, 9L, "admin");
|
details.approve(10001L, 21L, 31L, 9L, "admin");
|
||||||
|
|
||||||
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
|
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
|
||||||
|
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
|
||||||
|
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("审核通过且参与结算的人工费用写入Finance事件")
|
@DisplayName("审核通过的人工费用自动写入Finance事件")
|
||||||
void publishesApprovedSettlementFee() {
|
void publishesApprovedSettlementFee() {
|
||||||
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
|
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
|
||||||
approved.setId(31L);
|
approved.setId(31L);
|
||||||
@@ -342,7 +349,6 @@ class FeeManagementServiceTest {
|
|||||||
approved.setFeeItemId(11L);
|
approved.setFeeItemId(11L);
|
||||||
approved.setSourceType("MANUAL");
|
approved.setSourceType("MANUAL");
|
||||||
approved.setSourceFeeVersion(1);
|
approved.setSourceFeeVersion(1);
|
||||||
approved.setSettlementIncluded(true);
|
|
||||||
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
|
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
|
||||||
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
|
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
|
||||||
|
|
||||||
@@ -409,7 +415,6 @@ class FeeManagementServiceTest {
|
|||||||
current.setFeeItemId(11L);
|
current.setFeeItemId(11L);
|
||||||
current.setSourceType("MANUAL");
|
current.setSourceType("MANUAL");
|
||||||
current.setSourceFeeVersion(1);
|
current.setSourceFeeVersion(1);
|
||||||
current.setSettlementIncluded(true);
|
|
||||||
InboundFeeDetailEntity voided = new InboundFeeDetailEntity();
|
InboundFeeDetailEntity voided = new InboundFeeDetailEntity();
|
||||||
voided.setId(31L);
|
voided.setId(31L);
|
||||||
voided.setTenantId(10001L);
|
voided.setTenantId(10001L);
|
||||||
@@ -417,7 +422,6 @@ class FeeManagementServiceTest {
|
|||||||
voided.setFeeItemId(11L);
|
voided.setFeeItemId(11L);
|
||||||
voided.setSourceType("MANUAL");
|
voided.setSourceType("MANUAL");
|
||||||
voided.setSourceFeeVersion(1);
|
voided.setSourceFeeVersion(1);
|
||||||
voided.setSettlementIncluded(true);
|
|
||||||
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided);
|
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided);
|
||||||
when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1);
|
when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1);
|
||||||
|
|
||||||
|
|||||||
+20
-21
@@ -3,10 +3,10 @@ package com.baiyue.firstleg.service;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
|
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
|
||||||
import com.baiyue.firstleg.entity.FeeItemEntity;
|
import com.baiyue.firstleg.entity.FeeItemEntity;
|
||||||
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
|
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||||
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
|
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
|
||||||
import com.baiyue.firstleg.repository.FeeItemRepository;
|
import com.baiyue.firstleg.repository.FeeItemRepository;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
class FinanceFeeSourceSnapshotServiceTest {
|
class FinanceFeeSourceSnapshotServiceTest {
|
||||||
private final FeeItemRepository inboundFees = mock(FeeItemRepository.class);
|
private final FeeItemRepository inboundFees = mock(FeeItemRepository.class);
|
||||||
private final ContainerFeeDetailRepository containerFees = mock(ContainerFeeDetailRepository.class);
|
private final ContainerFeeSourceSnapshotRepository containerFees = mock(ContainerFeeSourceSnapshotRepository.class);
|
||||||
private final FinanceFeeSourceSnapshotService service =
|
private final FinanceFeeSourceSnapshotService service =
|
||||||
new FinanceFeeSourceSnapshotService(inboundFees, containerFees);
|
new FinanceFeeSourceSnapshotService(inboundFees, containerFees);
|
||||||
|
|
||||||
@@ -37,8 +37,7 @@ class FinanceFeeSourceSnapshotServiceTest {
|
|||||||
detail.setCurrency("CNY");
|
detail.setCurrency("CNY");
|
||||||
detail.setReviewStatus("APPROVED");
|
detail.setReviewStatus("APPROVED");
|
||||||
detail.setPublicationStatus("PUBLISHED");
|
detail.setPublicationStatus("PUBLISHED");
|
||||||
detail.setSettlementIncluded(true);
|
when(inboundFees.findSourceVersion(21L, 31L, 2)).thenReturn(detail);
|
||||||
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
|
|
||||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||||
inbound.setId(21L);
|
inbound.setId(21L);
|
||||||
inbound.setOrderId(81L);
|
inbound.setOrderId(81L);
|
||||||
@@ -50,53 +49,53 @@ class FinanceFeeSourceSnapshotServiceTest {
|
|||||||
item.setFeeCode("STORAGE");
|
item.setFeeCode("STORAGE");
|
||||||
when(inboundFees.findItem(11L)).thenReturn(item);
|
when(inboundFees.findItem(11L)).thenReturn(item);
|
||||||
|
|
||||||
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L);
|
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L, 2);
|
||||||
|
|
||||||
assertThat(snapshot.customerId()).isEqualTo(10001L);
|
assertThat(snapshot.customerId()).isEqualTo(10001L);
|
||||||
assertThat(snapshot.orderId()).isEqualTo(81L);
|
assertThat(snapshot.orderId()).isEqualTo(81L);
|
||||||
assertThat(snapshot.feeVersion()).isEqualTo(2);
|
assertThat(snapshot.feeVersion()).isEqualTo(2);
|
||||||
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
|
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
|
||||||
detail.setPublicationStatus("VOIDED");
|
detail.setPublicationStatus("VOIDED");
|
||||||
assertThat(service.inbound(21L, 31L).sourceHash()).isEqualTo(snapshot.sourceHash());
|
assertThat(service.inbound(21L, 31L, 2).sourceHash()).isEqualTo(snapshot.sourceHash());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsFeeThatDoesNotBelongToInboundPath() {
|
void rejectsFeeThatDoesNotBelongToInboundPath() {
|
||||||
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
|
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
|
||||||
detail.setInboundId(22L);
|
detail.setInboundId(22L);
|
||||||
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
|
when(inboundFees.findSourceVersion(21L, 31L, 1)).thenReturn(detail);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.inbound(21L, 31L))
|
assertThatThrownBy(() -> service.inbound(21L, 31L, 1))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
|
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void containerSnapshotPublishesPayableCounterpartyIdentity() {
|
void containerSnapshotPublishesPayableCounterpartyIdentity() {
|
||||||
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
|
ContainerFeeSourceSnapshotEntity detail = new ContainerFeeSourceSnapshotEntity();
|
||||||
detail.setId(51L);
|
detail.setSourceFeeId(51L);
|
||||||
detail.setTenantId(10001L);
|
detail.setTenantId(10001L);
|
||||||
detail.setLoadingPlanId(41L);
|
detail.setLoadingPlanId(41L);
|
||||||
detail.setContainerBookingId(61L);
|
detail.setContainerBookingId(61L);
|
||||||
detail.setContainerNo("CONT-41");
|
detail.setContainerNo("CONT-41");
|
||||||
detail.setFeeItemId(11L);
|
detail.setFeeItemId(11L);
|
||||||
detail.setFeeItemCodeSnapshot("TRUCKING");
|
detail.setFeeItemCode("TRUCKING");
|
||||||
detail.setFeeItemNameSnapshot("拖车费");
|
detail.setFeeItemName("拖车费");
|
||||||
detail.setDirection("PAYABLE");
|
detail.setDirection("PAYABLE");
|
||||||
detail.setPartnerId(71L);
|
detail.setSupplierId(71L);
|
||||||
detail.setPartnerName("现有往来单位");
|
detail.setSupplierName("现有供应商");
|
||||||
|
detail.setSupplierCategory("TRUCKING");
|
||||||
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
|
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
|
||||||
detail.setAmount(new BigDecimal("300.00"));
|
detail.setAmount(new BigDecimal("300.00"));
|
||||||
detail.setCurrency("CNY");
|
detail.setCurrency("CNY");
|
||||||
detail.setReviewStatus("APPROVED");
|
detail.setReviewStatus("APPROVED");
|
||||||
detail.setSourceStatus("ACTIVE");
|
|
||||||
detail.setSourceVersion(2);
|
detail.setSourceVersion(2);
|
||||||
detail.setSettlementIncluded(true);
|
when(containerFees.find(41L, 51L, 2)).thenReturn(detail);
|
||||||
when(containerFees.find(41L, 51L)).thenReturn(detail);
|
|
||||||
|
|
||||||
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L);
|
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L, 2);
|
||||||
|
|
||||||
assertThat(snapshot.partnerId()).isEqualTo(71L);
|
assertThat(snapshot.supplierId()).isEqualTo(71L);
|
||||||
assertThat(snapshot.partnerName()).isEqualTo("现有往来单位");
|
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
|
||||||
|
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
|
||||||
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
|
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class FirstlegDocumentNumberGeneratorTest {
|
||||||
|
private static final Clock CLOCK = Clock.fixed(
|
||||||
|
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatsAllOperationalDocumentPrefixes() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
|
||||||
|
.thenReturn(1L, 1L, 1L, 1L, 1L);
|
||||||
|
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
||||||
|
|
||||||
|
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
|
||||||
|
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
|
||||||
|
assertThat(generator.next("CB")).isEqualTo("CB-260818-0001");
|
||||||
|
assertThat(generator.next("CAB")).isEqualTo("CAB-260818-0001");
|
||||||
|
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
|
||||||
|
|
||||||
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
|
||||||
|
eq("OB:20260818"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsupportedTypeAndDailyOverflow() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
||||||
|
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
|
||||||
|
|
||||||
|
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
||||||
|
assertThatThrownBy(() -> generator.next("CAB")).isInstanceOf(BizException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ class SmartLoadingServiceTest {
|
|||||||
@MockBean private LoadingPickService loadingPickService;
|
@MockBean private LoadingPickService loadingPickService;
|
||||||
@MockBean private LoadingModelBridgeService loadingModelBridge;
|
@MockBean private LoadingModelBridgeService loadingModelBridge;
|
||||||
@MockBean private SplitPreloadService splitPreloadService;
|
@MockBean private SplitPreloadService splitPreloadService;
|
||||||
|
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
@Resource private SmartLoadingService service;
|
@Resource private SmartLoadingService service;
|
||||||
|
|
||||||
@TestConfiguration
|
@TestConfiguration
|
||||||
@@ -73,6 +74,7 @@ class SmartLoadingServiceTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
|
when(documentNumbers.next("LP")).thenReturn("LP-260818-0001");
|
||||||
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
|
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
|
||||||
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
|
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
|
||||||
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
|
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
|
||||||
@@ -174,6 +176,22 @@ class SmartLoadingServiceTest {
|
|||||||
verify(repository, never()).insert(any(LoadingPlanEntity.class));
|
verify(repository, never()).insert(any(LoadingPlanEntity.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsPlanWithShortStableNumber() {
|
||||||
|
when(repository.booking(99L)).thenReturn(booking());
|
||||||
|
when(repository.planByBooking(99L)).thenReturn(null);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
LoadingPlanEntity value = invocation.getArgument(0);
|
||||||
|
value.setId(1L);
|
||||||
|
return 1;
|
||||||
|
}).when(repository).insert(any(LoadingPlanEntity.class));
|
||||||
|
when(repository.items(1L)).thenReturn(List.of());
|
||||||
|
|
||||||
|
Plan result = service.create(99L, "备注", 7L, "u");
|
||||||
|
|
||||||
|
assertThat(result.planNo()).isEqualTo("LP-260818-0001");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsExcessBoxes() {
|
void rejectsExcessBoxes() {
|
||||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||||
|
|||||||
@@ -47,11 +47,13 @@ class TruckAppointmentServiceTest {
|
|||||||
@Mock WarehouseSupplierSnapshotService suppliers;
|
@Mock WarehouseSupplierSnapshotService suppliers;
|
||||||
@Mock IamCustomerSnapshotService customers;
|
@Mock IamCustomerSnapshotService customers;
|
||||||
@Mock TruckAppointmentFileClient files;
|
@Mock TruckAppointmentFileClient files;
|
||||||
|
@Mock FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
|
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
|
||||||
@InjectMocks TruckAppointmentService service;
|
@InjectMocks TruckAppointmentService service;
|
||||||
|
|
||||||
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
|
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
|
||||||
service.internalToken = "test-internal-token";
|
service.internalToken = "test-internal-token";
|
||||||
|
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
|
||||||
when(customers.active(anyLong(), any())).thenAnswer(invocation ->
|
when(customers.active(anyLong(), any())).thenAnswer(invocation ->
|
||||||
new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7",
|
new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7",
|
||||||
"Canonical Customer", "ACTIVE", null, null));
|
"Canonical Customer", "ACTIVE", null, null));
|
||||||
@@ -107,6 +109,7 @@ class TruckAppointmentServiceTest {
|
|||||||
assertThat(entity.getValue().getLoadingPlanId()).isNull();
|
assertThat(entity.getValue().getLoadingPlanId()).isNull();
|
||||||
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
|
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
|
||||||
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
|
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
|
||||||
|
assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001");
|
||||||
verifyNoInteractions(oceanBookings, containers, loadingPlans);
|
verifyNoInteractions(oceanBookings, containers, loadingPlans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user