This commit is contained in:
2026-09-15 14:18:18 +08:00
parent 6a2075fe4b
commit a474ca4d93
35 changed files with 751 additions and 228 deletions
@@ -19218,7 +19218,7 @@ USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照',
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -19226,9 +19226,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id),
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL)
AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
id BIGINT UNSIGNED NOT NULL COMMENT '邮件任务ID',
@@ -19264,7 +19262,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN'))
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID',
@@ -19277,17 +19275,19 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作';
-- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_terminal_drayage' AND column_name='actual_pickup_at'),
'ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at', 'SELECT 1'));
PREPARE terminal_drayage_drop_manual_pickup_stmt FROM @ddl;
EXECUTE terminal_drayage_drop_manual_pickup_stmt;
DEALLOCATE PREPARE terminal_drayage_drop_manual_pickup_stmt;
ALTER TABLE fl_terminal_drayage
DROP CHECK chk_terminal_drayage_times,
ADD CONSTRAINT chk_terminal_drayage_times CHECK(
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
(actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)),
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID',
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
MODIFY COLUMN fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照',
MODIFY COLUMN pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
MODIFY COLUMN actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间',
MODIFY COLUMN actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
MODIFY COLUMN version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号',
@@ -19301,7 +19301,7 @@ ALTER TABLE fl_terminal_drayage
ALTER TABLE fl_terminal_drayage_history
DROP CHECK chk_terminal_drayage_history_action,
ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')),
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')),
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照',
@@ -19365,7 +19365,6 @@ BEGIN
OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit')
OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit')
OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit')
OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm')
OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm')
) OR EXISTS (
SELECT 1 FROM iam_permission
@@ -19376,7 +19375,6 @@ BEGIN
OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201)
OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202)
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203)
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204)
OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突';
@@ -19400,8 +19398,6 @@ BEGIN
'维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0),
(5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0),
(5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm',
'确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0),
(5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm',
'确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE
@@ -19434,7 +19430,6 @@ BEGIN
'platform:firstleg:terminal-drayage:document:edit',
'platform:firstleg:terminal-drayage:fleet:edit',
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'platform:firstleg:terminal-drayage:pickup:confirm',
'platform:firstleg:terminal-drayage:empty-return:confirm')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
@@ -19451,8 +19446,7 @@ BEGIN
'platform:firstleg:terminal-drayage:document:edit',
'platform:firstleg:terminal-drayage:fleet:edit',
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'platform:firstleg:terminal-drayage:pickup:confirm',
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整';
END IF;
@@ -19472,8 +19466,7 @@ BEGIN
'platform:firstleg:terminal-drayage:document:edit',
'platform:firstleg:terminal-drayage:fleet:edit',
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'platform:firstleg:terminal-drayage:pickup:confirm',
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整';
END IF;
@@ -122,20 +122,6 @@ public class PlatformTerminalDrayageController {
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@PostMapping("/{containerBookingId}/pickup")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup:confirm')")
@Operation(summary = "确认目的港实际提柜")
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER",
resourceId = "#containerBookingId", description = "确认目的港实际提柜")
public ApiResponse<View> recordPickup(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActualTimeRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.authorize(service.recordPickup(
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@PostMapping("/{containerBookingId}/empty-return")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:empty-return:confirm')")
@Operation(summary = "确认实际还空柜")
@@ -17,7 +17,6 @@ public class TerminalDrayageEntity {
private Long fleetSupplierId;
private String fleetSupplierName;
private LocalDateTime pickupAppointmentAt;
private LocalDateTime actualPickupAt;
private LocalDateTime estimatedEmptyReturnAt;
private LocalDateTime actualEmptyReturnAt;
private Integer version;
@@ -69,7 +69,7 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
@Update("""
UPDATE fl_terminal_drayage SET fleet_supplier_id=#{value.fleetSupplierId},
fleet_supplier_name=#{value.fleetSupplierName},pickup_appointment_at=#{value.pickupAppointmentAt},
actual_pickup_at=#{value.actualPickupAt},estimated_empty_return_at=#{value.estimatedEmptyReturnAt},
estimated_empty_return_at=#{value.estimatedEmptyReturnAt},
actual_empty_return_at=#{value.actualEmptyReturnAt},updated_by=#{operatorId},
updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}
@@ -135,35 +135,6 @@ public class TerminalDrayageService {
request.reason(), operatorId, operatorName);
}
@Transactional
public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key,
ActualTimeRequest request) {
String action = "RECORD_PICKUP";
WorkContext context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜");
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
requireVersion(value, request.expectedVersion());
if (value.getFleetSupplierId() == null || value.getPickupAppointmentAt() == null) {
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
}
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
if (context.facts() != null && context.facts().latestActualAt() != null
&& request.actualAt().isBefore(context.facts().latestActualAt())) {
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
}
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
request.remark(), operatorId, operatorName);
int fromVersion = version(value);
String fromStatus = status(context, value);
value.setActualPickupAt(request.actualAt());
applyAuthoritativeDeadline(context, value);
persist(value, fromVersion, operatorId, operatorName);
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
fromVersion, request.remark(), operatorId, operatorName);
}
@Transactional
public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key,
ActualTimeRequest request) {
@@ -297,9 +268,6 @@ public class TerminalDrayageService {
result.add("SELECT_FLEET");
if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP");
}
if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence()
&& value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null
&& actualPickupAt(context, value) == null) result.add("RECORD_PICKUP");
if (context.base().unloadingCompletedAt() != null
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
return List.copyOf(result);
@@ -333,8 +301,8 @@ public class TerminalDrayageService {
}
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt()
: context.facts() == null ? null : context.facts().pickedUpAt();
if (context.facts() != null && context.facts().pickedUpAt() != null) return context.facts().pickedUpAt();
return context.base().unloadingCompletedAt();
}
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
@@ -400,7 +368,6 @@ public class TerminalDrayageService {
case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SEND_PICKUP_EMAIL" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit";
case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm";
case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm";
default -> "";
};
@@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照',
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间',
actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号',
@@ -20,10 +19,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
PRIMARY KEY(id),
UNIQUE KEY uk_terminal_drayage_container(container_booking_id),
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_drayage_times CHECK(
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
(actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
@@ -54,7 +50,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照',
action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_PICKUP/RECORD_EMPTY_RETURN',
action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_EMPTY_RETURN',
from_status VARCHAR(32) NOT NULL COMMENT '操作前状态',
to_status VARCHAR(32) NOT NULL COMMENT '操作后状态',
from_version INT UNSIGNED NOT NULL COMMENT '操作前版本',
@@ -67,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN'))
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
@@ -2143,7 +2143,7 @@ components:
allowedActions:
type: array
description: 已同时按真实业务状态与当前用户权限裁剪
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'}
ContainerWorkbenchStatusSummary:
@@ -2185,12 +2185,12 @@ components:
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
fleetSupplierName: {type: string, nullable: true}
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
actualPickupAt: {type: string, format: date-time, nullable: true}
actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间}
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: unloadingCompletedAt加72小时}
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {type: object, nullable: true}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
@@ -38,13 +38,6 @@ paths:
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:
post:
summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点
x-permission: platform:firstleg:terminal-drayage:pickup:confirm
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
post:
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
@@ -153,12 +146,12 @@ components:
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
fleetSupplierName: {type: string, nullable: true}
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
actualPickupAt: {type: string, format: date-time, nullable: true}
actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间}
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时}
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
updatedAt: {type: string, format: date-time, nullable: true}
ContainerActualFacts:
@@ -181,7 +174,7 @@ components:
type: object
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
properties:
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
fromStatus: {type: string}
toStatus: {type: string}
fromVersion: {type: integer, minimum: 0}
@@ -33,7 +33,6 @@ class TerminalDrayageOpenApiContractTest {
assertPermission("retryEmail", "platform:firstleg:terminal-drayage:fleet:edit");
assertPermission("emails", "platform:firstleg:terminal-drayage:list");
assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit");
assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm");
assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm");
}
@@ -43,18 +42,18 @@ class TerminalDrayageOpenApiContractTest {
assertThat(yaml).contains(
"/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:",
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:",
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
"/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:",
"platform:firstleg:terminal-drayage:list",
"platform:firstleg:terminal-drayage:fleet:edit",
"platform:firstleg:terminal-drayage:pickup-appointment:edit",
"platform:firstleg:terminal-drayage:pickup:confirm",
"platform:firstleg:terminal-drayage:empty-return:confirm",
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
assertThat(yaml).doesNotContain("/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
"platform:firstleg:terminal-drayage:pickup:confirm", "RECORD_PICKUP");
}
@Test
@@ -26,24 +26,19 @@ class TerminalDrayageSqlContractTest {
assertThat(Files.readString(path)).contains(
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (",
"CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))",
"('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')");
"('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')");
}
}
@Test
void bothSqlEntrypointsAllowReturnWithoutPickupAndRetainKnownTimeOrdering() throws Exception {
for (Path path : java.util.List.of(
Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"),
Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) {
String sql = Files.readString(path);
assertThat(sql).contains("actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at")
.doesNotContain("actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL");
}
void bothSqlEntrypointsRemoveTheManualPickupTimestamp() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
assertThat(baseline).doesNotContain("actual_pickup_at", "chk_terminal_drayage_times");
String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertThat(release).contains("DROP CHECK chk_terminal_drayage_times",
"ADD CONSTRAINT chk_terminal_drayage_times CHECK(",
assertThat(release).contains("ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at",
"DROP CHECK chk_terminal_drayage_history_action",
"ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN");
assertThat(release).doesNotContain("chk_terminal_drayage_times");
}
@Test
@@ -142,7 +142,6 @@ class ContainerWorkbenchServiceTest {
row.setId(51L);
row.setContainerBookingId(11L);
row.setVersion(3);
row.setActualPickupAt(LocalDateTime.of(2026, 9, 4, 9, 0));
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
when(repository.find(11L)).thenReturn(container);
when(repository.departed(11L)).thenReturn(true);
@@ -205,9 +204,9 @@ class ContainerWorkbenchServiceTest {
assertThat(result.workbenchStage()).isEqualTo("UNLOADED");
assertThat(result.terminalDrayage().status()).isEqualTo("UNLOADED");
assertThat(result.terminalDrayage().version()).isZero();
assertThat(result.terminalDrayage().actualPickupAt()).isNull();
assertThat(result.allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN");
assertThat(result.terminalDrayage().allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN");
assertThat(result.terminalDrayage().actualPickupAt()).isEqualTo(unloadedAt);
assertThat(result.allowedActions()).containsExactly("RECORD_EMPTY_RETURN");
assertThat(result.terminalDrayage().allowedActions()).containsExactly("RECORD_EMPTY_RETURN");
}
verify(mapper, never()).insert(any(com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity.class));
}
@@ -104,40 +104,12 @@ class TerminalDrayageServiceTest {
verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
}
@Test
void recordsPickupOnceAndAdvancesCanonicalNode() {
Context before = context("PICKUP_READY", null);
Context after = context("PICKED_UP", null);
when(mapper.lockContext(11L)).thenReturn(before);
when(mapper.context(11L)).thenReturn(after);
when(mapper.operation(11L, "pickup-1")).thenReturn(null);
TerminalDrayageEntity row = row(2);
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
when(mapper.lock(11L)).thenReturn(row);
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
row.setVersion(3);
return 1;
});
when(mapper.find(11L)).thenReturn(row);
var result = service.recordPickup(11L, 7L, "admin", "pickup-1",
new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜"));
assertThat(result.status()).isEqualTo("PICKED_UP");
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
}
@Test
void requiresOverseasUnloadBeforeEmptyReturn() {
Context context = context("PICKED_UP", null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.operation(11L, "return-1")).thenReturn(null);
TerminalDrayageEntity row = row(3);
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
when(mapper.lock(11L)).thenReturn(row);
assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1",
@@ -152,7 +124,6 @@ class TerminalDrayageServiceTest {
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0);
Context context = context("PICKED_UP", unloadedAt);
TerminalDrayageEntity row = row(3);
row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0));
when(mapper.context(11L)).thenReturn(context);
when(mapper.find(11L)).thenReturn(row);
@@ -178,7 +149,7 @@ class TerminalDrayageServiceTest {
assertThat(result.status()).isEqualTo("UNLOADED");
assertThat(result.allowedActions()).contains("RECORD_EMPTY_RETURN");
assertThat(result.actualPickupAt()).isNull();
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0));
assertThat(result.version()).isZero();
}
@@ -206,7 +177,7 @@ class TerminalDrayageServiceTest {
var result = service.recordEmptyReturn(11L, 7L, "admin", "return-new", request);
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
assertThat(result.actualPickupAt()).isNull();
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0));
assertThat(result.fleetSupplierId()).isNull();
assertThat(result.version()).isEqualTo(1);
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
@@ -229,12 +200,12 @@ class TerminalDrayageServiceTest {
void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() {
Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0));
TerminalDrayageEntity row = row(3);
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
when(mapper.context(11L)).thenReturn(context);
when(mapper.find(11L)).thenReturn(row);
var raw = service.detail(11L);
assertThat(raw.status()).isEqualTo("UNLOADED");
assertThat(raw.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 8, 0));
assertThat(service.authorize(raw,
Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions())
.containsExactly("RECORD_EMPTY_RETURN");
@@ -265,22 +236,6 @@ class TerminalDrayageServiceTest {
verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L));
}
@Test
void allowedActionsAreReducedByFrozenPermissionMatrix() {
Context context = context("PICKUP_READY", null);
TerminalDrayageEntity row = row(3);
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
when(mapper.context(11L)).thenReturn(context);
when(mapper.find(11L)).thenReturn(row);
var raw = service.detail(11L);
var authorized = service.authorize(raw,
Set.of("platform:firstleg:terminal-drayage:pickup:confirm"));
assertThat(raw.allowedActions()).contains("SELECT_FLEET", "SCHEDULE_PICKUP", "RECORD_PICKUP");
assertThat(authorized.allowedActions()).containsExactly("RECORD_PICKUP");
}
@Test
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
Context context = context("ARRIVED_DESTINATION_PORT", null);
@@ -337,7 +292,6 @@ class TerminalDrayageServiceTest {
when(mapper.operation(11L, "return-2")).thenReturn(null);
TerminalDrayageEntity row = row(3);
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
when(mapper.lock(11L)).thenReturn(row);
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
row.setVersion(4);
@@ -15,7 +15,6 @@ BEGIN
OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit')
OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit')
OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit')
OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm')
OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm')
) OR EXISTS (
SELECT 1 FROM iam_permission
@@ -26,7 +25,6 @@ BEGIN
OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201)
OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202)
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203)
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204)
OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突';
@@ -50,8 +48,6 @@ BEGIN
'维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0),
(5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0),
(5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm',
'确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0),
(5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm',
'确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE
@@ -84,7 +80,6 @@ BEGIN
'platform:firstleg:terminal-drayage:document:edit',
'platform:firstleg:terminal-drayage:fleet:edit',
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'platform:firstleg:terminal-drayage:pickup:confirm',
'platform:firstleg:terminal-drayage:empty-return:confirm')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
@@ -101,8 +96,7 @@ BEGIN
'platform:firstleg:terminal-drayage:document:edit',
'platform:firstleg:terminal-drayage:fleet:edit',
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'platform:firstleg:terminal-drayage:pickup:confirm',
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整';
END IF;
@@ -122,8 +116,7 @@ BEGIN
'platform:firstleg:terminal-drayage:document:edit',
'platform:firstleg:terminal-drayage:fleet:edit',
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'platform:firstleg:terminal-drayage:pickup:confirm',
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整';
END IF;
+1 -1
View File
@@ -124,7 +124,7 @@ paths:
get:
tags: [IAM Resources / IAM 资源]
summary: 查询当前用户有效权限
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。
responses:
'200':
description: 当前平台成员权限查询成功
@@ -42,7 +42,6 @@ class IamOpenApiContractTest {
.contains("platform:firstleg:terminal-drayage:document:edit")
.contains("platform:firstleg:terminal-drayage:fleet:edit")
.contains("platform:firstleg:terminal-drayage:pickup-appointment:edit")
.contains("platform:firstleg:terminal-drayage:pickup:confirm")
.contains("platform:firstleg:terminal-drayage:empty-return:confirm")
.contains("不以 `container:list/edit` 兼容放行国外操作")
.contains("merchant:warehouse:delivery:list")
@@ -22,7 +22,6 @@ class ContainerWorkspacePermissionSqlContractTest {
"platform:firstleg:terminal-drayage:document:edit",
"platform:firstleg:terminal-drayage:fleet:edit",
"platform:firstleg:terminal-drayage:pickup-appointment:edit",
"platform:firstleg:terminal-drayage:pickup:confirm",
"platform:firstleg:terminal-drayage:empty-return:confirm");
@Test
@@ -50,7 +49,6 @@ class ContainerWorkspacePermissionSqlContractTest {
"'platform:firstleg:terminal-drayage:document:edit',\n '维护目的港清关与DO/PU',NULL,'POST'",
"'platform:firstleg:terminal-drayage:fleet:edit',\n '维护目的港码头车队',NULL,'POST'",
"'platform:firstleg:terminal-drayage:pickup-appointment:edit',\n '预约目的港提柜',NULL,'POST'",
"'platform:firstleg:terminal-drayage:pickup:confirm',\n '确认目的港实际提柜',NULL,'POST'",
"'platform:firstleg:terminal-drayage:empty-return:confirm',\n '确认目的港实际还柜',NULL,'POST'");
}
@@ -75,8 +73,8 @@ class ContainerWorkspacePermissionSqlContractTest {
"MERCHANT_OWNER");
assertThat(sql).contains(
"permission_code IN (",
")) <> 9 THEN",
")) <> 18 THEN");
")) <> 8 THEN",
")) <> 16 THEN");
}
@Test
@@ -853,6 +853,40 @@ class ReceivingContainerServiceBehaviorTest {
.containsExactly("SKU-A", "SKU-B", "SKU-C");
}
@Test
void mixedContainerFbaBulkNeverReceivesDropshipOrOrdinaryOverseasLines() {
InboundPlanLine fba = line("FBA");
InboundPlanLine dropship = line("FBA");
dropship.setId(20L); dropship.setOrderId(2L); dropship.setInboundCode("IN-DROPSHIP");
InboundPlanLine overseas = line("OVERSEAS");
overseas.setId(30L); overseas.setOrderId(3L); overseas.setInboundCode("IN-OVERSEAS");
LastlegOrder dropshipOrder = order("FBA");
dropshipOrder.setId(2L); dropshipOrder.setOrderType("ONE_CLICK_DROPSHIP");
LastlegOrder overseasOrder = order("OVERSEAS"); overseasOrder.setId(3L);
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "SIGNED"));
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of(dropship, overseas, fba));
when(orders.selectById(1L)).thenReturn(order("FBA"));
when(orders.selectById(2L)).thenReturn(dropshipOrder);
when(orders.selectById(3L)).thenReturn(overseasOrder);
when(orderDetails.selectList(any())).thenReturn(List.of(detail("FBA-SKU", 2)));
stubReservedFbaPallet();
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
var result = service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 2, "mixed-fba-receive"));
assertThat(result.acceptedPackages()).isEqualTo(2);
ArgumentCaptor<InboundPlanLine> captured = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(workflow).receiveBulkResolved(any(), any(), captured.capture(), any(),
org.mockito.ArgumentMatchers.eq(2), any(), any());
assertThat(captured.getValue()).isSameAs(fba);
verifyNoInteractions(dropshipProfiles, dropshipCartons);
}
@Test
void dropshipCartonResolvesItsCompleteDpInsteadOfAPlanOrCustomer() {
stubCurrent("OVERSEAS", "CONT-A");
+2 -1
View File
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture']
'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture', 'mixed-container-receiving']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
@@ -44,6 +44,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1,
const MODULE_DEPENDENCIES = new Map([
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
['dropship-browser-fixture', ['merchant-qualification-fixture']],
['mixed-container-receiving', ['merchant-qualification-fixture']],
['suppliers', ['merchant-qualification-fixture']],
['supplemental', ['merchant-qualification-fixture']],
['orders', ['merchant-qualification-fixture']],
+8
View File
@@ -37,6 +37,14 @@ export async function run(context) {
## 可用上下文
`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。
一件代发矩阵在每个剩余实物托盘收货前核对待收箱数,并以 `allowDifference=false` 尝试正常完成:必须返回 A0409,回查会话事实不变后才继续收货。这不覆盖显式差异完成,不得把正常完成门禁解读成系统禁止一切差异结案。
每托首次收货及重复扫描后,矩阵按本轮已提交的来源托盘、箱唛和入仓码独立计算期望进度,回查柜级及每票的总箱数、实收、待收三项。柜级总数正确但串票、重复行、缺票或逐票待收未刷新仍判失败。离线验证器包含三票交替进度样本,但该样本不等于三类混柜真实 HTTP 验收。
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
@@ -5,6 +5,30 @@ const MODULE = 'dropship-pallet-matrix'
const OPTIONS = { scenario: 'dropship-outbound-edge-cases', deploymentBlocker: true }
const BASE = '/api/platform/v1/last-leg/outbound-orders'
// Expected progress comes from submitted fixture operations, never the server response.
export function receivingProgressMatches(snapshot, expected) {
const count = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN
if (!Array.isArray(snapshot?.inbounds) || !Array.isArray(expected) || !expected.length) return false
if (new Set(expected.map(row => row.inboundCode)).size !== expected.length
|| snapshot.inbounds.length !== expected.length
|| new Set(snapshot.inbounds.map(row => row.inboundCode)).size !== expected.length) return false
let total = 0
let received = 0
for (const row of expected) {
if (!Number.isInteger(row.total) || !Number.isInteger(row.received)
|| row.total <= 0 || row.received < 0 || row.received > row.total) return false
const actual = snapshot.inbounds.find(item => item.inboundCode === row.inboundCode)
if (!actual || count(actual.totalPackages) !== row.total
|| count(actual.receivedPackages) !== row.received
|| count(actual.pendingPackages) !== row.total - row.received) return false
total += row.total
received += row.received
}
return count(snapshot.totalPackages) === total && count(snapshot.receivedPackages) === received
&& count(snapshot.pendingPackages) === total - received
}
function record(edge, name, response, passed, evidence = '') {
edge.context.assert(MODULE, `${edge.label}${name}`, response, passed, evidence, OPTIONS)
}
@@ -25,6 +49,41 @@ async function taskSnapshot(edge) {
return edge.call('边界测试回查出库冻结事实', 'GET', `${BASE}/${edge.task.outboundNo}`)
}
export async function outboundCandidateGuards(edge) {
const warehouse = encodeURIComponent(edge.warehouseId)
const customer = encodeURIComponent(edge.customerId)
const paths = [
['平台首次查询不传客户', `${BASE}/one-click-dropship/candidates?warehouseId=${warehouse}`],
['平台指定客户查询', `${BASE}/one-click-dropship/candidates?customerId=${customer}&warehouseId=${warehouse}`],
]
const snapshots = []
for (const [name, path] of paths) {
const rows = await edge.call(name, 'GET', path)
edge.check(`${edge.label}${name}返回候选数组`, null, Array.isArray(rows))
edge.check(`${edge.label}${name}仓库隔离`, null,
rows.every(row => String(row.overseasWarehouseId) === String(edge.warehouseId)))
if (name === '平台指定客户查询') {
edge.check(`${edge.label}指定客户不返回其他客户库存`, null,
rows.every(row => String(row.customerId) === String(edge.customerId)))
}
const own = rows.filter(row => edge.group.some(x => x.source.inboundCode === row.inboundCode))
for (const record of edge.group) {
const selected = own.filter(row => row.inboundCode === record.source.inboundCode)
const expectedPallets = [...new Set(record.task.pallets.map(p => p.palletNo))].sort()
const actualPallets = [...new Set(selected.map(row => row.palletNo))].sort()
edge.check(`${record.label}${name}全部托盘与箱数完整`, null,
JSON.stringify(expectedPallets) === JSON.stringify(actualPallets)
&& selected.reduce((sum, row) => sum + Number(row.availablePackages), 0) === record.expectedCartonMarks.length
&& selected.every(row => String(row.customerId) === String(edge.customerId)))
}
snapshots.push(own.map(row => ({ inbound: row.inboundCode, pallet: row.palletNo,
position: String(row.inventoryPositionId), sku: row.skuCode, packages: Number(row.availablePackages) }))
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))))
}
edge.check(`${edge.label}客户筛选前后本轮库存事实一致`, null,
JSON.stringify(snapshots[0]) === JSON.stringify(snapshots[1]))
}
export async function outboundCreationGuards(edge) {
const before = await candidates(edge)
const first = edge.body.items[0]
@@ -3,7 +3,78 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { sameOutboundTask } from './dropship-pallet-matrix.mjs'
import { probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receivingProgressMatches, outboundCandidateGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
test('receiving verifier follows three independent inbound codes through interleaved progress', () => {
const expected = [{ inboundCode: 'FBA', total: 5, received: 0 },
{ inboundCode: 'DROPSHIP', total: 4, received: 0 }, { inboundCode: 'OVERSEAS', total: 3, received: 0 }]
for (const [index, count] of [[0, 2], [1, 1], [2, 3], [0, 3], [1, 3]]) {
expected[index].received += count
const snapshot = { totalPackages: 12, receivedPackages: expected.reduce((sum, x) => sum + x.received, 0),
pendingPackages: expected.reduce((sum, x) => sum + x.total - x.received, 0),
inbounds: expected.map(x => ({ inboundCode: x.inboundCode, totalPackages: x.total,
receivedPackages: x.received, pendingPackages: x.total - x.received })) }
assert.equal(receivingProgressMatches(snapshot, expected), true)
const stale = structuredClone(snapshot)
stale.inbounds[index].pendingPackages += 1
assert.equal(receivingProgressMatches(stale, expected), false)
const swapped = structuredClone(snapshot)
swapped.inbounds[0].receivedPackages += 1
swapped.inbounds[1].receivedPackages -= 1
assert.equal(receivingProgressMatches(swapped, expected), false, 'correct container total cannot conceal wrong ownership')
const duplicate = structuredClone(snapshot)
duplicate.inbounds[2] = duplicate.inbounds[0]
assert.equal(receivingProgressMatches(duplicate, expected), false)
}
assert.equal(receivingProgressMatches({ inbounds: [] }, []), false)
})
test('receiving verifier does not interpret absent or malformed progress as zero', () => {
const expected = [{ inboundCode: 'IN', total: 4, received: 0 }]
const good = { totalPackages: 4, receivedPackages: 0, pendingPackages: 4,
inbounds: [{ inboundCode: 'IN', totalPackages: 4, receivedPackages: 0, pendingPackages: 4 }] }
for (const value of [null, undefined, '', false, -1, 0.5]) {
const snapshot = structuredClone(good)
snapshot.inbounds[0].receivedPackages = value
assert.equal(receivingProgressMatches(snapshot, expected), false)
}
assert.equal(receivingProgressMatches(good, expected), true)
})
function candidateProbe(transform = rows => rows) {
const rows = ['P1', 'P2'].map((palletNo, index) => ({ palletNo, inventoryPositionId: index + 1,
inboundCode: 'IN-1', skuCode: 'SKU', availablePackages: 2, customerId: 'C1', overseasWarehouseId: 'W1' }))
const paths = []
return { paths, edge: { label: 'MULTI', customerId: 'C1', warehouseId: 'W1',
group: [{ label: 'MULTI', source: { inboundCode: 'IN-1' },
task: { pallets: [{ palletNo: 'P1' }, { palletNo: 'P2' }] }, expectedCartonMarks: ['a', 'b', 'c', 'd'] }],
check: (name, _response, passed) => assert.ok(passed, name),
call: async (_name, method, path) => {
assert.equal(method, 'GET'); paths.push(path)
return transform(structuredClone(rows), path)
},
} }
}
test('candidate probe sends the actual platform query without customer and checks both pallets', async () => {
const { edge, paths } = candidateProbe()
await outboundCandidateGuards(edge)
assert.equal(new URL(paths[0], 'https://example.invalid').searchParams.has('customerId'), false)
assert.equal(new URL(paths[1], 'https://example.invalid').searchParams.get('customerId'), 'C1')
})
for (const [name, transform] of [
['missing second pallet', rows => rows.slice(0, 1)],
['wrong warehouse', rows => rows.map(row => ({ ...row, overseasWarehouseId: 'W2' }))],
['wrong customer', rows => rows.map(row => ({ ...row, customerId: 'C2' }))],
['bad request response', () => ({ code: 'A0400', message: 'Bad request' })],
['candidate identity drift after filtering', (rows, path) => path.includes('customerId=')
? rows.map(row => ({ ...row, inventoryPositionId: row.inventoryPositionId + 10 })) : rows],
]) {
test(`candidate probe fails closed on ${name}`, async () => {
await assert.rejects(outboundCandidateGuards(candidateProbe(transform).edge))
})
}
test('browser fixture is explicit and stops before overseas receipt or outbound', () => {
assert.deepEqual(normalizeModuleOrder(['dropship-browser-fixture']), ['merchant-qualification-fixture', 'dropship-browser-fixture'])
@@ -47,6 +118,21 @@ test('outbound replay preserves business identity and database millisecond times
const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
const edges = readFileSync(new URL('./dropship-outbound-edge-cases.mjs', import.meta.url), 'utf8')
test('matrix runs candidate probes before selecting inventory or creating outbound', () => {
const probe = source.indexOf('await outboundCandidateGuards(')
assert.ok(probe > 0)
assert.ok(probe < source.indexOf("const all = await call('读取专用出库可用库存'"))
})
test('each remaining pallet tests normal completion rejection and zero drift before receiving', () => {
const loop = source.slice(source.indexOf('for (const [index, sourcePalletNo] of sourceNos.entries())'),
source.indexOf("check(`${label}整柜实收总箱数守恒`"))
assert.match(loop, /allowDifference: false/)
assert.match(loop, /premature.body\?\.code === 'A0409'/)
assert.match(loop, /JSON.stringify\(unchanged\) === JSON.stringify\(before\)/)
assert.ok(loop.indexOf('JSON.stringify(unchanged)') < loop.indexOf("const received = await call"))
})
test('outbound edge probes preserve failures and require zero drift before continuing', () => {
assert.match(edges, /deploymentBlocker: true/)
assert.match(edges, /四箱任务先拣一箱成功/)
@@ -11,7 +11,8 @@ import { run as completeCustoms } from './phase3-customs.mjs'
import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
import { runUntilPickupReady } from './phase4-carrier-nodes.mjs'
import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs'
import { outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receiveMixedContainer } from './mixed-container-receiving.mjs'
const MODULE = 'dropship-pallet-matrix'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
@@ -37,7 +38,11 @@ export async function prepareBrowserFixture(context) {
return runWorkflow(context, true)
}
async function runWorkflow(context, stopBeforeOverseasPda) {
export async function runMixedContainer(context) {
return runWorkflow(context, false, true)
}
async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false) {
const marker = context.marker
const savedOptions = context.options.prepareDropshipManualCount
const savedContext = { ...context.context }
@@ -48,25 +53,40 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
context.assert(MODULE, name, response, passed, details, OPTIONS)
if (!passed) throw new Error(name)
}
async function prepare(label, palletCount, mode = {}) {
async function prepare(label, palletCount, mode = {}, destinationType = null) {
context.marker = `${marker}-${label}`
for (const key of ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound', 'dropshipManualCount']) {
delete context.createdResources[key]
}
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderDestinationType = destinationType || 'OVERSEAS'
context.context.orderFulfillmentType = destinationType ? 'STANDARD' : 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount }
if (label.startsWith('MULTI-MERGE-')) {
context.context.waitSplitVariant.skuCodes = [`SKU-SHARED-${marker}`.slice(0, 64)]
}
context.options.prepareDropshipManualCount = true
context.options.prepareDropshipManualCount = !destinationType
const start = context.steps.length
await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context))
await context.executeModule(`${MODULE}-${label}-measurement`, () => measure(context))
check(`${label}本轮订单和测量前置全部通过`, null,
!context.steps.slice(start).some((step) => !step.passed))
if (destinationType) {
const measurement = { ...context.createdResources.inboundMeasurement }
const order = { ...context.createdResources.orders }
const response = await context.request('GET',
`/api/platform/inbounds/${measurement.inboundId}?tenantId=${measurement.tenantId}`)
const detail = context.apiSuccess(MODULE, `${label}普通订单待分柜快照`, response, '', OPTIONS)
check(`${label}普通订单四箱待分柜且类型未漂移`, response,
detail?.summary?.processStatus === 'WAIT_SPLIT' && measurement.actualPackageCount === 4
&& order.fulfillmentType === 'STANDARD' && order.destinationType === destinationType)
const record = { label, order, measurement, summary: detail.summary, task: null,
source: { inboundId: String(measurement.inboundId), inboundCode: detail.summary.inboundCode },
expectedCartonMarks: Array.from({ length: 4 }, (_, index) => `${detail.summary.inboundCode}#4-${index + 1}`) }
records.push(record)
return record
}
const source = context.createdResources.dropshipManualCount
check(`${label}存在本轮未清点任务`, null, Boolean(source?.taskId))
const task = await completeDropshipPalletization(context, {
@@ -112,11 +132,11 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
check(`${label}${name}成功`, response, response?.body?.code === '200' && Boolean(value))
return value
}
const pallets = [...new Map(group.flatMap((item) => item.task.pallets)
const pallets = [...new Map(group.flatMap((item) => item.task?.pallets || [])
.map((pallet) => [String(pallet.palletId), pallet])).values()]
for (const record of group) {
const fee = await ensureInitialCalculated(context, { ...record.measurement,
tenantId: String(record.task.tenantId), inboundId: record.source.inboundId,
tenantId: String(record.order.tenantId), inboundId: record.source.inboundId,
finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS)
check(`${record.label}初始费用已计算`, null, Boolean(fee))
}
@@ -136,14 +156,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
check(`${label}${name}没有混入其他订单`, null,
detail.items?.every((item) => expectedIds.has(String(item.inboundId))))
check(`${label}${name}整托汇总不重复计算箱数`, null,
plan.items?.length === pallets.length
(mixedContainer || plan.items?.length === pallets.length)
&& plan.items.reduce((sum, item) => sum + Number(item.packageCount), 0)
=== group.reduce((sum, record) => sum + record.expectedCartonMarks.length, 0))
for (const record of group) {
const actual = detail.items.filter((item) => String(item.inboundId) === record.source.inboundId)
check(`${record.label}${name}分配箱数与托盘身份正确`, null,
actual.reduce((sum, item) => sum + Number(item.packageCount), 0) === record.expectedCartonMarks.length
&& actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId))))
&& (!record.task || actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId)))))
}
}
for (const pallet of pallets) {
@@ -152,7 +172,16 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
operationId: `${context.marker}-add-${pallet.palletId}`,
})
}
if (mixedContainer) {
for (const record of group.filter(item => !item.task)) {
plan = await command(`${record.label}普通订单按整票箱数加入`, 'POST', `${path}/inbound-assignments`, {
inboundId: record.source.inboundId, packageCount: record.expectedCartonMarks.length,
expectedVersion: Number(plan.version), operationId: `${context.marker}-add-${record.label}`,
})
}
}
await conserve('手工加入后')
if (!mixedContainer) {
for (const pallet of pallets) {
plan = await command('按物理托盘完整移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, {
expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-remove-${pallet.palletId}`,
@@ -164,13 +193,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
customerId: String(group[0].task.tenantId), inboundCode: group[0].source.inboundCode,
})
await conserve('智能推荐后')
}
for (const record of group) record.loading = { cabinet, plan, allocationVerified: true }
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
const agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id))
const suffix = markerSuffix(context.marker)
const draft = oceanBookingDraft(context, suffix, {
customerId: String(group[0].task.tenantId), customerName: summary.customerName,
customerId: String(group[0].order.tenantId), customerName: summary.customerName,
warehouseId: String(summary.warehouseId), channelCode: summary.channelCode,
}, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name,
bookingAgentId: String(agent.id), bookingAgentName: agent.name })
@@ -211,10 +241,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
warehouseId: String(summary.warehouseId), loadingPlanId: String(plan.id), containerBookingId: String(cabinet.id),
expectedVersion: Number(claimed.version), inventorySnapshotToken: inventory.snapshotToken,
items: claimed.items.map((item) => ({ itemId: String(item.id), version: Number(item.version),
action: 'PICK_PALLET', palletNo: item.palletNo })),
...(item.unitType === 'SEALED_PALLET'
? { action: 'PICK_PALLET', palletNo: item.palletNo }
: { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }) })),
}
check(`${label}PDA全部任务均为封托单元`, null,
claimed.items.length > 0 && claimed.items.every((item) => item.unitType === 'SEALED_PALLET'))
claimed.items.length > 0 && (mixedContainer
? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET')
: claimed.items.every((item) => item.unitType === 'SEALED_PALLET')))
const picked = await command('整托拣货', 'POST', `${pda}/batch`, batch,
{ 'Idempotency-Key': `${context.marker}-pick` })
check(`${label}整托拣货等待最终确认`, null, picked.workflowStatus === 'AWAITING_REVIEW')
@@ -236,7 +270,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
`status=${finalTask?.taskStatus}, attempts=${terminal.attempts}`)
for (const record of group) {
const inbound = await command(`${record.label}回查入仓单实装状态`, 'GET',
`/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.task.tenantId}`)
`/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
check(`${record.label}装柜完成回写`, null, inbound.summary?.processStatus === 'LOADED')
record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released,
allocationVerified: true, loadingVerified: true, containerNo }
@@ -245,10 +279,10 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
async function progressShipping(group) {
const record = group[0]
context.marker = `${marker}-${record.label}-SHIPPING`
context.createdResources.orders = { ...record.order, tenantId: String(record.task.tenantId),
context.createdResources.orders = { ...record.order, tenantId: String(record.order.tenantId),
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId) }
context.createdResources.phase3Loading = {
marker: context.marker, tenantId: String(record.task.tenantId), orderId: record.order.orderId,
marker: context.marker, tenantId: String(record.order.tenantId), orderId: record.order.orderId,
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId),
containerBookingId: String(record.loading.cabinet.id), loadingPlanId: String(record.loading.plan.id),
oceanBookingId: String(record.loading.booking.oceanBooking.id),
@@ -270,7 +304,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
String(inbound.inboundId) === String(item.source.inboundId) && inbound.inboundStatus === 'LOADED'))
&& detail.timeline?.some((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH'))
context.createdResources.phase3OutboundDispatch = { marker: context.marker, containerBookingId: bookingId,
tenantId: String(record.task.tenantId), confirmedStatus: aggregate.dispatchStatus }
tenantId: String(record.order.tenantId), confirmedStatus: aggregate.dispatchStatus }
}
for (const [name, action] of [['customs', completeCustoms], ['departure', confirmDomesticDeparture],
['carrier-query', verifyCarrierQuery], ['pickup-ready', runUntilPickupReady]]) {
@@ -309,7 +343,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
check(`${label}跨区事件逐票建单完整`, null,
summary?.orders?.length === group.length && group.every((record) =>
summary.orders.some((order) => order.inboundCode === record.source.inboundCode
&& String(order.customerId) === String(record.task.tenantId))))
&& String(order.customerId) === String(record.order.tenantId))))
const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`)
check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length)
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion),
@@ -321,10 +355,10 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
const matching = plans.filter((plan) => plan.lines?.some((line) => line.inboundCode === record.source.inboundCode))
check(`${record.label}计划精确关联来源入仓码`, null, matching.length === 1)
const target = matching[0]
const expectedNos = record.task.pallets.map((pallet) => pallet.palletNo).sort()
const expectedNos = record.task?.pallets.map((pallet) => pallet.palletNo).sort()
const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort()
check(`${record.label}来源托盘完整绑定海外真实资源`, null,
JSON.stringify(expectedNos) === JSON.stringify(actualNos)
(expectedNos ? JSON.stringify(expectedNos) === JSON.stringify(actualNos) : actualNos.length > 0)
&& target.pallets.every((pallet) => pallet.warehousePalletId && pallet.warehouseLocationId))
record.overseas = { plan: target, warehouseId: String(preview.overseasWarehouseId),
summary: summary.orders.find((order) => order.inboundCode === record.source.inboundCode),
@@ -373,9 +407,35 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`,
{ photos, idempotencyKey: `${context.marker}-sign` })
check(`${label}签收成功`, null, signed.status === 'SIGNED')
if (mixedContainer) {
const completed = await receiveMixedContainer({ group, call, check, marker: context.marker,
request: (method, path, body) => context.requestOverseas(method, path, body, {}, picker.token) })
for (const item of group) {
const stock = await call(`${item.label}混柜完成后按入仓码查询库存`, 'POST',
'/api/pda/v1/last-leg/inventory/resolve', { code: item.source.inboundCode })
check(`${item.label}库存归属和真实托盘绑定正确且箱数核平`, null,
stock.queryCode === item.source.inboundCode && Array.isArray(stock.items) && stock.items.length > 0
&& stock.items.every(row => row.inboundCode === item.source.inboundCode
&& String(row.customerId) === String(item.order.tenantId)
&& row.locationId && item.overseas.plan.pallets.some(p => String(p.warehousePalletId) === String(row.palletId)))
&& stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === item.expectedCartonMarks.length)
item.overseas.receiving = { completed, stock, receivingVerified: true }
}
return
}
const sourceNos = [...new Set(group.flatMap((item) => item.task.pallets.map((pallet) => pallet.palletNo)))]
const receivedPallets = new Set()
let accepted = 0
for (const [index, sourcePalletNo] of sourceNos.entries()) {
const before = await call('未齐收货前回查柜级事实', 'GET', `${base}/current`)
check(`${label}剩余托盘待收箱数准确`, null,
Number(before.pendingPackages) === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0) - accepted)
const premature = await context.requestOverseas('POST', `${base}/current/complete`,
{ idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, {}, picker.token)
check(`${label}未收齐不得按正常完成提交`, premature, premature.body?.code === 'A0409')
const unchanged = await call('未齐完成被拒后回查柜级事实', 'GET', `${base}/current`)
check(`${label}未齐完成拒绝后事实不变且可继续收货`, null,
JSON.stringify(unchanged) === JSON.stringify(before))
const body = { sourcePalletNo, idempotencyKey: `${context.marker}-pallet-${index}` }
const received = await call('按来源实物托盘整托收货', 'POST', `${base}/current/receipts/pallet`, body)
accepted += Number(received.acceptedPackages)
@@ -385,6 +445,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
{ sourcePalletNo, idempotencyKey: `${context.marker}-duplicate-${index}` })
check(`${label}同托重复扫描不重复收货`, null,
duplicate.result === 'ALREADY_RECEIVED' && Number(duplicate.acceptedPackages) === 0)
receivedPallets.add(sourcePalletNo)
const progress = await call('每托收货及重扫后回查逐票进度', 'GET', `${base}/current`)
const expected = group.map(record => ({ inboundCode: record.source.inboundCode,
total: record.expectedCartonMarks.length,
received: new Set(record.task.pallets.filter(p => receivedPallets.has(p.palletNo))
.flatMap(p => p.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId))
.map(c => c.boxCode)).size }))
check(`${label}每托实收不串票且柜级与逐票剩余同步`, null, receivingProgressMatches(progress, expected))
}
check(`${label}整柜实收总箱数守恒`, null,
accepted === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0))
@@ -416,6 +484,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
return value
}
const candidatePath = `${base}/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${warehouseId}`
await outboundCandidateGuards({ context, group, customerId, warehouseId, label, check, call })
const all = await call('读取专用出库可用库存', 'GET', candidatePath)
const selected = all.filter((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode))
const inputs = selected.map((candidate) => {
@@ -480,6 +549,24 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true }
}
try {
if (mixedContainer) {
const dropship = await prepare('MIXED-DROPSHIP', 2)
await verify(dropship, 2)
context.context.waitSplitRoute = { originWarehouseName: dropship.order.receivingWarehouseName,
channelCode: dropship.order.channelCode }
const fba = await prepare('MIXED-FBA', 1, {}, 'FBA')
const overseas = await prepare('MIXED-OVERSEAS', 1, {}, 'OVERSEAS')
const group = [dropship, fba, overseas]
check('混柜三票属于同一起运仓和渠道组', null, group.every(r =>
String(r.summary.warehouseId) === String(dropship.summary.warehouseId)
&& channelGroup(r.summary.channelCode) === channelGroup(dropship.summary.channelCode)))
await allocate(group)
await progressShipping(group)
await convertOverseas(group)
await receiveOverseas(group)
context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED'
return
}
if (stopBeforeOverseasPda) {
const record = await prepare('BROWSER-MULTI', 2)
await verify(record, 2)
@@ -0,0 +1,84 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { mixedReceivingSchedule, receiveMixedContainer } from './mixed-container-receiving.mjs'
function fixtures() {
const record = (type, fulfillmentType = 'STANDARD') => ({ label: type,
order: { destinationType: type, fulfillmentType }, source: { inboundId: type, inboundCode: type },
expectedCartonMarks: [1, 2, 3, 4].map(i => `${type}#4-${i}`),
overseas: { plan: { pallets: [{ palletNo: `PAL-${type}` }] } } })
const d = record('DROPSHIP', 'ONE_CLICK_DROPSHIP')
d.task = { pallets: [{ palletNo: 'DP-1', cartons: [{ inboundId: 'DROPSHIP', boxCode: 'DROPSHIP#4-1' }] },
{ palletNo: 'DP-2', cartons: [2, 3, 4].map(i => ({ inboundId: 'DROPSHIP', boxCode: `DROPSHIP#4-${i}` })) }] }
return [d, record('FBA'), record('OVERSEAS')]
}
test('mixed receiving is registered with prerequisites, not transfer or downstream delivery', () => {
assert.deepEqual(normalizeModuleOrder(['mixed-container-receiving']), ['merchant-qualification-fixture', 'mixed-container-receiving'])
const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
const start = source.indexOf("const dropship = await prepare('MIXED-DROPSHIP'")
const branch = source.slice(start, source.indexOf('if (stopBeforeOverseasPda)', start))
for (const entry of ["'MIXED-FBA', 1, {}, 'FBA'", "'MIXED-OVERSEAS', 1, {}, 'OVERSEAS'",
'allocate(group)', 'progressShipping(group)', 'convertOverseas(group)', 'receiveOverseas(group)',
'MIXED_CONTAINER_INBOUND_COMPLETED']) assert.ok(branch.includes(entry), entry)
assert.doesNotMatch(branch, /outboundOverseas|warehouse-transfers/)
assert.match(source, /混柜完成后按入仓码查询库存/)
assert.match(source, /String\(row.customerId\) === String\(item.order.tenantId\)/)
assert.match(source, /String\(p.warehousePalletId\) === String\(row.palletId\)/)
})
test('schedule interleaves three modes and uses FBA cumulative count rather than delta', () => {
const steps = mixedReceivingSchedule(fixtures())
assert.deepEqual(steps.map(s => s.path), ['bulk', 'pallet', 'carton', 'carton', 'carton', 'carton', 'bulk', 'pallet'])
assert.deepEqual(steps.filter(s => s.path === 'bulk').map(s => [s.count, s.body.packageCount]), [[1, 1], [3, 4]])
assert.equal(steps.reduce((sum, s) => sum + s.count, 0), 12)
assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2)))
})
function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false } = {}) {
const group = fixtures()
const expected = new Map(group.map(r => [r.source.inboundCode, 0]))
const schedule = mixedReceivingSchedule(group)
const seen = new Map()
let index = 0
let complete = false
const snapshot = () => ({ status: complete ? 'COMPLETED' : 'SIGNED', totalPackages: 12,
receivedPackages: [...expected.values()].reduce((a, b) => a + b, 0),
pendingPackages: 12 - [...expected.values()].reduce((a, b) => a + b, 0),
inbounds: [...expected].map(([inboundCode, receivedPackages]) => ({ inboundCode, totalPackages: 4,
receivedPackages, pendingPackages: 4 - receivedPackages })) })
return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name),
request: async (_method, path) => ({ body: { code: (path.endsWith('/complete') ? earlyComplete : wrongModeAccepted) ? '200' : 'A0409' } }),
call: async (_name, method, path, body) => {
if (method === 'GET') return snapshot()
if (seen.has(body.idempotencyKey)) return structuredClone(seen.get(body.idempotencyKey))
if (path.endsWith('/complete')) {
complete = true
const result = snapshot(); seen.set(body.idempotencyKey, result); return result
}
const step = schedule[index++]
assert.ok(path.endsWith(`/receipts/${step.path}`))
const inbound = drift ? 'FBA' : step.record.source.inboundCode
expected.set(inbound, expected.get(inbound) + step.count)
const result = { acceptedPackages: step.count }
seen.set(body.idempotencyKey, result)
return result
} }
}
test('mixed receiving executes the whole schedule and completes only after all three rows balance', async () => {
const result = await receiveMixedContainer(driver())
assert.equal(result.status, 'COMPLETED')
assert.equal(result.receivedPackages, 12)
})
test('mixed receiving fails on early-completion acceptance', async () => {
await assert.rejects(receiveMixedContainer(driver({ earlyComplete: true })))
})
test('mixed receiving fails if overseas or dropship can bypass pallet/carton receipt rules', async () => {
await assert.rejects(receiveMixedContainer(driver({ wrongModeAccepted: true })))
})
test('mixed receiving detects correct aggregate count allocated to the wrong inbound', async () => {
await assert.rejects(receiveMixedContainer(driver({ drift: true })))
})
@@ -0,0 +1,69 @@
import { receivingProgressMatches } from './dropship-outbound-edge-cases.mjs'
export async function run(context) {
const { runMixedContainer } = await import('./dropship-pallet-matrix.mjs')
return runMixedContainer(context)
}
export function mixedReceivingSchedule(group) {
const fba = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'FBA')
const overseas = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'OVERSEAS')
const dropship = group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP')
if (group.length !== 3 || fba.length !== 1 || overseas.length !== 1 || dropship.length !== 1
|| dropship[0].task?.pallets?.length !== 2) throw new Error('混柜必须包含普通FBA、普通海外仓及一件代发双托三票')
const [f, o, d] = [fba[0], overseas[0], dropship[0]]
const fbaPallet = f.overseas.plan.pallets?.[0]?.palletNo
const overseasPallet = o.overseas.plan.pallets?.[0]?.palletNo
if (!fbaPallet || !overseasPallet || f.expectedCartonMarks.length < 2) throw new Error('混柜普通订单缺少真实托盘或分批箱数')
const palletStep = pallet => ({ record: d, path: 'pallet', count: pallet.cartons.filter(c =>
String(c.inboundId) === String(d.source.inboundId)).length, body: { sourcePalletNo: pallet.palletNo } })
return [
{ record: f, path: 'bulk', count: 1, body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: 1 } },
palletStep(d.task.pallets[0]),
...o.expectedCartonMarks.map(cartonMark => ({ record: o, path: 'carton', count: 1, body: { cartonMark, palletCode: overseasPallet } })),
{ record: f, path: 'bulk', count: f.expectedCartonMarks.length - 1,
body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: f.expectedCartonMarks.length } },
palletStep(d.task.pallets[1]),
]
}
export async function receiveMixedContainer({ group, call, check, request, marker }) {
const base = '/api/pda/v1/last-leg/receiving-containers/current'
const steps = mixedReceivingSchedule(group)
const expected = group.map(r => ({ inboundCode: r.source.inboundCode, total: r.expectedCartonMarks.length, received: 0 }))
for (const record of group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP' || r.order.destinationType === 'OVERSEAS')) {
const before = await call('混柜错误收货模式前回查', 'GET', base)
const invalid = await request('POST', `${base}/receipts/bulk`, {
inboundCode: record.source.inboundCode, palletCode: record.overseas.plan.pallets[0].palletNo,
packageCount: 1, idempotencyKey: `${marker}-forbid-bulk-${record.label}`,
})
check('普通海外仓和一件代发均不能绕过各自规则使用FBA批量收货', invalid, invalid.body?.code === 'A0409')
const after = await call('混柜错误收货模式拒绝后回查', 'GET', base)
check('混柜错误收货模式拒绝后零漂移', null, JSON.stringify(before) === JSON.stringify(after))
}
for (const [index, step] of steps.entries()) {
const before = await call('混柜交替收货前进度', 'GET', base)
check('混柜每步前逐票和柜级进度准确', null, receivingProgressMatches(before, expected))
const premature = await request('POST', `${base}/complete`,
{ allowDifference: false, idempotencyKey: `${marker}-early-${index}` })
check('混柜任一票未齐不得正常完成', premature, premature.body?.code === 'A0409')
const afterRejected = await call('混柜提前完成拒绝后回查', 'GET', base)
check('混柜提前完成拒绝后零漂移', null, JSON.stringify(afterRejected) === JSON.stringify(before))
const path = `${base}/receipts/${step.path}`
const body = { ...step.body, idempotencyKey: `${marker}-mixed-receive-${index}` }
const received = await call(`${step.record.label}交替收货`, 'POST', path, body)
check('混柜本次只收当前操作对应箱数', null, Number(received.acceptedPackages) === step.count)
const replay = await call('混柜收货同键重放', 'POST', path, body)
check('混柜同键重放结果稳定', null, JSON.stringify(replay) === JSON.stringify(received))
expected.find(row => row.inboundCode === step.record.source.inboundCode).received += step.count
const after = await call('混柜每步后逐票回查', 'GET', base)
check('混柜交替收货不串票不重计', null, receivingProgressMatches(after, expected))
}
const body = { allowDifference: false, idempotencyKey: `${marker}-mixed-complete` }
const completed = await call('混柜全部核平后正常完成入库', 'POST', `${base}/complete`, body)
check('混柜三票全收且普通完成', null,
completed.status === 'COMPLETED' && receivingProgressMatches(completed, expected))
const replay = await call('混柜入库完成重放', 'POST', `${base}/complete`, body)
check('混柜完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed))
return completed
}
@@ -7,12 +7,12 @@ import { prepareTerminalDrayage, run } from './phase5-terminal-drayage.mjs'
const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8')
test('four real command payloads mutate contract fields and preserve remarks and state on conflict', async () => {
test('three real command payloads mutate contract fields and preserve remarks and state on conflict', async () => {
const marker = 'offline-terminal'
const containerBookingId = '9223372036854775806'
const containerNo = 'TEST0000001'
const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN']
const paths = ['fleet', 'pickup-appointment', 'pickup', 'empty-return']
const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN']
const paths = ['fleet', 'pickup-appointment', 'empty-return']
let state = { containerBookingId, containerNo, version: 0, allowedActions: ['SELECT_FLEET'] }
const history = []
const commands = new Map()
@@ -59,20 +59,19 @@ test('four real command payloads mutate contract fields and preserve remarks and
state = { ...state, version: state.version + 1 }
if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] })
if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt
if (index === 2) Object.assign(state, { status: 'PICKED_UP', actualPickupAt: body.actualAt, allowedActions: [] })
if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } })
if (index === 2) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } })
history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] })
commands.set(key, { body: structuredClone(body), state: structuredClone(state) })
return response(state)
},
}
await prepareTerminalDrayage(context, producer)
state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }
state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', actualPickupAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }
await run(context)
assert.equal(commands.size, 4)
assert.equal(replays, 4)
assert.equal(conflicts, 4)
assert.equal(state.version, 4)
assert.equal(commands.size, 3)
assert.equal(replays, 3)
assert.equal(conflicts, 3)
assert.equal(state.version, 3)
assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED')
})
@@ -90,7 +89,7 @@ test('terminal drayage prepares pickup before unloading and finalizes return bef
test('module uses only public business endpoints and reuses public trucking master data', () => {
for (const path of [
'/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'pickup'", "'empty-return'", '/history',
'/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'empty-return'", '/history',
'category=TRUCKING',
]) assert.ok(source.includes(path), `missing ${path}`)
assert.doesNotMatch(source, /\/internal\//)
@@ -98,11 +97,11 @@ test('module uses only public business endpoints and reuses public trucking mast
assert.match(source, /脚本不临时创建主数据/)
})
test('all four commands cover idempotent replay hash conflict and lifecycle semantics', () => {
test('all three commands cover idempotent replay hash conflict and lifecycle semantics', () => {
for (const token of [
'选择车队', '预约提柜', '确认实际提柜', '确认实际还柜',
'同键重放', '同键异载荷返回A0409', 'PICKED_UP', 'UNLOADED', 'EMPTY_RETURNED',
'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED',
'选择车队', '预约提柜', '确认实际还柜',
'同键重放', '同键异载荷返回A0409', 'UNLOADED', 'EMPTY_RETURNED',
'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED',
]) assert.ok(source.includes(token), `missing ${token}`)
assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/)
assert.match(source, /terminal-\$\{path\}.*replace\(\/\[\^A-Za-z0-9:_-\]\//)
@@ -39,7 +39,7 @@ async function command(context, target, action, path, body) {
`${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key })
context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response,
JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
const changed = ['pickup', 'empty-return'].includes(path) ? { ...body, remark: `${body.remark}-DIFFERENT` }
const changed = path === 'empty-return' ? { ...body, remark: `${body.remark}-DIFFERENT` }
: { ...body, reason: `${body.reason}-DIFFERENT` }
const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`,
changed, context.token, { 'Idempotency-Key': key })
@@ -103,15 +103,7 @@ export async function prepareTerminalDrayage(context, producer) {
trace(scheduled.response), OPTIONS)
if (!success(scheduled.response)) return null
const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000))
const picked = await command(context, target, '确认实际提柜', 'pickup', {
expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, remark: context.marker,
})
context.assert(MODULE, '实际提柜同步PICKED_UP并等待海外仓卸柜', picked.response,
picked.data?.status === 'PICKED_UP' && picked.data?.actualPickupAt === pickupAt,
trace(picked.response), OPTIONS)
if (!success(picked.response) || picked.data?.status !== 'PICKED_UP') return null
resource.prepared = { ...target, appointmentAt, pickupAt, pickedVersion: Number(picked.data.version) }
resource.prepared = { ...target, appointmentAt, scheduledVersion: Number(scheduled.data.version) }
resource.containerBookingId = target.containerBookingId
resource.containerNo = target.containerNo
return resource.prepared
@@ -149,15 +141,15 @@ export async function run(context) {
context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response,
id(initial.data?.containerBookingId) === target.containerBookingId
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
&& Number(initial.data?.version) === Number(target.pickedVersion)
&& Number(initial.data?.version) === Number(target.scheduledVersion)
&& initial.data?.status === 'UNLOADED'
&& initial.data?.actualPickupAt === target.pickupAt
&& initial.data?.actualPickupAt === initial.data?.unloadingCompletedAt
&& initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'),
`pollRequests=${pollRequests}, status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS)
if (!success(initial.response)) return
const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`)
const returnBase = Math.max(Date.parse(`${target.pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt)
const returnBase = Number.isNaN(unloadingAt) ? Date.now() : unloadingAt
const returnedAt = localIso(new Date(returnBase + 60 * 60_000))
const returned = await command(context, target, '确认实际还柜', 'empty-return', {
expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker,
@@ -176,13 +168,12 @@ export async function run(context) {
&& list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS)
const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`)
const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action))
context.assert(MODULE, '历史完整记录四个业务动作和连续版本', history.response,
['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action))
context.assert(MODULE, '历史完整记录三个业务动作和连续版本', history.response,
['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action))
&& history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1),
`actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS)
context.assert(MODULE, '提柜和还柜备注写入不可变历史', history.response,
['RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) =>
history.data?.some((item) => item.action === action && item.reason === context.marker)),
context.assert(MODULE, '还柜备注写入不可变历史', history.response,
history.data?.some((item) => item.action === 'RECORD_EMPTY_RETURN' && item.reason === context.marker),
trace(history.response), OPTIONS)
context.createdResources.phase5TerminalDrayage = {
...context.createdResources.phase5TerminalDrayage,
+1
View File
@@ -17,6 +17,7 @@
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由;`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,未配置时使用服务发现 |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
| 测试 | `src/test/java/com/baiyue/warehouse/` |
@@ -0,0 +1,24 @@
package com.baiyue.warehouse.client;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
/** Reuses the Print service's existing label renderer; no local PDF template. */
@FeignClient(name = "print-service", contextId = "overseasWarehousePrintPdfClient",
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
public interface PrintPdfClient {
@PostMapping(value = "/inbound-marks", consumes = "application/json")
ResponseEntity<byte[]> generateInboundMarks(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody Request request);
record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<Label> labels) {}
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
record Label(String topCode, String warehouseText, String cartonText, String routeText, String qrContent) {}
}
@@ -10,6 +10,9 @@ import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.service.WarehouseResourceService;
import com.baiyue.warehouse.service.PalletLabelPrintService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestHeader;
import com.baiyue.warehouse.security.OverseasWarehousePermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -34,6 +37,7 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Platform overseas warehouse resources")
public class PlatformWarehouseResourceController {
@Resource private WarehouseResourceService service;
@Resource private PalletLabelPrintService palletLabels;
@PostMapping("/locations")
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_CREATE + "')")
@@ -134,6 +138,16 @@ public class PlatformWarehouseResourceController {
return ApiResponse.success(service.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id));
}
@GetMapping("/pallets/{id}/label")
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')")
@Operation(summary = "Print one existing pallet label", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST)
public ResponseEntity<byte[]> palletLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
requirePlatform(user);
return palletLabels.print(warehouseId, id, requestId);
}
@DeleteMapping("/pallets/{id}")
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_DELETE + "')")
@Operation(summary = "Delete a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_DELETE)
@@ -0,0 +1,56 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.client.PrintPdfClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
@Service
public class PalletLabelPrintService {
@Resource private WarehouseResourceService resources;
@Resource private DomesticWarehouseDirectory warehouses;
@Resource private PrintPdfClient print;
@Value("${baiyue.security.internal-service-token:${baiyue.internal-service.token}}")
private String internalToken;
public ResponseEntity<byte[]> print(long warehouseId, long palletId, String requestId) {
var pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, palletId);
String code = pallet.getPalletCode();
// Keep the stored business code intact, including the QR payload.
if (code == null || !code.matches("[\\x20-\\x7E]{1,34}") || code.isBlank()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "当前标签模板要求托盘编码为 1–34 位可打印 ASCII 字符,请核对托盘编码");
}
var warehouse = warehouses.required(warehouseId);
String fbaCode = pallet.getFbaWarehouseCode();
String fileName = code.replaceAll("[\\\\/:*?\"<>|]", "-") + "-托盘码.pdf";
var label = new PrintPdfClient.Label(code, fbaCode == null || fbaCode.isBlank() ? "—" : fbaCode,
"PALLET", warehouse.warehouseName(), code);
var request = new PrintPdfClient.Request("INBOUND_MARK_V1",
new PrintPdfClient.PageSpec(new BigDecimal("100"), new BigDecimal("100")), fileName, List.of(label));
try {
var response = print.generateInboundMarks(internalToken, requestId, request);
byte[] body = response == null ? null : response.getBody();
MediaType type = response == null ? null : response.getHeaders().getContentType();
if (response == null || !response.getStatusCode().is2xxSuccessful() || body == null || body.length == 0
|| type == null || !MediaType.APPLICATION_PDF.isCompatibleWith(type)) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务未返回有效 PDF,请稍后重试");
}
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).contentLength(body.length)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(fileName, StandardCharsets.UTF_8).build().toString())
.header(HttpHeaders.CACHE_CONTROL, "no-store").body(body);
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
}
}
}
@@ -42,6 +42,8 @@ baiyue:
pallet:
auto-created-unit-price: ${AUTO_CREATED_PALLET_UNIT_PRICE:1.00}
clients:
print:
url: ${PRINT_SERVICE_URL:}
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
@@ -0,0 +1,35 @@
openapi: 3.0.3
info:
title: Overseas pallet label
version: 1.0.0
paths:
/api/platform/v1/warehouse-admin/{warehouseId}/pallets/{id}/label:
get:
operationId: printOverseasPalletLabel
summary: 下载指定托盘的一页标签 PDF
description: 平台公共资源,要求 platform:overseas-warehouse:pallet:list。校验仓库和托盘归属后复用 Print 的 INBOUND_MARK_V1(100×100mm),原样使用托盘编码作为二维码,不创建或修改托盘。当前模板要求编码为 1–34 位可打印 ASCII 字符。
security:
- bearerAuth: []
parameters:
- { name: warehouseId, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: X-Request-Id, in: header, required: false, schema: { type: string } }
responses:
'200':
description: 成功返回单页 PDF;业务失败返回 JSON 信封(A0403、A0404、A0422 或 S0503)。
content:
application/pdf:
schema: { type: string, format: binary }
application/json:
schema:
type: object
required: [code, message]
properties:
code: { type: string }
message: { type: string }
requestId: { type: string }
'401': { description: 未认证 }
'403': { description: 权限不足 }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.service.WarehouseResourceService;
import com.baiyue.warehouse.service.PalletLabelPrintService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -17,6 +18,20 @@ import static org.mockito.Mockito.when;
class WarehouseResourceControllerTest {
private final WarehouseResourceService service = mock(WarehouseResourceService.class);
@Test
void palletLabelRejectsNonPublicPlatformScopeAndForwardsSelectedResource() {
var controller = platformController();
var labels = mock(PalletLabelPrintService.class);
ReflectionTestUtils.setField(controller, "palletLabels", labels);
assertThatThrownBy(() -> controller.palletLabel(user(10001L, "MERCHANT"), 200L, 9L, "r1"))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.palletLabel(user(10001L, "PLATFORM"), 200L, 9L, "r1"))
.isInstanceOf(BizException.class);
org.mockito.Mockito.verifyNoInteractions(labels);
controller.palletLabel(user(0L, "PLATFORM"), 200L, 9L, "r1");
verify(labels).print(200L, 9L, "r1");
}
@Test
void merchantTenantAlwaysComesFromAuthenticatedUser() {
MerchantWarehouseResourceController controller = merchantController();
@@ -27,6 +27,7 @@ class WarehouseResourcePermissionContractTest {
assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete");
assertPermission(methods.get("pallets"), PREFIX + "pallet:list");
assertPermission(methods.get("pallet"), PREFIX + "pallet:list");
assertPermission(methods.get("palletLabel"), PREFIX + "pallet:list");
assertPermission(methods.get("createPallet"), PREFIX + "pallet:create");
assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit");
assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete");
@@ -0,0 +1,86 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.client.PrintPdfClient;
import com.baiyue.warehouse.dto.SharedWarehouseReference;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class PalletLabelPrintServiceTest {
private final WarehouseResourceService resources = mock(WarehouseResourceService.class);
private final DomesticWarehouseDirectory warehouses = mock(DomesticWarehouseDirectory.class);
private final PrintPdfClient print = mock(PrintPdfClient.class);
private final PalletLabelPrintService service = new PalletLabelPrintService();
private final WarehousePalletEntity pallet = new WarehousePalletEntity();
@BeforeEach
void setup() {
ReflectionTestUtils.setField(service, "resources", resources);
ReflectionTestUtils.setField(service, "warehouses", warehouses);
ReflectionTestUtils.setField(service, "print", print);
ReflectionTestUtils.setField(service, "internalToken", "test-token");
pallet.setPalletCode("ABCD1234567-ABE3-003");
pallet.setFbaWarehouseCode("ABE3");
when(resources.pallet(0L, 200L, 9L)).thenReturn(pallet);
when(warehouses.required(200L)).thenReturn(new SharedWarehouseReference(200L, "LAX", "洛杉矶仓", "ACTIVE"));
}
@Test
void reusesExistingRendererWithExactlyTheSelectedCode() {
byte[] pdf = "%PDF-label".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
when(print.generateInboundMarks(anyString(), anyString(), any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(pdf));
var response = service.print(200L, 9L, "request-1");
var request = ArgumentCaptor.forClass(PrintPdfClient.Request.class);
verify(print).generateInboundMarks(eq("test-token"), eq("request-1"), request.capture());
assertThat(request.getValue().templateCode()).isEqualTo("INBOUND_MARK_V1");
assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("100");
assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("100");
assertThat(request.getValue().labels()).singleElement().satisfies(label -> {
assertThat(label.topCode()).isEqualTo("ABCD1234567-ABE3-003");
assertThat(label.qrContent()).isEqualTo(label.topCode());
assertThat(label.warehouseText()).isEqualTo("ABE3");
assertThat(label.routeText()).isEqualTo("洛杉矶仓");
});
assertThat(response.getBody()).isEqualTo(pdf);
verify(resources).pallet(0L, 200L, 9L);
verifyNoMoreInteractions(resources);
}
@Test
void refusesWrongWarehouseBeforeCallingPrint() {
when(resources.pallet(0L, 201L, 9L)).thenThrow(new BizException(ResultCode.NOT_FOUND, "托盘不存在"));
assertThatThrownBy(() -> service.print(201L, 9L, "request-1")).isInstanceOf(BizException.class);
verifyNoInteractions(print);
}
@Test
void rejectsJsonErrorInsteadOfDownloadingItAsPdf() {
when(print.generateInboundMarks(anyString(), anyString(), any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("{}".getBytes()));
assertThatThrownBy(() -> service.print(200L, 9L, "request-1"))
.isInstanceOf(BizException.class).hasMessageContaining("有效 PDF");
}
@Test
void neverTruncatesOrSubstitutesUnsupportedPalletCodes() {
for (String code : new String[] { "", "托盘001", "A".repeat(35) }) {
pallet.setPalletCode(code);
assertThatThrownBy(() -> service.print(200L, 9L, "request-1"))
.isInstanceOf(BizException.class).hasMessageContaining("托盘编码");
}
verifyNoInteractions(print);
}
}