diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index cab092ad3..d6f25fe28 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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,可重复执行) diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java index 855301b0f..23a337cae 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java @@ -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 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) {} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java index ccca2b5db..ab9ceba68 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java @@ -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 voucherFileIds, long createdBy, LocalDateTime createdAt, + List 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 lines, List transactions, diff --git a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java index 5483dcd86..0e1fef02a 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java @@ -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 lines, List paymentHistory, List 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) {} } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java b/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java new file mode 100644 index 000000000..8a70b1891 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java @@ -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; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index 0cc8821b4..e0a1120c9 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -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 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 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; } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index 7086b4c1d..df06686a3 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -124,24 +124,26 @@ public class FinanceQueryService { public List 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 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 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 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 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 receivableSettlements(Long customerId, boolean clientVisibleOnly) { diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 79fea18bb..db9169ba3 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -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 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 adjustBill(long operatorId,long billId,List 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 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 adjustBill(long operatorId,long billId,List 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 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 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 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 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 lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); java.util.Set 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 lines) { lineTotal(lines); Map 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); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 11a456340..dfec10e5b 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -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 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> 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 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 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")); } diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index a8c8db808..55f0a40b8 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -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 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 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> 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) {} } diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 22a0ab780..4025469f7 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -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), diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 65f4a78f0..4e0a01ff7 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -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 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 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()); diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java index 2a4680bd0..2c7823d6c 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java @@ -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); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index bdbc2efb6..575a0a01b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -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 diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index b492532e9..fe9b1f143 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -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 diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index aa36ba141..2f4cb0652 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -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')"); + } } diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index ce1111c58..44bfc1475 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -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); diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceOperatorSnapshotTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceOperatorSnapshotTest.java new file mode 100644 index 000000000..973afbb79 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceOperatorSnapshotTest.java @@ -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"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 9b5474500..8d88fdc61 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -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 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 items) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index dcffc7ff4..dec5c790a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -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){ } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java index 957eb1b9e..6e402cecf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java @@ -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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java index 9a2fd94a2..61f5b0ae3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -39,6 +39,18 @@ public interface LoadingModelBridgeMapper extends BaseMapper 0) return; + PreloadLoadingPlanLinkEntity link = repository.byPair(tenantId, inboundId, loadingPlanId); + if (link != null) repository.delete(link); + } + public List byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); } public List byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java index 9b71552f0..82bba04b4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java @@ -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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java index c12349796..39ef280cb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java @@ -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}") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 4e9ad0013..335c8fe38 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java index c767d6417..9673ba880 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java @@ -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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 76f34210b..8c0576f3f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -4,5 +4,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba @Select("") Page page(Page 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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index 0c0222f0d..683f1e174 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 7cf27b508..448d2ea9c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -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); - } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 62c1a9c9b..fac0340c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -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 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 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 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 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 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 5b3b00d26..3595ad1dc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -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 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 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;} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index 6e8cf8ab6..87a162638 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -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);} } diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index c6310e9a4..9a6f182ab 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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] diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 43b332c36..b4dc44d04 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -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 diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index bc37efbaf..50eeceb2c 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -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 diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 12b16d666..4836b7a3d 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index c84097b4b..fe8926a2c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -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+"')");} } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java index 56687799e..822daef09 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java @@ -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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 39dd4c83f..3c98722ce 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index c146edcd5..2209168ea 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 98eedf0f5..67abdf590 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index 5bd2b0ab0..30a8b69a2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -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 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()); } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index abad2a9e7..4120fe7cf 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d80f18391..fe984b14a 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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`,不得回退历史资源。 部署后需要定向验证该完整链时,模块顺序固定为: diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs index f0a94ab70..cebda88b9 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -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}['"]`)) } diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index e1a1bb988..fec4eb692 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -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) } } diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index ff727dc3b..b39ddbe06 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 0f8275260..dc50ded7b 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index ead7523a2..32c7b9049 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs new file mode 100644 index 000000000..c240c0cc9 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -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/) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 64c9a9ac1..0f7c52c5d 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 9e2ab135f..f33861a12 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -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', } } diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 68316d692..c1606c19d 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index e64d77e98..f0674d612 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -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) }) diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index e867c885a..7d0fb741d 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -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', + } + } } diff --git a/scripts/create-pending-review-orders-contract.test.mjs b/scripts/create-pending-review-orders-contract.test.mjs index 2d8b413f1..e30ca92c6 100644 --- a/scripts/create-pending-review-orders-contract.test.mjs +++ b/scripts/create-pending-review-orders-contract.test.mjs @@ -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) }) diff --git a/scripts/create-pending-review-orders.mjs b/scripts/create-pending-review-orders.mjs index 029338e7e..42d9ced20 100644 --- a/scripts/create-pending-review-orders.mjs +++ b/scripts/create-pending-review-orders.mjs @@ -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: '报告不保存密码或访问令牌。', } diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 048c778c3..b389da1c1 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -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/) })