fix phase3 phase4 development acceptance blockers

This commit is contained in:
2026-08-24 10:33:05 +08:00
parent 81899015cf
commit ccfd024aef
13 changed files with 112 additions and 28 deletions
@@ -10626,6 +10626,37 @@ DELIMITER ;
CALL migrate_carrier_schedule_retry_columns();
DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns;
-- CREATE TABLE IF NOT EXISTS 不会替换历史 CHECK 与唯一索引。统一收敛到当前
-- READY/MANUAL_REQUIRED/STOPPED、五种访问方式、十个实际节点和每业务范围唯一任务。
ALTER TABLE fl_carrier_schedule_query
DROP CHECK chk_carrier_schedule_query_access,
DROP CHECK chk_carrier_schedule_query_status;
DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check;
DELIMITER $$
CREATE PROCEDURE drop_legacy_carrier_schedule_node_check()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query'
AND constraint_name='chk_carrier_schedule_query_node' AND constraint_type='CHECK') THEN
ALTER TABLE fl_carrier_schedule_query DROP CHECK chk_carrier_schedule_query_node;
END IF;
END$$
DELIMITER ;
CALL drop_legacy_carrier_schedule_node_check();
DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check;
ALTER TABLE fl_carrier_schedule_query
ADD CONSTRAINT chk_carrier_schedule_query_access CHECK
(access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')),
ADD CONSTRAINT chk_carrier_schedule_query_status CHECK
(status IN ('READY','MANUAL_REQUIRED','STOPPED')),
ADD CONSTRAINT chk_carrier_schedule_query_node CHECK
(latest_standard_node IS NULL OR latest_standard_node IN
('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT',
'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
DROP INDEX uk_carrier_schedule_query_scope,
ADD UNIQUE KEY uk_carrier_schedule_query_scope
(customer_id,ocean_booking_id,container_scope_id);
SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00');
SET @carrier_schedule_next = CASE
WHEN TIME(@carrier_schedule_now) < '09:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 9 HOUR
@@ -187,6 +187,23 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='本土散板操作流水';
-- 尾程主订单状态历史补齐客户隔离键。代码写入 customer_id,已有表需显式升级;
-- 先允许 NULL 以便从主订单回填,再收紧为 NOT NULL,脚本可重复执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_loading_order_status_history' AND column_name='customer_id'), 'SELECT 1',
'ALTER TABLE ll_loading_order_status_history ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id'));
PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt;
UPDATE ll_loading_order_status_history history
JOIN ll_order orders ON orders.id=history.order_id
SET history.customer_id=orders.customer_id
WHERE history.customer_id IS NULL;
ALTER TABLE ll_loading_order_status_history
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_loading_order_status_history' AND index_name='idx_ll_loading_history_customer_order'), 'SELECT 1',
'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)'));
PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt;
-- 四期平台排车查询、智能预排与草稿锁;不开放编辑、发车、PDA 出库或派送。
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -1,5 +1,5 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import org.apache.ibatis.annotations.AutomapConstructor; import java.math.BigDecimal; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId,
@@ -7,8 +7,10 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm) {}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, 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 unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) {
@AutomapConstructor
public Candidate { }
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 unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) {
@@ -74,6 +74,15 @@ class CarrierScheduleQuerySqlContractTest {
.contains("ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0")
.contains("ADD COLUMN next_allowed_at DATETIME(3) NULL")
.contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)")
.contains("DROP CHECK chk_carrier_schedule_query_access")
.contains("DROP CHECK chk_carrier_schedule_query_status")
.contains("DROP INDEX uk_carrier_schedule_query_scope")
.contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope")
.contains("(customer_id,ocean_booking_id,container_scope_id)")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')")
.contains("ARRIVED_DESTINATION_PORT")
.contains("EMPTY_RETURNED")
.contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')")
.contains("SET @carrier_schedule_next = CASE")
.contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()")
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import org.apache.ibatis.annotations.AutomapConstructor;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
@@ -9,6 +11,18 @@ import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class SmartLoadingCandidateMapperContractTest {
@Test
void canonicalCandidateConstructorIsTheUniqueMybatisAutomapTarget() {
long annotated = java.util.Arrays.stream(Candidate.class.getDeclaredConstructors())
.filter(constructor -> constructor.isAnnotationPresent(AutomapConstructor.class))
.count();
assertThat(annotated).isEqualTo(1);
assertThat(java.util.Arrays.stream(Candidate.class.getDeclaredConstructors())
.filter(constructor -> constructor.isAnnotationPresent(AutomapConstructor.class))
.findFirst().orElseThrow().getParameterCount()).isEqualTo(18);
}
@Test
void selectsLatestApprovedBatch() throws Exception {
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", long.class);
@@ -104,6 +104,7 @@ CREATE TABLE IF NOT EXISTS ll_order_detail (
CREATE TABLE IF NOT EXISTS ll_loading_order_status_history (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单 ID',
from_status VARCHAR(32) COMMENT '变更前状态',
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态',
@@ -111,7 +112,7 @@ CREATE TABLE IF NOT EXISTS ll_loading_order_status_history (
remark VARCHAR(500) COMMENT '状态变更备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间',
PRIMARY KEY(id),
KEY idx_ll_loading_history_order(order_id,created_at)
KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单状态变更历史';
CREATE TABLE IF NOT EXISTS ll_event_conflict (
@@ -19,5 +19,7 @@ class LocalLooseboardSchemaContractTest {
assertTrue(sql.contains("uk_ll_local_looseboard_no(customer_id,order_no)"));
assertTrue(sql.contains("lastleg_order_id BIGINT NULL"));
assertTrue(sql.contains("uk_ll_local_looseboard_lastleg(lastleg_order_id)"));
assertTrue(sql.contains("customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'"));
assertTrue(sql.contains("idx_ll_loading_history_customer_order(customer_id,order_id,created_at)"));
}
}
@@ -38,7 +38,8 @@ test('Firstleg booking supplier snapshot requires ACTIVE BOOKING and has no dome
test('HTTP ocean booking acceptance selects an ACTIVE BOOKING supplier without a geographic scope', () => {
assert.match(oceanBookingFixture, /category=BOOKING&status=ACTIVE/)
assert.match(oceanBookingFixture, /bookingAgentId: bookingAgent\.supplierId/)
assert.match(oceanBookingFixture, /订舱持久化启用订舱供应商快照/)
assert.match(oceanBookingFixture, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/)
assert.match(oceanBookingFixture, /shippingCompanyId: id\(agent\.id\), shippingCompanyName: agent\.name/)
assert.match(oceanBookingFixture, /使用 ACTIVE BOOKING 订舱代理事实源/)
assert.doesNotMatch(oceanBookingFixture, /category=BOOKING[^'\n]*scope=/)
})
@@ -475,8 +475,8 @@ export async function run(context) {
const confirmedSummary = confirmedNetSummary(afterConfirm.workspace)
context.assert(MODULE, '货物总重量按全部已确认净重由服务端汇总', afterConfirm.response,
confirmedSummary.complete && confirmedSummary.count === baselineImageSummary.count + 1
&& closeNumber(afterConfirm.workspace?.actualGrossWeightKg, confirmedSummary.total, 0.0011),
`confirmedCount=${confirmedSummary.count}, confirmedNetTotal=${confirmedSummary.total}, actual=${afterConfirm.workspace?.actualGrossWeightKg ?? 'missing'}`,
&& closeNumber(afterConfirm.workspace?.actualNetWeightKg, confirmedSummary.total, 0.0011),
`confirmedCount=${confirmedSummary.count}, confirmedNetTotal=${confirmedSummary.total}, actual=${afterConfirm.workspace?.actualNetWeightKg ?? 'missing'}`,
NEW_CONTRACT_OPTIONS)
const staleDelete = await context.request('DELETE',
@@ -47,7 +47,10 @@ export async function run(context) {
step(context, '使用 ACTIVE BOOKING 订舱代理事实源', agentResponse, Boolean(agent?.id && agent?.name),
`supplierId=${id(agent?.id) || 'missing'}, scope=${agent?.scope || 'not-exposed'}`)
if (!agent?.id || !agent?.name) return
const data = fullDraft(context, suffix, master, { bookingAgentId: id(agent.id), bookingAgentName: agent.name })
const data = fullDraft(context, suffix, master, {
bookingAgentId: id(agent.id), bookingAgentName: agent.name,
shippingCompanyId: id(agent.id), shippingCompanyName: agent.name,
})
const create = await request(context, 'POST', BASE, { expectedVersion: 0, data },
{ 'Idempotency-Key': `${context.marker}-ocean-draft-create`.slice(0, 128) })
const created = payload(create); const bookingId = id(created?.oceanBooking?.id)
@@ -36,12 +36,12 @@ test('状态矩阵复用订单共享的逐仓真实报价选择,不假设首
test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT', () => {
assert.match(source, /result\.inbounds\.WAIT_MEASURE/)
assert.match(source, /processStatus: 'WAIT_MEASURE'/)
for (const status of ['MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_SPLIT']) {
for (const status of ['MEASURING', 'MEASURE_PENDING', 'WAIT_SPLIT']) {
assert.match(source, new RegExp(`'${status}'`))
}
assert.match(source, /\.\/inbound-measurement\.mjs/)
assert.match(source, /`\/api\/platform\/v1\/inbounds\/\$\{query\(inboundId\)\}\/measurements\/reject\?tenantId=\$\{query\(tenantId\)\}`/)
assert.doesNotMatch(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/)
assert.match(source, /入库列表保留 MEASURE_REJECTED/)
assert.match(source, /稳定 Admin 契约未开放 measurements\/reject/)
})
test('FBA 状态样本为订单头和每条货物行提供四个必填标识', () => {
@@ -60,9 +60,7 @@ test('测量状态截停发生在正确的 HTTP 边界', () => {
assert.match(source, /target !== 'MEASURING'/)
assert.doesNotMatch(source, /target === 'MEASURING' && ok\(response\)/)
assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/)
assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/)
assert.match(source, /measurements\/reject/)
assert.match(source, /expectedVersion: version/)
assert.doesNotMatch(source, /target === 'MEASURE_REJECTED' && isSubmit/)
})
test('入库不可独立保留状态显式阻断', () => {
@@ -191,18 +191,6 @@ async function prepareInboundStatus(context, target) {
if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`)
throw stop
}
if (target === 'MEASURE_REJECTED' && isSubmit) {
const submitted = await originalRequest.call(context, method, path, body, token, headers)
if (!ok(submitted)) throw new Error(`MEASURE_REJECTED 提交失败: HTTP=${submitted?.http}, code=${submitted?.body?.code}`)
const latest = await originalRequest.call(context, 'GET', detailPath, undefined, token)
const version = Number(data(latest)?.summary?.version)
const rejectPath = `/api/platform/v1/inbounds/${query(inboundId)}/measurements/reject?tenantId=${query(tenantId)}`
const rejected = await originalRequest.call(context, 'POST', rejectPath,
{ measurementId: body?.measurementId, expectedVersion: version, reason: `${fixtureMarker} 保留审核驳回样本` },
token, { 'Idempotency-Key': `${fixtureMarker}-reject`.slice(0, 128) })
if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}, requestId=${rejected?.requestId || 'missing'}, traceId=${rejected?.traceId || 'missing'}`)
throw stop
}
return originalRequest.call(context, method, path, body, token, headers)
}
if (target !== 'MEASURING') {
@@ -303,7 +291,7 @@ export async function run(context, matrix = context.createdResources.manualState
id: waitMeasure.orderId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`,
}
for (const status of ['MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_SPLIT']) {
for (const status of ['MEASURING', 'MEASURE_PENDING', 'WAIT_SPLIT']) {
const sample = await prepareInboundStatus(context, status)
result.inbounds[status] = sample
matrix.pages.inbounds.fixtures[status] = {
@@ -317,6 +305,9 @@ export async function run(context, matrix = context.createdResources.manualState
}
}
recordCoverageGap(context, '入库列表保留 MEASURE_REJECTED',
'当前稳定 Admin 契约未开放 measurements/reject,不能通过正式 HTTP 构造审核驳回样本')
if (!result.inbounds.WAIT_SPLIT) {
const legacyResponse = await context.request('GET',
`/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`)
+16 -1
View File
@@ -484,6 +484,7 @@ CREATE TABLE IF NOT EXISTS ll_order_detail (
CREATE TABLE IF NOT EXISTS ll_loading_order_status_history (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
from_status VARCHAR(32) NULL COMMENT '变更前状态',
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态',
@@ -491,7 +492,7 @@ CREATE TABLE IF NOT EXISTS ll_loading_order_status_history (
remark VARCHAR(500) NULL COMMENT '状态变更备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间',
PRIMARY KEY (id),
KEY idx_ll_loading_history_order (order_id,created_at)
KEY idx_ll_loading_history_customer_order (customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单状态变更历史';
-- Existing order installations: add transfer fields without replacing row data.
@@ -3685,6 +3686,20 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
AND table_name='ll_local_looseboard_order' AND index_name='uk_ll_local_looseboard_lastleg'), 'SELECT 1',
'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)'));
PREPARE phase4_looseboard_tail_030 FROM @ddl; EXECUTE phase4_looseboard_tail_030; DEALLOCATE PREPARE phase4_looseboard_tail_030;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_loading_order_status_history' AND column_name='customer_id'), 'SELECT 1',
'ALTER TABLE ll_loading_order_status_history ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id'));
PREPARE phase4_loading_history_customer_030 FROM @ddl; EXECUTE phase4_loading_history_customer_030; DEALLOCATE PREPARE phase4_loading_history_customer_030;
UPDATE ll_loading_order_status_history history
JOIN ll_order orders ON orders.id=history.order_id
SET history.customer_id=orders.customer_id
WHERE history.customer_id IS NULL;
ALTER TABLE ll_loading_order_status_history
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_loading_order_status_history' AND index_name='idx_ll_loading_history_customer_order'), 'SELECT 1',
'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)'));
PREPARE phase4_loading_history_customer_030 FROM @ddl; EXECUTE phase4_loading_history_customer_030; DEALLOCATE PREPARE phase4_loading_history_customer_030;
-- ============================================================================
-- 2026-08-20 / 029