优化
This commit is contained in:
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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;
|
||||
|
||||
+21
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+15
@@ -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},
|
||||
|
||||
+15
@@ -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; }
|
||||
|
||||
+85
-10
@@ -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}
|
||||
|
||||
+4
-1
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user