This commit is contained in:
2026-09-10 11:18:31 +08:00
parent 8731efcbea
commit e9aa2ac047
22 changed files with 374 additions and 52 deletions
+11 -3
View File
@@ -1,8 +1,9 @@
# 测试环境 SQL 执行说明
## 唯一数据库初始化与发布入口
## 数据库初始化与权限发布入口
- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
- 国内区域全量入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境升级,不再按日期拆分结构发布脚本。
- 通用权限点独立入口:`权限点初始化-MySQL8.sql`。国内与海外业务的 Admin/Client 权限主数据均在 `baiyue_iam` 统一管理。当发布内容只新增或更新 IAM 权限资源及默认角色授权,且 IAM 基础表、父菜单和系统角色已存在时,可只执行该文件。它不建表,不创建账号、客户、仓库或业务数据。
- 海外区域固定入口:`海外区域统一增量-MySQL8.sql`。该文件已经内联 Lastleg 与 Warehouse Overseas 的完整空库基线,会自行创建并初始化 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 和独立的 `baiyue_event_bridge_overseas`,清库后不得再额外执行模块基线。IAM 安全事件 Inbox 和可恢复投影位于 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。
- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;AUTHORIZATION 同步冻结当前 `PLATFORM_ADMIN` 角色及 IAM 跨区前缀下的完整权限集合,保证空库重建后海外 Gateway 可立即鉴权。统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,其他既有开发账号不建立兼容表或长期补投入口。
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
@@ -25,6 +26,13 @@
4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。
5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。
### 权限点独立入口边界
- 新增权限时,同步维护 IAM 模块来源、`权限点初始化-MySQL8.sql`、对应区域的全量统一 SQL、前端按钮/路由、Controller `@PreAuthorize` 和 OpenAPI。
- 权限点入口只用于既有 IAM 基线上的权限新增或元数据/默认授权更新。涉及建表、字段、索引、角色创建或其他业务数据时,仍执行全量统一 SQL。
- 独立入口必须可重复执行,执行前校验稳定权限 ID/权限码没有被其他语义占用,执行后只在授权完整时返回 `permission_initialization_status = PASS`。
- 权限写入后不修改 Redis 中已签发的会话快照;受影响用户必须退出并重新登录。权限撤销不通过该 SQL 直接处理,应走 IAM 角色授权命令以同步撤销会话。
## Finance 首期人工财务基线
- Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。
@@ -53,7 +61,7 @@
固定入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。
测试环境只执行当前固定入口;历史脚本和模块脚本不应在固定入口执行后再次串行执行。
测试环境结构发布只执行全量固定入口;纯权限点发布可改用权限点独立入口。历史脚本和模块脚本不应在任一发布入口执行后再次串行执行。
## 执行方式
@@ -1345,18 +1345,19 @@ VALUES
(5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0),
(5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0),
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0),
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0)
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0),
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025, 5102026)
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025, 5102026)
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
@@ -15719,6 +15720,16 @@ BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:管理员或订单运营角色缺少订单导出与下载中心权限';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE id = 5102027 AND tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code = 'platform:order:declaration:edit'
AND parent_id = 5102010 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR (SELECT COUNT(DISTINCT role_id) FROM baiyue_iam.iam_role_permission
WHERE tenant_id = 0 AND permission_id = 5102027
AND role_id IN (4000001, 4000003)) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:订单申报与运营预估权限未正确授予平台管理员和订单运营角色';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND id BETWEEN 5102121 AND 5102129
+66
View File
@@ -0,0 +1,66 @@
-- 通用 IAM 权限点独立初始化入口(MySQL 8.0)。
-- 国内与海外业务的 Admin/Client 权限主数据均在 baiyue_iam 统一管理。
-- 只允许写入 baiyue_iam 的权限资源和角色授权;
-- 不建表、不创建账号/客户/仓库,不写业务测试数据。
-- 前置:IAM 基础表、相关父菜单及系统角色已由全量基线创建。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS apply_permission_points;
DELIMITER $$
CREATE PROCEDURE apply_permission_points()
BEGIN
IF NOT EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102010 AND tenant_id = 0
AND permission_code = 'platform:order:list' AND permission_type = 'MENU') THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '权限初始化失败:订单列表父菜单不存在,请先执行全量基线';
END IF;
IF (SELECT COUNT(*) FROM iam_role WHERE id IN (4000001, 4000003) AND tenant_id = 0
AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '权限初始化失败:平台管理员或订单运营角色不存在';
END IF;
IF EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102027
AND permission_code <> 'platform:order:declaration:edit')
OR EXISTS (SELECT 1 FROM iam_permission
WHERE permission_code = 'platform:order:declaration:edit' AND id <> 5102027) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '权限初始化失败:5102027 或权限码已被其他语义占用';
END IF;
START TRANSACTION;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, deleted)
VALUES
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit',
'修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
permission_type = VALUES(permission_type), app_code = VALUES(app_code),
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role_id, 5102027, 0
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
IF (SELECT COUNT(*) FROM iam_permission WHERE id = 5102027 AND tenant_id = 0
AND app_code = 'PLATFORM' AND permission_type = 'BUTTON' AND parent_id = 5102010
AND permission_code = 'platform:order:declaration:edit'
AND status = 'ACTIVE' AND deleted = 0) <> 1
OR (SELECT COUNT(DISTINCT role_id) FROM iam_role_permission
WHERE tenant_id = 0 AND permission_id = 5102027
AND role_id IN (4000001, 4000003)) <> 2 THEN
ROLLBACK;
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '权限初始化失败:订单申报与运营预估权限未完整写入';
END IF;
COMMIT;
END$$
DELIMITER ;
CALL apply_permission_points();
DROP PROCEDURE IF EXISTS apply_permission_points;
SELECT 'PASS' AS permission_initialization_status;
@@ -10,6 +10,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import com.baiyue.firstleg.dto.InboundHoldDtos.Detail;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -22,6 +23,7 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestParam;
@@ -160,6 +163,18 @@ public class PlatformInboundOrderController {
return cartonLabelPrintService.generate(tenantId, inboundId, cartonIndex, requestId);
}
@Operation(summary = "批量生成入仓箱唛 PDF",
description = "按所选客户和入库单聚合生成一个 PDF;首次调用冻结各入库单整单箱号。")
@PostMapping("/carton-label-pdf")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
public ResponseEntity<byte[]> printCartonLabelsBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody @Size(min = 1, max = 200) List<@Valid InboundCartonLabels.PlatformBatchItem> items,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
requirePlatform(user);
return cartonLabelPrintService.generateForPlatformInbounds(items, requestId);
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
FirstlegScopeGuard.platform(user, tenantId);
}
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.util.List;
import jakarta.validation.constraints.Min;
/** 稳定箱号分配、补打和测量解析契约。 */
public final class InboundCartonLabels {
private InboundCartonLabels() {
@@ -12,6 +14,9 @@ public final class InboundCartonLabels {
List<Label> labels) {
}
public record PlatformBatchItem(@Min(1) long tenantId, @Min(1) long inboundId) {
}
public record Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName,
Integer detailPackageCount, String boxCode, Integer totalCount,
Integer cartonIndex, String boxSpecification, BigDecimal lengthCm,
@@ -286,21 +286,21 @@ public class FbaPricingRepository {
}
public int markInboundAdjusted(long tenantId, long inboundId, String processStatus, String feeStatus,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.set(InboundPricingEntity::getFeeStatus, feeStatus);
if ("KG".equals(chargeUnit)) {
update.set(InboundPricingEntity::getChargeableWeightKg, chargeableQuantity);
} else {
update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeableQuantity);
}
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeUnit, chargeableQuantity);
return inbounds.update(null, update);
}
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
@@ -312,12 +312,13 @@ public class FbaPricingRepository {
.contains(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
return inbounds.update(null, update);
}
public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeMode, java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
@@ -325,11 +326,21 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("CALCULATED", "CONFIRMED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
return inbounds.update(null, update);
}
private void applyChargeableSnapshot(LambdaUpdateWrapper<InboundPricingEntity> update,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
// 列表同时展示两项:当前计费单位保存最终计费量,另一项保存本次计价选定值。
update.set(InboundPricingEntity::getChargeableWeightKg,
"KG".equals(chargeUnit) ? chargeableQuantity : selectedWeightKg)
.set(InboundPricingEntity::getChargeableVolumeCbm,
"CBM".equals(chargeUnit) ? chargeableQuantity : selectedVolumeCbm);
}
public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
@@ -166,7 +166,7 @@ public class FbaPricingService {
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
estimate.chargeUnit(), chargeable) != 1) {
estimate.chargeUnit(), weight, volume, chargeable) != 1) {
throw conflict("入库单状态已变化,费用试算未保存");
}
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
@@ -342,7 +342,8 @@ public class FbaPricingService {
recalculated.getSubtotalAmount(), now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now));
if (repository.markInboundContainerFeeRecalculated(tenantId, member.inbound().getId(),
member.inbound().getProcessStatus(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
member.inbound().getProcessStatus(), member.current().getActualWeightKg(),
member.current().getActualVolumeCbm(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
throw conflict("柜内入库单状态已变化,不能重算阶梯费用");
}
feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(),
@@ -564,7 +565,7 @@ public class FbaPricingService {
repository.insertItem(extra);
}
if (repository.markInboundAdjusted(tenantId, inboundId, inbound.getProcessStatus(), adjusted.getFeeStatus(),
adjusted.getChargeUnit(), quantity) != 1) {
adjusted.getActualWeightKg(), adjusted.getActualVolumeCbm(), adjusted.getChargeUnit(), quantity) != 1) {
throw conflict("入库单状态已变化,费用调整未保存");
}
if (published) {
@@ -92,6 +92,27 @@ public class InboundCartonLabelPrintService {
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
}
public ResponseEntity<byte[]> generateForPlatformInbounds(
List<InboundCartonLabels.PlatformBatchItem> items, String requestId) {
if (items == null || items.isEmpty() || items.size() > 200
|| items.stream().anyMatch(item -> item == null || item.tenantId() <= 0 || item.inboundId() <= 0)) {
throw new BizException(ResultCode.BAD_REQUEST, "批量打印必须选择1至200个入库单");
}
List<InboundCartonLabels.PlatformBatchItem> distinctItems = items.stream().distinct().toList();
List<InboundCartonLabels.Label> labels = new ArrayList<>();
for (InboundCartonLabels.PlatformBatchItem item : distinctItems) {
labels.addAll(labelService.list(item.tenantId(), item.inboundId(), null).labels());
}
if (labels.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "所选订单没有可生成的箱唛");
}
ResponseEntity<byte[]> response = generatePdf("carton-labels-" + labels.size() + ".pdf", labels, requestId);
HttpHeaders headers = new HttpHeaders();
headers.putAll(response.getHeaders());
headers.set("X-Carton-Label-Order-Count", String.valueOf(distinctItems.size()));
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
}
/** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String requestId) {
if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) {
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -113,7 +114,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
throw new BizException(ResultCode.INTERNAL_ERROR, "预入库单补建后未能读取");
}
return prepared;
} catch (FeignException exception) {
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法补建预入库单");
}
}
@@ -177,7 +178,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId,
java.util.Comparator.nullsLast(Long::compareTo)))
.toList();
} catch (FeignException exception) {
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法生成唛头");
}
}
@@ -923,6 +923,39 @@ paths:
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
'403': {description: 无查看权限}
/api/platform/inbounds/carton-label-pdf:
post:
summary: 批量生成入仓箱唛 PDF
description: 需要 platform:firstleg:inbound:view 权限;可跨客户选择1至200个入库单,聚合生成一个 PDF,首次调用冻结各入库单整单箱号。
requestBody:
required: true
content:
application/json:
schema:
type: array
minItems: 1
maxItems: 200
items:
type: object
required: [tenantId, inboundId]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
responses:
'200':
description: 成功返回聚合 PDF;业务失败返回统一 JSON 响应,调用方必须按 Content-Type 区分。
content:
application/pdf: {schema: {type: string, format: binary}}
application/json:
schema:
type: object
required: [code, message]
properties:
code: {type: string}
message: {type: string}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
'403': {description: 无查看权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement:
get:
summary: 查询入库测量详情
@@ -22,6 +22,8 @@ class InboundOrderControllerContractTest {
AuthenticatedUser.class, long.class, long.class);
assertAuthority("printCartonLabels", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, long.class, long.class, List.class, String.class);
assertAuthority("printCartonLabelsBatch", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, List.class, String.class);
Method page = PlatformInboundOrderController.class.getDeclaredMethod(
"page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
@@ -47,6 +49,7 @@ class InboundOrderControllerContractTest {
.contains("/api/platform/inbounds:")
.contains("/api/platform/inbounds/{inboundId}:")
.contains("/api/platform/inbounds/{inboundId}/carton-label-pdf:")
.contains("/api/platform/inbounds/carton-label-pdf:")
.contains("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:")
.contains("required: false")
.contains("InboundOrderSummary:")
@@ -209,20 +209,42 @@ class FbaPricingRepositoryRuleListTest {
.thenReturn(1);
assertThat(repository.markInboundCalculated(
10001L, 88L, "WAIT_PALLETIZE", "CBM", new java.math.BigDecimal("1.0000")))
10001L, 88L, "WAIT_PALLETIZE", "CBM", new java.math.BigDecimal("10.000"),
new java.math.BigDecimal("1.0000"), new java.math.BigDecimal("1.0000")))
.isEqualTo(1);
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus", "chargeableVolumeCbm")
assertThat(wrapper.getValue().getSqlSet()).contains(
"feeStatus", "chargeableWeightKg", "chargeableVolumeCbm")
.doesNotContain("processStatus");
assertThat(wrapper.getValue().getCustomSqlSegment())
.contains("tenantId", "id", "processStatus", "feeStatus");
assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L, "WAIT_PALLETIZE");
}
@Test
void kilogramPricingStoresChargeableWeightAndSelectedVolumeTogether() {
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
.thenReturn(1);
assertThat(repository.markInboundCalculated(
10001L, 88L, "WAIT_SPLIT", "KG", new java.math.BigDecimal("297.000"),
new java.math.BigDecimal("9.0000"), new java.math.BigDecimal("1503.000")))
.isEqualTo(1);
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
assertThat(wrapper.getValue().getSqlSet())
.contains("chargeableWeightKg", "chargeableVolumeCbm");
assertThat(params(wrapper.getValue()).values()).contains(
new java.math.BigDecimal("1503.000"), new java.math.BigDecimal("9.0000"));
}
private Collection<Object> queryValues(Long tenantId, String ruleCode) {
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
@@ -216,7 +216,7 @@ class FbaPricingServiceTest {
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
new BigDecimal("167.000"));
new BigDecimal("100.000"), new BigDecimal("1.0000"), new BigDecimal("167.000"));
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 501L, OPERATOR, NOW);
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@@ -299,7 +299,7 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLETIZE");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLETIZE", "CBM",
new BigDecimal("1.0000"));
new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000"));
}
@Test
@@ -325,7 +325,7 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "LOADED", "CBM",
new BigDecimal("1.0000"));
new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000"));
}
@Test
@@ -470,7 +470,8 @@ class FbaPricingServiceTest {
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("SENT"), eq("KG"), any()))
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("SENT"),
any(), any(), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
@@ -565,7 +566,8 @@ class FbaPricingServiceTest {
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"), eq("KG"), any()))
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"),
any(), any(), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
@@ -624,7 +626,8 @@ class FbaPricingServiceTest {
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"), eq("KG"), any()))
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"),
any(), any(), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; })
.when(repository).insertFee(any());
@@ -772,7 +775,8 @@ class FbaPricingServiceTest {
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(), anyString(), any()))
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(),
any(), any(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(
regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600"));
@@ -824,7 +828,8 @@ class FbaPricingServiceTest {
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(), anyString(), any()))
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(),
any(), any(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
@@ -908,7 +913,8 @@ class FbaPricingServiceTest {
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(501L); return null; })
.when(repository).insertFee(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1);
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any(), any())).thenReturn(1);
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any(), any(), any(), any()))
.thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
}
@@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class InboundCartonLabelPrintServiceTest {
@@ -118,6 +119,39 @@ class InboundCartonLabelPrintServiceTest {
org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any());
}
@Test
void platformBatchCombinesLabelsAcrossCustomers() {
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
when(labels.list(10002L, 70002L, null)).thenReturn(batch());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.eq("platform-batch"), org.mockito.ArgumentMatchers.any()))
.thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers,
org.springframework.http.HttpStatus.OK));
ResponseEntity<byte[]> response = service.generateForPlatformInbounds(List.of(
new InboundCartonLabels.PlatformBatchItem(10001L, 70001L),
new InboundCartonLabels.PlatformBatchItem(10002L, 70002L)), "platform-batch");
assertThat(response.getHeaders().getFirst("X-Carton-Label-Order-Count")).isEqualTo("2");
assertThat(response.getBody()).containsExactly(1, 2, 3);
}
@Test
void platformBatchFailsWhenAnySelectedInboundCannotGenerateLabels() {
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
when(labels.list(10002L, 70002L, null))
.thenThrow(new BizException(ResultCode.NOT_FOUND, "箱唛入库单不存在"));
assertThatThrownBy(() -> service.generateForPlatformInbounds(List.of(
new InboundCartonLabels.PlatformBatchItem(10001L, 70001L),
new InboundCartonLabels.PlatformBatchItem(10002L, 70002L)), "platform-batch"))
.isInstanceOf(BizException.class)
.hasMessage("箱唛入库单不存在");
verifyNoInteractions(print);
}
private InboundCartonLabels.Batch batch() {
InboundCartonLabels.Label label = new InboundCartonLabels.Label(
1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1,
@@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
@@ -233,6 +234,21 @@ class InboundCartonLabelServiceTest {
.hasMessageContaining("订单服务暂不可用");
}
@Test
void convertsOrderCargoCircuitBreakerFailureToActionableBusinessError() {
InboundOrderEntity inbound = inbound();
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
.thenThrow(new NoFallbackAvailableException("order unavailable",
new IllegalStateException("downstream failure")));
assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L))
.isInstanceOf(BizException.class)
.hasMessage("订单服务暂不可用,无法生成唛头");
}
@Test
void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() {
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
@@ -0,0 +1,20 @@
-- 订单申报与运营预估权限模块来源。
-- 只操作 IAM 权限资源和角色授权,不创建账号、客户或业务数据。
USE baiyue_iam;
SET NAMES utf8mb4;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, deleted)
VALUES
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit',
'修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
permission_type = VALUES(permission_type), app_code = VALUES(app_code),
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role_id, 5102027, 0
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
@@ -130,18 +130,19 @@ VALUES
(5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0),
(5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0),
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0),
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0)
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0),
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025, 5102026)
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025, 5102026)
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
@@ -0,0 +1,44 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PermissionInitializationSqlContractTest {
@Test
void keepsSharedPermissionEntryFocusedAndAlignedWithFullBaseline() throws Exception {
String module = new ClassPathResource("db/order-declaration-planning-permission.sql")
.getContentAsString(StandardCharsets.UTF_8);
String permissionOnly = Files.readString(resolve("deploy/sql/权限点初始化-MySQL8.sql"),
StandardCharsets.UTF_8);
String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
for (String source : new String[] {module, permissionOnly, unified}) {
assertThat(source)
.contains("5102027")
.contains("platform:order:declaration:edit")
.contains("4000001")
.contains("4000003");
}
assertThat(permissionOnly)
.contains("START TRANSACTION")
.contains("permission_initialization_status")
.contains("SIGNAL SQLSTATE '45000'")
.contains("apply_permission_points")
.doesNotContain("apply_domestic_permission_points", "CREATE TABLE", "ALTER TABLE",
"INSERT INTO iam_tenant", "INSERT INTO iam_user", "INSERT INTO iam_membership",
"INSERT INTO auth_account");
}
private static Path resolve(String relative) {
Path direct = Path.of(relative);
return Files.exists(direct) ? direct : Path.of("..").resolve(relative);
}
}
@@ -38,6 +38,15 @@ SELECT 'platform_admin_not_fully_granted'
WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000001)
<> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON'))
UNION ALL
SELECT 'missing_order_declaration_edit_permission'
WHERE NOT EXISTS (
SELECT 1 FROM baiyue_iam.iam_permission
WHERE id = 5102027 AND tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code = 'platform:order:declaration:edit' AND status = 'ACTIVE' AND deleted = 0
)
OR (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission
WHERE tenant_id = 0 AND permission_id = 5102027 AND role_id IN (4000001, 4000003)) <> 2
UNION ALL
SELECT 'missing_global_merchant_owner_template'
WHERE NOT EXISTS (
SELECT 1 FROM baiyue_iam.iam_role
@@ -5,18 +5,5 @@ ALTER TABLE ord_order
ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间' AFTER expected_ship_date,
ADD COLUMN channel_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '订单历史上是否由平台变更过渠道' AFTER channel_name;
USE baiyue_iam;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, deleted)
VALUES
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit',
'修改客户申报资料', NULL, NULL, 121, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role_id, 5102027, 0
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- IAM 权限不再与 Order 表结构混合;模块来源见
-- iam/src/main/resources/db/order-declaration-planning-permission.sql。
@@ -28,9 +28,9 @@ class DomesticWarehouseOrderExportPermissionSqlContractTest {
.contains("platform:download:list")
.contains("4000003");
}
assertThat(iamSeed).contains("WHERE id IN (5102019, 5102024, 5102025, 5102026)");
assertThat(iamSeed).contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)");
assertThat(unified)
.contains("WHERE id IN (5102019, 5102024, 5102025, 5102026)")
.contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)")
.contains("管理员或订单运营角色缺少订单导出与下载中心权限");
}
@@ -95,12 +95,20 @@ class OrderDeclarationUpdateContractTest {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String source = Files.readString(unified, StandardCharsets.UTF_8);
Path iamDirect = Path.of("iam", "src", "main", "resources", "db", "phase1-admin-rbac.sql");
Path iam = Files.exists(iamDirect) ? iamDirect : Path.of("..").resolve(iamDirect);
String iamSource = Files.readString(iam, StandardCharsets.UTF_8);
assertThat(source)
.contains("expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间'")
.contains("channel_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '订单历史上是否由平台变更过渠道'")
.contains("CALL unified_assert_column('baiyue_order', 'ord_order', 'expected_arrival_at')")
.contains("CALL unified_assert_column('baiyue_order', 'ord_order', 'channel_changed')");
.contains("CALL unified_assert_column('baiyue_order', 'ord_order', 'channel_changed')")
.contains("platform:order:declaration:edit")
.contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)");
assertThat(iamSource)
.contains("platform:order:declaration:edit")
.contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)");
}
@SuppressWarnings("unchecked")