This commit is contained in:
2026-08-12 17:29:30 +08:00
parent 4562e3f1d1
commit b5649cf8ae
42 changed files with 1205 additions and 78 deletions
@@ -2369,6 +2369,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照',
channel_name VARCHAR(128) NOT NULL COMMENT '订舱渠道名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内中转仓ID快照',
warehouse_name VARCHAR(128) NOT NULL COMMENT '国内中转仓名称快照',
company_id BIGINT UNSIGNED NULL COMMENT '运营公司逻辑ID',
company_name VARCHAR(128) NULL COMMENT '运营公司名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际作业仓库ID',
@@ -4546,6 +4550,10 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
@@ -4922,7 +4930,9 @@ DROP PROCEDURE migrate_legacy_loading_pick_task;
CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
@@ -4933,6 +4943,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id),
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
KEY idx_loading_pick_container (container_no, task_status, started_at),
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
@@ -8443,6 +8454,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
container_type VARCHAR(32) NOT NULL COMMENT '所需集装箱尺寸类型编码',
container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码',
capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '该需求柜型可用容量快照m3',
max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '该需求柜型最大承重快照kg',
quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数',
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id),
@@ -13969,3 +13982,50 @@ UPDATE fl_loading_plan plan
-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
/* Phase 3 channel-owned containers and stable PDA identity. Source: firstleg phase3-channel-container-migration.sql */
DROP PROCEDURE IF EXISTS migrate_phase3_channel_containers;
DELIMITER $$
CREATE PROCEDURE migrate_phase3_channel_containers()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='channel_code') THEN
ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER customer_name,
ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code,
ADD COLUMN warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照' AFTER channel_name,
ADD COLUMN warehouse_name VARCHAR(128) NULL COMMENT '国内中转仓名称快照' AFTER warehouse_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking_container_requirement' AND column_name='capacity_cbm') THEN
ALTER TABLE baiyue_firstleg.fl_ocean_booking_container_requirement ADD COLUMN capacity_cbm DECIMAL(18,4) NULL COMMENT '柜型容量快照m3' AFTER container_kind,
ADD COLUMN max_weight_kg DECIMAL(18,3) NULL COMMENT '柜型最大承重快照kg' AFTER capacity_cbm;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_code') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no,
ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code,
ADD COLUMN ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID' AFTER channel_name,
ADD COLUMN ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID' AFTER ocean_booking_id;
END IF;
UPDATE baiyue_firstleg.fl_container_booking c JOIN baiyue_firstleg.fl_ocean_booking_container_allocation a ON a.container_booking_id=c.id
JOIN baiyue_firstleg.fl_ocean_booking b ON b.id=a.ocean_booking_id
SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name)
WHERE c.ocean_booking_id IS NULL OR c.ocean_booking_requirement_id IS NULL OR c.channel_code IS NULL;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_task' AND column_name='container_booking_id') THEN
ALTER TABLE baiyue_firstleg.fl_loading_pick_task ADD COLUMN container_booking_id BIGINT UNSIGNED NULL COMMENT '稳定柜次ID' AFTER loading_plan_id,
ADD COLUMN cabinet_no VARCHAR(64) NULL COMMENT '稳定内部柜次号快照' AFTER container_booking_id,
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份';
END IF;
UPDATE baiyue_firstleg.fl_loading_pick_task t JOIN baiyue_firstleg.fl_loading_plan p ON p.id=t.loading_plan_id JOIN baiyue_firstleg.fl_container_booking c ON c.id=p.container_booking_id
SET t.container_booking_id=c.id,t.cabinet_no=c.cabinet_no WHERE t.container_booking_id IS NULL OR t.cabinet_no IS NULL;
IF EXISTS (SELECT 1 FROM baiyue_firstleg.fl_loading_pick_task WHERE container_booking_id IS NULL OR cabinet_no IS NULL) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='渠道柜迁移失败:PDA任务无法回填稳定柜次归属';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_task' AND index_name='uk_loading_pick_task_booking') THEN
ALTER TABLE baiyue_firstleg.fl_loading_pick_task ADD UNIQUE KEY uk_loading_pick_task_booking (container_booking_id);
END IF;
END$$
DELIMITER ;
CALL migrate_phase3_channel_containers();
DROP PROCEDURE IF EXISTS migrate_phase3_channel_containers;
CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_container_transport_history (
id BIGINT UNSIGNED NOT NULL,container_booking_id BIGINT UNSIGNED NOT NULL,before_container_no VARCHAR(64) NULL,after_container_no VARCHAR(64) NULL,
before_voyage_no VARCHAR(64) NULL,after_voyage_no VARCHAR(64) NULL,reason VARCHAR(500) NULL,operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),KEY idx_container_transport_history(container_booking_id,created_at,id)
) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计';
@@ -8,6 +8,7 @@ import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -53,7 +54,9 @@ public class PlatformFinanceController {
@GetMapping("/fee-pool")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户应收应付费用池")
public ApiResponse<?> feePool(@RequestParam long customerId,
public ApiResponse<?> feePool(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId,
@RequestParam(required = false) String direction) {
return FinanceResponses.success(queryService.feePool(customerId, direction));
}
@@ -61,21 +64,27 @@ public class PlatformFinanceController {
@GetMapping("/statements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户对账单")
public ApiResponse<?> statements(@RequestParam long customerId) {
public ApiResponse<?> statements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return FinanceResponses.success(queryService.statements(customerId, false));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "结算单应收")
public ApiResponse<?> receivableSettlements(@RequestParam long customerId) {
public ApiResponse<?> receivableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return FinanceResponses.success(queryService.receivableSettlements(customerId, false));
}
@GetMapping("/payable-settlements")
@PreAuthorize("hasAuthority('platform:finance:cost:view')")
@Operation(summary = "结算单应付")
public ApiResponse<?> payableSettlements(@RequestParam long customerId) {
public ApiResponse<?> payableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return FinanceResponses.success(queryService.payableSettlements(customerId));
}
@@ -30,6 +30,7 @@ public final class FinanceReadModels {
public record FeePoolItem(
String poolItemId,
long customerId,
String direction,
String sourceType,
long sourceId,
@@ -58,45 +58,48 @@ public class FinanceQueryService {
return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries));
}
public List<FeePoolItem> feePool(long customerId, String direction) {
requireCustomer(customerId);
public List<FeePoolItem> feePool(Long customerId, String direction) {
validateOptionalCustomer(customerId);
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
List<FeePoolItem> result = new ArrayList<>();
String sourceSql = "SELECT id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,status,created_at FROM fin_fee_source WHERE customer_id=? AND status='AVAILABLE'";
Object[] sourceArgs;
if ("ALL".equals(normalized)) {
sourceSql += " ORDER BY created_at DESC,id DESC";
sourceArgs = new Object[]{customerId};
} else {
sourceSql += " AND direction=? ORDER BY created_at DESC,id DESC";
sourceArgs = new Object[]{customerId, normalized};
}
String sourceSql = "SELECT id,customer_id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,status,created_at FROM fin_fee_source WHERE status='AVAILABLE'";
List<Object> sourceArgs = new ArrayList<>();
if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); }
if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); }
sourceSql += " ORDER BY created_at DESC,id DESC";
result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> {
String rowDirection = rs.getString("direction");
return new FeePoolItem(rowDirection + ":" + rs.getLong("id"), rowDirection,
return new FeePoolItem(rowDirection + ":" + rs.getLong("id"), rs.getLong("customer_id"), rowDirection,
rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getString("currency_code"),
rs.getString("status"), time(rs, "created_at"));
}, sourceArgs));
}, sourceArgs.toArray()));
if (!"RECEIVABLE".equals(normalized)) {
result.addAll(jdbc.query("SELECT id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC", (rs, rowNum) ->
new FeePoolItem("PAYABLE:" + rs.getLong("id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1,
String costSql = "SELECT id,tenant_id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost";
Object[] costArgs = new Object[0];
if (customerId != null) { costSql += " WHERE tenant_id=?"; costArgs = new Object[]{customerId}; }
costSql += " ORDER BY created_at DESC,id DESC";
result.addAll(jdbc.query(costSql, (rs, rowNum) ->
new FeePoolItem("PAYABLE:" + rs.getLong("id"), rs.getLong("tenant_id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1,
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), customerId));
rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), costArgs));
}
result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder())));
return List.copyOf(result);
}
public List<Statement> statements(long customerId, boolean clientVisibleOnly) {
requireCustomer(customerId);
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
validateOptionalCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
return 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 FROM fin_bill WHERE tenant_id=?" + visible + " ORDER BY bill_date DESC,id DESC",
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 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")), customerId);
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), args);
}
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
@@ -127,22 +130,25 @@ public class FinanceQueryService {
rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"), time(rs, "created_at")), customerId, billId);
}
public List<ReceivableSettlement> receivableSettlements(long customerId, boolean clientVisibleOnly) {
public List<ReceivableSettlement> receivableSettlements(Long customerId, boolean clientVisibleOnly) {
return statements(customerId, clientVisibleOnly).stream().map(row -> new ReceivableSettlement(
row.id(), row.customerId(), row.billNo(), row.billDate(), row.dueDate(), row.totalAmount(), row.receivedAmount(),
row.outstandingAmount(), row.currency(), row.status(), row.version())).toList();
}
public List<PayableSettlement> payableSettlements(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC",
public List<PayableSettlement> payableSettlements(Long customerId) {
validateOptionalCustomer(customerId);
String sql = "SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + " ORDER BY created_at DESC,id DESC",
(rs, rowNum) -> {
BigDecimal amount = rs.getBigDecimal("cost_amount");
return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"),
rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"),
time(rs, "created_at"), rs.getInt("version"));
}, customerId);
}, args);
}
public CostDetail cost(long customerId, long costId) {
@@ -206,6 +212,10 @@ public class FinanceQueryService {
if (customerId <= 0) throw bad("客户范围无效");
}
private static void validateOptionalCustomer(Long customerId) {
if (customerId != null) requireCustomer(customerId);
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
@@ -59,7 +59,7 @@ class FinanceFeePoolDirectionBehaviorTest {
}
private static FeePoolItem item(String direction, String sourceType, long sourceId) {
return new FeePoolItem(direction + ":" + sourceId, direction, sourceType, sourceId, 1,
return new FeePoolItem(direction + ":" + sourceId, 7L, direction, sourceType, sourceId, 1,
11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY", "AVAILABLE",
LocalDateTime.of(2026, 8, 11, 1, 0));
}
@@ -0,0 +1,52 @@
package com.baiyue.finance;
import com.baiyue.finance.controller.PlatformFinanceController;
import io.swagger.v3.oas.annotations.Parameter;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class FinancePlatformListContractTest {
@Test
void platformCustomerFiltersAreOptionalAndDocumentAllCustomerDefault() throws Exception {
for (String methodName : new String[]{"feePool", "statements", "receivableSettlements", "payableSettlements"}) {
Method method = find(methodName);
var customer = method.getParameters()[0];
assertThat(customer.getType()).isEqualTo(Long.class);
assertThat(customer.getAnnotation(RequestParam.class).required()).isFalse();
assertThat(customer.getAnnotation(Parameter.class).description()).contains("省略时查询全部客户");
}
}
@Test
void platformListPermissionsRemainSeparatedByReceivableAndPayableData() throws Exception {
Map<String, String> expected = Map.of(
"feePool", "hasAuthority('platform:finance:bill:view')",
"statements", "hasAuthority('platform:finance:bill:view')",
"receivableSettlements", "hasAuthority('platform:finance:bill:view')",
"payableSettlements", "hasAuthority('platform:finance:cost:view')");
for (var entry : expected.entrySet()) {
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).isEqualTo(entry.getValue());
}
}
@Test
void allCustomerFeePoolResponseCarriesCustomerIdentity() {
assertThat(Arrays.stream(com.baiyue.finance.dto.FinanceReadModels.FeePoolItem.class
.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
.contains("customerId");
}
private static Method find(String name) throws Exception {
return switch (name) {
case "feePool" -> PlatformFinanceController.class.getMethod(name, Long.class, String.class);
default -> PlatformFinanceController.class.getMethod(name, Long.class);
};
}
}
@@ -0,0 +1,67 @@
package com.baiyue.finance;
import com.baiyue.finance.service.FinanceQueryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FinancePlatformOptionalCustomerQueryTest {
@Test
void allCustomerQueriesOmitCustomerPredicateAndArguments() {
RecordingJdbc jdbc = new RecordingJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
service.feePool(null, null);
service.statements(null, false);
service.receivableSettlements(null, false);
service.payableSettlements(null);
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
assertThat(call.sql()).doesNotContain("customer_id=?", "tenant_id=?");
assertThat(call.args()).isEmpty();
});
}
@Test
void customerFilterAddsPredicateAndRealCustomerArgument() {
RecordingJdbc jdbc = new RecordingJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
service.feePool(31L, null);
service.statements(31L, false);
service.receivableSettlements(31L, false);
service.payableSettlements(31L);
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
assertThat(call.sql()).containsAnyOf("customer_id=?", "tenant_id=?");
assertThat(call.args()).containsExactly(31L);
});
}
@Test
void optionalCustomerNeverTreatsZeroAsAllCustomers() {
FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper());
assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效");
assertThatThrownBy(() -> service.feePool(-1L, null)).hasMessageContaining("客户范围无效");
}
private record Call(String sql, List<Object> args) {}
private static final class RecordingJdbc extends JdbcTemplate {
private final List<Call> calls = new ArrayList<>();
@Override
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) {
calls.add(new Call(sql, List.of(args)));
return List.of();
}
}
}
@@ -22,6 +22,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -75,4 +76,35 @@ class FinanceReadApiBehaviorTest {
verify(queryService).receivableSettlements(11L, false);
verify(queryService).payableSettlements(11L);
}
@Test
void platformListsDefaultToAllCustomersAndKeepOptionalFilter() {
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
controller.feePool(null, null);
controller.statements(null);
controller.receivableSettlements(null);
controller.payableSettlements(null);
controller.statements(31L);
verify(queryService).feePool(null, null);
verify(queryService).statements(null, false);
verify(queryService).receivableSettlements(null, false);
verify(queryService).payableSettlements(null);
verify(queryService).statements(31L, false);
}
@Test
void clientListsAlwaysUseAuthenticatedCustomerScope() {
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
var controller = new ClientFinanceController(service, queryService);
controller.bills(user);
controller.statements(user);
controller.receivableSettlements(user);
verify(queryService, org.mockito.Mockito.times(2)).statements(23L, true);
verify(queryService).receivableSettlements(23L, true);
verifyNoMoreInteractions(queryService);
}
}
@@ -60,6 +60,15 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.pendingTasks(user.userId(), keyword));
}
@GetMapping("/tasks/{taskId}")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Task> taskById(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(taskId, warehouseId));
}
@GetMapping("/tasks/{taskId}/inventory-snapshot")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<InventorySnapshot> inventorySnapshot(@AuthenticationPrincipal AuthenticatedUser user,
@@ -84,6 +84,7 @@ public final class ContainerDtos {
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @PositiveOrZero Integer version
@NotNull @PositiveOrZero Integer version,
@NotBlank @Size(max = 500) String reason
) {}
}
@@ -62,6 +62,7 @@ public final class LoadingListDtos {
public record PreloadSummary(
Long planId,
Long containerBookingId,
String preloadNo,
String containerNo,
String internalContainerNo,
@@ -86,6 +87,7 @@ public final class LoadingListDtos {
public record ConfirmedSummary(
Long planId,
Long containerBookingId,
String loadingListNo,
Long warehouseId,
String warehouseName,
@@ -58,7 +58,9 @@ public final class LoadingPickDtos {
/** PDA 在线草稿只在员工提交时发送一次,服务端原子处理整批明细。 */
public record BatchRequest(
@NotNull @Min(1) Long warehouseId,
@NotBlank @Size(max = 64) String containerNo,
@NotNull @Min(1) Long loadingPlanId,
@NotNull @Min(1) Long containerBookingId,
@Size(max = 64) String containerNo,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 64) String inventorySnapshotToken,
@NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items
@@ -125,13 +127,15 @@ public final class LoadingPickDtos {
Long operatorId, String operatorName, LocalDateTime createdAt) {
}
public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus,
public record Task(Long id, Long loadingPlanId, Long containerBookingId, String cabinetNo,
String currentContainerNo, String taskStatus,
String workflowStatus, Integer version, Assignment assignment,
Review review, List<PickItem> items) {
}
/** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */
public record PendingTask(Long taskId, Long loadingPlanId, String containerNo,
public record PendingTask(Long taskId, Long loadingPlanId, Long containerBookingId,
String cabinetNo, String currentContainerNo,
Long warehouseId, String warehouseName, String taskStatus,
String workflowStatus, Integer version, Integer assignmentVersion,
Integer itemCount, Integer pendingItemCount) {
@@ -146,7 +150,8 @@ public final class LoadingPickDtos {
List<InventoryResource> resources) {
}
public record InventorySnapshot(Long taskId, Integer taskVersion, String containerNo,
public record InventorySnapshot(Long taskId, Long loadingPlanId, Long containerBookingId,
String cabinetNo, Integer taskVersion, String currentContainerNo,
Long warehouseId, LocalDateTime capturedAt, String snapshotToken,
List<InboundInventory> inbounds) {
}
@@ -2,12 +2,12 @@ package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
}
}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
@@ -20,6 +20,10 @@ public class ContainerBookingEntity {
private String bookingNo;
private String containerNo;
private String cabinetNo;
private String channelCode;
private String channelName;
private Long oceanBookingId;
private Long oceanBookingRequirementId;
private Long warehouseId;
private String warehouseName;
private String billOfLadingNo;
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
@TableName("fl_container_transport_history")
public class ContainerTransportHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long containerBookingId;
private String beforeContainerNo;
private String afterContainerNo;
private String beforeVoyageNo;
private String afterVoyageNo;
private String reason;
private Long operatorId;
private String operatorName;
}
@@ -17,6 +17,8 @@ public class LoadingPickTaskEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long loadingPlanId;
private Long containerBookingId;
private String cabinetNo;
private String containerNo;
private String taskStatus;
private Long startedBy;
@@ -96,4 +96,15 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},
voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt},updated_by=#{operatorId},
updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE ocean_booking_id=#{oceanBookingId}
""")
int syncReleasedTransport(@Param("oceanBookingId") long oceanBookingId,
@Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName,
@Param("voyageNo") String voyageNo, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContainerTransportHistoryMapper extends BaseMapper<ContainerTransportHistoryEntity> {}
@@ -24,7 +24,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE task.container_no = #{containerNo} AND task.task_status = 'LOADING'
WHERE booking.container_no = #{containerNo} AND task.task_status = 'LOADING'
AND booking.warehouse_id = #{warehouseId}
LIMIT 1
""")
@@ -34,7 +34,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
@Select("""
<script>
SELECT task.id AS taskId, task.loading_plan_id AS loadingPlanId,
task.container_no AS containerNo, booking.warehouse_id AS warehouseId,
task.container_booking_id AS containerBookingId, task.cabinet_no AS cabinetNo,
booking.container_no AS containerNo, booking.warehouse_id AS warehouseId,
booking.warehouse_name AS warehouseName, task.task_status AS taskStatus,
CASE WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED' ELSE 'IN_PROGRESS' END AS workflowStatus,
task.version AS version, COALESCE(assignment.version, 0) AS assignmentVersion,
@@ -51,7 +52,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
AND (task.container_no LIKE CONCAT('%', #{keyword}, '%')
OR booking.warehouse_name LIKE CONCAT('%', #{keyword}, '%'))
</if>
GROUP BY task.id, task.loading_plan_id, task.container_no, booking.warehouse_id,
GROUP BY task.id, task.loading_plan_id, task.container_booking_id, task.cabinet_no,
booking.container_no, booking.warehouse_id,
booking.warehouse_name, task.task_status, task.version,
assignment.pick_task_id, assignment.version
ORDER BY task.id DESC
@@ -65,6 +67,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
private Long taskId;
private Long loadingPlanId;
private String containerNo;
private Long containerBookingId;
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String taskStatus;
@@ -80,6 +84,10 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
public void setLoadingPlanId(Long loadingPlanId) { this.loadingPlanId = loadingPlanId; }
public String getContainerNo() { return containerNo; }
public void setContainerNo(String containerNo) { this.containerNo = containerNo; }
public Long getContainerBookingId() { return containerBookingId; }
public void setContainerBookingId(Long value) { this.containerBookingId = value; }
public String getCabinetNo() { return cabinetNo; }
public void setCabinetNo(String value) { this.cabinetNo = value; }
public Long getWarehouseId() { return warehouseId; }
public void setWarehouseId(Long warehouseId) { this.warehouseId = warehouseId; }
public String getWarehouseName() { return warehouseName; }
@@ -4,7 +4,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
@@ -42,7 +42,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
@@ -43,7 +43,7 @@ public class PlatformOceanBookingController {
public ApiResponse<Detail> confirm(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/release") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:release')")
@BusinessAudit(event="OCEAN_BOOKING_RELEASE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认放舱")
public ApiResponse<Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); }
public ApiResponse<Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReleaseRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')")
@BusinessAudit(event="OCEAN_BOOKING_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记海运订舱失败")
public ApiResponse<Detail> fail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.fail(oceanBookingId, user.userId(), user.username(), key, request)); }
@@ -15,6 +15,7 @@ import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
public final class OceanBookingDtos {
@@ -34,10 +35,16 @@ public final class OceanBookingDtos {
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
@NotBlank @Size(max = 32) String containerKind,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
@NotNull @Positive Integer quantity) {}
public record DraftData(@NotNull @Positive Long customerId,
@NotBlank @Size(max = 128) String customerName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo,
@Positive Long shippingCompanyId,
@Size(max = 128) String shippingCompanyName,
@@ -64,6 +71,13 @@ public final class OceanBookingDtos {
@Valid @NotNull DraftData data) {}
public record ActionRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {}
public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String billOfLadingNo,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@NotNull LocalDateTime estimatedSailingAt,
@NotNull LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String reason) {}
public record ChangeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@@ -14,6 +15,8 @@ public class OceanBookingContainerRequirementEntity {
private Long oceanBookingId;
private String containerType;
private String containerKind;
private BigDecimal capacityCbm;
private BigDecimal maxWeightKg;
private Integer quantity;
private Integer sortNo;
}
@@ -20,6 +20,10 @@ public class OceanBookingEntity {
private String status;
private Long customerId;
private String customerName;
private String channelCode;
private String channelName;
private Long warehouseId;
private String warehouseName;
private String billOfLadingNo;
private Long shippingCompanyId;
private String shippingCompanyName;
@@ -30,6 +30,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Update("""
UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},
channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},
bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},
shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},
booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},
@@ -56,6 +57,20 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status='RELEASED',bill_of_lading_no=#{billOfLadingNo},
vessel_name=#{vesselName},voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt},
estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status='CONFIRMED' AND version=#{expectedVersion}
""")
int release(@Param("oceanBookingId") long oceanBookingId, @Param("billOfLadingNo") String billOfLadingNo,
@Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo,
@Param("estimatedSailingAt") LocalDateTime estimatedSailingAt,
@Param("estimatedArrivalAt") LocalDateTime estimatedArrivalAt,
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status='CHANGE_PENDING',pending_vessel_name=#{vesselName},
pending_voyage_no=#{voyageNo},pending_estimated_sailing_at=#{estimatedSailingAt},
@@ -5,7 +5,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.oceanbooking.entity.*;
import com.baiyue.firstleg.oceanbooking.mapper.*;
import jakarta.annotation.Resource;
@@ -22,6 +25,7 @@ public class OceanBookingRepository {
@Resource private OceanBookingStatusHistoryMapper histories;
@Resource private OceanBookingOperationMapper operations;
@Resource private ContainerBookingMapper containerBookings;
@Resource private LoadingPlanMapper loadingPlans;
public PageResult<OceanBookingEntity> page(Query query) {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
@@ -35,6 +39,11 @@ public class OceanBookingRepository {
int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, failureReason, cancellationReason, version, operatorId, operatorName);
}
public int release(long id, ReleaseRequest request, long operatorId, String operatorName) {
return bookings.release(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
request.voyageNo().trim(), request.estimatedSailingAt(), request.estimatedArrivalAt(),
request.expectedVersion(), operatorId, operatorName);
}
public int requestChange(long id, OceanBookingEntity pending, String reason, int version,
long operatorId, String operatorName) {
return bookings.requestChange(id, pending.getPendingVesselName(), pending.getPendingVoyageNo(),
@@ -55,6 +64,12 @@ public class OceanBookingRepository {
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
public ContainerBookingEntity containerBooking(long id) { return containerBookings.selectById(id); }
public void insert(ContainerBookingEntity value) { containerBookings.insert(value); }
public void insert(LoadingPlanEntity value) { loadingPlans.insert(value); }
public int syncReleasedTransport(long id, ReleaseRequest request, long operatorId, String operatorName) {
return containerBookings.syncReleasedTransport(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
request.voyageNo().trim(), request.estimatedSailingAt(), operatorId, operatorName);
}
public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); }
public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); }
public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; }
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.*;
@@ -82,12 +83,32 @@ public class OceanBookingService {
OceanBookingStatus.SUBMITTED, request, false);
}
@Transactional public Detail confirm(long id, long operatorId, String name, String key, ActionRequest request) {
return transition(id, operatorId, name, key, "CONFIRM", OceanBookingStatus.SUBMITTED,
OceanBookingStatus.CONFIRMED, request, false);
String action = "CONFIRM";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.SUBMITTED);
if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(),
null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
generateContainers(after, repository.requirements(id), operatorId, name);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional public Detail release(long id, long operatorId, String name, String key, ActionRequest request) {
return transition(id, operatorId, name, key, "RELEASE", OceanBookingStatus.CONFIRMED,
OceanBookingStatus.RELEASED, request, false);
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
validateRange(request.estimatedSailingAt(), request.estimatedArrivalAt());
String action = "RELEASE";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.CONFIRMED);
if (repository.release(id, request, operatorId, name) != 1) throw concurrent();
repository.syncReleasedTransport(id, request, operatorId, name);
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional
@@ -177,6 +198,7 @@ public class OceanBookingService {
ContainerBookingEntity container = repository.containerBooking(request.containerBookingId());
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
if (!requirement.getContainerType().equals(container.getContainerType())) throw conflict("柜次箱型与订舱需求不一致");
requireSameChannel(booking, container);
long used = repository.allocations(id).stream().filter(v -> request.requirementId().equals(v.getRequirementId())).count();
if (used >= requirement.getQuantity()) throw conflict("该箱型需求已分配完毕");
if (repository.touchForAllocation(id, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
@@ -237,7 +259,11 @@ public class OceanBookingService {
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
private void validateDraft(DraftData data) {
validateRange(data.estimatedSailingAt(), data.estimatedArrivalAt());
if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null
|| StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName())
|| StringUtils.hasText(data.voyageNo())) {
throw badRequest("真实运输数据请在确认放舱时录入");
}
Set<String> types = new HashSet<>();
for (PartyRequest party : data.parties()) {
String type = party.partyType().trim().toUpperCase();
@@ -254,17 +280,66 @@ public class OceanBookingService {
}
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
private static void copy(OceanBookingEntity v, DraftData d) {
v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim()); v.setBillOfLadingNo(trim(d.billOfLadingNo()));
v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim());
v.setChannelCode(d.channelCode().trim()); v.setChannelName(d.channelName().trim());
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim());
v.setBillOfLadingNo(null);
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(d.bookingAgentName()));
v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo()));
v.setVesselName(null); v.setVoyageNo(null);
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName()));
v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm()));
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt()); v.setRemark(trim(d.remark()));
v.setEstimatedSailingAt(null); v.setEstimatedArrivalAt(null); v.setRemark(trim(d.remark()));
}
private void generateContainers(OceanBookingEntity booking,
List<OceanBookingContainerRequirementEntity> requirements,
long operatorId, String operatorName) {
for (OceanBookingContainerRequirementEntity requirement : requirements) {
for (int sequence = 1; sequence <= requirement.getQuantity(); sequence++) {
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
container.setCabinetNo("CAB-" + booking.getBookingNo() + '-' + requirement.getSortNo() + '-' + sequence);
container.setContainerNo(null); container.setChannelCode(booking.getChannelCode());
container.setChannelName(booking.getChannelName()); container.setOceanBookingId(booking.getId());
container.setOceanBookingRequirementId(requirement.getId());
container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName());
container.setContainerType(requirement.getContainerType());
container.setCapacityCbm(requirement.getCapacityCbm());
container.setMaxWeightKg(requirement.getMaxWeightKg());
container.setShippingCompanyId(booking.getShippingCompanyId());
container.setShippingCompanyName(booking.getShippingCompanyName());
container.setDeparturePortCode(booking.getDeparturePortCode());
container.setDeparturePortName(booking.getDeparturePortName());
container.setDestinationPortCode(booking.getDestinationPortCode());
container.setDestinationPortName(booking.getDestinationPortName());
container.setCreatedBy(operatorId); container.setCreatedByName(operatorName);
container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0);
repository.insert(container);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo("LP-" + container.getId()); plan.setContainerBookingId(container.getId());
plan.setPlanStatus("EDITING"); plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName);
plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); plan.setVersion(0);
repository.insert(plan);
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId());
allocation.setRequirementId(requirement.getId()); allocation.setContainerBookingId(container.getId());
allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName);
allocation.setAllocatedAt(LocalDateTime.now()); repository.insert(allocation);
}
}
}
private static void requireSameChannel(OceanBookingEntity booking, ContainerBookingEntity container) {
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(container.getChannelCode())) {
throw conflict("订舱或柜次缺少渠道快照,请先完成存量数据回填");
}
if (!booking.getChannelCode().equals(container.getChannelCode())) throw conflict("柜次渠道与订舱渠道不一致");
}
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(r.containerType().trim().toUpperCase()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(r.containerType().trim().toUpperCase()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
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 String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存订舱历史快照", ex); } }
private static OceanBookingStatus parseStatus(String status) { try { return OceanBookingStatus.valueOf(status); } catch (RuntimeException ex) { throw badRequest("订舱状态无效"); } }
@@ -102,7 +102,8 @@ public class LoadingListRepository {
}
private PreloadSummary preload(AggregateRow row, List<Customer> customers) {
return new PreloadSummary(row.getPlanId(), row.getPlanNo(), row.getContainerNo(), row.getCabinetNo(),
return new PreloadSummary(row.getPlanId(), row.getContainerBookingId(), row.getPlanNo(),
row.getContainerNo(), row.getCabinetNo(),
customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(),
row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()),
value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()),
@@ -110,7 +111,8 @@ public class LoadingListRepository {
}
private ConfirmedSummary confirmed(AggregateRow row) {
return new ConfirmedSummary(row.getPlanId(), row.getPlanNo(), row.getWarehouseId(), row.getWarehouseName(),
return new ConfirmedSummary(row.getPlanId(), row.getContainerBookingId(), row.getPlanNo(),
row.getWarehouseId(), row.getWarehouseName(),
row.getContainerNo(), row.getCabinetNo(), row.getPlanStatus(), value(row.getTotalPackageCount()),
decimal(row.getTotalGrossWeightKg()), decimal(row.getTotalNetWeightKg()),
decimal(row.getTotalVolumeCbm()), value(row.getTotalQuantity()), row.getRemark(),
@@ -13,6 +13,8 @@ import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource;
@@ -29,6 +31,7 @@ public class ContainerService {
@Resource private WarehouseMasterDataClient warehouses;
@Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickService loadingPickService;
@Resource private ContainerTransportHistoryMapper transportHistories;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) {
@@ -87,6 +90,11 @@ public class ContainerService {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
ContainerTransportHistoryEntity history = new ContainerTransportHistoryEntity();
history.setContainerBookingId(id); history.setBeforeContainerNo(current.getContainerNo());
history.setAfterContainerNo(containerNo); history.setBeforeVoyageNo(current.getVoyageNo());
history.setAfterVoyageNo(trim(request.voyageNo())); history.setReason(request.reason().trim());
history.setOperatorId(operatorId); history.setOperatorName(operatorName); transportHistories.insert(history);
ContainerBookingEntity updated = required(id);
beginPdaTaskAfterContainerBound(updated, containerNo, operatorId, operatorName);
return updated;
@@ -80,7 +80,10 @@ public class LoadingPickService {
if (task == null) {
task = new LoadingPickTaskEntity();
task.setLoadingPlanId(planId);
task.setContainerNo(containerNo);
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
task.setContainerBookingId(booking.getId());
task.setCabinetNo(booking.getCabinetNo());
task.setContainerNo(null);
task.setTaskStatus("LOADING");
task.setStartedBy(operatorId);
task.setStartedByName(operatorName);
@@ -122,10 +125,15 @@ public class LoadingPickService {
return view(task, warehouseId);
}
public Task task(long taskId, long warehouseId) {
return view(requiredTask(taskId), warehouseId);
}
public List<PendingTask> pendingTasks(long operatorId, String keyword) {
String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null;
return repository.pendingTasks(operatorId, normalizedKeyword).stream()
.map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerNo(),
.map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerBookingId(),
row.getCabinetNo(), row.getContainerNo(),
row.getWarehouseId(), row.getWarehouseName(), row.getTaskStatus(), row.getWorkflowStatus(),
row.getVersion(), row.getAssignmentVersion(), row.getItemCount(), row.getPendingItemCount()))
.toList();
@@ -297,8 +305,9 @@ public class LoadingPickService {
if (!Objects.equals(request.expectedVersion(), task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后重试");
}
if (!task.getContainerNo().equals(request.containerNo().trim())) {
throw conflict("柜号与PDA任务不一致");
if (!Objects.equals(request.loadingPlanId(), task.getLoadingPlanId())
|| !Objects.equals(request.containerBookingId(), task.getContainerBookingId())) {
throw conflict("装柜计划或柜次归属与PDA任务不一致");
}
LoadingPickAssignmentEntity assignment = repository.assignment(taskId);
if (assignment == null) throw conflict("请先领取PDA拣货任务");
@@ -501,7 +510,9 @@ public class LoadingPickService {
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
: assigned == null ? "UNASSIGNED"
: repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS";
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(),
var booking = smartLoadingRepository.booking(task.getContainerBookingId());
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), task.getCabinetNo(),
booking == null ? null : booking.getContainerNo(), task.getTaskStatus(),
workflowStatus, task.getVersion(), assignment, review, items);
}
@@ -613,7 +624,9 @@ public class LoadingPickService {
return new InboundInventory(inboundId, available, resources);
}).toList();
String token = inventoryToken(task, warehouseId, inbounds);
return new InventorySnapshot(task.getId(), task.getVersion(), task.getContainerNo(), warehouseId,
var booking = smartLoadingRepository.booking(task.getContainerBookingId());
return new InventorySnapshot(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), task.getCabinetNo(),
task.getVersion(), booking == null ? null : booking.getContainerNo(), warehouseId,
LocalDateTime.now(), token, inbounds);
}
@@ -808,13 +821,13 @@ public class LoadingPickService {
}
private static String batchRequestHash(long taskId, BatchRequest request) {
if (request == null || request.warehouseId() == null || !StringUtils.hasText(request.containerNo())
if (request == null || request.warehouseId() == null
|| request.expectedVersion() == null || !StringUtils.hasText(request.inventorySnapshotToken())
|| request.items() == null || request.items().isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "整批提交参数不完整");
}
StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|')
.append(request.warehouseId()).append('|').append(request.containerNo().trim()).append('|')
.append(request.warehouseId()).append('|').append(request.loadingPlanId()).append('|')
.append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim());
request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId,
Comparator.nullsFirst(Long::compareTo)))
@@ -93,6 +93,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
requireSameChannel(booking, candidate);
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
@@ -135,6 +136,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
for (Candidate candidate : candidates(booking.getWarehouseId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
if (!sameChannel(booking, candidate)) continue;
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
int count = fittingBoxes(booking, repository.items(planId), candidate);
@@ -170,6 +172,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
requireSameChannel(booking, candidate);
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
@@ -287,6 +290,7 @@ public class SmartLoadingService {
}
}
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,int count){
requireSameChannel(b, c);
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) throw validation("目的仓/港不一致");
BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
@@ -344,6 +348,16 @@ public class SmartLoadingService {
return p;
}
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
private static boolean sameChannel(ContainerBookingEntity booking, Candidate candidate) {
return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode())
&& booking.getChannelCode().equals(candidate.channelCode());
}
private static void requireSameChannel(ContainerBookingEntity booking, Candidate candidate) {
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) {
throw validation("柜次或入库货物缺少渠道快照,请先完成存量数据回填");
}
if (!sameChannel(booking, candidate)) throw validation("禁止跨渠道混装");
}
private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");}
private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) {
LoadingPlanHistoryEntity history = repository.operation(operation);
@@ -0,0 +1,57 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS migrate_phase3_channel_containers;
DELIMITER $$
CREATE PROCEDURE migrate_phase3_channel_containers()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' AND column_name='channel_code') THEN
ALTER TABLE fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER customer_name,
ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code,
ADD COLUMN warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照' AFTER channel_name,
ADD COLUMN warehouse_name VARCHAR(128) NULL COMMENT '国内中转仓名称快照' AFTER warehouse_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking_container_requirement' AND column_name='capacity_cbm') THEN
ALTER TABLE fl_ocean_booking_container_requirement ADD COLUMN capacity_cbm DECIMAL(18,4) NULL COMMENT '柜型容量快照m3' AFTER container_kind,
ADD COLUMN max_weight_kg DECIMAL(18,3) NULL COMMENT '柜型最大承重快照kg' AFTER capacity_cbm;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_code') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no,
ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code,
ADD COLUMN ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID' AFTER channel_name,
ADD COLUMN ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID' AFTER ocean_booking_id;
END IF;
UPDATE fl_container_booking c
JOIN fl_ocean_booking_container_allocation a ON a.container_booking_id=c.id
JOIN fl_ocean_booking b ON b.id=a.ocean_booking_id
SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,
c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name)
WHERE c.ocean_booking_id IS NULL OR c.ocean_booking_requirement_id IS NULL OR c.channel_code IS NULL;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_loading_pick_task' AND column_name='container_booking_id') THEN
ALTER TABLE fl_loading_pick_task ADD COLUMN container_booking_id BIGINT UNSIGNED NULL COMMENT '稳定柜次ID' AFTER loading_plan_id,
ADD COLUMN cabinet_no VARCHAR(64) NULL COMMENT '稳定内部柜次号快照' AFTER container_booking_id,
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份';
END IF;
UPDATE fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id
JOIN fl_container_booking c ON c.id=p.container_booking_id
SET t.container_booking_id=c.id,t.cabinet_no=c.cabinet_no
WHERE t.container_booking_id IS NULL OR t.cabinet_no IS NULL;
IF EXISTS (SELECT 1 FROM fl_loading_pick_task WHERE container_booking_id IS NULL OR cabinet_no IS NULL) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='渠道柜迁移失败:PDA任务无法回填稳定柜次归属';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_loading_pick_task' AND index_name='uk_loading_pick_task_booking') THEN
ALTER TABLE fl_loading_pick_task ADD UNIQUE KEY uk_loading_pick_task_booking (container_booking_id);
END IF;
END$$
DELIMITER ;
CALL migrate_phase3_channel_containers();
DROP PROCEDURE IF EXISTS migrate_phase3_channel_containers;
CREATE TABLE IF NOT EXISTS fl_container_transport_history (
id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL,
before_container_no VARCHAR(64) NULL, after_container_no VARCHAR(64) NULL,
before_voyage_no VARCHAR(64) NULL, after_voyage_no VARCHAR(64) NULL,
reason VARCHAR(500) NULL, operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
KEY idx_container_transport_history (container_booking_id,created_at,id)
) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计';
@@ -7,6 +7,10 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照',
channel_name VARCHAR(128) NOT NULL COMMENT '订舱渠道名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内中转仓ID快照',
warehouse_name VARCHAR(128) NOT NULL COMMENT '国内中转仓名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照',
@@ -71,6 +75,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
container_type VARCHAR(32) NOT NULL COMMENT '所需集装箱尺寸类型编码',
container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码',
capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '该需求柜型可用容量快照m3',
max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '该需求柜型最大承重快照kg',
quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数',
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id),
@@ -31,6 +31,10 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
@@ -396,7 +400,9 @@ DROP PROCEDURE migrate_legacy_loading_pick_task;
CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
@@ -407,6 +413,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id),
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
KEY idx_loading_pick_container (container_no, task_status, started_at),
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
@@ -953,6 +953,10 @@ components:
bookingNo: {type: string, maxLength: 40}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled}
channelName: {type: string, maxLength: 128, nullable: true}
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
oceanBookingRequirementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
@@ -1017,11 +1021,12 @@ components:
data: {$ref: '#/components/schemas/ContainerSaveRequest'}
ContainerTransportUpdateRequest:
type: object
required: [version]
required: [version, reason]
properties:
containerNo: {type: string, maxLength: 64, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
version: {type: integer, minimum: 0}
reason: {type: string, maxLength: 500}
MerchantInboundBillingEnvelope:
type: object
properties:
@@ -208,9 +208,10 @@ components:
customerName: {type: string, nullable: true, description: 客户名称}
PreloadSummary:
type: object
required: [planId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version]
required: [planId, containerBookingId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version]
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
preloadNo: {type: string, description: 预装单号}
containerNo: {type: string, nullable: true, description: 集装箱号}
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
@@ -229,9 +230,10 @@ components:
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
ConfirmedSummary:
type: object
required: [planId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
required: [planId, containerBookingId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
loadingListNo: {type: string, description: 装柜清单号}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
@@ -0,0 +1,74 @@
openapi: 3.0.3
info: {title: Phase 3 Ocean Booking API, version: 1.1.0}
paths:
/api/platform/v1/ocean-bookings:
post:
summary: Create an ocean booking draft with immutable channel and warehouse snapshots
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateRequest'}}}}
responses: {'200': {description: Draft detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/confirm:
post:
summary: Confirm booking and atomically create one stable cabinet and editing loading plan per requested quantity
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: CONFIRMED detail; no real container number required}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post:
summary: Confirm released transport data and synchronize associated cabinet transport snapshots
description: Does not create cabinets, change channels, replace cabinetNo, or recreate loading plans.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}}
components:
parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
CreateRequest:
type: object
required: [expectedVersion, data]
properties:
expectedVersion: {type: integer, enum: [0]}
data: {$ref: '#/components/schemas/DraftData'}
DraftData:
type: object
required: [customerId, customerName, channelCode, channelName, warehouseId, warehouseName, parties, containerRequirements]
properties:
customerId: {type: integer, format: int64, minimum: 1}
customerName: {type: string, maxLength: 128}
channelCode: {type: string, maxLength: 64}
channelName: {type: string, maxLength: 128}
warehouseId: {type: integer, format: int64, minimum: 1}
warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
vesselName: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
voyageNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
estimatedSailingAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release}
estimatedArrivalAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release}
parties: {type: array, minItems: 3, items: {type: object}}
containerRequirements: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequirement'}}
ContainerRequirement:
type: object
required: [containerType, containerKind, capacityCbm, maxWeightKg, quantity]
properties:
containerType: {type: string, maxLength: 32}
containerKind: {type: string, maxLength: 32}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}
quantity: {type: integer, minimum: 1}
ActionRequest:
type: object
required: [expectedVersion]
properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 1000}}
ReleaseRequest:
type: object
required: [expectedVersion, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt]
properties:
expectedVersion: {type: integer, minimum: 0}
billOfLadingNo: {type: string, maxLength: 128}
vesselName: {type: string, maxLength: 128}
voyageNo: {type: string, maxLength: 64}
estimatedSailingAt: {type: string, format: date-time}
estimatedArrivalAt: {type: string, format: date-time}
reason: {type: string, maxLength: 1000}
@@ -7,7 +7,7 @@ paths:
get:
summary: Find the active container picking task in one platform warehouse
parameters:
- {name: containerNo, in: query, required: true, schema: {type: string}}
- {name: containerNo, in: query, required: true, deprecated: true, description: Compatibility lookup only; taskId is the stable identity, schema: {type: string}}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
@@ -26,6 +26,14 @@ paths:
schema:
type: array
items: {$ref: '#/components/schemas/PendingTask'}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}:
get:
summary: Read one PDA task by stable task identity
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200': {description: Task with current transport display fields}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim:
post:
summary: Atomically claim an unassigned task for the authenticated operator
@@ -112,11 +120,13 @@ components:
schemas:
Task:
type: object
required: [id, loadingPlanId, containerNo, taskStatus, workflowStatus, version, items]
required: [id, loadingPlanId, containerBookingId, cabinetNo, taskStatus, workflowStatus, version, items]
properties:
id: {type: integer, format: int64}
loadingPlanId: {type: integer, format: int64}
containerNo: {type: string}
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string}
currentContainerNo: {type: string, nullable: true, description: Mutable transport display field; never task identity}
taskStatus: {type: string, enum: [LOADING, COMPLETED]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]}
version: {type: integer, minimum: 0}
@@ -147,11 +157,13 @@ components:
version: {type: integer, minimum: 0}
PendingTask:
type: object
required: [taskId, loadingPlanId, containerNo, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount]
required: [taskId, loadingPlanId, containerBookingId, cabinetNo, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount]
properties:
taskId: {type: integer, format: int64}
loadingPlanId: {type: integer, format: int64}
containerNo: {type: string}
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string}
currentContainerNo: {type: string, nullable: true}
warehouseId: {type: integer, format: int64}
warehouseName: {type: string, nullable: true}
taskStatus: {type: string, enum: [LOADING]}
@@ -176,10 +188,12 @@ components:
operationId: {type: string, maxLength: 128}
BatchRequest:
type: object
required: [warehouseId, containerNo, expectedVersion, inventorySnapshotToken, items]
required: [warehouseId, loadingPlanId, containerBookingId, expectedVersion, inventorySnapshotToken, items]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
containerNo: {type: string, maxLength: 64}
loadingPlanId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1}
containerNo: {type: string, maxLength: 64, nullable: true, deprecated: true, description: Accepted during compatibility period but ignored for identity and idempotency}
expectedVersion: {type: integer, minimum: 0}
inventorySnapshotToken: {type: string, minLength: 64, maxLength: 64}
items:
@@ -217,11 +231,14 @@ components:
remark: {type: string, maxLength: 500}
InventorySnapshot:
type: object
required: [taskId, taskVersion, containerNo, warehouseId, capturedAt, snapshotToken, inbounds]
required: [taskId, loadingPlanId, containerBookingId, cabinetNo, taskVersion, warehouseId, capturedAt, snapshotToken, inbounds]
properties:
taskId: {type: integer, format: int64}
loadingPlanId: {type: integer, format: int64}
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string}
taskVersion: {type: integer, minimum: 0}
containerNo: {type: string}
currentContainerNo: {type: string, nullable: true}
warehouseId: {type: integer, format: int64}
capturedAt: {type: string, format: date-time}
snapshotToken: {type: string, minLength: 64, maxLength: 64}
@@ -102,7 +102,10 @@ class LoadingListControllerContractTest {
.doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
assertThat(properties(schemas, "PreloadSummary")).containsKeys("planId", "customers", "skuCount");
assertThat(properties(schemas, "PreloadSummary"))
.containsKeys("planId", "containerBookingId", "customers", "skuCount");
assertThat(properties(schemas, "ConfirmedSummary"))
.containsKeys("planId", "containerBookingId", "loadingListNo");
assertThat(properties(schemas, "LoadingItem")).containsKeys(
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
"destinationWarehouse", "productQuantity", "frozenAt");
@@ -0,0 +1,64 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LoadingListRepositoryTest {
@Mock
private LoadingListQueryMapper mapper;
private LoadingListRepository repository;
@BeforeEach
void setUp() {
repository = new LoadingListRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
}
@Test
void mapsRealContainerBookingIdIntoPreloadAndConfirmedSummaries() {
AggregateRow row = new AggregateRow();
row.setPlanId(41L);
row.setContainerBookingId(61L);
row.setPlanNo("LP-41");
row.setPlanStatus("LOADING");
row.setVersion(1);
Page<AggregateRow> preloadPage = new Page<>(1, 20);
preloadPage.setRecords(List.of(row));
preloadPage.setTotal(1);
when(mapper.selectPreloadPage(any(), any())).thenReturn(preloadPage);
when(mapper.selectCustomers(List.of(41L))).thenReturn(List.of());
Page<AggregateRow> confirmedPage = new Page<>(1, 20);
confirmedPage.setRecords(List.of(row));
confirmedPage.setTotal(1);
when(mapper.selectConfirmedPage(any(), any())).thenReturn(confirmedPage);
var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, 1, 20))
.records().get(0);
var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20))
.records().get(0);
assertThat(preload.planId()).isEqualTo(41L);
assertThat(preload.containerBookingId()).isEqualTo(61L);
assertThat(confirmed.planId()).isEqualTo(41L);
assertThat(confirmed.containerBookingId()).isEqualTo(61L);
}
}
@@ -0,0 +1,67 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'finance-demo-fixtures.mjs'), 'utf8')
test('演示数据只消费本轮固定 marker 和客户 10001', () => {
assert.match(source, /const MARKER = 'FIN-DEMO-20260812'/)
assert.match(source, /fixture\.marker !== runMarker/)
assert.match(source, /fixture\.customerId !== '10001'/)
assert.match(source, /const runMarker = `\$\{MARKER\}-/)
assert.match(source, /禁止借历史来源/)
assert.doesNotMatch(source, /DELETE|cleanup|remove/i)
})
test('覆盖费用、对账单、AR 和 AP 页面分层状态', () => {
for (const value of ['pendingReview', 'approvedUnbilled', 'draft', 'arUnpaid', 'arPartial', 'arPaid',
'apUnpaid', 'apPartial', 'apPaid']) assert.match(source, new RegExp(value))
for (const status of ['DRAFT', 'PENDING_REVIEW', 'ISSUED']) assert.match(source, new RegExp(`'${status}'`))
assert.match(source, /reviewStatus === 'PENDING'/)
assert.match(source, /reviewStatus === 'APPROVED'/)
assert.match(source, /settlementIncluded === true/)
})
test('平台列表默认全量查询不带 customerId,未部署时登记 blocker', () => {
assert.match(source, /\['statements', 'receivable-settlements', 'payable-settlements', 'receipts', 'cost-entries'\]/)
assert.match(source, /context\.request\('GET', `\$\{BASE\}\/\$\{path\}`\)/)
assert.match(source, /finance-unfiltered-list-not-deployed/)
})
test('所有财务写操作使用 marker 幂等键并上传真实凭证', () => {
assert.match(source, /'Idempotency-Key': key\(context, operation\)/)
assert.match(source, /fixture\.voucherFileId/)
assert.match(source, /finance-full-fixtures\.mjs/)
assert.match(source, /context\.createdResources\.financeDemo = matrix/)
assert.match(source, /`source-\$\{role\}-\$\{source\.feeSourceId\}-include`/)
assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-create`/)
})
test('额外应付审核路径先追加 feeId 再追加 accountId 查询参数', () => {
assert.match(source, /`\$\{basePath\}\/\$\{created\.id\}\/approve\?accountId=10001`/)
assert.doesNotMatch(source, /fee-details\?accountId=10001\/\$\{created\.id\}/)
})
test('费用来源使用 operationVersion,应付供应商长整型保持字符串精度', () => {
assert.match(source, /expectedVersion: operationVersion\(current\.value\)/)
assert.match(source, /supplierId: id\(source\.supplierId\)/)
assert.doesNotMatch(source, /supplierId: Number\(source\.supplierId\)/)
})
test('恢复模式只续补固定报告来源并校验 AR AP 金额状态', () => {
assert.match(source, /process\.env\.FINANCE_DEMO_RECOVERY_REPORT/)
assert.match(source, /恢复报告不是 FIN-DEMO-20260812/)
assert.match(source, /拒绝借历史数据/)
assert.match(source, /supplierId: id\(fixture\.supplier\?\.supplierId\)/)
assert.match(source, /const payableIds = \['307', '310'\]/)
assert.match(source, /finance-container-partner-not-deployed/)
assert.match(source, /findPayableBySource/)
assert.match(source, /approved\?\.statementGenerated === true/)
assert.match(source, /finance-settlement-payment-status-not-deployed/)
assert.match(source, /\['arUnpaid', 'UNPAID'\]/)
assert.match(source, /\['arPartial', 'PARTIALLY_PAID'\]/)
assert.match(source, /\['arPaid', 'PAID'\]/)
})
@@ -0,0 +1,360 @@
import { readFile } from 'node:fs/promises'
const MODULE = 'finance-demo-fixtures'
const MARKER = 'FIN-DEMO-20260812'
const BASE = '/api/platform/finance/v1'
const OPTIONS = { scenario: 'finance-demo-layered-data', deploymentBlocker: true }
const id = (value) => value == null ? '' : String(value)
const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : []
const data = (response) => response?.body?.data ?? null
const ok = (response) => response?.http === 200 && String(response?.body?.code) === '200'
const statementId = (value) => id(value?.statementId ?? value?.id)
const settlementId = (value) => id(value?.settlementId ?? value?.id)
const outstanding = (value) => Number(value?.outstandingAmount ?? 0)
const operationVersion = (value) => Number(value?.operationVersion ?? 0)
const version = (value) => Number(value?.version ?? 0)
const key = (context, operation) => `${context.marker}-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
const write = (context, method, path, body, operation) => context.request(method, path, body, context.token,
{ 'Idempotency-Key': key(context, operation) })
function record(context, name, response, condition, evidence) {
return context.assert(MODULE, name, response, condition, evidence, OPTIONS)
}
function blocker(context, name, response, evidence, category) {
context.addStep(MODULE, name, response, false, evidence, OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, name, evidence, category, {
marker: context.marker, http: response?.http ?? null, code: response?.body?.code ?? null,
requestId: response?.requestId ?? null, traceId: response?.traceId ?? null,
})
}
async function waitForSources(context, sourceFeeIds) {
let pool = []
for (let attempt = 0; attempt < 30; attempt += 1) {
const response = await context.request('GET', `${BASE}/fee-pool?customerId=10001&direction=ALL`)
pool = rows(data(response)).concat(rows(data(response)?.items))
const visible = new Set(pool.map((item) => id(item.sourceId)))
if (sourceFeeIds.every((sourceId) => visible.has(sourceId))) return pool
await new Promise((resolve) => setTimeout(resolve, 500))
}
return pool
}
function financeId(poolItem) {
const value = id(poolItem?.poolItemId)
return value.includes(':') ? value.slice(value.indexOf(':') + 1) : id(poolItem?.feeSourceId ?? poolItem?.id)
}
async function sourceDetail(context, feeSourceId) {
const response = await context.request('GET', `${BASE}/fee-sources/${feeSourceId}`)
return { response, value: data(response) }
}
async function approveAndInclude(context, source, role) {
let current = await sourceDetail(context, source.feeSourceId)
if (current.value?.reviewStatus === 'PENDING') {
const response = await write(context, 'POST', `${BASE}/fee-sources/${source.feeSourceId}/review`,
{ action: 'APPROVE', reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-approve`)
current = { response, value: data(response) }
}
if (current.value?.settlementIncluded !== true) {
const response = await write(context, 'PUT', `${BASE}/fee-sources/${source.feeSourceId}/settlement-inclusion`,
{ included: true, reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-include`)
current = { response, value: data(response) }
}
record(context, `${role} 来源已审核并纳入结算`, current.response,
current.value?.reviewStatus === 'APPROVED' && current.value?.settlementIncluded === true
&& current.value?.sourceStatus === 'AVAILABLE',
`feeSourceId=${source.feeSourceId}, review=${current.value?.reviewStatus}, included=${current.value?.settlementIncluded}`)
return current.value
}
async function createExtraPayable(context, fixture, index) {
const itemResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const feeItem = rows(data(itemResponse)).find((item) => item.status === 'ACTIVE' && item.warehousePayable === true)
const inboundId = id(fixture.orders.at(-1)?.inboundId)
const partnerId = id(fixture.supplier?.partnerId ?? fixture.supplier?.supplierId)
if (!feeItem || !inboundId || !partnerId) return null
const body = { feeItemId: Number(feeItem.id), direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10),
unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true,
partnerId: Number(partnerId), partnerName: fixture.supplier?.supplierName || null, remark: `${MARKER}-AP-${index}` }
const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details`
const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`)
const created = data(createdResponse)
if (!ok(createdResponse) || !created?.id) return null
const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=10001`, undefined, `extra-payable-${index}-approve`)
record(context, `创建第 ${index} 条额外应付来源`, approveResponse, ok(approveResponse),
`sourceFeeId=${created.id}, inboundId=${inboundId}, partnerId=${partnerId}`)
return { sourceFeeId: id(created.id), direction: 'PAYABLE', businessDate: body.businessDate,
supplierId: partnerId, supplierName: body.partnerName }
}
async function createStatement(context, source, role, targetStatus) {
const body = { customerId: '10001', currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [source.feeSourceId], periodStart: source.businessDate, periodEnd: source.businessDate,
remark: `${MARKER}-${role}` }
const response = await write(context, 'POST', `${BASE}/statements`, body, `statement-${role}-create`)
let statement = data(response)
if (!ok(response) || !statementId(statement)) return { response, value: null }
for (const [action, status] of [['SUBMIT', 'PENDING_REVIEW'], ['APPROVE', 'APPROVED'], ['ISSUE', 'ISSUED']]) {
if (targetStatus === 'DRAFT' || statement.status === targetStatus) break
const actionResponse = await write(context, 'POST', `${BASE}/reconciliation-statements/${statementId(statement)}/actions`,
{ action, expectedVersion: version(statement) }, `statement-${role}-${action.toLowerCase()}`)
statement = data(actionResponse)
if (!ok(actionResponse) || statement?.status !== status) return { response: actionResponse, value: null }
}
record(context, `${role} 对账单保留 ${targetStatus}`, response, statement?.status === targetStatus,
`statementId=${statementId(statement)}, status=${statement?.status}`)
return { response, value: statement }
}
async function createAr(context, statement, role) {
const response = await write(context, 'POST', `${BASE}/receivable-settlements`,
{ reconciliationStatementId: Number(statementId(statement)), currency: 'CNY' }, `ar-${role}-create`)
const value = data(response)
record(context, `${role} 创建应收结算单`, response, ok(response) && settlementId(value),
`settlementId=${settlementId(value)}, status=${value?.paymentStatus}`)
return value
}
async function receive(context, fixture, settlement, amount, role) {
const response = await write(context, 'POST', `${BASE}/receipts`, {
customerId: 10001, receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY',
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [fixture.voucherFileId], remark: `${MARKER}-${role}`,
allocations: [{ billId: Number(settlement.reconciliationStatementId), amount }],
}, `receipt-${role}`)
const detailResponse = await context.request('GET', `${BASE}/settlements/${settlementId(settlement)}`)
return { response, value: data(detailResponse) }
}
async function createAp(context, fixture, source, role) {
const response = await write(context, 'POST', `${BASE}/payable-settlements`, {
customerId: 10001, supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)],
dueDate: new Date().toISOString().slice(0, 10), currency: 'CNY',
}, `ap-${role}-${source.feeSourceId}-create`)
const value = data(response)
record(context, `${role} 创建应付结算单`, response, ok(response) && settlementId(value),
`settlementId=${settlementId(value)}, status=${value?.paymentStatus}`)
return value
}
async function findPayableBySource(context, feeSourceId) {
const listResponse = await context.request('GET', `${BASE}/payable-settlements?customerId=10001`)
for (const item of rows(data(listResponse))) {
const detailResponse = await context.request('GET', `${BASE}/settlements/${settlementId(item)}`)
const value = data(detailResponse)
if (value?.direction === 'PAYABLE' && value?.lines?.some((line) => id(line.feeSourceId) === id(feeSourceId))) {
return { response: detailResponse, value }
}
}
return { response: listResponse, value: null }
}
function verifySettlementState(context, direction, role, response, value, expectedStatus) {
const remaining = outstanding(value)
const status = value?.paymentStatus ?? value?.status
const amountMatches = role.endsWith('Unpaid') ? remaining > 0 && Number(value?.paidAmount ?? 0) === 0
: role.endsWith('Partial') ? remaining > 0 && Number(value?.paidAmount ?? 0) > 0
: remaining === 0 && Number(value?.paidAmount ?? 0) > 0
const expected = amountMatches && status === expectedStatus
if (expected) record(context, `${role} 金额和状态一致`, response, true,
`settlementId=${settlementId(value)}, paid=${value?.paidAmount}, outstanding=${remaining}, status=${status}`)
else blocker(context, `${role} 金额和状态一致`, response,
`settlementId=${settlementId(value)}, direction=${direction}, paid=${value?.paidAmount}, outstanding=${remaining}, expectedStatus=${expectedStatus}, actualStatus=${status}`,
'finance-settlement-payment-status-not-deployed')
}
async function recoverExistingDemo(context, reportPath) {
const report = JSON.parse(await readFile(reportPath, 'utf8'))
const fixture = report?.createdResources?.financeFullFixtures
const matrix = structuredClone(report?.createdResources?.financeDemo ?? {})
if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER || matrix.customerId !== '10001') {
throw new Error('恢复报告不是 FIN-DEMO-20260812 / 客户 10001 的同一轮 fixture')
}
context.marker = matrix.runMarker
await verifyUnfilteredLists(context)
const containerPayable = await sourceDetail(context, '308')
if (!id(containerPayable.value?.supplierId)) blocker(context, '柜次费用携带供应商快照', containerPayable.response,
`feeSourceId=308, sourceFeeId=${containerPayable.value?.sourceFeeId ?? 'missing'}, supplierId=missing;需部署柜次 partner 新契约后补跑`,
'finance-container-partner-not-deployed')
const payableIds = ['307', '310']
const roles = ['apUnpaid', 'apPartial']
for (let index = 0; index < roles.length; index += 1) {
const role = roles[index]
const detail = await sourceDetail(context, payableIds[index])
const source = detail.value
if (!ok(detail.response) || source?.direction !== 'PAYABLE' || source?.customerId !== '10001'
|| !id(source?.sourceFeeId)) {
blocker(context, `${role} 恢复来源属于本轮 fixture`, detail.response,
`feeSourceId=${payableIds[index]}, sourceFeeId=${source?.sourceFeeId ?? 'missing'}`,
'invalid-finance-demo-recovery-source')
continue
}
const produced = fixture.feeSources.some((item) => id(item.feeSourceId) === id(source.sourceFeeId))
|| report.steps?.some((step) => step.name === `创建第 ${index + 1} 条额外应付来源`
&& step.passed && step.evidence?.includes(`sourceFeeId=${source.sourceFeeId}`))
if (!produced) {
blocker(context, `${role} 恢复来源属于本轮 fixture`, detail.response,
`Finance feeSourceId=${payableIds[index]} 不属于 runMarker=${matrix.runMarker} 的来源,拒绝借历史数据`,
'invalid-finance-demo-recovery-source')
continue
}
const approved = await approveAndInclude(context, { feeSourceId: payableIds[index] }, role)
const existing = approved?.statementGenerated === true ? await findPayableBySource(context, payableIds[index]) : null
const settlement = existing?.value ?? await createAp(context, fixture, {
feeSourceId: payableIds[index], supplierId: id(fixture.supplier?.supplierId),
}, role)
if (existing?.value) record(context, `${role} 回读已有应付结算单`, existing.response, true,
`settlementId=${settlementId(existing.value)}, feeSourceId=${payableIds[index]}`)
if (!settlementId(settlement)) continue
if (role === 'apUnpaid') matrix.payableSettlements[role] = settlement
else {
const amount = Math.max(0.01, Math.floor(outstanding(settlement) * 50) / 100)
matrix.payableSettlements[role] = (await pay(context, fixture, settlement, amount, role)).value
}
verifySettlementState(context, 'PAYABLE', role, detail.response, matrix.payableSettlements[role],
role === 'apUnpaid' ? 'UNPAID' : 'PARTIALLY_PAID')
}
for (const [role, expectedStatus] of [['arUnpaid', 'UNPAID'], ['arPartial', 'PARTIALLY_PAID'], ['arPaid', 'PAID']]) {
const current = matrix.receivableSettlements?.[role]
if (!settlementId(current)) continue
const response = await context.request('GET', `${BASE}/settlements/${settlementId(current)}`)
const value = data(response)
matrix.receivableSettlements[role] = value
verifySettlementState(context, 'RECEIVABLE', role, response, value, expectedStatus)
}
matrix.feeSources.approvedUnbilled = await approveAndInclude(context, { feeSourceId: '300' }, 'approved-unbilled')
context.createdResources.financeFullFixtures = fixture
context.createdResources.financeDemo = matrix
}
async function pay(context, fixture, settlement, amount, role) {
const response = await write(context, 'POST', `${BASE}/settlements/${settlementId(settlement)}/transactions`, {
transactionDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', method: 'BANK_TRANSFER',
referenceNo: `${MARKER}-${role}`, voucherFileIds: [fixture.voucherFileId],
expectedVersion: version(settlement), remark: `${MARKER}-${role}`,
}, `payment-${role}`)
return { response, value: data(response) }
}
async function verifyUnfilteredLists(context) {
for (const path of ['statements', 'receivable-settlements', 'payable-settlements', 'receipts', 'cost-entries']) {
const response = await context.request('GET', `${BASE}/${path}`)
if (!ok(response)) blocker(context, `平台 ${path} 默认查询全部客户`, response,
`GET ${BASE}/${path} 未部署无 customerId 全量查询;HTTP=${response.http}, code=${response.body?.code ?? 'missing'}, requestId=${response.requestId ?? 'null'}, traceId=${response.traceId ?? 'null'}`,
'finance-unfiltered-list-not-deployed')
else record(context, `平台 ${path} 默认查询全部客户`, response, Array.isArray(data(response)) || Array.isArray(data(response)?.records),
`records=${rows(data(response)).length}`)
}
}
export async function run(context) {
const start = context.steps.length
const recoveryReport = process.env.FINANCE_DEMO_RECOVERY_REPORT
if (recoveryReport) {
await recoverExistingDemo(context, recoveryReport)
const steps = context.steps.slice(start)
context.scenarios.push({ name: OPTIONS.scenario, module: MODULE,
success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker),
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
return
}
const runMarker = `${MARKER}-${new Date().toISOString().replace(/\D/g, '').slice(8, 14)}`
context.marker = runMarker
await verifyUnfilteredLists(context)
const producer = await import('./finance-full-fixtures.mjs')
await producer.run(context)
const fixture = context.createdResources.financeFullFixtures
if (!fixture || fixture.marker !== runMarker || fixture.customerId !== '10001') {
blocker(context, '取得演示企业真实 Finance fixture', null,
'finance-full-fixtures 未返回 marker=FIN-DEMO-20260812、customerId=10001 的独立资源,禁止借历史来源',
'missing-finance-demo-fixture')
return
}
const extraPayables = []
for (let index = 1; index <= 2; index += 1) {
const value = await createExtraPayable(context, fixture, index)
if (value) extraPayables.push(value)
}
const produced = [...fixture.feeSources.map((item) => ({ ...item, sourceFeeId: id(item.feeSourceId) })), ...extraPayables]
const pool = await waitForSources(context, produced.map((item) => item.sourceFeeId).filter(Boolean))
const poolBySource = new Map(pool.map((item) => [id(item.sourceId), item]))
const sources = produced.map((source) => ({ ...source,
feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker,
supplierId: id(source.supplierId ?? source.partnerId ?? fixture.supplier?.supplierId) }))
.filter((source) => source.feeSourceId)
const receivables = sources.filter((source) => source.direction === 'RECEIVABLE')
const payables = sources.filter((source) => source.direction === 'PAYABLE' && source.supplierId)
if (receivables.length < 7 || payables.length < 3) {
blocker(context, '分层状态来源数量门禁', null,
`需要至少 7 条本轮应收和 3 条本轮应付;actual AR sources=${receivables.length}, AP sources=${payables.length}`,
'insufficient-demo-sources')
return
}
const matrix = { marker: MARKER, runMarker, customerId: '10001', customerLabel: '一期演示企业 10001',
feeSources: {}, statements: {}, receivableSettlements: {}, payableSettlements: {},
voucherFileId: id(fixture.voucherFileId), container: fixture.container, allocations: fixture.allocations }
const pending = await sourceDetail(context, receivables[0].feeSourceId)
record(context, '保留费用待审核', pending.response,
pending.value?.reviewStatus === 'PENDING' && pending.value?.settlementIncluded === false,
`feeSourceId=${receivables[0].feeSourceId}, review=${pending.value?.reviewStatus}`)
matrix.feeSources.pendingReview = pending.value
matrix.feeSources.approvedUnbilled = await approveAndInclude(context, receivables[1], 'approved-unbilled')
const statementRoles = [
['draft', 'DRAFT'], ['pendingReview', 'PENDING_REVIEW'], ['arUnpaid', 'ISSUED'],
['arPartial', 'ISSUED'], ['arPaid', 'ISSUED'],
]
for (let index = 0; index < statementRoles.length; index += 1) {
const [role, status] = statementRoles[index]
await approveAndInclude(context, receivables[index + 2], role)
const statement = await createStatement(context, receivables[index + 2], role, status)
if (statement.value) matrix.statements[role] = statement.value
}
for (const role of ['arUnpaid', 'arPartial', 'arPaid']) {
const statement = matrix.statements[role]
if (!statement) continue
const settlement = await createAr(context, statement, role)
if (!settlement) continue
if (role === 'arUnpaid') matrix.receivableSettlements[role] = settlement
if (role === 'arPartial') {
const amount = Math.max(0.01, Math.floor(outstanding(settlement) * 50) / 100)
matrix.receivableSettlements[role] = (await receive(context, fixture, settlement, amount, role)).value
}
if (role === 'arPaid') matrix.receivableSettlements[role] = (await receive(context, fixture, settlement,
outstanding(settlement), role)).value
verifySettlementState(context, 'RECEIVABLE', role, settlement, matrix.receivableSettlements[role],
role === 'arUnpaid' ? 'UNPAID' : role === 'arPartial' ? 'PARTIALLY_PAID' : 'PAID')
}
for (const [index, role] of ['apUnpaid', 'apPartial', 'apPaid'].entries()) {
await approveAndInclude(context, payables[index], role)
const settlement = await createAp(context, fixture, payables[index], role)
if (!settlement) continue
if (role === 'apUnpaid') matrix.payableSettlements[role] = settlement
if (role === 'apPartial') {
const amount = Math.max(0.01, Math.floor(outstanding(settlement) * 50) / 100)
matrix.payableSettlements[role] = (await pay(context, fixture, settlement, amount, role)).value
}
if (role === 'apPaid') matrix.payableSettlements[role] = (await pay(context, fixture, settlement,
outstanding(settlement), role)).value
verifySettlementState(context, 'PAYABLE', role, settlement, matrix.payableSettlements[role],
role === 'apUnpaid' ? 'UNPAID' : role === 'apPartial' ? 'PARTIALLY_PAID' : 'PAID')
}
context.createdResources.financeDemo = matrix
const steps = context.steps.slice(start)
context.scenarios.push({ name: OPTIONS.scenario, module: MODULE,
success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker),
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
}