This commit is contained in:
2026-08-17 19:51:54 +08:00
parent cddd23e4c7
commit 45b51415cf
84 changed files with 1278 additions and 158 deletions
@@ -527,6 +527,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
expected_ship_date DATE NULL COMMENT '预计发运日期',
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间',
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓逻辑ID',
receiving_warehouse_name VARCHAR(128) NULL COMMENT '下单时国内收货仓名称快照;主数据变化不回写',
receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照',
@@ -56,6 +56,23 @@ public class FinancePhase1OperationController {
return service.listDerivedCustomerAccountLedger(customerId, page, pageSize, businessType, startDate, endDate);
}
@GetMapping("/fee-sources")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public Object listFinanceFeeSources(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) Long customerId,
@RequestParam(required = false) String direction,
@RequestParam(required = false) String status,
@RequestParam(required = false) String reviewStatus,
@RequestParam(required = false) Boolean settlementIncluded,
@RequestParam(required = false) Boolean statementGenerated,
@RequestParam(required = false) LocalDate businessDateFrom,
@RequestParam(required = false) LocalDate businessDateTo,
@RequestParam(required = false) String keyword) {
return service.listFinanceFeeSources(page, pageSize, customerId, direction, status, reviewStatus,
settlementIncluded, statementGenerated, businessDateFrom, businessDateTo, keyword);
}
@GetMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
@@ -74,7 +74,7 @@ public class PlatformFinanceController {
public ApiResponse<?> statements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return FinanceResponses.success(queryService.statements(customerId, false));
return FinanceResponses.success(queryService.reconciliationStatements(customerId));
}
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@@ -37,10 +37,21 @@ public final class FinancePhase1Dtos {
BigDecimal outstandingReceivableAfter, String operatorName,
String remark) {}
public record FeeSourceDetail(long feeSourceId, String sourceType, long sourceFeeId,
public record FeeSourceSummary(long feeSourceId, String sourceSystem, String sourceType,
String sourceBusinessId, long sourceFeeId, int sourceVersion,
String direction, long customerId, 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, boolean settlementIncluded,
String sourceStatus, boolean statementGenerated) {}
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
String sourceBusinessId, long sourceFeeId,
int sourceVersion, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
Long inboundId, String inboundCode, Long containerId,
Long orderId, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus, boolean settlementIncluded,
@@ -76,6 +76,37 @@ public final class FinanceReadModels {
String updatedByName,
LocalDateTime updatedAt) {}
public record ReconciliationSummary(
long statementId,
String statementNo,
long customerId,
String customerName,
LocalDate periodStart,
LocalDate periodEnd,
int creditDays,
String currency,
int lineCount,
int sourceCount,
BigDecimal sourceAmount,
BigDecimal adjustmentAmount,
BigDecimal totalReceivable,
BigDecimal receivedAmount,
BigDecimal outstandingAmount,
LocalDateTime lastReceiptAt,
boolean settled,
String status,
LocalDateTime submittedAt,
LocalDateTime reviewedAt,
LocalDateTime issuedAt,
int version,
String remark,
long createdBy,
String createdByName,
LocalDateTime createdAt,
long updatedBy,
String updatedByName,
LocalDateTime updatedAt) {}
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
public record StatementLine(
@@ -22,14 +22,18 @@ public final class FinanceSettlementDtos {
public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
Long reconciliationStatementId, String businessPeriod, String currency,
Long reconciliationStatementId, String reconciliationStatementNo,
String businessPeriod, String feeItemSummary, String currency,
BigDecimal totalAmount, BigDecimal paidAmount, BigDecimal outstandingAmount,
LocalDate generatedDate, LocalDate dueDate, String paymentStatus,
String status, int version) {}
String status, long createdBy, String createdByName,
LocalDateTime createdAt, LocalDateTime updatedAt,
LocalDateTime lastPaymentAt, int version) {}
public record SettlementLine(long feeSourceId, String sourceType, long sourceFeeId, int sourceVersion,
String direction, long customerId, String customerName, Long supplierId,
String supplierName, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
String supplierName, Long orderId, String orderNo, Long inboundId,
String inboundCode, Long containerId, String containerNo,
LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
String reviewStatus, boolean settlementIncluded, String sourceStatus,
boolean statementGenerated) {}
@@ -29,14 +29,18 @@ public final class ReconciliationStatementDtos {
List<ReconciliationRevision> revisions) {}
public record ReconciliationLine(long statementLineId, long feeSourceId, int sourceVersion,
String sourceHash, Long inboundId, Long containerId,
String sourceHash, String direction, String sourceType,
String sourceNo, Long orderId, Long inboundId, Long containerId,
String inboundCode, String containerNo, LocalDate businessDate,
BigDecimal quantity, BigDecimal unitPrice,
String feeItemName, BigDecimal sourceAmount,
BigDecimal statementAmount, String currency,
String adjustmentReason) {}
public record PaymentHistory(long transactionId, String transactionType,
LocalDate transactionDate, BigDecimal amount,
String method, String referenceNo, long createdBy, String createdByName,
String method, String referenceNo, List<String> voucherFileIds,
long createdBy, String createdByName,
LocalDateTime createdAt, String remark) {}
public record ReconciliationRevision(int revisionNo, String action, BigDecimal beforeAmount,
@@ -2,6 +2,7 @@ package com.baiyue.finance.service;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
@@ -86,6 +87,52 @@ public class FinancePhase1OperationService {
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
}
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
String direction, String sourceStatus,
String reviewStatus, Boolean settlementIncluded,
Boolean statementGenerated, LocalDate businessDateFrom,
LocalDate businessDateTo, String keyword) {
page = page(page); pageSize = pageSize(pageSize);
if (customerId != null) customer(customerId);
if (businessDateFrom != null && businessDateTo != null && businessDateFrom.isAfter(businessDateTo)) {
throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
}
String normalizedDirection = normalized(direction, List.of("RECEIVABLE", "PAYABLE"), "费用方向无效");
String normalizedReview = normalized(reviewStatus, List.of("PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "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')";
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
List<Object> args = new ArrayList<>();
if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); }
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
if (settlementIncluded != null) { where.append(" AND f.settlement_included=?"); args.add(settlementIncluded); }
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
String normalizedKeyword = blankToNull(keyword);
if (normalizedKeyword != null) {
where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.partnerName')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
for (int i = 0; i < 7; i++) args.add(normalizedKeyword);
}
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
List<Object> queryArgs = new ArrayList<>(args);
queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize);
String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" +
where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?";
List<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效");
List<FeeSourceDetail> rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId);
@@ -136,11 +183,29 @@ public class FinancePhase1OperationService {
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;
return new FeeSourceDetail(id, rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), null, null,
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"), "PENDING"), rs.getBoolean("settlement_included"), status, generated,
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"), firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")),
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"), "PENDING"), rs.getBoolean("settlement_included"), status, generated,
rs.getString("review_reason"), rs.getString("settlement_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
JsonNode snapshot = parse(rs.getString("source_snapshot"));
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;
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"),
firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")), 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"), "PENDING"), rs.getBoolean("settlement_included"),
rs.getString("effective_status"), generated);
}
private List<OperationLog> logs(long customerId, long id) {
return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) ->
new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id);
@@ -186,6 +251,13 @@ public class FinancePhase1OperationService {
private static void customer(long value) { if (value <= 0) throw error("FIN-REQ-400", "客户ID无效"); }
private static boolean inRange(LocalDateTime value, LocalDate start, LocalDate end) { return value != null && (start == null || !value.toLocalDate().isBefore(start)) && (end == null || !value.toLocalDate().isAfter(end)); }
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String normalized(String value, List<String> allowed, String message) {
String normalized = blankToNull(value);
if (normalized == null) return null;
normalized = normalized.toUpperCase(java.util.Locale.ROOT);
if (!allowed.contains(normalized)) throw error("FIN-REQ-400", message);
return normalized;
}
private static BigDecimal money(ResultSet rs, String column) throws SQLException { BigDecimal value = rs.getBigDecimal(column); return value == null ? BigDecimal.ZERO : value; }
private static LocalDateTime time(ResultSet rs, String column) throws SQLException { Timestamp value = rs.getTimestamp(column); return value == null ? null : value.toLocalDateTime(); }
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
@@ -193,6 +265,9 @@ public class FinancePhase1OperationService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
private static Long jsonLong(JsonNode node, String field) { JsonNode value = node.path(field); return value.isIntegralNumber() && value.asLong() > 0 ? value.asLong() : null; }
private static Long firstLong(Long first, Long 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); }
@@ -15,6 +15,7 @@ import com.baiyue.finance.dto.FinanceReadModels.PayableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.PlatformReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ReceivableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.Statement;
import com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary;
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -27,6 +28,7 @@ import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.ArrayList;
@@ -135,6 +137,34 @@ public class FinanceQueryService {
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
}
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
validateOptionalCustomer(customerId);
String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," +
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
"COALESCE(p.credit_days,30) credit_days," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodStart')) AS DATE) period_start," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodEnd')) AS DATE) period_end," +
"COALESCE(l.line_count,0) line_count,COALESCE(l.source_count,0) source_count,COALESCE(l.source_amount,0) source_amount," +
"b.total_amount-COALESCE(l.source_amount,0) adjustment_amount,r.last_receipt_at," +
"a.submitted_at,a.reviewed_at " +
"FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id " +
"LEFT JOIN fin_audit_log c ON c.tenant_id=b.tenant_id AND c.resource_type='RECONCILIATION_STATEMENT' AND c.resource_id=b.id AND c.action='RECONCILIATION_CREATED' AND c.id=(SELECT MIN(c2.id) FROM fin_audit_log c2 WHERE c2.tenant_id=b.tenant_id AND c2.resource_type='RECONCILIATION_STATEMENT' AND c2.resource_id=b.id AND c2.action='RECONCILIATION_CREATED') " +
"LEFT JOIN (SELECT bl.tenant_id,bl.bill_id,COUNT(*) line_count,COUNT(DISTINCT bl.source_id) source_count,SUM(f.amount) source_amount FROM fin_bill_line bl JOIN fin_fee_source f ON f.id=bl.source_id AND f.customer_id=bl.tenant_id GROUP BY bl.tenant_id,bl.bill_id) l ON l.tenant_id=b.tenant_id AND l.bill_id=b.id " +
"LEFT JOIN (SELECT rb.tenant_id,rb.bill_id,MAX(r.received_at) last_receipt_at FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id AND r.status='CONFIRMED' GROUP BY rb.tenant_id,rb.bill_id) r ON r.tenant_id=b.tenant_id AND r.bill_id=b.id " +
"LEFT JOIN (SELECT tenant_id,resource_id,MAX(CASE WHEN action='RECONCILIATION_SUBMIT' THEN created_at END) submitted_at,MAX(CASE WHEN action IN ('RECONCILIATION_APPROVE','RECONCILIATION_REJECT') THEN created_at END) reviewed_at FROM fin_audit_log WHERE resource_type='RECONCILIATION_STATEMENT' GROUP BY tenant_id,resource_id) a ON a.tenant_id=b.tenant_id AND a.resource_id=b.id WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + " ORDER BY b.bill_date DESC,b.id DESC", (rs, rowNum) -> new ReconciliationSummary(
rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"),
date(rs, "period_start"), date(rs, "period_end"), rs.getInt("credit_days"), rs.getString("currency_code"),
rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"),
rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"),
time(rs, "last_receipt_at"), rs.getBigDecimal("outstanding_amount").signum() == 0, rs.getString("status"),
time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"), rs.getString("remark"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
}
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
@@ -249,6 +279,11 @@ public class FinanceQueryService {
return timestamp == null ? null : timestamp.toLocalDateTime();
}
private static LocalDate date(ResultSet rs, String column) throws SQLException {
java.sql.Date date = rs.getDate(column);
return date == null ? null : date.toLocalDate();
}
private static void requireCustomer(long customerId) {
if (customerId <= 0) throw bad("客户范围无效");
}
@@ -43,6 +43,22 @@ import java.util.UUID;
public class FinanceSettlementService {
private static final String CNY = "CNY";
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
private static final String SETTLEMENT_SUMMARY_SELECT =
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
@@ -164,14 +180,12 @@ public class FinanceSettlementService {
public SettlementDetail getSettlement(long settlementId) {
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
Map<String, Object> row = one(
"SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id,supplier_name_snapshot," +
"reconciliation_statement_id,business_period,currency_code,total_amount,paid_amount,outstanding_amount," +
"generated_date,due_date,payment_status,status,version FROM fin_settlement WHERE id=?",
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
"结算单不存在", settlementId);
SettlementSummary summary = summary(row);
List<SettlementLine> lines = jdbc.queryForList(
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
"f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " +
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " +
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList();
@@ -202,17 +216,14 @@ public class FinanceSettlementService {
if (customerId != null && customerId <= 0) {
throw error("FIN_400", "客户范围无效");
}
String sql = "SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id," +
"supplier_name_snapshot,reconciliation_statement_id,business_period,currency_code,total_amount," +
"paid_amount,outstanding_amount,generated_date,due_date,payment_status,status,version " +
"FROM fin_settlement WHERE direction=?";
String sql = SETTLEMENT_SUMMARY_SELECT + "WHERE s.direction=?";
List<Object> args = new ArrayList<>();
args.add(direction);
if (customerId != null) {
sql += " AND tenant_id=?";
sql += " AND s.tenant_id=?";
args.add(customerId);
}
sql += " ORDER BY generated_date DESC,id DESC";
sql += " ORDER BY s.generated_date DESC,s.id DESC";
return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList();
}
@@ -427,10 +438,13 @@ public class FinanceSettlementService {
return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"),
text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"),
nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"),
nullableNumber(row, "reconciliation_statement_id"), text(row, "business_period"),
nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"),
text(row, "business_period"), text(row, "fee_item_summary"),
text(row, "currency_code"), decimal(row, "total_amount"), decimal(row, "paid_amount"),
decimal(row, "outstanding_amount"), date(row, "generated_date"), date(row, "due_date"),
text(row, "payment_status"), text(row, "status"), number(row, "version").intValue());
text(row, "payment_status"), text(row, "status"), number(row, "created_by").longValue(),
text(row, "created_by_name"), time(row, "created_at"), time(row, "updated_at"),
nullableTime(row.get("last_payment_at")), number(row, "version").intValue());
}
private SettlementLine line(Map<String, Object> row, SettlementSummary settlement) {
@@ -446,6 +460,7 @@ public class FinanceSettlementService {
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
supplierId, firstText(jsonText(snapshot, "supplierName"),
firstText(jsonText(snapshot, "partnerName"), settlement.supplierName())),
nullableNumber(row, "order_id"), jsonText(snapshot, "orderNo"),
nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"),
jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"),
quantity, unitPrice, amount, text(row, "currency_code"), firstText(jsonText(snapshot, "reviewStatus"), "PENDING"),
@@ -561,6 +576,9 @@ public class FinanceSettlementService {
if (value instanceof LocalDateTime time) return time;
return value == null ? null : LocalDateTime.parse(value.toString());
}
private static LocalDateTime nullableTime(Object value) {
return value == null ? null : localTime(value);
}
private static String blank(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static boolean truth(Object value) { return value != null && Boolean.parseBoolean(value.toString()); }
private static String jsonText(JsonNode node, String key) {
@@ -163,10 +163,16 @@ public class ReconciliationStatementService {
Bill bill = bill(customerId, statementId);
CreationMetadata metadata = creationMetadata(customerId, statementId);
List<ReconciliationLine> lines = jdbc.query(
"SELECT f.id,l.id statement_line_id,f.source_type,f.source_fee_id,f.source_fee_version,f.source_hash,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_code,f.fee_item_name,f.amount source_amount,l.amount statement_amount,l.adjustment_reason,f.currency_code,f.status FROM fin_bill_line l JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id",
"SELECT f.id,l.id statement_line_id,f.source_type,f.source_no,f.source_fee_id,f.source_fee_version,f.source_hash,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id," +
"JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) inbound_code,JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) container_no," +
"STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d') business_date," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.quantity')) AS DECIMAL(20,6)) quantity," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.unitPrice')) AS DECIMAL(20,6)) unit_price," +
"f.fee_item_code,f.fee_item_name,f.amount source_amount,l.amount statement_amount,l.adjustment_reason,f.currency_code,f.status " +
"FROM fin_bill_line l JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id",
(rs, row) -> line(rs), customerId, statementId);
List<PaymentHistory> payments = jdbc.query(
"SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
"SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
(rs, row) -> payment(rs), customerId, statementId);
List<ReconciliationRevision> revisions = jdbc.query(
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND before_data IS NOT NULL ORDER BY created_at,id",
@@ -394,21 +400,39 @@ public class ReconciliationStatementService {
private static ReconciliationLine line(ResultSet rs) throws SQLException {
return new ReconciliationLine(rs.getLong("statement_line_id"), rs.getLong("id"),
rs.getInt("source_fee_version"), rs.getString("source_hash"), nullableLong(rs, "inbound_id"),
nullableLong(rs, "container_id"), rs.getString("fee_item_name"), rs.getBigDecimal("source_amount"),
rs.getInt("source_fee_version"), rs.getString("source_hash"), rs.getString("direction"),
rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"),
nullableLong(rs, "container_id"), rs.getString("inbound_code"), rs.getString("container_no"),
rs.getDate("business_date") == null ? null : rs.getDate("business_date").toLocalDate(),
rs.getBigDecimal("quantity"), rs.getBigDecimal("unit_price"), rs.getString("fee_item_name"), rs.getBigDecimal("source_amount"),
rs.getBigDecimal("statement_amount"), rs.getString("currency_code"), rs.getString("adjustment_reason"));
}
private static PaymentHistory payment(ResultSet rs) throws SQLException {
private PaymentHistory payment(ResultSet rs) throws SQLException {
Timestamp received = rs.getTimestamp("received_at");
return new PaymentHistory(rs.getLong("id"), "RECEIPT", received.toLocalDateTime().toLocalDate(),
rs.getBigDecimal("applied_amount"), rs.getString("payment_method"), rs.getString("voucher_no"),
rs.getLong("created_by"), rs.getString("created_by_name"),
stringList(rs.getString("voucher_file_ids")), rs.getLong("created_by"), rs.getString("created_by_name"),
rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
}
private List<String> stringList(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
JsonNode node = objectMapper.readTree(value);
if (!node.isArray()) throw error("FIN_001", "收款凭证快照损坏");
List<String> result = new ArrayList<>();
node.forEach(item -> { if (item.isTextual() && StringUtils.hasText(item.asText())) result.add(item.asText()); });
return List.copyOf(result);
} catch (FinanceEventException exception) {
throw exception;
} catch (Exception exception) {
throw error("FIN_001", "收款凭证快照损坏");
}
}
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
return new ReconciliationRevision(revisionNo, rs.getString("action"), amountFromJson(rs.getString("before_data")),
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")),
amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"),
rs.getString("operator_name_snapshot"),
rs.getTimestamp("created_at").toLocalDateTime());
@@ -52,12 +52,16 @@ class FinancePhase1OperationBehaviorTest {
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L);
controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED", true,
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
controller.getFinanceFeeSource(88L);
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion);
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
verify(operationService).listFinanceFeeSources(eq(1), eq(20), eq(9L), eq("RECEIVABLE"),
eq("AVAILABLE"), eq("APPROVED"), eq(true), eq(false), any(), any(), eq("IN-9"));
verify(operationService).getFinanceFeeSource(88L);
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
verify(operationService).updateFeeSourceSettlementInclusion(88L, 71L, "include-key", inclusion);
@@ -112,6 +116,59 @@ class FinancePhase1OperationBehaviorTest {
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L);
}
@Test
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
.thenReturn(1L);
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(88L);
when(rs.getLong("source_fee_id")).thenReturn(9001L);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getLong("customer_id")).thenReturn(9L);
when(rs.getLong("order_id")).thenReturn(19L);
when(rs.getLong("inbound_id")).thenReturn(29L);
when(rs.getLong("container_id")).thenReturn(39L);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
when(rs.getString("source_no")).thenReturn("IN-9");
when(rs.getString("direction")).thenReturn("RECEIVABLE");
when(rs.getString("fee_item_name")).thenReturn("入库操作费");
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("review_status")).thenReturn("APPROVED");
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"partnerId\":79,\"partnerName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("settlement_included")).thenReturn(true);
when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0));
});
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
true, false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
assertThat(result.page()).satisfies(meta -> {
assertThat(meta.page()).isEqualTo(2);
assertThat(meta.pageSize()).isEqualTo(50);
assertThat(meta.total()).isEqualTo(1);
});
assertThat(result.data()).singleElement().satisfies(source -> {
assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG");
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
assertThat(source.inboundCode()).isEqualTo("IN-9");
assertThat(source.containerNo()).isEqualTo("CONT-9");
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
assertThat(source.settlementIncluded()).isTrue();
assertThat(source.statementGenerated()).isFalse();
});
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
}
@Test
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -245,9 +302,12 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getLong("id")).thenReturn(88L);
when(rs.getLong("source_fee_id")).thenReturn(700L);
when(rs.getLong("customer_id")).thenReturn(9L);
when(rs.getLong("order_id")).thenReturn(90L);
when(rs.getLong("inbound_id")).thenReturn(100L);
when(rs.getLong("container_id")).thenReturn(200L);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_no")).thenReturn("IN-100");
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
when(rs.getString("direction")).thenReturn("RECEIVABLE");
when(rs.getString("fee_item_name")).thenReturn("头程运费");
@@ -73,20 +73,31 @@ class FinancePlatformListContractTest {
@Test
void financeDetailsKeepOperatorIdsAndExposeNameSnapshots() {
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary.class,
"periodStart", "periodEnd", "lineCount", "sourceCount", "sourceAmount", "adjustmentAmount",
"lastReceiptAt", "submittedAt", "reviewedAt", "issuedAt");
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.Statement.class,
"createdBy", "createdByName", "updatedBy", "updatedByName");
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.BillRevision.class,
"createdBy", "createdByName");
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail.class,
"createdBy", "createdByName", "updatedBy", "updatedByName");
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine.class,
"direction", "sourceType", "sourceNo", "orderId", "inboundCode", "containerNo",
"businessDate", "quantity", "unitPrice");
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory.class,
"createdBy", "createdByName");
"voucherFileIds", "createdBy", "createdByName");
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
"operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
"createdBy", "createdByName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
"operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
"reconciliationStatementNo", "feeItemSummary", "createdBy", "createdByName", "createdAt",
"updatedAt", "lastPaymentAt");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine.class,
"orderId", "orderNo", "inboundId", "inboundCode", "containerId", "containerNo");
}
private static void assertRecordFields(Class<?> type, String... names) {
@@ -20,10 +20,11 @@ class FinancePlatformOptionalCustomerQueryTest {
service.feePool(null, null);
service.statements(null, false);
service.reconciliationStatements(null);
service.receivableSettlements(null, false);
service.payableSettlements(null);
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> {
assertThat(call.sql()).doesNotContain("customer_id=?", "tenant_id=?");
assertThat(call.args()).isEmpty();
});
@@ -36,10 +37,11 @@ class FinancePlatformOptionalCustomerQueryTest {
service.feePool(31L, null);
service.statements(31L, false);
service.reconciliationStatements(31L);
service.receivableSettlements(31L, false);
service.payableSettlements(31L);
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> {
assertThat(call.sql()).containsAnyOf("customer_id=?", "tenant_id=?");
assertThat(call.args()).containsExactly(31L);
});
@@ -50,6 +52,7 @@ class FinancePlatformOptionalCustomerQueryTest {
FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper());
assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效");
assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效");
assertThatThrownBy(() -> service.feePool(-1L, null)).hasMessageContaining("客户范围无效");
}
@@ -76,7 +76,7 @@ class FinanceReadApiBehaviorTest {
settlementController.payableSettlements(11L);
verify(queryService).customerAccount(11L);
verify(queryService).statements(11L, false);
verify(queryService).reconciliationStatements(11L);
verify(settlementService).listSettlements("RECEIVABLE", 11L);
verify(settlementService).listSettlements("PAYABLE", 11L);
}
@@ -93,10 +93,10 @@ class FinanceReadApiBehaviorTest {
controller.statements(31L);
verify(queryService).feePool(null, null);
verify(queryService).statements(null, false);
verify(queryService).reconciliationStatements(null);
verify(settlementService).listSettlements("RECEIVABLE", null);
verify(settlementService).listSettlements("PAYABLE", null);
verify(queryService).statements(31L, false);
verify(queryService).reconciliationStatements(31L);
}
@Test
@@ -102,10 +102,10 @@ class FinanceSettlementBehaviorTest {
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE"));
verify(jdbc).queryForList(scopedSql.capture(), eq("PAYABLE"), eq(10L));
assertThat(allSql.getValue()).contains("FROM fin_settlement WHERE direction=?")
.doesNotContain("FROM fin_bill", "FROM fin_cost", "tenant_id=?");
assertThat(scopedSql.getValue()).contains("FROM fin_settlement WHERE direction=?", "tenant_id=?")
.doesNotContain("FROM fin_bill", "FROM fin_cost");
assertThat(allSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?")
.doesNotContain("FROM fin_cost", "AND s.tenant_id=?");
assertThat(scopedSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?", "AND s.tenant_id=?")
.doesNotContain("FROM fin_cost");
}
@Test
@@ -232,7 +232,7 @@ class FinanceSettlementBehaviorTest {
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))).thenReturn(List.of(before));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(88L))).thenReturn(List.of(after));
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
@@ -355,7 +355,7 @@ class FinanceSettlementBehaviorTest {
@Test
void settlementDetailRejectsMissingResource() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(999L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(999L))).thenReturn(List.of());
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).getSettlement(999L), "FIN_404");
}
@@ -574,7 +574,7 @@ class FinanceSettlementBehaviorTest {
}
private static void stubSettlementDetail(JdbcTemplate jdbc, Map<String, Object> settlement) {
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(88L))).thenReturn(List.of(settlement));
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
long customerId = ((Number) settlement.get("tenant_id")).longValue();
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
@@ -594,24 +594,32 @@ class FinanceSettlementBehaviorTest {
private static Map<String, Object> settlementRow(String direction, String status, BigDecimal paid,
BigDecimal outstanding, int version) {
return new java.util.HashMap<>(Map.ofEntries(
java.util.HashMap<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("id", 88L), Map.entry("tenant_id", 10L), Map.entry("settlement_no", "AP-20260811-001"),
Map.entry("direction", direction), Map.entry("customer_name_snapshot", "客户10"),
Map.entry("supplier_id", 20L), Map.entry("supplier_name_snapshot", "供应商20"),
Map.entry("business_period", "2026-08"), Map.entry("currency_code", "CNY"),
Map.entry("business_period", "2026-08"), Map.entry("fee_item_summary", "拖车费"),
Map.entry("currency_code", "CNY"),
Map.entry("total_amount", paid.add(outstanding)), Map.entry("paid_amount", paid),
Map.entry("outstanding_amount", outstanding), Map.entry("generated_date", Date.valueOf("2026-08-11")),
Map.entry("due_date", Date.valueOf("2026-09-10")),
Map.entry("payment_status", paid.signum() == 0 ? "UNPAID" : status), Map.entry("status", status),
Map.entry("version", version)));
Map.entry("created_by", 7L), Map.entry("created_by_name", "platform_admin"),
Map.entry("created_at", Timestamp.valueOf("2026-08-11 09:00:00")),
Map.entry("updated_at", Timestamp.valueOf("2026-08-11 09:00:00")), Map.entry("version", version)));
row.put("reconciliation_statement_id", null);
row.put("reconciliation_statement_no", null);
row.put("last_payment_at", null);
return row;
}
private static SettlementDetail detail(String direction, String status, BigDecimal paid,
BigDecimal outstanding, int version) {
return new SettlementDetail(new SettlementSummary(88L, "SET-88", direction, 10L, "客户10", 20L,
"供应商20", null, "2026-08", "CNY", paid.add(outstanding), paid, outstanding,
"供应商20", null, null, "2026-08", "拖车费", "CNY", paid.add(outstanding), paid, outstanding,
LocalDate.of(2026, 8, 11), LocalDate.of(2026, 9, 10), paid.signum() == 0 ? "UNPAID" : status,
status, version), List.of(), List.of(), List.of());
status, 7L, "platform_admin", LocalDateTime.of(2026, 8, 11, 9, 0),
LocalDateTime.of(2026, 8, 11, 9, 0), null, version), List.of(), List.of(), List.of());
}
private static AuthenticatedUser user(Set<String> permissions) {
@@ -47,16 +47,18 @@ class FinanceExportProviderServiceTest {
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
null, new BigDecimal("300"), new BigDecimal("100"), new BigDecimal("200"), "CNY", "ISSUED", 3,
7L, "创建人A", LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"));
ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", 21L, 31L, "运费",
ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", "RECEIVABLE", "CONTAINER_FEE",
"CONT-31", 11L, 21L, 31L, "IN-21", "CONT-31", LocalDate.parse("2026-08-15"), BigDecimal.TEN,
new BigDecimal("30"), "运费",
new BigDecimal("320"), new BigDecimal("300"), "CNY", "优惠调整");
ReconciliationDetail detail = new ReconciliationDetail(501L, "REC-501", 10001L, "客户A",
LocalDate.parse("2026-08-01"), LocalDate.parse("2026-08-31"), 30, "CNY", new BigDecimal("300"),
new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3, 7L, "创建人A",
LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"),
"八月账单", List.of(line), List.of(new PaymentHistory(701L, "RECEIPT", LocalDate.parse("2026-08-20"),
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
FeeSourceDetail source = new FeeSourceDetail(81L, "CONTAINER_FEE", 9001L, 2, "RECEIVABLE", 10001L,
"客户A", null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
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", true,
"AVAILABLE", true, null, null, "hash", 2, List.of());
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -12,7 +13,7 @@ public record InboundOrderSummary(
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
String feeStatus, String feeReviewStatus, String notificationStatus,
Integer version,
LocalDateTime expectedArrivalAt,
LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt,
Long customerId, String customerName, Long companyId, String companyName,
Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName,
@@ -26,18 +27,20 @@ public record InboundOrderSummary(
String locationCodes,
LocalDateTime measureCompletedAt, LocalDateTime inboundedAt,
String expressCompanyCode, String expressCompanyName, String expressTrackingNo,
String loadingStatus, String containerNo, String cabinetNo,
String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt,
LocalDateTime createdAt, LocalDateTime updatedAt
) {
public static InboundOrderSummary from(
InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) {
InboundOrderEntity order, InboundTransportSnapshotEntity transport,
InboundPickupSnapshotEntity pickup, String locationCodes) {
return new InboundOrderSummary(
order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(),
order.getVersion(),
order.getExpectedArrivalAt(), order.getCustomerId(), order.getCustomerName(),
order.getExpectedArrivalAt(), pickup == null ? null : pickup.getPickupAt(),
order.getCustomerId(), order.getCustomerName(),
order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(),
order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(),
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(),
@@ -50,7 +53,6 @@ public record InboundOrderSummary(
locationCodes,
order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(),
order.getExpressCompanyName(), order.getExpressTrackingNo(),
transport == null ? null : transport.getLoadingStatus(),
transport == null ? null : transport.getContainerNo(),
transport == null ? null : transport.getCabinetNo(),
transport == null ? null : transport.getVesselName(),
@@ -166,9 +166,12 @@ public final class LoadingListDtos {
Long itemId,
Long accountId,
String customerName,
Long orderId,
String orderNo,
Long inboundId,
Long inboundDetailId,
String inboundCode,
String containerNo,
String skuCode,
String destinationWarehouse,
String chineseName,
@@ -31,8 +31,25 @@ public record OrderApprovedInboundCommand(
String publicRemark,
String expressCompanyName,
String expressTrackingNo,
Integer totalPackageCount
Integer totalPackageCount,
String pickupMethod,
LocalDateTime requestedPickupAt
) {
public OrderApprovedInboundCommand(
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName,
Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode,
Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode,
String destinationName, String publicRemark, String expressCompanyName, String expressTrackingNo,
Integer totalPackageCount
) {
this(tenantId, orderId, orderNo, inboundCode, customerId, customerName, warehouseId, warehouseName,
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo, totalPackageCount, null, null);
}
/** 兼容尚未携带订单总箱数的历史事件。 */
public OrderApprovedInboundCommand(
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
@@ -46,7 +63,7 @@ public record OrderApprovedInboundCommand(
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo, null);
expressTrackingNo, null, null, null);
}
/** 兼容未携带报价包税维度的历史调用方。 */
@@ -62,6 +79,6 @@ public record OrderApprovedInboundCommand(
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo, null);
expressTrackingNo, null, null, null);
}
}
@@ -62,5 +62,11 @@ public class ContainerBookingEntity {
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
@TableField(exist = false) private Long planId;
@TableField(exist = false) private String planNo;
@TableField(exist = false) private String planStatus;
@TableField(exist = false) private Boolean exceptionActive;
@TableField(exist = false) private String destinationClearanceDocumentStatus;
@TableField(exist = false) private String customsDeclarationDocumentStatus;
@TableField(exist = false) private String deliveryOrderDocumentStatus;
@TableField(exist = false) private String pickupOrderDocumentStatus;
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 客户上门提货要求在入库域的快照。 */
@Getter
@Setter
@TableName("fl_inbound_pickup_snapshot")
public class InboundPickupSnapshotEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String pickupMethod;
private LocalDateTime pickupAt;
private LocalDateTime createdAt;
}
@@ -62,4 +62,5 @@ public class TruckAppointmentEntity {
@TableField(exist = false) private String bookingAgentName;
@TableField(exist = false) private String cabinetNo;
@TableField(exist = false) private String containerNo;
@TableField(exist = false) private Long attachmentCount;
}
@@ -13,7 +13,20 @@ import org.apache.ibatis.annotations.Update;
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
@Select("""
<script>
SELECT b.*, p.id AS plan_id,
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
CASE WHEN (SELECT eh.action FROM fl_container_exception_history eh
WHERE eh.container_booking_id=b.id ORDER BY eh.occurred_at DESC,eh.id DESC LIMIT 1)='MARK'
THEN TRUE ELSE FALSE END AS exception_active,
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status,
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status,
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
@@ -61,7 +74,20 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page, @Param("query") Query query);
@Select("""
SELECT b.*, p.id AS plan_id,
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
CASE WHEN (SELECT eh.action FROM fl_container_exception_history eh
WHERE eh.container_booking_id=b.id ORDER BY eh.occurred_at DESC,eh.id DESC LIMIT 1)='MARK'
THEN TRUE ELSE FALSE END AS exception_active,
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status,
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status,
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface InboundPickupSnapshotMapper extends BaseMapper<InboundPickupSnapshotEntity> {
}
@@ -171,16 +171,19 @@ public interface LoadingListQueryMapper {
@Param("operatorName") String operatorName);
@Select("""
SELECT id AS item_id, account_id, customer_name, inbound_id, inbound_detail_id,
inbound_code, sku_code, destination_warehouse_name, product_name_zh,
SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no,
i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no,
i.sku_code, i.destination_warehouse_name, i.product_name_zh,
product_name_en, product_image_url, package_count, pallet_count,
gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg,
product_quantity, units_per_carton, fba_tracking_no, shipment_no,
shipping_mark, internal_remark, customer_remark, warehouse_remark,
load_sequence, frozen_at
FROM fl_loading_plan_item
WHERE loading_plan_id = #{planId} AND deleted = 0
ORDER BY load_sequence, id
FROM fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id=i.loading_plan_id
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
WHERE i.loading_plan_id = #{planId} AND i.deleted = 0
ORDER BY i.load_sequence, i.id
""")
List<ItemRow> selectItems(@Param("planId") long planId);
@@ -337,9 +340,12 @@ public interface LoadingListQueryMapper {
private Long itemId;
private Long accountId;
private String customerName;
private Long orderId;
private String orderNo;
private Long inboundId;
private Long inboundDetailId;
private String inboundCode;
private String containerNo;
private String skuCode;
private String destinationWarehouseName;
private String productNameZh;
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List<TruckAppointmentEntity> activeForUpdate(@Param("containerBookingId") long containerBookingId);
@Select("<script>SELECT * FROM fl_truck_appointment WHERE (#{q.keyword} IS NULL OR appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR status=#{q.status}) AND (#{q.customerId} IS NULL OR customer_id=#{q.customerId}) AND (#{q.containerBookingId} IS NULL OR container_booking_id=#{q.containerBookingId}) ORDER BY updated_at DESC,id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,COALESCE(o.warehouse_name,c.warehouse_name) warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,c.cabinet_no,c.container_no,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.record_status='ACTIVE' WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.customerId} IS NULL OR a.customer_id=#{q.customerId}) AND (#{q.containerBookingId} IS NULL OR a.container_booking_id=#{q.containerBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
@@ -54,4 +54,7 @@ public class OceanBookingEntity {
private String updatedByName;
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
@TableField(exist = false) private String shipperName;
@TableField(exist = false) private String consigneeName;
@TableField(exist = false) private Long containerCount;
}
@@ -15,16 +15,24 @@ import java.time.LocalDateTime;
public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Select("""
<script>
SELECT * FROM fl_ocean_booking
WHERE (#{query.customerId} IS NULL OR customer_id=#{query.customerId})
AND status IN ('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED')
AND (#{query.status} IS NULL OR status=#{query.status})
AND (#{query.keyword} IS NULL OR booking_no LIKE CONCAT('%',#{query.keyword},'%')
OR customer_name LIKE CONCAT('%',#{query.keyword},'%')
OR bill_of_lading_no LIKE CONCAT('%',#{query.keyword},'%')
OR vessel_name LIKE CONCAT('%',#{query.keyword},'%')
OR voyage_no LIKE CONCAT('%',#{query.keyword},'%'))
ORDER BY updated_at DESC,id DESC
SELECT booking.*,
(SELECT party.party_name FROM fl_ocean_booking_party party
WHERE party.ocean_booking_id=booking.id AND party.party_type='SHIPPER' LIMIT 1) shipper_name,
(SELECT party.party_name FROM fl_ocean_booking_party party
WHERE party.ocean_booking_id=booking.id AND party.party_type='CONSIGNEE' LIMIT 1) consignee_name,
(SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation
JOIN fl_container_booking container ON container.id=allocation.container_booking_id
WHERE allocation.ocean_booking_id=booking.id AND container.record_status='ACTIVE') container_count
FROM fl_ocean_booking booking
WHERE (#{query.customerId} IS NULL OR booking.customer_id=#{query.customerId})
AND booking.status IN ('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED')
AND (#{query.status} IS NULL OR booking.status=#{query.status})
AND (#{query.keyword} IS NULL OR booking.booking_no LIKE CONCAT('%',#{query.keyword},'%')
OR booking.customer_name LIKE CONCAT('%',#{query.keyword},'%')
OR booking.bill_of_lading_no LIKE CONCAT('%',#{query.keyword},'%')
OR booking.vessel_name LIKE CONCAT('%',#{query.keyword},'%')
OR booking.voyage_no LIKE CONCAT('%',#{query.keyword},'%'))
ORDER BY booking.updated_at DESC,booking.id DESC
</script>
""")
Page<OceanBookingEntity> selectPage(Page<OceanBookingEntity> page, @Param("query") Query query);
@@ -20,7 +20,8 @@ public final class OutboundDispatchDtos {
public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId,
Long customerId, String customerName, String inboundStatus,
Long containerBookingId, String containerNo, Long loadingPlanId, String loadingPlanStatus,
Long containerBookingId, String cabinetNo, String containerNo,
Long loadingPlanId, String loadingPlanStatus,
Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus,
Long truckAppointmentId, String truckAppointmentStatus,
String dispatchStatus, Integer version, GateResult gate) {}
@@ -15,7 +15,7 @@ public interface OutboundDispatchReadMapper {
SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId,
inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId,
inbound.customer_name customerName,inbound.process_status inboundStatus,
booking.id containerBookingId,booking.container_no containerNo,
booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo,
plan.id loadingPlanId,plan.plan_status loadingPlanStatus
FROM fl_loading_plan_item item
JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id
@@ -100,7 +100,7 @@ public interface OutboundDispatchReadMapper {
record Binding(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId,
Long customerId, String customerName, String inboundStatus, Long containerBookingId,
String containerNo, Long loadingPlanId, String loadingPlanStatus) {}
String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {}
record IdStatus(Long id, String status) {}
record Evidence(Integer evidenceCount, Integer validSeal) {}
}
@@ -109,7 +109,8 @@ public class OutboundDispatchService {
GateResult gate = gate(binding, pick, ocean, truck, evidence);
return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(),
binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(),
binding.containerBookingId(), binding.containerNo(), binding.loadingPlanId(), binding.loadingPlanStatus(),
binding.containerBookingId(), binding.cabinetNo(), binding.containerNo(),
binding.loadingPlanId(), binding.loadingPlanStatus(),
id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck),
dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(),
dispatch == null ? 0 : dispatch.getVersion(), gate);
@@ -14,12 +14,14 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity;
import com.baiyue.firstleg.mapper.InboundFileMapper;
import com.baiyue.firstleg.mapper.InboundInternalNoteMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
@@ -37,6 +39,8 @@ public class InboundOrderRepository {
@Resource
private InboundTransportSnapshotMapper transports;
@Resource
private InboundPickupSnapshotMapper pickupSnapshots;
@Resource
private InboundPalletAllocationMapper pallets;
@Resource
private InboundFileMapper files;
@@ -82,9 +86,11 @@ public class InboundOrderRepository {
Map<Long, Long> tenantByInbound = result.getRecords().stream()
.collect(Collectors.toMap(InboundOrderEntity::getId, InboundOrderEntity::getTenantId));
Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, tenantByInbound);
Map<Long, InboundPickupSnapshotEntity> pickupByInbound = pickupMap(tenantId, tenantByInbound);
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, tenantByInbound);
List<InboundOrderSummary> records = result.getRecords().stream()
.map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()),
pickupByInbound.get(order.getId()),
locationCodesByInbound.get(order.getId())))
.toList();
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
@@ -191,6 +197,9 @@ public class InboundOrderRepository {
InboundTransportSnapshotEntity transport = transports.selectOne(
owned(InboundTransportSnapshotEntity::getTenantId,
InboundTransportSnapshotEntity::getInboundId, tenantId, inboundId));
InboundPickupSnapshotEntity pickup = pickupSnapshots.selectOne(
owned(InboundPickupSnapshotEntity::getTenantId,
InboundPickupSnapshotEntity::getInboundId, tenantId, inboundId));
List<InboundPalletAllocationEntity> palletAllocations = pallets.selectList(
owned(InboundPalletAllocationEntity::getTenantId,
InboundPalletAllocationEntity::getInboundId, tenantId, inboundId)
@@ -228,7 +237,7 @@ public class InboundOrderRepository {
.map(InboundPalletAllocationEntity::getLocationCode)
.filter(StringUtils::hasText)
.collect(Collectors.joining("、"));
return new InboundOrderDetail(InboundOrderSummary.from(order, transport, locationCodes),
return new InboundOrderDetail(InboundOrderSummary.from(order, transport, pickup, locationCodes),
order.getPublicRemark(),
palletSummaries, fileSummaries, noteSummaries, historySummaries);
}
@@ -247,6 +256,17 @@ public class InboundOrderRepository {
Function.identity(), (left, right) -> left));
}
public Map<Long, InboundPickupSnapshotEntity> pickupMap(
Long tenantId, Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of();
return pickupSnapshots.selectList(new LambdaQueryWrapper<InboundPickupSnapshotEntity>()
.eq(tenantId != null, InboundPickupSnapshotEntity::getTenantId, tenantId)
.in(InboundPickupSnapshotEntity::getInboundId, tenantByInbound.keySet()))
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.toMap(InboundPickupSnapshotEntity::getInboundId,
Function.identity(), (left, right) -> left));
}
public Map<Long, String> locationCodesMap(Long tenantId, Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) {
return Map.of();
@@ -171,7 +171,8 @@ public class LoadingListRepository {
private List<Item> items(List<ItemRow> rows) {
return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(),
row.getInboundId(), row.getInboundDetailId(), row.getInboundCode(), row.getSkuCode(),
row.getOrderId(), row.getOrderNo(), row.getInboundId(), row.getInboundDetailId(),
row.getInboundCode(), row.getContainerNo(), row.getSkuCode(),
row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(),
row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()),
decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()),
@@ -9,8 +9,10 @@ import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.dto.InboundOrderReference;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
@@ -27,6 +29,8 @@ public class InboundOrderCreationService {
@Resource
private InboundStatusHistoryMapper histories;
@Resource
private InboundPickupSnapshotMapper pickupSnapshots;
@Resource
private FulfillmentTaskProvisioningService fulfillmentTasks;
@Transactional
@@ -37,6 +41,7 @@ public class InboundOrderCreationService {
FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now());
linkTask(existing, task.getId());
upgradePreparing(existing, command);
savePickupSnapshot(existing.getId(), command, LocalDateTime.now());
return inboundId;
}
@@ -87,6 +92,7 @@ public class InboundOrderCreationService {
inbound.setVersion(0);
try {
orders.insert(inbound);
savePickupSnapshot(inbound.getId(), command, now);
} catch (DuplicateKeyException duplicate) {
InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true);
if (winner == null) {
@@ -119,6 +125,7 @@ public class InboundOrderCreationService {
if (existing != null) {
long inboundId = requireMatchingInboundCode(existing, command.inboundCode());
refreshPreparing(existing, command);
savePickupSnapshot(existing.getId(), command, LocalDateTime.now());
return inboundId;
}
@@ -126,6 +133,7 @@ public class InboundOrderCreationService {
InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "DRAFT");
try {
orders.insert(inbound);
savePickupSnapshot(inbound.getId(), command, now);
} catch (DuplicateKeyException duplicate) {
InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true);
if (winner == null) {
@@ -248,6 +256,27 @@ public class InboundOrderCreationService {
histories.insert(history);
}
private void savePickupSnapshot(long inboundId, OrderApprovedInboundCommand command, LocalDateTime now) {
InboundPickupSnapshotEntity existing = pickupSnapshots.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundPickupSnapshotEntity>()
.eq(InboundPickupSnapshotEntity::getTenantId, command.tenantId())
.eq(InboundPickupSnapshotEntity::getInboundId, inboundId));
if (existing == null) {
InboundPickupSnapshotEntity snapshot = new InboundPickupSnapshotEntity();
snapshot.setId(IdWorker.getId());
snapshot.setTenantId(command.tenantId());
snapshot.setInboundId(inboundId);
snapshot.setPickupMethod(command.pickupMethod());
snapshot.setPickupAt(command.requestedPickupAt());
snapshot.setCreatedAt(now);
pickupSnapshots.insert(snapshot);
} else if ("PREPARING".equals(findByOrder(command.tenantId(), command.orderId(), false).getProcessStatus())) {
existing.setPickupMethod(command.pickupMethod());
existing.setPickupAt(command.requestedPickupAt());
pickupSnapshots.updateById(existing);
}
}
private InboundOrderEntity findByOrder(long tenantId, long orderId, boolean currentRead) {
var query = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
@@ -51,7 +51,7 @@ public class TruckAppointmentService {
private BookingLink validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(b.getCustomerId()==null||!StringUtils.hasText(b.getCustomerName()))throw conflict("关联订舱缺少客户快照"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(StringUtils.hasText(r.customerName())&&!r.customerName().trim().equals(b.getCustomerName()))throw bad("customerName与订舱客户快照不一致"); ContainerBookingEntity c=containers.find(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致"); LoadingPlanEntity plan=loadingPlans.planByBooking(r.containerBookingId()); if(plan==null)throw conflict("关联柜次尚未建立装柜计划"); if(r.loadingPlanId()!=null&&!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜体当前装柜计划不一致"); return new BookingLink(b,plan);}
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}return value;}
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
private void copy(TruckAppointmentEntity e,SaveRequest r,BookingLink link,String carrierName){OceanBookingEntity booking=link.booking();e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(link.plan().getId());e.setWarehouseId(r.warehouseId());e.setCustomerId(booking.getCustomerId());e.setCustomerName(booking.getCustomerName());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());}
private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}}
private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}}
@@ -77,7 +77,8 @@ public class UnifiedInboundQueryService {
}
InboundTransportSnapshotEntity transport = includePhaseThree
? operations.findTransport(tenantId, inbound.getId()) : null;
return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt, transport, null), timeline);
return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt,
transport, null, null), timeline);
}
private List<UnifiedInboundSummary> summaries(List<InboundOrderEntity> records) {
@@ -93,17 +94,19 @@ public class UnifiedInboundQueryService {
Map<Long, Long> owners = records.stream().collect(Collectors.toMap(
InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left));
var locationCodes = inbounds.locationCodesMap(null, owners);
var pickups = inbounds.pickupMap(null, owners);
return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()),
picks.get(inbound.getId()), evidences.getOrDefault(inbound.getId(), List.of()),
deliveries.get(inbound.getId()) == null ? null : deliveries.get(inbound.getId()).getOccurredAt(),
transports.get(inbound.getId()), locationCodes.get(inbound.getId()))).toList();
transports.get(inbound.getId()), locationCodes.get(inbound.getId()), pickups.get(inbound.getId()))).toList();
}
private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail,
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick,
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
InboundTransportSnapshotEntity transport, String locationCodes) {
InboundTransportSnapshotEntity transport, String locationCodes,
com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) {
String mainStatus = mainStatus(inbound.getProcessStatus());
List<LoadingEvidenceEntity> activeEvidence = evidence.stream()
.filter(item -> item.getStatus() == null || "ACTIVE".equals(item.getStatus())).toList();
@@ -122,7 +125,7 @@ public class UnifiedInboundQueryService {
"DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt,
inbound.getExpressTrackingNo()) : null;
var inboundSummary = detail == null
? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, locationCodes) : detail.summary();
? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, pickup, locationCodes) : detail.summary();
return new UnifiedInboundSummary(inboundSummary, mainStatus,
nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()),
preloadSummary, loadingSummary, deliverySummary);
@@ -1011,6 +1011,7 @@ components:
notificationStatus: {type: string, nullable: true}
version: {type: integer, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from fl_inbound_pickup_snapshot.pickup_at}
customerId: {type: integer, format: int64, nullable: true}
customerName: {type: string, nullable: true}
companyId: {type: integer, format: int64, nullable: true}
@@ -1045,7 +1046,6 @@ components:
inboundedAt: {type: string, format: date-time, nullable: true}
expressCompanyCode: {type: string, nullable: true}
expressCompanyName: {type: string, nullable: true}
loadingStatus: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
cabinetNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
@@ -1053,6 +1053,8 @@ components:
sailingAt: {type: string, format: date-time, nullable: true}
portArrivalAt: {type: string, format: date-time, nullable: true}
expressTrackingNo: {type: string, nullable: true}
pickupMethod: {type: string, enum: [SELF_EXPRESS, PICKUP, SELF_TRUCK], nullable: true}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: 客户要求平台上门提货的预约时间}
createdByName: {type: string, nullable: true}
createdAt: {type: string, format: date-time, nullable: true}
updatedAt: {type: string, format: date-time, nullable: true}
@@ -1370,10 +1372,11 @@ components:
pages: {type: string, pattern: '^[0-9]+$'}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, version]
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, pickupOrderDocumentStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
planNo: {type: string, nullable: true, description: 装柜清单号;未创建计划时为空}
bookingNo: {type: string, maxLength: 40}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
@@ -1410,7 +1413,16 @@ components:
estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}
exceptionActive: {type: boolean, description: 最新异常历史动作是否为 MARK}
destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
customsDeclarationDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
deliveryOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
pickupOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
version: {type: integer, minimum: 0}
ContainerDocumentStatus:
type: string
enum: [MISSING, UPLOADED, COMPLETED]
description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED
ContainerSaveRequest:
type: object
required: [cabinetNo, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
@@ -369,14 +369,17 @@ components:
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
LoadingItem:
type: object
required: [itemId, accountId, inboundId, inboundDetailId, packageCount, palletCount, grossWeightKg, netWeightKg, volumeCbm, dimensionalWeightKg, productQuantity, unitsPerPackage, loadSequence]
required: [itemId, accountId, orderId, orderNo, inboundId, inboundDetailId, packageCount, palletCount, grossWeightKg, netWeightKg, volumeCbm, dimensionalWeightKg, productQuantity, unitsPerPackage, loadSequence]
properties:
itemId: {$ref: '#/components/schemas/LongId'}
accountId: {$ref: '#/components/schemas/LongId'}
customerName: {type: string, nullable: true, description: 客户名称快照}
orderId: {$ref: '#/components/schemas/LongId'}
orderNo: {type: string, description: 订单号快照}
inboundId: {$ref: '#/components/schemas/LongId'}
inboundDetailId: {$ref: '#/components/schemas/LongId'}
inboundCode: {type: string, nullable: true, description: 入仓码}
containerNo: {type: string, nullable: true, description: 当前 ACTIVE 柜次的真实集装箱号}
skuCode: {type: string, nullable: true, description: SKU 编码快照}
destinationWarehouse: {type: string, nullable: true, description: 目的仓名称快照}
chineseName: {type: string, nullable: true, description: 中文品名快照}
@@ -18,7 +18,18 @@ paths:
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses: {'200': {description: Operational real booking page}}
responses:
'200':
description: Operational real booking page; each record follows OceanBookingSummary
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/OceanBookingSummary'}}
post:
summary: Register an offline-confirmed ocean booking with complete transport snapshots
description: 'Requires platform:firstleg:ocean-booking:create. Registers every real container unit atomically, creates one stable fl_container_booking plus one requirement/allocation per container, and persists the booking directly as CONFIRMED. RELEASED remains a separate later action.'
@@ -64,6 +75,34 @@ components:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
OceanBookingSummary:
type: object
required: [id, bookingNo, status, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt, containerCount, version]
properties:
id: {type: integer, format: int64}
bookingNo: {type: string}
status: {type: string, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED]}
customerId: {type: integer, format: int64, nullable: true}
customerName: {type: string, nullable: true}
billOfLadingNo: {type: string}
shipperName: {type: string, nullable: true, description: SHIPPER party snapshot}
consigneeName: {type: string, nullable: true, description: CONSIGNEE party snapshot}
shippingCompanyId: {type: integer, format: int64, nullable: true}
shippingCompanyName: {type: string, nullable: true}
bookingAgentId: {type: integer, format: int64, nullable: true}
bookingAgentName: {type: string, nullable: true}
vesselName: {type: string}
voyageNo: {type: string}
departurePortCode: {type: string, nullable: true}
departurePortName: {type: string, nullable: true}
destinationPortCode: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
estimatedSailingAt: {type: string, format: date-time}
estimatedArrivalAt: {type: string, format: date-time}
containerCount: {type: integer, format: int64, minimum: 0, description: ACTIVE real container units allocated to the booking}
createdByName: {type: string, nullable: true}
updatedAt: {type: string, format: date-time}
version: {type: integer, minimum: 0}
CreateRequest:
type: object
required: [expectedVersion, data]
@@ -11,7 +11,14 @@ paths:
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
responses:
'200': {description: Business envelope containing matching aggregates}
'200':
description: Business envelope containing matching aggregates, each with stable cabinetNo and real containerNo
content:
application/json:
schema:
type: object
properties:
data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}}
/api/platform/v1/first-leg/outbound-dispatches/detail:
get:
summary: Query one aggregate and its unified timeline
@@ -20,7 +27,17 @@ paths:
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
responses:
'200': {description: Business envelope containing aggregate detail}
'200':
description: Business envelope containing aggregate detail
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
aggregate: {$ref: '#/components/schemas/Aggregate'}
/api/platform/v1/first-leg/outbound-dispatches/check-gate:
get:
summary: Evaluate the fail-closed domestic outbound gate
@@ -59,6 +76,32 @@ components:
ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
Aggregate:
type: object
required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus, containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, gate]
properties:
orderId: {type: integer, format: int64}
orderNo: {type: string}
inboundId: {type: integer, format: int64}
inboundCode: {type: string}
tenantId: {type: integer, format: int64}
customerId: {type: integer, format: int64, nullable: true}
customerName: {type: string, nullable: true}
inboundStatus: {type: string}
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string, description: 稳定柜次号}
containerNo: {type: string, nullable: true, description: 真实集装箱号}
loadingPlanId: {type: integer, format: int64}
loadingPlanStatus: {type: string}
pickTaskId: {type: integer, format: int64, nullable: true}
pickTaskStatus: {type: string, nullable: true}
oceanBookingId: {type: integer, format: int64, nullable: true}
oceanBookingStatus: {type: string, nullable: true}
truckAppointmentId: {type: integer, format: int64, nullable: true}
truckAppointmentStatus: {type: string, nullable: true}
dispatchStatus: {type: string}
version: {type: integer, minimum: 0}
gate: {type: object}
ActionRequest:
type: object
required: [inboundId, containerBookingId, expectedVersion]
@@ -10,7 +10,17 @@ paths:
parameters:
- {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: Paged truck appointments}
'200':
description: Paged truck appointments with stable booking/container snapshots and attachment count
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
post:
summary: Create a truck appointment linked to a released ocean booking
requestBody:
@@ -185,3 +195,4 @@ components:
bookingAgentName: {type: string}
carrierId: {type: integer, format: int64}
carrierName: {type: string}
attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment}
@@ -51,6 +51,9 @@ class InboundOrderControllerContractTest {
.contains("required: false")
.contains("InboundOrderSummary:")
.contains("required: [id, tenantId]")
.contains("actualNetWeightKg: {type: number, nullable: true}")
.contains("chargeableVolumeCbm: {type: number, nullable: true}")
.contains("measureCompletedAt: {type: string, format: date-time, nullable: true}")
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
.contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:")
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, WAIT_SPLIT]")
@@ -40,8 +40,8 @@ class UnifiedInboundOpenApiContractTest {
"businessType", "channelCode", "pricingTaxMode", "containerMode", "destinationType",
"holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm",
"volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount",
"inboundedAt", "expressCompanyCode", "expressCompanyName", "loadingStatus", "containerNo",
"cabinetNo", "sailingAt", "portArrivalAt", "createdAt", "updatedAt");
"inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo",
"cabinetNo", "sailingAt", "portArrivalAt", "createdByName", "createdAt", "updatedAt");
}
@SuppressWarnings("unchecked")
@@ -16,10 +16,12 @@ class OceanBookingMapperContractTest {
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("customer_id=#{query.customerId}")
.contains("status=#{query.status}")
.contains("booking_no LIKE")
.contains("ORDER BY updated_at DESC,id DESC");
assertThat(sql).contains("booking.customer_id=#{query.customerId}")
.contains("booking.status=#{query.status}")
.contains("booking.booking_no LIKE")
.contains("party.party_type='SHIPPER'", "party.party_type='CONSIGNEE'")
.contains("fl_ocean_booking_container_allocation", "container.record_status='ACTIVE'", "container_count")
.contains("ORDER BY booking.updated_at DESC,booking.id DESC");
}
@Test
@@ -7,6 +7,14 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OutboundDispatchReadMapperContractTest {
@Test
void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception {
String sql = String.join("\n", OutboundDispatchReadMapper.class
.getMethod("bindings", Long.class, Long.class, Long.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo");
}
@Test
void pickTaskQueryOrdersByTheSchemaStartedAtColumn() throws Exception {
String sql = OutboundDispatchReadMapper.class.getMethod("pick", long.class)
@@ -129,7 +129,7 @@ class OutboundDispatchServiceTest {
private static OutboundDispatchReadMapper.Binding binding() {
return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A",
"LOADED", 22L, "C-22", 44L, "COMPLETED");
"LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED");
}
private static OutboundDispatchGateContributor passing(String code) {
@@ -24,6 +24,10 @@ class ContainerBookingMapperContractTest {
assertThat(select)
.contains("fl_loading_plan")
.contains("p.id AS plan_id")
.contains("p.plan_no AS plan_no")
.contains("fl_container_exception_history")
.contains("destination_clearance_document_status")
.contains("delivery_order_document_status")
.contains("CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'")
.contains("plan_status")
.contains("p.plan_status != 'COMPLETED'")
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.mockito.ArgumentCaptor;
@@ -37,6 +38,7 @@ import static org.mockito.Mockito.when;
class InboundOrderRepositoryTest {
@MockBean private InboundOrderMapper orders;
@MockBean private InboundTransportSnapshotMapper transports;
@MockBean private InboundPickupSnapshotMapper pickupSnapshots;
@MockBean private InboundPalletAllocationMapper pallets;
@MockBean private InboundFileMapper files;
@MockBean private InboundInternalNoteMapper notes;
@@ -162,8 +164,6 @@ class InboundOrderRepositoryTest {
assertThat(result.records()).extracting(record -> record.tenantId())
.containsExactly(10001L, 10002L);
assertThat(result.records()).extracting(record -> record.loadingStatus())
.containsExactly("T1", "T2");
assertThat(result.records()).extracting(record -> record.locationCodes())
.containsExactly("A1", "B1");
ArgumentCaptor<Wrapper<InboundOrderEntity>> orderCaptor = ArgumentCaptor.forClass(Wrapper.class);
@@ -54,6 +54,16 @@ class LoadingListQueryMapperContractTest {
.doesNotContain("fl_loading_list");
}
@Test
void detailItemsExposeFrozenOrderAndActiveContainerFacts() throws Exception {
String items = sql("selectItems", Select.class);
assertThat(items)
.contains("i.order_id", "i.order_no", "b.container_no")
.contains("b.record_status='ACTIVE'")
.contains("i.loading_plan_id = #{planId}")
.contains("i.deleted = 0");
}
@Test
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
String touch = sql("touchEditable", Update.class);
@@ -13,7 +13,8 @@ class TruckAppointmentMapperContractTest {
@Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception {
assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment");
String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0];
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "customer_id=#{q.customerId}", "container_booking_id=#{q.containerBookingId}");
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "customer_id=#{q.customerId}", "container_booking_id=#{q.containerBookingId}",
"fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count");
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1");
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -42,6 +43,8 @@ class InboundOrderCreationServiceTest {
@Mock
private InboundStatusHistoryMapper histories;
@Mock
private InboundPickupSnapshotMapper pickupSnapshots;
@Mock
private FulfillmentTaskProvisioningService fulfillmentTasks;
@InjectMocks
private InboundOrderCreationService service;
@@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.RbacDtos.*;
import com.baiyue.iam.entity.IamPermission;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.service.RbacService;
import com.baiyue.iam.service.TenantGuard;
import io.swagger.v3.oas.annotations.Operation;
@@ -45,7 +44,7 @@ public class RbacController {
@PostMapping("/roles")
@Operation(summary = "创建角色")
@PreAuthorize("hasAuthority(#app + ':role:create')")
public ApiResponse<IamRole> createRole(@PathVariable String app,
public ApiResponse<RoleResponse> createRole(@PathVariable String app,
@Valid @RequestBody CreateRoleRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
/** 企业成员接口的数据契约。 */
@@ -44,8 +45,9 @@ public final class MemberDtos {
public record ResetMemberPasswordRequest(@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
public record MemberResponse(Long membershipId, Long userId, Long authUserId,
public record MemberResponse(Long membershipId, Long userId, Long authUserId, String username,
String displayName, String phone, String email,
String memberType, String memberNo, String status, List<Long> roleIds) { }
String memberType, String memberNo, String status, LocalDateTime lastLoginAt,
List<Long> roleIds) { }
}
@@ -25,7 +25,7 @@ public final class RbacDtos {
/** 角色回显模型,permissionIds 可直接用于全量授权保存。 */
public record RoleResponse(Long id, Long tenantId, String appCode, String roleCode,
String roleName, String roleType, String status,
String description, Set<Long> permissionIds) { }
String description, long memberCount, Set<Long> permissionIds) { }
public record MemberRolesResponse(Long membershipId, Set<Long> roleIds) { }
@@ -8,7 +8,9 @@ import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** RBAC 仓储,所有业务查询均显式携带租户和应用域。 */
@@ -132,6 +134,23 @@ public class RbacRepository {
.eq(IamMembershipRole::getRoleId, roleId)) > 0;
}
public long countRoleMembers(long tenantId, Long roleId) {
return membershipRoleMapper.selectCount(new LambdaQueryWrapper<IamMembershipRole>()
.eq(IamMembershipRole::getTenantId, tenantId)
.eq(IamMembershipRole::getRoleId, roleId));
}
public Map<Long, Long> findRoleMemberCounts(long tenantId, List<Long> roleIds) {
if (roleIds.isEmpty()) return Map.of();
List<IamMembershipRole> assignments = membershipRoleMapper.selectList(
new LambdaQueryWrapper<IamMembershipRole>()
.eq(IamMembershipRole::getTenantId, tenantId)
.in(IamMembershipRole::getRoleId, roleIds));
Map<Long, Long> counts = new LinkedHashMap<>();
assignments.forEach(assignment -> counts.merge(assignment.getRoleId(), 1L, Long::sum));
return counts;
}
public List<Long> findAssignedAuthUserIds(long tenantId, Long roleId) {
return membershipRoleMapper.findAssignedAuthUserIds(tenantId, roleId);
}
@@ -189,8 +189,9 @@ public class MemberService {
return response(tenantId, membership, user);
}
private MemberResponse response(long tenantId, IamMembership m, IamUser u) {
return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), u.getDisplayName(),
u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(),
AuthAccountClient.AccountSummary account = accountSummary(u.getAuthUserId());
return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), account.username(), u.getDisplayName(),
u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(), account.lastLoginAt(),
repository.findRoleIds(tenantId, m.getId()));
}
private IamMembership required(long tenantId, Long membershipId) {
@@ -37,19 +37,24 @@ public class RbacService {
private String internalToken;
public List<RoleResponse> roles(long tenantId, String appCode) {
return repository.findRoles(tenantId, appCode).stream()
.map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId())))
List<IamRole> roles = repository.findRoles(tenantId, appCode);
Map<Long, Long> memberCounts = repository.findRoleMemberCounts(
tenantId, roles.stream().map(IamRole::getId).toList());
return roles.stream()
.map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId()),
memberCounts.getOrDefault(role.getId(), 0L)))
.toList();
}
public RoleResponse role(long tenantId, String appCode, Long roleId) {
IamRole role = requireRole(tenantId, appCode, roleId);
return response(role, repository.findRolePermissionIds(tenantId, roleId));
return response(role, repository.findRolePermissionIds(tenantId, roleId),
repository.countRoleMembers(tenantId, roleId));
}
@BusinessAudit(event = "ROLE_CREATE", resourceType = "ROLE", resourceId = "#request.roleCode",
description = "创建角色", captureBefore = false, captureAfter = true)
public IamRole createRole(long tenantId, String appCode, CreateRoleRequest request) {
public RoleResponse createRole(long tenantId, String appCode, CreateRoleRequest request) {
if (repository.existsRoleCode(tenantId, appCode, request.roleCode(), null)) {
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
}
@@ -68,7 +73,7 @@ public class RbacService {
// 数据库唯一键负责兜底并发创建,向调用方保持稳定的业务错误。
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
}
return role;
return response(role, Set.of(), 0L);
}
@BusinessAudit(event = "ROLE_UPDATE", resourceType = "ROLE", resourceId = "#roleId",
@@ -78,7 +83,8 @@ public class RbacService {
role.setRoleName(request.roleName());
role.setDescription(request.description());
repository.updateRole(role);
return response(role, repository.findRolePermissionIds(tenantId, roleId));
return response(role, repository.findRolePermissionIds(tenantId, roleId),
repository.countRoleMembers(tenantId, roleId));
}
@BusinessAudit(event = "ROLE_STATUS_CHANGE", resourceType = "ROLE", resourceId = "#roleId",
@@ -90,7 +96,8 @@ public class RbacService {
role.setStatus(request.status());
repository.updateRole(role);
revokeAssignedSessions(tenantId, roleId);
return response(role, repository.findRolePermissionIds(tenantId, roleId));
return response(role, repository.findRolePermissionIds(tenantId, roleId),
repository.countRoleMembers(tenantId, roleId));
}
/** 删除策略:系统角色禁止删除;已分配给成员的角色拒绝删除;其余角色清理授权关系后逻辑删除。 */
@@ -319,10 +326,10 @@ public class RbacService {
return membership;
}
private RoleResponse response(IamRole role, Set<Long> permissionIds) {
private RoleResponse response(IamRole role, Set<Long> permissionIds, long memberCount) {
return new RoleResponse(role.getId(), role.getTenantId(), role.getAppCode(), role.getRoleCode(),
role.getRoleName(), role.getRoleType(), role.getStatus(), role.getDescription(),
Set.copyOf(permissionIds));
memberCount, Set.copyOf(permissionIds));
}
private BizException forbidden() {
+84 -6
View File
@@ -502,7 +502,11 @@ paths:
tags: [Merchant Tenant / 商户企业]
summary: List enterprise members / 查询企业成员
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 企业成员列表,包含真实登录账号与最后登录时间
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantMemberListApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [Merchant Tenant / 商户企业]
@@ -514,7 +518,11 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/CreateMemberRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 新增后的企业成员
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
@@ -530,7 +538,11 @@ paths:
summary: 查询成员详情及当前角色
description: 返回当前客户成员资料,roleIds 为服务端实时查询的当前角色集合;跨客户成员按不存在处理。
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 企业成员详情
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
put:
@@ -543,7 +555,11 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/UpdateMemberRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 更新后的企业成员
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
@@ -552,7 +568,11 @@ paths:
tags: [IAM Resources / IAM 资源]
summary: List tenant addresses / 查询企业地址
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 角色列表及各角色实时成员数
content:
application/json:
schema: {$ref: '#/components/schemas/RoleListApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [IAM Resources / IAM 资源]
@@ -563,7 +583,11 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/AddressRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 新增后的角色及实时成员数
content:
application/json:
schema: {$ref: '#/components/schemas/RoleApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/merchant/v1/iam/tenant/qualifications:
@@ -887,6 +911,60 @@ components:
type: array
uniqueItems: true
items: {type: string}
MerchantMemberResponse:
type: object
required: [membershipId, userId, authUserId, username, displayName, memberType, status, lastLoginAt, roleIds]
properties:
membershipId: {type: string, pattern: '^\d+$'}
userId: {type: string, pattern: '^\d+$'}
authUserId: {type: string, pattern: '^\d+$'}
username: {type: string, description: 统一认证登录账号}
displayName: {type: string}
phone: {type: string, nullable: true}
email: {type: string, format: email, nullable: true}
memberType: {type: string, enum: [OWNER, EMPLOYEE]}
memberNo: {type: string, nullable: true, description: 企业内部成员编号,不作为登录账号}
status: {type: string, enum: [ACTIVE, DISABLED]}
lastLoginAt: {type: string, format: date-time, nullable: true, description: 从 Auth 账号事实返回;null 表示从未登录}
roleIds: {type: array, items: {type: string, pattern: '^\d+$'}}
MerchantMemberListApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {type: array, items: {$ref: '#/components/schemas/MerchantMemberResponse'}}
MerchantMemberApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {$ref: '#/components/schemas/MerchantMemberResponse'}
RoleResponse:
type: object
required: [id, tenantId, appCode, roleCode, roleName, roleType, status, memberCount, permissionIds]
properties:
id: {type: string, pattern: '^\d+$'}
tenantId: {type: string, pattern: '^\d+$'}
appCode: {type: string, enum: [PLATFORM, MERCHANT]}
roleCode: {type: string}
roleName: {type: string}
roleType: {type: string, enum: [SYSTEM, CUSTOM]}
status: {type: string, enum: [ACTIVE, DISABLED]}
description: {type: string, nullable: true}
memberCount: {type: integer, format: int64, minimum: 0, description: 当前客户及应用域内分配该角色的成员数}
permissionIds: {type: array, uniqueItems: true, items: {type: string, pattern: '^\d+$'}}
RoleListApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {type: array, items: {$ref: '#/components/schemas/RoleResponse'}}
RoleApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {$ref: '#/components/schemas/RoleResponse'}
TenantPageApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
@@ -40,6 +40,11 @@ class IamOpenApiContractTest {
.contains("TenantAddressListApiResponse:")
.contains("TenantQualificationListApiResponse:")
.contains("InternalUserListApiResponse:")
.contains("MerchantMemberResponse:")
.contains("username: {type: string, description: 统一认证登录账号}")
.contains("lastLoginAt: {type: string, format: date-time, nullable: true")
.contains("RoleResponse:")
.contains("memberCount: {type: integer, format: int64, minimum: 0")
.contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:")
.contains("AssignInternalUserRolesRequest:")
.contains("/api/merchant/v1/iam/members/{membershipId}:")
@@ -19,6 +19,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
@@ -44,15 +45,20 @@ class MemberServiceTest {
when(repository.findByTenantAndId(100L, 20L)).thenReturn(member);
when(repository.findUser(30L)).thenReturn(user);
when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L));
when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(40L, "member01", "测试成员", "ACTIVE",
LocalDateTime.of(2026, 8, 17, 10, 30), 100L, 20L, "MERCHANT")));
}
@Test void shouldHandleCase01() {
user.setPhone("13800138000"); user.setEmail("member@example.com");
MemberResponse result = service.detail(100L, 20L);
assertEquals("测试成员", result.displayName());
assertEquals("member01", result.username());
assertEquals("13800138000", result.phone());
assertEquals(LocalDateTime.of(2026, 8, 17, 10, 30), result.lastLoginAt());
assertEquals(List.of(50L), result.roleIds());
verifyNoInteractions(auth);
verify(auth).summary("internal-token", 40L);
}
@Test void shouldReturnOwnerAccountForPlatformCustomerDetail() {
@@ -27,6 +27,7 @@ import org.springframework.test.context.TestPropertySource;
import jakarta.annotation.Resource;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
@@ -46,11 +47,14 @@ class RbacServiceTest {
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role));
when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L));
when(repository.findRoleMemberCounts(100L, List.of(11L))).thenReturn(Map.of(11L, 3L));
var result = service.roles(100L, "MERCHANT");
assertEquals(Set.of(21L, 22L), result.get(0).permissionIds());
assertEquals(3L, result.get(0).memberCount());
assertEquals(100L, result.get(0).tenantId());
verify(repository, never()).countRoleMembers(anyLong(), anyLong());
}
@Test
@@ -44,7 +44,8 @@ public interface FirstlegInboundClient {
Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode,
Boolean customsInspection, String destinationType, Long destinationWarehouseId,
String destinationCode, String destinationName, String publicRemark,
String expressCompanyName, String expressTrackingNo, Integer totalPackageCount
String expressCompanyName, String expressTrackingNo, Integer totalPackageCount,
String pickupMethod, LocalDateTime requestedPickupAt
) {
}
}
@@ -47,14 +47,14 @@ public interface FirstlegUnifiedOrderClient {
Long customerId, String customerName, Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName, String channelName,
Integer totalPackageCount, String locationCodes,
LocalDateTime expectedArrivalAt, String feeReviewStatus,
LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus,
String companyName, String businessType, String taxMode, String containerMode,
Boolean customsInspection, String destinationType, String destinationCode,
BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount,
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
LocalDateTime inboundedAt, String expressCompanyName, String createdByName,
String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo,
String containerNo, String cabinetNo, String vesselName, String voyageNo,
LocalDateTime sailingAt, LocalDateTime portArrivalAt,
String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {}
record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
@@ -53,6 +53,8 @@ public final class OrderRequests {
@Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty @Valid List<Cargo> cargoes,
@NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod,
@Schema(description = "Customer-requested pickup appointment; only applies to PICKUP / 客户要求平台上门提货的预约时间,仅 PICKUP 适用")
LocalDateTime requestedPickupAt,
@NotNull Long receivingWarehouseId,
@NotBlank @Size(max = 128) String receivingWarehouseName,
@NotBlank @Size(max = 64) String channelCode,
@@ -71,6 +73,23 @@ public final class OrderRequests {
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId
) {
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
Party sender, Party receiver, List<Cargo> cargoes, String pickupMethod,
Long receivingWarehouseId, String receivingWarehouseName, String channelCode,
String channelName, Long carrierId, String carrierName, String taxMode,
String pricingTaxMode, String containerMode, Boolean customsInspection,
String destinationType, String amazonWarehouseCode, String shipmentId,
String referenceId) {
this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode,
destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark,
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId);
}
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
public boolean isCarrierSnapshotComplete() {
return (carrierId == null) == (carrierName == null || carrierName.isBlank());
@@ -33,7 +33,7 @@ public final class UnifiedOrderResponse {
MeasurementSummary measurement, PreloadSummary preload,
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
String customerRemark, String latestInternalNote,
LocalDateTime createdAt, LocalDateTime updatedAt
LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime requestedPickupAt
) {}
public record OrderRelationSummary(String role, Long rootOrderId, Long parentOrderId,
@@ -51,7 +51,7 @@ public final class UnifiedOrderResponse {
Integer expectedCount, Integer pickedCount, Integer evidenceCount,
String sealNo, LocalDateTime updatedAt) {}
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
public record TransportSummary(String loadingStatus, String containerNo, String cabinetNo,
public record TransportSummary(String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt,
LocalDateTime portArrivalAt) {}
public record TimelineEvent(String source, String eventType, String mainStatus, String action,
@@ -33,6 +33,7 @@ public class OrderEntity extends BaseEntity {
private LocalDate expectedShipDate;
private String customerRemark;
private String pickupMethod;
private LocalDateTime requestedPickupAt;
private Long receivingWarehouseId;
private String receivingWarehouseName;
private String receivingWarehouseAddress;
@@ -208,10 +208,10 @@ public class OrderRepository {
return cargoMapper.selectObjs(wrapper).stream().map(value -> ((Number) value).longValue()).toList();
}
public List<OrderCargoEntity> findCargoByOrderIds(long tenantId, List<Long> orderIds) {
public List<OrderCargoEntity> findCargoByOrderIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
.eq(OrderCargoEntity::getTenantId, tenantId)
.eq(tenantId != null, OrderCargoEntity::getTenantId, tenantId)
.in(OrderCargoEntity::getOrderId, orderIds)
.orderByAsc(OrderCargoEntity::getOrderId)
.orderByAsc(OrderCargoEntity::getItemNo));
@@ -288,6 +288,9 @@ public class OrderApplicationService {
if ("PICKUP".equals(request.pickupMethod()) && request.sender() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
}
if ("PICKUP".equals(request.pickupMethod()) && request.requestedPickupAt() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写预约提货时间");
}
if ("PRIVATE".equals(request.destinationType()) && request.receiver() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "私人地址必须填写收货信息");
}
@@ -421,7 +424,9 @@ public class OrderApplicationService {
order.setDestinationWarehouseName(snapshots.destination() == null ? request.destinationWarehouseName()
: snapshots.destination().warehouse().warehouseName());
order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark());
order.setPickupMethod(request.pickupMethod()); order.setReceivingWarehouseId(request.receivingWarehouseId());
order.setPickupMethod(request.pickupMethod());
order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null);
order.setReceivingWarehouseId(request.receivingWarehouseId());
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving()));
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
@@ -524,7 +529,8 @@ public class OrderApplicationService {
order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(),
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount);
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount,
order.getPickupMethod(), order.getRequestedPickupAt());
}
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable();
@@ -187,7 +187,7 @@ public class UnifiedOrderQueryService {
string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(),
null, null, null, null, null,
order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt());
}
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
@@ -204,7 +204,7 @@ public class UnifiedOrderQueryService {
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound),
preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt());
}
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
@@ -226,7 +226,7 @@ public class UnifiedOrderQueryService {
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(),
order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote,
inbound.createdAt(), inbound.updatedAt());
inbound.createdAt(), inbound.updatedAt(), inbound.requestedPickupAt());
}
private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) {
@@ -248,8 +248,8 @@ public class UnifiedOrderQueryService {
value.status(), value.completedAt(), value.trackingNo());
}
private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) {
return new UnifiedOrderResponse.TransportSummary(inbound.loadingStatus(), inbound.containerNo(),
inbound.cabinetNo(), inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt());
return new UnifiedOrderResponse.TransportSummary(inbound.containerNo(), inbound.cabinetNo(),
inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt());
}
private String orderMainStatus(OrderStatus status) {
+1
View File
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
expected_ship_date DATE NULL COMMENT '预计发运日期',
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间',
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID',
receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照',
receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照',
@@ -433,6 +433,7 @@ components:
companyName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
pickupMethod: {type: string, nullable: true}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from the pickup snapshot}
taxMode: {type: string, nullable: true}
containerMode: {type: string, nullable: true}
customsInspection: {type: boolean, nullable: true}
@@ -509,7 +510,6 @@ components:
type: object
nullable: true
properties:
loadingStatus: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
cabinetNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
@@ -53,7 +53,7 @@ class UnifiedOrderApiContractTest {
assertThat(stringMap(row.get("properties"))).containsKeys(
"orderId", "mainStatus", "inboundId", "inboundVersion", "measurement", "preload",
"loading", "delivery", "transport", "customerRemark", "latestInternalNote",
"expectedArrivalAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
@@ -22,6 +22,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalTime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@@ -40,7 +41,7 @@ class OrderApplicationServiceStateTest {
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
}
@Test
void unapprovedTenantCanCreateDraft() {
@@ -356,7 +357,7 @@ class OrderApplicationServiceStateTest {
ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"eligibilityGuard",eligibility);
ReflectionTestUtils.setField(service,"masterDataGuard",mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service,"json",new ObjectMapper());
ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules());
assertThatThrownBy(() -> service.transit(10001,2,"u",1,OrderStatus.SUBMITTED,null))
.isInstanceOf(BizException.class).hasMessageContaining("重复提交");
verify(repository,never()).saveOrder(anyLong(), any());
@@ -511,7 +512,7 @@ class OrderApplicationServiceStateTest {
when(repository.nextInboundCodeSequence(any(LocalDate.class))).thenReturn(1L);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class));
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
ReflectionTestUtils.setField(service, "json", new ObjectMapper().findAndRegisterModules());
return service;
}
@@ -525,7 +526,8 @@ class OrderApplicationServiceStateTest {
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道", 9L, "船司",
party, party, List.of(cargo), "PICKUP", LocalDateTime.of(2026, 8, 20, 9, 0),
8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null);
}
@@ -539,7 +541,9 @@ class OrderApplicationServiceStateTest {
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道", 9L, "船司",
sender, receiver, List.of(cargo), pickupMethod,
"PICKUP".equals(pickupMethod) ? LocalDateTime.of(2026, 8, 20, 9, 0) : null,
8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, destinationType, null, null, null);
}
@@ -27,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -169,6 +170,9 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.inboundCode()).isEqualTo("RK-77");
assertThat(row.recommendedPalletType()).isNull();
assertThat(row.locationCodes()).isEqualTo("A0-01, B2-03");
assertThat(row.companyName()).isEqualTo("测试客户有限公司");
assertThat(row.businessType()).isEqualTo("SEA_LCL");
assertThat(row.totalPalletCount()).isEqualTo(2);
});
verify(orders, never()).unifiedPage(any(), any(), any());
}
@@ -224,8 +228,9 @@ class UnifiedOrderQueryServiceTest {
when(orders.findParties(10001L, 77L)).thenReturn(List.of());
when(orders.findNotes(10001L, 77L)).thenReturn(List.of());
when(orders.findHistory(10001L, 77L)).thenReturn(List.of());
when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of())));
var fulfillmentDetail = new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of());
when(firstleg.detail(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(fulfillmentDetail));
var result = service.detail(10001L, 77L, false);
@@ -322,12 +327,30 @@ class UnifiedOrderQueryServiceTest {
}
private FirstlegUnifiedOrderClient.Summary summary(String status, String action) {
var inbound = new FirstlegUnifiedOrderClient.Inbound(
88L, 10001L, 77L, "FL-77", "RK-77", "INBOUNDED", "APPROVED", "APPROVED", 7,
30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, "A0-01, B2-03", null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null,
LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0));
var inbound = mock(FirstlegUnifiedOrderClient.Inbound.class);
when(inbound.id()).thenReturn(88L);
when(inbound.tenantId()).thenReturn(10001L);
when(inbound.orderId()).thenReturn(77L);
when(inbound.orderNo()).thenReturn("FL-77");
when(inbound.inboundCode()).thenReturn("RK-77");
when(inbound.processStatus()).thenReturn("INBOUNDED");
when(inbound.orderStatusSnapshot()).thenReturn("APPROVED");
when(inbound.measureReviewStatus()).thenReturn("APPROVED");
when(inbound.version()).thenReturn(7);
when(inbound.customerId()).thenReturn(30001L);
when(inbound.customerName()).thenReturn("测试客户");
when(inbound.warehouseId()).thenReturn(11L);
when(inbound.warehouseName()).thenReturn("深圳仓");
when(inbound.deliveryWarehouseId()).thenReturn(22L);
when(inbound.deliveryWarehouseName()).thenReturn("ONT8");
when(inbound.channelName()).thenReturn("美森");
when(inbound.totalPackageCount()).thenReturn(10);
when(inbound.locationCodes()).thenReturn("A0-01, B2-03");
when(inbound.companyName()).thenReturn("测试客户有限公司");
when(inbound.businessType()).thenReturn("SEA_LCL");
when(inbound.palletCount()).thenReturn(2);
when(inbound.createdAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 10, 0));
when(inbound.updatedAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 11, 0));
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,
new FirstlegUnifiedOrderClient.Preload("PENDING", 1, 2, null),
new FirstlegUnifiedOrderClient.Loading("IN_PROGRESS", 3, 3, 10, 5, 1, "SEAL-1", null), null);
+10
View File
@@ -121,6 +121,16 @@ node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
node --test scripts/admin-test-env-modules/domestic-container-contract.test.mjs
```
## 货柜履约列表字段合同
`fulfillment-list-field-contract.mjs` 统一校验货柜、预装/装柜清单、订舱、船期、拖车、报关协同和国内出仓正式响应中的稳定 ID、非空展示字段、非负数量/重量/体积及 ISO 时间格式。各生产模块在已取得的本轮 marker 响应上执行断言,不额外借用历史数据;未冻结的原型字段只登记在 `docs/三期/货柜履约原型字段验收矩阵-2026-08-17.md` 的 `UNKNOWN`,不得由 fixture 伪造。
离线合同:
```powershell
node --test scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs
```
## 清库后的商户下单前置
`merchant-qualification-fixture.mjs` 是默认全量验收的首个模块。它通过平台正式接口创建本轮隔离客户,经 File 上传真实营业执照和法人身份证文件,再走 IAM 提交审核和平台审核通过;审核完成后把本轮动态加载模块使用的商户账号切换到该客户。
@@ -0,0 +1,165 @@
function valueAt(record, path) {
return path.split('.').reduce((value, key) => value?.[key], record)
}
export function isPositiveId(value) {
return /^\d+$/.test(String(value ?? '')) && BigInt(String(value)) > 0n
}
export function isNonBlank(value) {
return typeof value === 'string' && value.trim().length > 0
}
export function isIsoDateTime(value) {
return isNonBlank(value) && Number.isFinite(Date.parse(value))
}
export function isNonNegativeNumber(value) {
return value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0
}
function gaps(record, rules) {
return Object.entries(rules)
.filter(([path, predicate]) => !predicate(valueAt(record, path), record))
.map(([path]) => path)
}
export function containerFieldGaps(record, { requireContainerNo = false } = {}) {
return gaps(record, {
id: isPositiveId,
cabinetNo: isNonBlank,
warehouseId: isPositiveId,
warehouseName: isNonBlank,
channelCode: isNonBlank,
containerType: isNonBlank,
capacityCbm: (value) => Number(value) > 0,
maxWeightKg: (value) => Number(value) > 0,
planStatus: isNonBlank,
...(requireContainerNo ? { containerNo: isNonBlank } : {}),
})
}
export function preloadFieldGaps(record) {
return gaps(record, {
planId: isPositiveId,
containerBookingId: isPositiveId,
preloadNo: isNonBlank,
cabinetNo: isNonBlank,
customers: (value) => Array.isArray(value) && value.length > 0
&& value.every((customer) => isPositiveId(customer?.accountId) && isNonBlank(customer?.customerName)),
warehouseName: isNonBlank,
containerType: isNonBlank,
status: isNonBlank,
skuCount: isNonNegativeNumber,
totalPackageCount: isNonNegativeNumber,
totalVolumeCbm: isNonNegativeNumber,
totalGrossWeightKg: isNonNegativeNumber,
createdAt: isIsoDateTime,
})
}
export function loadingListFieldGaps(record) {
return gaps(record, {
planId: isPositiveId,
containerBookingId: isPositiveId,
loadingListNo: isNonBlank,
containerNo: isNonBlank,
cabinetNo: isNonBlank,
warehouseName: isNonBlank,
status: (value) => value === 'COMPLETED',
totalPackageCount: isNonNegativeNumber,
totalGrossWeightKg: isNonNegativeNumber,
totalNetWeightKg: isNonNegativeNumber,
totalVolumeCbm: isNonNegativeNumber,
totalQuantity: isNonNegativeNumber,
createdAt: isIsoDateTime,
})
}
export function oceanBookingFieldGaps(record) {
return gaps(record, {
id: isPositiveId,
bookingNo: isNonBlank,
status: (value) => ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'].includes(value),
vesselName: isNonBlank,
voyageNo: isNonBlank,
estimatedSailingAt: isIsoDateTime,
estimatedArrivalAt: isIsoDateTime,
customerName: (value, item) => item.customerId == null ? value == null : isNonBlank(value),
shippingCompanyName: (value, item) => item.shippingCompanyId == null ? value == null : isNonBlank(value),
bookingAgentName: (value, item) => item.bookingAgentId == null ? value == null : isNonBlank(value),
})
}
export function portScheduleFieldGaps(record) {
const view = record?.portSchedule ?? record
const schedule = view?.schedule ?? view
return gaps(schedule, {
id: isPositiveId,
scheduleNo: isNonBlank,
sourceName: isNonBlank,
vesselName: isNonBlank,
voyageNo: isNonBlank,
departurePortCode: isNonBlank,
destinationPortCode: isNonBlank,
etdAt: isIsoDateTime,
etaAt: isIsoDateTime,
observedAt: isIsoDateTime,
updatedAt: isIsoDateTime,
expiresAt: isIsoDateTime,
}).concat(typeof view?.expired === 'boolean' ? [] : ['expired'])
}
export function truckAppointmentFieldGaps(record) {
return gaps(record, {
id: isPositiveId,
appointmentNo: isNonBlank,
oceanBookingId: isPositiveId,
containerBookingId: isPositiveId,
loadingPlanId: isPositiveId,
warehouseId: isPositiveId,
warehouseName: isNonBlank,
plannedLoadingAt: isIsoDateTime,
status: isNonBlank,
customerName: (value, item) => item.customerId == null ? value == null : isNonBlank(value),
carrierName: (value, item) => item.carrierId == null ? value == null : isNonBlank(value),
})
}
export function customsCaseFieldGaps(record) {
const customsCase = record?.customsCase ?? record?.case ?? record
return gaps(customsCase, {
id: isPositiveId,
customsNo: isNonBlank,
orderId: isPositiveId,
inboundId: isPositiveId,
containerBookingId: isPositiveId,
checklistVersionId: isPositiveId,
checklistCode: isNonBlank,
checklistVersionNo: isNonBlank,
status: isNonBlank,
updatedAt: isIsoDateTime,
})
}
export function outboundAggregateFieldGaps(record) {
const aggregate = record?.aggregate ?? record
return gaps(aggregate, {
orderId: isPositiveId,
inboundId: isPositiveId,
inboundCode: isNonBlank,
customerId: isPositiveId,
customerName: isNonBlank,
containerBookingId: isPositiveId,
containerNo: isNonBlank,
loadingPlanId: isPositiveId,
loadingPlanStatus: isNonBlank,
pickTaskId: isPositiveId,
pickTaskStatus: isNonBlank,
oceanBookingId: isPositiveId,
oceanBookingStatus: isNonBlank,
dispatchStatus: isNonBlank,
version: isNonNegativeNumber,
'gate.items': (value) => Array.isArray(value),
})
}
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
containerFieldGaps,
customsCaseFieldGaps,
loadingListFieldGaps,
oceanBookingFieldGaps,
outboundAggregateFieldGaps,
portScheduleFieldGaps,
preloadFieldGaps,
truckAppointmentFieldGaps,
} from './fulfillment-list-field-contract.mjs'
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const workspace = path.resolve(repo, '..')
const read = (...parts) => fs.readFileSync(path.join(...parts), 'utf8')
const ids = {
id: '2089000000000000001', plan: '2089000000000000002', container: '2089000000000000003',
order: '2089000000000000004', inbound: '2089000000000000005', task: '2089000000000000006',
}
const at = '2026-08-17T08:00:00'
test('原型锁定货柜、预装、装柜、订舱与拖车关键列表字段', () => {
const source = read(workspace, 'docs', '悦', '货代SAAS平台后台', 'prototype-src', 'modules', '06-containers.html')
for (const label of ['集装箱号', '装柜清单号', '货柜状态', '柜号', '预装单号', '客户', '仓库',
'船名航次', '箱型', '总毛重', '总体积', '订舱代理', '派车单号', '运输公司', '预计装箱日期']) {
assert.match(source, new RegExp(label))
}
})
test('八类 Admin 列表均以左固定操作列开头', () => {
const sources = [
['container-loading/pages/ContainersView.vue', 1],
['loading-lists/components/LoadingListsView.vue', 2],
['ocean-bookings/pages/OceanBookingsView.vue', 1],
['port-schedules/pages/PortSchedulesView.vue', 1],
['trucking/pages/TruckAppointmentsView.vue', 1],
['customs/pages/CustomsCasesView.vue', 1],
['outbound-dispatch/pages/OutboundDispatchesView.vue', 1],
]
for (const [relative, count] of sources) {
const source = read(workspace, 'apps', 'admin-web', 'src', 'modules', relative)
assert.equal([...source.matchAll(/\{ key: 'actions', label: '操作',[^\n}]*fixed: 'left'|\{ key: 'actions', label: '操作', fixed: 'left'/g)].length, count,
`${relative} operation column count`)
}
})
test('Admin 当前列表逐字段列与验收矩阵一致', () => {
const expectations = {
'container-loading/pages/ContainersView.vue': ['cabinetNo', 'oceanBooking', 'containerNo', 'planStatus', 'warehouseName', 'terminalTruckingCompany', 'billOfLadingNo', 'containerType', 'shippingCompanyName', 'estimatedSailingAt', 'actualPortArrivalAt'],
'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'],
'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'],
'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'],
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'customerName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachments'],
'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'],
'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'],
}
for (const [relative, fields] of Object.entries(expectations)) {
const source = read(workspace, 'apps', 'admin-web', 'src', 'modules', relative)
for (const field of fields) assert.match(source, new RegExp(field), `${relative}: ${field}`)
}
})
test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => {
const sources = [
['firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java', ['cabinetNo', 'containerNo', 'channelCode', 'warehouseName', 'capacityCbm', 'maxWeightKg', 'planStatus']],
['firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java', ['record PreloadSummary', 'record ConfirmedSummary', 'totalGrossWeightKg', 'totalVolumeCbm', 'customers']],
['firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', ['record Detail', 'record RealContainer', 'bookingAgentName', 'estimatedSailingAt', 'estimatedArrivalAt']],
['firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java', ['record ScheduleView', 'sourceName', 'etdAt', 'etaAt', 'expiresAt']],
['firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', ['appointmentNo', 'customerName', 'carrierName', 'plannedLoadingAt', 'containerNo']],
['firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java', ['customsNo', 'checklistCode', 'checklistVersionNo', 'updatedAt']],
['firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java', ['record Aggregate', 'customerName', 'pickTaskStatus', 'dispatchStatus', 'GateResult']],
]
for (const [relative, fields] of sources) {
const source = read(repo, relative)
for (const field of fields) assert.match(source, new RegExp(field), `${relative}: ${field}`)
}
})
test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
const samples = [
[containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'],
[preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HC', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'],
[loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'],
[oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'],
[portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'],
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', customerId: ids.id, customerName: '测试客户', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT' }, undefined, 'appointmentNo'],
[customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'],
[outboundAggregateFieldGaps, { orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', customerId: ids.id, customerName: '测试客户', containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { items: [] } }, undefined, 'inboundCode'],
]
for (const [validator, sample, options, missing] of samples) {
assert.deepEqual(validator(sample, options), [])
const invalid = structuredClone(sample)
const target = invalid.portSchedule?.schedule ?? invalid.customsCase ?? invalid
target[missing] = ''
assert.ok(validator(invalid, options).includes(missing))
}
})
test('字段断言接入正式生产模块和默认全量入口', () => {
const consumers = {
'phase3-container-loading.mjs': ['containerFieldGaps', 'preloadFieldGaps', 'loadingListFieldGaps'],
'ocean-bookings.mjs': ['oceanBookingFieldGaps'],
'phase3-port-schedules.mjs': ['portScheduleFieldGaps'],
'truck-appointments.mjs': ['truckAppointmentFieldGaps'],
'phase3-customs.mjs': ['customsCaseFieldGaps'],
'phase3-outbound-dispatch.mjs': ['outboundAggregateFieldGaps'],
}
for (const [file, tokens] of Object.entries(consumers)) {
const source = read(repo, 'scripts', 'admin-test-env-modules', file)
for (const token of tokens) assert.match(source, new RegExp(token), `${file}: ${token}`)
}
const entry = read(repo, 'scripts', 'admin-test-env-acceptance.mjs')
for (const module of Object.keys(consumers).map((file) => file.replace(/\.mjs$/, ''))) {
assert.match(entry, new RegExp(`['"]${module}['"]`), `default entry: ${module}`)
}
})
test('矩阵登记 LANDED 与 UNKNOWN 且禁止 fixture 伪造未知字段', () => {
const matrix = read(workspace, 'docs', '三期', '货柜履约原型字段验收矩阵-2026-08-17.md')
assert.match(matrix, /LANDED/)
assert.match(matrix, /UNKNOWN/)
assert.match(matrix, /不得由 fixture 伪造|不生成假值/)
for (const label of ['货柜', '预装清单', '装柜清单', '订舱', '船期', '拖车', '报关协同', '国内出仓']) {
assert.match(matrix, new RegExp(label))
}
})
@@ -2,6 +2,7 @@ const MODULE = 'ocean-bookings'
const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/ocean-bookings'
import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
import { oceanBookingFieldGaps } from './fulfillment-list-field-contract.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) }
@@ -247,6 +248,9 @@ export async function run(context) {
version = Number(releasedDetail?.oceanBooking?.version)
const final = await request(context, 'GET', path); const fd = data(final)
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
const bookingFieldGaps = oceanBookingFieldGaps(fd?.oceanBooking)
context.assert(MODULE, '订舱列表所需客户、船公司、代理、航次与关键时间字段来源完整', final,
bookingFieldGaps.length === 0, `missing=${bookingFieldGaps.join(',') || 'none'}`, OPTIONS)
context.assert(MODULE, '独立订舱状态历史、审计操作者和终态完整', final,
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 4 && fd.histories.every((h) => h.operatorName)
&& fd?.containers?.length === 2,
@@ -1,5 +1,6 @@
import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs'
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'phase3-container-loading'
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
@@ -431,6 +432,10 @@ export async function run(context) {
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId
&& !records(activeContainers).some((item) => id(item?.id) === id(requirement.containerBookingId)),
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}, mergedSourceId=${id(requirement.containerBookingId)}`, OPTIONS)
const containerGaps = containerFieldGaps(activeRealRows[0], { requireContainerNo: true })
context.assert(MODULE, '货柜列表关键展示字段来自正式柜次快照且格式完整', activeContainersResponse,
activeRealRows.length === 1 && containerGaps.length === 0,
`missing=${containerGaps.join(',') || 'none'}`, OPTIONS)
const mergedBookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${bookingId}`)
const mergedBooking = context.apiSuccess(MODULE, '合并后回查订舱真实柜当前操作身份', mergedBookingResponse, '', OPTIONS)
@@ -481,9 +486,13 @@ export async function run(context) {
const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
const preloadRow = records(preloadPage).find((item) => id(item.planId) === planId)
context.assert(MODULE, '预装清单包含本轮已推送 EDITING 计划', preloadPageResponse,
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
`records=${records(preloadPage).length}`, OPTIONS)
const preloadGaps = preloadFieldGaps(preloadRow)
context.assert(MODULE, '预装清单客户、仓库、箱型、数量重量体积与时间字段完整', preloadPageResponse,
preloadGaps.length === 0, `missing=${preloadGaps.join(',') || 'none'}`, OPTIONS)
const unconfirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
@@ -784,9 +793,13 @@ export async function run(context) {
const confirmedPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-lists?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
const confirmedPage = context.apiSuccess(MODULE, '查询完成装柜清单', confirmedPageResponse, '', OPTIONS)
const confirmedRow = records(confirmedPage).find((item) => id(item.planId) === planId)
context.assert(MODULE, '完成清单分页包含本轮计划', confirmedPageResponse,
records(confirmedPage).some((item) => id(item.planId) === planId && item.status === 'COMPLETED'),
`records=${records(confirmedPage).length}`, OPTIONS)
const loadingListGaps = loadingListFieldGaps(confirmedRow)
context.assert(MODULE, '装柜清单柜号、仓库、数量重量体积与时间字段完整', confirmedPageResponse,
loadingListGaps.length === 0, `missing=${loadingListGaps.join(',') || 'none'}`, OPTIONS)
const confirmedDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-lists/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
@@ -1,3 +1,5 @@
import { customsCaseFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'phase3-customs'
const OPTIONS = { scenario: 'phase3-customs-collaboration', deploymentBlocker: true }
const PLATFORM = '/api/platform/v1/customs'
@@ -183,12 +185,15 @@ export async function run(context) {
{ 'Idempotency-Key': key(context, 'case') })
const created = context.apiSuccess(MODULE, '创建客户订单柜次关联报关协同单', caseResponse, '', OPTIONS)
const caseId = id(created?.customsCase?.id)
const customsFieldGaps = customsCaseFieldGaps(created)
context.assert(MODULE, '报关单绑定本轮实际承载来源分片、入库单、柜次和清单版本', caseResponse,
Boolean(caseId) && id(created?.customsCase?.tenantId) === tenantId && id(created?.customsCase?.orderId) === actualOrderId
&& id(created?.customsCase?.inboundId) === inboundId && id(created?.customsCase?.containerBookingId) === containerBookingId
&& id(created?.customsCase?.checklistVersionId) === checklistVersionId
&& created?.customsCase?.status === 'DOCUMENTS_PENDING' && Number(created?.customsCase?.version) === 0,
`caseId=${caseId || 'missing'}, parentOrderId=${parentOrderId}, sourceOrderId=${sourceOrderId}, actualOrderId=${actualOrderId}, status=${created?.customsCase?.status || 'missing'}`, OPTIONS)
context.assert(MODULE, '报关列表单号、订单入库柜次、清单版本、状态与更新时间字段完整', caseResponse,
customsFieldGaps.length === 0, `missing=${customsFieldGaps.join(',') || 'none'}`, OPTIONS)
if (!caseId) return
const caseReplay = await request(context, 'POST', `${PLATFORM}/cases`, casePayload, context.token,
@@ -1,3 +1,5 @@
import { outboundAggregateFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'phase3-outbound-dispatch'
const OPTIONS = { scenario: 'phase3-domestic-outbound-gate', deploymentBlocker: true }
const BASE = '/api/platform/v1/first-leg/outbound-dispatches'
@@ -43,10 +45,13 @@ export async function run(context) {
const aggregates = context.apiSuccess(MODULE, '按入库单和柜次查询出仓主聚合', listResponse, selector, OPTIONS)
const aggregate = Array.isArray(aggregates) ? aggregates.find((item) => id(item.inboundId) === inboundId
&& id(item.containerBookingId) === containerBookingId) : null
const outboundFieldGaps = outboundAggregateFieldGaps(aggregate)
context.assert(MODULE, '主聚合串联订单、入库单、柜次、计划与客户隔离键', listResponse,
Boolean(aggregate?.orderId) && Boolean(aggregate?.tenantId) && Boolean(aggregate?.loadingPlanId)
&& id(aggregate?.inboundId) === inboundId && id(aggregate?.containerBookingId) === containerBookingId,
`orderId=${id(aggregate?.orderId) || 'missing'}, tenantId=${id(aggregate?.tenantId) || 'missing'}, planId=${id(aggregate?.loadingPlanId) || 'missing'}`, OPTIONS)
context.assert(MODULE, '国内出仓列表客户、订单入库、柜次计划、PDA订舱与门禁摘要完整', listResponse,
outboundFieldGaps.length === 0, `missing=${outboundFieldGaps.join(',') || 'none'}`, OPTIONS)
if (!aggregate) return
const orderResponse = await request(context, 'GET', `${BASE}?orderId=${encodeURIComponent(id(aggregate.orderId))}`)
@@ -1,3 +1,5 @@
import { portScheduleFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'phase3-port-schedules'
const OPTIONS = { scenario: 'port-schedule-source-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/port-schedules'
@@ -135,6 +137,7 @@ export async function run(context) {
const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS)
const scheduleId = id(created?.portSchedule?.schedule?.id)
const scheduleFieldGaps = portScheduleFieldGaps(created)
context.assert(MODULE, '船期来源、时效、截单截关截港和关联字段完整', createResponse,
Boolean(scheduleId) && created?.portSchedule?.expired === false
&& created?.portSchedule?.schedule?.sourceType === 'CARRIER'
@@ -146,6 +149,8 @@ export async function run(context) {
&& Boolean(created?.portSchedule?.schedule?.customsCutoffAt)
&& Boolean(created?.portSchedule?.schedule?.portCutoffAt),
`scheduleId=${scheduleId || 'missing'}, expired=${created?.portSchedule?.expired ?? 'missing'}`, OPTIONS)
context.assert(MODULE, '船期列表来源、船名航次、港口与关键时间字段格式完整', createResponse,
scheduleFieldGaps.length === 0, `missing=${scheduleFieldGaps.join(',') || 'none'}`, OPTIONS)
if (!scheduleId) return
const replay = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
@@ -1,3 +1,5 @@
import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'truck-appointments'
const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/truck-appointments'
@@ -189,6 +191,9 @@ export async function run(context) {
const action = async (name, endpoint, expectedStatus) => { const response = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: version, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const result = context.apiSuccess(MODULE, name, response, '', OPTIONS); context.assert(MODULE, `${name} 状态返回`, response, result?.status === expectedStatus, `status=${result?.status || 'missing'}`, OPTIONS); version = Number(result?.version) }
for (const [name, endpoint, status] of [['提交预约','submit','PENDING_DISPATCH'],['派车','dispatch','DISPATCHED'],['开始提货','pickup','PICKUP_IN_PROGRESS'],['到仓','arrive','ARRIVED_WAREHOUSE'],['开始装货','load','LOADING'],['离场','depart','DEPARTED'],['完成','complete','COMPLETED']]) await action(name, endpoint, status)
const final = await request(context, 'GET', path); const finalData = data(final); context.apiSuccess(MODULE, '回查 COMPLETED 拖车预约', final, '', OPTIONS)
const appointmentFieldGaps = truckAppointmentFieldGaps(finalData)
context.assert(MODULE, '拖车列表客户、仓库、订舱柜次、供应商与关键时间字段完整', final,
appointmentFieldGaps.length === 0, `missing=${appointmentFieldGaps.join(',') || 'none'}`, OPTIONS)
context.assert(MODULE, '拖车预约状态终态和费用快照正确', final, finalData?.status === 'COMPLETED' && finalData?.payableAmount != null && finalData?.receivableAmount != null, `status=${finalData?.status || 'missing'}`, OPTIONS)
const history = await request(context, 'GET', `${path}/history`); const histories = context.apiSuccess(MODULE, '查询拖车预约状态历史', history, '', OPTIONS)
const actions = Array.isArray(histories) ? histories.map((item) => item.action) : []