Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
This commit is contained in:
2026-09-03 16:19:48 +08:00
10 changed files with 307 additions and 123 deletions
@@ -2593,6 +2593,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
KEY idx_inbound_arrival (tenant_id, expected_arrival_at),
KEY idx_inbound_destination (tenant_id, destination_code, created_at),
KEY idx_inbound_channel (tenant_id, channel_code, created_at),
KEY idx_inbound_smart_loading (warehouse_id, process_status, channel_code, expected_arrival_at, id),
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
@@ -2773,6 +2774,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
PRIMARY KEY (id),
UNIQUE KEY uk_measure_external_event (tenant_id, source_code, external_event_no),
KEY idx_measure_inbound_time (tenant_id, inbound_id, created_at),
KEY idx_measure_inbound_status_id (tenant_id, inbound_id, status, id),
CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库长宽高测量批次和第三方原始数据';
@@ -2815,6 +2817,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no),
UNIQUE KEY uk_measure_box_code (tenant_id, box_code),
KEY idx_measure_item_candidate (tenant_id, measurement_id, inbound_detail_id),
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
@@ -5194,6 +5197,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
KEY idx_loading_item_account (account_id, created_at, id),
KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted),
KEY idx_loading_item_order (account_id, order_id, deleted),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
@@ -8573,6 +8577,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
UNIQUE KEY uk_inbound_split_batch_source_child (tenant_id, split_batch_id, source_measurement_item_id, child_inbound_id),
KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
KEY idx_inbound_split_candidate (tenant_id, parent_inbound_id, source_inbound_detail_id),
CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0),
CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
@@ -17307,6 +17312,40 @@ CALL auth_backfill_login_phone();
DROP PROCEDURE auth_backfill_login_phone;
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS baiyue_firstleg.ensure_smart_loading_candidate_indexes;
DELIMITER $$
CREATE PROCEDURE baiyue_firstleg.ensure_smart_loading_candidate_indexes()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_firstleg'
AND table_name='fl_inbound_order' AND index_name='idx_inbound_smart_loading') THEN
ALTER TABLE baiyue_firstleg.fl_inbound_order
ADD KEY idx_inbound_smart_loading (warehouse_id,process_status,channel_code,expected_arrival_at,id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_firstleg'
AND table_name='fl_inbound_measurement' AND index_name='idx_measure_inbound_status_id') THEN
ALTER TABLE baiyue_firstleg.fl_inbound_measurement
ADD KEY idx_measure_inbound_status_id (tenant_id,inbound_id,status,id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_firstleg'
AND table_name='fl_inbound_measurement_item' AND index_name='idx_measure_item_candidate') THEN
ALTER TABLE baiyue_firstleg.fl_inbound_measurement_item
ADD KEY idx_measure_item_candidate (tenant_id,measurement_id,inbound_detail_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_firstleg'
AND table_name='fl_loading_plan_item' AND index_name='idx_loading_item_candidate') THEN
ALTER TABLE baiyue_firstleg.fl_loading_plan_item
ADD KEY idx_loading_item_candidate (account_id,inbound_id,inbound_detail_id,deleted);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_firstleg'
AND table_name='fl_inbound_split_item' AND index_name='idx_inbound_split_candidate') THEN
ALTER TABLE baiyue_firstleg.fl_inbound_split_item
ADD KEY idx_inbound_split_candidate (tenant_id,parent_inbound_id,source_inbound_detail_id);
END IF;
END$$
DELIMITER ;
CALL baiyue_firstleg.ensure_smart_loading_candidate_indexes();
DROP PROCEDURE IF EXISTS baiyue_firstleg.ensure_smart_loading_candidate_indexes;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
@@ -1,5 +1,5 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import org.apache.ibatis.annotations.*; import java.util.List; import java.util.Set;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import org.apache.ibatis.annotations.*; import java.time.LocalDateTime; import java.util.List; import java.util.Set;
@Mapper public interface SmartLoadingCandidateMapper {
@Select("""
SELECT b.channel_code channelCode,COUNT(DISTINCT b.id) availableContainerCount
@@ -84,12 +84,83 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
List<Candidate> candidates(@Param("warehouseId") long warehouseId);
@Select("""
<script>
SELECT current_group.id groupId,
CASE WHEN current_group.id IS NULL THEN eligible.accountId END accountId,
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END inboundId,
MIN(eligible.expectedArrivalAt) sortArrivalAt,MIN(eligible.inboundId) sortInboundId
FROM (
SELECT detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt,
SUM(detail.availablePackageCount) availablePackageCount
FROM (
SELECT o.tenant_id accountId,o.id inboundId,o.inbound_code inboundCode,
o.expected_arrival_at expectedArrivalAt,i.inbound_detail_id,
SUM(i.package_count)
-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
-COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
AND m.status='APPROVED'
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
AND i.inbound_detail_id IS NOT NULL AND i.package_count&gt;0
WHERE (#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId})
AND o.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
AND o.process_status='WAIT_SPLIT'
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
GROUP BY o.tenant_id,o.id,o.inbound_code,o.expected_arrival_at,i.inbound_detail_id
HAVING availablePackageCount&gt;0
) detail
GROUP BY detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt
) eligible
LEFT JOIN fl_coload_group_member current_member
ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId
AND current_member.active_flag=1
LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status IN ('ACTIVE','LOCKED')
GROUP BY current_group.id,
CASE WHEN current_group.id IS NULL THEN eligible.accountId END,
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END
HAVING (current_group.id IS NULL OR (
MIN(current_group.group_status)='ACTIVE' AND MAX(current_group.locked_plan_id) IS NULL
AND COUNT(*)=(SELECT COUNT(*) FROM fl_coload_group_member group_member_count
WHERE group_member_count.group_id=current_group.id AND group_member_count.active_flag=1)
AND SUM(CASE WHEN eligible.availablePackageCount=current_member.full_package_count THEN 1 ELSE 0 END)=COUNT(*)
))
<if test="query.customerId != null">
AND SUM(CASE WHEN eligible.accountId=#{query.customerId} THEN 1 ELSE 0 END)&gt;0
</if>
<if test="query.inboundCode != null and query.inboundCode.trim() != ''">
AND SUM(CASE WHEN LOWER(eligible.inboundCode) LIKE CONCAT('%',LOWER(#{query.inboundCode}),'%') THEN 1 ELSE 0 END)&gt;0
</if>
<if test="query.expectedArrivalStart != null or query.expectedArrivalEnd != null">
AND SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL
<if test="query.expectedArrivalStart != null">AND DATE(eligible.expectedArrivalAt)&gt;=#{query.expectedArrivalStart}</if>
<if test="query.expectedArrivalEnd != null">AND DATE(eligible.expectedArrivalAt)&lt;=#{query.expectedArrivalEnd}</if>
THEN 1 ELSE 0 END)&gt;0
</if>
ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId
</script>
""")
Page<CandidateUnit> globalCandidateUnits(Page<CandidateUnit> page,
@Param("query") InboundCandidateQuery query,@Param("actualChannels") Set<String> actualChannels);
@Select("""
<script>
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
SUM(i.package_count)-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
-COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item
@@ -98,36 +169,44 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount,
COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg,
COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm,
NULL coLoadGroupId,NULL groupNo,NULL memberCount,NULL groupTotalBoxes,
NULL locked,NULL blockReason,o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt
COALESCE(SUM(COALESCE(i.total_volume_cbm,COALESCE(i.unit_volume_cbm,0)*i.package_count))
/ NULLIF(SUM(i.package_count),0),0) unitVolumeCbm,
current_group.id coLoadGroupId,current_group.group_no groupNo,
(SELECT COUNT(*) FROM fl_coload_group_member member_count
WHERE member_count.group_id=current_group.id AND member_count.active_flag=1) memberCount,
(SELECT SUM(member_boxes.full_package_count) FROM fl_coload_group_member member_boxes
WHERE member_boxes.group_id=current_group.id AND member_boxes.active_flag=1) groupTotalBoxes,
CASE WHEN current_group.id IS NULL THEN NULL ELSE FALSE END locked,NULL blockReason,
o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
AND m.status='APPROVED'
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN (
SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count,
COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count))
/ NULLIF(SUM(package_count),0),0) unit_volume_cbm
FROM fl_inbound_measurement_item
WHERE inbound_detail_id IS NOT NULL AND package_count>0
GROUP BY tenant_id,measurement_id,inbound_detail_id
) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE (#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId})
AND o.process_status='WAIT_SPLIT'
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
AND i.inbound_detail_id IS NOT NULL AND i.package_count&gt;0
LEFT JOIN fl_coload_group_member current_member
ON current_member.tenant_id=o.tenant_id AND current_member.inbound_id=o.id AND current_member.active_flag=1
LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status='ACTIVE'
AND current_group.locked_plan_id IS NULL
WHERE o.process_status='WAIT_SPLIT'
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
+COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_inbound_detail_id=i.inbound_detail_id),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC
AND (
<foreach collection="units" item="unit" separator=" OR ">
<choose>
<when test="unit.groupId != null">current_group.id=#{unit.groupId}</when>
<otherwise>(current_group.id IS NULL AND o.tenant_id=#{unit.accountId} AND o.id=#{unit.inboundId})</otherwise>
</choose>
</foreach>
)
GROUP BY o.id,m.id,i.inbound_detail_id,current_group.id,current_group.group_no
HAVING availablePackageCount&gt;0
ORDER BY o.inbounded_at ASC,o.id ASC,i.inbound_detail_id ASC
</script>
""")
List<Candidate> globalCandidates(@Param("warehouseId") Long warehouseId);
List<Candidate> globalCandidatesByUnits(@Param("units") List<CandidateUnit> units);
@Select("""
<script>
@@ -247,4 +326,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
private String channelCode;
private Long availableContainerCount;
}
record CandidateUnit(Long groupId,Long accountId,Long inboundId,
LocalDateTime sortArrivalAt,Long sortInboundId) {}
}
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.common.core.api.PageResult;
@@ -17,6 +18,7 @@ import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
@@ -37,7 +39,14 @@ public class SmartLoadingRepository {
@Resource private InboundOrderMapper inbounds;
public List<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); }
public List<Candidate> globalCandidates(Long warehouseId) { return candidates.globalCandidates(warehouseId); }
public PageResult<CandidateUnit> globalCandidateUnits(InboundCandidateQuery query,
Set<String> actualChannels) {
return PageResult.from(candidates.globalCandidateUnits(
new Page<>(query.page(), query.size()), query, actualChannels));
}
public List<Candidate> globalCandidatesByUnits(List<CandidateUnit> units) {
return units.isEmpty() ? List.of() : candidates.globalCandidatesByUnits(units);
}
public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query,
Set<String> actualChannels) {
return PageResult.from(candidates.workbenchContainers(
@@ -13,6 +13,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.split.service.SplitPreloadService;
import org.springframework.dao.DuplicateKeyException;
@@ -109,46 +110,42 @@ public class SmartLoadingService {
}
/** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */
@Transactional(readOnly = true)
public PageResult<InboundCandidate> inboundCandidates(InboundCandidateQuery query) {
if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null
&& query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) {
throw validation("预计到货开始日期不能晚于结束日期");
}
Set<String> actualChannels = actualChannels(query.channelGroupCodes());
List<InboundCandidate> all = repository.globalCandidates(query.warehouseId()).stream()
if (query.orderStatus() != null && query.orderStatus() != CandidateOrderStatus.WAIT_SPLIT) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
PageResult<CandidateUnit> unitPage = repository.globalCandidateUnits(query, actualChannels);
if (unitPage.records().isEmpty()) {
return new PageResult<>(List.of(), unitPage.page(), unitPage.size(), unitPage.total(), unitPage.pages());
}
List<InboundCandidate> all = repository.globalCandidatesByUnits(unitPage.records()).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList()))
.values().stream().map(this::inboundCandidate).toList();
Map<String, GroupSnapshot> snapshots = new HashMap<>();
Map<Long, List<InboundCandidate>> grouped = new LinkedHashMap<>();
List<InboundCandidate> standalone = new ArrayList<>();
Map<String, InboundCandidate> units = new HashMap<>();
Map<Long, List<InboundCandidate>> grouped = all.stream()
.filter(candidate -> candidate.coLoadGroupId() != null)
.collect(java.util.stream.Collectors.groupingBy(InboundCandidate::coLoadGroupId,
LinkedHashMap::new, java.util.stream.Collectors.toList()));
for (InboundCandidate candidate : all) {
GroupSnapshot group = snapshots.computeIfAbsent(candidate.accountId() + "|" + candidate.inboundId(),
ignored -> group(candidate.accountId(), candidate.inboundId()));
if (group == null) {
standalone.add(candidate);
} else {
grouped.computeIfAbsent(group.groupId(), ignored -> new ArrayList<>()).add(candidate);
if (candidate.coLoadGroupId() == null) {
units.put(unitKey(null, candidate.accountId(), candidate.inboundId()), candidate);
}
}
List<InboundCandidate> units = new ArrayList<>(standalone);
for (List<InboundCandidate> members : grouped.values()) {
GroupSnapshot group = snapshots.get(members.get(0).accountId() + "|" + members.get(0).inboundId());
InboundCandidate aggregate = globalGroupCandidate(group, members);
if (aggregate != null) units.add(aggregate);
for (var entry : grouped.entrySet()) {
InboundCandidate aggregate = globalGroupCandidate(entry.getValue());
if (aggregate != null) units.put(unitKey(entry.getKey(), null, null), aggregate);
}
units = units.stream().filter(candidate -> matches(query, actualChannels, candidate))
.sorted(Comparator.comparing(InboundCandidate::expectedArrivalAt,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(InboundCandidate::inboundId, Comparator.nullsLast(Comparator.naturalOrder())))
.toList();
long total = units.size();
long offset = query.page() - 1 > Long.MAX_VALUE / query.size()
? Long.MAX_VALUE : (query.page() - 1) * query.size();
long from = Math.min(total, offset);
long to = Math.min(total, from + query.size());
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(units.subList((int) from, (int) to), query.page(), query.size(), total, pages);
List<InboundCandidate> records = unitPage.records().stream()
.map(unit -> units.get(unitKey(unit.groupId(), unit.accountId(), unit.inboundId())))
.filter(Objects::nonNull).toList();
return new PageResult<>(records, unitPage.page(), unitPage.size(), unitPage.total(), unitPage.pages());
}
public PageResult<WorkbenchContainer> containers(WorkbenchContainerQuery query) {
@@ -180,18 +177,16 @@ public class SmartLoadingService {
first.expectedArrivalAt(), List.of());
}
private InboundCandidate globalGroupCandidate(GroupSnapshot group, List<InboundCandidate> availableMembers) {
if (group == null || !"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) return null;
Map<Long, InboundCandidate> byInbound = availableMembers.stream()
.collect(java.util.stream.Collectors.toMap(InboundCandidate::inboundId, value -> value, (a, b) -> a));
for (Member member : group.members()) {
InboundCandidate candidate = byInbound.get(member.inboundId());
if (candidate == null || !Objects.equals(candidate.accountId(), member.tenantId())
|| !Objects.equals(candidate.availablePackageCount(), member.fullPackageCount())) return null;
}
List<InboundCandidate> ordered = group.members().stream().map(member -> byInbound.get(member.inboundId())).toList();
private InboundCandidate globalGroupCandidate(List<InboundCandidate> availableMembers) {
if (availableMembers.isEmpty()) return null;
InboundCandidate metadata = availableMembers.get(0);
if (metadata.coLoadGroupId() == null || metadata.memberCount() == null
|| metadata.groupTotalBoxes() == null || availableMembers.size() != metadata.memberCount()) return null;
List<InboundCandidate> ordered = availableMembers.stream()
.sorted(Comparator.comparing(InboundCandidate::inboundId)).toList();
InboundCandidate first = ordered.get(0);
int packages = ordered.stream().map(InboundCandidate::availablePackageCount).mapToInt(Integer::intValue).sum();
if (packages != metadata.groupTotalBoxes()) return null;
BigDecimal volume = ordered.stream().map(InboundCandidate::availableVolumeCbm)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP);
BigDecimal gross = weightedAverage(ordered, InboundCandidate::unitGrossWeightKg, packages);
@@ -209,12 +204,16 @@ public class SmartLoadingService {
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
customers, goods, packages, gross, net, averageVolume, volume, group.groupId(), group.groupNo(),
group.members().size(), group.fullPackageCount(), false, null, first.orderStatusSnapshot(),
customers, goods, packages, gross, net, averageVolume, volume, metadata.coLoadGroupId(), metadata.groupNo(),
metadata.memberCount(), metadata.groupTotalBoxes(), false, null, first.orderStatusSnapshot(),
ordered.stream().map(InboundCandidate::expectedArrivalAt).filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null),
groupMembers);
}
private static String unitKey(Long groupId, Long accountId, Long inboundId) {
return groupId == null ? "I|" + accountId + '|' + inboundId : "G|" + groupId;
}
private static BigDecimal weightedAverage(List<InboundCandidate> members,
java.util.function.Function<InboundCandidate, BigDecimal> getter,
int packages) {
@@ -236,36 +235,6 @@ public class SmartLoadingService {
return actual;
}
private static boolean matches(InboundCandidateQuery query, Set<String> actualChannels,
InboundCandidate candidate) {
if (!actualChannels.contains(candidate.channelCode())) return false;
if (query.customerId() != null && !Objects.equals(candidate.accountId(), query.customerId())
&& candidate.groupMembers().stream().noneMatch(
member -> Objects.equals(member.accountId(), query.customerId()))) return false;
String inboundCode = trim(query.inboundCode());
if (StringUtils.hasText(inboundCode) && candidate.groupMembers().stream()
.map(GroupMemberCandidate::inboundCode).filter(Objects::nonNull)
.noneMatch(value -> value.toLowerCase(Locale.ROOT).contains(inboundCode.toLowerCase(Locale.ROOT)))
&& (candidate.inboundCode() == null || !candidate.inboundCode().toLowerCase(Locale.ROOT)
.contains(inboundCode.toLowerCase(Locale.ROOT)))) return false;
if (query.expectedArrivalStart() != null || query.expectedArrivalEnd() != null) {
boolean arrivalMatches = candidate.groupMembers().isEmpty()
? inArrivalRange(candidate.expectedArrivalAt(), query)
: candidate.groupMembers().stream().anyMatch(member -> inArrivalRange(
member.expectedArrivalAt(), query));
if (!arrivalMatches) return false;
}
return query.orderStatus() == null || query.orderStatus() == CandidateOrderStatus.WAIT_SPLIT;
}
private static boolean inArrivalRange(LocalDateTime arrival, InboundCandidateQuery query) {
if (arrival == null) return false;
return (query.expectedArrivalStart() == null
|| !arrival.toLocalDate().isBefore(query.expectedArrivalStart()))
&& (query.expectedArrivalEnd() == null
|| !arrival.toLocalDate().isAfter(query.expectedArrivalEnd()));
}
public Plan draftByBooking(long bookingId) {
ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity plan = repository.planByBooking(bookingId);
@@ -76,6 +76,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
KEY idx_inbound_arrival (tenant_id, expected_arrival_at),
KEY idx_inbound_destination (tenant_id, destination_code, created_at),
KEY idx_inbound_channel (tenant_id, channel_code, created_at),
KEY idx_inbound_smart_loading (warehouse_id, process_status, channel_code, expected_arrival_at, id),
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
@@ -247,6 +248,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
PRIMARY KEY (id),
UNIQUE KEY uk_measure_external_event (tenant_id, source_code, external_event_no),
KEY idx_measure_inbound_time (tenant_id, inbound_id, created_at),
KEY idx_measure_inbound_status_id (tenant_id, inbound_id, status, id),
CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库长宽高测量批次和第三方原始数据';
@@ -287,6 +289,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no),
UNIQUE KEY uk_measure_box_code (tenant_id, box_code),
KEY idx_measure_item_candidate (tenant_id, measurement_id, inbound_detail_id),
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
@@ -207,6 +207,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
UNIQUE KEY uk_inbound_split_batch_source_child (tenant_id, split_batch_id, source_measurement_item_id, child_inbound_id),
KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
KEY idx_inbound_split_candidate (tenant_id, parent_inbound_id, source_inbound_detail_id),
CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0),
CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
@@ -301,6 +301,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
KEY idx_loading_item_account (account_id, created_at, id),
KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted),
KEY idx_loading_item_order (account_id, order_id, deleted),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
@@ -3,12 +3,17 @@ package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.Set;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@@ -68,15 +73,54 @@ class SmartLoadingCandidateMapperContractTest {
}
@Test
void globalCandidateQueryHasOptionalWarehouseAndNoContainerJoin() throws Exception {
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidates", Long.class);
String sql = String.join(" ", list.getAnnotation(Select.class).value());
void globalCandidateQueryPagesUnitsBeforeLoadingDetailsAndHasNoContainerJoin() throws Exception {
Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits",
Page.class, InboundCandidateQuery.class, Set.class);
String unitSql = String.join(" ", units.getAnnotation(Select.class).value());
Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"globalCandidatesByUnits", List.class);
String detailSql = String.join(" ", details.getAnnotation(Select.class).value());
assertThat(sql)
.contains("#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId}")
assertThat(unitSql)
.contains("#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId}")
.contains("o.channel_code IN")
.contains("o.process_status='WAIT_SPLIT'")
.contains("current_group.id groupId")
.contains("group_member_count.group_id=current_group.id")
.contains("availablePackageCount=current_member.full_package_count")
.doesNotContain("fl_container_booking")
.doesNotContain("bookingId");
assertThat(detailSql)
.contains("collection=\"units\"")
.contains("current_group.id=#{unit.groupId}")
.contains("o.tenant_id=#{unit.accountId}")
.contains("o.id=#{unit.inboundId}")
.doesNotContain("fl_container_booking");
}
@Test
void globalCandidateDynamicSqlRendersForStandaloneAndGroupedUnits() throws Exception {
Configuration configuration = new Configuration();
XMLLanguageDriver driver = new XMLLanguageDriver();
Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits",
Page.class, InboundCandidateQuery.class, Set.class);
String unitScript = String.join(" ", units.getAnnotation(Select.class).value());
var query = new InboundCandidateQuery(List.of("MATSON_CLX"), 100L, 9L, "IN-",
java.time.LocalDate.parse("2026-09-01"), java.time.LocalDate.parse("2026-09-30"),
null, 1, 20);
String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class)
.getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql();
assertThat(renderedUnits).contains("o.channel_code IN", "DATE(eligible.expectedArrivalAt)");
Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"globalCandidatesByUnits", List.class);
String detailScript = String.join(" ", details.getAnnotation(Select.class).value());
List<SmartLoadingCandidateMapper.CandidateUnit> page = List.of(
new SmartLoadingCandidateMapper.CandidateUnit(null, 9L, 10L, null, 10L),
new SmartLoadingCandidateMapper.CandidateUnit(700L, null, null, null, 11L));
String renderedDetails = driver.createSqlSource(configuration, detailScript, Map.class)
.getBoundSql(Map.of("units", page)).getSql();
assertThat(renderedDetails).contains("current_group.id=?", "o.tenant_id=?", "o.id=?");
}
@Test
@@ -100,6 +100,26 @@ class SmartLoadingSqlContractTest {
.doesNotContain("split_item.source_measurement_item_id=i.id");
}
@Test
@DisplayName("全局候选分页具备平台维度和关联明细索引")
void shouldProvideGlobalCandidatePagingIndexes() throws Exception {
assertThat(new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8))
.contains("idx_inbound_smart_loading (warehouse_id, process_status, channel_code, expected_arrival_at, id)")
.contains("idx_measure_inbound_status_id (tenant_id, inbound_id, status, id)")
.contains("idx_measure_item_candidate (tenant_id, measurement_id, inbound_detail_id)");
assertThat(migration())
.contains("idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted)");
String unified = unifiedMigration();
assertThat(unified)
.contains("CREATE PROCEDURE baiyue_firstleg.ensure_smart_loading_candidate_indexes()")
.contains("ADD KEY idx_inbound_smart_loading")
.contains("ADD KEY idx_measure_inbound_status_id")
.contains("ADD KEY idx_measure_item_candidate")
.contains("ADD KEY idx_loading_item_candidate")
.contains("ADD KEY idx_inbound_split_candidate");
}
@Test
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
@@ -29,6 +30,7 @@ import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit;
import com.baiyue.firstleg.split.service.SplitPreloadService;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
@@ -62,6 +64,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
@@ -156,10 +159,14 @@ class SmartLoadingServiceTest {
"MATSON_CLX_TRUCK", "美森正班海卡");
Candidate max = withChannel(candidate(19L, 11L, 21L, 31L, 6),
"MATSON_MAX_TRUCK", "美森加班海卡");
when(repository.globalCandidates(null)).thenReturn(List.of(clx, max));
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), null, null, null, null, null, null, 1, 20);
CandidateUnit unit = new CandidateUnit(null, 9L, 10L, null, 10L);
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(List.of(unit), 1, 20, 1, 1));
when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(clx));
var result = service.inboundCandidates(new InboundCandidateQuery(
List.of("MATSON_CLX"), null, null, null, null, null, null, 1, 20));
var result = service.inboundCandidates(query);
assertThat(result.total()).isEqualTo(1);
assertThat(result.pages()).isEqualTo(1);
@@ -167,7 +174,9 @@ class SmartLoadingServiceTest {
assertThat(candidate.inboundId()).isEqualTo(10L);
assertThat(candidate.channelCode()).isEqualTo("MATSON_CLX_TRUCK");
});
verify(repository).globalCandidates(null);
verify(repository).globalCandidateUnits(eq(query), anySet());
verify(repository).globalCandidatesByUnits(List.of(unit));
verify(coloadGroups, never()).findByInbound(anyLong(), anyLong());
verify(repository, never()).booking(anyLong());
}
@@ -194,19 +203,20 @@ class SmartLoadingServiceTest {
@Test
void treatsWholeColoadGroupAsOneStablePagingUnit() {
Candidate first = withArrival(withChannel(candidate(9L, 10L, 20L, 30L, 4),
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-01T09:00:00");
Candidate second = withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6),
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00");
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 30L, 10L, "I10", 4), new Member(19L, 31L, 11L, "I11", 6));
when(repository.globalCandidates(100L)).thenReturn(List.of(first, second));
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
var result = service.inboundCandidates(new InboundCandidateQuery(
Candidate first = withGroup(withArrival(withChannel(candidate(9L, 10L, 20L, 30L, 4),
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-01T09:00:00"), 2, 10);
Candidate second = withGroup(withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6),
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"), 2, 10);
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-10"),
LocalDate.parse("2026-09-10"), null, 1, 1));
LocalDate.parse("2026-09-10"), null, 1, 1);
CandidateUnit unit = new CandidateUnit(700L, null, null,
LocalDateTime.parse("2026-09-01T09:00:00"), 10L);
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(List.of(unit), 1, 1, 1, 1));
when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(first, second));
var result = service.inboundCandidates(query);
assertThat(result.total()).isEqualTo(1);
assertThat(result.records()).singleElement().satisfies(candidate -> {
@@ -219,18 +229,16 @@ class SmartLoadingServiceTest {
@Test
void excludesIncompleteColoadGroupInsteadOfPublishingPartialMember() {
Candidate first = withChannel(candidate(9L, 10L, 20L, 30L, 4),
"MATSON_CLX_TRUCK", "美森正班海卡");
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 30L, 10L, "I10", 4), new Member(19L, 31L, 11L, "I11", 6));
when(repository.globalCandidates(100L)).thenReturn(List.of(first));
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), 100L, null, null, null, null, null, 1, 20);
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
var result = service.inboundCandidates(new InboundCandidateQuery(
List.of("MATSON_CLX"), 100L, null, null, null, null, null, 1, 20));
var result = service.inboundCandidates(query);
assertThat(result.records()).isEmpty();
assertThat(result.total()).isZero();
verify(repository, never()).globalCandidatesByUnits(anyList());
}
@Test
@@ -1223,6 +1231,15 @@ class SmartLoadingServiceTest {
value.orderStatusSnapshot(), LocalDateTime.parse(expectedArrivalAt));
}
private static Candidate withGroup(Candidate value, int memberCount, int groupTotalBoxes) {
return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(),
value.orderNo(), value.inboundCode(), value.warehouseId(), value.channelCode(), value.channelName(),
value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(),
value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(),
value.unitNetWeightKg(), value.unitVolumeCbm(), 700L, "CLG-700", memberCount, groupTotalBoxes,
false, null, value.orderStatusSnapshot(), value.expectedArrivalAt());
}
private static ChannelRow channel(String channelCode, String channelName) {
ChannelRow value = new ChannelRow();
value.setChannelCode(channelCode); value.setChannelName(channelName);