Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
40 changed files with 1471 additions and 6 deletions
Showing only changes of commit ca9e7b60e8 - Show all commits
+1
View File
@@ -27,6 +27,7 @@
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
| 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
+1
View File
@@ -29,6 +29,7 @@
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingAssociationDtos.Summary;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingAssociationQueryService;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Positive;
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;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/platform/v1/first-leg/loading-associations")
public class PlatformLoadingAssociationController {
@Resource
private LoadingAssociationQueryService service;
@GetMapping("/summary")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Summary> summary(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) @Positive Long inboundId,
@RequestParam(required = false) @Size(max = 40) String inboundCode) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.summary(inboundId, inboundCode));
}
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.dto;
import java.util.List;
/** Admin 统一关联抽屉使用的装柜/PDA只读业务摘要。 */
public final class LoadingAssociationDtos {
private LoadingAssociationDtos() {
}
public record ItemReference(String itemId) {
}
public record Summary(
String inboundId,
String inboundCode,
String orderNo,
String loadingPlanId,
String loadingPlanNo,
String containerBookingId,
String cabinetNo,
String containerNo,
String loadingStatus,
String taskId,
String pdaTaskStatus,
String coLoadGroupId,
String coLoadGroupNo,
List<ItemReference> items
) {
public Summary {
items = items == null ? List.of() : List.copyOf(items);
}
}
}
@@ -0,0 +1,72 @@
package com.baiyue.firstleg.mapper;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** Firstleg 单库内按入仓片段读取装柜、PDA与同柜组稳定关联。 */
@Mapper
public interface LoadingAssociationMapper {
String SELECT_ASSOCIATIONS = """
SELECT inbound.id inboundId,inbound.inbound_code inboundCode,inbound.order_no orderNo,
item.id itemId,plan.id loadingPlanId,plan.plan_no loadingPlanNo,
booking.id containerBookingId,booking.cabinet_no cabinetNo,
booking.container_no containerNo,plan.plan_status loadingStatus,
task.id taskId,
CASE WHEN task.id IS NULL THEN NULL
WHEN task.task_status='COMPLETED' THEN 'COMPLETED'
WHEN task.task_status='COMPLETING' THEN 'PROCESSING'
WHEN task.task_status='COMPLETION_FAILED' THEN 'COMPLETION_FAILED'
WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'
WHEN NOT EXISTS (SELECT 1 FROM fl_loading_pick_item unresolved
WHERE unresolved.pick_task_id=task.id
AND unresolved.item_status IN ('PENDING','PICKING'))
THEN 'AWAITING_REVIEW'
ELSE 'IN_PROGRESS' END pdaTaskStatus,
group_row.id coLoadGroupId,group_row.group_no coLoadGroupNo
FROM fl_inbound_order inbound
LEFT JOIN fl_loading_plan_item item
ON item.account_id=inbound.tenant_id AND item.inbound_id=inbound.id AND item.deleted=0
LEFT JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id
LEFT JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
LEFT JOIN fl_loading_pick_task task ON task.loading_plan_id=plan.id
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
LEFT JOIN fl_coload_group_member group_member ON group_member.id=(
SELECT MAX(member_latest.id) FROM fl_coload_group_member member_latest
JOIN fl_coload_group group_latest ON group_latest.id=member_latest.group_id
AND group_latest.group_status<>'CANCELLED'
WHERE member_latest.tenant_id=inbound.tenant_id
AND member_latest.inbound_id=inbound.id)
LEFT JOIN fl_coload_group group_row ON group_row.id=group_member.group_id
WHERE inbound.deleted=0
""";
@Select(SELECT_ASSOCIATIONS + " AND inbound.id=#{inboundId} ORDER BY item.id")
List<Row> byInboundId(@Param("inboundId") long inboundId);
@Select(SELECT_ASSOCIATIONS + " AND inbound.inbound_code=#{inboundCode} ORDER BY inbound.id,item.id")
List<Row> byInboundCode(@Param("inboundCode") String inboundCode);
@Getter
@Setter
class Row {
private Long inboundId;
private String inboundCode;
private String orderNo;
private Long itemId;
private Long loadingPlanId;
private String loadingPlanNo;
private Long containerBookingId;
private String cabinetNo;
private String containerNo;
private String loadingStatus;
private Long taskId;
private String pdaTaskStatus;
private Long coLoadGroupId;
private String coLoadGroupNo;
}
}
@@ -0,0 +1,59 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingAssociationDtos.ItemReference;
import com.baiyue.firstleg.dto.LoadingAssociationDtos.Summary;
import com.baiyue.firstleg.mapper.LoadingAssociationMapper;
import com.baiyue.firstleg.mapper.LoadingAssociationMapper.Row;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Objects;
/** 装柜关联只读查询;inboundCode解析和业务摘要编排均不改变现有写接口主键。 */
@Service
public class LoadingAssociationQueryService {
@Resource
private LoadingAssociationMapper mapper;
public Summary summary(Long inboundId, String inboundCode) {
String code = StringUtils.hasText(inboundCode) ? inboundCode.trim() : null;
if ((inboundId == null) == (code == null)) {
throw new BizException(ResultCode.BAD_REQUEST, "inboundId与inboundCode必须且只能提供一个");
}
if (inboundId != null && inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "inboundId必须大于0");
}
List<Row> rows = inboundId == null ? mapper.byInboundCode(code) : mapper.byInboundId(inboundId);
if (rows == null || rows.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "入仓片段不存在");
}
Row first = rows.get(0);
requireSingleValue(rows.stream().map(Row::getInboundId).toList(), "inboundCode匹配多个入仓片段");
requireSingleValue(rows.stream().map(Row::getLoadingPlanId).filter(Objects::nonNull).toList(),
"同一入仓片段关联多个有效装柜计划");
requireSingleValue(rows.stream().map(Row::getTaskId).filter(Objects::nonNull).toList(),
"同一入仓片段关联多个PDA任务");
requireSingleValue(rows.stream().map(Row::getCoLoadGroupId).filter(Objects::nonNull).toList(),
"同一入仓片段关联多个有效同柜组");
List<ItemReference> items = rows.stream().map(Row::getItemId).filter(Objects::nonNull).distinct()
.sorted().map(String::valueOf).map(ItemReference::new).toList();
return new Summary(id(first.getInboundId()), first.getInboundCode(), first.getOrderNo(),
id(first.getLoadingPlanId()), first.getLoadingPlanNo(), id(first.getContainerBookingId()),
first.getCabinetNo(), first.getContainerNo(), first.getLoadingStatus(), id(first.getTaskId()),
first.getPdaTaskStatus(), id(first.getCoLoadGroupId()), first.getCoLoadGroupNo(), items);
}
private static void requireSingleValue(List<Long> values, String message) {
if (values.stream().distinct().limit(2).count() > 1) {
throw new BizException(ResultCode.CONFLICT, message);
}
}
private static String id(Long value) {
return value == null ? null : value.toString();
}
}
@@ -414,6 +414,29 @@ paths:
- {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: {'200': {description: 成功,data 为入仓码级候选列表}}
/api/platform/v1/first-leg/loading-associations/summary:
get:
summary: 查询入仓片段的装柜与国内PDA关联摘要
description: >-
需要 platform:firstleg:smart-loading:list 权限。inboundId 与 inboundCode 必须且只能提供一个;
inboundCode 是订单当前有效入仓片段业务号。响应优先提供 orderNo、loadingPlanNo、cabinetNo/containerNo、
loadingStatus、pdaTaskStatus 与 coLoadGroupNo 供页面展示;loadingPlanId、containerBookingId、taskId、
coLoadGroupId 和 items[].itemId 仅为技术关联,不得替代业务号作为UI主标识。
parameters:
- {name: inboundId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundCode, in: query, required: false, schema: {type: string, minLength: 1, maxLength: 40}}
responses:
'200':
description: 成功或业务失败;未进入装柜时仍返回 inboundId/inboundCode,其余关联字段为空
content:
application/json:
schema:
type: object
required: [code, message, data]
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/LoadingAssociationSummary'}
/api/platform/v1/first-leg/smart-loading/plans/{planId}:
get:
summary: 查询智能装柜草稿详情
@@ -1682,6 +1705,31 @@ components:
expectedPlanVersion: {type: integer, minimum: 0}
expectedItemVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
LoadingAssociationSummary:
type: object
required: [inboundId, inboundCode, orderNo, items]
properties:
inboundId: {type: string, pattern: '^[1-9][0-9]*$', description: 入仓片段内部技术ID}
inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示}
orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示}
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID}
loadingPlanNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', description: 装柜计划业务号,UI主展示}
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID}
cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示}
containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示}
loadingStatus: {type: string, enum: [EDITING, LOADING, COMPLETED], nullable: true}
taskId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 国内PDA任务技术关联ID}
pdaTaskStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED, COMPLETED], nullable: true, description: 国内PDA任务业务状态}
coLoadGroupId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 同柜不可拆组技术关联ID}
coLoadGroupNo: {type: string, nullable: true, description: 同柜不可拆组业务号,UI主展示}
items:
type: array
description: 装柜明细技术关联;不作为UI主展示
items:
type: object
required: [itemId]
properties:
itemId: {type: string, pattern: '^[1-9][0-9]*$'}
SmartLoadingPlan:
type: object
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingAssociationDtos;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformLoadingAssociationControllerContractTest {
@Test
void technicalAssociationIdsAreExplicitStringsInResponseDto() {
assertThat(LoadingAssociationDtos.Summary.class.getRecordComponents())
.filteredOn(component -> component.getName().endsWith("Id"))
.allSatisfy(component -> assertThat(component.getType()).isEqualTo(String.class));
assertThat(LoadingAssociationDtos.ItemReference.class.getRecordComponents()[0].getType())
.isEqualTo(String.class);
}
@Test
void summaryUsesSmartLoadingReadPermission() throws Exception {
Method method = PlatformLoadingAssociationController.class.getDeclaredMethod(
"summary", AuthenticatedUser.class, Long.class, String.class);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:smart-loading:list')");
}
@Test
void openApiKeepsBusinessNumbersPrimaryAndTechnicalIdsAsAssociations() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml)
.contains("/api/platform/v1/first-leg/loading-associations/summary:")
.contains("inboundId 与 inboundCode 必须且只能提供一个")
.contains("LoadingAssociationSummary:")
.contains("orderNo:", "loadingPlanNo:", "cabinetNo:", "containerNo:", "coLoadGroupNo:")
.contains("pdaTaskStatus:")
.contains("loadingPlanId:", "containerBookingId:", "taskId:", "itemId:")
.contains("仅为技术关联,不得替代业务号作为UI主标识");
}
}
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingAssociationMapperContractTest {
@Test
void mapperIsExplicitAndReadsOnlyFirstlegOwnedTables() throws Exception {
assertThat(LoadingAssociationMapper.class).hasAnnotation(Mapper.class);
Method method = LoadingAssociationMapper.class.getDeclaredMethod("byInboundId", long.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertThat(sql)
.contains("fl_inbound_order", "fl_loading_plan_item", "fl_loading_plan",
"fl_container_booking", "fl_loading_pick_task", "fl_coload_group_member")
.contains("plan.plan_no loadingPlanNo")
.contains("inbound.order_no orderNo")
.contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo")
.contains("group_row.group_no coLoadGroupNo")
.contains("'UNASSIGNED'", "'IN_PROGRESS'", "'AWAITING_REVIEW'", "'PROCESSING'",
"'COMPLETION_FAILED'", "'COMPLETED'")
.doesNotContain("baiyue_order", "baiyue_warehouse", "baiyue_iam");
}
@Test
void inboundCodeQueryUsesExactBusinessIdentifier() throws Exception {
Method method = LoadingAssociationMapper.class.getDeclaredMethod("byInboundCode", String.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertThat(sql).contains("inbound.inbound_code=#{inboundCode}")
.doesNotContain("LIKE CONCAT");
}
}
@@ -0,0 +1,97 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.mapper.LoadingAssociationMapper;
import com.baiyue.firstleg.mapper.LoadingAssociationMapper.Row;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(LoadingAssociationQueryService.class)
class LoadingAssociationQueryServiceTest {
@MockBean
private LoadingAssociationMapper mapper;
@Resource
private LoadingAssociationQueryService service;
@Test
void returnsBusinessIdentifiersAndPreservesEveryTechnicalItemReference() {
Row first = row(800L);
Row second = row(801L);
when(mapper.byInboundCode("ZBYHK2608300001-2")).thenReturn(List.of(first, second));
var result = service.summary(null, " ZBYHK2608300001-2 ");
assertThat(result.inboundCode()).isEqualTo("ZBYHK2608300001-2");
assertThat(result.orderNo()).isEqualTo("FO-260830-0001");
assertThat(result.inboundId()).isEqualTo("100");
assertThat(result.loadingPlanId()).isEqualTo("200");
assertThat(result.loadingPlanNo()).isEqualTo("LP-260830-0001");
assertThat(result.cabinetNo()).isEqualTo("CAB-260830-0001");
assertThat(result.containerNo()).isEqualTo("MSCU1234567");
assertThat(result.loadingStatus()).isEqualTo("LOADING");
assertThat(result.pdaTaskStatus()).isEqualTo("AWAITING_REVIEW");
assertThat(result.coLoadGroupNo()).isEqualTo("CLG-260830-0001");
assertThat(result.items()).extracting(item -> item.itemId()).containsExactly("800", "801");
verify(mapper).byInboundCode("ZBYHK2608300001-2");
}
@Test
void returnsFragmentIdentityWhenItHasNoLoadingAssociation() {
Row row = new Row();
row.setInboundId(100L);
row.setInboundCode("ZBYHK2608300002");
when(mapper.byInboundId(100L)).thenReturn(List.of(row));
var result = service.summary(100L, null);
assertThat(result.inboundCode()).isEqualTo("ZBYHK2608300002");
assertThat(result.loadingPlanId()).isNull();
assertThat(result.pdaTaskStatus()).isNull();
assertThat(result.items()).isEmpty();
}
@Test
void selectorMustBeExclusiveAndExactCodeCannotResolveMultipleFragments() {
assertThatThrownBy(() -> service.summary(null, null))
.isInstanceOf(BizException.class).hasMessageContaining("必须且只能");
assertThatThrownBy(() -> service.summary(100L, "ZBYHK2608300001"))
.isInstanceOf(BizException.class).hasMessageContaining("必须且只能");
Row first = row(800L);
Row other = row(801L);
other.setInboundId(101L);
when(mapper.byInboundCode("DUPLICATE")).thenReturn(List.of(first, other));
assertThatThrownBy(() -> service.summary(null, "DUPLICATE"))
.isInstanceOfSatisfying(BizException.class, error ->
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
}
private static Row row(long itemId) {
Row row = new Row();
row.setInboundId(100L);
row.setInboundCode("ZBYHK2608300001-2");
row.setOrderNo("FO-260830-0001");
row.setItemId(itemId);
row.setLoadingPlanId(200L);
row.setLoadingPlanNo("LP-260830-0001");
row.setContainerBookingId(300L);
row.setCabinetNo("CAB-260830-0001");
row.setContainerNo("MSCU1234567");
row.setLoadingStatus("LOADING");
row.setTaskId(400L);
row.setPdaTaskStatus("AWAITING_REVIEW");
row.setCoLoadGroupId(500L);
row.setCoLoadGroupNo("CLG-260830-0001");
return row;
}
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.inventory.query.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inventory.query.dto.InboundCodeRelationshipResponse;
import com.baiyue.lastleg.inventory.query.service.InboundCodeRelationshipService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@Tag(name = "Inventory queries")
public class InboundCodeRelationshipController {
static final String PATH = "/api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}";
@Resource private InboundCodeRelationshipService service;
@GetMapping(PATH)
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Resolve overseas relationships by inbound code",
description = "Platform-only Lastleg relationship read model. Required authority: "
+ OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InboundCodeRelationshipResponse> find(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String inboundCode) {
requirePlatform(user);
return ApiResponse.success(service.find(inboundCode));
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
}
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inventory.query.dto;
import java.util.List;
public record InboundCodeRelationshipResponse(
String inboundCode,
boolean found,
Customer customer,
OverseasWarehouse overseasWarehouse,
String overseasReceivingStatus,
List<TaillegOrder> taillegOrders,
List<InboundPlan> inboundPlans,
List<String> palletCodes,
List<InventorySku> inventory
) {
public record Customer(String customerId, String customerName) { }
public record OverseasWarehouse(String warehouseId, String warehouseCode, String warehouseName) { }
public record TaillegOrder(String orderNo, String orderStatus) { }
public record InboundPlan(String planNo, String planStatus, String receivingStatus) { }
public record InventorySku(String skuCode, String productNameZh, String productNameEn, long packageCount) { }
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.inventory.query.mapper;
import com.baiyue.lastleg.inventory.query.model.InboundCodeRelationshipRows;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface InboundCodeRelationshipMapper {
@Select("SELECT o.customer_id AS customerId,o.customer_name AS customerName,o.order_no AS orderNo," +
"o.current_status AS orderStatus,p.plan_no AS planNo,p.status AS planStatus," +
"COALESCE(p.status,o.current_status) AS receivingStatus," +
"COALESCE(b.overseas_warehouse_id,o.overseas_warehouse_id,CAST(p.overseas_warehouse_id AS CHAR)) AS overseasWarehouseId," +
"b.overseas_warehouse_code AS overseasWarehouseCode," +
"COALESCE(b.overseas_warehouse_name,o.overseas_warehouse_name) AS overseasWarehouseName " +
"FROM ll_order o " +
"LEFT JOIN ll_inbound_plan_line l ON l.customer_id=o.customer_id AND l.order_id=o.id AND l.inbound_code=o.inbound_code " +
"LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.id=l.plan_id " +
"LEFT JOIN ll_inventory_balance b ON b.customer_id=o.customer_id AND b.inbound_order_id=o.id AND b.inbound_code=o.inbound_code " +
"WHERE o.inbound_code=#{inboundCode} ORDER BY o.customer_id,o.id,p.id,b.id")
List<InboundCodeRelationshipRows.Base> bases(@Param("inboundCode") String inboundCode);
@Select("SELECT DISTINCT r.pallet_no FROM ll_inbound_stock_receipt r " +
"JOIN ll_order o ON o.customer_id=r.customer_id AND o.id=r.order_id " +
"WHERE o.inbound_code=#{inboundCode} AND o.customer_id=#{customerId} " +
"AND r.pallet_no IS NOT NULL AND TRIM(r.pallet_no)<>'' ORDER BY r.pallet_no")
List<String> palletCodes(@Param("customerId") String customerId,
@Param("inboundCode") String inboundCode);
@Select("SELECT r.sku_code AS skuCode,MAX(r.product_name_zh) AS productNameZh," +
"MAX(r.product_name_en) AS productNameEn,SUM(r.received_packages) AS packageCount " +
"FROM ll_inbound_stock_receipt r " +
"JOIN ll_order o ON o.customer_id=r.customer_id AND o.id=r.order_id " +
"WHERE o.inbound_code=#{inboundCode} AND o.customer_id=#{customerId} " +
"GROUP BY r.sku_code ORDER BY r.sku_code")
List<InboundCodeRelationshipRows.InventorySku> inventory(
@Param("customerId") String customerId,
@Param("inboundCode") String inboundCode);
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.inventory.query.model;
import lombok.Data;
public final class InboundCodeRelationshipRows {
private InboundCodeRelationshipRows() { }
@Data
public static class Base {
private String customerId;
private String customerName;
private String orderNo;
private String orderStatus;
private String planNo;
private String planStatus;
private String receivingStatus;
private String overseasWarehouseId;
private String overseasWarehouseCode;
private String overseasWarehouseName;
}
@Data
public static class InventorySku {
private String skuCode;
private String productNameZh;
private String productNameEn;
private Long packageCount;
}
}
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.inventory.query.service;
import com.baiyue.lastleg.inventory.query.dto.InboundCodeRelationshipResponse;
import com.baiyue.lastleg.inventory.query.mapper.InboundCodeRelationshipMapper;
import com.baiyue.lastleg.inventory.query.model.InboundCodeRelationshipRows;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Objects;
@Service
public class InboundCodeRelationshipService {
@Resource private InboundCodeRelationshipMapper mapper;
public InboundCodeRelationshipResponse find(String inboundCode) {
String normalized = required(inboundCode);
List<InboundCodeRelationshipRows.Base> bases = mapper.bases(normalized);
if (bases.isEmpty()) return empty(normalized);
List<String> customerIds = bases.stream().map(InboundCodeRelationshipRows.Base::getCustomerId)
.filter(Objects::nonNull).distinct().toList();
if (customerIds.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code resolves multiple customer scopes");
}
String customerId = customerIds.get(0);
String customerName = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getCustomerName).toList(),
"customer name");
String warehouseId = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getOverseasWarehouseId).toList(),
"overseas warehouse ID");
String warehouseCode = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getOverseasWarehouseCode).toList(),
"overseas warehouse code");
String warehouseName = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getOverseasWarehouseName).toList(),
"overseas warehouse name");
List<InboundCodeRelationshipResponse.TaillegOrder> orders = bases.stream()
.filter(value -> trim(value.getOrderNo()) != null)
.map(value -> new InboundCodeRelationshipResponse.TaillegOrder(
value.getOrderNo().trim(), trim(value.getOrderStatus())))
.distinct().sorted(java.util.Comparator.comparing(InboundCodeRelationshipResponse.TaillegOrder::orderNo))
.toList();
List<InboundCodeRelationshipResponse.InboundPlan> plans = bases.stream()
.filter(value -> trim(value.getPlanNo()) != null)
.map(value -> new InboundCodeRelationshipResponse.InboundPlan(
value.getPlanNo().trim(), trim(value.getPlanStatus()), trim(value.getReceivingStatus())))
.distinct().sorted(java.util.Comparator.comparing(InboundCodeRelationshipResponse.InboundPlan::planNo)).toList();
List<InboundCodeRelationshipResponse.InventorySku> inventory = mapper.inventory(customerId, normalized).stream()
.map(value -> new InboundCodeRelationshipResponse.InventorySku(value.getSkuCode(),
value.getProductNameZh(), value.getProductNameEn(), number(value.getPackageCount()))).toList();
String status = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getReceivingStatus).toList(),
"overseas receiving status");
InboundCodeRelationshipResponse.OverseasWarehouse warehouse = warehouseId == null
&& warehouseCode == null && warehouseName == null ? null
: new InboundCodeRelationshipResponse.OverseasWarehouse(warehouseId, warehouseCode, warehouseName);
return new InboundCodeRelationshipResponse(normalized, true,
new InboundCodeRelationshipResponse.Customer(customerId, customerName), warehouse, status,
orders, plans, mapper.palletCodes(customerId, normalized), inventory);
}
private static InboundCodeRelationshipResponse empty(String inboundCode) {
return new InboundCodeRelationshipResponse(inboundCode, false, null, null, null,
List.of(), List.of(), List.of(), List.of());
}
private static String unique(List<String> values, String field) {
List<String> normalized = values.stream().map(InboundCodeRelationshipService::trim)
.filter(Objects::nonNull).distinct().toList();
if (normalized.size() > 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code has inconsistent " + field + " snapshots");
}
return normalized.isEmpty() ? null : normalized.get(0);
}
private static String required(String value) {
String normalized = trim(value);
if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "inboundCode is required");
if (normalized.length() > 128) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "inboundCode is too long");
return normalized;
}
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static long number(Long value) { return value == null ? 0L : value; }
}
@@ -3,6 +3,17 @@ info:
title: Lastleg Overseas Business API
version: 1.0.0
paths:
/api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}:
get:
summary: 按入仓码查询海外关系摘要
description: 仅平台且要求 lastleg:inventory:list;不接收customerId,不跨国内数据库查询,未命中返回found=false和空关系。
security: [{ bearerAuth: [] }]
parameters:
- { in: path, name: inboundCode, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
responses:
'200': { description: 海外订单、计划、收货、托盘和库存业务关系, content: { application/json: { schema: { $ref: '#/components/schemas/InboundCodeRelationshipApiResponse' } } } }
'403': { description: 非平台身份或缺少库存查看权限 }
'409': { description: 入仓码跨客户或快照关系不一致 }
/api/pda/v1/last-leg/receiving-containers/candidates:
get:
summary: 查询当前客户待签收柜号
@@ -643,6 +654,57 @@ paths:
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
components:
schemas:
InboundCodeRelationship:
type: object
required: [inboundCode, found, taillegOrders, inboundPlans, palletCodes, inventory]
properties:
inboundCode: { type: string }
found: { type: boolean }
customer: { $ref: '#/components/schemas/InboundCodeRelationshipCustomer', nullable: true }
overseasWarehouse: { $ref: '#/components/schemas/InboundCodeRelationshipWarehouse', nullable: true }
overseasReceivingStatus: { type: string, nullable: true }
taillegOrders: { type: array, items: { $ref: '#/components/schemas/InboundCodeRelationshipOrder' } }
inboundPlans: { type: array, items: { $ref: '#/components/schemas/InboundCodeRelationshipPlan' } }
palletCodes: { type: array, uniqueItems: true, items: { type: string } }
inventory: { type: array, items: { $ref: '#/components/schemas/InboundCodeRelationshipInventorySku' } }
InboundCodeRelationshipCustomer:
type: object
required: [customerId]
properties:
customerId: { type: string, description: 字符串业务隔离标识,禁止按JavaScript number处理 }
customerName: { type: string, nullable: true }
InboundCodeRelationshipWarehouse:
type: object
properties:
warehouseId: { type: string, nullable: true, description: 字符串共享仓库ID,禁止按JavaScript number处理 }
warehouseCode: { type: string, nullable: true }
warehouseName: { type: string, nullable: true }
InboundCodeRelationshipOrder:
type: object
required: [orderNo]
properties:
orderNo: { type: string }
orderStatus: { type: string, nullable: true }
InboundCodeRelationshipPlan:
type: object
required: [planNo]
properties:
planNo: { type: string }
planStatus: { type: string, nullable: true }
receivingStatus: { type: string, nullable: true }
InboundCodeRelationshipInventorySku:
type: object
required: [skuCode, packageCount]
properties:
skuCode: { type: string }
productNameZh: { type: string, nullable: true }
productNameEn: { type: string, nullable: true }
packageCount: { type: integer, format: int64, minimum: 0 }
InboundCodeRelationshipApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
properties: { data: { $ref: '#/components/schemas/InboundCodeRelationship' } }
FbaTransferDestination:
type: object
required: [warehouseId, warehouseCode, warehouseName]
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.inventory.query;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class InboundCodeRelationshipOpenApiContractTest {
@Test
void publishesPlatformRelationshipLookupWithoutClientCustomerScope() throws IOException {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"));
String path = "/api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}:";
int start = yaml.indexOf(path);
int end = yaml.indexOf("\n /", start + path.length());
assertThat(start).isGreaterThanOrEqualTo(0);
String operation = yaml.substring(start, end > start ? end : yaml.length());
assertThat(operation)
.contains("name: inboundCode")
.contains("$ref: '#/components/schemas/InboundCodeRelationshipApiResponse'")
.doesNotContain("name: customerId");
assertThat(yaml)
.contains("InboundCodeRelationship:")
.contains("overseasReceivingStatus:")
.contains("taillegOrders:")
.contains("inboundPlans:")
.contains("orderStatus:")
.contains("planStatus:")
.contains("palletCodes:")
.contains("inventory:");
}
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.inventory.query.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inventory.query.dto.InboundCodeRelationshipResponse;
import com.baiyue.lastleg.inventory.query.service.InboundCodeRelationshipService;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundCodeRelationshipControllerTest {
@Mock InboundCodeRelationshipService service;
private InboundCodeRelationshipController controller;
@BeforeEach
void setUp() {
controller = new InboundCodeRelationshipController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void platformReadsByInboundCodeWithoutSupplyingCustomerId() {
InboundCodeRelationshipResponse response = new InboundCodeRelationshipResponse(
"IN-1", false, null, null, null, List.of(), List.of(), List.of(), List.of());
when(service.find("IN-1")).thenReturn(response);
var result = controller.find(platform(), "IN-1");
assertThat(result.data()).isSameAs(response);
verify(service).find("IN-1");
}
@Test
void customerAccountCannotUsePlatformRelationshipReadModel() {
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 8L, "customer",
"MERCHANT", "WEB", Set.of(OverseasPermissions.INVENTORY_LIST));
assertThatThrownBy(() -> controller.find(customer, "IN-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
verifyNoInteractions(service);
}
@Test
void missingIdentityIsUnauthorized() {
assertThatThrownBy(() -> controller.find(null, "IN-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("401");
verifyNoInteractions(service);
}
private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB",
Set.of(OverseasPermissions.INVENTORY_LIST));
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inventory.query.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class InboundCodeRelationshipMapperContractTest {
@Test
void mapperIsExplicitlyScannableAndQueriesOnlyLastlegTables() throws Exception {
assertThat(InboundCodeRelationshipMapper.class).hasAnnotation(Mapper.class);
String sql = String.join(" ", InboundCodeRelationshipMapper.class
.getMethod("bases", String.class).getAnnotation(Select.class).value());
assertThat(sql).contains("ll_order", "ll_inbound_plan", "ll_inventory_balance");
assertThat(sql).doesNotContain("customerId}", "warehouse.", "iam.");
}
}
@@ -0,0 +1,102 @@
package com.baiyue.lastleg.inventory.query.service;
import com.baiyue.lastleg.inventory.query.mapper.InboundCodeRelationshipMapper;
import com.baiyue.lastleg.inventory.query.model.InboundCodeRelationshipRows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundCodeRelationshipServiceTest {
@Mock InboundCodeRelationshipMapper mapper;
private InboundCodeRelationshipService service;
@BeforeEach
void setUp() {
service = new InboundCodeRelationshipService();
ReflectionTestUtils.setField(service, "mapper", mapper);
}
@Test
void missingInboundCodeReturnsExplicitEmptyRelationship() {
when(mapper.bases("NYEXX2608300001")).thenReturn(List.of());
var result = service.find(" NYEXX2608300001 ");
assertThat(result.found()).isFalse();
assertThat(result.customer()).isNull();
assertThat(result.taillegOrders()).isEmpty();
assertThat(result.inboundPlans()).isEmpty();
assertThat(result.palletCodes()).isEmpty();
assertThat(result.inventory()).isEmpty();
}
@Test
void relationshipUsesBusinessNumbersAndKeepsLongIdentifiersAsStrings() {
InboundCodeRelationshipRows.Base row = base("2092618717861048321", "Customer A");
when(mapper.bases("NYEXX2608300001")).thenReturn(List.of(row, row));
when(mapper.palletCodes("2092618717861048321", "NYEXX2608300001"))
.thenReturn(List.of("PAL-LAX-001"));
InboundCodeRelationshipRows.InventorySku sku = new InboundCodeRelationshipRows.InventorySku();
sku.setSkuCode("SKU-001"); sku.setProductNameZh("产品一"); sku.setPackageCount(40L);
when(mapper.inventory("2092618717861048321", "NYEXX2608300001")).thenReturn(List.of(sku));
var result = service.find("NYEXX2608300001");
assertThat(result.found()).isTrue();
assertThat(result.customer().customerId()).isEqualTo("2092618717861048321");
assertThat(result.overseasWarehouse().warehouseId()).isEqualTo("193000000000200003");
assertThat(result.taillegOrders()).extracting("orderNo").containsExactly("LMO-260830-0001");
assertThat(result.taillegOrders()).extracting("orderStatus").containsExactly("INBOUND_PLANNED");
assertThat(result.inboundPlans()).extracting("planNo").containsExactly("LIP-260830-0001");
assertThat(result.inboundPlans()).extracting("planStatus").containsExactly("COMPLETED");
assertThat(result.palletCodes()).containsExactly("PAL-LAX-001");
assertThat(result.inventory()).extracting("skuCode", "packageCount")
.containsExactly(org.assertj.core.groups.Tuple.tuple("SKU-001", 40L));
}
@Test
void sameInboundCodeAcrossCustomersIsRejectedBeforeScopedDetailQueries() {
when(mapper.bases("DUPLICATE")).thenReturn(List.of(
base("100", "Customer A"), base("200", "Customer B")));
assertThatThrownBy(() -> service.find("DUPLICATE"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(mapper, never()).palletCodes(anyString(), anyString());
verify(mapper, never()).inventory(anyString(), anyString());
}
@Test
void blankAndOversizedInboundCodesAreRejected() {
assertThatThrownBy(() -> service.find(" ")).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400");
assertThatThrownBy(() -> service.find("X".repeat(129))).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400");
verifyNoInteractions(mapper);
}
private static InboundCodeRelationshipRows.Base base(String customerId, String customerName) {
InboundCodeRelationshipRows.Base value = new InboundCodeRelationshipRows.Base();
value.setCustomerId(customerId); value.setCustomerName(customerName);
value.setOrderNo("LMO-260830-0001"); value.setOrderStatus("INBOUND_PLANNED");
value.setPlanNo("LIP-260830-0001"); value.setPlanStatus("COMPLETED");
value.setReceivingStatus("COMPLETED"); value.setOverseasWarehouseId("193000000000200003");
value.setOverseasWarehouseCode("US-LAX-01"); value.setOverseasWarehouseName("洛杉矶一号仓");
return value;
}
}
@@ -10,11 +10,13 @@ import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.dto.MerchantInboundOrderRow;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import com.baiyue.order.dto.InboundCodeAssociationSummary;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.MerchantInboundOrderListService;
import com.baiyue.order.service.MerchantBatchDraftService;
import com.baiyue.order.service.InboundCodeAssociationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -50,6 +52,8 @@ public class MerchantFirstLegOrderController {
private MerchantInboundOrderListService inboundOrderListService;
@Resource
private MerchantBatchDraftService batchDraftService;
@Resource
private InboundCodeAssociationService inboundCodeAssociations;
@GetMapping("/inbound-code-policy")
@PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')")
@@ -57,6 +61,17 @@ public class MerchantFirstLegOrderController {
return ApiResponse.success(service.inboundCodePolicy());
}
@GetMapping("/inbound-code-associations/{inboundCode}")
@PreAuthorize("hasAuthority('merchant:order:list')")
@Operation(summary = "按入仓码查询本客户订单基础关联",
description = "客户范围只取登录态;其他客户的入仓码按不存在处理。")
public ApiResponse<InboundCodeAssociationSummary> inboundCodeAssociation(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String inboundCode) {
return ApiResponse.success(inboundCodeAssociations.findForCustomer(
OrderScopeGuard.merchant(user), inboundCode));
}
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿")
@Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。")
@ApiResponses({
@@ -9,12 +9,14 @@ import com.baiyue.order.dto.BatchOrderReviewResponse;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import com.baiyue.order.dto.InboundCodeAssociationSummary;
import com.baiyue.order.dto.TenantOrderCountResponse;
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.InboundCodeAssociationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -47,6 +49,8 @@ import java.util.List;
public class PlatformFirstLegOrderController {
@Resource
private OrderApplicationService service;
@Resource
private InboundCodeAssociationService inboundCodeAssociations;
@GetMapping("/inbound-code-policy")
@PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')")
@@ -54,6 +58,17 @@ public class PlatformFirstLegOrderController {
return ApiResponse.success(service.inboundCodePolicy());
}
@GetMapping("/inbound-code-associations/{inboundCode}")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "按入仓码查询订单基础关联",
description = "入仓码和订单号是主要展示键;Long ID 仅供路由关联。")
public ApiResponse<InboundCodeAssociationSummary> inboundCodeAssociation(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String inboundCode) {
OrderScopeGuard.platform(user);
return ApiResponse.success(inboundCodeAssociations.findForPlatform(inboundCode));
}
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "平台代客创建草稿")
@Operation(summary = "平台代客创建草稿", description = "在明确指定的目标租户内创建头程订单草稿,必须携带幂等键。")
@ApiResponses({
@@ -0,0 +1,19 @@
package com.baiyue.order.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/** 以全局唯一入仓码定位的订单基础关联摘要;技术 ID 统一使用字符串。 */
public record InboundCodeAssociationSummary(
@Schema(description = "主要展示键:全局唯一入仓码") String inboundCode,
@Schema(description = "主要展示键:订单业务号") String orderNo,
@Schema(description = "订单交易状态") String orderStatus,
@Schema(description = "订单内部 ID,仅供路由关联") String orderId,
@Schema(description = "客户 ID,字符串避免前端精度损失") String customerId,
String customerName,
String destinationType,
String destinationCountryCode,
@Schema(description = "目的仓内部 ID,仅供路由关联") String destinationWarehouseId,
@Schema(description = "目的仓业务编码;FBA 场景为 Amazon 仓码") String destinationWarehouseCode,
String destinationWarehouseName
) {
}
@@ -121,6 +121,21 @@ public class OrderRepository {
.eq(OrderEntity::getId, orderId));
}
/** 平台按数据库全局唯一键查询,不接受客户筛选参数。 */
public OrderEntity findByInboundCode(String inboundCode) {
return orderMapper.selectOne(new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getInboundCode, inboundCode)
.eq(OrderEntity::getDeleted, false));
}
/** 客户查询必须在 SQL 条件中同时绑定登录客户与入仓码。 */
public OrderEntity findOwnedByInboundCode(long tenantId, String inboundCode) {
return orderMapper.selectOne(new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId)
.eq(OrderEntity::getInboundCode, inboundCode)
.eq(OrderEntity::getDeleted, false));
}
public boolean inboundCodeExists(String inboundCode, Long excludedOrderId) {
if (!StringUtils.hasText(inboundCode)) return false;
return orderMapper.selectCount(new LambdaQueryWrapper<OrderEntity>()
@@ -0,0 +1,65 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.InboundCodeAssociationSummary;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.repository.OrderRepository;
import org.springframework.stereotype.Service;
import java.util.regex.Pattern;
/** 从 Order 权威快照按入仓码读取只读基础关联。 */
@Service
public class InboundCodeAssociationService {
private static final Pattern INBOUND_CODE = Pattern.compile("[A-Za-z0-9._:-]{1,40}");
private final OrderRepository repository;
public InboundCodeAssociationService(OrderRepository repository) {
this.repository = repository;
}
public InboundCodeAssociationSummary findForPlatform(String inboundCode) {
String normalized = normalize(inboundCode);
return summary(require(repository.findByInboundCode(normalized)), normalized);
}
public InboundCodeAssociationSummary findForCustomer(long tenantId, String inboundCode) {
if (tenantId <= 0) throw new BizException(ResultCode.FORBIDDEN, "Customer scope is required");
String normalized = normalize(inboundCode);
return summary(require(repository.findOwnedByInboundCode(tenantId, normalized)), normalized);
}
private String normalize(String value) {
String normalized = value == null ? "" : value.trim();
if (!INBOUND_CODE.matcher(normalized).matches()) {
throw new BizException(ResultCode.BAD_REQUEST, "入仓码格式无效");
}
return normalized;
}
private OrderEntity require(OrderEntity order) {
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入仓码关联订单不存在");
return order;
}
private InboundCodeAssociationSummary summary(OrderEntity order, String inboundCode) {
return new InboundCodeAssociationSummary(
inboundCode,
order.getOrderNo(),
order.getStatus() == null ? null : order.getStatus().name(),
string(order.getId()),
string(order.getCustomerId()),
order.getCustomerName(),
order.getDestinationType(),
order.getDestinationCountryCode(),
string(order.getDestinationWarehouseId()),
order.getAmazonWarehouseCode(),
order.getDestinationWarehouseName());
}
private String string(Long value) {
return value == null ? null : value.toString();
}
}
+58 -1
View File
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 柏悦头程订单 API
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
version: 1.6.0
version: 1.7.0
security:
- bearerAuth: []
paths:
@@ -21,6 +21,21 @@ paths:
code: {type: string, example: '200'}
data: {$ref: '#/components/schemas/InboundCodePolicy'}
'403': {description: 需要平台订单列表、创建、编辑或提交权限之一}
/api/platform/v1/orders/first-leg/inbound-code-associations/{inboundCode}:
get:
summary: 平台按全局入仓码查询订单基础关联
description: >-
需要 platform:order:list。入仓码和订单业务号是主要展示键;Long ID 均以字符串返回,
只供页面路由和后续接口关联。查询直接使用 Order 全局唯一入仓码事实,不跨库读取 Firstleg。
parameters:
- $ref: '#/components/parameters/InboundCodePath'
responses:
'200':
description: 查询成功或业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/InboundCodeAssociationEnvelope'}
'403': {description: 需要平台身份和 platform:order:list 权限}
/api/platform/v1/orders:
get:
summary: 平台统一订单列表
@@ -187,6 +202,21 @@ paths:
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
/api/merchant/v1/orders/first-leg/inbound-code-associations/{inboundCode}:
get:
summary: 客户按入仓码查询本客户订单基础关联
description: >-
需要 merchant:order:list。客户范围仅取登录态,不接受客户 ID 参数;其他客户的入仓码统一按不存在处理。
入仓码和订单业务号是主要展示键,Long ID 只供路由关联。
parameters:
- $ref: '#/components/parameters/InboundCodePath'
responses:
'200':
description: 查询成功、不存在或其他业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/InboundCodeAssociationEnvelope'}
'403': {description: 需要客户身份和 merchant:order:list 权限}
/api/merchant/v1/orders/first-leg/batch:
post:
summary: 批量创建头程订单草稿
@@ -671,6 +701,27 @@ components:
properties:
mode: {type: string, enum: [AUTO, LEGACY_MANUAL]}
manualEntryRequired: {type: boolean}
InboundCodeAssociationEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/InboundCodeAssociationSummary'}
InboundCodeAssociationSummary:
type: object
required: [inboundCode, orderNo, orderStatus, orderId, customerId, customerName, destinationType]
properties:
inboundCode: {type: string, maxLength: 40, pattern: '^[A-Za-z0-9._:-]+$', description: 主要展示键:全局唯一入仓码}
orderNo: {type: string, description: 主要展示键:订单业务号}
orderStatus: {type: string, description: Order 权威交易状态}
orderId: {type: string, pattern: '^[1-9][0-9]*$', description: 内部 ID,仅供路由关联}
customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 客户 ID,字符串避免精度损失}
customerName: {type: string}
destinationType: {type: string}
destinationCountryCode: {type: string, nullable: true}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 内部 ID,仅供路由关联}
destinationWarehouseCode: {type: string, nullable: true, description: 目的仓业务编码;FBA 场景为 Amazon 仓码}
destinationWarehouseName: {type: string, nullable: true}
FirstLegBatchDraftItem:
type: object
required: [clientRowId, draft]
@@ -778,6 +829,12 @@ components:
packageCount: {type: integer}
loadingPlanId: {type: string}
parameters:
InboundCodePath:
name: inboundCode
in: path
required: true
schema: {type: string, minLength: 1, maxLength: 40, pattern: '^[A-Za-z0-9._:-]+$'}
description: 全局唯一业务入仓码
InternalToken:
name: X-Internal-Service-Token
in: header
@@ -0,0 +1,48 @@
package com.baiyue.order.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.InboundCodeAssociationSummary;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class InboundCodeAssociationContractTest {
@Test
void platformAndCustomerEndpointsUseListPermissionAndSameSummary() throws Exception {
assertEndpoint(PlatformFirstLegOrderController.class, "platform:order:list");
assertEndpoint(MerchantFirstLegOrderController.class, "merchant:order:list");
}
@Test
void openApiMakesBusinessKeysPrimaryAndRouteIdsStrings() throws Exception {
String openapi = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/platform/v1/orders/first-leg/inbound-code-associations/{inboundCode}:")
.contains("/api/merchant/v1/orders/first-leg/inbound-code-associations/{inboundCode}:")
.contains("InboundCodeAssociationSummary:")
.contains("入仓码和订单业务号是主要展示键")
.contains("orderId: {type: string")
.contains("customerId: {type: string")
.contains("destinationWarehouseId: {type: string")
.contains("其他客户的入仓码统一按不存在处理")
.doesNotContain("tenantId, in: query, required: true, description: 入仓码关联查询");
}
private void assertEndpoint(Class<?> controller, String authority) throws Exception {
var method = controller.getDeclaredMethod(
"inboundCodeAssociation", AuthenticatedUser.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/inbound-code-associations/{inboundCode}");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
assertThat(method.getGenericReturnType().getTypeName())
.contains(InboundCodeAssociationSummary.class.getName());
}
}
@@ -21,6 +21,8 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, MerchantInboundOrderQuery.class);
assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')",
AuthenticatedUser.class, long.class);
assertAuthority(MerchantFirstLegOrderController.class, "inboundCodeAssociation",
"hasAuthority('merchant:order:list')", AuthenticatedUser.class, String.class);
}
@Test
@@ -31,6 +33,8 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, long.class, long.class);
assertAuthority(PlatformFirstLegOrderController.class, "tenantCounts", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, List.class);
assertAuthority(PlatformFirstLegOrderController.class, "inboundCodeAssociation",
"hasAuthority('platform:order:list')", AuthenticatedUser.class, String.class);
}
@Test
@@ -7,6 +7,7 @@ import com.baiyue.order.dto.BatchOrderReviewResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.InboundCodeAssociationService;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -26,6 +27,7 @@ import static org.mockito.Mockito.verifyNoInteractions;
@SpringJUnitConfig(PlatformFirstLegOrderController.class)
class PlatformOrderPageControllerTest {
@MockBean private OrderApplicationService service;
@MockBean private InboundCodeAssociationService inboundCodeAssociations;
@Resource private PlatformFirstLegOrderController controller;
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@@ -126,6 +126,36 @@ class OrderRepositoryIsolationUpdateTest {
.contains("FIRST_LEG_ORDER", "PENDING");
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void customerInboundCodeLookupBindsCustomerAndGlobalBusinessKey() {
OrderMapper mapper = mock(OrderMapper.class);
OrderRepository repository = repository("orderMapper", mapper);
repository.findOwnedByInboundCode(11L, "ZBYHK2608300001");
ArgumentCaptor<Wrapper<OrderEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).selectOne(wrapper.capture());
assertThat(wrapper.getValue().getSqlSegment()).contains("tenant_id", "inbound_code", "deleted");
assertThat(params(wrapper.getValue()).values()).contains(11L, "ZBYHK2608300001", false);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void platformInboundCodeLookupUsesGlobalUniqueBusinessKeyWithoutCustomerPredicate() {
OrderMapper mapper = mock(OrderMapper.class);
OrderRepository repository = repository("orderMapper", mapper);
repository.findByInboundCode("ZBYHK2608300001");
ArgumentCaptor<Wrapper<OrderEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).selectOne(wrapper.capture());
assertThat(wrapper.getValue().getSqlSegment())
.contains("inbound_code", "deleted")
.doesNotContain("tenant_id");
assertThat(params(wrapper.getValue()).values()).contains("ZBYHK2608300001", false);
}
private static <T> void initialize(Class<T> entityType, String resource) {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), resource), entityType);
@@ -0,0 +1,83 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class InboundCodeAssociationServiceTest {
private final OrderRepository repository = mock(OrderRepository.class);
private final InboundCodeAssociationService service = new InboundCodeAssociationService(repository);
@Test
void platformReadsGlobalBusinessKeyAndReturnsStringRouteIds() {
when(repository.findByInboundCode("ZBYHK2608300001")).thenReturn(order());
var summary = service.findForPlatform(" ZBYHK2608300001 ");
assertThat(summary.inboundCode()).isEqualTo("ZBYHK2608300001");
assertThat(summary.orderNo()).isEqualTo("FL202608300001");
assertThat(summary.orderStatus()).isEqualTo("APPROVED");
assertThat(summary.orderId()).isEqualTo("9007199254740993");
assertThat(summary.customerId()).isEqualTo("9007199254740995");
assertThat(summary.customerName()).isEqualTo("客户甲");
assertThat(summary.destinationType()).isEqualTo("FBA");
assertThat(summary.destinationCountryCode()).isEqualTo("US");
assertThat(summary.destinationWarehouseId()).isEqualTo("9007199254740997");
assertThat(summary.destinationWarehouseCode()).isEqualTo("ONT8");
assertThat(summary.destinationWarehouseName()).isEqualTo("ONT8 仓");
}
@Test
void customerQueryAlwaysUsesAuthenticatedCustomerScope() {
when(repository.findOwnedByInboundCode(10001L, "ZBYHK2608300001")).thenReturn(order());
service.findForCustomer(10001L, "ZBYHK2608300001");
verify(repository).findOwnedByInboundCode(10001L, "ZBYHK2608300001");
verify(repository, never()).findByInboundCode("ZBYHK2608300001");
}
@Test
void anotherCustomersCodeIsIndistinguishableFromMissingCode() {
when(repository.findOwnedByInboundCode(10001L, "ZBYHK2608300001")).thenReturn(null);
assertThatThrownBy(() -> service.findForCustomer(10001L, "ZBYHK2608300001"))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND))
.hasMessage("入仓码关联订单不存在");
}
@Test
void invalidInboundCodeIsRejectedBeforeRepositoryAccess() {
assertThatThrownBy(() -> service.findForPlatform("bad/code"))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.BAD_REQUEST));
verifyNoInteractions(repository);
}
private OrderEntity order() {
OrderEntity order = new OrderEntity();
order.setId(9007199254740993L);
order.setOrderNo("FL202608300001");
order.setStatus(OrderStatus.APPROVED);
order.setCustomerId(9007199254740995L);
order.setCustomerName("客户甲");
order.setDestinationType("FBA");
order.setDestinationCountryCode("US");
order.setDestinationWarehouseId(9007199254740997L);
order.setAmazonWarehouseCode("ONT8");
order.setDestinationWarehouseName("ONT8 仓");
return order;
}
}
+58
View File
@@ -2,6 +2,64 @@
全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。
## 脚本目录归属
脚本按作用域放置,不要求全部集中到根 `scripts/`:
| 目录 | 归属 | 是否直接运行 |
|---|---|---|
| `scripts/` | 仓库级验收、回归、诊断、环境辅助和静态合同 | 本文“仓库级入口清单”列出的文件可以直接运行 |
| `scripts/admin-test-env-modules/` | `admin-test-env-acceptance.mjs` 动态加载的业务模块、fixture 和离线合同 | 业务 `.mjs` 原则上不直接运行;模块名、依赖和边界见该目录 `README.md` |
| `scripts/openapi-validation/` | 全仓 OpenAPI 发现、解析和引用校验 | 从该目录安装依赖后运行 `validate-openapi.mjs`;`validate-openapi.test.mjs` 是离线合同 |
| `firstleg/scripts/` | Firstleg 服务内部 Java 包依赖分析 | 只维护 Firstleg 内部模块化基线,与服务一起演进 |
| `lastleg-service/scripts/` | Lastleg 服务内部 Java 包依赖分析 | 只维护 Lastleg 内部模块化基线,与服务一起演进 |
| `warehouse/scripts/` | Warehouse 公共主数据等服务局部离线合同 | 从后端根用 Node test runner 执行,不连接数据库 |
| `deploy/scripts/` | Jenkins/部署主流程调用的镜像部署与测试库初始化脚本 | 只在受控部署或测试基础设施流程运行 |
| `deploy/rocketmq/` | RocketMQ 容器启动时渲染 broker 配置 | 由容器入口调用,不作为人工验收入口 |
因此 `firstleg/scripts/`、`lastleg-service/scripts/` 和 `deploy/**` 不是散落或放错目录;它们靠近所属部署单元或运行组件。仓库检索到的可执行脚本如果既不在上述目录,也没有在本表登记,应视为脚本归属缺失。
## 仓库级入口清单
下表覆盖 `scripts/` 下所有非 `*.test.mjs` 的可执行入口。`*.test.mjs` 统一为离线 Node 静态合同,不访问测试环境;`admin-test-env-modules/*.mjs` 的逐模块说明由该目录 `README.md` 和总入口注册表维护。
| 分类 | 入口 | 用途与副作用 |
|---|---|---|
| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 |
| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 |
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 二期入库、测量、费用和权限专项;写测试数据 |
| 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 |
| 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` |
| 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 |
| 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 |
| 测试环境 HTTP | `phase2-test-env-acceptance.ps1` | 二期测试环境 API 验收,会写业务数据 |
| 场景组件 | `mojia-measurement-scenario.mjs` | 被 Admin 验收入口复用的墨家测量场景;不是独立 CLI,调用正式测量接口并上传图片 |
| 定向诊断 | `pda-final-complete-diagnostic.mjs` | 对指定 PDA task 回查最终完成门禁;传 `--seed-evidence` 时会补五类证据并产生写操作 |
| 本地真实 API | `order-warehouse-real-api-regression.ps1` | 本地 Order/Warehouse 往来单位真实 API 回归,并直接读写本机 MySQL;不得指向共享或测试数据库 |
| 一期本地验收 | `phase1-acceptance.ps1` | 一期本地服务/API 综合验收,会创建本地业务数据 |
| 一期本地验收 | `phase1-business-maturity-regression.ps1` | 一期业务成熟度回归,调用本地服务并生成报告 |
| 一期本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 |
| 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 |
| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试与类型检查,不访问 Gateway |
| 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 |
| 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 |
| 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 |
| 本地质量 | `java-quality-summary.mjs` | 聚合 Maven 拓扑、OpenAPI、JaCoCo 和 SpotBugs 已有报告,不启动服务 |
| 本地质量 | `agents-index-contract.ps1` | 离线检查 AGENTS 快速索引与关键入口一致性 |
| 契约数据 | `synchronous-dependency-baseline.mjs` | 同步服务依赖合同的数据源,只由相邻合同测试导入,不是 CLI |
| OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 |
| 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 |
| 本地环境 | `publish-local-nacos-config.ps1` | 将 `config/nacos/local/*.yml` 发布到指定本地 Nacos,会修改 Nacos 配置 |
| 密钥配置 | `generate-overseas-jwk.mjs` | 生成 3072 位海外 JWT RSA JWK 并写入指定 `deploy/env/.env.*`;已有 JWK 时拒绝覆盖,密钥文件不得提交 |
| 迁移辅助 | `migrate-local-qualification-file.ps1` | 本地资质文件迁移辅助,会修改本地文件/数据;运行前核对脚本参数和备份策略 |
| Jenkins 管理 | `provision-development-service-jobs.groovy` | Jenkins Script Console 批量准备开发服务 Job;默认 `applyChanges=false` 仅预览,改为 true 会创建 Job |
部署目录的入口另有:`deploy/scripts/deploy-built-service.sh`(单服务健康检查与失败回滚)、`deploy/scripts/deploy-development-service.sh`(经 SSH 部署开发服务)、`deploy/scripts/deploy-services.sh`(按服务列表编排部署)、`deploy/scripts/initialize-test-databases.sh`(初始化测试基础设施数据库)和 `deploy/rocketmq/render-broker-config.sh`(容器内渲染并启动 RocketMQ broker)。这些脚本都会改变运行环境,不能作为普通本地检查执行。
服务局部分析入口为 `firstleg/scripts/analyze-firstleg-dependencies.mjs` 与 `lastleg-service/scripts/analyze-lastleg-dependencies.mjs`,相邻的 `*.test.mjs` 负责对基线和违规依赖做离线回归;`warehouse/scripts/warehouse-public-master-data-contract.test.mjs` 只读校验 Warehouse 公共主数据 CSV 与 SQL。SQL 辅助文件 `scripts/db/incremental.sql` 不是当前统一部署入口,正式数据库变更以 `deploy/sql/README.md` 指向的统一增量为准。
新增可直接运行的脚本时,必须同时在本节登记用途、目标环境、是否写数据和密钥边界,并运行 `node --test scripts/script-documentation-contract.test.mjs`。仅新增 `*.test.mjs` 或 `admin-test-env-modules/` 内部模块时,分别更新对应合同范围或模块 README,不把内部文件误写成独立入口。
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|---|---|---|---|
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 |
@@ -42,9 +42,10 @@
- SKU 数随机为 `1–4`,但不得超过本票总箱数。
- 总箱数随机拆分到各 SKU,每个 SKU 至少 1 箱。
- 毛重、净重、数量、申报价值和体积根据各 SKU 箱数计算。
- 称重图片、托盘和占用库位数量必须与上述托数完全一致。
- 称重图片数量与上述托数一致;每托只在既有 `TP-001`、`YW-TP-002` 中随机复用一个托盘编码,不创建新的托盘主档,因此同一票和不同票都允许重复使用这两个编码。
- 库位由正式仓储预占/结算接口分配,不创建库位主档;普通库位不足时直接使用仓库公共位 `A0`。托盘复用不改变 `palletCount = ceil(totalPackageCount / 30)` 的业务托数。
- 箱唛打印冻结真实总箱数;多 SKU 测量使用各 SKU 实际箱数和对应箱序号。
- 通过正式文件、称重 OCR、托盘库位、测量提交和审核接口推进状态,不直接写数据库。
- 通过正式文件、称重 OCR、托盘库位预占、测量提交和审核接口推进状态,不直接写数据库。
## 尽力执行语义
@@ -92,5 +93,6 @@ node scripts/it-test-wait-split-bulk.mjs `
- 仅用于已明确授权的测试环境,禁止用于生产环境。
- 账号、密码、Token 和环境地址只从本机环境配置加载,不进入源码或报告。
- 不自动删除已成功或部分成功的数据。
- 不新增托盘或库位主档;只能复用 `TP-001`、`YW-TP-002`,库位不足落公共位 `A0`。
- 不重置数据库、不执行 SQL、不绕过正式状态机。
- 清理测试数据或文件必须另行获得明确授权。
+2
View File
@@ -4,6 +4,8 @@
需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。
固定“IT测试”客户与“义乌仓”、其余线路随机并尽力生成最多 8,000 票时,独立编排入口是 `../it-test-wait-split-bulk.mjs`。它仍复用本目录的 `orders.mjs` 与 `inbound-measurement.mjs`,但逐票保存日志且单票失败后继续。测量前置只随机复用既有 `TP-001`、`YW-TP-002`,不得调用托盘新增接口;库位由正式仓储接口分配,普通位不足时使用公共位 `A0`,不得调用库位新增接口。
订单造数中的 `GET /api/merchant/v1/partners/candidates?type=SHIPPER` 属于 Warehouse 正式客户候选能力。失败时订单脚本仍可使用 sender 快照继续收集后续链路证据,但 HTTP 503/`S0503` 必须保留为 deployment blocker,不能因订单、测量、分柜或装柜后续通过而降级为 coverage gap。
人工分柜采用两个可选模块,不进入默认全量入口:`manual-order-splitting-fixture.mjs` 基于本轮至少四票 `WAIT_SPLIT` 数据生成部分分配、整票拼柜和未分配对照清单;人工在 Admin 完成计划分配后,`manual-order-splitting-verify.mjs` 读取准备报告和 `loadingPlanId`,只读核对柜次范围、计划箱数及候选余量。完整命令见同一指南的“人工分柜验收”章节。
@@ -55,6 +55,7 @@
- 固定渠道存在当前 `ACTIVE` 价格簿和进仓码前缀。
- FBA/OVERSEAS 至少存在一个与固定渠道价格明细匹配的目的仓。
- File、Order、Firstleg、Warehouse、IAM、Auth 等链路可用。
- 测量托盘只复用仓库已有 `TP-001`、`YW-TP-002`;脚本不创建托盘主档。库位由正式仓储接口分配,普通位不足时使用公共位 `A0`,脚本不创建库位主档。
账号、密码、环境域名和 Token 不得写入本文、源码、提交记录或报告。建议由本地未提交环境文件或当前终端环境变量提供。
@@ -405,7 +406,7 @@ node scripts/admin-test-env-acceptance.mjs `
## 失败、重跑与批量拆分
- 批次遇到第一条失败即停止,已经成功创建的订单不会自动删除或回滚。
- `admin-test-env-acceptance.mjs --wait-split-count` 的普通批次遇到第一条失败即停止;`it-test-wait-split-bulk.mjs` 会记录该票失败并继续尝试后续票。两种入口都不会自动删除或回滚已经成功创建的订单。
- 先查看日志和 JSON 报告中最后一个成功 fixture、失败接口的 `code/requestId/traceId`,修复前置后再运行剩余数量。
- 重跑会生成新业务单号和入仓码,不覆盖旧订单;不要假设跨多次运行的入仓码一定连续。
- 每次运行的随机序列从 1 重新开始;分批执行时组合可能重复,但仍保持固定三项和真实契约。
@@ -429,6 +430,7 @@ node scripts/admin-test-env-acceptance.mjs `
```powershell
node --check scripts/admin-test-env-acceptance.mjs
node --check scripts/it-test-wait-split-bulk.mjs
node --check scripts/admin-test-env-modules/orders.mjs
node --check scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs
node --check scripts/admin-test-env-modules/manual-order-splitting-verify.mjs
@@ -440,7 +442,7 @@ node --check scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs
node --check scripts/admin-test-env-modules/manual-pda-batch-verify.mjs
node --check scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs
node --check scripts/admin-test-env-modules/manual-loading-complete-verify.mjs
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs
node --test scripts/script-documentation-contract.test.mjs scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs
git diff --check
```
+13
View File
@@ -107,6 +107,14 @@ Assert-Contains $backendAgents '所有生产 MyBatis Mapper 接口必须显式
Assert-Contains $backendAgents 'Mapper 新增、移动、Bean 缺失与扫描遗漏' '后端 Mapper 快速路由'
Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同'
Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同'
$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md')
$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml')
Assert-Contains $rootAgents 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
Assert-Contains $adminAgents '成员数是入仓片段数,不等于订单票数' 'Admin 同柜成员语义'
Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupController.java' '后端同柜不可拆组入口'
Assert-Contains $firstlegAgents '服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数' 'Firstleg 同柜建组边界'
Assert-Contains $coloadOpenApi '允许跨客户及不同目的仓港成组,仅要求国内仓和渠道一致' '同柜资格 OpenAPI'
Assert-Contains $coloadOpenApi '服务端解析每个订单全部 WAIT_SPLIT' '同柜订单展开 OpenAPI'
$mapperModuleAgents = @(
@{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' },
@{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' },
@@ -408,6 +416,8 @@ $criticalPaths = @(
'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts',
'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.test.ts',
'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts',
'apps/admin-web/src/modules/unified-orders/api/coLoadGroups.ts',
'apps/admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue',
'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts',
'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue',
'apps/admin-web/src/core/routing/navigationRecovery.ts',
@@ -483,6 +493,9 @@ $criticalPaths = @(
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java',
'baiyue-firstleg/firstleg/src/main/resources/openapi/customs-v1.yaml',
'baiyue-firstleg/firstleg/src/main/resources/openapi/coload-groups-v1.yaml',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java',
'baiyue-firstleg/file/src/main/resources/openapi/customs-file-and-export-v1.yaml',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java',
'baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml',
+10
View File
@@ -1,3 +1,13 @@
#!/usr/bin/env node
/**
* 为已授权的非生产环境尽力生成 IT测试/义乌仓 WAIT_SPLIT 数据。
*
* 每票随机 1-300 箱(可通过参数缩小),失败会记录后继续;不会自动清理已写入数据。
* 测量前置只复用 TP-001/YW-TP-002,不创建托盘;库位由正式仓储接口分配,
* 普通位不足时由服务端落公共位 A0,脚本不创建库位。
* 账号、密码和 Gateway 只从 admin-test-env-acceptance.mjs 支持的环境变量读取。
*/
import { randomInt } from 'node:crypto'
import { appendFile, mkdir, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)))
const scriptsReadme = path.join(repositoryRoot, 'scripts', 'README.md')
const excludedDirectoryNames = new Set([
'.git', '.logs', 'dist', 'node_modules', 'output', 'outputs', 'reports', 'target', 'uni_modules',
])
const allowedDirectoryPrefixes = [
'scripts/',
'deploy/scripts/',
'deploy/rocketmq/',
'firstleg/scripts/',
'lastleg-service/scripts/',
'warehouse/scripts/',
]
async function executableFiles(directory, extensions, { excludeTests = true } = {}) {
const entries = await readdir(path.join(repositoryRoot, directory), { withFileTypes: true })
return entries
.filter((entry) => entry.isFile())
.filter((entry) => extensions.some((extension) => entry.name.endsWith(extension)))
.filter((entry) => !excludeTests || !entry.name.endsWith('.test.mjs'))
.map((entry) => `${directory}/${entry.name}`.replaceAll('\\', '/'))
}
async function discoverScriptFiles(directory = repositoryRoot) {
const discovered = []
const entries = await readdir(directory, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory() && (
excludedDirectoryNames.has(entry.name)
|| entry.name.startsWith('node_modules.')
|| entry.name.startsWith('.codex-')
|| entry.name.startsWith('.runtime-')
)) continue
const absolute = path.join(directory, entry.name)
if (entry.isDirectory()) {
discovered.push(...await discoverScriptFiles(absolute))
continue
}
if (!['.mjs', '.ps1', '.sh', '.groovy'].some((extension) => entry.name.endsWith(extension))) continue
discovered.push(path.relative(repositoryRoot, absolute).replaceAll('\\', '/'))
}
return discovered
}
test('executable scripts only live in documented directories', async () => {
const files = await discoverScriptFiles()
const misplaced = files.filter((file) => !allowedDirectoryPrefixes.some((prefix) => file.startsWith(prefix)))
assert.deepEqual(misplaced, [], `以下脚本位于未登记目录:\n${misplaced.join('\n')}`)
})
test('all directly runnable scripts are documented in scripts/README.md', async () => {
const readme = await readFile(scriptsReadme, 'utf8')
const files = [
...await executableFiles('scripts', ['.mjs', '.ps1', '.groovy']),
...await executableFiles('scripts/openapi-validation', ['.mjs']),
...await executableFiles('deploy/scripts', ['.sh']),
...await executableFiles('deploy/rocketmq', ['.sh']),
...await executableFiles('firstleg/scripts', ['.mjs']),
...await executableFiles('lastleg-service/scripts', ['.mjs']),
]
const undocumented = files.filter((file) => {
const relativeToScripts = file.startsWith('scripts/') ? file.slice('scripts/'.length) : file
return !readme.includes(file) && !readme.includes(relativeToScripts)
})
assert.deepEqual(undocumented, [], `以下可执行脚本未登记用途与边界:\n${undocumented.join('\n')}`)
})
test('README explains internal modules and test scripts are not standalone entries', async () => {
const readme = await readFile(scriptsReadme, 'utf8')
assert.match(readme, /\*\.test\.mjs.*离线 Node 静态合同/)
assert.match(readme, /admin-test-env-modules\/\*\.mjs.*逐模块说明/)
assert.match(readme, /warehouse\/scripts\/warehouse-public-master-data-contract\.test\.mjs/)
})
+9
View File
@@ -0,0 +1,9 @@
# Warehouse 服务局部脚本
本目录只放 Warehouse 服务自身的离线分析或数据合同,不放仓库级 HTTP 验收、部署脚本或共享工具。
| 脚本 | 用途 | 副作用 | 运行方式 |
|---|---|---|---|
| `warehouse-public-master-data-contract.test.mjs` | 校验公共 FBA 仓 CSV、四个固定平台仓和 `warehouse-public-master-data.sql` 的数量、唯一性、字段完整性及 SQL 安全边界 | 只读 Warehouse 源码中的 CSV/SQL;不连接数据库、不执行 SQL、不写文件 | 从后端根执行 `node --test warehouse/scripts/warehouse-public-master-data-contract.test.mjs` |
新增 Warehouse 局部 Node 脚本时应放在本目录,并同步本表。跨服务或仓库级脚本仍放根 `scripts/`,Java/JUnit 测试仍放 `src/test/java/`。
@@ -4,7 +4,7 @@ import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = dirname(fileURLToPath(import.meta.url))
const warehouseRoot = resolve(currentDir, '..', '..', '..')
const warehouseRoot = resolve(currentDir, '..')
const sqlPath = resolve(warehouseRoot, 'src/main/resources/db/warehouse-public-master-data.sql')
const acceptedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.accepted.csv')
const rejectedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.rejected.csv')