完善财务费用来源与柜级分摊

This commit is contained in:
2026-09-02 17:07:03 +08:00
parent 6cd477a037
commit 3bdaba2c60
34 changed files with 625 additions and 71 deletions
@@ -16,8 +16,8 @@ public class OverseasFeeBusinessGuard {
if (fee.businessId() == null) {
if (!"WAREHOUSE_SERVICE".equals(fee.businessType()))
throw bad("业务记录ID不能为空");
if (fee.warehouseId() == null || (fee.inboundId() == null && blank(fee.inboundCode())))
throw bad("海外仓服务费必须关联仓库以及入仓记录或入仓码");
if (fee.warehouseId() == null)
throw bad("海外仓服务费必须关联海外仓;入仓记录和入仓码按实际业务选填");
return;
}
Table table = switch (fee.businessType()) {
@@ -28,7 +28,7 @@ public class OverseasFeeOutboxService {
@Resource private ObjectMapper objectMapper;
public void publish(OverseasFeeSource source, String action) {
BusinessFeeSourceChangedV1 unsigned = payload(source, action, null);
BusinessFeeSourceChangedV1 unsigned = payload(source, null, null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = payload(source, action, sourceHash);
@@ -67,7 +67,7 @@ public class OverseasFeeOutboxService {
value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(),
value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(),
value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(),
value.getPublicationStatus(), value.getSourceStatus(), hash);
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash);
}
private static String text(Long value) { return value == null ? null : String.valueOf(value); }
@@ -0,0 +1,50 @@
openapi: 3.0.3
info: { title: Overseas Business Fee Source API, version: 1.0.0 }
paths:
/api/platform/v1/last-leg/finance-fee-sources:
get:
summary: 查询海外业务费用来源当前版本
security: [{ bearerAuth: [] }]
parameters:
- { in: query, name: customerId, schema: { type: string } }
- { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } }
- { in: query, name: businessNo, schema: { type: string } }
responses: { '200': { description: 当前版本列表 } }
post:
summary: 创建海外业务费用来源草稿
description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。
security: [{ bearerAuth: [] }]
parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } }
responses: { '200': { description: 草稿费用来源 }, '400': { description: 业务归属或付款事实无效 } }
/api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}:
post:
summary: 提交、审核、调整或作废海外业务费用来源
description: 审核通过与作废均事务性写入Outbox,经Event Bridge可靠投递至独立Finance;action为submit、review、adjust、void。
security: [{ bearerAuth: [] }]
parameters:
- { in: path, name: sourceFeeId, required: true, schema: { type: string } }
- { in: path, name: action, required: true, schema: { type: string, enum: [submit, review, adjust, void] } }
responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } }
components:
schemas:
BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE] }
SaveRequest:
type: object
required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount]
properties:
customerId: { type: string }
sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE] }
businessType: { $ref: '#/components/schemas/BusinessType' }
businessId: { type: string, nullable: true }
businessNo: { type: string, maxLength: 128 }
warehouseId: { type: string, nullable: true }
inboundId: { type: string, nullable: true }
inboundCode: { type: string, maxLength: 64, nullable: true }
feeItemCode: { type: string }
feeItemName: { type: string }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
businessDate: { type: string, format: date }
currency: { type: string, enum: [CNY] }
amount: { type: number, format: decimal, exclusiveMinimum: 0 }
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } }
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
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 OverseasFeeOutboxServiceTest {
private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final OverseasFeeOutboxService service = new OverseasFeeOutboxService();
@Test
void publishesCompleteFeeSnapshotWithOptionalInboundReference() throws Exception {
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "objectMapper", json);
when(mapper.findByEventKey(anyString())).thenReturn(null);
OverseasFeeSource source = new OverseasFeeSource();
source.setSourceFeeId(701L); source.setSourceVersion(1); source.setCustomerId("10001");
source.setCustomerName("客户A"); source.setSourceType("OVERSEAS_PROCUREMENT_FEE");
source.setSourceNo("PO-US-001"); source.setBusinessType("OVERSEAS_PROCUREMENT");
source.setBusinessId(801L); source.setBusinessNo("PO-US-001"); source.setWarehouseId(91L);
source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费");
source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2));
source.setCurrency("CNY"); source.setAmount(new BigDecimal("120.00"));
source.setReviewStatus("APPROVED"); source.setPublicationStatus("PUBLISHED");
source.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of());
service.publish(source, "PUBLISHED");
ArgumentCaptor<LastlegEventOutbox> row = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(row.capture());
EventEnvelope envelope = json.readValue(row.getValue().getPayloadJson(), EventEnvelope.class).requireValid();
BusinessFeeSourceChangedV1 payload = json.treeToValue(envelope.payload(), BusinessFeeSourceChangedV1.class);
assertThat(payload.customerId()).isEqualTo("10001");
assertThat(payload.businessNo()).isEqualTo("PO-US-001");
assertThat(payload.inboundId()).isNull();
assertThat(payload.inboundCode()).isNull();
assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71);
}
}