This commit is contained in:
2026-08-11 13:55:16 +08:00
parent 40171b7047
commit 7c2274be31
9 changed files with 320 additions and 8 deletions
@@ -0,0 +1,17 @@
package com.baiyue.finance.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
@FeignClient(name="firstleg-service", contextId="financeFirstlegCostSnapshotClient", url="${baiyue.clients.firstleg.url:}", path="/internal/firstleg/v1/finance-cost-snapshots")
public interface FirstlegCostSnapshotClient {
@GetMapping("/customers/{customerId}/containers/{containerId}")
ApiResponse<ContainerSnapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long customerId, @PathVariable long containerId);
record ContainerSnapshot(long containerId, long customerId, List<OrderVolume> orders) {}
record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
}
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -22,11 +23,12 @@ import org.springframework.web.multipart.MultipartFile;
public class PlatformFinanceController {
private final FinanceService service;
private final FinanceFileClient fileClient;
private final FirstlegCostSnapshotClient costSnapshotClient;
@Value("${baiyue.security.internal-service-token:}") private String fileToken;
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient) { this.service = service; this.fileClient = fileClient; }
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient, FirstlegCostSnapshotClient costSnapshotClient) { this.service = service; this.fileClient = fileClient; this.costSnapshotClient = costSnapshotClient; }
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@PreAuthorize("hasAuthority('platform:finance:receipt:create')")
@PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')")
public ApiResponse<FinanceFileClient.Metadata> uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long customerId,
@RequestPart("file") MultipartFile file) {
@@ -71,6 +73,12 @@ public class PlatformFinanceController {
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
public ApiResponse<Map<String, Object>> bill(@PathVariable long billId) { return ApiResponse.success(service.billById(billId)); }
@GetMapping("/bills/{billId}/revisions")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) {
return ApiResponse.success(service.billRevisions(customerId, billId));
}
@GetMapping("/receipts")
@PreAuthorize("hasAuthority('platform:finance:receipt:view')")
public ApiResponse<?> receipts(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listReceipts(customerId)); }
@@ -100,7 +108,17 @@ public class PlatformFinanceController {
public ApiResponse<Map<String, Object>> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) {
if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY");
var internal = new FinanceService.CostRequest(null, request.costType(), request.orderId(), null, request.containerId(), null, null, "MANUAL", request.expenseName(), request.amount(), request.voucherFileIds(), request.remark());
return ApiResponse.success(service.createCost(request.customerId(), user.userId(), internal, key));
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
if ("CONTAINER_SHARED".equals(request.costType())) {
if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次");
var response = costSnapshotClient.container(fileToken, request.customerId(), request.containerId());
var snapshot = response == null ? null : response.data();
if (snapshot == null || snapshot.customerId() != request.customerId() || snapshot.containerId() != request.containerId() || snapshot.orders() == null || snapshot.orders().isEmpty()) {
throw new IllegalArgumentException("Firstleg未返回有效柜内订单体积快照");
}
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), v.inboundId(), snapshot.containerId(), snapshot.customerId(), v.volumeCbm())).toList();
}
return ApiResponse.success(service.createCost(request.customerId(), user.userId(), internal, volumes, key));
}
@PutMapping("/bills/{billId}")
@@ -74,6 +74,13 @@ public class FinanceService {
return bill(tenantId, billId);
}
public List<Map<String,Object>> billRevisions(long customerId, long billId) {
requireCustomer(customerId);
Number count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill WHERE id=? AND tenant_id=?", Number.class, billId, customerId);
if (count == null || count.intValue() != 1) throw bad("账单不存在或不属于客户");
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,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);
@@ -215,12 +222,14 @@ public class FinanceService {
@Transactional
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request,
List<ContainerOrderVolume> containerOrders, String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey, request);
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey, Map.of("cost", request, "containerOrders", containerOrders == null ? List.of() : containerOrders));
if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=? AND tenant_id=?", replay, tenantId);
if (!"SINGLE_ORDER".equals(request.costType()) && !"CONTAINER_SHARED".equals(request.costType())) throw bad("成本类型只能是SINGLE_ORDER或CONTAINER_SHARED");
if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0");
validateVoucherFiles(tenantId, request.voucherFileIds());
if ("SINGLE_ORDER".equals(request.costType()) && (request.orderId() == null || request.containerId() != null)) throw bad("单票成本必须指定订单且不能指定柜次");
if ("CONTAINER_SHARED".equals(request.costType()) && (request.containerId() == null || containerOrders == null || containerOrders.isEmpty())) throw bad("整柜成本必须指定柜次及柜内有效订单体积");
if ("SINGLE_ORDER".equals(request.costType())) validateSingleOrder(tenantId, request.orderId());
validateContainerOrders(tenantId, request.containerId(), containerOrders);
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
@@ -242,9 +251,16 @@ public class FinanceService {
|| row.volumeCbm() == null || row.volumeCbm().signum() <= 0 || !seen.add(row.orderId())) {
throw bad("柜次、订单、客户归属或体积无效");
}
Number exists = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source WHERE customer_id=? AND order_id=? AND status='AVAILABLE'", Number.class, tenantId, row.orderId());
if (exists != null && exists.intValue() == 0) throw bad("柜内订单不存在或不属于客户");
}
}
private void validateSingleOrder(long tenantId, long orderId) {
Number exists = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source WHERE customer_id=? AND order_id=? AND status='AVAILABLE'", Number.class, tenantId, orderId);
if (exists != null && exists.intValue() == 0) throw bad("单票订单不存在或不属于客户");
}
private void persistContainerAllocations(long tenantId, long operatorId, long costId, BigDecimal amount,
List<ContainerOrderVolume> rows) {
for (CostAllocation allocation : allocateContainerCost(amount, rows)) {
@@ -271,10 +287,11 @@ public class FinanceService {
ContainerOrderVolume row = rows.get(i);
boolean tail = i == rows.size() - 1;
BigDecimal rate = row.volumeCbm().divide(totalVolume, 10, java.math.RoundingMode.HALF_UP);
BigDecimal share = tail ? amount.subtract(allocated).setScale(6, java.math.RoundingMode.HALF_UP)
: amount.multiply(rate).setScale(6, java.math.RoundingMode.HALF_UP);
BigDecimal regularShare = amount.multiply(rate).setScale(6, java.math.RoundingMode.HALF_UP);
BigDecimal share = tail ? amount.subtract(allocated).setScale(6, java.math.RoundingMode.HALF_UP) : regularShare;
boolean roundingAdjustment = tail && share.compareTo(regularShare) != 0;
allocated = allocated.add(share);
result.add(new CostAllocation(row, totalVolume, rate, share, tail));
result.add(new CostAllocation(row, totalVolume, rate, share, roundingAdjustment));
}
return List.copyOf(result);
}