优化
This commit is contained in:
+3
-2
@@ -28,8 +28,9 @@ public class SmartDispatchController {
|
||||
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
|
||||
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
|
||||
@RequestParam(required=false)List<Long> planIds,@RequestParam(required=false)String orderNo,
|
||||
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) {
|
||||
return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken)));
|
||||
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken,
|
||||
@RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) {
|
||||
return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken,page,size,warehouseTab)));
|
||||
}
|
||||
/** 只列出当前拥有者未失效草稿。 */
|
||||
@GetMapping("/drafts")
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 四种来源共用数据库分页;页外来源不进入测量、完整组和预约的逐组装配。 */
|
||||
@Mapper
|
||||
public interface DispatchCandidatePageMapper {
|
||||
/** 统计相同筛选下的来源行,不把当前页条数当全仓总数。 */
|
||||
@SelectProvider(type=Sql.class,method="count")
|
||||
long count(@Param("query")CandidateQuery query);
|
||||
/** 稳定排序后在数据库限页,完整组可包含页外成员。 */
|
||||
@SelectProvider(type=Sql.class,method="page")
|
||||
List<DispatchPalletCandidate> page(@Param("query")CandidateQuery query,@Param("offset")long offset);
|
||||
|
||||
/** 复用既有资格SQL,只统一各来源投影的列序,不复制资格或客户隔离规则。 */
|
||||
final class Sql {
|
||||
private static final String FIELDS="planId,planPalletId,warehousePalletId,orderId,customerId,customerName,planNo,orderType,volumeWeightKg,grossWeightKg,containerNo,appointmentAt,expectedLoadingAt,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,packageCount,volumeCbm,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptId,returnReceiptNo,returnCartonSource";
|
||||
private static final Set<String> PLAN_MISSING=Set.of("returnReceiptId","returnReceiptNo","returnCartonSource");
|
||||
private static final Set<String> RETURN_FIELDS=Set.of(("returnReceiptId,returnReceiptNo,orderId,customerId,customerName,orderType,orderNo,orderRefs,inboundCode,warehousePalletId,palletNo,overseasWarehouseId,overseasWarehouseName,overseasWarehouseCode,destinationType,destinationWarehouseCode,fbaWarehouseCode,destinationWarehouseName,deliveryAddress,postalCode,contactName,contactPhone,sourceStage,packageCount").split(","));
|
||||
private static final Set<String> NATIVE_FIELDS=Set.of(("customerId,orderId,overseasWarehouseId,overseasWarehouseName,orderType,orderNo,orderRefs,inboundCode,palletNo,packageCount,sourceStage,returnCartonSource,relabelCartonMarks,fbaWarehouseCode,grossWeightKg,volumeCbm,destinationWarehouseCode").split(","));
|
||||
private static final String BINDINGS="<bind name='customerId' value='null'/><bind name='relabelNo' value='null'/>"
|
||||
+"<bind name='planIds' value='query.planIds'/><bind name='orderNo' value='query.orderNo'/>"
|
||||
+"<bind name='fbaWarehouseCode' value='query.destinationWarehouseCode'/><bind name='fba' value='query.destinationWarehouseCode'/>"
|
||||
+"<bind name='sourceStage' value='query.sourceStage'/><bind name='draftToken' value='query.draftToken'/>";
|
||||
/** 来源字段缺省仅表示该来源没有此字段,不伪造业务编号或实托身份。 */
|
||||
private static String project(String body,Set<String> fields) {
|
||||
return "SELECT "+Arrays.stream(FIELDS.split(",")).map(field->fields.contains(field)?"s."+field:
|
||||
("returnCartonSource".equals(field)?"0":"NULL")+" AS "+field).collect(Collectors.joining(","))
|
||||
+" FROM ("+body+") s";
|
||||
}
|
||||
/** 不同来源用同一组筛选和稳定行标识组合,排序前排除未选来源类型。 */
|
||||
private static String rows() {
|
||||
var planFields=new HashSet<>(Arrays.asList(FIELDS.split(",")));planFields.removeAll(PLAN_MISSING);
|
||||
return "<choose><when test=\"sourceStage != 'RELABEL'\">"+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields)
|
||||
+"</when><otherwise>SELECT "+Arrays.stream(FIELDS.split(",")).map(f->"NULL AS "+f).collect(Collectors.joining(","))+" WHERE 1=0</otherwise></choose>"
|
||||
+"<if test='planIds == null or planIds.isEmpty()'>"
|
||||
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED'\"> UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS)+"</if>"
|
||||
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED' or sourceStage == 'RELABEL'\"> UNION ALL "
|
||||
+project(DispatchCandidateMapper.RELABEL_BODY,planFields)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS)+"</if></if>";
|
||||
}
|
||||
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
|
||||
private static String filtered() {
|
||||
return " FROM ("+rows()+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
|
||||
+"<if test='query.destinationWarehouseCode != null'> AND fbaWarehouseCode=#{query.destinationWarehouseCode}</if>"
|
||||
+"<if test=\"query.warehouseTab == 'LAX'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'</if>"
|
||||
+"<if test=\"query.warehouseTab == 'NYC'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'</if>";
|
||||
}
|
||||
/** 同一事务内统计分页全集。 */
|
||||
public static String count(){return "<script>"+BINDINGS+"SELECT COUNT(*)"+filtered()+"</script>";}
|
||||
/** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */
|
||||
public static String page(){return "<script>"+BINDINGS+"SELECT *"+filtered()
|
||||
+" ORDER BY customerId,orderNo,palletNo,sourceStage,planPalletId,returnReceiptId,orderRefs,returnCartonSource"
|
||||
+" LIMIT #{query.size} OFFSET #{offset}</script>";}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -12,7 +12,8 @@ public final class DispatchPlanningModels {
|
||||
private DispatchPlanningModels() { }
|
||||
|
||||
public record CandidateQuery(String warehouseId, List<Long> planIds, String orderNo,
|
||||
String destinationWarehouseCode, String sourceStage, String draftToken) { }
|
||||
String destinationWarehouseCode, String sourceStage, String draftToken,
|
||||
int page, int size, String warehouseTab) { }
|
||||
|
||||
/** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */
|
||||
public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion,
|
||||
@@ -36,7 +37,9 @@ public final class DispatchPlanningModels {
|
||||
public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List<String> sourceKeys,
|
||||
long packageCount) { }
|
||||
|
||||
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete) { }
|
||||
/** complete只在当前响应包含筛选全集时为true;组内complete独立保证跨页完整来源。 */
|
||||
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete,
|
||||
long total, int page, int size, long pages) { }
|
||||
|
||||
public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity,
|
||||
List<String> sourceKeys) { }
|
||||
|
||||
+7
@@ -11,6 +11,13 @@ import java.util.List;
|
||||
/** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */
|
||||
@Repository
|
||||
public class DispatchSourceRepository {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.mapper.DispatchCandidatePageMapper pages;
|
||||
/** 筛选全集的数据库行数,与分页使用同一只读事务。 */
|
||||
public long candidateCount(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){return pages.count(query);}
|
||||
/** 只返回当前页来源,禁止先全量装配再截取。 */
|
||||
public List<DispatchPalletCandidate> candidatePage(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){
|
||||
return pages.page(query,Math.multiplyExact((long)query.page()-1,query.size()));
|
||||
}
|
||||
private final DispatchCandidateMapper plans;
|
||||
private final ReturnDispatchCandidateMapper returns;
|
||||
private final DispatchSourceMapper source;
|
||||
|
||||
+7
-3
@@ -148,10 +148,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
requirePlatform(actor);
|
||||
if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓");
|
||||
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
|
||||
if(query.page()<1 || query.size()<1 || query.size()>50 || !Set.of("ALL","LAX","NYC").contains(Objects.toString(query.warehouseTab(),"")))throw bad("候选分页或仓库页签不合法");
|
||||
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
|
||||
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
|
||||
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()),query.page(),query.size(),query.warehouseTab());
|
||||
long started=System.nanoTime();
|
||||
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
|
||||
var page=sourceRules.candidatePage(actor.operatorId(),normalized);
|
||||
var rows=page.rows();
|
||||
long candidatesRead=System.nanoTime();
|
||||
var groups=sourceRules.selectionGroups(rows);
|
||||
long groupsRead=System.nanoTime();
|
||||
@@ -192,7 +194,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
"dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}",
|
||||
rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L,
|
||||
(batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L);
|
||||
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
|
||||
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),
|
||||
normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(),
|
||||
(page.total()+normalized.size()-1)/normalized.size());
|
||||
}
|
||||
|
||||
/** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */
|
||||
|
||||
+36
@@ -16,6 +16,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
@Service
|
||||
public class DispatchPdaQueryService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
|
||||
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
|
||||
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
|
||||
@@ -59,6 +61,8 @@ public class DispatchPdaQueryService {
|
||||
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
|
||||
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
|
||||
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
|
||||
if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null)
|
||||
sourcePositions=receivedPlanPositions(actor,item,items);
|
||||
if(normalRelabel(item)) {
|
||||
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
|
||||
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
|
||||
@@ -89,6 +93,38 @@ public class DispatchPdaQueryService {
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
|
||||
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
|
||||
if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of();
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)return List.of();
|
||||
var actual=rows.get(0);
|
||||
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null
|
||||
|| !Objects.equals(actual.customerId(),item.getCustomerId())
|
||||
|| !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
|| !Objects.equals(actual.packageCount(),item.getPackageCount())
|
||||
|| !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|
||||
|| !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of();
|
||||
var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll(
|
||||
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of();
|
||||
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
|
||||
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
|
||||
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||
if(!group.complete() || group.sources().isEmpty())return List.of();
|
||||
for(var member:group.sources()) {
|
||||
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId())
|
||||
|| n(selected.getPackageCount())!=member.packages())return List.of();
|
||||
}
|
||||
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId())
|
||||
|| !Objects.equals(p.palletId(),actual.warehousePalletId()))
|
||||
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of();
|
||||
return stock.stream().filter(p->p.availablePackages()>0)
|
||||
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList();
|
||||
}
|
||||
/** 仓和操作者均来自受信身份,不接收请求自报。 */
|
||||
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
||||
/** 缺失计数不作为业务标识使用。 */
|
||||
|
||||
+11
-21
@@ -212,32 +212,22 @@ public class DispatchService {
|
||||
public record WholeSource(DispatchPalletCandidate candidate,
|
||||
com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { }
|
||||
|
||||
/** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */
|
||||
public List<DispatchPalletCandidate> wholeCandidates(String operator,
|
||||
com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) {
|
||||
/** 总数与当前页同事务读取;页外候选不参与逐组装配。 */
|
||||
public record CandidatePage(List<DispatchPalletCandidate> rows,long total) { }
|
||||
|
||||
/** 草稿所有权先验证,数据库分页后才读取测量和原生退货物理身份。 */
|
||||
public CandidatePage candidatePage(String operator,CandidateQuery query) {
|
||||
validateSourceStage(query.sourceStage());
|
||||
if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream()
|
||||
.noneMatch(d -> query.draftToken().equals(d.getDraftToken())))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源");
|
||||
var result=new java.util.ArrayList<DispatchPalletCandidate>();
|
||||
if(!"RELABEL".equals(query.sourceStage())) result.addAll(wholeSources.availablePlans(query));
|
||||
if(query.planIds()==null || query.planIds().isEmpty()) {
|
||||
if(planIdsForReturns(query.sourceStage())) result.addAll(wholeSources.availableReturns(query));
|
||||
if(query.sourceStage()==null || Set.of("RECEIVED","RELABEL").contains(query.sourceStage())) {
|
||||
var rows=wholeSources.availableRelabel(query);
|
||||
if(rows.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围");
|
||||
result.addAll(measuredRelabelCandidates(rows));
|
||||
result.addAll(returnRelabelStock.wholeCandidates(query.warehouseId(),query.orderNo()));
|
||||
}
|
||||
}
|
||||
if(result.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围");
|
||||
return result.stream().filter(row -> query.destinationWarehouseCode()==null
|
||||
|| query.destinationWarehouseCode().equals(row.fbaWarehouseCode()))
|
||||
.sorted(java.util.Comparator.comparing((DispatchPalletCandidate row) -> Objects.toString(row.customerId(),""))
|
||||
.thenComparing(row -> Objects.toString(row.orderNo(),""))
|
||||
.thenComparing(row -> Objects.toString(row.palletNo(),""))).toList();
|
||||
long total=wholeSources.candidateCount(query);
|
||||
var rows=wholeSources.candidatePage(query);
|
||||
measuredRelabelCandidates(rows.stream().filter(row->"RELABEL".equals(row.sourceStage()) && !row.returnCartonSource()).toList());
|
||||
var nativeRows=rows.stream().filter(DispatchPalletCandidate::returnCartonSource).toList();
|
||||
if(!nativeRows.isEmpty())returnRelabelStock.resolvePage(nativeRows);
|
||||
return new CandidatePage(List.copyOf(rows),total);
|
||||
}
|
||||
|
||||
/** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public List<WholeSource> lockWholeSources(String warehouseId,
|
||||
|
||||
+3
-5
@@ -27,11 +27,9 @@ public class ReturnRelabelDispatchStock {
|
||||
return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo));
|
||||
}
|
||||
|
||||
/** 整车候选只解析已经按仓限定且有界的原生退货记录。 */
|
||||
public List<DispatchPalletCandidate> wholeCandidates(String warehouse,String orderNo) {
|
||||
var rows=candidates.wholeNativeRelabelAvailable(warehouse,null,orderNo,null);
|
||||
if(rows.size()>1000) throw conflict("候选来源超过1000项,请缩小筛选范围");
|
||||
return resolve(rows);
|
||||
/** 分页后仅核实本页原生退货实托;身份失效明确拒绝,不能静默缩页并返回错误总数。 */
|
||||
public void resolvePage(List<DispatchPalletCandidate> rows) {
|
||||
if(resolve(rows).size()!=rows.size())throw conflict("当前页退货托盘的实际仓或状态已变化,请核对后重查");
|
||||
}
|
||||
|
||||
/** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
|
||||
@Mapper
|
||||
public interface DocumentSequenceMapper {
|
||||
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
|
||||
@Insert("INSERT INTO ll_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
|
||||
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
|
||||
int allocate(@Param("key") String key);
|
||||
|
||||
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
|
||||
@Select("SELECT LAST_INSERT_ID()")
|
||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||
Long allocatedValue();
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.repository;
|
||||
|
||||
import com.baiyue.lastleg.mapper.DocumentSequenceMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
|
||||
@Repository
|
||||
public class DocumentSequenceRepository {
|
||||
private final DocumentSequenceMapper mapper;
|
||||
|
||||
/** 只访问所属服务的编号表。 */
|
||||
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public long next(String key) {
|
||||
mapper.allocate(key);
|
||||
Long value = mapper.allocatedValue();
|
||||
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baiyue.common.core.number.DailyDocumentNumber;
|
||||
import com.baiyue.lastleg.repository.DocumentSequenceRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.Clock;
|
||||
import java.util.Set;
|
||||
|
||||
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
|
||||
@Component
|
||||
public class LastlegDocumentNumberGenerator {
|
||||
private static final Set<String> PREFIXES = Set.of("IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR");
|
||||
private final DocumentSequenceRepository sequences;
|
||||
private final Clock clock;
|
||||
|
||||
/** 正式运行使用统一业务时区。 */
|
||||
@Autowired
|
||||
public LastlegDocumentNumberGenerator(DocumentSequenceRepository sequences) {
|
||||
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
|
||||
}
|
||||
|
||||
/** 固定时钟用于跨日及前缀独立性验证。 */
|
||||
LastlegDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
|
||||
this.sequences = sequences;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** 只为本服务已登记的业务类型分配号码。 */
|
||||
public String next(String prefix) {
|
||||
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
|
||||
return DailyDocumentNumber.next(prefix, clock, sequences::next);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -33,8 +33,8 @@ class SmartDispatchControllerTest {
|
||||
|
||||
/** 候选仍委托实际主流程,保留仓库及全部真实筛选。 */
|
||||
@Test void candidatesKeepWarehouseMultiCustomerFilters(){
|
||||
controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1");
|
||||
verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1"));
|
||||
controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX");
|
||||
verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX"));
|
||||
verifyNoInteractions(planning);
|
||||
}
|
||||
/** HTTP实际参数采用destinationWarehouseCode,不能因旧名称使前端筛选静默丢失。 */
|
||||
@@ -43,7 +43,7 @@ class SmartDispatchControllerTest {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of()));
|
||||
mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseId","92").param("destinationWarehouseCode","LAX9"))
|
||||
.andExpect(status().isOk());
|
||||
verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null));verifyNoInteractions(planning);
|
||||
verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null,1,20,"ALL"));verifyNoInteractions(planning);
|
||||
}
|
||||
/** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */
|
||||
@Test void allDraftRoutesDelegateFrozenVersionedRequests(){
|
||||
|
||||
+3
-1
@@ -161,7 +161,7 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
"source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING",
|
||||
"destination_fba_warehouse_code","ONT8");
|
||||
f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED");
|
||||
var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null));
|
||||
var candidates=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,20,"ALL")).rows();
|
||||
var groups=f.rules.selectionGroups(candidates);
|
||||
assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L));
|
||||
var group=groups.get("RELABEL:RL-A:501");
|
||||
@@ -200,7 +200,9 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
}
|
||||
var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json);
|
||||
var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json);
|
||||
f.session.getConfiguration().addMapper(DispatchCandidatePageMapper.class);
|
||||
var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
|
||||
ReflectionTestUtils.setField(sourceRepository,"pages",f.session.getMapper(DispatchCandidatePageMapper.class));
|
||||
ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
|
||||
ReflectionTestUtils.setField(rules,"relabelSources",provider);ReflectionTestUtils.setField(rules,"palletSerial",new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(f.session.getMapper(PhysicalPalletSerialMapper.class))));
|
||||
ReflectionTestUtils.setField(rules,"candidates",f.session.getMapper(DispatchCandidateMapper.class));ReflectionTestUtils.setField(rules,"relabelApplications",f.session.getMapper(RelabelApplicationMapper.class));
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.Stock;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */
|
||||
class DispatchPlanPdaReadPersistenceTest {
|
||||
/** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */
|
||||
@Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception {
|
||||
var f=new Fixture();var before=f.dbState();
|
||||
var task=f.read();
|
||||
assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L);
|
||||
assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3);
|
||||
assertThat(f.dbState()).isEqualTo(before);
|
||||
verify(f.sources).stock("101",501L,false);
|
||||
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
|
||||
verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
|
||||
}
|
||||
/** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */
|
||||
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
|
||||
var f=new Fixture();var row=f.f.candidates.get(1L);
|
||||
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
|
||||
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
|
||||
var f=new Fixture();
|
||||
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
|
||||
when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
|
||||
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
|
||||
null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of()));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
/** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */
|
||||
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
|
||||
var f=new Fixture();
|
||||
for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1),
|
||||
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) {
|
||||
when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
}
|
||||
}
|
||||
/** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */
|
||||
private static final class Fixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchSourceRepository sources=mock(DispatchSourceRepository.class);
|
||||
final DispatchService rules=mock(DispatchService.class);
|
||||
final DispatchPdaQueryService queries;final String number;
|
||||
/** 原车已审核,只清除自然收货尚未补写的实托字段。 */
|
||||
Fixture() throws Exception {
|
||||
var selection=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection))));
|
||||
f.approve(created);number=created.dispatchNo();
|
||||
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'");
|
||||
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
|
||||
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
|
||||
when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L)));
|
||||
when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L));
|
||||
when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
|
||||
when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1)));
|
||||
when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
|
||||
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
|
||||
selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of()));
|
||||
}
|
||||
/** 读取正式PDA入口。 */
|
||||
DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));}
|
||||
/** 比较持久化原明细与主单版本。 */
|
||||
Object dbState(){return List.of(f.db.queryForList("SELECT * FROM ll_dispatch_item"),f.db.queryForList("SELECT record_version FROM ll_dispatch_order"));}
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -58,12 +58,16 @@ class DispatchServiceTest {
|
||||
verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial);
|
||||
}
|
||||
@Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){
|
||||
assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources);
|
||||
assertThatThrownBy(()->service.candidatePage("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign",1,20,"ALL"))).hasMessageContaining("草稿");verifyNoInteractions(sources);
|
||||
}
|
||||
@Test void wholeCandidatesPreserveActualCustomersAndDoNotChooseAHeaderOwner(){
|
||||
var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null);
|
||||
when(sources.availablePlans(query)).thenReturn(List.of(row(2,502,"102"),row(1,501,"101")));
|
||||
assertThat(service.wholeCandidates("operator",query)).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102");
|
||||
@Test void candidatePagePreservesDatabaseOrderAndDoesNotReadOtherRows(){
|
||||
var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null,2,20,"ALL");
|
||||
when(sources.candidateCount(query)).thenReturn(55L);
|
||||
when(sources.candidatePage(query)).thenReturn(List.of(row(1,501,"101"),row(2,502,"102")));
|
||||
var page=service.candidatePage("operator",query);
|
||||
assertThat(page.total()).isEqualTo(55);
|
||||
assertThat(page.rows()).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102");
|
||||
verify(sources).candidateCount(query);verify(sources).candidatePage(query);verifyNoMoreInteractions(sources);
|
||||
}
|
||||
@Test void duplicateAndPartialRelabelSelectionsAreRejectedBeforeMutation(){
|
||||
var app=new RelabelApplication();app.setCustomerId("101");app.setRelabelNo("RL-1");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("TRUCK");app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
|
||||
|
||||
Reference in New Issue
Block a user