优化
This commit is contained in:
@@ -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 '国内收货仓完整地址快照',
|
||||
|
||||
+17
@@ -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,
|
||||
|
||||
+77
-2
@@ -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) {
|
||||
|
||||
+31
-7
@@ -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) {
|
||||
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
+6
-4
@@ -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;
|
||||
}
|
||||
|
||||
+18
-10
@@ -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);
|
||||
|
||||
+2
-1
@@ -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) {}
|
||||
|
||||
+2
-2
@@ -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) {}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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}
|
||||
|
||||
+3
@@ -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]")
|
||||
|
||||
+2
-2
@@ -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")
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
+8
@@ -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)
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+4
@@ -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'")
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+10
@@ -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);
|
||||
|
||||
+2
-1
@@ -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];
|
||||
|
||||
+3
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) : []
|
||||
|
||||
Reference in New Issue
Block a user