This commit is contained in:
2026-08-17 15:41:42 +08:00
parent 4abcb2b80f
commit 5f79616623
60 changed files with 1457 additions and 270 deletions
@@ -10712,7 +10712,9 @@ CREATE TABLE IF NOT EXISTS fin_bill (
void_reason VARCHAR(500) NULL COMMENT '账单作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
@@ -10775,6 +10777,7 @@ CREATE TABLE IF NOT EXISTS fin_receipt (
void_reason VARCHAR(500) NULL COMMENT '收款作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
@@ -10825,6 +10828,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
void_reason VARCHAR(500) NULL COMMENT '成本作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
@@ -10869,6 +10873,7 @@ CREATE TABLE IF NOT EXISTS fin_bill_revision (
bill_snapshot JSON NOT NULL COMMENT '调整前账单主记录不可变JSON快照',
line_snapshot JSON NOT NULL COMMENT '调整前全部账单明细不可变JSON数组快照',
created_by BIGINT UNSIGNED NOT NULL COMMENT '执行调整的操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '执行调整的操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '修订快照创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_revision (bill_id, version),
@@ -10975,6 +10980,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '登记操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no),
@@ -10984,6 +10990,35 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
DROP PROCEDURE IF EXISTS finance_add_operator_snapshot_column;
DELIMITER $$
CREATE PROCEDURE finance_add_operator_snapshot_column(
IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN after_column VARCHAR(64)
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = target_table AND column_name = target_column
) THEN
SET @finance_operator_snapshot_ddl = CONCAT(
'ALTER TABLE `', target_table,
'` ADD COLUMN `', target_column, '` VARCHAR(128) NULL COMMENT ''操作人名称快照'' AFTER `', after_column, '`'
);
PREPARE finance_operator_snapshot_stmt FROM @finance_operator_snapshot_ddl;
EXECUTE finance_operator_snapshot_stmt;
DEALLOCATE PREPARE finance_operator_snapshot_stmt;
END IF;
END$$
DELIMITER ;
CALL finance_add_operator_snapshot_column('fin_bill', 'created_by_name', 'created_by');
CALL finance_add_operator_snapshot_column('fin_bill', 'updated_by_name', 'updated_by');
CALL finance_add_operator_snapshot_column('fin_receipt', 'created_by_name', 'created_by');
CALL finance_add_operator_snapshot_column('fin_cost', 'created_by_name', 'created_by');
CALL finance_add_operator_snapshot_column('fin_settlement_payment', 'created_by_name', 'created_by');
CALL finance_add_operator_snapshot_column('fin_bill_revision', 'created_by_name', 'created_by');
DROP PROCEDURE IF EXISTS finance_add_operator_snapshot_column;
USE baiyue_firstleg;
-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql
-- Firstleg -> Finance 费用来源事件和柜次费用模型(MySQL 8,可重复执行)
@@ -70,8 +70,10 @@ public final class FinanceReadModels {
String status,
int version,
long createdBy,
String createdByName,
LocalDateTime createdAt,
long updatedBy,
String updatedByName,
LocalDateTime updatedAt) {}
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
@@ -168,6 +170,7 @@ public final class FinanceReadModels {
String billSnapshot,
String lineSnapshot,
long createdBy,
String createdByName,
LocalDateTime createdAt) {}
public record CustomerTerms(long customerId, String customerName, int creditDays, String status) {}
@@ -35,10 +35,11 @@ public final class FinanceSettlementDtos {
boolean statementGenerated) {}
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo,
List<String> voucherFileIds, long createdBy, LocalDateTime createdAt,
List<String> voucherFileIds, long createdBy, String createdByName,
LocalDateTime createdAt,
String remark) {}
public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount,
BigDecimal afterAmount, String reason, long operatorId,
BigDecimal afterAmount, String reason, long operatorId, String operatorName,
LocalDateTime occurredAt) {}
public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List<SettlementLine> lines,
List<SettlementTransaction> transactions,
@@ -23,8 +23,8 @@ public final class ReconciliationStatementDtos {
int creditDays, String currency, BigDecimal totalReceivable,
BigDecimal receivedAmount, BigDecimal outstandingAmount,
boolean settled, String status, int version,
long createdBy, LocalDateTime createdAt,
long updatedBy, LocalDateTime updatedAt, String remark,
long createdBy, String createdByName, LocalDateTime createdAt,
long updatedBy, String updatedByName, LocalDateTime updatedAt, String remark,
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
List<ReconciliationRevision> revisions) {}
@@ -36,10 +36,10 @@ public final class ReconciliationStatementDtos {
public record PaymentHistory(long transactionId, String transactionType,
LocalDate transactionDate, BigDecimal amount,
String method, String referenceNo, long createdBy,
String method, String referenceNo, long createdBy, String createdByName,
LocalDateTime createdAt, String remark) {}
public record ReconciliationRevision(int revisionNo, String action, BigDecimal beforeAmount,
BigDecimal afterAmount, String reason, long operatorId,
BigDecimal afterAmount, String reason, long operatorId, String operatorName,
LocalDateTime occurredAt) {}
}
@@ -0,0 +1,26 @@
package com.baiyue.finance.service;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
final class FinanceOperatorSnapshot {
private FinanceOperatorSnapshot() { }
static String currentName(long operatorId) {
if (operatorId == 0) return "系统";
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user
&& user.userId() != null && user.userId() == operatorId
&& StringUtils.hasText(user.username())) {
return user.username().trim();
}
return null;
}
static String requiredName(long operatorId) {
String name = currentName(operatorId);
return name == null ? "操作人 " + operatorId : name;
}
}
@@ -66,9 +66,9 @@ public class FinancePhase1OperationService {
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
if (startDate != null && endDate != null && startDate.isAfter(endDate)) throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
List<RawLedger> rows = new ArrayList<>();
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,created_by_name,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,created_by_name,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id));
BigDecimal outstanding = BigDecimal.ZERO;
List<LedgerEntry> projected = new ArrayList<>();
@@ -76,7 +76,7 @@ public class FinancePhase1OperationService {
BigDecimal receivableChange = row.receivable();
outstanding = outstanding.add(receivableChange);
BigDecimal payable = "COST".equals(row.documentType()) ? row.amount() : BigDecimal.ZERO;
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, "operator-" + row.operatorId(), row.remark());
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark());
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
}
projected.sort(Comparator.comparing(LedgerEntry::occurredAt).reversed()
@@ -166,17 +166,20 @@ public class FinancePhase1OperationService {
private void audit(long customerId, long id, String action, String reason, Object before, Object after, long operatorId) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, "FIN_FEE_SOURCE", id, action, reason, json(before), json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
customerId, "FIN_FEE_SOURCE", id, action, reason, json(before), json(after), operatorId,
FinanceOperatorSnapshot.requiredName(operatorId), MDC.get("requestId"), MDC.get("traceId"));
}
private static RawLedger raw(ResultSet rs, String businessType, String documentType, String noColumn, String debitColumn, String creditColumn) throws SQLException {
LocalDateTime occurred = time(rs, documentType.equals("RECEIPT") ? "received_time" : "created_at");
BigDecimal amount = documentType.equals("COST") ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn);
BigDecimal receivable = debitColumn != null ? amount : creditColumn != null ? amount.negate() : BigDecimal.ZERO;
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable, rs.getLong("created_by"), rs.getString("remark"));
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable,
rs.getLong("created_by"), rs.getString("created_by_name"), rs.getString("remark"));
}
private record RawLedger(long id, LocalDateTime occurredAt, String businessType, String documentType,
String documentNo, BigDecimal amount, BigDecimal receivable, long operatorId, String remark) {}
String documentNo, BigDecimal amount, BigDecimal receivable, long operatorId,
String operatorName, String remark) {}
private static int page(int value) { if (value < 1) throw error("FIN-REQ-400", "页码必须大于0"); return value; }
private static int pageSize(int value) { if (value < 1 || value > 200) throw error("FIN-REQ-400", "每页数量必须在1到200之间"); return value; }
@@ -124,24 +124,26 @@ public class FinanceQueryService {
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
validateOptionalCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE 1=1";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), args);
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')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), customerId, billId);
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")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
List<StatementLine> lines = jdbc.query("SELECT id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",
(rs, rowNum) -> new StatementLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_id"), rs.getInt("source_version"),
@@ -158,9 +160,10 @@ public class FinanceQueryService {
public List<BillRevision> billRevisions(long customerId, long billId) {
statement(customerId, billId, false);
return jdbc.query("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC",
return jdbc.query("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC",
(rs, rowNum) -> new BillRevision(rs.getLong("id"), rs.getLong("bill_id"), rs.getInt("version"), rs.getString("reason"),
rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"), time(rs, "created_at")), customerId, billId);
rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"),
rs.getString("created_by_name"), time(rs, "created_at")), customerId, billId);
}
public List<ReceivableSettlement> receivableSettlements(Long customerId, boolean clientVisibleOnly) {
@@ -79,7 +79,7 @@ public class FinanceService {
requireCustomer(customerId);
Number count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill WHERE id=? AND tenant_id=?", Number.class, billId, customerId);
if (count == null || count.intValue() != 1) throw bad("账单不存在或不属于客户");
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId);
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId);
}
@Transactional
@@ -130,10 +130,10 @@ public class FinanceService {
LocalDate billDate = request.billDate() == null ? LocalDate.now() : request.billDate();
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,total_amount,outstanding_amount,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,total_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("BILL")); ps.setLong(3, request.subjectId()); ps.setString(4, customerName);
ps.setString(5, "CNY"); ps.setObject(6, billDate); ps.setObject(7, billDate.plusDays(days)); ps.setBigDecimal(8, total); ps.setBigDecimal(9, total);
ps.setString(10, request.remark()); ps.setLong(11, operatorId); ps.setLong(12, operatorId); return ps;
ps.setString(10, request.remark()); ps.setLong(11, operatorId); ps.setString(12, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(13, operatorId); ps.setString(14, FinanceOperatorSnapshot.currentName(operatorId)); return ps;
}, holder);
long billId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo("BILL", billId), billId);
@@ -192,8 +192,8 @@ public class FinanceService {
public Map<String, Object> issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, Map.of("billId",billId,"expectedVersion",expectedVersion));
if (replay != null) return bill(tenantId, replay);
int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?",
LocalDateTime.now(), operatorId, operatorId, billId, tenantId, expectedVersion);
int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, updated_by_name=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?",
LocalDateTime.now(), operatorId, operatorId, FinanceOperatorSnapshot.currentName(operatorId), billId, tenantId, expectedVersion);
if (updated != 1) throw bad("账单不存在或已出账");
complete(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, billId);
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=? AND tenant_id=?", billId, tenantId);
@@ -224,15 +224,15 @@ public class FinanceService {
}
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_receipt(tenant_id,receipt_no,subject_id,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("RCPT")); ps.setLong(3, request.subjectId()); ps.setBigDecimal(4, total); ps.setObject(5, request.receivedAt() == null ? LocalDateTime.now() : request.receivedAt()); ps.setString(6, request.paymentMethod()); ps.setString(7, request.voucherNo()); ps.setString(8, json(request.voucherFileIds())); ps.setString(9, request.remark()); ps.setLong(10, operatorId); ps.setLong(11, operatorId); return ps;
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_receipt(tenant_id,receipt_no,subject_id,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,remark,created_by,created_by_name,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("RCPT")); ps.setLong(3, request.subjectId()); ps.setBigDecimal(4, total); ps.setObject(5, request.receivedAt() == null ? LocalDateTime.now() : request.receivedAt()); ps.setString(6, request.paymentMethod()); ps.setString(7, request.voucherNo()); ps.setString(8, json(request.voucherFileIds())); ps.setString(9, request.remark()); ps.setLong(10, operatorId); ps.setString(11, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(12, operatorId); return ps;
}, holder);
long receiptId = holder.getKey().longValue();
jdbc.update("UPDATE fin_receipt SET receipt_no=? WHERE id=?", stableNo("RCPT", receiptId), receiptId);
for (var entry : byBill.entrySet()) {
BigDecimal applied = entry.getValue();
jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId);
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, operatorId, entry.getKey(), tenantId);
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, updated_by_name=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, operatorId, FinanceOperatorSnapshot.currentName(operatorId), entry.getKey(), tenantId);
Integer settlementCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status<>'VOIDED'",
Integer.class, tenantId, entry.getKey());
@@ -283,8 +283,8 @@ public class FinanceService {
validateContainerOrders(tenantId, request.containerId(), containerOrders);
String supplierNameSnapshot = supplierName;
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, supplierNameSnapshot); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setLong(15, operatorId); return ps; }, holder);
jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,created_by_name,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, supplierNameSnapshot); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setString(15, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(16, operatorId); return ps; }, holder);
long costId = holder.getKey().longValue();
jdbc.update("UPDATE fin_cost SET cost_no=? WHERE id=?", stableNo("COST", costId), costId);
if ("CONTAINER_SHARED".equals(request.costType())) {
@@ -323,7 +323,8 @@ public class FinanceService {
if (allocation.roundingAdjustment()) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot) VALUES(?,?,?,?,?,?,?,?,?)",
tenantId, "FIN_COST_ALLOCATION", costId, "ROUNDING_ADJUSTMENT", "尾差归集到最后一票", null,
json(Map.of("orderId", row.orderId(), "allocatedAmount", allocation.amount(), "totalAmount", amount)), operatorId, "operator-" + operatorId);
json(Map.of("orderId", row.orderId(), "allocatedAmount", allocation.amount(), "totalAmount", amount)),
operatorId, FinanceOperatorSnapshot.requiredName(operatorId));
}
}
}
@@ -417,20 +418,20 @@ public class FinanceService {
Object credit = customerTerms(tenantId).get("credit_days");
if (credit == null) credit = customerTerms(tenantId).get("creditDays");
LocalDate dueDate = effectiveDate.plusDays(credit == null ? 30 : ((Number) credit).intValue());
int changed = jdbc.update("UPDATE fin_bill SET bill_date=?,due_date=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?", effectiveDate,dueDate,operatorId,billId,tenantId,expectedVersion);
int changed = jdbc.update("UPDATE fin_bill SET bill_date=?,due_date=?,version=version+1,updated_by=?,updated_by_name=? WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?", effectiveDate,dueDate,operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion);
if (changed != 1) throw bad("仅草稿账单可修改且版本必须匹配");
jdbc.update("UPDATE fin_bill SET total_amount=?, outstanding_amount=? WHERE id=? AND tenant_id=?", total, total, billId, tenantId);
complete(tenantId, operatorId, "BILL_UPDATE", key, billId);
return bill(tenantId,billId);
}
@Transactional
public Map<String,Object> adjustBill(long operatorId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_ADJUST",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason,"lines",lines)); if(replay!=null) return bill(tenantId,replay); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map<String,Object> current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); }
public Map<String,Object> adjustBill(long operatorId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_ADJUST",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason,"lines",lines)); if(replay!=null) return bill(tenantId,replay); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map<String,Object> current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=?,updated_by_name=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); }
@Transactional
public Map<String,Object> voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_VOID",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason)); if(replay!=null) return bill(tenantId,replay); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); }
public Map<String,Object> voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_VOID",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason)); if(replay!=null) return bill(tenantId,replay); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); }
private long billTenant(long billId){ Long t=jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?",Long.class,billId); if(t==null) throw bad("账单不存在"); return t; }
private void snapshot(long tenantId,long billId,int version,String reason,Map<String,Object> bill,long operatorId){
var lines=jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",tenantId,billId);
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by) VALUES(?,?,?,?,?,?,?)",tenantId,billId,version,reason,json(bill),json(lines),operatorId);
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name) VALUES(?,?,?,?,?,?,?,?)",tenantId,billId,version,reason,json(bill),json(lines),operatorId,FinanceOperatorSnapshot.currentName(operatorId));
}
private BigDecimal lineTotal(List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); java.util.Set<String> sources = new java.util.HashSet<>(); return lines.stream().map(l -> { if (l.sourceFeeId() <= 0 || l.sourceFeeVersion() <= 0) throw bad("费用来源版本无效"); if (!sources.add(l.sourceFeeId()+":"+l.sourceFeeVersion())) throw bad("重复费用来源不允许合并"); if (l.amount() == null || l.amount().signum() < 0) throw bad("费用金额不能为负数"); return l.amount(); }).reduce(BigDecimal.ZERO, BigDecimal::add); }
private void replaceDraftLines(long tenantId, long billId, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { lineTotal(lines); Map<String,Object> current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); }
@@ -176,18 +176,18 @@ public class FinanceSettlementService {
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList();
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_at,remark " +
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " +
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
summary.customerId(), settlementId).stream().map(this::transaction).toList());
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
transactions.addAll(jdbc.queryForList(
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_at,r.remark " +
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_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.created_at,r.id",
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
}
List<Map<String, Object>> audits = jdbc.queryForList(
"SELECT action,reason,before_data,after_data,operator_id,created_at FROM fin_audit_log " +
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
summary.customerId(), settlementId);
List<SettlementRevision> revisions = new ArrayList<>();
@@ -317,10 +317,11 @@ public class FinanceSettlementService {
private void insertPayment(long customerId, long settlementId, CreateSettlementPaymentRequest request,
long operatorId) {
jdbc.update("INSERT INTO fin_settlement_payment(tenant_id,settlement_id,transaction_type,transaction_no," +
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_at) " +
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))",
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_by_name,created_at) " +
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))",
customerId, settlementId, documentNo("PAY"), request.transactionDate(), request.amount(), CNY,
request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()), operatorId);
request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()),
operatorId, FinanceOperatorSnapshot.currentName(operatorId));
}
private void validatePayableRequest(CreatePayableSettlementRequest request) {
@@ -418,7 +419,8 @@ public class FinanceSettlementService {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data," +
"operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, "FIN_SETTLEMENT", resourceId, action, reason, before == null ? null : json(before),
json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
json(after), operatorId, FinanceOperatorSnapshot.requiredName(operatorId),
MDC.get("requestId"), MDC.get("traceId"));
}
private SettlementSummary summary(Map<String, Object> row) {
@@ -454,6 +456,7 @@ public class FinanceSettlementService {
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
text(row, "created_by_name"),
time(row, "created_at"), text(row, "remark"));
}
@@ -462,6 +465,7 @@ public class FinanceSettlementService {
JsonNode after = parseTree(text(row, "after_data"));
return new SettlementRevision(revisionNo, text(row, "action"), jsonDecimal(before, "totalAmount"),
jsonDecimal(after, "totalAmount"), text(row, "reason"), number(row, "operator_id").longValue(),
text(row, "operator_name_snapshot"),
time(row, "created_at"));
}
@@ -65,7 +65,7 @@ public class ReconciliationStatementService {
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,updated_by) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?)",
"INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, request.customerId());
statement.setString(2, "REC-PENDING-" + UUID.randomUUID());
@@ -78,7 +78,9 @@ public class ReconciliationStatementService {
statement.setBigDecimal(9, total);
statement.setString(10, request.remark());
statement.setLong(11, operatorId);
statement.setLong(12, operatorId);
statement.setString(12, FinanceOperatorSnapshot.currentName(operatorId));
statement.setLong(13, operatorId);
statement.setString(14, FinanceOperatorSnapshot.currentName(operatorId));
return statement;
}, holder);
if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
@@ -115,9 +117,10 @@ public class ReconciliationStatementService {
if ("VOID".equals(action)) requireVoidable(before.customerId(), statementId);
if ("REOPEN".equals(action) && "VOIDED".equals(before.status())) reacquireSources(before.customerId(), statementId);
snapshot(before, request.reason(), operatorId);
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?",
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?",
transition.to(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId,
transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion());
transition.to(), request.reason(), operatorId, FinanceOperatorSnapshot.currentName(operatorId),
statementId, before.customerId(), transition.from(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
if ("ISSUE".equals(action)) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'",
@@ -146,8 +149,9 @@ public class ReconciliationStatementService {
BigDecimal.class, before.customerId(), statementId);
if (total == null) total = BigDecimal.ZERO;
if (total.compareTo(before.received()) < 0) throw error("FIN_409", "调整后金额不得低于已收金额");
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
total, total, operatorId, statementId, before.customerId(), request.expectedVersion());
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
total, total, operatorId, FinanceOperatorSnapshot.currentName(operatorId), statementId,
before.customerId(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", request.reason(), before, after, operatorId);
@@ -162,15 +166,16 @@ public class ReconciliationStatementService {
"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",
(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_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.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,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",
"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",
(rs, row) -> revision(rs, row + 1), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
bill.createdBy(), bill.createdAt(), bill.updatedBy(), bill.updatedAt(), bill.remark(), lines, payments, revisions);
bill.createdBy(), bill.createdByName(), bill.createdAt(), bill.updatedBy(), bill.updatedByName(),
bill.updatedAt(), bill.remark(), lines, payments, revisions);
}
public ReconciliationDetail detailForPlatform(long statementId) {
@@ -248,8 +253,9 @@ public class ReconciliationStatementService {
private void snapshot(Bill bill, String reason, long operatorId) {
List<Map<String, Object>> lines = jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", bill.customerId(), bill.id());
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by) VALUES(?,?,?,?,?,?,?)",
bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId);
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name) VALUES(?,?,?,?,?,?,?,?)",
bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId,
FinanceOperatorSnapshot.currentName(operatorId));
}
private void requireVoidable(long customerId, long statementId) {
@@ -301,7 +307,8 @@ public class ReconciliationStatementService {
Object before, Object after, long operatorId) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, "RECONCILIATION_STATEMENT", statementId, action, reason, before == null ? null : json(before),
after == null ? null : json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
after == null ? null : json(after), operatorId, FinanceOperatorSnapshot.requiredName(operatorId),
MDC.get("requestId"), MDC.get("traceId"));
}
private static void validateCreate(CreateReconciliationRequest request) {
@@ -396,12 +403,14 @@ public class ReconciliationStatementService {
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.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
rs.getLong("created_by"), rs.getString("created_by_name"),
rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
}
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
return new ReconciliationRevision(revisionNo, rs.getString("action"), 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());
}
@@ -421,7 +430,8 @@ public class ReconciliationStatementService {
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
rs.getString("created_by_name"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
rs.getString("updated_by_name"),
rs.getTimestamp("updated_at").toLocalDateTime());
}
@@ -462,6 +472,6 @@ public class ReconciliationStatementService {
String feeItemName, BigDecimal amount) {}
private record Bill(long id, long customerId, String number, String customerName, String status,
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
int creditDays, String remark, long createdBy, LocalDateTime createdAt,
long updatedBy, LocalDateTime updatedAt) {}
int creditDays, String remark, long createdBy, String createdByName,
LocalDateTime createdAt, long updatedBy, String updatedByName, LocalDateTime updatedAt) {}
}
@@ -110,7 +110,9 @@ CREATE TABLE IF NOT EXISTS fin_bill (
void_reason VARCHAR(500) NULL COMMENT '账单作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
@@ -163,6 +165,7 @@ CREATE TABLE IF NOT EXISTS fin_receipt (
void_reason VARCHAR(500) NULL COMMENT '收款作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
@@ -213,6 +216,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
void_reason VARCHAR(500) NULL COMMENT '成本作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
@@ -257,6 +261,7 @@ CREATE TABLE IF NOT EXISTS fin_bill_revision (
bill_snapshot JSON NOT NULL COMMENT '调整前账单主记录不可变JSON快照',
line_snapshot JSON NOT NULL COMMENT '调整前全部账单明细不可变JSON数组快照',
created_by BIGINT UNSIGNED NOT NULL COMMENT '执行调整的操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '执行调整的操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '修订快照创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_revision (bill_id, version),
@@ -363,6 +368,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '登记操作人ID',
created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no),
@@ -18,6 +18,7 @@ import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -111,6 +112,37 @@ class FinancePhase1OperationBehaviorTest {
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L);
}
@Test
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT id,bill_no"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(81L);
when(rs.getLong("created_by")).thenReturn(71L);
when(rs.getString("bill_no")).thenReturn("REC-81");
when(rs.getString("created_by_name")).thenReturn("platform_admin");
when(rs.getString("remark")).thenReturn("八月账单");
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("5200.00"));
when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-08-17 13:00:00"));
return List.of(mapper.mapRow(rs, 0));
});
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
assertThat(result.data()).singleElement().satisfies(entry -> {
assertThat(entry.businessType()).isEqualTo("SETTLEMENT_ISSUED");
assertThat(entry.documentType()).isEqualTo("BILL");
assertThat(entry.documentNo()).isEqualTo("REC-81");
assertThat(entry.occurredAt()).isEqualTo(LocalDateTime.of(2026, 8, 17, 13, 0));
assertThat(entry.operatorName()).isEqualTo("platform_admin");
});
}
@Test
void rejectedFeeCannotBeIncludedInSettlement() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
@@ -182,7 +214,7 @@ class FinancePhase1OperationBehaviorTest {
eq("APPROVED"), eq("核对无误"), eq(88L), eq(0))).thenReturn(1);
when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"),
eq(88L), eq("FEE_SOURCE_REVIEW_APPROVE"), eq("核对无误"), anyString(), anyString(),
eq(71L), eq("operator-71"), any(), any())).thenReturn(1);
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
@@ -71,6 +71,29 @@ class FinancePlatformListContractTest {
.contains("customerId");
}
@Test
void financeDetailsKeepOperatorIdsAndExposeNameSnapshots() {
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.PaymentHistory.class,
"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");
}
private static void assertRecordFields(Class<?> type, String... names) {
assertThat(Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
.contains(names);
}
private static Method find(String name) throws Exception {
return switch (name) {
case "feePool" -> PlatformFinanceController.class.getMethod(name, Long.class, String.class);
@@ -171,7 +171,7 @@ class FinanceServiceValidationTest {
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")),
eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00")));
verify(jdbc).update(contains("status=CASE WHEN outstanding_amount=0"),
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(2L), eq(50L), eq(9L));
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(2L), isNull(), eq(50L), eq(9L));
}
@Test
@@ -234,11 +234,14 @@ class FinanceSettlementBehaviorTest {
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 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.of(
"id", 501L, "transaction_type", "PAYMENT", "transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11)),
"amount", new BigDecimal("40.00"), "method", "BANK_TRANSFER", "reference_no", "BANK-1",
"voucher_file_ids", "[\"91\"]", "created_by", 7L,
"created_at", Timestamp.valueOf(LocalDateTime.of(2026, 8, 11, 10, 0)), "remark", "首笔付款")));
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"),
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
Map.entry("created_by", 7L), Map.entry("created_by_name", "platform_admin"),
Map.entry("created_at", Timestamp.valueOf(LocalDateTime.of(2026, 8, 11, 10, 0))),
Map.entry("remark", "首笔付款"))));
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(10L), eq(88L))).thenReturn(List.of());
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
@@ -252,6 +255,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.summary().outstandingAmount()).isEqualByComparingTo("60.00");
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("PAYMENT");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
any(), any(), any(), any(), any(), any(), any(), any());
}
@@ -431,6 +435,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("transaction_date", Date.valueOf("2026-08-11")), Map.entry("amount", new BigDecimal("40.00")),
Map.entry("method", "BANK_TRANSFER"), Map.entry("reference_no", "RCPT-701"),
Map.entry("voucher_file_ids", "[\"91\"]"), Map.entry("created_by", 7L),
Map.entry("created_by_name", "platform_admin"),
Map.entry("created_at", Timestamp.valueOf("2026-08-11 10:00:00")), Map.entry("remark", "首笔收款"))));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
@@ -439,6 +444,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("RECEIPT");
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
}
@Test
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(16);
assertThat(columnCount).isEqualTo(233);
assertThat(columnCount).isEqualTo(239);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -70,4 +70,25 @@ class FinanceSqlCommentContractTest {
.contains("ALTER TABLE baiyue_finance.fin_bill_line ADD COLUMN adjustment_reason")
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'");
}
@Test
void financeLedgerSourcesPersistOperatorNameSnapshotsInBaselineAndUnifiedSql() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path unifiedPath = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
assertThat(baseline.split("created_by_name VARCHAR\\(128\\) NULL COMMENT '创建操作人名称快照'", -1))
.hasSize(4);
assertThat(baseline)
.contains("updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照'")
.contains("created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照'");
assertThat(unified)
.contains("CALL finance_add_operator_snapshot_column('fin_bill', 'created_by_name', 'created_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_bill', 'updated_by_name', 'updated_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_receipt', 'created_by_name', 'created_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_cost', 'created_by_name', 'created_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_settlement_payment', 'created_by_name', 'created_by')")
.contains("CALL finance_add_operator_snapshot_column('fin_bill_revision', 'created_by_name', 'created_by')");
}
}
@@ -33,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
import static org.mockito.Mockito.never;
@@ -103,6 +104,8 @@ class ReconciliationStatementBehaviorTest {
assertThat(result.statementId()).isEqualTo(501L);
assertThat(result.statementNo()).isEqualTo("REC-00000000000000000501");
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.createdByName()).isEqualTo("platform_admin");
assertThat(result.updatedByName()).isEqualTo("platform_admin");
assertThat(result.totalReceivable()).isEqualByComparingTo("100.00");
assertThat(result.lines()).hasSize(1);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
@@ -147,11 +150,11 @@ class ReconciliationStatementBehaviorTest {
assertThat(result.status()).isEqualTo("PENDING_REVIEW");
assertThat(result.version()).isEqualTo(1);
assertThat(result.periodStart()).isEqualTo(LocalDate.of(2026, 8, 1));
verify(jdbc).update(anyString(), eq(9L), eq(501L), eq(0), eq("提交审核"), anyString(), anyString(), eq(71L));
verify(jdbc).update(anyString(), eq(9L), eq(501L), eq(0), eq("提交审核"), anyString(), anyString(), eq(71L), isNull());
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="),
eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq(71L),
eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq(71L), eq("PENDING_REVIEW"),
eq("提交审核"), eq(71L), eq(501L), eq(9L), eq("DRAFT"), eq(0));
eq("提交审核"), eq(71L), isNull(), eq(501L), eq(9L), eq("DRAFT"), eq(0));
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), (Object[]) any());
}
@@ -230,7 +233,7 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill_line SET amount="),
eq(new BigDecimal("280.00")), eq("运费更正"), eq(601L), eq(501L), eq(9L));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="),
eq(new BigDecimal("280.00")), eq(new BigDecimal("280.00")), eq(71L), eq(501L), eq(9L), eq(3));
eq(new BigDecimal("280.00")), eq(new BigDecimal("280.00")), eq(71L), isNull(), eq(501L), eq(9L), eq(3));
}
@Test
@@ -465,6 +468,8 @@ class ReconciliationStatementBehaviorTest {
when(rs.getInt("version")).thenReturn(version);
when(rs.getInt("credit_days")).thenReturn(30);
when(rs.getString("remark")).thenReturn("八月对账");
when(rs.getString("created_by_name")).thenReturn("platform_admin");
when(rs.getString("updated_by_name")).thenReturn("platform_admin");
Timestamp timestamp = Timestamp.valueOf("2026-08-11 10:00:00");
when(rs.getTimestamp("created_at")).thenReturn(timestamp);
when(rs.getTimestamp("updated_at")).thenReturn(timestamp);
@@ -0,0 +1,37 @@
package com.baiyue.finance.service;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceOperatorSnapshotTest {
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void capturesAuthenticatedUsernameForMatchingOperator() {
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null,
"platform_admin", "ADMIN", "WEB", Set.of());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null));
assertThat(FinanceOperatorSnapshot.currentName(71L)).isEqualTo("platform_admin");
assertThat(FinanceOperatorSnapshot.requiredName(71L)).isEqualTo("platform_admin");
assertThat(FinanceOperatorSnapshot.currentName(72L)).isNull();
}
@Test
void labelsSystemGeneratedEntriesWithoutInventingAnOperator() {
assertThat(FinanceOperatorSnapshot.currentName(0L)).isEqualTo("系统");
assertThat(FinanceOperatorSnapshot.currentName(71L)).isNull();
assertThat(FinanceOperatorSnapshot.requiredName(71L)).isEqualTo("操作人 71");
}
}
@@ -26,5 +26,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List<LoadingPlanItemEntity> items) {}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,
Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus,
Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<LoadingPlanItemEntity> items) {}
}
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long customerId, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@Positive Long customerId,@Size(max=128) String customerName,@Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@Positive Long customerId,@Size(max=128) String customerName,@Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
public record AttachmentRequest(@NotNull @Positive Long fileId,@Size(max=64) String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
}
@@ -10,6 +10,11 @@ public class LoadingPlanItemEntity {
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot;
private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy;
private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted;
@TableField(exist=false) private Long activeInboundDetailId; private Long createdBy; private String createdByName; private LocalDateTime createdAt;
@TableField(exist=false) private Long activeInboundDetailId;
@TableField(exist=false) private Integer pickedPackageCount;
@TableField(exist=false) private Integer minPackageCount;
@TableField(exist=false) private Boolean canAdjust;
@TableField(exist=false) private Boolean canRemove;
private Long createdBy; private String createdByName; private LocalDateTime createdAt;
private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
}
@@ -39,6 +39,18 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
@Param("inboundId") long inboundId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_preload_plan SET status='CONFIRMED',confirmed_by=#{operatorId},"
+ "confirmed_by_name=#{operatorName},confirmed_at=NOW(),updated_by=#{operatorId},"
+ "updated_by_name=#{operatorName},version=version+1 WHERE id=#{preloadPlanId} "
+ "AND tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status='PENDING' "
+ "AND version=#{expectedVersion}")
int confirmIncrementalPreload(@Param("preloadPlanId") long preloadPlanId,
@Param("tenantId") long tenantId,
@Param("inboundId") long inboundId,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},"
+ "version=version+1 WHERE id=#{loadingPlanId} AND plan_status='EDITING' "
+ "AND version=#{expectedVersion}")
@@ -42,11 +42,19 @@ public class LoadingModelBridgeRepository {
public void insert(PreloadLoadingPlanLinkEntity value) { mapper.insert(value); }
public void delete(PreloadLoadingPlanLinkEntity value) { mapper.deleteById(value); }
public int touchPreload(long preloadPlanId, long tenantId, long inboundId, int version,
long operatorId, String operatorName) {
return mapper.touchPreload(preloadPlanId, tenantId, inboundId, version, operatorId, operatorName);
}
public int confirmIncrementalPreload(long preloadPlanId, long tenantId, long inboundId, int version,
long operatorId, String operatorName) {
return mapper.confirmIncrementalPreload(preloadPlanId, tenantId, inboundId, version,
operatorId, operatorName);
}
public int touchLoadingPlan(long loadingPlanId, int version, long operatorId, String operatorName) {
return mapper.touchLoadingPlan(loadingPlanId, version, operatorId, operatorName);
}
@@ -109,6 +109,57 @@ public class LoadingModelBridgeService {
return repository.byLoadingPlan(loadingPlanId);
}
/** Adds a newly allocated inbound to an already pushed EDITING/LOADING plan. */
@Transactional
public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId,
long operatorId, String operatorName, String operationId) {
if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return;
var plan = repository.loadingPlan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
if (plan.preloadPushedAt() == null) return;
if (!Set.of("EDITING", "LOADING").contains(plan.status())) {
throw conflict("PDA任务正在最终确认或已经完成,不能新增预装关联");
}
if (repository.allocatedPackages(tenantId, inboundId, loadingPlanId) <= 0) {
throw conflict("柜次计划尚未分配当前入库单货物");
}
var preload = repository.preload(tenantId, inboundId);
if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())) {
throw conflict("新增入库单预装状态不允许进入当前柜次");
}
PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity();
link.setTenantId(tenantId); link.setInboundId(inboundId); link.setPreloadPlanId(preload.id());
link.setLoadingPlanId(loadingPlanId);
link.setIdempotencyKey("INCREMENTAL:" + hash(operationId + "|" + tenantId + "|" + inboundId
+ "|" + loadingPlanId).substring(0, 52));
link.setRequestHash(hash("INCREMENTAL|" + tenantId + "|" + inboundId + "|" + loadingPlanId));
link.setCreatedBy(operatorId); link.setCreatedByName(operatorName);
try {
repository.insert(link);
} catch (DuplicateKeyException exception) {
if (repository.byPair(tenantId, inboundId, loadingPlanId) == null) {
throw conflict("新增入库单预装关联并发冲突,请刷新后重试");
}
}
// A running PDA task has already passed plan-level confirmation. Adding an inbound is the
// explicit Admin approval for that new allocation, so its fresh PENDING preload joins the
// same confirmed plan atomically instead of leaving an unreachable pending aggregate.
if ("LOADING".equals(plan.status()) && "PENDING".equals(preload.status())
&& repository.confirmIncrementalPreload(preload.id(), tenantId, inboundId,
preload.version(), operatorId, operatorName) != 1) {
throw conflict("新增入库单预装确认并发冲突,请刷新后重试");
}
}
/** Removes only a now-empty allocation link; picked-item protection is enforced by the PDA task lock. */
@Transactional
public void removeIfUnallocated(long loadingPlanId, long tenantId, long inboundId) {
if (repository.allocatedPackages(tenantId, inboundId, loadingPlanId) > 0) return;
PreloadLoadingPlanLinkEntity link = repository.byPair(tenantId, inboundId, loadingPlanId);
if (link != null) repository.delete(link);
}
public List<Link> byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); }
public List<Link> byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); }
@@ -15,7 +15,7 @@ public interface LoadingCompletionGateMapper {
WHERE a.container_booking_id=p.container_booking_id AND b.status='RELEASED') THEN 1 ELSE 0 END),0) oceanReady,
COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM fl_truck_appointment t
WHERE t.container_booking_id=p.container_booking_id AND t.loading_plan_id=p.id
WHERE t.container_booking_id=p.container_booking_id
AND t.status='COMPLETED') THEN 1 ELSE 0 END),0) truckReady
FROM fl_preload_loading_plan_link l
JOIN fl_loading_plan p ON p.id=l.loading_plan_id
@@ -11,6 +11,10 @@ public interface LoadingInboundMapper {
+ "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version "
+ "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 FOR UPDATE")
Snapshot findForUpdate(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
+ "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
@@ -121,6 +121,20 @@ public class LoadingDeliveryService {
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
}
/**
* Read-only preflight used before PDA settlement. The current plan may still be LOADING here;
* every external/business hard gate must pass before the PDA task is mutated.
*/
public void requirePdaCompletionReady(long tenantId, long inboundId) {
Snapshot inbound = requireInbound(tenantId, inboundId);
if (!"LOADING".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING");
}
loadingModelBridge.requireStarted(tenantId, inboundId);
completionGate.requireBookingAndTransport(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId);
}
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request,
@@ -20,16 +20,4 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
WHERE id=#{planId} AND plan_status='EDITING' AND preload_pushed_at IS NOT NULL
""")
int startPicking(@Param("planId") long planId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan plan SET plan_status='EDITING',updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE plan.id=#{planId} AND plan.plan_status='LOADING' AND plan.version=#{expectedVersion}
AND NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id=pick_item.pick_task_id
WHERE pick_task.loading_plan_id=plan.id AND pick_item.picked_package_count>0
)
""")
int recycleUnpicked(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -4,5 +4,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba
@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);
@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 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);
@Update("UPDATE fl_truck_appointment SET vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},customer_id=#{e.customerId},customer_name=#{e.customerName},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
}
@@ -225,6 +225,10 @@ public class LoadingPickRepository {
return inbounds.find(tenantId, inboundId);
}
public LoadingInboundMapper.Snapshot inboundForUpdate(long tenantId, long inboundId) {
return inbounds.findForUpdate(tenantId, inboundId);
}
public int beginInboundLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion);
}
@@ -75,7 +75,4 @@ public class SmartLoadingRepository {
public int startPicking(long planId, long operatorId, String operatorName) {
return plans.startPicking(planId, operatorId, operatorName);
}
public int recycleUnpicked(long planId, int expectedVersion, long operatorId, String operatorName) {
return plans.recycleUnpicked(planId, expectedVersion, operatorId, operatorName);
}
}
@@ -32,10 +32,13 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
import com.baiyue.firstleg.loading.dto.LoadingRequests;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -56,6 +59,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.Map;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -77,6 +81,8 @@ public class LoadingPickService {
@Resource
private LoadingListService loadingListService;
@Resource
private LoadingDeliveryService loadingDeliveryService;
@Resource
private InboundStatusHistoryMapper inboundHistories;
@Resource
private PdaPickSettlementService settlementService;
@@ -159,11 +165,38 @@ public class LoadingPickService {
@Transactional
public void sync(long planId, LoadingPlanItemEntity planItem) {
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task != null && "LOADING".equals(task.getTaskStatus())) {
syncItem(task.getId(), planItem);
if (task == null) return;
task = repository.taskForUpdate(task.getId());
requireMutableTask(task);
syncItem(task.getId(), planItem);
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化,装柜明细同步失败");
}
}
/** Shared lock used by Admin plan mutations and the final PDA completion transaction. */
@Transactional
public void lockMutable(long planId) {
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) return;
requireMutableTask(repository.taskForUpdate(task.getId()));
}
/** Read model used by the Admin plan response; mutations still lock the task row separately. */
public MutationSnapshot mutationSnapshot(long planId) {
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) return new MutationSnapshot(null, null, null, Map.of());
Map<Long, Integer> pickedByPlanItem = repository.items(task.getId()).stream()
.filter(item -> item.getLoadingPlanItemId() != null)
.collect(Collectors.toMap(LoadingPickItemEntity::getLoadingPlanItemId,
item -> count(item.getPickedPackageCount()), Math::max));
return new MutationSnapshot(task.getId(), task.getTaskStatus(), task.getVersion(), pickedByPlanItem);
}
public record MutationSnapshot(Long taskId, String taskStatus, Integer taskVersion,
Map<Long, Integer> pickedByPlanItem) {
}
public Task task(String cabinetNo, Long loadingPlanId, long warehouseId) {
int selectors = (StringUtils.hasText(cabinetNo) ? 1 : 0)
+ (loadingPlanId != null ? 1 : 0);
@@ -412,6 +445,19 @@ public class LoadingPickService {
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
}
if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成");
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
.sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId)
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
.toList();
if (owners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
Map<LoadingPickItemMapper.InboundOwner, LoadingInboundMapper.Snapshot> inboundSnapshots = new LinkedHashMap<>();
for (LoadingPickItemMapper.InboundOwner owner : owners) {
LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId());
if (snapshot == null) throw conflict("PDA任务关联入库单不存在");
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId());
inboundSnapshots.put(owner, snapshot);
}
settlementService.settle(taskId, operatorId, operatorName);
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
throw conflict("柜次仍有未完成PDA拣货的箱数");
@@ -422,6 +468,13 @@ public class LoadingPickService {
insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
repository.syncInboundProjections(taskId, operatorId, operatorName);
for (Map.Entry<LoadingPickItemMapper.InboundOwner, LoadingInboundMapper.Snapshot> entry
: inboundSnapshots.entrySet()) {
LoadingPickItemMapper.InboundOwner owner = entry.getKey();
loadingDeliveryService.completeLoading(owner.tenantId(), owner.inboundId(), operatorId,
operatorName, "PDA-" + operationId + '-' + owner.inboundId(),
new LoadingRequests.CompleteLoadingRequest(entry.getValue().version(), remark));
}
return view(requiredTask(taskId), null);
}
@@ -438,10 +491,21 @@ public class LoadingPickService {
if (item == null) {
return;
}
LoadingPickTaskEntity task = repository.taskForUpdate(item.getPickTaskId());
requireMutableTask(task);
if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) {
throw conflict("已有拣货记录的明细不能删除");
}
repository.deleteItem(item.getId());
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化,装柜明细删除失败");
}
}
private static void requireMutableTask(LoadingPickTaskEntity task) {
if (task == null || !"LOADING".equals(task.getTaskStatus())) {
throw conflict("PDA任务正在最终确认或已经完成,不能修改预装明细");
}
}
private void syncItem(long taskId, LoadingPlanItemEntity planItem) {
@@ -130,10 +130,12 @@ public class SmartLoadingService {
if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改");
changed = existing;
}
history(planId, changed.getId(), "ADD_ITEM", "EDITING", "EDITING", operation,
history(planId, changed.getId(), "ADD_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash, "itemId", changed.getId(),
"packageCount", changed.getPackageCount())), operatorId, operatorName);
loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(),
operatorId, operatorName, operation);
loadingPickService.sync(planId, changed);
return view(repository.plan(planId), booking);
}
@@ -163,7 +165,7 @@ public class SmartLoadingService {
}
}
LoadingPlanEntity recommended = repository.plan(planId);
history(planId, null, "SMART_RECOMMEND", "EDITING", "EDITING", operation,
history(planId, null, "SMART_RECOMMEND", plan.getPlanStatus(), recommended.getPlanStatus(), operation,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash, "version", recommended.getVersion())),
operatorId, operatorName);
@@ -188,6 +190,7 @@ public class SmartLoadingService {
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
@@ -198,7 +201,7 @@ public class SmartLoadingService {
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount());
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
history(planId, itemId, "ADJUST_ITEM", "EDITING", "EDITING", operation,
history(planId, itemId, "ADJUST_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "packageCount", request.packageCount())),
operatorId, operatorName);
@@ -221,7 +224,8 @@ public class SmartLoadingService {
loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName);
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");
history(planId, itemId, "REMOVE_ITEM", "EDITING", "EDITING", operation,
loadingModelBridge.removeIfUnallocated(planId, item.getAccountId(), item.getInboundId());
history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
@@ -372,7 +376,28 @@ public class SmartLoadingService {
int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue();
return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW)));
}
private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),b.getContainerNo(),b.getVoyageNo(),p.getVersion(),repository.items(p.getId()));}
private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){
var task = loadingPickService.mutationSnapshot(p.getId());
boolean planMutable = Set.of("EDITING", "LOADING").contains(p.getPlanStatus());
boolean taskMutable = task.taskId() == null || "LOADING".equals(task.taskStatus());
boolean mutable = planMutable && taskMutable;
String blockedReason = mutable ? null : "COMPLETED".equals(p.getPlanStatus())
|| "COMPLETED".equals(task.taskStatus()) ? "装柜已最终完成,不能修改预装明细"
: "当前计划或PDA任务状态不允许修改预装明细";
List<LoadingPlanItemEntity> items = repository.items(p.getId());
for (LoadingPlanItemEntity item : items) {
int picked = task.pickedByPlanItem().getOrDefault(item.getId(), 0);
item.setPickedPackageCount(picked);
item.setMinPackageCount(Math.max(1, picked));
item.setCanAdjust(mutable);
item.setCanRemove(mutable && picked == 0);
}
String finalStatus = task.taskId() == null ? "NOT_STARTED"
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus,
task.taskId(),task.taskStatus(),task.taskVersion(),items);
}
private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) {
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿");
if (!Set.of("EDITING", "LOADING").contains(plan.getPlanStatus())) {
@@ -386,11 +411,7 @@ public class SmartLoadingService {
if (!Set.of("EDITING", "LOADING").contains(p.getPlanStatus()) || !Objects.equals(p.getVersion(), version)) {
throw conflict("计划已完成或版本冲突");
}
if ("LOADING".equals(p.getPlanStatus())
&& repository.recycleUnpicked(id, version, operatorId, operatorName) == 1) {
p.setPlanStatus("EDITING");
p.setVersion(version + 1);
}
loadingPickService.lockMutable(id);
return p;
}
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
@@ -43,21 +43,21 @@ public class TruckAppointmentService {
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(type);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(r.attachmentType());a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); OceanBookingEntity booking=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,booking,carrier==null?null:carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); OceanBookingEntity booking=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,booking,carrier==null?null:carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); BookingLink link=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,link,carrier==null?null:carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); BookingLink link=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,link,carrier==null?null:carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
private OceanBookingEntity validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); 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与订舱客户快照不一致"); if(r.containerBookingId()!=null&&r.containerBookingId()>0){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与柜体不一致");} validateLoadingPlanLink(r);return b;}
private void validateLoadingPlanLink(SaveRequest r){if(r.loadingPlanId()==null)return;if(r.loadingPlanId()<=0)throw bad("loadingPlanId必须大于0");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("关联装柜计划时containerBookingId不能为空");LoadingPlanEntity plan=loadingPlans.plan(r.loadingPlanId());if(plan==null)throw new BizException(ResultCode.NOT_FOUND,"关联装柜计划不存在");if(!Objects.equals(plan.getContainerBookingId(),r.containerBookingId()))throw bad("loadingPlanId与柜体不一致");}
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 void copy(TruckAppointmentEntity e,SaveRequest r,OceanBookingEntity booking,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());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 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);}}
private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}}
private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");}
private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");}
private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){}
private record BookingLink(OceanBookingEntity booking,LoadingPlanEntity plan){}
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}
}
@@ -357,6 +357,53 @@ paths:
responses:
'200': {description: 成功、计划不存在或计划已进入预装流程}
'403': {description: 无智能装柜查询权限}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:
post:
summary: 向预装计划新增入库单明细
description: >-
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
校验仓库、渠道、客户范围、可用箱数、容积和限重后原子新增/累加明细;LOADING 时同步到原 taskId,
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
PDA 应重新 GET task detail 与 inventory snapshot,Admin 应重新 GET SmartLoadingPlan。新增项沿用原 taskId、
loadingPlanId、containerBookingId 和 inbound identity;已全拣任务会因新增 PENDING item 从 AWAITING_REVIEW
回到 IN_PROGRESS,已有证据不删除,但 final complete 门禁撤回。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingAssignRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:
patch:
summary: 调整预装计划明细箱数
description: >-
EDITING/LOADING 均可;packageCount 不得小于已拣 pickedPackageCount。成功递增 plan、计划明细和现有 PDA task 版本,
原 inventorySnapshotToken 失效。PDA task 已完成或并发版本变化返回 A0409,事务零写入;调用方须刷新
SmartLoadingPlan,PDA 在下一次写入前须刷新 task detail 与 inventory snapshot。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingAdjustRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
delete:
summary: 删除未拣预装计划明细
description: >-
仅 pickedPackageCount=0 且计划/PDA task 未最终完成时允许。成功递增 plan 和 task 版本并使原库存快照失效;
已拣、COMPLETED 或并发版本变化返回 A0409,事务零写入。PDA final complete 与本操作使用相同 task 行锁,
因而只允许一个事务成功;失败方刷新计划、task detail 与 inventory snapshot 后再决定后续操作。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:
post:
summary: 推送智能装柜计划到预装清单
@@ -1303,6 +1350,67 @@ components:
required: [expectedVersion]
properties:
expectedVersion: {type: integer, minimum: 0}
SmartLoadingAssignRequest:
type: object
required: [inboundId, inboundDetailId, packageCount, expectedVersion, operationId]
properties:
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'}
packageCount: {type: integer, minimum: 1}
expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingAdjustRequest:
type: object
required: [packageCount, expectedPlanVersion, expectedItemVersion, operationId]
properties:
packageCount: {type: integer, minimum: 1, description: 不得小于响应中的 minPackageCount}
expectedPlanVersion: {type: integer, minimum: 0}
expectedItemVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingDeleteRequest:
type: object
required: [expectedPlanVersion, expectedItemVersion, operationId]
properties:
expectedPlanVersion: {type: integer, minimum: 0}
expectedItemVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingPlan:
type: object
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
properties:
id: {type: string}
planNo: {type: string}
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
bookingId: {type: string}
containerNo: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
version: {type: integer, minimum: 0, description: planVersion,写请求使用此值}
canAssign: {type: boolean}
canEdit: {type: boolean}
blockedReason: {type: string, nullable: true}
finalCompletionStatus: {type: string, enum: [NOT_STARTED, IN_PROGRESS, COMPLETED], description: IN_PROGRESS 表示 final complete 门禁尚未通过;增量新增 PENDING item 会保持或恢复此值,COMPLETED 后 canAssign/canEdit 均为 false}
pdaTaskId: {type: string, nullable: true}
pdaTaskStatus: {type: string, nullable: true, enum: [LOADING, COMPLETED], description: LOADING task 在增量后保持原 taskId;COMPLETED 永久拒绝增量}
pdaTaskVersion: {type: integer, minimum: 0, nullable: true, description: 服务端同步后最新 taskVersion;Admin 写操作不提交该值}
items:
type: array
items: {$ref: '#/components/schemas/SmartLoadingPlanItem'}
SmartLoadingPlanItem:
type: object
required: [id, packageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove]
properties:
id: {type: string}
inboundId: {type: string}
inboundDetailId: {type: string}
orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
customerName: {type: string, nullable: true}
packageCount: {type: integer, minimum: 1}
version: {type: integer, minimum: 0, description: itemVersion,调整/删除写请求使用此值}
pickedPackageCount: {type: integer, minimum: 0}
minPackageCount: {type: integer, minimum: 1}
canAdjust: {type: boolean}
canRemove: {type: boolean}
ContainerDocumentAttachRequest:
type: object
required: [fileId, expectedVersion]
@@ -94,7 +94,8 @@ paths:
'200': {description: Business envelope containing task or null}
/api/platform/v1/inbounds/{inboundId}/loading/complete:
post:
summary: Complete loading only after picking and five evidence types each have an active photo
summary: Compatibility endpoint for completing loading after the canonical PDA confirmation
description: Canonical PDA confirmation now atomically completes task, plan and inbound LOADED. This endpoint remains an idempotent server-side completion primitive; RELEASED booking, COMPLETED truck, completed plan/picking and five ACTIVE evidence types remain hard gates.
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
@@ -106,7 +106,7 @@ paths:
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete:
post:
summary: PDA拣货全部处理后人工确认装柜完成
description: PDA拣货只锁定数量和仓储资源,本接口执行结算、释放资源并生成装柜清单。
description: PDA拣货只锁定数量和仓储资源。本接口先无写入校验全部关联入库单的RELEASED订舱、COMPLETED拖车和五类ACTIVE证据;任何阻断返回A0409且task、plan、inbound仍保持LOADING。全部满足后在同一事务内结算资源、完成task/plan并推进inbound为LOADED。
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody:
required: true
@@ -155,11 +155,11 @@ components:
reason: {type: string, maxLength: 500}
SaveRequest:
type: object
required: [oceanBookingId, plannedLoadingAt, expectedVersion]
required: [oceanBookingId, containerBookingId, plannedLoadingAt, expectedVersion]
properties:
oceanBookingId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1}
loadingPlanId: {type: integer, format: int64, minimum: 1}
loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint; the server resolves and freezes the current loadingPlanId from containerBookingId and rejects a mismatch.}
carrierId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE Warehouse supplier ID validated as TRUCKING.}
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse and cannot be supplied without carrierId.}
plannedLoadingAt: {type: string, format: date-time}
@@ -34,5 +34,24 @@ class PlatformSmartLoadingControllerContractTest {
.contains("领取后才进入 LOADING")
.doesNotContain("真实柜号必须已录入");
}
@Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml)
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:")
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:")
.contains("SmartLoadingAssignRequest:")
.contains("SmartLoadingAdjustRequest:")
.contains("SmartLoadingDeleteRequest:")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
.contains("minPackageCount: {type: integer, minimum: 1}")
.contains("canAdjust: {type: boolean}")
.contains("canRemove: {type: boolean}")
.contains("原 inventorySnapshotToken 随即失效")
.contains("PDA task 行锁 -> plan 乐观锁")
.contains("回到 IN_PROGRESS")
.contains("COMPLETED 永久拒绝增量");
}
private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");}
}
@@ -18,7 +18,9 @@ class LoadingCompletionGateSqlContractTest {
.contains("fl_ocean_booking_container_allocation")
.contains("b.status='RELEASED'")
.contains("fl_truck_appointment")
.contains("t.container_booking_id=p.container_booking_id")
.contains("t.status='COMPLETED'")
.doesNotContain("t.loading_plan_id=p.id")
.contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}")
.doesNotContain("truckReady, FROM")
.doesNotContain("fl_customs_case", "fl_customs_receipt", "customsReady")
@@ -102,6 +102,44 @@ class LoadingDeliveryServiceTest {
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void pdaCompletionPreflightChecksStartedPlanTransportAndEvidenceWithoutWriting() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
service.requirePdaCompletionReady(10, 20);
verify(loadingModelBridge).requireStarted(10, 20);
verify(completionGate).requireBookingAndTransport(10, 20);
verify(loadingModelBridge, never()).requireCompleted(anyLong(), anyLong());
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
verify(repository, never()).claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString());
}
@Test
void pdaCompletionPreflightReportsTruckBlockerBeforeEvidenceOrWrites() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
doThrow(new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT,
"LOADED门禁未通过:拖车尚未完成(必须为 COMPLETED)"))
.when(completionGate).requireBookingAndTransport(10, 20);
assertThatThrownBy(() -> service.requirePdaCompletionReady(10, 20))
.hasMessageContaining("拖车尚未完成");
verify(repository, never()).listEvidence(anyLong(), anyLong());
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void completesLoadedFulfillmentSliceBeforeAggregatingRoot() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
@@ -43,7 +44,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -64,6 +68,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingListService loadingListService;
@MockBean
private LoadingDeliveryService loadingDeliveryService;
@MockBean
private InboundStatusHistoryMapper inboundHistories;
@MockBean
private PdaPickSettlementService settlementService;
@@ -491,6 +497,10 @@ class LoadingPickServiceTest {
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
@@ -498,6 +508,35 @@ class LoadingPickServiceTest {
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
verify(loadingDeliveryService).completeLoading(eq(101L), eq(50L), eq(9L), eq("operator"),
eq("PDA-complete-1-50"), argThat(value -> value.expectedVersion() == 7
&& "checked".equals(value.remark())));
}
@Test
void truckBlockerLeavesPdaTaskPlanAndSettlementUntouched() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.review(20L)).thenReturn(null);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"LOADED门禁未通过:拖车尚未完成(必须为 COMPLETED)"))
.when(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-blocked", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("拖车尚未完成");
verify(settlementService, never()).settle(anyLong(), anyLong(), anyString());
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), any());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeLoading(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), any());
}
@Test
@@ -34,6 +34,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
@@ -68,6 +69,8 @@ class SmartLoadingServiceTest {
@BeforeEach
void setUp() {
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
new BigDecimal("10"), new BigDecimal("2"))));
@@ -95,6 +98,53 @@ class SmartLoadingServiceTest {
assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING");
}
@Test
void exposesPickedMinimumAndItemCapabilitiesForRunningTask() {
LoadingPlanEntity plan = plan("LOADING", 3);
LoadingPlanItemEntity line = item(8, 9, 20, 3);
when(repository.booking(99L)).thenReturn(booking());
when(repository.plan(1L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of(line));
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2)));
Plan result = service.detail(1L);
assertThat(result.canAssign()).isTrue();
assertThat(result.canEdit()).isTrue();
assertThat(result.finalCompletionStatus()).isEqualTo("IN_PROGRESS");
assertThat(result.pdaTaskId()).isEqualTo(77L);
assertThat(result.pdaTaskVersion()).isEqualTo(6);
assertThat(result.items()).singleElement().satisfies(item -> {
assertThat(item.getPickedPackageCount()).isEqualTo(2);
assertThat(item.getMinPackageCount()).isEqualTo(2);
assertThat(item.getCanAdjust()).isTrue();
assertThat(item.getCanRemove()).isFalse();
});
}
@Test
void completedTaskDisablesEveryIncrementalCapability() {
LoadingPlanEntity plan = plan("COMPLETED", 4);
LoadingPlanItemEntity line = item(8, 9, 20, 3);
when(repository.booking(99L)).thenReturn(booking());
when(repository.plan(1L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of(line));
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "COMPLETED", 7, Map.of(8L, 3)));
Plan result = service.detail(1L);
assertThat(result.canAssign()).isFalse();
assertThat(result.canEdit()).isFalse();
assertThat(result.blockedReason()).contains("最终完成");
assertThat(result.finalCompletionStatus()).isEqualTo("COMPLETED");
assertThat(result.items()).singleElement().satisfies(item -> {
assertThat(item.getCanAdjust()).isFalse();
assertThat(item.getCanRemove()).isFalse();
});
}
@Test
void doesNotRestoreCompletedPlanInTheLoadingWorkbench() {
when(repository.booking(99L)).thenReturn(booking());
@@ -227,13 +277,12 @@ class SmartLoadingServiceTest {
}
@Test
void recyclesLegacyLoadingPlanBeforeAddingFirstItem() {
LoadingPlanEntity stale = plan("LOADING", 5);
LoadingPlanEntity after = plan("EDITING", 7);
when(repository.plan(1)).thenReturn(stale, after);
void addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus() {
LoadingPlanEntity loading = plan("LOADING", 5);
LoadingPlanEntity after = plan("LOADING", 6);
when(repository.plan(1)).thenReturn(loading, after);
when(repository.booking(99)).thenReturn(booking());
when(repository.recycleUnpicked(1, 5, 7, "u")).thenReturn(1);
when(repository.touch(1, 6, 7, "u")).thenReturn(1);
when(repository.touch(1, 5, 7, "u")).thenReturn(1);
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
@@ -241,11 +290,15 @@ class SmartLoadingServiceTest {
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 1,
BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null)));
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-recycle"), 7, "u");
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u");
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).recycleUnpicked(1, 5, 7, "u");
verify(repository).touch(1, 6, 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
var order = inOrder(loadingPickService, repository, loadingModelBridge);
order.verify(loadingPickService).lockMutable(1L);
order.verify(repository).touch(1, 5, 7, "u");
order.verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L,
7L, "u", "assign-loading");
order.verify(loadingPickService).sync(eq(1L), any(LoadingPlanItemEntity.class));
}
@Test
@@ -78,6 +78,7 @@ class TruckAppointmentServiceTest {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED");
when(oceanBookings.find(99L)).thenReturn(booking);
assertThatThrownBy(() -> service.create(7L, "admin", request)).isInstanceOf(BizException.class).hasMessageContaining("放舱");
@@ -88,15 +89,24 @@ class TruckAppointmentServiceTest {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.customerId()).thenReturn(7L);
when(request.loadingPlanId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer");
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(77L); plan.setContainerBookingId(88L);
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getCustomerId()).isEqualTo(7L);
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getContainerBookingId()).isEqualTo(88L);
assertThat(entity.getValue().getLoadingPlanId()).isEqualTo(77L);
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@@ -113,11 +123,11 @@ class TruckAppointmentServiceTest {
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(77L); plan.setContainerBookingId(66L);
when(loadingPlans.plan(77L)).thenReturn(plan);
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(78L); plan.setContainerBookingId(88L);
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("柜体");
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
verify(repository, never()).insert(any());
}
@@ -134,10 +144,10 @@ class TruckAppointmentServiceTest {
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
when(loadingPlans.plan(77L)).thenReturn(null);
when(loadingPlans.planByBooking(88L)).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("装柜计划不存在");
.isInstanceOf(BizException.class).hasMessageContaining("尚未建立装柜计划");
verify(repository, never()).insert(any());
}
+17 -1
View File
@@ -642,7 +642,7 @@ export class AcceptanceRun {
const fixtureMarker = `${originalMarker}-LOAD`
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound',
'phase3LoadingRequireSharedOverflow']
'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount']
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
}]))
@@ -674,9 +674,25 @@ export class AcceptanceRun {
orderId: fixture.orderId || this.createdResources.orders?.orderId,
inboundId: fixture.inboundId,
} : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true }
const incrementalMarker = `${originalMarker}-LOAD-INC`
this.marker = incrementalMarker
this.context.phase3LoadingRequireSharedOverflow = false
this.context.phase3LoadingIncrementalPackageCount = 2
await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this))
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this))
const incrementalSource = this.createdResources.inboundMeasurement || this.createdResources.orders
const incrementalFixture = incrementalSource ? {
marker: originalMarker,
sourceMarker: incrementalMarker,
tenantId: incrementalSource.tenantId,
orderId: incrementalSource.orderId,
inboundId: incrementalSource.inboundId,
} : { marker: originalMarker, failed: true }
this.createdResources.orchestrationFixtures = {
...(this.createdResources.orchestrationFixtures || {}),
phase3Loading: loadingFixture,
phase3LoadingIncremental: incrementalFixture,
inboundMeasurement: loadingFixture,
}
this.marker = originalMarker
+5 -3
View File
@@ -46,7 +46,7 @@ export async function run(context) {
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
| 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` |
@@ -62,9 +62,11 @@ export async function run(context) {
默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为:
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货/PDA complete -> 五类证据 -> 报关及下游门禁 -> loading complete -> LOADED`。
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> 拖车与报关清关门禁 -> PDA task complete 同事务完成 task/plan/inbound -> LOADED`。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:服务端预检同柜 `RELEASED` 订舱、`COMPLETED` 拖车、五类 ACTIVE 证据和 bridge,缺项返回 A0409 且 task/plan/inbound 零写入;全部满足后同一事务推进 `LOADED`。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
部署后需要定向验证该完整链时,模块顺序固定为:
@@ -87,10 +87,16 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
return ok(permissions)
}
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=11') {
return ok({ records: [{ customerId: '11', accountName: 'Fixture Account' }] })
return ok({ records: [{ customerId: '11', customerCode: 'TEN11', customerName: 'Fixture Account',
contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100,
pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE',
lastChangedAt: '2026-08-10T09:00:00' }] })
}
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/accounts/11/ledger?')) {
return ok({ records: [{ customerId: '11', statementId: '1', remark: marker }] })
return ok({ records: [{ customerId: '11', statementId: '1', ledgerId: '1',
occurredAt: '2026-08-10T09:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL',
documentId: '1', documentNo: 'REC-1', receivableChange: 100, payableChange: 0,
outstandingReceivableAfter: 100, operatorName: 'operator-7001', remark: marker }] })
}
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/iam/tenants?')) {
return ok({ records: [{ id: '11', status: 'ACTIVE' }, { id: '12', status: 'ACTIVE' }] })
@@ -225,6 +231,10 @@ test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输
assert.match(source, /\/accounts\/\$\{encodeURIComponent\(customerId\)\}\/ledger\?page=1&pageSize=200/)
assert.match(source, /balance.*recharge.*prepayment/)
assert.match(source, /accountReadback/)
assert.match(source, /accountInformationComplete/)
assert.match(source, /ledgerInformationComplete/)
assert.match(source, /客户账户基本信息、四项金额和最近变动时间完整/)
assert.match(source, /客户账户流水时间、业务类型、关联单据、金额和操作人完整/)
for (const action of ['SUBMIT', 'APPROVE', 'REJECT', 'ISSUE', 'ADJUST', 'VOID', 'REOPEN']) {
assert.match(source, new RegExp(`['"]${action}['"]`))
}
@@ -129,6 +129,18 @@ function accountCustomerId(value) { return id(value?.customerId ?? value?.tenant
function hasWalletField(value) {
return value && typeof value === 'object' && ['balance', 'recharge', 'prepayment'].some((field) => Object.hasOwn(value, field))
}
function accountInformationComplete(value) {
return ['customerCode', 'customerName', 'contactName', 'contactPhone', 'status', 'lastChangedAt']
.every((field) => present(value?.[field]))
&& ['outstandingReceivable', 'pendingBilling', 'receivedInPeriod', 'outstandingPayable']
.every((field) => Number.isFinite(Number(value?.[field])))
}
function ledgerInformationComplete(value) {
return ['ledgerId', 'occurredAt', 'businessType', 'documentType', 'documentId', 'documentNo', 'operatorName']
.every((field) => present(value?.[field]))
&& ['receivableChange', 'payableChange', 'outstandingReceivableAfter']
.every((field) => Number.isFinite(Number(value?.[field])))
}
async function verifyCustomerAccountAndLedger(context, fixture) {
const customerId = id(fixture.customerId)
const accountResponse = await context.request('GET',
@@ -138,6 +150,9 @@ async function verifyCustomerAccountAndLedger(context, fixture) {
context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse,
Boolean(account) && !accounts.some(hasWalletField),
`customerId=${customerId}, accountCount=${accounts.length}, requestId=${accountResponse.requestId ?? 'null'}, traceId=${accountResponse.traceId ?? 'null'}`, OPTIONS)
context.assert(MODULE, '客户账户基本信息、四项金额和最近变动时间完整', accountResponse,
accountInformationComplete(account),
`customerId=${customerId}, customerCode=${account?.customerCode ?? 'missing'}, contact=${account?.contactName ?? 'missing'}/${account?.contactPhone ?? 'missing'}, lastChangedAt=${account?.lastChangedAt ?? 'missing'}`, OPTIONS)
const ledgerResponse = await context.request('GET', `${BASE}/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`)
const ledger = accountRows(ledgerResponse)
const rowsForCustomer = ledger.filter((item) => {
@@ -155,6 +170,10 @@ async function verifyCustomerAccountAndLedger(context, fixture) {
context.assert(MODULE, '客户账户流水仅属于本轮客户并包含账单/收款或结算流水', ledgerResponse,
rowsForCustomer.length === ledger.length && containsRoundTrip,
`customerId=${customerId}, ledgerCount=${ledger.length}, requestId=${ledgerResponse.requestId ?? 'null'}, traceId=${ledgerResponse.traceId ?? 'null'}`, OPTIONS)
const incompleteLedger = rowsForCustomer.filter((item) => !ledgerInformationComplete(item))
context.assert(MODULE, '客户账户流水时间、业务类型、关联单据、金额和操作人完整', ledgerResponse,
rowsForCustomer.length > 0 && incompleteLedger.length === 0,
`customerId=${customerId}, ledgerCount=${rowsForCustomer.length}, incomplete=${incompleteLedger.length}`, OPTIONS)
fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse),
ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) }
}
@@ -208,7 +208,7 @@ export async function run(context) {
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
if (!frozenTransportMatches) return
const allocatedContainerBookingId = ''
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
if (['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)) {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
`bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS)
+6 -4
View File
@@ -266,14 +266,16 @@ function buildPayload(context, profile, master, productImageFileId) {
cargoes: BOXES.map((box, index) => {
const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
const unitsPerPackage = 10 + index
const packageCount = Number(context.context?.phase3LoadingIncrementalPackageCount) === 2 ? 2 : 1
return {
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
lengthCm: box.lengthCm, widthCm: box.widthCm, heightCm: box.heightCm,
unitsPerPackage, packageCount: 1, grossWeightKg: 10 + index,
volumeCbm, totalQuantity: unitsPerPackage, netWeightKg: 9 + index,
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * 8.5, currency: 'USD',
unitsPerPackage, packageCount, grossWeightKg: (10 + index) * packageCount,
volumeCbm: volumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
netWeightKg: (9 + index) * packageCount,
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD',
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index,
@@ -294,7 +296,7 @@ function assertCargoContract(context, response, cargoes, payload) {
&& actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName
&& actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode
&& Number(actual?.unitsPerPackage) === expected.unitsPerPackage
&& Number(actual?.packageCount) === 1 && Number(actual?.totalQuantity) === expected.totalQuantity
&& Number(actual?.packageCount) === expected.packageCount && Number(actual?.totalQuantity) === expected.totalQuantity
&& Number(actual?.netWeightKg) === expected.netWeightKg
&& Number(actual?.unitDeclaredValue) === expected.unitDeclaredValue
&& actual?.material === expected.material && actual?.productUsage === expected.usage
@@ -130,7 +130,9 @@ export async function run(context) {
const containerBookingId = id(container?.containerBookingId)
const loadingPlanId = id(container?.loadingPlanId)
if (!tenantId || !parentOrderId || !inboundId || !containerBookingId
|| !['COMPLETED', 'LOADED'].includes(container?.finalStatus)) {
|| !['AWAITING_REVIEW', 'LOADED'].includes(container?.finalStatus)
|| (container?.finalStatus === 'AWAITING_REVIEW'
&& (container?.loadingPlanStatus !== 'LOADING' || !container?.canonicalPdaTaskId))) {
context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false,
`tenantId=${tenantId || 'missing'}, parentOrderId=${parentOrderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS)
return
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const read = (path) => readFileSync(resolve(here, path), 'utf8')
const firstlegOpenApi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
const pdaOpenApi = read('../../firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml')
const entry = read('../admin-test-env-acceptance.mjs')
const loading = read('./phase3-loading.mjs')
const convergence = read('./phase3-pda-convergence.mjs')
const customs = read('./phase3-customs.mjs')
const finalize = read('./phase3-loading-finalize.mjs')
function ordered(source, fragments) {
let cursor = -1
for (const fragment of fragments) {
const next = source.indexOf(fragment, cursor + 1)
assert.ok(next > cursor, `${fragment} must appear in frozen order`)
cursor = next
}
}
test('OpenAPI freezes LOADING incremental plan capabilities and optimistic versions', () => {
for (const endpoint of [
'/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:',
'/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:',
]) assert.ok(firstlegOpenApi.includes(endpoint), `${endpoint} missing`)
for (const field of [
'inboundId', 'inboundDetailId', 'packageCount', 'expectedVersion', 'operationId',
'expectedPlanVersion', 'expectedItemVersion', 'canAssign', 'canEdit', 'blockedReason',
'finalCompletionStatus', 'pdaTaskId', 'pdaTaskStatus', 'pdaTaskVersion',
'pickedPackageCount', 'minPackageCount', 'canAdjust', 'canRemove',
]) assert.match(firstlegOpenApi, new RegExp(`\\b${field}\\b`))
assert.match(firstlegOpenApi, /enum: \[NOT_STARTED, IN_PROGRESS, COMPLETED\]/)
})
test('default fixture publishes a second marker-owned inbound for the same-plan increment', () => {
assert.match(entry, /const incrementalMarker = `\$\{originalMarker\}-LOAD-INC`/)
assert.match(entry, /orders-phase3-loading-incremental-fixture/)
assert.match(entry, /inbound-measurement-phase3-loading-incremental-fixture/)
assert.match(entry, /phase3LoadingIncrementalPackageCount = 2/)
assert.match(entry, /phase3LoadingIncremental: incrementalFixture/)
assert.match(entry, /marker: originalMarker/)
assert.doesNotMatch(entry, /phase3LoadingIncremental[\s\S]{0,300}(history|fallback|latest)/i)
})
test('HTTP chain refreshes after stale inventory and keeps one stable PDA identity', () => {
ordered(loading, [
'PDA 原子批次完成拣货并等待人工确认',
'五类装柜证据完整且铅封号持久化',
'LOADING 阶段向同一 PDA task 增加入库单',
'增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁',
'增量后旧 inventory token 明确 A0409 并要求刷新',
'增量旧 token 冲突后 plan/task/items 零写入',
'未拣动态增量明细允许删除',
'未拣动态增量明细允许调整数量',
'调整数量递增 plan/task/item 版本并保持稳定任务',
'重新增量后刷新 PDA task detail',
'重新增量后刷新 inventory snapshot',
'动态新增项拣完后同一任务重新进入 AWAITING_REVIEW',
'已拣项减量低于 minPackageCount 返回 A0409',
'已拣项删除返回 A0409',
'已拣项删除和减量失败后 plan/task/items 零写入',
'订舱拖车报关未齐备时 LOADED 硬门禁拒绝',
])
assert.match(loading, /String\(staleIncrementBatch\.body\?\.message \|\| ''\)\.includes\('库存已变化,请刷新后重试'\)/)
assert.match(loading, /id\(incrementedTask\?\.loadingPlanId\) === loadingPlanId/)
assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/)
assert.match(loading, /loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW'/)
assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/)
})
test('downstream modules keep the task pending until the transactional PDA final complete', () => {
assert.match(convergence, /summary\?\.status === 'LOADING'/)
assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/)
assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/)
ordered(finalize, [
'最终完成前拖车与报关清关门禁属于同一稳定柜次',
'最终完成前四元身份稳定且任务等待人工确认',
'读取最终完成并发前计划即时版本',
'Admin 增量与 PDA final complete 并发恰好一个事务成功',
'订舱拖车报关齐备后完成装柜',
'统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移',
'入库单终态 LOADED、版本递增且审计历史存在',
'统一硬门禁通过后五类有效证据仍完整',
'COMPLETED 后新增计划明细被拒绝',
'COMPLETED 后调整计划明细被拒绝',
'COMPLETED 后删除计划明细被拒绝',
])
assert.match(finalize, /`\$\{taskPath\}\/complete`/)
assert.match(finalize, /await Promise\.all\(/)
assert.match(finalize, /completeWon !== adjustWon/)
assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/)
})
test('PDA final completion OpenAPI freezes zero-write gate semantics', () => {
assert.match(pdaOpenApi, /\/api\/platform\/v1\/first-leg\/pda-loading\/tasks\/\{taskId\}\/complete:/)
assert.match(pdaOpenApi, /先无写入校验全部关联入库单的RELEASED订舱、COMPLETED拖车和五类ACTIVE证据/)
assert.match(pdaOpenApi, /任何阻断返回A0409且task、plan、inbound仍保持LOADING/)
assert.match(pdaOpenApi, /全部满足后在同一事务内结算资源、完成task\/plan并推进inbound为LOADED/)
})
@@ -50,8 +50,11 @@ export async function run(context) {
const fixture = context.createdResources?.phase3Loading
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
const orderId = id(fixture?.orderId)
const taskId = id(fixture?.canonicalPdaTaskId); const loadingPlanId = id(fixture?.loadingPlanId)
const containerBookingId = id(fixture?.containerBookingId)
if (!tenantId || !orderId || !inboundId || fixture?.marker !== context.marker
|| fixture?.loadingPlanStatus !== 'COMPLETED') {
|| !taskId || !loadingPlanId || !containerBookingId
|| fixture?.loadingPlanStatus !== 'LOADING' || fixture?.finalStatus !== 'AWAITING_REVIEW') {
context.addStep(MODULE, '取得下游门禁待完成装柜 fixture', null, false,
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, marker=${fixture?.marker || 'missing'}, plan=${fixture?.loadingPlanStatus || 'missing'}`, OPTIONS)
return
@@ -66,30 +69,93 @@ export async function run(context) {
if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return
const base = `/api/platform/v1/inbounds/${inboundId}`
const completeResponse = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: version, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
const truck = context.createdResources?.serviceCenterFlow
const customs = context.createdResources?.phase3Customs
context.assert(MODULE, '最终完成前拖车与报关清关门禁属于同一稳定柜次', beforeResponse,
truck?.marker === context.marker && truck?.finalStatus === 'COMPLETED'
&& id(truck?.containerBookingId) === containerBookingId
&& customs?.marker === context.marker && customs?.finalStatus === 'CLEARANCE_COMPLETED'
&& id(customs?.containerBookingId) === containerBookingId,
`truck=${truck?.finalStatus || 'missing'}, customs=${customs?.finalStatus || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}`
const taskBeforeResponse = await context.request('GET', taskPath)
const taskBefore = context.apiSuccess(MODULE, '读取最终完成前 PDA 任务即时版本', taskBeforeResponse, '', OPTIONS)
context.assert(MODULE, '最终完成前四元身份稳定且任务等待人工确认', taskBeforeResponse,
id(taskBefore?.id) === taskId && id(taskBefore?.inboundId) === inboundId
&& id(taskBefore?.loadingPlanId) === loadingPlanId
&& id(taskBefore?.containerBookingId) === containerBookingId
&& taskBefore?.taskStatus === 'LOADING' && taskBefore?.workflowStatus === 'AWAITING_REVIEW',
`taskId=${id(taskBefore?.id) || 'missing'}, workflow=${taskBefore?.workflowStatus || 'missing'}, version=${taskBefore?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(Number(taskBefore?.version))) return
const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}`
const planBeforeRaceResponse = await context.request('GET', planPath)
const planBeforeRace = context.apiSuccess(MODULE, '读取最终完成并发前计划即时版本', planBeforeRaceResponse, '', OPTIONS)
const raceItem = planBeforeRace?.items?.find?.((item) => Number(item?.pickedPackageCount) > 0)
context.assert(MODULE, '最终完成并发使用同一已拣计划明细与即时版本', planBeforeRaceResponse,
Boolean(raceItem?.id) && Number.isInteger(Number(planBeforeRace?.version))
&& Number.isInteger(Number(raceItem?.version)),
`planVersion=${planBeforeRace?.version ?? 'missing'}, itemId=${id(raceItem?.id) || 'missing'}, itemVersion=${raceItem?.version ?? 'missing'}`, OPTIONS)
if (!raceItem?.id || !Number.isInteger(Number(planBeforeRace?.version))) return
let completeBody = {
expectedTaskVersion: Number(taskBefore.version),
operationId: `${context.marker}-pda-final-complete`.slice(0, 128), remark: context.marker,
}
let completeKey = `${context.marker}-p3-complete-after-downstream`.slice(0, 128)
const concurrentAdjustBody = {
packageCount: Number(raceItem.packageCount), expectedPlanVersion: Number(planBeforeRace.version),
expectedItemVersion: Number(raceItem.version), operationId: `${context.marker}-race-adjust`.slice(0, 128),
}
const [initialCompleteResponse, concurrentAdjustResponse] = await Promise.all([
request(context, 'POST', `${taskPath}/complete`, completeBody, { 'Idempotency-Key': completeKey }),
request(context, 'PATCH',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(raceItem.id))}`,
concurrentAdjustBody),
])
const completeWon = initialCompleteResponse.http === 200 && codeOf(initialCompleteResponse) === '200'
const adjustWon = concurrentAdjustResponse.http === 200 && codeOf(concurrentAdjustResponse) === '200'
context.assert(MODULE, 'Admin 增量与 PDA final complete 并发恰好一个事务成功', initialCompleteResponse,
completeWon !== adjustWon
&& (completeWon ? codeOf(concurrentAdjustResponse) === 'A0409' : codeOf(initialCompleteResponse) === 'A0409'),
`completeCode=${codeOf(initialCompleteResponse)}, adjustCode=${codeOf(concurrentAdjustResponse)}, completeRequestId=${initialCompleteResponse.requestId || 'missing'}, adjustRequestId=${concurrentAdjustResponse.requestId || 'missing'}`, OPTIONS)
let completeResponse = initialCompleteResponse
if (adjustWon) {
const taskAfterRaceResponse = await context.request('GET', taskPath)
const taskAfterRace = context.apiSuccess(MODULE, '增量赢得并发后刷新 PDA 任务版本', taskAfterRaceResponse, '', OPTIONS)
const planAfterRaceResponse = await context.request('GET', planPath)
const planAfterRace = context.apiSuccess(MODULE, '增量赢得并发后回查唯一计划事实', planAfterRaceResponse, '', OPTIONS)
context.assert(MODULE, 'final complete 失败方零写入且增量版本成为唯一事实', taskAfterRaceResponse,
taskAfterRace?.taskStatus === 'LOADING' && taskAfterRace?.workflowStatus === 'AWAITING_REVIEW'
&& Number(taskAfterRace?.version) > Number(taskBefore?.version)
&& Number(planAfterRace?.version) > Number(planBeforeRace?.version),
`taskVersion=${taskAfterRace?.version ?? 'missing'}, planVersion=${planAfterRace?.version ?? 'missing'}`, OPTIONS)
completeBody = {
expectedTaskVersion: Number(taskAfterRace?.version),
operationId: `${context.marker}-pda-final-complete-retry`.slice(0, 128), remark: context.marker,
}
completeKey = `${context.marker}-p3-complete-after-race-refresh`.slice(0, 128)
completeResponse = await request(context, 'POST', `${taskPath}/complete`, completeBody,
{ 'Idempotency-Key': completeKey })
}
const completed = context.apiSuccess(MODULE, '订舱拖车报关齐备后完成装柜', completeResponse, '', OPTIONS)
const evidences = completed?.evidences?.evidences || []
context.assert(MODULE, '统一硬门禁通过后推进 LOADED 且五类有效证据仍完整', completeResponse,
completed?.mainStatus === 'LOADED' && fixture.evidenceTypes?.every?.((type) =>
evidences.some((item) => item.evidenceType === type && item.status !== 'VOID')),
`mainStatus=${completed?.mainStatus || 'missing'}, evidences=${evidences.length}`, OPTIONS)
context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse,
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
&& id(completed?.id) === taskId && id(completed?.loadingPlanId) === loadingPlanId
&& id(completed?.containerBookingId) === containerBookingId && id(completed?.inboundId) === inboundId,
`taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}`, OPTIONS)
const replayResponse = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: version, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
`${taskPath}/complete`, completeBody, { 'Idempotency-Key': completeKey })
context.assert(MODULE, 'LOADED 完成请求 Idempotency-Key 重放保持终态', replayResponse,
replayResponse.http === 200 && String(replayResponse.body?.code) === '200'
&& replayResponse.body?.data?.mainStatus === 'LOADED',
`HTTP=${replayResponse.http}, code=${replayResponse.body?.code ?? 'missing'}, status=${replayResponse.body?.data?.mainStatus || 'missing'}`, OPTIONS)
&& replayResponse.body?.data?.taskStatus === 'COMPLETED'
&& id(replayResponse.body?.data?.id) === taskId,
`HTTP=${replayResponse.http}, code=${replayResponse.body?.code ?? 'missing'}, status=${replayResponse.body?.data?.taskStatus || 'missing'}`, OPTIONS)
const finalResponse = await context.request('GET', detailPath)
const finalData = context.apiSuccess(MODULE, '回查三期装柜完成终态', finalResponse, '', OPTIONS)
context.assert(MODULE, '入库单终态 LOADED、版本递增且审计历史存在', finalResponse,
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === version + 1
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) > version
&& finalData?.statusHistory?.some?.((item) => item.action === 'COMPLETE_LOADING'
&& item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'),
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
@@ -114,7 +180,10 @@ export async function run(context) {
const evidencePath = `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`
const immutableEvidenceResponse = await context.request('GET', evidencePath)
const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS)
const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(id(fixture.loadingPlanId))}`
context.assert(MODULE, '统一硬门禁通过后五类有效证据仍完整', immutableEvidenceResponse,
fixture.evidenceTypes?.every?.((type) => immutableEvidence?.evidences?.some?.((item) =>
item.evidenceType === type && item.status !== 'VOID')),
`evidences=${immutableEvidence?.evidences?.length ?? 'missing'}`, OPTIONS)
const immutablePlanResponse = await context.request('GET', planPath)
const immutablePlan = context.apiSuccess(MODULE, '读取 LOADED 后装柜计划快照', immutablePlanResponse, '', OPTIONS)
const finalVersion = Number(finalData?.summary?.version)
@@ -130,18 +199,19 @@ export async function run(context) {
})
const completeNewKey = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: finalVersion, remark: `${context.marker}-loaded-new-key` },
`${taskPath}/complete`,
{ expectedTaskVersion: Number(completed?.version),
operationId: `${context.marker}-pda-final-complete-new-key`.slice(0, 128), remark: `${context.marker}-loaded-new-key` },
{ 'Idempotency-Key': `${context.marker}-p3-complete-loaded-new-key`.slice(0, 128) })
context.assert(MODULE, 'LOADED 后使用新键重复 complete 幂等保持终态', completeNewKey,
completeNewKey.http === 200 && codeOf(completeNewKey) === '200'
&& completeNewKey.body?.data?.mainStatus === 'LOADED',
`HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.mainStatus || 'missing'}`, OPTIONS)
&& completeNewKey.body?.data?.taskStatus === 'COMPLETED',
`HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.taskStatus || 'missing'}`, OPTIONS)
const completeSameKeyDifferentBody = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: version, remark: `${context.marker}-different-complete-body` },
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
`${taskPath}/complete`,
{ ...completeBody, remark: `${context.marker}-different-complete-body` },
{ 'Idempotency-Key': completeKey })
context.addStep(MODULE, 'LOADED complete 同键异参返回冲突', completeSameKeyDifferentBody,
completeSameKeyDifferentBody.http === 200 && codeOf(completeSameKeyDifferentBody) === 'A0409'
&& Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId),
@@ -175,6 +245,34 @@ export async function run(context) {
&& Boolean(immutableTransport.requestId) && Boolean(immutableTransport.traceId),
`HTTP=${immutableTransport.http}, code=${codeOf(immutableTransport)}, requestId=${immutableTransport.requestId || 'missing'}, traceId=${immutableTransport.traceId || 'missing'}`, OPTIONS)
const completedPlanItem = immutablePlan?.items?.[0]
const assignmentAfterCompleted = await request(context, 'POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/assignments`, {
inboundId: fixture.incrementalInboundId || inboundId,
inboundDetailId: id(completedPlanItem?.inboundDetailId), packageCount: 1,
expectedVersion: Number(immutablePlan?.version),
operationId: `${context.marker}-assign-after-completed`.slice(0, 128),
})
context.addStep(MODULE, 'COMPLETED 后新增计划明细被拒绝', assignmentAfterCompleted,
assignmentAfterCompleted.http === 200 && codeOf(assignmentAfterCompleted) === 'A0409',
`HTTP=${assignmentAfterCompleted.http}, code=${codeOf(assignmentAfterCompleted)}`, OPTIONS)
const adjustAfterCompleted = await request(context, 'PATCH',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(completedPlanItem?.id))}`, {
packageCount: Number(completedPlanItem?.packageCount || 1), expectedPlanVersion: Number(immutablePlan?.version),
expectedItemVersion: Number(completedPlanItem?.version), operationId: `${context.marker}-adjust-after-completed`.slice(0, 128),
})
context.addStep(MODULE, 'COMPLETED 后调整计划明细被拒绝', adjustAfterCompleted,
adjustAfterCompleted.http === 200 && codeOf(adjustAfterCompleted) === 'A0409',
`HTTP=${adjustAfterCompleted.http}, code=${codeOf(adjustAfterCompleted)}`, OPTIONS)
const deleteAfterCompleted = await request(context, 'DELETE',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(completedPlanItem?.id))}`, {
expectedPlanVersion: Number(immutablePlan?.version), expectedItemVersion: Number(completedPlanItem?.version),
operationId: `${context.marker}-delete-after-completed`.slice(0, 128),
})
context.addStep(MODULE, 'COMPLETED 后删除计划明细被拒绝', deleteAfterCompleted,
deleteAfterCompleted.http === 200 && codeOf(deleteAfterCompleted) === 'A0409',
`HTTP=${deleteAfterCompleted.http}, code=${codeOf(deleteAfterCompleted)}`, OPTIONS)
const afterBoundaryResponse = await context.request('GET', detailPath)
const afterBoundary = context.apiSuccess(MODULE, '回查 LOADED 高风险边界后的入库快照', afterBoundaryResponse, '', OPTIONS)
const afterEvidenceResponse = await context.request('GET', evidencePath)
+217 -50
View File
@@ -652,39 +652,28 @@ export async function run(context) {
atomicBatchReplayVerified,
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS)
const completeResponse = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
expectedTaskVersion: Number(pickedCanonical?.version),
operationId: `${context.marker}-bridge-complete`.slice(0, 128), remark: context.marker,
})
const completedCanonical = context.apiSuccess(MODULE, '人工确认柜次 PDA 装柜完成', completeResponse, '', OPTIONS)
const directPdaCompletion = completedCanonical?.taskStatus === 'COMPLETED'
&& completedCanonical?.workflowStatus === 'COMPLETED'
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse,
directPdaCompletion && completedCanonical?.items?.every?.((item) => item.itemStatus === 'PICKED'),
`taskStatus=${completedCanonical?.taskStatus || 'missing'}, workflow=${completedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
const completionOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const completionOperations = context.apiSuccess(MODULE, '回查原子 Batch 完成审计', completionOperationsResponse, '', OPTIONS)
context.assert(MODULE, '人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计', completionOperationsResponse,
Array.isArray(completionOperations)
&& completionOperations.some((item) => item.actionType === 'CLAIM'
const preFinalOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const preFinalOperations = context.apiSuccess(MODULE, '回查下游门禁前原子 Batch 审计', preFinalOperationsResponse, '', OPTIONS)
context.assert(MODULE, '最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING', preFinalOperationsResponse,
Array.isArray(preFinalOperations)
&& preFinalOperations.some((item) => item.actionType === 'CLAIM'
&& id(item.operatorId) && Boolean(item.createdAt))
&& completionOperations.some((item) => item.actionType === 'BATCH'
&& preFinalOperations.some((item) => item.actionType === 'BATCH'
&& id(item.operatorId) && Boolean(item.createdAt))
&& completionOperations.some((item) => item.actionType === 'COMPLETE_LOADING'
&& id(item.operatorId) && Boolean(item.createdAt))
&& !completionOperations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
&& !preFinalOperations.some((item) => item.actionType === 'COMPLETE_LOADING')
&& !preFinalOperations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(preFinalOperations) ? preFinalOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS)
context.assert(MODULE, '人工确认后柜次装柜计划完成', pickedPlanResponse,
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
context.assert(MODULE, '下游门禁前柜次计划保持 LOADING 且等待最终完成', pickedPlanResponse,
pickedPlan?.status === 'LOADING' && pickedPlan?.finalCompletionStatus === 'IN_PROGRESS'
&& id(pickedPlan?.pdaTaskId) === canonicalTaskId && pickedPlan?.pdaTaskStatus === 'LOADING',
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
context.assert(MODULE, 'PDA 完成后预装清单仍为 CONFIRMED 且归属稳定', preload,
context.assert(MODULE, 'PDA 最终完成前预装清单仍为 CONFIRMED 且归属稳定', preload,
id(preloadData?.id) === planId && id(preloadData?.inboundId) === inboundId && id(preloadData?.tenantId) === tenantId
&& preloadData?.status === 'CONFIRMED',
`planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS)
@@ -697,18 +686,6 @@ export async function run(context) {
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(loadingVersion)) return
// COMPLETE_LOADING is the only positive completion action; REVIEW is a
// negative compatibility assertion after the task is already complete.
const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, {
expectedTaskVersion: Number(completedCanonical?.version),
operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker,
})
context.addStep(MODULE, '柜次 PDA 已完成后拒绝重复复核', canonicalReview,
directPdaCompletion
? canonicalReview.http === 200 && codeOf(canonicalReview) === 'A0409'
: canonicalReview.http === 200 && codeOf(canonicalReview) === '200',
`HTTP=${canonicalReview.http}, code=${codeOf(canonicalReview)}`, OPTIONS)
context.addStep(MODULE, '柜次 PDA 作为现场作业唯一事实源', null, true,
'本场景不调用旧入库级 picking/start 写接口,避免客户端汇总数量覆盖柜次任务事实', OPTIONS)
@@ -759,8 +736,9 @@ export async function run(context) {
`bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS)
if (evidenceType === 'PHOTO_STEP_4') {
const missingSeal = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: loadingVersion, remark: 'must require seal' },
const missingSeal = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`,
{ expectedTaskVersion: Number(pickedCanonical?.version),
operationId: `${context.marker}-complete-before-seal`.slice(0, 128), remark: 'must require seal' },
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-seal`.slice(0, 128) })
context.addStep(MODULE, '缺铅封照片和锁码时拒绝完成装柜', missingSeal, failure(missingSeal),
`HTTP=${missingSeal.http}, code=${codeOf(missingSeal)}`, OPTIONS)
@@ -864,8 +842,10 @@ export async function run(context) {
duplicateVoid.http === 200 && codeOf(duplicateVoid) === 'A0409',
`HTTP=${duplicateVoid.http}, code=${codeOf(duplicateVoid)}`, OPTIONS)
const missingTypeComplete = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: loadingVersion, remark: `${context.marker}-missing-photo-step-4` },
const missingTypeComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`,
{ expectedTaskVersion: Number(pickedCanonical?.version),
operationId: `${context.marker}-complete-missing-photo-step-4`.slice(0, 128),
remark: `${context.marker}-missing-photo-step-4` },
{ 'Idempotency-Key': `${context.marker}-p3-complete-missing-photo-step-4`.slice(0, 128) })
context.addStep(MODULE, '作废唯一证据类型后 LOADED 门禁拒绝', missingTypeComplete,
missingTypeComplete.http === 200 && codeOf(missingTypeComplete) === 'A0409',
@@ -916,6 +896,178 @@ export async function run(context) {
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental
const incrementalInboundId = id(incrementalFixture?.inboundId)
let dynamicIncrementVerified = false
if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) {
context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false,
`inboundId=${incrementalInboundId || 'missing'}, primaryInboundId=${inboundId}, marker=${incrementalFixture?.marker || 'missing'}`, OPTIONS)
} else {
const dynamicCandidatesResponse = await context.request('GET',
`${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const dynamicCandidates = context.apiSuccess(MODULE, '查询同柜动态增量候选', dynamicCandidatesResponse, '', OPTIONS)
const dynamicCandidate = Array.isArray(dynamicCandidates) ? dynamicCandidates.find((item) =>
id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : null
context.assert(MODULE, '动态增量候选保持客户、仓库和渠道隔离', dynamicCandidatesResponse,
Boolean(dynamicCandidate?.inboundDetailId) && id(dynamicCandidate?.accountId) === tenantId
&& id(dynamicCandidate?.warehouseId) === warehouseId
&& dynamicCandidate?.channelCode === initial?.summary?.channelCode,
`inboundId=${incrementalInboundId}, detailId=${id(dynamicCandidate?.inboundDetailId) || 'missing'}, available=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (dynamicCandidate?.inboundDetailId) {
const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS)
const taskBeforeIncrementResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const taskBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前任务', taskBeforeIncrementResponse, '', OPTIONS)
const evidenceBeforeIncrement = JSON.stringify(evidenceListData?.evidences || [])
const incrementResponse = await request(context, 'POST',
`${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(planBeforeIncrement?.version),
operationId: `${context.marker}-dynamic-assign`.slice(0, 128),
})
const incrementedPlan = context.apiSuccess(MODULE, 'LOADING 阶段向同一 PDA task 增加入库单', incrementResponse, '', OPTIONS)
const incrementalPlanItem = incrementedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId)
const incrementedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS)
const incrementalTaskItem = incrementedTask?.items?.find?.((item) =>
id(item?.loadingPlanItemId) === id(incrementalPlanItem?.id))
const evidenceAfterIncrementResponse = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
const evidenceAfterIncrement = context.apiSuccess(MODULE, '增量后回查既有五类证据', evidenceAfterIncrementResponse, '', OPTIONS)
context.assert(MODULE, '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', incrementResponse,
id(incrementedPlan?.id) === loadingPlanId && incrementedPlan?.status === 'LOADING'
&& incrementedPlan?.finalCompletionStatus === 'IN_PROGRESS'
&& id(incrementedPlan?.pdaTaskId) === canonicalTaskId && incrementedPlan?.pdaTaskStatus === 'LOADING'
&& Number(incrementedPlan?.version) > Number(planBeforeIncrement?.version)
&& Number(incrementedPlan?.pdaTaskVersion) > Number(taskBeforeIncrement?.version)
&& Number(incrementalPlanItem?.version) === 0 && Number(incrementalPlanItem?.pickedPackageCount) === 0
&& Number(incrementalPlanItem?.minPackageCount) === 1
&& incrementalPlanItem?.canAdjust === true && incrementalPlanItem?.canRemove === true
&& id(incrementedTask?.loadingPlanId) === loadingPlanId
&& id(incrementedTask?.containerBookingId) === containerBookingId
&& incrementedTask?.workflowStatus === 'IN_PROGRESS'
&& JSON.stringify(evidenceAfterIncrement?.evidences || []) === evidenceBeforeIncrement,
`planVersion=${incrementedPlan?.version ?? 'missing'}, taskVersion=${incrementedTask?.version ?? 'missing'}, workflow=${incrementedTask?.workflowStatus || 'missing'}`, OPTIONS)
if (incrementalTaskItem?.id) {
const staleIncrementBatch = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(incrementedTask?.version),
inventorySnapshotToken: effectiveBatchPayload.inventorySnapshotToken,
items: [{ itemId: id(incrementalTaskItem.id), action: 'PICK', pickedPackageCount: 1,
releaseMode: 'MANUAL', locationIds: [], version: Number(incrementalTaskItem.version) }],
}, { 'Idempotency-Key': `${context.marker}-dynamic-stale-inventory`.slice(0, 128) })
context.addStep(MODULE, '增量后旧 inventory token 明确 A0409 并要求刷新', staleIncrementBatch,
staleIncrementBatch.http === 200 && codeOf(staleIncrementBatch) === 'A0409'
&& String(staleIncrementBatch.body?.message || '').includes('库存已变化,请刷新后重试'),
responseEvidence(staleIncrementBatch), OPTIONS)
const taskAfterStaleResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const taskAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量任务', taskAfterStaleResponse, '', OPTIONS)
const planAfterStaleResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const planAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量计划', planAfterStaleResponse, '', OPTIONS)
context.assert(MODULE, '增量旧 token 冲突后 plan/task/items 零写入', taskAfterStaleResponse,
Number(taskAfterStale?.version) === Number(incrementedTask?.version)
&& taskItemsSignature(taskAfterStale) === taskItemsSignature(incrementedTask)
&& Number(planAfterStale?.version) === Number(incrementedPlan?.version),
`taskVersion=${taskAfterStale?.version ?? 'missing'}, planVersion=${planAfterStale?.version ?? 'missing'}`, OPTIONS)
const removeResponse = await request(context, 'DELETE',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(incrementalPlanItem.id))}`, {
expectedPlanVersion: Number(incrementedPlan.version), expectedItemVersion: Number(incrementalPlanItem.version),
operationId: `${context.marker}-dynamic-remove-unpicked`.slice(0, 128),
})
const removedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许删除', removeResponse, '', OPTIONS)
context.assert(MODULE, '删除未拣项递增计划和任务版本且稳定身份不变', removeResponse,
!removedPlan?.items?.some?.((item) => id(item?.id) === id(incrementalPlanItem.id))
&& Number(removedPlan?.version) > Number(incrementedPlan?.version)
&& Number(removedPlan?.pdaTaskVersion) > Number(incrementedPlan?.pdaTaskVersion)
&& id(removedPlan?.pdaTaskId) === canonicalTaskId && removedPlan?.status === 'LOADING',
`planVersion=${removedPlan?.version ?? 'missing'}, taskVersion=${removedPlan?.pdaTaskVersion ?? 'missing'}`, OPTIONS)
const reassignResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(removedPlan?.version), operationId: `${context.marker}-dynamic-reassign`.slice(0, 128),
})
const reassignedPlan = context.apiSuccess(MODULE, '删除后重新加入同一动态入库单', reassignResponse, '', OPTIONS)
let effectiveDynamicPlan = reassignedPlan
let reassignedPlanItem = reassignedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId)
context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse,
Number(dynamicCandidate?.availablePackageCount) >= 2,
`availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (Number(dynamicCandidate?.availablePackageCount) < 2) return
if (Number(dynamicCandidate?.availablePackageCount) >= 2 && reassignedPlanItem?.id) {
const adjustResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, {
packageCount: 2, expectedPlanVersion: Number(reassignedPlan?.version),
expectedItemVersion: Number(reassignedPlanItem.version),
operationId: `${context.marker}-dynamic-adjust`.slice(0, 128),
})
const adjustedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许调整数量', adjustResponse, '', OPTIONS)
const adjustedItem = adjustedPlan?.items?.find?.((item) => id(item?.id) === id(reassignedPlanItem.id))
context.assert(MODULE, '调整数量递增 plan/task/item 版本并保持稳定任务', adjustResponse,
Number(adjustedPlan?.version) > Number(reassignedPlan?.version)
&& Number(adjustedPlan?.pdaTaskVersion) > Number(reassignedPlan?.pdaTaskVersion)
&& Number(adjustedItem?.version) > Number(reassignedPlanItem?.version)
&& Number(adjustedItem?.packageCount) === 2 && id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS)
effectiveDynamicPlan = adjustedPlan
reassignedPlanItem = adjustedItem
}
const refreshedDynamicTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const refreshedDynamicTask = context.apiSuccess(MODULE, '重新增量后刷新 PDA task detail', refreshedDynamicTaskResponse, '', OPTIONS)
const refreshedDynamicTaskItem = refreshedDynamicTask?.items?.find?.((item) =>
id(item?.loadingPlanItemId) === id(reassignedPlanItem?.id))
const refreshedDynamicInventoryResponse = await context.request('GET',
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const refreshedDynamicInventory = context.apiSuccess(MODULE, '重新增量后刷新 inventory snapshot', refreshedDynamicInventoryResponse, '', OPTIONS)
const dynamicBatchResponse = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(refreshedDynamicTask?.version),
inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken,
items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount),
releaseMode: 'MANUAL', locationIds: [], version: Number(refreshedDynamicTaskItem?.version) }],
}, { 'Idempotency-Key': `${context.marker}-dynamic-pick`.slice(0, 128) })
const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS)
context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse,
id(dynamicPickedTask?.id) === canonicalTaskId && dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW'
&& dynamicPickedTask?.taskStatus === 'LOADING'
&& dynamicPickedTask?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
`taskId=${id(dynamicPickedTask?.id) || 'missing'}, workflow=${dynamicPickedTask?.workflowStatus || 'missing'}`, OPTIONS)
const pickedDynamicPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const pickedDynamicPlan = context.apiSuccess(MODULE, '动态新增项拣完后回查 minPackageCount', pickedDynamicPlanResponse, '', OPTIONS)
const pickedDynamicPlanItem = pickedDynamicPlan?.items?.find?.((item) => id(item?.id) === id(reassignedPlanItem?.id))
const beforePickedMutationTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const beforePickedMutationTask = context.apiSuccess(MODULE, '已拣项负向前读取任务快照', beforePickedMutationTaskResponse, '', OPTIONS)
const reducePickedResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, {
packageCount: Math.max(1, Number(pickedDynamicPlanItem?.minPackageCount) - 1),
expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
operationId: `${context.marker}-dynamic-reduce-picked`.slice(0, 128),
})
context.addStep(MODULE, '已拣项减量低于 minPackageCount 返回 A0409', reducePickedResponse,
reducePickedResponse.http === 200 && codeOf(reducePickedResponse) === 'A0409', responseEvidence(reducePickedResponse), OPTIONS)
const deletePickedResponse = await request(context, 'DELETE',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, {
expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
operationId: `${context.marker}-dynamic-delete-picked`.slice(0, 128),
})
context.addStep(MODULE, '已拣项删除返回 A0409', deletePickedResponse,
deletePickedResponse.http === 200 && codeOf(deletePickedResponse) === 'A0409', responseEvidence(deletePickedResponse), OPTIONS)
const afterPickedMutationPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const afterPickedMutationPlan = context.apiSuccess(MODULE, '已拣项负向后回查计划', afterPickedMutationPlanResponse, '', OPTIONS)
const afterPickedMutationTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const afterPickedMutationTask = context.apiSuccess(MODULE, '已拣项负向后回查任务', afterPickedMutationTaskResponse, '', OPTIONS)
context.assert(MODULE, '已拣项删除和减量失败后 plan/task/items 零写入', afterPickedMutationPlanResponse,
Number(afterPickedMutationPlan?.version) === Number(pickedDynamicPlan?.version)
&& Number(afterPickedMutationTask?.version) === Number(beforePickedMutationTask?.version)
&& taskItemsSignature(afterPickedMutationTask) === taskItemsSignature(beforePickedMutationTask),
`planVersion=${afterPickedMutationPlan?.version ?? 'missing'}, taskVersion=${afterPickedMutationTask?.version ?? 'missing'}`, OPTIONS)
dynamicIncrementVerified = dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW'
&& Number(pickedDynamicPlanItem?.minPackageCount) === Number(reassignedPlanItem?.packageCount)
&& Number(effectiveDynamicPlan?.version) <= Number(pickedDynamicPlan?.version)
}
}
}
const beforeHardGateResponse = await context.request('GET', detailPath)
const beforeHardGate = context.apiSuccess(MODULE, '读取 LOADED 硬门禁调用前即时版本', beforeHardGateResponse, '', OPTIONS)
const beforeHardGateVersion = Number(beforeHardGate?.summary?.version)
@@ -923,18 +1075,31 @@ export async function run(context) {
beforeHardGate?.summary?.processStatus === 'LOADING' && Number.isInteger(beforeHardGateVersion),
`status=${beforeHardGate?.summary?.processStatus || 'missing'}, version=${beforeHardGate?.summary?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(beforeHardGateVersion)) return
const blockedBeforeDownstream = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: beforeHardGateVersion, remark: `${context.marker}-before-downstream` },
const beforeBlockedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const beforeBlockedTask = context.apiSuccess(MODULE, '读取下游门禁拒绝前 PDA 任务快照', beforeBlockedTaskResponse, '', OPTIONS)
const beforeBlockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const beforeBlockedPlan = context.apiSuccess(MODULE, '读取下游门禁拒绝前计划快照', beforeBlockedPlanResponse, '', OPTIONS)
const blockedBeforeDownstream = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`,
{ expectedTaskVersion: Number(beforeBlockedTask?.version),
operationId: `${context.marker}-complete-before-downstream`.slice(0, 128), remark: `${context.marker}-before-downstream` },
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) })
context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream,
failure(blockedBeforeDownstream),
`HTTP=${blockedBeforeDownstream.http}, code=${codeOf(blockedBeforeDownstream)}`, OPTIONS)
blockedBeforeDownstream.http === 200 && codeOf(blockedBeforeDownstream) === 'A0409',
responseEvidence(blockedBeforeDownstream), OPTIONS)
const blockedDetailResponse = await context.request('GET', detailPath)
const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS)
context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse,
const blockedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const blockedTask = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的 PDA 任务', blockedTaskResponse, '', OPTIONS)
const blockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const blockedPlan = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的计划', blockedPlanResponse, '', OPTIONS)
context.assert(MODULE, 'LOADED 硬门禁拒绝后 task/plan/inbound 零写入', blockedDetailResponse,
blockedDetail?.summary?.processStatus === 'LOADING'
&& Number(blockedDetail?.summary?.version) === beforeHardGateVersion,
`status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
&& Number(blockedDetail?.summary?.version) === beforeHardGateVersion
&& Number(blockedTask?.version) === Number(beforeBlockedTask?.version)
&& taskItemsSignature(blockedTask) === taskItemsSignature(beforeBlockedTask)
&& Number(blockedPlan?.version) === Number(beforeBlockedPlan?.version)
&& blockedPlan?.status === beforeBlockedPlan?.status,
`inboundStatus=${blockedDetail?.summary?.processStatus || 'missing'}, inboundVersion=${blockedDetail?.summary?.version ?? 'missing'}, taskVersion=${blockedTask?.version ?? 'missing'}, planVersion=${blockedPlan?.version ?? 'missing'}`, OPTIONS)
context.createdResources.phase3Loading = {
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
@@ -946,9 +1111,11 @@ export async function run(context) {
oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId,
canonicalPdaTaskId: canonicalTaskId,
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified,
inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified,
atomicBatchVerified: awaitingReview && atomicBatchReplayVerified,
inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified,
inventoryMutationVerified, inventoryMutationCleanup,
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED',
dynamicIncrementVerified, incrementalInboundId,
pdaTaskVersion: Number(blockedTask?.version),
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW',
}
}
@@ -28,26 +28,26 @@ export async function run(context) {
}
const loadingListResponse = await request(context, 'GET',
`/api/platform/v1/first-leg/loading-lists/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const loadingList = context.apiSuccess(MODULE, '回查柜次 PDA 完成后的装柜清单', loadingListResponse, '', OPTIONS)
context.assert(MODULE, '装柜清单保留 PDA 完成终态和操作者审计', loadingListResponse,
id(loadingList?.summary?.planId) === loadingPlanId && loadingList?.summary?.status === 'COMPLETED'
&& loadingList?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING'
&& Boolean(item.operatorName) && Boolean(item.occurredAt)),
const loadingList = context.apiSuccess(MODULE, '回查柜次 PDA 待最终完成的装柜清单', loadingListResponse, '', OPTIONS)
context.assert(MODULE, '装柜清单保持 LOADING 且尚无最终完成审计', loadingListResponse,
id(loadingList?.summary?.planId) === loadingPlanId && loadingList?.summary?.status === 'LOADING'
&& !loadingList?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING'),
`planId=${id(loadingList?.summary?.planId) || 'missing'}, status=${loadingList?.summary?.status || 'missing'}`, OPTIONS)
const projectionResponse = await request(context, 'GET',
`/api/platform/v1/inbounds/${inboundId}/loading/picking?tenantId=${encodeURIComponent(tenantId)}`)
const projection = context.apiSuccess(MODULE, '查询入库级 PDA 服务端聚合投影', projectionResponse, '', OPTIONS)
context.assert(MODULE, '入库投影与柜次事实数量和终态一致', projectionResponse,
projection?.status === 'COMPLETED' && Number(projection?.expectedCount) > 0
context.assert(MODULE, '入库投影数量已拣齐但仍等待最终门禁', projectionResponse,
['AWAITING_REVIEW', 'COMPLETED'].includes(projection?.status) && Number(projection?.expectedCount) > 0
&& Number(projection?.expectedCount) === Number(projection?.pickedCount),
`status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS)
const operationsResponse = await request(context, 'GET',
`/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/operations`)
const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS)
context.assert(MODULE, '直接完成契约保留领取操作且不伪造复核历史', operationsResponse,
context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse,
Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM'
&& Boolean(item.operatorId) && Boolean(item.createdAt))
&& !operations.some((item) => item.actionType === 'REVIEW'),
&& !operations.some((item) => item.actionType === 'REVIEW')
&& !operations.some((item) => item.actionType === 'COMPLETE_LOADING'),
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse,
@@ -101,7 +101,8 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
'读取柜次级 PDA 唯一事实任务',
"claimedPlan?.status === 'LOADING'",
"const canonicalPick = await request",
"pickedPlan?.status === 'COMPLETED'",
"pickedPlan?.status === 'LOADING'",
"pickedPlan?.finalCompletionStatus === 'IN_PROGRESS'",
])
assert.match(value, /const confirmPath = `\/api\/platform\/v1\/first-leg\/loading-plans\/\$\{loadingPlanId\}\/confirm`/)
assert.match(value, /confirmed\?\.confirmationStatus === 'CONFIRMED'/)
@@ -225,7 +226,7 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
const batchPayload = loading.slice(loading.indexOf('const batchPayload ='), loading.indexOf('const staleBatch ='))
assert.doesNotMatch(batchPayload, /containerNo[,\s]/)
assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/)
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/)
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified/)
assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/)
assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/)
@@ -427,7 +428,7 @@ test('phase3 loading proves mixed-line rollback across task, all items and opera
assert.equal((loading.match(/tasks\?loadingPlanId=/g) || []).length >= 7, true)
})
test('phase3 loading proves stale inventory zero-write for all items and completion audit', () => {
test('phase3 loading proves stale inventory zero-write and defers final completion audit', () => {
const loading = source('./phase3-loading.mjs')
assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/)
assertOrder(loading, [
@@ -435,9 +436,10 @@ test('phase3 loading proves stale inventory zero-write for all items and complet
'库存变化后刷新 PDA 实时库存快照',
'原子提交柜次 PDA 整批拣货',
'PDA 原子批次同参同键重放保持待人工确认',
'人工确认柜次 PDA 装柜完成',
'回查原子 Batch 完成审计',
'人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计',
'回查下游门禁前原子 Batch 审计',
'最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING',
'订舱拖车报关未齐备时 LOADED 硬门禁拒绝',
'LOADED 硬门禁拒绝后 task/plan/inbound 零写入',
])
})
@@ -540,7 +542,7 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy
assert.match(entry, /inbound-measurement-phase3-loading-fixture/)
assert.match(entry, /marker: originalMarker/)
assert.match(entry, /sourceMarker: fixtureMarker/)
assert.match(entry, /phase3Loading: loadingFixture,\s*\n\s*inboundMeasurement: loadingFixture/)
assert.match(entry, /phase3Loading: loadingFixture,\s*\n\s*phase3LoadingIncremental: incrementalFixture,\s*\n\s*inboundMeasurement: loadingFixture/)
assert.equal(run.marker.includes('-LOAD'), false)
})
@@ -54,7 +54,7 @@ export async function run(context) {
Boolean(customerId) && Boolean(customerName),
`customerId=${customerId || 'missing'}, customerName=${customerName || 'missing'}`, OPTIONS)
if (!customerId || !customerName) return
const linkContainer = (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED')
const linkContainer = ['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)
&& id(containerFlow?.tenantId) === customerId
&& Boolean(containerFlow?.containerBookingId)
&& Boolean(containerFlow?.loadingPlanId)
@@ -104,12 +104,6 @@ export async function run(context) {
&& Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
if (!appointmentId) return
context.createdResources.serviceCenterFlow = {
appointmentId, oceanBookingId: ocean.id,
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
marker: context.marker, finalStatus: 'COMPLETED',
}
const path = `${TRUCK}/${appointmentId}`
let version = Number(appointment.version)
for (const [name, endpoint, status] of [
@@ -146,4 +140,12 @@ export async function run(context) {
&& Boolean(item.operatorName) && Boolean(item.createdAt) && item.reason === context.marker)),
`history=${history?.length ?? 'n/a'}, actions=${actions.map((item) => item.action).join(',')}`,
OPTIONS)
if (final?.status === 'COMPLETED') {
context.createdResources.serviceCenterFlow = {
appointmentId, oceanBookingId: ocean.id,
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
marker: context.marker, finalStatus: 'COMPLETED',
}
}
}
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { buildOrderPayload, parseArguments, validateCount } from './create-pending-review-orders.mjs'
import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, validateCount } from './create-pending-review-orders.mjs'
const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8')
@@ -10,6 +10,13 @@ test('uses the fixed merchant account and stops at pending review', () => {
assert.ok(source.includes("USERNAME: '17512095155'"), 'fixed username is missing')
assert.ok(/PASSWORD:\s*'[^']*'/.test(source), 'CONFIG.PASSWORD is missing')
assert.ok(/COUNT:\s*\d+/.test(source), 'CONFIG.COUNT is missing')
assert.match(source, /COUNT:\s*50/)
assert.match(source, /randomInt\(1, 31\)/)
assert.match(source, /loadPricedRoutes/)
assert.match(source, /destinationWarehouseCode/)
assert.match(source, /channelCode/)
assert.match(source, /pricingTaxMode/)
assert.equal(PRODUCT_CATALOG.length, 8)
assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/\$\{encodeURIComponent\(orderId\)\}\/submit/)
assert.match(source, /detail\?\.order\?\.status !== 'SUBMITTED'/)
assert.match(source, /\/api\/merchant\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/)
@@ -41,6 +48,8 @@ test('builds an order payload owned by the fixed profile', () => {
channel: { channelCode: 'SEA-US', channelName: '美西海运' },
pricingTaxMode: 'TAX_INCLUDED',
},
product: PRODUCT_CATALOG[0],
packageCount: 7,
productImageFileId: '33',
})
assert.equal(payload.receivingWarehouseId, '11')
@@ -49,4 +58,25 @@ test('builds an order payload owned by the fixed profile', () => {
assert.equal(payload.customerName, '固定测试客户')
assert.equal(payload.cargoes.length, 1)
assert.equal(payload.cargoes[0].productImageFileId, '33')
assert.equal(payload.cargoes[0].cargoName, PRODUCT_CATALOG[0].cargoName)
assert.equal(payload.cargoes[0].packageCount, 7)
assert.equal(payload.cargoes[0].totalQuantity, PRODUCT_CATALOG[0].unitsPerPackage * 7)
assert.equal(payload.cargoes[0].grossWeightKg, Number((PRODUCT_CATALOG[0].cartonGrossWeightKg * 7).toFixed(3)))
assert.equal(payload.cargoes[0].declaredValue, PRODUCT_CATALOG[0].unitDeclaredValue * PRODUCT_CATALOG[0].unitsPerPackage * 7)
})
test('interleaves different logistics combinations before repeating one', () => {
const route = (receivingId, channelCode, pricingTaxMode, destinationCode) => ({
receiving: { id: receivingId }, channel: { channelCode }, pricingTaxMode,
destination: { warehouseCode: destinationCode },
})
const diversified = diversifyRoutes([
route('1', 'SEA-A', 'TAX_INCLUDED', 'A1'),
route('1', 'SEA-A', 'TAX_INCLUDED', 'A2'),
route('1', 'SEA-B', 'TAX_EXCLUDED', 'B1'),
route('1', 'SEA-B', 'TAX_EXCLUDED', 'B2'),
])
const firstKey = `${diversified[0].channel.channelCode}|${diversified[0].pricingTaxMode}`
const secondKey = `${diversified[1].channel.channelCode}|${diversified[1].pricingTaxMode}`
assert.notEqual(firstKey, secondKey)
})
+111 -35
View File
@@ -1,18 +1,31 @@
#!/usr/bin/env node
import { randomUUID } from 'node:crypto'
import { randomInt, randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const MAX_COUNT = 100
const MAX_ROUTE_POOL_SIZE = 40
const MAX_DESTINATIONS_PER_LOGISTICS = 3
export const PRODUCT_CATALOG = [
{ cargoName: '蓝牙音箱', cargoNameEn: 'Bluetooth speaker', hsCode: '8518210000', material: '塑料和电子元件 / Plastic and electronic components', usage: '音频播放 / Audio playback', brand: 'SOUNDTEST', modelPrefix: 'SPK', lengthCm: 42, widthCm: 32, heightCm: 28, unitsPerPackage: 8, cartonGrossWeightKg: 12.8, unitDeclaredValue: 18.5, productLengthCm: 18, productWidthCm: 10, productHeightCm: 8 },
{ cargoName: '手机保护壳', cargoNameEn: 'Mobile phone case', hsCode: '3926909090', material: '热塑性聚氨酯 / TPU', usage: '手机保护 / Mobile phone protection', brand: 'CASETEST', modelPrefix: 'CASE', lengthCm: 48, widthCm: 38, heightCm: 32, unitsPerPackage: 100, cartonGrossWeightKg: 16.5, unitDeclaredValue: 2.2, productLengthCm: 17, productWidthCm: 8, productHeightCm: 1.5 },
{ cargoName: '不锈钢保温杯', cargoNameEn: 'Stainless steel vacuum bottle', hsCode: '9617009000', material: '不锈钢 / Stainless steel', usage: '饮品保温 / Beverage insulation', brand: 'CUPTEST', modelPrefix: 'CUP', lengthCm: 50, widthCm: 40, heightCm: 32, unitsPerPackage: 24, cartonGrossWeightKg: 15.2, unitDeclaredValue: 9.8, productLengthCm: 8, productWidthCm: 8, productHeightCm: 25 },
{ cargoName: '棉质短袖T恤', cargoNameEn: 'Cotton short sleeve T-shirt', hsCode: '6109100021', material: '棉 / Cotton', usage: '日常穿着 / Daily wear', brand: 'SHIRTTEST', modelPrefix: 'TS', lengthCm: 55, widthCm: 42, heightCm: 35, unitsPerPackage: 60, cartonGrossWeightKg: 18.6, unitDeclaredValue: 6.5, productLengthCm: 30, productWidthCm: 25, productHeightCm: 2 },
{ cargoName: '瑜伽垫', cargoNameEn: 'Yoga mat', hsCode: '9506919000', material: '热塑性弹性体 / TPE', usage: '健身运动 / Fitness exercise', brand: 'YOGATEST', modelPrefix: 'YOGA', lengthCm: 65, widthCm: 45, heightCm: 45, unitsPerPackage: 10, cartonGrossWeightKg: 13.5, unitDeclaredValue: 12, productLengthCm: 61, productWidthCm: 18, productHeightCm: 18 },
{ cargoName: '塑料收纳盒', cargoNameEn: 'Plastic storage box', hsCode: '3924900000', material: '聚丙烯 / Polypropylene', usage: '家居收纳 / Household storage', brand: 'BOXTEST', modelPrefix: 'BOX', lengthCm: 62, widthCm: 48, heightCm: 40, unitsPerPackage: 12, cartonGrossWeightKg: 17.8, unitDeclaredValue: 7.2, productLengthCm: 38, productWidthCm: 28, productHeightCm: 20 },
{ cargoName: 'LED台灯', cargoNameEn: 'LED desk lamp', hsCode: '9405210090', material: '铝和塑料 / Aluminum and plastic', usage: '桌面照明 / Desk lighting', brand: 'LAMPTEST', modelPrefix: 'LAMP', lengthCm: 58, widthCm: 42, heightCm: 36, unitsPerPackage: 12, cartonGrossWeightKg: 14.4, unitDeclaredValue: 15.6, productLengthCm: 35, productWidthCm: 15, productHeightCm: 40 },
{ cargoName: '宠物橡胶玩具', cargoNameEn: 'Pet rubber toy', hsCode: '4016999090', material: '天然橡胶 / Natural rubber', usage: '宠物玩耍 / Pet play', brand: 'PETTEST', modelPrefix: 'PET', lengthCm: 46, widthCm: 36, heightCm: 34, unitsPerPackage: 48, cartonGrossWeightKg: 15.6, unitDeclaredValue: 3.4, productLengthCm: 12, productWidthCm: 7, productHeightCm: 7 },
]
// Local test-environment settings. Keep PASSWORD empty in committed code.
const CONFIG = {
GATEWAY: 'http://8.163.30.128:9000',
USERNAME: '17512095155',
PASSWORD: 'd6KW2ZC4vv44WJ4a',
COUNT: 10,
COUNT: 50,
}
export function parseArguments(values) {
@@ -63,6 +76,40 @@ function items(value) {
return Array.isArray(value) ? value : []
}
function shuffled(values) {
const result = [...values]
for (let index = result.length - 1; index > 0; index -= 1) {
const target = randomInt(index + 1)
;[result[index], result[target]] = [result[target], result[index]]
}
return result
}
function round(value, scale) {
return Number(value.toFixed(scale))
}
function logisticsKey(route) {
return `${route.receiving.id}|${route.channel.channelCode}|${route.pricingTaxMode}`
}
export function diversifyRoutes(routes) {
const grouped = new Map()
for (const route of routes) {
const key = logisticsKey(route)
if (!grouped.has(key)) grouped.set(key, [])
grouped.get(key).push(route)
}
const queues = shuffled([...grouped.values()]).map((group) => shuffled(group))
const result = []
while (queues.some((queue) => queue.length > 0)) {
for (const queue of queues) {
if (queue.length > 0) result.push(queue.shift())
}
}
return result
}
function requireSuccess(response, action) {
if (response.http !== 200 || String(response.body?.code) !== '200') {
throw new Error(`${action} failed: http=${response.http}, code=${response.body?.code ?? 'missing'}, message=${response.body?.message ?? 'missing'}, requestId=${response.requestId || 'missing'}, traceId=${response.traceId || 'missing'}`)
@@ -101,7 +148,7 @@ async function request(gateway, timeoutMs, method, path, { token, body, headers
}
}
async function loadMasterData(gateway, timeoutMs, token) {
async function loadPricedRoutes(gateway, timeoutMs, token) {
const domesticResponse = await request(gateway, timeoutMs, 'GET',
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', { token })
const domestic = records(requireSuccess(domesticResponse, 'Query active domestic warehouses'))
@@ -112,14 +159,16 @@ async function loadMasterData(gateway, timeoutMs, token) {
throw new Error('No active domestic receiving warehouse or FBA destination warehouse is available')
}
for (const receiving of domestic) {
const routes = []
receivingLoop: for (const receiving of shuffled(domestic)) {
const channelResponse = await request(gateway, timeoutMs, 'GET',
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, { token })
const channels = items(requireSuccess(channelResponse, 'Query active pricing channels'))
.filter((channel) => channel?.channelCode && items(channel.pricingTaxModes).length > 0)
for (const channel of channels) {
for (const pricingTaxMode of channel.pricingTaxModes) {
for (const destination of destinations) {
for (const channel of shuffled(channels)) {
for (const pricingTaxMode of shuffled(channel.pricingTaxModes)) {
let logisticsDestinationCount = 0
for (const destination of shuffled(destinations)) {
const estimateResponse = await request(gateway, timeoutMs, 'POST',
'/api/merchant/v1/first-leg/pricing/estimate', {
token,
@@ -136,13 +185,17 @@ async function loadMasterData(gateway, timeoutMs, token) {
},
})
if (estimateResponse.http === 200 && String(estimateResponse.body?.code) === '200') {
return { receiving, destination, channel, pricingTaxMode }
routes.push({ receiving, destination, channel, pricingTaxMode })
logisticsDestinationCount += 1
if (routes.length >= MAX_ROUTE_POOL_SIZE) break receivingLoop
if (logisticsDestinationCount >= MAX_DESTINATIONS_PER_LOGISTICS) break
}
}
}
}
}
throw new Error('No priced route is available for the fixed merchant account')
if (routes.length === 0) throw new Error('No priced route is available for the fixed merchant account')
return routes
}
function testPng() {
@@ -161,11 +214,16 @@ async function uploadProductImage(gateway, timeoutMs, token, marker) {
return data.fileId
}
export function buildOrderPayload({ marker, index, profile, master, productImageFileId }) {
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, productImageFileId }) {
const sequence = String(index).padStart(3, '0')
const shipmentId = `SHIP-${sequence}-${marker}`.slice(0, 64)
const referenceId = `REF-${sequence}-${marker}`.slice(0, 64)
const companyName = profile.tenantName || `固定客户-${marker}`
const totalQuantity = product.unitsPerPackage * packageCount
const grossWeightKg = round(product.cartonGrossWeightKg * packageCount, 3)
const netWeightKg = round(grossWeightKg * 0.9, 3)
const volumeCbm = round(product.lengthCm * product.widthCm * product.heightCm / 1_000_000 * packageCount, 6)
const declaredValue = round(product.unitDeclaredValue * totalQuantity, 2)
return {
customerId: profile.id,
customerName: companyName,
@@ -206,30 +264,30 @@ export function buildOrderPayload({ marker, index, profile, master, productImage
receiver: null,
cargoes: [{
skuNo: `SKU-${sequence}-${marker}`.slice(0, 64),
cargoName: '待审核测试产品',
cargoNameEn: 'Pending review test product',
hsCode: '8518210000',
customsHsCode: '8518210000',
cargoName: product.cargoName,
cargoNameEn: product.cargoNameEn,
hsCode: product.hsCode,
customsHsCode: product.hsCode,
packageType: 'CARTON',
lengthCm: 30,
widthCm: 20,
heightCm: 20,
unitsPerPackage: 12,
packageCount: 2,
grossWeightKg: 20.5,
volumeCbm: 0.024,
totalQuantity: 24,
netWeightKg: 18,
unitDeclaredValue: 8.5,
declaredValue: 204,
lengthCm: product.lengthCm,
widthCm: product.widthCm,
heightCm: product.heightCm,
unitsPerPackage: product.unitsPerPackage,
packageCount,
grossWeightKg,
volumeCbm,
totalQuantity,
netWeightKg,
unitDeclaredValue: product.unitDeclaredValue,
declaredValue,
currency: 'USD',
material: '塑料 / Plastic',
usage: '接口测试 / API testing',
model: `MODEL-${sequence}`,
brand: 'TEST-NO-LOGO',
productLengthCm: 12,
productWidthCm: 8,
productHeightCm: 6,
material: product.material,
usage: product.usage,
model: `${product.modelPrefix}-${sequence}`,
brand: product.brand,
productLengthCm: product.productLengthCm,
productWidthCm: product.productWidthCm,
productHeightCm: product.productHeightCm,
shipmentId,
referenceId,
upcNo: `${Date.now()}${sequence}`.slice(-12),
@@ -289,11 +347,16 @@ export async function main(argv = process.argv.slice(2)) {
throw new Error(`Fixed merchant account is not ready: tenantId=${profile?.id ?? 'missing'}, status=${profile?.status ?? 'missing'}`)
}
const master = await loadMasterData(gateway, timeoutMs, token)
const routePool = await loadPricedRoutes(gateway, timeoutMs, token)
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
const randomizedProducts = shuffled(PRODUCT_CATALOG)
const randomizedRoutes = diversifyRoutes(routePool)
for (let index = 1; index <= count; index += 1) {
const payload = buildOrderPayload({ marker, index, profile, master, productImageFileId })
const master = randomizedRoutes[(index - 1) % randomizedRoutes.length]
const product = randomizedProducts[(index - 1) % randomizedProducts.length]
const packageCount = randomInt(1, 31)
const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, productImageFileId })
const createResponse = await request(gateway, timeoutMs, 'POST',
'/api/merchant/v1/orders/first-leg', {
token,
@@ -330,9 +393,19 @@ export async function main(argv = process.argv.slice(2)) {
orderNo,
clientStatus: detail.order.status,
mainStatus: unifiedOrder.summary.mainStatus,
destinationWarehouseId: String(master.destination.id),
destinationWarehouseCode: master.destination.warehouseCode,
destinationWarehouseName: master.destination.warehouseName,
receivingWarehouseId: String(master.receiving.id),
receivingWarehouseName: master.receiving.warehouseName,
channelCode: master.channel.channelCode,
channelName: master.channel.channelName,
pricingTaxMode: master.pricingTaxMode,
cargoName: product.cargoName,
packageCount,
}
orders.push(result)
console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} status=PENDING_REVIEW`)
console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} logistics=${result.channelCode}/${result.pricingTaxMode} destination=${result.destinationWarehouseCode} product=${result.cargoName} cartons=${result.packageCount} status=PENDING_REVIEW`)
}
const report = {
@@ -346,6 +419,9 @@ export async function main(argv = process.argv.slice(2)) {
startedAt,
completedAt: new Date().toISOString(),
productImageFileId: String(productImageFileId),
pricedRoutePoolSize: routePool.length,
logisticsPoolSize: new Set(routePool.map(logisticsKey)).size,
productCatalogSize: PRODUCT_CATALOG.length,
orders,
securityNote: '报告不保存密码或访问令牌。',
}
@@ -130,7 +130,8 @@ test('default Admin HTTP entry carries one marker-isolated loading chain through
assert.match(evidenceGate, /taskId[\s\S]*inboundId[\s\S]*loadingPlanId[\s\S]*containerBookingId/)
assert.match(customs, /createdResources\?\.phase3Loading[\s\S]*container\?\.finalStatus/)
assert.match(finalize, /fixture\?\.marker !== context\.marker/)
assert.match(finalize, /统一硬门禁通过后推进 LOADED 且五类有效证据仍完整/)
assert.match(finalize, /统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移/)
assert.match(finalize, /统一硬门禁通过后五类有效证据仍完整/)
assert.match(finalize, /统一订单详情投影同一订单为 LOADED[\s\S]*统一订单列表投影同一订单为 LOADED/)
})