Compare commits

..
Author SHA1 Message Date
liuyihe 3f8a0df433 费用提交 2026-10-26 15:35:14 +08:00
8 changed files with 39 additions and 106 deletions
@@ -219,7 +219,6 @@ public class FinanceQueryService {
row.outstandingAmount(), row.currency(), row.status(), row.version())).toList();
}
/** 成本查询按来源付款事实回显金额,直接已付不能再次展示为待付款。 */
public List<PayableSettlement> payableSettlements(Long customerId) {
validateOptionalCustomer(customerId);
String sql = "SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE 1=1";
@@ -228,13 +227,9 @@ public class FinanceQueryService {
return jdbc.query(sql + " ORDER BY created_at DESC,id DESC",
(rs, rowNum) -> {
BigDecimal amount = rs.getBigDecimal("cost_amount");
boolean directPaid = "SOURCE_DIRECT_PAID".equals(rs.getString("cost_type"));
// 已确认直付成本已全额付款;作废历史不再计入有效已付,也不形成新应付。
BigDecimal paid = directPaid && "CONFIRMED".equals(rs.getString("status")) ? amount : BigDecimal.ZERO;
BigDecimal outstanding = directPaid ? BigDecimal.ZERO : amount;
return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"),
rs.getString("fee_item_name"), amount, paid, outstanding, rs.getString("currency_code"), rs.getString("status"),
rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"),
time(rs, "created_at"), rs.getInt("version"));
}, args);
}
@@ -1,89 +0,0 @@
package com.baiyue.finance;
import com.baiyue.finance.service.FinanceQueryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** 成本列表和详情须与直接已付事实一致,不能提示供应商费用仍待支付。 */
class FinanceDirectPaidCostReadTest {
private JdbcTemplate jdbc;
private FinanceQueryService service;
/** 使用隔离内存表验证正式读取路径,不访问共享测试环境。 */
@BeforeEach
void setUp() {
jdbc = new JdbcTemplate(new DriverManagerDataSource(
"jdbc:h2:mem:cost-read-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
jdbc.execute("CREATE TABLE fin_cost(id BIGINT PRIMARY KEY,tenant_id BIGINT,cost_no VARCHAR(128),"
+ "cost_type VARCHAR(24),cost_origin VARCHAR(24),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,"
+ "supplier_name_snapshot VARCHAR(128),fee_item_name VARCHAR(128),cost_amount DECIMAL(18,2),"
+ "currency_code VARCHAR(3),status VARCHAR(24),created_at TIMESTAMP,version INT)");
jdbc.execute("CREATE TABLE fin_cost_allocation(id BIGINT,tenant_id BIGINT,cost_id BIGINT,order_id BIGINT,"
+ "inbound_id BIGINT,volume_cbm DECIMAL(18,6),total_volume_cbm DECIMAL(18,6),allocation_rate DECIMAL(18,6),"
+ "allocated_amount DECIMAL(18,2),rounding_adjustment BOOLEAN)");
service = new FinanceQueryService(jdbc, new ObjectMapper());
}
/** 头程与海外直付均已付款,列表和详情不能再次显示未付余额。 */
@ParameterizedTest
@ValueSource(strings = {"BUSINESS_DIRECT_PAID", "FIRSTLEG_DIRECT_PAID"})
void confirmedDirectPaidCostIsFullyPaidInListAndDetail(String origin) {
insertCost(1, 11, "SOURCE_DIRECT_PAID", origin, "CONFIRMED");
var row = service.payableSettlements(11L).get(0);
assertThat(row.payableAmount()).isEqualByComparingTo("0.03");
assertThat(row.paidAmount()).isEqualByComparingTo("0.03");
assertThat(row.outstandingAmount()).isZero();
assertThat(service.cost(11, 1).cost()).isEqualTo(row);
}
/** 已作废的直付历史保留原金额,但不再作为有效已付或待付款展示。 */
@Test
void voidedDirectPaidCostHasNoActivePaymentOrOutstandingAmount() {
insertCost(1, 11, "SOURCE_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "VOIDED");
var row = service.payableSettlements(11L).get(0);
assertThat(row.payableAmount()).isEqualByComparingTo("0.03");
assertThat(row.paidAmount()).isZero();
assertThat(row.outstandingAmount()).isZero();
assertThat(row.status()).isEqualTo("VOIDED");
}
/** 人工补充成本没有直接已付事实,不能被自动视为付款完成。 */
@ParameterizedTest
@ValueSource(strings = {"SINGLE_ORDER", "CONTAINER_SHARED"})
void manualCostKeepsItsExistingUnpaidAmounts(String type) {
insertCost(1, 11, type, "MANUAL", "CONFIRMED");
var row = service.payableSettlements(11L).get(0);
assertThat(row.paidAmount()).isZero();
assertThat(row.outstandingAmount()).isEqualByComparingTo("0.03");
}
/** 金额回显修复不能让其他客户读到成本列表或详情。 */
@Test
void costReadsKeepCustomerIsolation() {
insertCost(1, 11, "SOURCE_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "CONFIRMED");
assertThat(service.payableSettlements(12L)).isEmpty();
assertThatThrownBy(() -> service.cost(12, 1)).hasMessageContaining("成本不存在或不属于客户");
}
/** 仅构造测试所需的成本事实,金额对应本轮发现问题的小额样本。 */
private void insertCost(long id, long customer, String type, String origin, String status) {
jdbc.update("INSERT INTO fin_cost(id,tenant_id,cost_no,cost_type,cost_origin,order_id,container_id,"
+ "supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version) "
+ "VALUES(?,?,?,?,?,101,201,'验收供应商','人工费',0.03,'CNY',?,CURRENT_TIMESTAMP,0)",
id, customer, "COST-READ-TEST", type, origin, status);
}
}
@@ -2,7 +2,7 @@
2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。
- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;待审核且无购买、收货事实时平台可核定方向,审核通过后不再通过通用编辑修改方向。
- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;平台审核仅展示客户下单方向,不允许审核改向;采购仓按该方向筛选。审核底部直接取消、拒绝或通过,拒绝必须填写具体原因。
- 起运收货仓按方向读取 Warehouse 权威公共启用仓目录,国内方向只接受 DOMESTIC,进口方向保持海外 RECEIVING 能力约束。实际收货、计划仓更改、实际仓更正及面单寄件地址均校验同一方向。
- 出口流程:审核 → 国内购买 → 国内分次实收并确认 → 装箱 → 国际面单、预估运费冻结、贴单 → 国内国际交寄 → 海外分次实收并确认 → 海外核定实际费用 → 寄海外客户。正常终态仍为 SHIPPED_TO_CUSTOMER,取消、拒绝、异常关闭规则保留。
- 此次沿用单商品、一次购买、两段各整批交寄及 Admin 台账,不新增 WMS 库存、PDA 入账、分批购买或分批交寄。目的地接收地址沿用国际面单地址快照,最终客户地址由下单提供,最终面单变更仍需客户凭证。
@@ -234,8 +234,11 @@ public class OverseasProcurementService {
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
String direction = request.procurementDirection() == null ? ProcurementDirection.normalize(order.getProcurementDirection())
: ProcurementDirection.normalize(request.procurementDirection());
if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection())) && directionRepository.hasStarted(order.getId())) {
throw conflict("本单已有购买或收货事实,不能切换采购方向");
if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection()))) {
throw conflict("审核不能修改采购方向,请按客户下单方向选择采购仓");
}
if (REJECTED.equals(next) && trim(request.remark()) == null) {
throw badRequest("请填写拒绝原因");
}
Map<String, Object> before = reviewSnapshot(order);
if (WAIT_PURCHASE.equals(next)) {
@@ -5055,7 +5055,7 @@ components:
ProcurementReview:
type: object
required: [version, decision, idempotencyKey]
description: 审核通过必须选择与采购方向匹配的有效公共收货仓;国内采购选择国内仓,海外采购选择海外收货仓;拒绝不要求仓库。已有购买或收货事实不得切换方向。
description: 审核通过必须选择与采购方向匹配的有效公共收货仓;国内采购选择国内仓,海外采购选择海外收货仓;拒绝不要求仓库,但 remark 必须填写非空白拒绝原因。审核不得修改客户下单采购方向。
properties:
procurementDirection: { $ref: '#/components/schemas/ProcurementDirection' }
version: { type: integer, format: int64, minimum: 0 }
@@ -215,7 +215,7 @@ class DispatchMergedRelabelPersistenceTest {
assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero();
}
/** 隔离测量、车队及仓租门禁;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
/** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
private static final class Merged {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchService rules=new DispatchService();final DispatchPickingService picking;final DispatchPdaQueryService pda;
@@ -261,7 +261,6 @@ class DispatchMergedRelabelPersistenceTest {
ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins);
picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json);
ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots);
ReflectionTestUtils.setField(picking,"storageRent",mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins);
balance(601,"101",5);position(101,601,"101",crossCustomer?2:5);
if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);}
@@ -195,6 +195,7 @@ class OverseasProcurementServiceTest {
@Test void exportReviewPersistsDirectionAndCannotReplayWithChangedDirection() {
var order = order(OverseasProcurementService.PENDING_REVIEW,0,0);
order.setProcurementDirection(ProcurementDirection.EXPORT);
when(orders.lockNo("100","HC1")).thenReturn(order);
when(orders.findNo("100","HC1")).thenReturn(order);
when(histories.listOrder("100",1L)).thenReturn(List.of());
@@ -221,7 +222,7 @@ class OverseasProcurementServiceTest {
@Test void pendingReviewCannotReverseDirectionOfAnAlreadyRecordedPurchase() {
var order = order(OverseasProcurementService.PENDING_REVIEW,0,0);
when(orders.lockNo("100","HC1")).thenReturn(order);
when(directionRepository.hasStarted(1L)).thenReturn(true);
assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7",
new OverseasProcurementRequests.Review(0L,"APPROVE","export-after-purchase","checked",10L,ProcurementDirection.EXPORT)));
verify(warehouseDirectory,never()).required(10L,ProcurementDirection.EXPORT);
@@ -239,4 +240,30 @@ class OverseasProcurementServiceTest {
assertEquals("US, California, test destination",detail.receivingAddress());
assertEquals(OverseasProcurementService.PENDING_REVIEW,detail.summary().status());
}
@Test void rejectionRequiresSpecificNonblankReasonWithoutAWarehouse() {
var order = order(OverseasProcurementService.PENDING_REVIEW,0,0);
when(orders.lockNo("100","HC1")).thenReturn(order);
for (String reason : new String[]{null,""," "}) {
var error = assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7",
new OverseasProcurementRequests.Review(0L,"REJECT","reject-empty",reason,null)));
assertEquals("请填写拒绝原因",error.getReason());
}
assertEquals(OverseasProcurementService.PENDING_REVIEW,order.getStatus());
when(orders.findNo("100","HC1")).thenReturn(order);
when(histories.listOrder("100",1L)).thenReturn(List.of());
var result=service.review("100","HC1","7",new OverseasProcurementRequests.Review(0L,"REJECT","reject-reason","供应商无法供货",null));
assertEquals(OverseasProcurementService.REJECTED,result.summary().status());
assertEquals("供应商无法供货",result.reviewRemark());
verify(warehouseDirectory,never()).required(10L);
}
@Test void reviewCannotChangeDirectionEvenBeforePurchase() {
var order = order(OverseasProcurementService.PENDING_REVIEW,0,0);
order.setProcurementDirection(ProcurementDirection.EXPORT);
when(orders.lockNo("100","HC1")).thenReturn(order);
var error=assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7",
new OverseasProcurementRequests.Review(0L,"APPROVE","change-direction","checked",10L,ProcurementDirection.IMPORT)));
assertEquals("审核不能修改采购方向,请按客户下单方向选择采购仓",error.getReason());
assertEquals(ProcurementDirection.EXPORT,order.getProcurementDirection());
}
}
@@ -424,10 +424,9 @@ class LastlegOrderServiceTest {
}
@Test
void departedPageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null,
null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null,
LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null);
null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null, null, null, null);
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(3, 25);
resultPage.setTotal(0);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
@@ -439,7 +438,6 @@ class LastlegOrderServiceTest {
verify(orders).selectPage(page.capture(), any());
assertEquals(3, page.getValue().getCurrent());
assertEquals(25, page.getValue().getSize());
verifyNoInteractions(preDeparture);
}