提交
This commit is contained in:
@@ -8,3 +8,5 @@ baiyue:
|
||||
region: ${REGION_CODE:CN}
|
||||
deployment-cell: ${DEPLOYMENT_CELL:prod-cn}
|
||||
data-residency: ${DATA_RESIDENCY:CN}
|
||||
event-dispatcher:
|
||||
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg-service:9203/internal/firstleg/v1/container-events}
|
||||
|
||||
@@ -4,3 +4,9 @@ spring:
|
||||
username: ${LASTLEG_DB_USERNAME}
|
||||
password: ${LASTLEG_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue:
|
||||
clients:
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL}
|
||||
event-bridge:
|
||||
url: ${CN_EVENT_BRIDGE_URL}
|
||||
|
||||
@@ -15701,6 +15701,71 @@ ALTER TABLE fl_inbound_outbox
|
||||
MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
|
||||
ALTER TABLE fl_inbound_outbox
|
||||
MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法';
|
||||
-- 2026-08-20 / 海外仓现有业务平台权限目录。
|
||||
USE baiyue_iam;
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant;
|
||||
CREATE TEMPORARY TABLE tmp_lastleg_role_grant (
|
||||
tenant_id BIGINT NOT NULL, role_id BIGINT NOT NULL, permission_code VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (tenant_id, role_id, permission_code)
|
||||
);
|
||||
INSERT IGNORE INTO tmp_lastleg_role_grant (tenant_id, role_id, permission_code)
|
||||
SELECT role_permission.tenant_id, role_permission.role_id, permission.permission_code
|
||||
FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id=role_permission.permission_id
|
||||
WHERE permission.id BETWEEN 5106000 AND 5106004
|
||||
AND permission.permission_code IN ('platform:lastleg:order:list','platform:lastleg:order:transfer');
|
||||
DELETE role_permission FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id=role_permission.permission_id
|
||||
WHERE (permission.id BETWEEN 5106000 AND 5106004 AND permission.permission_code LIKE 'platform:lastleg:%')
|
||||
OR permission.permission_code IN ('platform:lastleg:order:detail','platform:lastleg:order:assign','platform:lastleg:order:merge-pallet');
|
||||
DELETE FROM iam_permission
|
||||
WHERE (id BETWEEN 5106000 AND 5106004 AND permission_code LIKE 'platform:lastleg:%')
|
||||
OR (tenant_id=0 AND app_code='PLATFORM' AND permission_code IN
|
||||
('platform:lastleg:order:detail','platform:lastleg:order:assign','platform:lastleg:order:merge-pallet'));
|
||||
INSERT INTO iam_permission
|
||||
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||
VALUES
|
||||
(5107000,0,'PLATFORM','MENU',0,'platform:lastleg','海外仓业务','/lastleg',NULL,700,'ACTIVE',1,0),
|
||||
(5107010,0,'PLATFORM','MENU',5107000,'platform:lastleg:order:list','尾程订单','/lastleg/orders','GET',10,'ACTIVE',1,0),
|
||||
(5107011,0,'PLATFORM','BUTTON',5107010,'platform:lastleg:order:export','导出尾程订单',NULL,'GET',10,'ACTIVE',1,0),
|
||||
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:lastleg:order:transfer','尾程订单转仓',NULL,'PUT',20,'ACTIVE',1,0),
|
||||
(5107020,0,'PLATFORM','MENU',5107000,'platform:lastleg:inbound-plan:list','入库单','/lastleg/inbound-plans','GET',20,'ACTIVE',1,0),
|
||||
(5107021,0,'PLATFORM','BUTTON',5107020,'platform:lastleg:inbound-plan:create','新建入库单',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5107022,0,'PLATFORM','BUTTON',5107020,'platform:lastleg:inbound-plan:edit','维护入库单',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5107023,0,'PLATFORM','BUTTON',5107020,'platform:lastleg:inbound-plan:appointment','FBA预约',NULL,'POST',30,'ACTIVE',1,0),
|
||||
(5107030,0,'PLATFORM','MENU',5107000,'platform:lastleg:relabel:list','换标单','/lastleg/relabels','GET',30,'ACTIVE',1,0),
|
||||
(5107031,0,'PLATFORM','BUTTON',5107030,'platform:lastleg:relabel:create','新增换标单',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5107032,0,'PLATFORM','BUTTON',5107030,'platform:lastleg:relabel:status','操作换标状态',NULL,'PUT',20,'ACTIVE',1,0),
|
||||
(5107040,0,'PLATFORM','MENU',5107000,'platform:lastleg:inventory:list','库存管理','/lastleg/inventory','GET',40,'ACTIVE',1,0),
|
||||
(5107041,0,'PLATFORM','BUTTON',5107040,'platform:lastleg:inventory:export','导出库存',NULL,'GET',10,'ACTIVE',1,0),
|
||||
(5107042,0,'PLATFORM','BUTTON',5107040,'platform:lastleg:inventory:manage','库存业务操作',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5107043,0,'PLATFORM','BUTTON',5107040,'platform:lastleg:inventory:adjust','库存盘点调整',NULL,'POST',30,'ACTIVE',1,0),
|
||||
(5107050,0,'PLATFORM','MENU',5107000,'platform:lastleg:dispatch:list','排车管理','/lastleg/dispatches','GET',50,'ACTIVE',1,0),
|
||||
(5107051,0,'PLATFORM','BUTTON',5107050,'platform:lastleg:dispatch:create','创建预排车单',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5107052,0,'PLATFORM','BUTTON',5107050,'platform:lastleg:dispatch:edit','编辑排车单',NULL,'PUT',20,'ACTIVE',1,0),
|
||||
(5107053,0,'PLATFORM','BUTTON',5107050,'platform:lastleg:dispatch:delete','取消排车单',NULL,'DELETE',30,'ACTIVE',1,0),
|
||||
(5107060,0,'PLATFORM','MENU',5107000,'platform:lastleg:overseas-procurement:list','海外采购','/lastleg/overseas-procurements','GET',60,'ACTIVE',1,0),
|
||||
(5107061,0,'PLATFORM','BUTTON',5107060,'platform:lastleg:overseas-procurement:edit','编辑海外采购单',NULL,'PATCH',10,'ACTIVE',1,0),
|
||||
(5107062,0,'PLATFORM','BUTTON',5107060,'platform:lastleg:overseas-procurement:review','审核海外采购单',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5107063,0,'PLATFORM','BUTTON',5107060,'platform:lastleg:overseas-procurement:outbound','登记采购出库',NULL,'POST',30,'ACTIVE',1,0),
|
||||
(5107070,0,'PLATFORM','MENU',5107000,'platform:lastleg:us-trucking:list','本土卡车','/lastleg/us-truck-orders','GET',70,'ACTIVE',1,0),
|
||||
(5107071,0,'PLATFORM','BUTTON',5107070,'platform:lastleg:us-trucking:status','操作卡车订单状态',NULL,'PATCH',10,'ACTIVE',1,0),
|
||||
(5107072,0,'PLATFORM','BUTTON',5107070,'platform:lastleg:us-trucking:receive','登记卡车收托',NULL,'POST',20,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),permission_type=VALUES(permission_type),
|
||||
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
|
||||
SELECT saved.tenant_id,saved.role_id,permission.id,NOW(3)
|
||||
FROM tmp_lastleg_role_grant saved
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
AND permission.permission_code=saved.permission_code;
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant;
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
|
||||
SELECT 0,role.id,permission.id,NOW(3) FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND (permission.permission_code='platform:lastleg' OR permission.permission_code LIKE 'platform:lastleg:%');
|
||||
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
|
||||
+2
-3
@@ -16,8 +16,7 @@ import java.util.List;
|
||||
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
|
||||
@Select("""
|
||||
SELECT event_id FROM bridge_inbox
|
||||
WHERE target_region = 'OVERSEAS'
|
||||
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
|
||||
WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
|
||||
OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
|
||||
ORDER BY received_at LIMIT #{limit}
|
||||
""")
|
||||
@@ -26,7 +25,7 @@ public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
|
||||
@Update("""
|
||||
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
|
||||
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND)
|
||||
WHERE event_id=#{eventId} AND target_region='OVERSEAS'
|
||||
WHERE event_id=#{eventId}
|
||||
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
|
||||
OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
|
||||
""")
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
|
||||
public interface FirstlegEventClient {
|
||||
String deliver(BridgeInbox event) throws Exception;
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
@Component
|
||||
public class HttpFirstlegEventClient implements FirstlegEventClient {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private EventDispatcherProperties properties;
|
||||
@Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}")
|
||||
private URI consumerUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
|
||||
private HttpClient httpClient;
|
||||
|
||||
@PostConstruct
|
||||
void initializeHttpClient() {
|
||||
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deliver(BridgeInbox row) throws Exception {
|
||||
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
|
||||
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
|
||||
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
|
||||
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
|
||||
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
|
||||
HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout())
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Internal-Service-Token", serviceToken)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode responseBody = objectMapper.readTree(response.body());
|
||||
if (response.statusCode() / 100 != 2 || responseBody == null
|
||||
|| !"200".equals(responseBody.path("code").asText())) {
|
||||
throw new IllegalStateException("头程消费失败: HTTP " + response.statusCode());
|
||||
}
|
||||
return String.valueOf(response.statusCode());
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -20,6 +20,7 @@ public class OverseasEventDispatcher {
|
||||
@Resource private BridgeInboxMapper inboxMapper;
|
||||
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
|
||||
@Resource private LastlegEventClient eventClient;
|
||||
@Resource private FirstlegEventClient firstlegEventClient;
|
||||
@Resource private EventDispatcherProperties properties;
|
||||
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
|
||||
|
||||
@@ -40,7 +41,7 @@ public class OverseasEventDispatcher {
|
||||
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
|
||||
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
|
||||
try {
|
||||
String responseCode = eventClient.deliver(event);
|
||||
String responseCode = deliver(event);
|
||||
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
|
||||
throw new IllegalStateException("事件租约已失效,无法确认投递结果");
|
||||
}
|
||||
@@ -49,11 +50,16 @@ public class OverseasEventDispatcher {
|
||||
String error = abbreviate(exception.getMessage(), 1000);
|
||||
inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error);
|
||||
finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error);
|
||||
log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}",
|
||||
log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}",
|
||||
eventId, retryAttempt, deliveryAttemptNo, error);
|
||||
}
|
||||
}
|
||||
|
||||
private String deliver(BridgeInbox event) throws Exception {
|
||||
return "CN".equals(event.getTargetRegion())
|
||||
? firstlegEventClient.deliver(event) : eventClient.deliver(event);
|
||||
}
|
||||
|
||||
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
|
||||
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
|
||||
attempt.setEventId(event.getEventId());
|
||||
|
||||
@@ -32,6 +32,7 @@ baiyue:
|
||||
region: ${REGION_CODE:OVERSEAS}
|
||||
event-dispatcher:
|
||||
consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}
|
||||
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/firstleg/v1/container-events}
|
||||
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
|
||||
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
|
||||
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
|
||||
|
||||
+17
@@ -21,6 +21,7 @@ class OverseasEventDispatcherTest {
|
||||
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
|
||||
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
|
||||
private final LastlegEventClient eventClient = mock(LastlegEventClient.class);
|
||||
private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class);
|
||||
private OverseasEventDispatcher dispatcher;
|
||||
private BridgeInbox event;
|
||||
|
||||
@@ -34,6 +35,7 @@ class OverseasEventDispatcherTest {
|
||||
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
|
||||
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
|
||||
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
|
||||
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
|
||||
ReflectionTestUtils.setField(dispatcher, "properties", properties);
|
||||
event = new BridgeInbox();
|
||||
event.setEventId("event-1");
|
||||
@@ -83,9 +85,24 @@ class OverseasEventDispatcherTest {
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verifyNoInteractions(eventClient);
|
||||
verifyNoInteractions(firstlegEventClient);
|
||||
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cnTargetIsDeliveredToFirstleg() throws Exception {
|
||||
event.setSourceRegion("OVERSEAS");
|
||||
event.setTargetRegion("CN");
|
||||
when(firstlegEventClient.deliver(event)).thenReturn("200");
|
||||
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(firstlegEventClient).deliver(event);
|
||||
verifyNoInteractions(eventClient);
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
||||
}
|
||||
|
||||
private BridgeDeliveryAttempt updatedAttempt() {
|
||||
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
|
||||
verify(attemptMapper).updateById(captor.capture());
|
||||
|
||||
@@ -1,25 +1,78 @@
|
||||
USE baiyue_iam;
|
||||
|
||||
-- 尾程订单权限属于平台端;列表权限使用 MENU,其余操作使用 BUTTON。
|
||||
-- 海外业务权限使用独立 5107000 号段,避免与消息中心 5106000 号段冲突。
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant;
|
||||
CREATE TEMPORARY TABLE tmp_lastleg_role_grant (
|
||||
tenant_id BIGINT NOT NULL, role_id BIGINT NOT NULL, permission_code VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (tenant_id, role_id, permission_code)
|
||||
);
|
||||
INSERT IGNORE INTO tmp_lastleg_role_grant (tenant_id, role_id, permission_code)
|
||||
SELECT role_permission.tenant_id, role_permission.role_id, permission.permission_code
|
||||
FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id = role_permission.permission_id
|
||||
WHERE permission.id BETWEEN 5106000 AND 5106004
|
||||
AND permission.permission_code IN ('platform:lastleg:order:list', 'platform:lastleg:order:transfer');
|
||||
|
||||
DELETE role_permission FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id = role_permission.permission_id
|
||||
WHERE (permission.id BETWEEN 5106000 AND 5106004 AND permission.permission_code LIKE 'platform:lastleg:%')
|
||||
OR permission.permission_code IN (
|
||||
'platform:lastleg:order:detail', 'platform:lastleg:order:assign',
|
||||
'platform:lastleg:order:merge-pallet');
|
||||
DELETE FROM iam_permission
|
||||
WHERE (id BETWEEN 5106000 AND 5106004 AND permission_code LIKE 'platform:lastleg:%')
|
||||
OR (tenant_id = 0 AND app_code = 'PLATFORM' AND permission_code IN (
|
||||
'platform:lastleg:order:detail', 'platform:lastleg:order:assign',
|
||||
'platform:lastleg:order:merge-pallet'));
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0),
|
||||
(5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0),
|
||||
(5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0)
|
||||
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
|
||||
(5107000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg', '海外仓业务', '/lastleg', NULL, 700, 'ACTIVE', 1, 0),
|
||||
(5107010, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 10, 'ACTIVE', 1, 0),
|
||||
(5107011, 0, 'PLATFORM', 'BUTTON', 5107010, 'platform:lastleg:order:export', '导出尾程订单', NULL, 'GET', 10, 'ACTIVE', 1, 0),
|
||||
(5107012, 0, 'PLATFORM', 'BUTTON', 5107010, 'platform:lastleg:order:transfer', '尾程订单转仓', NULL, 'PUT', 20, 'ACTIVE', 1, 0),
|
||||
(5107020, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inbound-plan:list', '入库单', '/lastleg/inbound-plans', 'GET', 20, 'ACTIVE', 1, 0),
|
||||
(5107021, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:create', '新建入库单', NULL, 'POST', 10, 'ACTIVE', 1, 0),
|
||||
(5107022, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:edit', '维护入库单', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5107023, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:appointment', 'FBA预约', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5107030, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:relabel:list', '换标单', '/lastleg/relabels', 'GET', 30, 'ACTIVE', 1, 0),
|
||||
(5107031, 0, 'PLATFORM', 'BUTTON', 5107030, 'platform:lastleg:relabel:create', '新增换标单', NULL, 'POST', 10, 'ACTIVE', 1, 0),
|
||||
(5107032, 0, 'PLATFORM', 'BUTTON', 5107030, 'platform:lastleg:relabel:status', '操作换标状态', NULL, 'PUT', 20, 'ACTIVE', 1, 0),
|
||||
(5107040, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inventory:list', '库存管理', '/lastleg/inventory', 'GET', 40, 'ACTIVE', 1, 0),
|
||||
(5107041, 0, 'PLATFORM', 'BUTTON', 5107040, 'platform:lastleg:inventory:export', '导出库存', NULL, 'GET', 10, 'ACTIVE', 1, 0),
|
||||
(5107042, 0, 'PLATFORM', 'BUTTON', 5107040, 'platform:lastleg:inventory:manage', '库存业务操作', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5107043, 0, 'PLATFORM', 'BUTTON', 5107040, 'platform:lastleg:inventory:adjust', '库存盘点调整', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5107050, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:dispatch:list', '排车管理', '/lastleg/dispatches', 'GET', 50, 'ACTIVE', 1, 0),
|
||||
(5107051, 0, 'PLATFORM', 'BUTTON', 5107050, 'platform:lastleg:dispatch:create', '创建预排车单', NULL, 'POST', 10, 'ACTIVE', 1, 0),
|
||||
(5107052, 0, 'PLATFORM', 'BUTTON', 5107050, 'platform:lastleg:dispatch:edit', '编辑排车单', NULL, 'PUT', 20, 'ACTIVE', 1, 0),
|
||||
(5107053, 0, 'PLATFORM', 'BUTTON', 5107050, 'platform:lastleg:dispatch:delete', '取消排车单', NULL, 'DELETE', 30, 'ACTIVE', 1, 0),
|
||||
(5107060, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:overseas-procurement:list', '海外采购', '/lastleg/overseas-procurements', 'GET', 60, 'ACTIVE', 1, 0),
|
||||
(5107061, 0, 'PLATFORM', 'BUTTON', 5107060, 'platform:lastleg:overseas-procurement:edit', '编辑海外采购单', NULL, 'PATCH', 10, 'ACTIVE', 1, 0),
|
||||
(5107062, 0, 'PLATFORM', 'BUTTON', 5107060, 'platform:lastleg:overseas-procurement:review', '审核海外采购单', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5107063, 0, 'PLATFORM', 'BUTTON', 5107060, 'platform:lastleg:overseas-procurement:outbound', '登记采购出库', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5107070, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:us-trucking:list', '本土卡车', '/lastleg/us-truck-orders', 'GET', 70, 'ACTIVE', 1, 0),
|
||||
(5107071, 0, 'PLATFORM', 'BUTTON', 5107070, 'platform:lastleg:us-trucking:status', '操作卡车订单状态', NULL, 'PATCH', 10, 'ACTIVE', 1, 0),
|
||||
(5107072, 0, 'PLATFORM', 'BUTTON', 5107070, 'platform:lastleg:us-trucking:receive', '登记卡车收托', NULL, 'POST', 20, 'ACTIVE', 1, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
|
||||
permission_type = VALUES(permission_type), route_path = VALUES(route_path),
|
||||
http_method = VALUES(http_method), sort_no = VALUES(sort_no),
|
||||
status = 'ACTIVE', built_in = 1, deleted = 0;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT saved.tenant_id, saved.role_id, permission.id, NOW(3)
|
||||
FROM tmp_lastleg_role_grant saved
|
||||
JOIN iam_permission permission ON permission.tenant_id = 0
|
||||
AND permission.app_code = 'PLATFORM' AND permission.permission_code = saved.permission_code;
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, NOW(3)
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND permission.permission_code IN (
|
||||
'platform:lastleg:order:list', 'platform:lastleg:order:detail',
|
||||
'platform:lastleg:order:assign', 'platform:lastleg:order:transfer',
|
||||
'platform:lastleg:order:merge-pallet');
|
||||
JOIN iam_permission permission ON permission.tenant_id = 0 AND permission.app_code = 'PLATFORM'
|
||||
WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code = 'PLATFORM_ADMIN'
|
||||
AND role.status = 'ACTIVE' AND role.deleted = 0
|
||||
AND (permission.permission_code = 'platform:lastleg'
|
||||
OR permission.permission_code LIKE 'platform:lastleg:%');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LastlegPermissionSqlContractTest {
|
||||
@Test
|
||||
void registersExistingOverseasBusinessesAndMigratesLegacyIds() throws Exception {
|
||||
String sql = new ClassPathResource("db/lastleg-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql).contains(
|
||||
"tmp_lastleg_role_grant", "id BETWEEN 5106000 AND 5106004",
|
||||
"(5107000, 0, 'PLATFORM', 'MENU'",
|
||||
"platform:lastleg:order:list", "platform:lastleg:inbound-plan:list",
|
||||
"platform:lastleg:relabel:list", "platform:lastleg:inventory:list",
|
||||
"platform:lastleg:dispatch:list", "platform:lastleg:overseas-procurement:list",
|
||||
"platform:lastleg:us-trucking:list",
|
||||
"INSERT IGNORE INTO iam_role_permission", "role.role_code = 'PLATFORM_ADMIN'");
|
||||
assertThat(sql).doesNotContain("(5106000, 0, 'PLATFORM'", "platform:lastleg:order:view");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listPermissionsOwnDetailsAndRetiredDetailPermissionIsDeleted() throws Exception {
|
||||
String sql = new ClassPathResource("db/lastleg-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql).contains(
|
||||
"platform:lastleg:order:detail", "DELETE FROM iam_permission",
|
||||
"(5107010, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:order:list'",
|
||||
"(5107020, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inbound-plan:list'");
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ package com.baiyue.lastleg;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.lastleg.mapper")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
@EnableScheduling
|
||||
public class BaiyueLastlegApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
|
||||
}
|
||||
|
||||
+6
@@ -19,6 +19,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -49,6 +50,7 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:order:list')")
|
||||
/** 分页查询尾程订单列表。 */
|
||||
@Operation(summary = "分页查询尾程订单")
|
||||
public ApiResponse<PageResult<LastlegOrderSummary>> list(
|
||||
@@ -59,6 +61,7 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:order:list')")
|
||||
/** 查询指定入仓单维度的尾程订单详情。 */
|
||||
@Operation(summary = "查询尾程订单详情")
|
||||
public ApiResponse<LastlegOrderDetailResponse> detail(
|
||||
@@ -69,6 +72,7 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:order:export')")
|
||||
/** 按筛选条件导出尾程订单 CSV 文件。 */
|
||||
@Operation(summary = "导出尾程订单")
|
||||
public ResponseEntity<byte[]> export(
|
||||
@@ -83,6 +87,7 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:order:transfer')")
|
||||
/** 将单个尾程订单转移到目标仓库。 */
|
||||
@Operation(summary = "单个订单转移仓库")
|
||||
public ApiResponse<Integer> transferOne(
|
||||
@@ -94,6 +99,7 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/destination")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:order:transfer')")
|
||||
/** 将多个尾程订单批量转移到目标仓库。 */
|
||||
@Operation(summary = "批量订单转移仓库")
|
||||
public ApiResponse<Integer> transferBatch(
|
||||
|
||||
+7
@@ -17,6 +17,7 @@ import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -38,6 +39,7 @@ public class DispatchController {
|
||||
@Resource private DispatchPdaWorkflowService pdaWorkflow;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')")
|
||||
/** 查询可用于智能预派车的未锁定托盘。 */
|
||||
@Operation(summary = "查询可派车托盘")
|
||||
public ApiResponse<List<DispatchPalletCandidate>> candidates(
|
||||
@@ -50,6 +52,7 @@ public class DispatchController {
|
||||
}
|
||||
|
||||
@PostMapping("/pre-arrange")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:create')")
|
||||
/** 创建待出发派车单并锁定选中的托盘。 */
|
||||
@Operation(summary = "创建预派车单")
|
||||
public ApiResponse<DispatchResponse> preArrange(
|
||||
@@ -62,6 +65,7 @@ public class DispatchController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')")
|
||||
/** 分页查询派车单。 */
|
||||
@Operation(summary = "分页查询派车单")
|
||||
public ApiResponse<PageResult<DispatchResponse>> page(
|
||||
@@ -72,6 +76,7 @@ public class DispatchController {
|
||||
}
|
||||
|
||||
@GetMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')")
|
||||
/** 查询派车单详情及托盘目的地。 */
|
||||
@Operation(summary = "查询派车单详情")
|
||||
public ApiResponse<DispatchResponse> detail(
|
||||
@@ -82,6 +87,7 @@ public class DispatchController {
|
||||
}
|
||||
|
||||
@PutMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:edit')")
|
||||
/** 编辑车辆、司机、预约、路线和出发信息。 */
|
||||
@Operation(summary = "编辑派车单")
|
||||
public ApiResponse<DispatchResponse> update(
|
||||
@@ -93,6 +99,7 @@ public class DispatchController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:delete')")
|
||||
/** 取消待处理派车单并释放托盘锁定。 */
|
||||
@Operation(summary = "取消派车单")
|
||||
public ApiResponse<Void> cancel(
|
||||
|
||||
@@ -24,5 +24,7 @@ public class LastlegEventOutbox {
|
||||
private LocalDateTime nextRetryAt;
|
||||
private LocalDateTime publishedAt;
|
||||
private String lastError;
|
||||
private String lockedBy;
|
||||
private LocalDateTime lockedAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ public class LastlegOrder {
|
||||
private String warehouseRemark;
|
||||
private String sourceEventId;
|
||||
private String sourceLoadingPlanId;
|
||||
private String sourceContainerBookingId;
|
||||
private Long sourceLoadingPlanVersion;
|
||||
private Long sourceHandoverVersion;
|
||||
private String sourcePayloadHash;
|
||||
|
||||
+6
@@ -15,6 +15,7 @@ import jakarta.validation.Valid;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -26,6 +27,7 @@ public class InboundPlanController {
|
||||
@Resource private InboundFbaAppointmentService appointmentService;
|
||||
|
||||
@PostMapping("/convert")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:create')")
|
||||
/** 将入仓单转换为入仓计划。 */
|
||||
@Operation(summary = "入仓单转入仓计划")
|
||||
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -34,6 +36,7 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@GetMapping("/{planNo}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')")
|
||||
/** 查询入仓计划及其明细、托盘和分配信息。 */
|
||||
@Operation(summary = "查询入仓计划详情")
|
||||
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -43,6 +46,7 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@PostMapping("/{planNo}/pallets/supplement")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:edit')")
|
||||
/** 为计划目标仓新增一个可打印的补充托盘。 */
|
||||
@Operation(summary = "新增补充托盘")
|
||||
public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -52,6 +56,7 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@PostMapping("/fba-appointments/batch")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:appointment')")
|
||||
/** 为选中的入仓明细创建 FBA 预约。 */
|
||||
@Operation(summary = "创建 FBA 预约")
|
||||
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
|
||||
@@ -63,6 +68,7 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@GetMapping("/fba-appointments/{appointmentNo}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')")
|
||||
/** 查询 FBA 预约及其选中的入仓明细。 */
|
||||
@Operation(summary = "查询 FBA 预约详情")
|
||||
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
|
||||
|
||||
+12
@@ -16,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
@@ -114,6 +117,15 @@ public class InboundPdaController {
|
||||
return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<PdaResponses.UnloadPhotoFile> uploadUnloadPhotoFile(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
||||
@RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) {
|
||||
requireOperate(user);
|
||||
return ApiResponse.success(service.uploadUnloadPhotoFile(
|
||||
scope(user, customerId), planNo, user.userId(), file));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
|
||||
@@ -12,6 +12,8 @@ public final class PdaResponses {
|
||||
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
|
||||
LocalDateTime estimatedEmptyReturnAt) {}
|
||||
|
||||
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
|
||||
|
||||
public record Operation(String result, long allocationId, int requiredPackageCount,
|
||||
int countedPackageCount, int pickedPackageCount,
|
||||
BigDecimal actualVolumeCbm, Integer remainingPackageCount,
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.gateway;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
|
||||
private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO";
|
||||
@Resource private RestClient.Builder restClientBuilder;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
private RestClient client;
|
||||
|
||||
@PostConstruct
|
||||
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
|
||||
|
||||
@Override
|
||||
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
|
||||
long tenantId = tenantId(customerId);
|
||||
MultipartBodyBuilder body = new MultipartBodyBuilder();
|
||||
try {
|
||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||
@Override public String getFilename() { return file.getOriginalFilename(); }
|
||||
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
|
||||
: MediaType.parseMediaType(file.getContentType()));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("Cannot read unload photo", exception);
|
||||
}
|
||||
MetadataEnvelope response = client.post()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
|
||||
tenantId, BUSINESS_TYPE, taskId)
|
||||
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new IllegalArgumentException("Unload photo upload failed");
|
||||
}).body(MetadataEnvelope.class);
|
||||
Metadata data = response == null ? null : response.data();
|
||||
if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata");
|
||||
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
|
||||
data.sizeBytes() == null ? 0 : data.sizeBytes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(String customerId, long taskId, long fileId) {
|
||||
long tenantId = tenantId(customerId);
|
||||
MetadataEnvelope response = client.get()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
|
||||
tenantId, BUSINESS_TYPE, taskId, fileId)
|
||||
.header("X-Internal-Service-Token", internalToken)
|
||||
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid");
|
||||
}).body(MetadataEnvelope.class);
|
||||
Metadata data = response == null ? null : response.data();
|
||||
if (data == null || data.fileId() == null || data.fileId() != fileId
|
||||
|| data.tenantId() == null || data.tenantId() != tenantId
|
||||
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) {
|
||||
throw new IllegalArgumentException("Unload photo metadata does not match the PDA task");
|
||||
}
|
||||
}
|
||||
|
||||
private long tenantId(String customerId) {
|
||||
try { return Long.parseLong(customerId); }
|
||||
catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); }
|
||||
}
|
||||
|
||||
private record MetadataEnvelope(Metadata data) {}
|
||||
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String contentType, Long sizeBytes) {}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.gateway;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface UnloadPhotoFileGateway {
|
||||
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
|
||||
void validate(String customerId, long taskId, long fileId);
|
||||
|
||||
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
|
||||
}
|
||||
+41
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
@@ -21,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -40,12 +42,14 @@ import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class InboundPdaWorkflowService {
|
||||
private static final long MAX_UNLOAD_PHOTO_SIZE = 10L * 1024 * 1024;
|
||||
private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000");
|
||||
|
||||
@Resource private PdaWorkflowRepository repository;
|
||||
@Resource private WarehouseResourceGateway warehouse;
|
||||
@Resource private InboundStockReceiptService stockReceipts;
|
||||
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
|
||||
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
|
||||
@Resource private ObjectMapper sourceObjectMapper;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
@@ -421,6 +425,7 @@ public class InboundPdaWorkflowService {
|
||||
InboundPdaTask task = requireActiveTask(customerId, plan);
|
||||
List<Long> fileIds = request.fileIds().stream().distinct().sorted().toList();
|
||||
if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated");
|
||||
fileIds.forEach(fileId -> validateUnloadPhoto(customerId, task.getId(), fileId));
|
||||
LocalDateTime uploadedAt = LocalDateTime.now();
|
||||
task.setUnloadPhotoFileIds(write(fileIds));
|
||||
task.setUnloadPhotoUploadedAt(uploadedAt);
|
||||
@@ -433,6 +438,21 @@ public class InboundPdaWorkflowService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public PdaResponses.UnloadPhotoFile uploadUnloadPhotoFile(String customerId, String planNo,
|
||||
long operatorId, MultipartFile file) {
|
||||
validateUnloadPhotoFile(file);
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
InboundPdaTask task = requireActiveTask(customerId, plan);
|
||||
final UnloadPhotoFileGateway.UploadedFile uploaded;
|
||||
try {
|
||||
uploaded = unloadPhotoFiles.upload(customerId, task.getId(), operatorId, file);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception);
|
||||
}
|
||||
return new PdaResponses.UnloadPhotoFile(uploaded.fileId(), uploaded.fileName(),
|
||||
uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId,
|
||||
int packageCount, BigDecimal movedVolume) {
|
||||
source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount);
|
||||
@@ -623,6 +643,27 @@ public class InboundPdaWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUnloadPhoto(String customerId, long taskId, long fileId) {
|
||||
try {
|
||||
unloadPhotoFiles.validate(customerId, taskId, fileId);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw conflict("Unload photo does not exist or does not belong to this PDA task");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateUnloadPhotoFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot be empty");
|
||||
}
|
||||
if (file.getSize() > MAX_UNLOAD_PHOTO_SIZE) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot exceed 10 MB");
|
||||
}
|
||||
String contentType = file.getContentType();
|
||||
if (!"image/jpeg".equalsIgnoreCase(contentType) && !"image/png".equalsIgnoreCase(contentType)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo must be JPG or PNG");
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal perPackageVolume(InboundPlanLine line) {
|
||||
int required = value(line.getRequiredPackageCount());
|
||||
if (required <= 0) throw conflict("Inbound line required package count must be positive");
|
||||
|
||||
+5
-12
@@ -9,11 +9,9 @@ import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasContainerUnloadedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -29,26 +27,21 @@ import java.util.List;
|
||||
@Service
|
||||
public class InboundUnloadCompletedOutboxService {
|
||||
@Resource private LastlegEventOutboxMapper mapper;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public void publish(InboundPlan plan, InboundPdaTask task, List<Long> photoFileIds) {
|
||||
LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId());
|
||||
if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null
|
||||
|| sourceOrder.getSourceContainerBookingId().isBlank()) {
|
||||
throw new IllegalStateException("Inbound plan has no source container booking ID");
|
||||
}
|
||||
Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC);
|
||||
var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), photoUploadedAt,
|
||||
Instant unloadingCompletedAt = task.getCompletedAt().toInstant(ZoneOffset.UTC);
|
||||
var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), unloadingCompletedAt,
|
||||
photoUploadedAt, null);
|
||||
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||
// Multiple inbound plans can share one container; upload epoch keeps their container events ordered.
|
||||
long aggregateVersion = photoUploadedAt.toEpochMilli();
|
||||
// Multiple inbound plans can share one container; completion epoch keeps their container events ordered.
|
||||
long aggregateVersion = unloadingCompletedAt.toEpochMilli();
|
||||
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId();
|
||||
String traceId = EventIdentifiers.newUuid();
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING",
|
||||
sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode,
|
||||
plan.getContainerNo(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
|
||||
|
||||
LastlegEventOutbox event = new LastlegEventOutbox();
|
||||
|
||||
+10
-2
@@ -11,6 +11,7 @@ import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -21,11 +22,12 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@RequestMapping("/api/platform/v1/last-leg/inventory")
|
||||
@Tag(name = "尾程入仓单库存操作")
|
||||
public class InventoryCommandController {
|
||||
public static final String MANAGE_PERMISSION = "lastleg:inventory:manage";
|
||||
public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust";
|
||||
public static final String MANAGE_PERMISSION = "platform:lastleg:inventory:manage";
|
||||
public static final String ADJUST_PERMISSION = "platform:lastleg:inventory:adjust";
|
||||
@Resource private InventoryCommandService service;
|
||||
|
||||
@PostMapping("/hold")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
|
||||
/** 将可用入仓单箱数转为冻结。 */
|
||||
@Operation(summary = "冻结入仓单库存")
|
||||
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -35,6 +37,7 @@ public class InventoryCommandController {
|
||||
}
|
||||
|
||||
@PostMapping("/release")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
|
||||
/** 释放已冻结的入仓单箱数。 */
|
||||
@Operation(summary = "释放入仓单库存")
|
||||
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -44,6 +47,7 @@ public class InventoryCommandController {
|
||||
}
|
||||
|
||||
@PostMapping("/allocate")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
|
||||
/** 将可用入仓单箱数分配给出库单。 */
|
||||
@Operation(summary = "分配出库库存")
|
||||
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -53,6 +57,7 @@ public class InventoryCommandController {
|
||||
}
|
||||
|
||||
@PostMapping("/unallocate")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
|
||||
/** 取消出库库存分配。 */
|
||||
@Operation(summary = "取消出库分配")
|
||||
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -62,6 +67,7 @@ public class InventoryCommandController {
|
||||
}
|
||||
|
||||
@PostMapping("/pick")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
|
||||
/** 确认入仓单箱数已拣货。 */
|
||||
@Operation(summary = "确认拣货")
|
||||
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -71,6 +77,7 @@ public class InventoryCommandController {
|
||||
}
|
||||
|
||||
@PostMapping("/ship")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
|
||||
/** 出库已拣货箱数并返回托盘是否为空。 */
|
||||
@Operation(summary = "出库拣货库存")
|
||||
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -80,6 +87,7 @@ public class InventoryCommandController {
|
||||
}
|
||||
|
||||
@PostMapping("/adjust")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:adjust')")
|
||||
/** 根据实物盘点结果调整入仓单库存。 */
|
||||
@Operation(summary = "盘点调整库存")
|
||||
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+7
@@ -18,6 +18,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -35,6 +36,7 @@ public class InventoryQueryController {
|
||||
@Resource private InventoryQueryService service;
|
||||
|
||||
@GetMapping(BASE_PATH + "/summary")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
|
||||
/** 查询入仓单库存汇总。 */
|
||||
@Operation(summary = "查询库存汇总")
|
||||
public ApiResponse<InventoryDashboardResponse> summary(
|
||||
@@ -45,6 +47,7 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/balances")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
|
||||
/** 按客户、仓库和入仓单分页查询库存。 */
|
||||
@Operation(summary = "分页查询库存余额")
|
||||
public ApiResponse<PageResult<InventoryBalanceView>> balances(
|
||||
@@ -55,6 +58,7 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
|
||||
/** 查询库存余额对应的托盘和批次位置。 */
|
||||
@Operation(summary = "查询库存位置")
|
||||
public ApiResponse<List<InventoryPositionView>> positions(
|
||||
@@ -66,6 +70,7 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/ledger")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
|
||||
/** 分页查询只增不改的库存流水。 */
|
||||
@Operation(summary = "分页查询库存流水")
|
||||
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
|
||||
@@ -76,6 +81,7 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:export')")
|
||||
/** 导出筛选后的入仓单库存 CSV 文件。 */
|
||||
@Operation(summary = "导出库存余额")
|
||||
public ResponseEntity<byte[]> exportBalances(
|
||||
@@ -86,6 +92,7 @@ public class InventoryQueryController {
|
||||
}
|
||||
|
||||
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:inventory:export')")
|
||||
/** 导出筛选后的库存流水 CSV 文件。 */
|
||||
@Operation(summary = "导出库存流水")
|
||||
public ResponseEntity<byte[]> exportLedgers(
|
||||
|
||||
@@ -3,7 +3,47 @@ package com.baiyue.lastleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegEventOutboxMapper extends BaseMapper<LastlegEventOutbox> {
|
||||
@Select("""
|
||||
SELECT id FROM ll_event_outbox
|
||||
WHERE event_type='OverseasContainerUnloaded' AND retry_count < #{maxAttempts}
|
||||
AND (status='PENDING' OR (status='PROCESSING' AND locked_at <= #{staleBefore}))
|
||||
AND (next_retry_at IS NULL OR next_retry_at <= #{now})
|
||||
ORDER BY created_at,id LIMIT #{limit}
|
||||
""")
|
||||
List<Long> relayCandidates(@Param("limit") int limit, @Param("maxAttempts") int maxAttempts,
|
||||
@Param("now") LocalDateTime now, @Param("staleBefore") LocalDateTime staleBefore);
|
||||
|
||||
@Update("""
|
||||
UPDATE ll_event_outbox SET status='PROCESSING',locked_by=#{worker},locked_at=#{now}
|
||||
WHERE id=#{id} AND (status='PENDING' OR (status='PROCESSING' AND locked_at <= #{staleBefore}))
|
||||
""")
|
||||
int claim(@Param("id") long id, @Param("worker") String worker, @Param("now") LocalDateTime now,
|
||||
@Param("staleBefore") LocalDateTime staleBefore);
|
||||
|
||||
@Select("SELECT * FROM ll_event_outbox WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker}")
|
||||
LastlegEventOutbox claimed(@Param("id") long id, @Param("worker") String worker);
|
||||
|
||||
@Update("""
|
||||
UPDATE ll_event_outbox SET status='PUBLISHED',published_at=#{now},next_retry_at=NULL,
|
||||
last_error=NULL,locked_by=NULL,locked_at=NULL
|
||||
WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker}
|
||||
""")
|
||||
int markPublished(@Param("id") long id, @Param("worker") String worker, @Param("now") LocalDateTime now);
|
||||
|
||||
@Update("""
|
||||
UPDATE ll_event_outbox SET status=#{status},retry_count=retry_count+1,next_retry_at=#{retryAt},
|
||||
last_error=#{error},locked_by=NULL,locked_at=NULL
|
||||
WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker}
|
||||
""")
|
||||
int markFailed(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
|
||||
@Param("retryAt") LocalDateTime retryAt, @Param("error") String error);
|
||||
}
|
||||
|
||||
+7
-1
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -26,6 +27,7 @@ public class PlatformOverseasProcurementController {
|
||||
@Resource private OverseasProcurementService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')")
|
||||
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -35,6 +37,7 @@ public class PlatformOverseasProcurementController {
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')")
|
||||
public ApiResponse<OverseasProcurementResponses.Detail> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo) {
|
||||
@@ -43,6 +46,7 @@ public class PlatformOverseasProcurementController {
|
||||
}
|
||||
|
||||
@PostMapping("/{orderNo}/review")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:review')")
|
||||
public ApiResponse<OverseasProcurementResponses.Detail> review(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) {
|
||||
@@ -51,6 +55,7 @@ public class PlatformOverseasProcurementController {
|
||||
}
|
||||
|
||||
@PatchMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:edit')")
|
||||
public ApiResponse<OverseasProcurementResponses.Detail> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) {
|
||||
@@ -59,10 +64,11 @@ public class PlatformOverseasProcurementController {
|
||||
}
|
||||
|
||||
@PostMapping("/{orderNo}/outbounds")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:outbound')")
|
||||
public ApiResponse<OverseasProcurementResponses.Detail> addOutbound(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) {
|
||||
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
|
||||
requirePermission(user, "platform:lastleg:overseas-procurement:outbound");
|
||||
return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -31,8 +31,8 @@ import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class OverseasProcurementService {
|
||||
public static final String REVIEW_PERMISSION = "lastleg:overseas-procurement:review";
|
||||
public static final String MANAGE_PERMISSION = "lastleg:overseas-procurement:manage";
|
||||
public static final String REVIEW_PERMISSION = "platform:lastleg:overseas-procurement:review";
|
||||
public static final String MANAGE_PERMISSION = "platform:lastleg:overseas-procurement:edit";
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
public static final String APPROVED = "APPROVED";
|
||||
public static final String REJECTED = "REJECTED";
|
||||
|
||||
+5
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -19,21 +20,25 @@ public class RelabelApplicationController {
|
||||
@Resource private RelabelApplicationService service;
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:relabel:create')")
|
||||
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) {
|
||||
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
|
||||
}
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:relabel:list')")
|
||||
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
}
|
||||
@GetMapping("/{relabelNo}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:relabel:list')")
|
||||
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String relabelNo) {
|
||||
return ApiResponse.success(service.detail(scope(user, customerId), relabelNo));
|
||||
}
|
||||
@PutMapping("/{relabelNo}/status")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:relabel:status')")
|
||||
public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String relabelNo,
|
||||
@Valid @RequestBody RelabelRequests.ChangeStatus request) {
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class ContainerUnloadOutboxRelay {
|
||||
private static final int MAX_ATTEMPTS = 12;
|
||||
@Resource private LastlegEventOutboxMapper outbox;
|
||||
@Resource private EventBridgePublisher publisher;
|
||||
private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID();
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.container-unload-relay.fixed-delay:3000}")
|
||||
public void relay() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime staleBefore = now.minusMinutes(2);
|
||||
for (Long id : outbox.relayCandidates(50, MAX_ATTEMPTS, now, staleBefore)) {
|
||||
relayOne(id, now, staleBefore);
|
||||
}
|
||||
}
|
||||
|
||||
void relayOne(long id, LocalDateTime now, LocalDateTime staleBefore) {
|
||||
if (outbox.claim(id, worker, now, staleBefore) != 1) return;
|
||||
LastlegEventOutbox row = outbox.claimed(id, worker);
|
||||
if (row == null) return;
|
||||
try {
|
||||
publisher.publish(row.getPayloadJson());
|
||||
outbox.markPublished(id, worker, LocalDateTime.now());
|
||||
} catch (Exception exception) {
|
||||
int attempt = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1;
|
||||
boolean exhausted = attempt >= MAX_ATTEMPTS;
|
||||
LocalDateTime retryAt = exhausted ? null : LocalDateTime.now().plusSeconds(backoffSeconds(attempt));
|
||||
outbox.markFailed(id, worker, exhausted ? "FAILED" : "PENDING", retryAt,
|
||||
abbreviate(exception.getMessage()));
|
||||
log.warn("海外卸柜事件投递失败 id={}, attempt={}, reason={}", id, attempt, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static long backoffSeconds(int attempt) {
|
||||
return Math.min(900L, 5L << Math.min(Math.max(attempt - 1, 0), 8));
|
||||
}
|
||||
|
||||
private static String abbreviate(String value) {
|
||||
String safe = value == null || value.isBlank() ? "未知投递错误" : value;
|
||||
return safe.length() <= 1000 ? safe : safe.substring(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
public class EventBridgePublisher {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.clients.event-bridge.url:http://127.0.0.1:9312}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
|
||||
private HttpClient httpClient;
|
||||
|
||||
@PostConstruct
|
||||
void initialize() {
|
||||
httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
|
||||
}
|
||||
|
||||
public void publish(String envelopeJson) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/internal/event-bridge/events"))
|
||||
.timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json")
|
||||
.header("X-Internal-Service-Token", serviceToken)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(envelopeJson)).build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode body = objectMapper.readTree(response.body());
|
||||
if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) {
|
||||
throw new IllegalStateException("事件桥接收失败: HTTP " + response.statusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -120,8 +120,7 @@ public class LoadingCompletedConsumer {
|
||||
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
|
||||
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId());
|
||||
order.setSourceLoadingPlanVersion(event.aggregateVersion());
|
||||
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion());
|
||||
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
|
||||
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());
|
||||
try {
|
||||
|
||||
+12
-3
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -26,6 +27,7 @@ public class PlatformUsTruckOrderController {
|
||||
@Resource private UsTruckOrderService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')")
|
||||
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid UsTruckOrderRequests.Query query) {
|
||||
requirePlatform(user);
|
||||
@@ -33,6 +35,7 @@ public class PlatformUsTruckOrderController {
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')")
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo) {
|
||||
requirePlatform(user);
|
||||
@@ -40,6 +43,7 @@ public class PlatformUsTruckOrderController {
|
||||
}
|
||||
|
||||
@PatchMapping("/{orderNo}/status")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:status')")
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo,
|
||||
@Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
|
||||
@@ -48,10 +52,11 @@ public class PlatformUsTruckOrderController {
|
||||
}
|
||||
|
||||
@PostMapping("/{orderNo}/receive-pallets")
|
||||
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:receive')")
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> receivePallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String customerId, @PathVariable String orderNo,
|
||||
@Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) {
|
||||
requirePermission(user);
|
||||
requirePermission(user, "platform:lastleg:us-trucking:receive");
|
||||
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||
}
|
||||
|
||||
@@ -63,10 +68,14 @@ public class PlatformUsTruckOrderController {
|
||||
}
|
||||
|
||||
private void requirePermission(AuthenticatedUser user) {
|
||||
requirePermission(user, UsTruckOrderService.MANAGE_PERMISSION);
|
||||
}
|
||||
|
||||
private void requirePermission(AuthenticatedUser user, String permission) {
|
||||
requirePlatform(user);
|
||||
if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) {
|
||||
if (user.permissions() == null || !user.permissions().contains(permission)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
UsTruckOrderService.MANAGE_PERMISSION + " permission is required");
|
||||
permission + " permission is required");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import java.util.function.Consumer;
|
||||
|
||||
@Service
|
||||
public class UsTruckOrderService {
|
||||
public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage";
|
||||
public static final String MANAGE_PERMISSION = "platform:lastleg:us-trucking:status";
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
/** Kept for source compatibility; new orders use PENDING_REVIEW. */
|
||||
@Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW;
|
||||
|
||||
@@ -35,6 +35,9 @@ spring:
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
baiyue:
|
||||
clients:
|
||||
event-bridge:
|
||||
url: ${CN_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
|
||||
security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
|
||||
jwt-mode: jwks
|
||||
|
||||
@@ -62,7 +62,6 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
||||
warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)',
|
||||
source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID',
|
||||
source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID',
|
||||
source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID',
|
||||
source_loading_plan_version BIGINT COMMENT '来源装柜计划版本',
|
||||
source_handover_version BIGINT COMMENT '交接版本',
|
||||
source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要',
|
||||
@@ -164,8 +163,6 @@ CREATE TABLE IF NOT EXISTS ll_order_status_history (
|
||||
-- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。
|
||||
ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱';
|
||||
ALTER TABLE ll_order COMMENT='尾程订单主表';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
||||
PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt;
|
||||
ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细';
|
||||
ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史';
|
||||
ALTER TABLE ll_event_conflict COMMENT='事件冲突记录';
|
||||
@@ -240,7 +237,6 @@ ALTER TABLE ll_order
|
||||
MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)',
|
||||
MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID',
|
||||
MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID',
|
||||
MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID',
|
||||
MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本',
|
||||
MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本',
|
||||
MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要',
|
||||
@@ -519,7 +515,8 @@ CREATE TABLE IF NOT EXISTS ll_event_outbox (
|
||||
event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
|
||||
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', locked_by VARCHAR(128) NULL COMMENT '当前投递租约持有者',
|
||||
locked_at DATETIME(3) NULL COMMENT '当前投递租约获取时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key),
|
||||
KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱';
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.lastleg.controller;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchController;
|
||||
import com.baiyue.lastleg.inboundplan.controller.InboundPlanController;
|
||||
import com.baiyue.lastleg.inventory.query.controller.InventoryQueryController;
|
||||
import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController;
|
||||
import com.baiyue.lastleg.procurement.controller.PlatformOverseasProcurementController;
|
||||
import com.baiyue.lastleg.relabel.controller.RelabelApplicationController;
|
||||
import com.baiyue.lastleg.trucking.controller.PlatformUsTruckOrderController;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LastlegPlatformPermissionContractTest {
|
||||
@Test
|
||||
void readEndpointsReuseListPermissionsAndActionsStayIndependent() {
|
||||
assertPermission(LastlegOrderController.class, "list", "platform:lastleg:order:list");
|
||||
assertPermission(LastlegOrderController.class, "detail", "platform:lastleg:order:list");
|
||||
assertPermission(LastlegOrderController.class, "export", "platform:lastleg:order:export");
|
||||
assertPermission(LastlegOrderController.class, "transferOne", "platform:lastleg:order:transfer");
|
||||
|
||||
assertPermission(InboundPlanController.class, "detail", "platform:lastleg:inbound-plan:list");
|
||||
assertPermission(InboundPlanController.class, "fbaAppointmentDetail", "platform:lastleg:inbound-plan:list");
|
||||
assertPermission(InboundPlanController.class, "convert", "platform:lastleg:inbound-plan:create");
|
||||
assertPermission(InboundPlanController.class, "batchFbaAppointment", "platform:lastleg:inbound-plan:appointment");
|
||||
|
||||
assertPermission(RelabelApplicationController.class, "page", "platform:lastleg:relabel:list");
|
||||
assertPermission(RelabelApplicationController.class, "detail", "platform:lastleg:relabel:list");
|
||||
assertPermission(InventoryQueryController.class, "balances", "platform:lastleg:inventory:list");
|
||||
assertPermission(InventoryQueryController.class, "positions", "platform:lastleg:inventory:list");
|
||||
assertPermission(InventoryQueryController.class, "exportBalances", "platform:lastleg:inventory:export");
|
||||
assertPermission(InventoryCommandController.class, "hold", "platform:lastleg:inventory:manage");
|
||||
assertPermission(InventoryCommandController.class, "adjust", "platform:lastleg:inventory:adjust");
|
||||
|
||||
assertPermission(DispatchController.class, "page", "platform:lastleg:dispatch:list");
|
||||
assertPermission(DispatchController.class, "detail", "platform:lastleg:dispatch:list");
|
||||
assertPermission(DispatchController.class, "preArrange", "platform:lastleg:dispatch:create");
|
||||
|
||||
assertPermission(PlatformOverseasProcurementController.class, "page", "platform:lastleg:overseas-procurement:list");
|
||||
assertPermission(PlatformOverseasProcurementController.class, "detail", "platform:lastleg:overseas-procurement:list");
|
||||
assertPermission(PlatformUsTruckOrderController.class, "page", "platform:lastleg:us-trucking:list");
|
||||
assertPermission(PlatformUsTruckOrderController.class, "detail", "platform:lastleg:us-trucking:list");
|
||||
}
|
||||
|
||||
private static void assertPermission(Class<?> type, String methodName, String permission) {
|
||||
Method method = Arrays.stream(type.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(methodName))
|
||||
.findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class)).isNotNull();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
}
|
||||
}
|
||||
+56
-1
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -20,6 +21,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -33,6 +35,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -44,6 +47,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
@Mock WarehouseResourceGateway warehouse;
|
||||
@Mock InboundStockReceiptService stockReceipts;
|
||||
@Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox;
|
||||
@Mock UnloadPhotoFileGateway unloadPhotoFiles;
|
||||
private InboundPdaWorkflowService service;
|
||||
|
||||
private InboundPlan plan;
|
||||
@@ -64,6 +68,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
|
||||
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox);
|
||||
ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles);
|
||||
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
service.initializeObjectMapper();
|
||||
plan = plan();
|
||||
@@ -76,7 +81,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0);
|
||||
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
|
||||
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
|
||||
when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
|
||||
lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -326,6 +331,8 @@ class InboundPdaWorkflowServiceTest {
|
||||
|
||||
assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds());
|
||||
assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt());
|
||||
verify(unloadPhotoFiles).validate("100", task.getId(), 101L);
|
||||
verify(unloadPhotoFiles).validate("100", task.getId(), 103L);
|
||||
verify(repository).update(task);
|
||||
ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class);
|
||||
verify(repository).insert(operation.capture());
|
||||
@@ -344,6 +351,54 @@ class InboundPdaWorkflowServiceTest {
|
||||
verify(repository, never()).update(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadUnloadPhotosRejectsAFileOwnedByAnotherBusinessTask() {
|
||||
activeTask();
|
||||
org.mockito.Mockito.doThrow(new IllegalArgumentException("ownership mismatch"))
|
||||
.when(unloadPhotoFiles).validate("100", task.getId(), 101L);
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.uploadUnloadPhotos("100", "LIP1",
|
||||
new PdaRequests.UnloadPhotos(List.of(101L), "unload-photo-wrong-owner", "7", "worker")));
|
||||
|
||||
verify(repository, never()).update(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadUnloadPhotoFileBindsTheFileToTheActivePdaTask() {
|
||||
activeTask();
|
||||
var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[] {1, 2, 3});
|
||||
when(unloadPhotoFiles.upload("100", task.getId(), 7L, file))
|
||||
.thenReturn(new UnloadPhotoFileGateway.UploadedFile(101L, "unload.jpg", "image/jpeg", 3L));
|
||||
|
||||
var result = service.uploadUnloadPhotoFile("100", "LIP1", 7L, file);
|
||||
|
||||
assertEquals(101L, result.fileId());
|
||||
assertEquals("unload.jpg", result.fileName());
|
||||
assertEquals("image/jpeg", result.contentType());
|
||||
assertEquals(3L, result.sizeBytes());
|
||||
verify(unloadPhotoFiles).upload("100", task.getId(), 7L, file);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadUnloadPhotoFileRejectsEmptyFileBeforeCallingFileService() {
|
||||
var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[0]);
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file));
|
||||
|
||||
verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadUnloadPhotoFileRejectsNonImageBeforeCallingFileService() {
|
||||
var file = new MockMultipartFile("file", "unload.pdf", "application/pdf", new byte[] {1});
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file));
|
||||
|
||||
verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesWhenMergedAllocationsPreserveExactLineTotals() {
|
||||
activeTask();
|
||||
|
||||
+8
-17
@@ -2,13 +2,12 @@ package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasContainerUnloadedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -29,7 +28,6 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InboundUnloadCompletedOutboxServiceTest {
|
||||
@Mock LastlegEventOutboxMapper outbox;
|
||||
@Mock LastlegOrderMapper orders;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
private InboundUnloadCompletedOutboxService service;
|
||||
|
||||
@@ -37,17 +35,15 @@ class InboundUnloadCompletedOutboxServiceTest {
|
||||
void setUp() {
|
||||
service = new InboundUnloadCompletedOutboxService();
|
||||
ReflectionTestUtils.setField(service, "mapper", outbox);
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesAValidOverseasToCnEnvelopeUsingTheSourceBookingId() throws Exception {
|
||||
InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); plan.setContainerNo("MSCU1");
|
||||
void writesAValidOverseasToCnEnvelopeUsingTheContainerNo() throws Exception {
|
||||
InboundPlan plan = new InboundPlan(); plan.setContainerNo("MSCU1");
|
||||
InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100");
|
||||
task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); task.setVersion(3);
|
||||
LastlegOrder order = new LastlegOrder(); order.setSourceContainerBookingId("9001");
|
||||
when(orders.selectById(11L)).thenReturn(order);
|
||||
task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0));
|
||||
task.setCompletedAt(LocalDateTime.of(2026, 8, 20, 10, 5)); task.setVersion(3);
|
||||
|
||||
service.publish(plan, task, List.of(101L));
|
||||
|
||||
@@ -57,15 +53,10 @@ class InboundUnloadCompletedOutboxServiceTest {
|
||||
assertEquals(EventTypes.OVERSEAS_CONTAINER_UNLOADED, envelope.eventType());
|
||||
assertEquals(RegionCode.OVERSEAS, envelope.sourceRegion());
|
||||
assertEquals(RegionCode.CN, envelope.targetRegion());
|
||||
assertEquals("9001", envelope.aggregateId());
|
||||
assertEquals("MSCU1", envelope.aggregateId());
|
||||
assertEquals("MSCU1", envelope.payload().path("containerNo").asText());
|
||||
OverseasContainerUnloadedV1 payload = objectMapper.treeToValue(envelope.payload(), OverseasContainerUnloadedV1.class);
|
||||
assertEquals("2026-08-20T10:05:00Z", payload.unloadingCompletedAt().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPlansWhoseSourceOrderLostTheContainerBookingId() {
|
||||
InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L);
|
||||
InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100");
|
||||
when(orders.selectById(11L)).thenReturn(new LastlegOrder());
|
||||
assertThrows(IllegalStateException.class, () -> service.publish(plan, task, List.of(101L)));
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ContainerUnloadOutboxRelayTest {
|
||||
private final LastlegEventOutboxMapper outbox = mock(LastlegEventOutboxMapper.class);
|
||||
private final EventBridgePublisher publisher = mock(EventBridgePublisher.class);
|
||||
private ContainerUnloadOutboxRelay relay;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
relay = new ContainerUnloadOutboxRelay();
|
||||
ReflectionTestUtils.setField(relay, "outbox", outbox);
|
||||
ReflectionTestUtils.setField(relay, "publisher", publisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishesClaimedEnvelopeAndMarksItDone() throws Exception {
|
||||
LastlegEventOutbox row = row(0);
|
||||
when(outbox.claim(eq(11L), anyString(), any(), any())).thenReturn(1);
|
||||
when(outbox.claimed(eq(11L), anyString())).thenReturn(row);
|
||||
|
||||
relay.relayOne(11L, LocalDateTime.now(), LocalDateTime.now().minusMinutes(2));
|
||||
|
||||
verify(publisher).publish("{\"eventId\":\"event-1\"}");
|
||||
verify(outbox).markPublished(eq(11L), anyString(), any());
|
||||
verify(outbox, never()).markFailed(anyLong(), anyString(), anyString(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void schedulesRetryWhenBridgeIsUnavailable() throws Exception {
|
||||
LastlegEventOutbox row = row(2);
|
||||
when(outbox.claim(eq(11L), anyString(), any(), any())).thenReturn(1);
|
||||
when(outbox.claimed(eq(11L), anyString())).thenReturn(row);
|
||||
doThrow(new IllegalStateException("bridge down")).when(publisher).publish(anyString());
|
||||
|
||||
relay.relayOne(11L, LocalDateTime.now(), LocalDateTime.now().minusMinutes(2));
|
||||
|
||||
verify(outbox).markFailed(eq(11L), anyString(), eq("PENDING"), any(), eq("bridge down"));
|
||||
verify(outbox, never()).markPublished(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
private LastlegEventOutbox row(int retries) {
|
||||
LastlegEventOutbox value = new LastlegEventOutbox();
|
||||
value.setId(11L); value.setPayloadJson("{\"eventId\":\"event-1\"}"); value.setRetryCount(retries);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
|
||||
class LoadingCompletedConsumerTest {
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
|
||||
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"book-1".equals(o.getSourceContainerBookingId()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
|
||||
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
|
||||
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
|
||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
|
||||
@@ -3577,3 +3577,70 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_inbound_plan ADD UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id,source_order_id)'));
|
||||
PREPARE ll_inbound_plan_source_order_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_unique_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_unique_027;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-20 / 028
|
||||
-- Overseas PDA unload photos and container empty-return event outbox.
|
||||
-- ============================================================================
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_event_outbox (
|
||||
id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本',
|
||||
event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
|
||||
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', locked_by VARCHAR(128) NULL COMMENT '当前投递租约持有者',
|
||||
locked_at DATETIME(3) NULL COMMENT '当前投递租约获取时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), KEY idx_ll_event_outbox_publish (status,next_retry_at,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_event_outbox'), 'ALTER TABLE ll_event_outbox COMMENT=''尾程跨区业务事件发件箱''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='id'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN id BIGINT NOT NULL COMMENT ''事件主键ID''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='customer_id'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='aggregate_type'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT ''聚合类型''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='aggregate_id'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT ''聚合标识''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='event_type'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT ''事件类型''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='event_version'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN event_version INT NOT NULL COMMENT ''事件版本''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='event_key'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN event_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='payload_json'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT ''事件载荷JSON''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='status'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''发布状态''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='retry_count'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT ''重试次数''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='next_retry_at'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN next_retry_at DATETIME(3) NULL COMMENT ''下次重试时间''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='published_at'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN published_at DATETIME(3) NULL COMMENT ''发布时间''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='last_error'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT ''最后一次发布错误''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='created_at'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1'));
|
||||
PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-20 / 029
|
||||
-- Reliable lease for the overseas container-unload event relay.
|
||||
-- ============================================================================
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''当前投递租约持有者'' AFTER last_error'));
|
||||
PREPARE lastleg_unload_relay_029 FROM @ddl; EXECUTE lastleg_unload_relay_029; DEALLOCATE PREPARE lastleg_unload_relay_029;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''当前投递租约获取时间'' AFTER locked_by'));
|
||||
PREPARE lastleg_unload_relay_029 FROM @ddl; EXECUTE lastleg_unload_relay_029; DEALLOCATE PREPARE lastleg_unload_relay_029;
|
||||
|
||||
+6
@@ -17,6 +17,7 @@ import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
@@ -53,6 +54,7 @@ public class PlatformWarehouseController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
|
||||
})
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:warehouse:create')")
|
||||
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody WarehouseRequests.SaveRequest request) {
|
||||
requirePlatform(user);
|
||||
@@ -72,6 +74,7 @@ public class PlatformWarehouseController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
|
||||
})
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:warehouse:edit')")
|
||||
public ApiResponse<Void> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
|
||||
@@ -95,6 +98,7 @@ public class PlatformWarehouseController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限")
|
||||
})
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:warehouse:list')")
|
||||
public ApiResponse<PageResult<WarehouseEntity>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
|
||||
@@ -115,6 +119,7 @@ public class PlatformWarehouseController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在")
|
||||
})
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:warehouse:list')")
|
||||
public ApiResponse<WarehouseDetailResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
|
||||
@@ -137,6 +142,7 @@ public class PlatformWarehouseController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在")
|
||||
})
|
||||
@PatchMapping("/{id}/status")
|
||||
@PreAuthorize("hasAuthority('platform:warehouse:status')")
|
||||
public ApiResponse<Void> status(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
|
||||
|
||||
+11
@@ -14,6 +14,7 @@ import jakarta.validation.Valid;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -32,6 +33,7 @@ public class PlatformWarehouseResourceController {
|
||||
@Resource private WarehouseResourceService service;
|
||||
|
||||
@PostMapping("/locations")
|
||||
@PreAuthorize("hasAuthority('platform:location:create')")
|
||||
public ApiResponse<Long> createLocation(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId,
|
||||
@@ -41,6 +43,7 @@ public class PlatformWarehouseResourceController {
|
||||
}
|
||||
|
||||
@PutMapping("/locations/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:location:edit')")
|
||||
public ApiResponse<Void> updateLocation(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId, @PathVariable long id,
|
||||
@@ -51,6 +54,7 @@ public class PlatformWarehouseResourceController {
|
||||
}
|
||||
|
||||
@GetMapping("/locations")
|
||||
@PreAuthorize("hasAuthority('platform:location:list')")
|
||||
public ApiResponse<PageResult<WarehouseLocationEntity>> locations(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) {
|
||||
@@ -59,6 +63,7 @@ public class PlatformWarehouseResourceController {
|
||||
}
|
||||
|
||||
@GetMapping("/locations/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:location:list')")
|
||||
public ApiResponse<WarehouseLocationEntity> location(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId,
|
||||
@@ -68,6 +73,7 @@ public class PlatformWarehouseResourceController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/locations/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:location:delete')")
|
||||
public ApiResponse<Void> deleteLocation(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId, @PathVariable long id) {
|
||||
@@ -77,6 +83,7 @@ public class PlatformWarehouseResourceController {
|
||||
}
|
||||
|
||||
@PostMapping("/pallets")
|
||||
@PreAuthorize("hasAuthority('platform:pallet:create')")
|
||||
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId,
|
||||
@@ -86,6 +93,7 @@ public class PlatformWarehouseResourceController {
|
||||
}
|
||||
|
||||
@PutMapping("/pallets/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:pallet:edit')")
|
||||
public ApiResponse<Void> updatePallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId, @PathVariable long id,
|
||||
@@ -96,6 +104,7 @@ public class PlatformWarehouseResourceController {
|
||||
}
|
||||
|
||||
@GetMapping("/pallets")
|
||||
@PreAuthorize("hasAuthority('platform:pallet:list')")
|
||||
public ApiResponse<PageResult<WarehousePalletEntity>> pallets(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) {
|
||||
@@ -104,6 +113,7 @@ public class PlatformWarehouseResourceController {
|
||||
}
|
||||
|
||||
@GetMapping("/pallets/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:pallet:list')")
|
||||
public ApiResponse<WarehousePalletEntity> pallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId, @PathVariable long id) {
|
||||
@@ -112,6 +122,7 @@ public class PlatformWarehouseResourceController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/pallets/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:pallet:delete')")
|
||||
public ApiResponse<Void> deletePallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@PathVariable long warehouseId, @PathVariable long id) {
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.warehouse.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformWarehousePermissionContractTest {
|
||||
@Test
|
||||
void platformWarehouseReadsUseListAndWritesUseIndependentPermissions() {
|
||||
assertPermission(PlatformWarehouseController.class, "page", "platform:warehouse:list");
|
||||
assertPermission(PlatformWarehouseController.class, "detail", "platform:warehouse:list");
|
||||
assertPermission(PlatformWarehouseController.class, "create", "platform:warehouse:create");
|
||||
assertPermission(PlatformWarehouseController.class, "update", "platform:warehouse:edit");
|
||||
assertPermission(PlatformWarehouseController.class, "status", "platform:warehouse:status");
|
||||
|
||||
assertPermission(PlatformWarehouseResourceController.class, "locations", "platform:location:list");
|
||||
assertPermission(PlatformWarehouseResourceController.class, "location", "platform:location:list");
|
||||
assertPermission(PlatformWarehouseResourceController.class, "createLocation", "platform:location:create");
|
||||
assertPermission(PlatformWarehouseResourceController.class, "pallets", "platform:pallet:list");
|
||||
assertPermission(PlatformWarehouseResourceController.class, "pallet", "platform:pallet:list");
|
||||
assertPermission(PlatformWarehouseResourceController.class, "createPallet", "platform:pallet:create");
|
||||
}
|
||||
|
||||
private static void assertPermission(Class<?> type, String methodName, String permission) {
|
||||
Method method = Arrays.stream(type.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(methodName))
|
||||
.findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class)).isNotNull();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user