This commit is contained in:
2026-08-30 17:02:19 +08:00
parent 0aa77327d5
commit ca9e7b60e8
40 changed files with 1471 additions and 6 deletions
+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;
}
}