统一财务费用来源入池链路

This commit is contained in:
2026-09-09 16:39:20 +08:00
parent 9fdb199972
commit 17b9f3eb73
35 changed files with 442 additions and 195 deletions
@@ -12185,6 +12185,8 @@ CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'wallet_hold_id',
"BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID' AFTER operation_version");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmation_status',
@@ -18387,6 +18389,38 @@ DELIMITER ;
CALL migrate_container_workspace_permissions();
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
-- 财务 P0:海外费用原币、计费依据与钱包结算关联
DROP PROCEDURE IF EXISTS add_finance_p0_column;
DELIMITER $$
CREATE PROCEDURE add_finance_p0_column(
IN target_schema VARCHAR(64), IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN column_definition TEXT
)
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=target_schema AND table_name=target_table)
AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=target_schema AND table_name=target_table AND column_name=target_column) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_schema, '`.`', target_table, '` ADD COLUMN `', target_column, '` ', column_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','original_currency',"CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '原始业务币种' AFTER amount");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','original_amount',"DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '原币金额' AFTER original_currency");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','exchange_rate',"DECIMAL(20,8) NOT NULL DEFAULT 1 COMMENT '原币折算CNY汇率' AFTER original_amount");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','exchange_rate_month',"CHAR(7) NOT NULL DEFAULT '1970-01' COMMENT '汇率月份YYYY-MM' AFTER exchange_rate");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','chargeable_quantity',"DECIMAL(20,6) NULL COMMENT '计费数量' AFTER exchange_rate_month");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','quantity_unit',"VARCHAR(32) NULL COMMENT '计费单位' AFTER chargeable_quantity");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','unit_price',"DECIMAL(20,6) NULL COMMENT '原币单价' AFTER quantity_unit");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','period_start',"DATE NULL COMMENT '计费期间开始日' AFTER unit_price");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','period_end',"DATE NULL COMMENT '计费期间结束日' AFTER period_start");
CALL add_finance_p0_column('baiyue_finance','fin_fee_source','wallet_hold_id',"BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID' AFTER operation_version");
DROP PROCEDURE IF EXISTS add_finance_p0_column;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_finance' AND table_name='fin_fee_source' AND index_name='idx_fin_fee_source_wallet_hold'), 'SELECT 1', 'ALTER TABLE baiyue_finance.fin_fee_source ADD KEY idx_fin_fee_source_wallet_hold(wallet_hold_id)'));
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
-- 货柜双工作台权限结束,恢复头程数据库上下文
USE baiyue_firstleg;
@@ -2052,3 +2052,44 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
-- 财务 P0:海外费用保留原币、月度汇率和计费依据。
USE baiyue_lastleg;
DROP PROCEDURE IF EXISTS add_overseas_finance_p0_column;
DELIMITER $$
CREATE PROCEDURE add_overseas_finance_p0_column(IN p_column VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_lastleg' AND table_name='ll_finance_fee_source' AND column_name=p_column) THEN
SET @ddl = CONCAT('ALTER TABLE ll_finance_fee_source ADD COLUMN ', p_column, ' ', p_definition);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL add_overseas_finance_p0_column('original_currency', "VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '原币币种'");
CALL add_overseas_finance_p0_column('original_amount', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '原币金额'");
CALL add_overseas_finance_p0_column('exchange_rate', "DECIMAL(18,8) NOT NULL DEFAULT 1 COMMENT '原币折算CNY汇率'");
CALL add_overseas_finance_p0_column('exchange_rate_month', "CHAR(7) NULL COMMENT '汇率月份YYYY-MM'");
CALL add_overseas_finance_p0_column('chargeable_quantity', "DECIMAL(18,4) NULL COMMENT '计费数量'");
CALL add_overseas_finance_p0_column('quantity_unit', "VARCHAR(32) NULL COMMENT '计费单位'");
CALL add_overseas_finance_p0_column('unit_price', "DECIMAL(18,4) NULL COMMENT '原币单价'");
CALL add_overseas_finance_p0_column('period_start', "DATE NULL COMMENT '周期费用开始日期'");
CALL add_overseas_finance_p0_column('period_end', "DATE NULL COMMENT '周期费用结束日期'");
DROP PROCEDURE IF EXISTS add_overseas_finance_p0_column;
DROP PROCEDURE IF EXISTS assert_overseas_finance_p0_ready;
DELIMITER $$
CREATE PROCEDURE assert_overseas_finance_p0_ready()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_lastleg' AND table_name='ll_finance_fee_source'
AND column_name IN ('original_currency','original_amount','exchange_rate','exchange_rate_month',
'chargeable_quantity','quantity_unit','unit_price','period_start','period_end')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外财务P0原币、汇率和计费依据字段未完整部署';
END IF;
END$$
DELIMITER ;
CALL assert_overseas_finance_p0_ready();
DROP PROCEDURE IF EXISTS assert_overseas_finance_p0_ready;
SELECT 'PASS' AS migration_status, '海外财务P0费用来源字段已就绪' AS message;
@@ -55,8 +55,10 @@ public final class FinancePhase1Dtos {
Long supplierId, String supplierName, Long orderId,
Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus,
BigDecimal quantity, String quantityUnit, BigDecimal unitPrice,
LocalDate periodStart, LocalDate periodEnd, BigDecimal amount,
String currency, String originalCurrency, BigDecimal originalAmount,
BigDecimal exchangeRate, String exchangeRateMonth, String reviewStatus,
String sourceStatus, boolean statementGenerated) {}
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
@@ -65,8 +67,10 @@ public final class FinancePhase1Dtos {
String customerName, Long supplierId, String supplierName,
Long orderId, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus,
BigDecimal quantity, String quantityUnit, BigDecimal unitPrice,
LocalDate periodStart, LocalDate periodEnd, BigDecimal amount,
String currency, String originalCurrency, BigDecimal originalAmount,
BigDecimal exchangeRate, String exchangeRateMonth, String reviewStatus,
String sourceStatus, boolean statementGenerated,
String reviewReason, String sourceHash,
int operationVersion, List<OperationLog> operationLogs) {}
@@ -102,6 +102,7 @@ public class BusinessFeeSourceEventService {
voucherJson(source.paymentVoucherFileIds()), source.sourceHash(), json(source));
long id = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=?",
Long.class, system, source.sourceType(), feeId, source.sourceVersion());
linkSettledWalletHold(id, source);
if ("PAYABLE".equals(source.direction()) && "DIRECT_PAID".equals(source.settlementMode()))
materializeDirectPaidCost(system, feeId, source);
return id;
@@ -175,15 +176,21 @@ public class BusinessFeeSourceEventService {
}
private void requireSourceNotFrozen(String system, String type, long feeId) {
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=? AND f.source_type=? AND f.source_fee_id=? AND (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 bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, system, type, feeId);
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=? AND f.source_type=? AND f.source_fee_id=? AND (f.wallet_hold_id IS NOT NULL OR 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 bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, system, type, feeId);
if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代");
}
private void requireNotFrozen(long id) {
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (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 bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, id);
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (f.wallet_hold_id IS NOT NULL OR 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 bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, id);
if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止作废");
}
private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) {
if (!"RECEIVABLE".equals(source.direction())) return;
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND h.amount=f.amount",
feeSourceId);
}
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
@@ -152,7 +152,7 @@ public class FinancePhase1OperationService {
String normalizedReview = normalized(reviewStatus,
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
@@ -322,9 +322,15 @@ public class FinancePhase1OperationService {
if (generated && "AVAILABLE".equals(status)) status = "BILLED";
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = rs.getBigDecimal("amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"),
quantity != null && quantity.signum() > 0 ? originalAmount.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"), 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"), "DRAFT"), 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, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
@@ -333,15 +339,18 @@ public class FinancePhase1OperationService {
boolean generated = rs.getBoolean("statement_generated");
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = money(rs, "amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0
? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0
? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
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"),
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"),
quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
defaultString(rs.getString("review_status"), "DRAFT"),
rs.getString("effective_status"), generated);
}
@@ -421,10 +430,12 @@ public class FinancePhase1OperationService {
}
}
private static Long firstLong(Long first, Long second) { return first != null ? first : second; }
private static BigDecimal firstDecimal(BigDecimal first, BigDecimal second) { return first != null ? first : second; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; }
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
private static String defaultString(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; }
private static BigDecimal decimal(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : new BigDecimal(value); }
private static LocalDate date(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? LocalDate.of(1970, 1, 1) : LocalDate.parse(value); }
private static LocalDate nullableDate(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : LocalDate.parse(value); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}
@@ -59,7 +59,7 @@ public class WalletCommandEventService {
}
private WalletHold execute(WalletCommandRequestedV1 command) {
return switch (command.operation()) {
WalletHold hold = switch (command.operation()) {
case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(),
new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark()));
case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(),
@@ -68,6 +68,11 @@ public class WalletCommandEventService {
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效");
};
if ("SETTLE".equals(command.operation())) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=h.amount",
hold.holdId());
}
return hold;
}
private WalletCommandRequestedV1 validate(EventEnvelope envelope) {
@@ -63,6 +63,8 @@ CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'wallet_hold_id',
"BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID' AFTER operation_version");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmation_status',
+3 -1
View File
@@ -35,13 +35,15 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因',
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
wallet_hold_id BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SETTLED资金账户已结清、SUPERSEDED已被新版本替代、VOIDED已作废',
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 '来源状态最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_fee_source (source_system, source_type, source_fee_id, source_fee_version),
KEY idx_fin_fee_source_customer (customer_id, status),
KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id),
KEY idx_fin_fee_source_wallet_hold (wallet_hold_id),
CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0),
CONSTRAINT chk_fin_fee_source_settlement CHECK (
@@ -61,6 +61,7 @@ class BusinessFeeSourceEventServiceTest {
assertThat(values.getValue()[5]).isEqualTo("PO-US-001");
assertThat(values.getValue()[9]).isNull();
assertThat(values.getValue()[13]).isNull();
verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(901L));
}
private EventEnvelope eventWithoutInbound() {
@@ -71,6 +71,22 @@ class WalletCommandEventServiceTest {
eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId()));
}
@Test
void settledWalletHoldMarksMatchingReceivableSourceSettled() {
EventEnvelope envelope = envelope("SETTLE", 31L, new BigDecimal("88.50"));
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId())))
.thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
when(wallet.settle(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"),
eq("freeze-procurement-1"), any())).thenReturn(new WalletHold(9L, 31L,
"OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), new BigDecimal("80.00"),
new BigDecimal("8.50"), "SETTLED", 71L, 72L, null, null, null));
service.consume(envelope);
verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(9L));
}
@Test
void rejectsCustomerMismatchBeforeTouchingWallet() {
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00"));
@@ -64,7 +64,8 @@ class FinanceExportProviderServiceTest {
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,
"客户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",
BigDecimal.TEN, "KG", new BigDecimal("30"), null, null, new BigDecimal("300"), "CNY",
"CNY", new BigDecimal("300"), BigDecimal.ONE, "2026-08", "APPROVED",
"AVAILABLE", true, null, "hash", 2, List.of());
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
when(reconciliationService.detailForPlatform(501L)).thenReturn(detail);
@@ -10,7 +10,7 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
@@ -132,12 +132,14 @@ public class LastlegOrderController {
@PostMapping("/api/platform/v1/last-leg/orders/{id}/fees")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<LastlegOrderFee> createFee(
public ApiResponse<OverseasFeeSource> createFee(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody LastlegOrderRequests.CreateFee request) {
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user)));
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request,
operatorName(user), idempotencyKey));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/hold")
@@ -9,6 +9,7 @@ import jakarta.validation.Valid;
import java.util.List;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalDate;
public final class DeliveryRequests {
private DeliveryRequests() {}
@@ -31,8 +32,23 @@ public final class DeliveryRequests {
@NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees) {}
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
@NotBlank @Size(max = 8) String currency,
@NotNull @Min(0) BigDecimal amount,
@NotBlank @Size(max = 128) String feeName,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "CNY|USD") String currency,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
@Min(1) Long supplierId,
@Size(max = 200) String supplierName,
@Size(max = 32) String supplierCategory,
@jakarta.validation.constraints.Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@jakarta.validation.constraints.Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
@Size(max = 32) String quantityUnit,
@jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd,
@Size(max = 500) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
@@ -1,24 +0,0 @@
package com.baiyue.lastleg.delivery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_delivery_fee")
public class DeliveryFee {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long deliveryOrderId;
private String feeDirection;
private String feeType;
private String currency;
private BigDecimal amount;
private String remark;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -1,16 +0,0 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryFee;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DeliveryFeeMapper extends BaseMapper<DeliveryFee> {
@Select("SELECT * FROM ll_delivery_fee WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY id")
List<DeliveryFee> findByDelivery(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
}
@@ -7,13 +7,14 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.DeliveryFee;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
@@ -56,6 +57,7 @@ import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.math.BigDecimal;
@@ -68,7 +70,7 @@ public class DeliveryTransferService {
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPallets;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private DeliveryFeeMapper deliveryFees;
@Resource private OverseasFeeSourceService feeSources;
@Resource private DeliveryOperationMapper deliveryOperations;
@Resource private OutboundPdaTaskMapper pdaTasks;
@Resource private OutboundPdaPickMapper pdaPicks;
@@ -184,9 +186,18 @@ public class DeliveryTransferService {
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
for (DeliveryRequests.FeeItem input : feeInputs) {
DeliveryFee fee = new DeliveryFee(); fee.setCustomerId(customerId); fee.setDeliveryOrderId(delivery.getId());
fee.setFeeDirection("PAYABLE"); fee.setFeeType(input.feeType().trim()); fee.setCurrency(input.currency().trim().toUpperCase(Locale.ROOT));
fee.setAmount(input.amount()); fee.setRemark(trim(input.remark())); fee.setCreatedBy(operatorId); fee.setCreatedAt(now); deliveryFees.insert(fee);
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()),
new OverseasFeeSourceDtos.SaveRequest(customerId, delivery.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", delivery.getId(), delivery.getDeliveryNo(),
firstOrder == null ? null : firstOrder.getId(), null,
firstOrder == null ? null : firstOrder.getInboundCode(), null,
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(delivery.getOverseasWarehouseId()),
input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(),
input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
input.periodStart(), input.periodEnd()));
}
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "CREATED", now);
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
@@ -202,8 +213,9 @@ public class DeliveryTransferService {
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if (!"USD".equalsIgnoreCase(input.currency().trim())) throw conflict("Delivery fees must use USD");
if (input.amount().signum() < 0) throw conflict("Delivery fee amount cannot be negative");
if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT)))
throw conflict("Delivery fees only support CNY or USD");
if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero");
}
if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required");
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
@@ -559,7 +571,7 @@ public class DeliveryTransferService {
new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
List<DeliveryResponses.DeliverySku> skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList();
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
deliveryFees.findByDelivery(customerId, delivery.getId()).stream().map(this::feeRow).toList(),
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList());
}
@@ -616,7 +628,7 @@ public class DeliveryTransferService {
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
@@ -632,6 +644,15 @@ public class DeliveryTransferService {
}
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) { List<Long> ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
}
private static String childKey(String key, String suffix) {
String prefix = key == null ? "" : key.trim();
int maxPrefix = Math.max(0, 128 - suffix.length() - 1);
return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
}
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
@@ -1,7 +1,7 @@
package com.baiyue.lastleg.dto;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import java.time.LocalDateTime;
@@ -12,7 +12,7 @@ public record LastlegOrderDetailResponse(
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes,
List<LastlegOrderDetail> cargo,
List<History> statusHistory,
List<LastlegOrderFee> fees,
List<OverseasFeeSource> fees,
List<Operation> operationHistory
) {
public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {}
@@ -11,6 +11,7 @@ import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.List;
public final class LastlegOrderRequests {
@@ -81,11 +82,25 @@ public final class LastlegOrderRequests {
}
public record CreateFee(
@NotBlank @Size(max = 128) String feeItem,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotBlank @Size(max = 64) String feeItemCode,
@NotBlank @Size(max = 200) String feeItemName,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotBlank @Pattern(regexp = "CNY|USD") String currency,
@NotNull @DecimalMin(value = "0.01") BigDecimal amount,
@NotBlank @Pattern(regexp = "PENDING_REVIEW|APPROVED|REJECTED") String status,
@Size(max = 500) String supplierDescription
@NotNull LocalDate businessDate,
@Min(1) Long supplierId,
@Size(max = 200) String supplierName,
@Size(max = 32) String supplierCategory,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
@Size(max = 32) String quantityUnit,
@DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd
) {
}
@@ -1,25 +0,0 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_order_fee")
public class LastlegOrderFee {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long orderId;
private String feeItem;
private String currency;
private BigDecimal amount;
private String status;
private String supplierDescription;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -20,8 +20,8 @@ public final class OverseasFeeSourceDtos {
public record SaveRequest(
@NotBlank @Size(max = 128) String customerId,
@Size(max = 200) String customerName,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE") String sourceType,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER") String businessType,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE|OVERSEAS_ORDER_FEE|OVERSEAS_DELIVERY_FEE|OVERSEAS_RELABEL_FEE") String sourceType,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER") String businessType,
@Min(1) Long businessId,
@NotBlank @Size(max = 128) String businessNo,
@Min(1) Long orderId,
@@ -2,7 +2,11 @@ package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -14,6 +18,8 @@ import java.util.List;
@RequiredArgsConstructor
public class OverseasFeeBusinessGuard {
private final JdbcTemplate jdbc;
@Resource private DomesticWarehouseDirectoryClient warehouses;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
List<ContainerCustomerOption> customers = jdbc.query(
@@ -37,13 +43,18 @@ public class OverseasFeeBusinessGuard {
throw bad("业务记录ID不能为空");
if (fee.warehouseId() == null)
throw bad("海外仓服务费必须关联海外仓;入仓记录和入仓码按实际业务选填");
requireActiveOverseasWarehouse(fee.warehouseId());
return;
}
Table table = switch (fee.businessType()) {
case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no");
case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no");
case "PROCUREMENT_FEE" -> new Table("ll_overseas_procurement_fee", "fee_no");
case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no");
case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no");
case "LASTLEG_ORDER" -> new Table("ll_order", "order_no");
case "DELIVERY_ORDER" -> new Table("ll_delivery_order", "delivery_no");
case "RELABEL_ORDER" -> new Table("ll_relabel_application", "relabel_no");
default -> throw bad("业务类型与业务记录ID不匹配");
};
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name
@@ -83,6 +94,21 @@ public class OverseasFeeBusinessGuard {
if (owned == null || owned < 1) throw bad("该客户不属于费用关联的真实货柜");
}
private void requireActiveOverseasWarehouse(long warehouseId) {
try {
var response = warehouses.activeWarehouse(warehouseId, internalToken);
var warehouse = response == null || response.data() == null ? null : response.data().warehouse();
if (warehouse == null || !response.successful() || !Long.valueOf(warehouseId).equals(warehouse.id())
|| !Long.valueOf(0L).equals(warehouse.tenantId()) || !"ACTIVE".equals(warehouse.status())
|| !List.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType())) {
throw bad("海外仓不存在、已停用或类型不正确");
}
} catch (FeignException exception) {
if (exception.status() >= 400 && exception.status() < 500) throw bad("海外仓不存在、已停用或类型不正确");
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库主数据暂不可用", exception);
}
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
@@ -53,6 +53,15 @@ public class OverseasFeeSourceService {
.orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId));
}
public List<OverseasFeeSource> listForBusiness(String customerId, String businessType, long businessId) {
return mapper.selectList(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(OverseasFeeSource::getCustomerId, customerId)
.eq(OverseasFeeSource::getBusinessType, businessType)
.eq(OverseasFeeSource::getBusinessId, businessId)
.inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id")
.orderByAsc(OverseasFeeSource::getCreatedAt).orderByAsc(OverseasFeeSource::getId));
}
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
return businessGuard.listContainerCustomers(containerBookingId);
}
@@ -70,6 +79,22 @@ public class OverseasFeeSourceService {
return value;
}
@Transactional
public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) {
OverseasFeeSource value = create(operatorId, key, request);
if ("DRAFT".equals(value.getReviewStatus()) || "REJECTED".equals(value.getReviewStatus())) {
value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit"));
}
if ("PENDING".equals(value.getReviewStatus())) {
value = review(value.getSourceFeeId(), operatorId,
new ReviewRequest("APPROVE", "业务完成自动确认费用", childKey(key, "approve")));
}
if (!"APPROVED".equals(value.getReviewStatus()) || !"PUBLISHED".equals(value.getPublicationStatus())) {
throw conflict("业务费用未能审核发布");
}
return value;
}
@Transactional
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
@@ -168,10 +193,14 @@ public class OverseasFeeSourceService {
String expected = switch (value.businessType()) {
case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE";
case "OVERSEAS_PROCUREMENT" -> "OVERSEAS_PROCUREMENT_FEE";
case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE";
case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE";
case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE";
case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE";
case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE";
case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE";
case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE";
case "RELABEL_ORDER" -> "OVERSEAS_RELABEL_FEE";
default -> "";
};
if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配");
@@ -212,6 +241,12 @@ public class OverseasFeeSourceService {
if (existing != null && !existing.getRequestHash().equals(hash(request))) throw conflict("幂等键已被不同请求使用");
return existing;
}
private static String childKey(String key, String suffix) {
String prefix = key == null ? "" : key.trim();
int maxPrefix = Math.max(0, 128 - suffix.length() - 1);
return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
}
private void command(String operator, String action, String key, Object request, long sourceFeeId, int version) {
OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator);
value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(request)); value.setSourceFeeId(sourceFeeId);
@@ -1,9 +0,0 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LastlegOrderFeeMapper extends BaseMapper<LastlegOrderFee> {
}
@@ -16,6 +16,8 @@ import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.wallet.WalletResultHandler;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
@@ -33,7 +35,9 @@ import org.springframework.web.server.ResponseStatusException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
@@ -62,6 +66,7 @@ public class OverseasProcurementService implements WalletResultHandler {
@Resource private OverseasProcurementFeeMapper procurementFees;
@Resource private ProcurementFileGateway fileGateway;
@Resource private WalletCommandOutboxService walletCommands;
@Resource private OverseasFeeSourceService feeSources;
@Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
@@ -220,6 +225,7 @@ public class OverseasProcurementService implements WalletResultHandler {
if ("FREEZE".equals(result.operation()) && "SUCCEEDED".equals(result.result())) {
OverseasProcurementOrder order = orders.selectById(row.getOrderId());
if (order != null && order.getInboundQuantity() >= order.getPurchaseQuantity()) {
publishFeeSource(order, row);
walletCommands.publish("SETTLE", result.customerId(), "PROCUREMENT_FEE", row.getFeeNo(), null,
"海外采购完成入库,结算费用", 2L);
row.setWalletStatus("SETTLE_PENDING"); procurementFees.updateById(row);
@@ -313,6 +319,7 @@ public class OverseasProcurementService implements WalletResultHandler {
if (order.getInboundQuantity() >= order.getPurchaseQuantity()) {
for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getOrderId, order.getId()).eq(OverseasProcurementFee::getWalletStatus, "FROZEN"))) {
publishFeeSource(order, fee);
walletCommands.publish("SETTLE", Long.parseLong(customerId), "PROCUREMENT_FEE", fee.getFeeNo(), null,
"海外采购完成入库,结算费用", 2L);
fee.setWalletStatus("SETTLE_PENDING"); fee.setUpdatedAt(now); procurementFees.updateById(fee);
@@ -321,6 +328,16 @@ public class OverseasProcurementService implements WalletResultHandler {
return detail(customerId, orderNo);
}
private void publishFeeSource(OverseasProcurementOrder order, OverseasProcurementFee fee) {
feeSources.publishApproved(StringUtils.hasText(fee.getCreatedBy()) ? fee.getCreatedBy() : "SYSTEM", "PROCUREMENT:" + fee.getFeeNo(),
new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(),
"OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getId(), fee.getFeeNo(),
order.getId(), null, null, null, null, null, parseLong(order.getPurchaseWarehouseId()),
fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", null, null, null, null,
null, null, null, List.of(), LocalDate.now(), fee.getCurrency(), fee.getAmount(),
BigDecimal.ONE, "ITEM", fee.getAmount(), null, null));
}
private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) {
if (!hash.equals(order.getRequestHash())) {
throw conflict("Idempotency key was reused with different procurement data");
@@ -491,6 +508,10 @@ public class OverseasProcurementService implements WalletResultHandler {
}
private static long value(Long value) { return value == null ? 0 : value; }
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
}
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String defaultCurrency(String value) { return trim(value) == null ? "USD" : value.trim().toUpperCase(); }
private static void setIfText(String value, java.util.function.Consumer<String> setter) {
@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
@@ -28,9 +30,11 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.LinkedHashMap;
@@ -53,6 +57,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Resource private WalletCommandOutboxService walletCommands;
@Resource private OverseasFeeSourceService feeSources;
@Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
@@ -184,7 +189,20 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setStatus(next);
application.setUpdatedAt(now);
applications.updateById(application);
List<LastlegOrder> linkedOrders = tailOrders(application);
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
if ("COMPLETED".equals(next)) {
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
application.getLastlegOrderId(), null, null, null, null, null,
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
application.getChargeCurrency(), application.getChargeAmount(),
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
null, null));
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标完成,结算费用", 2L);
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
@@ -193,8 +211,6 @@ public class RelabelApplicationService implements WalletResultHandler {
"换标取消,释放冻结", 2L);
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application);
}
List<LastlegOrder> linkedOrders = tailOrders(application);
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
for (LastlegOrder order : linkedOrders) {
String orderBefore = order.getCurrentStatus();
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
@@ -351,6 +367,10 @@ public class RelabelApplicationService implements WalletResultHandler {
.digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -14,7 +14,6 @@ import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
@@ -25,7 +24,9 @@ import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
@@ -64,7 +65,7 @@ public class LastlegOrderService {
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private LastlegOrderFeeMapper feeMapper;
@Resource private OverseasFeeSourceService feeSources;
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
@Resource private LastlegOrderManualNodeMapper manualNodeMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@@ -246,9 +247,7 @@ public class LastlegOrderService {
.eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
List<LastlegOrderFee> fees = feeMapper.selectList(new LambdaQueryWrapper<LastlegOrderFee>()
.eq(LastlegOrderFee::getCustomerId, order.getCustomerId())
.eq(LastlegOrderFee::getOrderId, orderId).orderByAsc(LastlegOrderFee::getCreatedAt));
List<OverseasFeeSource> fees = feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId);
List<LastlegOrderOperationLog> operations = operationLogMapper.selectList(
new LambdaQueryWrapper<LastlegOrderOperationLog>()
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
@@ -270,24 +269,21 @@ public class LastlegOrderService {
}
@Transactional
public LastlegOrderFee createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
String operatorName) {
public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
String operatorName, String idempotencyKey) {
LastlegOrder order = findOwned(customerId, orderId);
LocalDateTime now = LocalDateTime.now();
LastlegOrderFee fee = new LastlegOrderFee();
fee.setId(IdWorker.getId());
fee.setCustomerId(order.getCustomerId());
fee.setOrderId(orderId);
fee.setFeeItem(request.feeItem().trim());
fee.setCurrency(request.currency());
fee.setAmount(request.amount());
fee.setStatus(request.status());
fee.setSupplierDescription(StringUtils.hasText(request.supplierDescription())
? request.supplierDescription().trim() : null);
fee.setCreatedBy(operatorName);
fee.setCreatedAt(now);
fee.setUpdatedAt(now);
feeMapper.insert(fee);
OverseasFeeSource fee = feeSources.create(operatorName, idempotencyKey,
new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(),
"OVERSEAS_ORDER_FEE", "LASTLEG_ORDER", orderId, order.getOrderNo(), orderId,
null, order.getInboundCode(), null, parseLong(order.getSourceContainerBookingId()),
order.getContainerNo(), parseLong(order.getOverseasWarehouseId()), request.feeItemCode(),
request.feeItemName(), request.direction(), request.supplierId(), request.supplierName(),
request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(),
request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(),
request.currency(), request.amount(), request.chargeableQuantity(), request.quantityUnit(),
request.unitPrice(), request.periodStart(), request.periodEnd()));
fee = feeSources.submit(fee.getSourceFeeId(), operatorName, childKey(idempotencyKey, "submit"));
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId());
@@ -295,7 +291,8 @@ public class LastlegOrderService {
operation.setOrderId(orderId);
operation.setOperationNode("新增费用");
operation.setOperatorName(operatorName);
operation.setResult("新增" + fee.getFeeItem() + ":" + fee.getCurrency() + " " + fee.getAmount().toPlainString());
operation.setResult("新增" + fee.getFeeItemName() + ":" + fee.getOriginalCurrency() + " "
+ fee.getOriginalAmount().toPlainString() + ",已提交财务审核");
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
return fee;
@@ -585,6 +582,17 @@ public class LastlegOrderService {
? null : order.getInboundCode();
}
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
}
private static String childKey(String key, String suffix) {
String prefix = key == null ? "" : key.trim();
int maxPrefix = Math.max(0, 128 - suffix.length() - 1);
return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
}
private static String csv(Object value) {
if (value == null) return "";
String text = String.valueOf(value);
@@ -163,22 +163,6 @@ CREATE TABLE IF NOT EXISTS ll_loading_order_status_history (
KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单状态变更历史';
CREATE TABLE IF NOT EXISTS ll_order_fee (
id BIGINT NOT NULL COMMENT '费用明细主键',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
fee_item VARCHAR(128) NOT NULL COMMENT '费用项目',
currency VARCHAR(3) NOT NULL COMMENT '币种代码',
amount DECIMAL(18,2) NOT NULL COMMENT '费用金额',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回',
supplier_description VARCHAR(500) NULL COMMENT '供应商或费用说明',
created_by VARCHAR(128) NOT NULL 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 '更新时间',
PRIMARY KEY(id),
KEY idx_ll_order_fee_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单费用明细';
CREATE TABLE IF NOT EXISTS ll_order_operation_log (
id BIGINT NOT NULL COMMENT '操作记录主键',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
@@ -302,12 +286,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count'));
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
CREATE TABLE IF NOT EXISTS ll_delivery_fee (
id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种',
amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)
) ENGINE=InnoDB COMMENT='派送单费用明细';
CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (
id BIGINT NOT NULL COMMENT '派送资料历史ID',
customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键',
@@ -194,11 +194,12 @@ paths:
/api/platform/v1/last-leg/orders/{id}/fees:
post:
summary: 新增尾程订单费用
description: 权限 platform:overseas-warehouse:last-mile-order:edit;在订单客户范围内新增费用并记录操作人和处理结果。
description: 权限 platform:overseas-warehouse:last-mile-order:edit;在订单客户范围内创建统一费用来源并提交财务审核。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeCreate' } } }
@@ -1331,28 +1332,44 @@ components:
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: ll_order.estimated_warehouse_at }
LastMileFeeCreate:
type: object
required: [feeItem, currency, amount, status]
required: [feeItemCode, feeItemName, direction, currency, amount, businessDate]
properties:
feeItem: { type: string, minLength: 1, maxLength: 128 }
currency: { type: string, pattern: '^[A-Z]{3}$', example: USD }
feeItemCode: { type: string, minLength: 1, maxLength: 64 }
feeItemName: { type: string, minLength: 1, maxLength: 128 }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
currency: { type: string, enum: [CNY, USD] }
amount: { type: number, format: decimal, minimum: 0.01 }
status: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
supplierDescription: { type: string, maxLength: 500 }
businessDate: { type: string, format: date }
supplierId: { type: string, pattern: '^[1-9][0-9]*$' }
supplierName: { type: string, maxLength: 200 }
supplierCategory: { type: string, maxLength: 32 }
settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] }
chargeableQuantity: { type: number, exclusiveMinimum: 0 }
quantityUnit: { type: string, maxLength: 32 }
unitPrice: { type: number, exclusiveMinimum: 0 }
periodStart: { type: string, format: date }
periodEnd: { type: string, format: date }
LastMileFee:
type: object
required: [id, customerId, orderId, feeItem, currency, amount, status, createdBy, createdAt, updatedAt]
required: [id, sourceFeeId, sourceVersion, customerId, feeItemCode, feeItemName, direction, currency, amount, originalCurrency, originalAmount, reviewStatus, publicationStatus, sourceStatus]
properties:
id: { type: integer, format: int64 }
id: { type: string, pattern: '^[1-9][0-9]*$' }
sourceFeeId: { type: string, pattern: '^[1-9][0-9]*$' }
sourceVersion: { type: integer, minimum: 1 }
customerId: { type: string }
orderId: { type: integer, format: int64 }
feeItem: { type: string }
currency: { type: string }
orderId: { type: string, pattern: '^[1-9][0-9]*$' }
feeItemCode: { type: string }
feeItemName: { type: string }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
currency: { type: string, enum: [CNY] }
amount: { type: number, format: decimal }
status: { type: string }
supplierDescription: { type: string, nullable: true }
createdBy: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
originalCurrency: { type: string, enum: [CNY, USD] }
originalAmount: { type: number, format: decimal }
exchangeRate: { type: number, format: decimal }
exchangeRateMonth: { type: string, pattern: '^\\d{4}-\\d{2}$' }
reviewStatus: { type: string, enum: [DRAFT, PENDING, APPROVED, REJECTED] }
publicationStatus: { type: string }
sourceStatus: { type: string }
LastMileFeeApiResponse:
type: object
required: [code, message, data]
@@ -1842,11 +1859,22 @@ components:
enum: [START_PROCESSING, COMPLETE_EXCEPTION, CANCEL_EXCEPTION]
DeliveryFeeInput:
type: object
required: [feeType, currency, amount]
required: [feeType, feeName, direction, currency, amount]
properties:
feeType: { type: string, maxLength: 64 }
currency: { type: string, maxLength: 8 }
amount: { type: number, minimum: 0 }
feeName: { type: string, maxLength: 128 }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
currency: { type: string, enum: [CNY, USD] }
amount: { type: number, exclusiveMinimum: 0 }
supplierId: { type: string, pattern: '^[1-9][0-9]*$' }
supplierName: { type: string, maxLength: 200 }
supplierCategory: { type: string, maxLength: 32 }
settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] }
chargeableQuantity: { type: number, exclusiveMinimum: 0 }
quantityUnit: { type: string, maxLength: 32 }
unitPrice: { type: number, exclusiveMinimum: 0 }
periodStart: { type: string, format: date }
periodEnd: { type: string, format: date }
remark: { type: string, nullable: true, maxLength: 500 }
OneClickOutboundCreate:
type: object
@@ -42,7 +42,7 @@ paths:
responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } }
components:
schemas:
BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER] }
BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, PROCUREMENT_FEE, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] }
OverseasContainerFeeItem:
type: string
description: 海外阶段柜级费用项目:港口费、堆存费、修箱费、吊机费、码头拖车费、其他。
@@ -70,7 +70,7 @@ components:
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, OVERSEAS_CONTAINER_FEE] }
sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] }
businessType: { $ref: '#/components/schemas/BusinessType' }
businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true }
businessNo: { type: string, maxLength: 128 }
@@ -134,6 +134,8 @@ class MerchantDeliveryControllerTest {
private static DeliveryRequests.Transfer transferRequest() {
return new DeliveryRequests.Transfer("merchant-key", LocalDateTime.now(), "SELF_DELIVERY", null, null,
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null,
List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "USD", BigDecimal.ZERO, null)));
List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY",
new BigDecimal("10.00"), null, null, null, null, null, null, null, null,
null, null, null, null, null, null)));
}
}
@@ -8,12 +8,12 @@ import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
@@ -67,7 +67,7 @@ class DeliveryTransferServiceTest {
@Mock OutboundOrderMapper outboundOrders;
@Mock OutboundPalletItemMapper outboundPallets;
@Mock DeliveryOrderMapper deliveryOrders;
@Mock DeliveryFeeMapper deliveryFees;
@Mock OverseasFeeSourceService feeSources;
@Mock DeliveryOperationMapper deliveryOperations;
@Mock InboundPlanPalletAllocationMapper planAllocations;
@Mock InboundPlanLineMapper planLines;
@@ -92,7 +92,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "deliveryFees", deliveryFees);
ReflectionTestUtils.setField(service, "feeSources", feeSources);
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "planAllocations", planAllocations);
ReflectionTestUtils.setField(service, "planLines", planLines);
@@ -641,7 +641,13 @@ class DeliveryTransferServiceTest {
private static DeliveryRequests.Transfer transferRequest(String idempotencyKey, String remark) {
return new DeliveryRequests.Transfer(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null,
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, remark,
List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "USD", BigDecimal.ZERO, null)));
List.of(receivableDeliveryFee()));
}
private static DeliveryRequests.FeeItem receivableDeliveryFee() {
return new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY",
new BigDecimal("10.00"), null, null, null, null, null, null, null, null,
null, null, null, null, null, null);
}
private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode,
@@ -10,6 +10,8 @@ import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory;
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -46,6 +48,7 @@ class OverseasProcurementServiceTest {
@Mock OverseasProcurementFeeMapper procurementFees;
@Mock ProcurementFileGateway fileGateway;
@Mock WalletCommandOutboxService walletCommands;
@Mock OverseasFeeSourceService feeSources;
private OverseasProcurementService service;
@BeforeEach
@@ -60,6 +63,8 @@ class OverseasProcurementServiceTest {
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
ReflectionTestUtils.setField(service, "fileGateway", fileGateway);
ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
ReflectionTestUtils.setField(service, "feeSources", feeSources);
lenient().when(feeSources.publishApproved(anyString(), anyString(), any())).thenReturn(new OverseasFeeSource());
lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001");
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
lenient().when(procurementFees.selectList(any())).thenReturn(List.of());
@@ -16,6 +16,8 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -51,6 +53,7 @@ class RelabelApplicationServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock WalletCommandOutboxService walletCommands;
@Mock OverseasFeeSourceService feeSources;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
private RelabelApplicationService service;
@@ -65,8 +68,10 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
ReflectionTestUtils.setField(service, "feeSources", feeSources);
ReflectionTestUtils.setField(service, "objectMapper", mapper());
lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1");
lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource());
}
@Test
@@ -14,7 +14,7 @@ class Phase5OverseasUpgradeSqlContractTest {
private static final List<String> PHASE5_TABLES = List.of(
"ll_dispatch_pda_task", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation",
"ll_dispatch_pda_pick_order", "ll_outbound_order", "ll_outbound_pallet_item",
"ll_outbound_pda_task", "ll_outbound_pda_pick", "ll_delivery_order", "ll_delivery_fee",
"ll_outbound_pda_task", "ll_outbound_pda_pick", "ll_delivery_order",
"ll_delivery_operation", "ll_dropship_order_profile", "ll_dropship_carton",
"ll_dropship_sku_inventory", "ll_dropship_carton_ledger", "ll_exception_order",
"ll_customer_visit");
@@ -12,7 +12,8 @@ import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
@@ -28,6 +29,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -53,7 +55,7 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock LastlegOrderFeeMapper fees;
@Mock OverseasFeeSourceService feeSources;
@Mock LastlegOrderOperationLogMapper operations;
@Mock LastlegOrderManualNodeMapper manualNodes;
@Mock InboundPlanCartonMapper cartons;
@@ -70,7 +72,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "feeMapper", fees);
ReflectionTestUtils.setField(service, "feeSources", feeSources);
ReflectionTestUtils.setField(service, "operationLogMapper", operations);
ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
@@ -140,7 +142,7 @@ class LastlegOrderServiceTest {
when(orders.selectOne(any())).thenReturn(order);
when(details.selectList(any())).thenReturn(List.of());
when(histories.selectList(any())).thenReturn(List.of());
when(fees.selectList(any())).thenReturn(List.of());
when(feeSources.listForBusiness("100", "LASTLEG_ORDER", 11L)).thenReturn(List.of());
when(operations.selectList(any())).thenReturn(List.of());
when(manualNodes.selectList(any())).thenReturn(List.of());
@@ -186,14 +188,21 @@ class LastlegOrderServiceTest {
void createFeePersistsFeeAndOperationForOwnedOrder() {
when(orders.selectOne(any())).thenReturn(order(11L, "100", "PENDING_ASSIGNMENT", null));
var request = new LastlegOrderRequests.CreateFee(
"清关费", "USD", new BigDecimal("420.00"), "PENDING_REVIEW", "报关代理");
"CUSTOMS_CLEARANCE", "清关费", "RECEIVABLE", "USD", new BigDecimal("420.00"),
LocalDate.of(2026, 9, 9), null, null, null, null, null, null, null, List.of(),
null, null, null, null, null);
OverseasFeeSource draft = new OverseasFeeSource();
draft.setSourceFeeId(701L); draft.setCustomerId("100"); draft.setFeeItemName("清关费");
draft.setOriginalCurrency("USD"); draft.setOriginalAmount(new BigDecimal("420.00"));
when(feeSources.create(eq("platform_admin"), eq("fee-key"), any())).thenReturn(draft);
when(feeSources.submit(701L, "platform_admin", "fee-key:submit")).thenReturn(draft);
var fee = service.createFee("100", 11L, request, "platform_admin");
var fee = service.createFee("100", 11L, request, "platform_admin", "fee-key");
assertEquals("100", fee.getCustomerId());
assertEquals("清关费", fee.getFeeItem());
assertEquals(new BigDecimal("420.00"), fee.getAmount());
verify(fees).insert(any(com.baiyue.lastleg.entity.LastlegOrderFee.class));
assertEquals("清关费", fee.getFeeItemName());
verify(feeSources).create(eq("platform_admin"), eq("fee-key"), any());
verify(feeSources).submit(701L, "platform_admin", "fee-key:submit");
verify(operations).insert(any(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class));
}