feat: complete finance and overseas receiving workflow

This commit is contained in:
2026-09-10 22:43:11 +08:00
parent e531ae9407
commit 56635ad4e4
66 changed files with 1601 additions and 417 deletions
+1
View File
@@ -36,6 +36,7 @@
- 所有客户查询在 Repository/Mapper 显式带 `tenant_id`;状态变更保留幂等、历史、审计和并发控制。
- `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。
- 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。
- 客户提交订单时除 IAM 审核资格外,还必须通过 Finance 资金账户校验;实际余额小于 0 时拒绝提交并提示充值。草稿保存不触发资金门禁,Finance 不可用或响应无效时提交失败关闭。
- Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。
- 智能装柜待审核候选只返回 Order 权威 `SUBMITTED` 订单,并按服务身份、客户/仓库/渠道、入仓码、创建日期、预计到货日期和分页条件查询;Firstleg 可据此提前占位,但不得把该内部查询或申报数量当作审核、测量或可物理装柜完成的事实。
- 中文批量模板按登录客户生成取货/私人地址、国内收货仓、目的仓和渠道下拉;仓库目录必须翻页读取全部候选,不得只取第一页。导入时重新读取并校验动态目录、客户归属和启用状态,不信任模板隐藏页;文件上限 5MB、有效数据最多 50 行,仅创建草稿,`PARTIAL` 允许单行失败、`ATOMIC` 任一行失败不得落单。
@@ -0,0 +1,23 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.time.LocalDate;
@FeignClient(name = "finance-service", contextId = "orderFinanceWalletClient",
url = "${baiyue.clients.finance.url:}", path = "/internal/finance/v1/wallet")
public interface FinanceWalletClient {
@GetMapping("/accounts/{customerId}")
ApiResponse<WalletAccount> account(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("customerId") long customerId);
record WalletAccount(BigDecimal actualBalance, BigDecimal availableBalance,
BigDecimal creditLimit, BigDecimal availableCredit,
LocalDate creditExpiresOn, boolean creditActive) { }
}
@@ -3,14 +3,18 @@ package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.TenantEligibilityClient;
import com.baiyue.order.client.FinanceWalletClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class OrderEligibilityGuard {
@Resource private TenantEligibilityClient client;
@Resource private FinanceWalletClient financeWalletClient;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public void requireApproved(long tenantId) {
@@ -32,6 +36,7 @@ public class OrderEligibilityGuard {
throw new BizException(ResultCode.FORBIDDEN,
response.data().reason() == null ? "Enterprise review approval is required" : response.data().reason());
}
requireNonNegativeActualBalance(tenantId);
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM eligibility service is unavailable");
} catch (BizException exception) {
@@ -41,4 +46,23 @@ public class OrderEligibilityGuard {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 下单资格响应无效");
}
}
private void requireNonNegativeActualBalance(long tenantId) {
try {
var response = financeWalletClient.account(internalToken, tenantId);
if (response == null || !response.successful() || response.data() == null
|| response.data().actualBalance() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Unable to verify customer account balance");
}
if (response.data().actualBalance().compareTo(BigDecimal.ZERO) < 0) {
throw new BizException(ResultCode.FORBIDDEN, "账户实际余额为负,请充值后再下单");
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Finance account service is unavailable");
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Finance account response is invalid");
}
}
}
@@ -3,11 +3,15 @@ package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FinanceWalletClient;
import com.baiyue.order.client.TenantEligibilityClient;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -48,4 +52,46 @@ class OrderEligibilityGuardTest {
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void negativeActualBalanceBlocksNewOrderSubmission() {
TenantEligibilityClient client = approvedClient();
FinanceWalletClient finance = mock(FinanceWalletClient.class);
when(finance.account("internal-token", 10001L)).thenReturn(ApiResponse.success(
new FinanceWalletClient.WalletAccount(new BigDecimal("-0.01"), new BigDecimal("99.99"),
new BigDecimal("100.00"), new BigDecimal("99.99"), LocalDate.now().plusDays(7), true)));
OrderEligibilityGuard guard = guard(client, finance);
assertThatThrownBy(() -> guard.requireApproved(10001L))
.isInstanceOf(BizException.class)
.hasMessage("账户实际余额为负,请充值后再下单")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
}
@Test
void zeroActualBalanceAllowsOrderWhileCreditIsActive() {
TenantEligibilityClient client = approvedClient();
FinanceWalletClient finance = mock(FinanceWalletClient.class);
when(finance.account("internal-token", 10001L)).thenReturn(ApiResponse.success(
new FinanceWalletClient.WalletAccount(BigDecimal.ZERO, new BigDecimal("100.00"),
new BigDecimal("100.00"), new BigDecimal("100.00"), LocalDate.now().plusDays(7), true)));
guard(client, finance).requireApproved(10001L);
}
private static TenantEligibilityClient approvedClient() {
TenantEligibilityClient client = mock(TenantEligibilityClient.class);
when(client.check("internal-token", 10001L)).thenReturn(ApiResponse.success(
new TenantEligibilityClient.EligibilityResponse(true, "ACTIVE", null)));
return client;
}
private static OrderEligibilityGuard guard(TenantEligibilityClient client, FinanceWalletClient finance) {
OrderEligibilityGuard guard = new OrderEligibilityGuard();
ReflectionTestUtils.setField(guard, "client", client);
ReflectionTestUtils.setField(guard, "financeWalletClient", finance);
ReflectionTestUtils.setField(guard, "internalToken", "internal-token");
return guard;
}
}