Files
baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java
T

183 lines
14 KiB
Java

package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.constraints.*;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
@Service
public class AppointmentCargoVerificationService {
private final AppointmentSourceService sources;
private final AppointmentCargoVerificationMapper mapper;
private final ObjectMapper json;
private final AppointmentCargoReferenceRepository references;
/** 核实记录与原货物资料分别读取,已有货件号无需重复录入。 */
public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper,
ObjectMapper json, AppointmentCargoReferenceRepository references) {
this.sources=sources; this.mapper=mapper; this.json=json; this.references=references;
}
public record Save(@NotBlank @Size(max=160) String destinationKey, @Size(max=160) String expectedSourceVersion,
@Min(0) int expectedVersion, @Positive Integer packageCount,
@NotNull @Size(max=100) List<@NotBlank @Size(max=128) String> shipmentReferences,
@Size(max=1000) String remark, @NotBlank @Size(max=64) String idempotencyKey) { }
public record Verification(int version, String sourceVersion, String destinationKey, Integer sourcePackages,
Integer packageCount, List<String> shipmentReferences, String remark,
String operatorName, Instant verifiedAt) { }
public record Context(AppointmentSourceService.Source source, Verification verification,
long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, List<String> effectiveShipmentReferences,
Long availablePackages, boolean sourceChanged, List<String> allowedActions,
List<String> blockReasons) { }
@Transactional(readOnly=true)
public Context get(String sourceKey,String customerId,String destinationKey) {
return inspect(sourceKey,customerId,destinationKey);
}
/** Caller-owned transaction: a handled read prerequisite must not mark the whole work scan rollback-only. */
public Context inspect(String sourceKey,String customerId,String destinationKey) {
var source=sources.resolve(sourceKey,customerId,destinationKey);
return inspect(source);
}
public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) {
public Context requireContext() { if(error!=null) throw error; return context; }
}
/** 在本次批读内共用来源、仓库及原货件资料,不缓存可变业务事实。 */
public List<Inspection> inspectBatch(List<AppointmentCoverageService.SourceRef> refs) {
var result=new ArrayList<Inspection>();
for(int start=0;start<refs.size();start+=100) {
var batch=refs.subList(start,Math.min(start+100,refs.size()));
var resolved=sources.inspectBatch(batch);
var originals=originalShipmentReferences(resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::source).toList());
var valid=resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::reference).toList();
var latest=new HashMap<List<String>,AppointmentCargoVerification>();
var allocations=new HashMap<List<String>,AppointmentCargoVerificationMapper.Allocation>();
if(!valid.isEmpty()) {
mapper.latestBatch(valid).forEach(r->latest.put(List.of(r.getCustomerId(),r.getSourceKey()),r));
mapper.allocatedBatch(valid).forEach(r->allocations.put(List.of(r.customerId(),r.sourceKey()),new AppointmentCargoVerificationMapper.Allocation(r.packages(),r.unknownItems())));
}
for(var resolution:resolved) {
var key=List.of(resolution.reference().customerId(),resolution.reference().sourceKey());
result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0)),originals.getOrDefault(key,List.of())):null,resolution.error()));
}
}
return List.copyOf(result);
}
/** 使用已授权来源读取本票核实记录及原有货件资料。 */
public Context inspect(AppointmentSourceService.Source source) {
var row=mapper.latest(source.customerId(),source.sourceKey());
var allocation=mapper.allocated(source.customerId(),source.sourceKey());
return inspect(source,row,allocation,originalShipmentReferences(List.of(source)).getOrDefault(List.of(source.customerId(),source.sourceKey()),List.of()));
}
/** 已保存的货件核实值优先;尚未补录货件时带入原货物的已有编号。 */
private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation,List<String> originals) {
boolean changed=row!=null && changed(row,source);
Integer total=row==null?source.packages():row.getPackageCount();
var reasons=new ArrayList<>(source.blockReasons());
if(changed) reasons.add("原来源资料已变化,请重新核实预约货物资料");
if(total==null || total<=0) reasons.add("箱数尚未核实");
if(allocation.unknownItems()>0) reasons.add("已有预约的分配箱数待核实");
if(total!=null && allocation.packages()>total) reasons.add("已有分配超过当前货量,请先复核原安排");
Long available=changed || total==null || total<=0 || allocation.unknownItems()>0 || allocation.packages()>total
?null:total.longValue()-allocation.packages();
// Actions describe this endpoint's business capability; controller permissions remain mandatory.
var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.<String>of();
var verified=row==null?null:view(row);
var effectiveReferences=verified==null || verified.shipmentReferences().isEmpty()?originals:verified.shipmentReferences();
return new Context(source,verified,allocation.packages(),allocation.unknownItems(),total,effectiveReferences,
available,changed,actions,List.copyOf(reasons));
}
/** 对预约明细和核实表单提供同一批原始货件资料,不改写原订单。 */
public Map<List<String>,List<String>> originalShipmentReferences(List<AppointmentSourceService.Source> sources) {
return references.originalReferences(sources);
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) {
validate(request,operatorId);
String hash=hash(customerId,sourceKey,operatorId,request);
var replay=mapper.command(customerId,sourceKey,request.idempotencyKey());
if(replay!=null) return replay(replay,hash);
var refs=List.of(new AppointmentCoverageService.SourceRef(customerId,sourceKey,request.destinationKey()));
sources.lockForWrite(refs);
var before=sources.resolveBatch(refs).get(0);
if(before.orderId()!=null && mapper.lockSourceOrder(before.orderId())==null) throw conflict("原货源已变化,请重新查询");
var source=sources.resolveBatch(refs).get(0);
if(!Objects.equals(before.orderId(),source.orderId())) throw conflict("货源关联已变化,请重新查询");
// The order/booking lock serializes verification with allocation and invalidates pre-lock facts.
replay=mapper.command(customerId,sourceKey,request.idempotencyKey());
if(replay!=null) return replay(replay,hash);
if(!source.canFollowup()) throw conflict("当前货源已完成、取消或尚未具备安排资格");
if(!Objects.equals(source.sourceVersion(),request.expectedSourceVersion())) throw conflict("原来源版本已变化,请重新核实");
var latest=mapper.latest(customerId,sourceKey);
if((latest==null?0:latest.getVersion())!=request.expectedVersion()) throw conflict("货物核实资料已更新,请刷新后提交");
Integer count=request.packageCount();
if(count!=null && source.packages()!=null && count>source.packages()) throw conflict("核实箱数不能超过原来源箱数,请先核对来源");
var allocation=mapper.allocated(customerId,sourceKey);
if(allocation.packages()>0 && (count==null || count<allocation.packages())) throw conflict("核实箱数不能低于已有预约分配总量");
var row=new AppointmentCargoVerification(); row.setId(IdWorker.getId());row.setCustomerId(customerId);row.setSourceKey(sourceKey);
row.setDestinationKey(source.destinationKey());row.setSourceVersion(source.sourceVersion());row.setSourcePackages(source.packages());
row.setVersion(request.expectedVersion()+1);row.setPackageCount(count);
row.setShipmentReferencesJson(encode(request.shipmentReferences().stream().map(String::trim).distinct().toList()));
row.setRemark(clean(request.remark()));row.setOperatorId(operatorId);row.setOperatorName(clean(operatorName));
row.setCommandKey(request.idempotencyKey());row.setRequestHash(hash);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS));
try { mapper.insert(row); }
catch(DuplicateKeyException error) {
var raced=mapper.command(customerId,sourceKey,request.idempotencyKey());
if(raced!=null) return replay(raced,hash);
throw conflict("核实资料版本已更新,请刷新后提交");
}
return view(row);
}
/** Caller already holds the original source lock when using this evidence to allocate or confirm. */
public Verification current(AppointmentSourceService.Source source) {
var row=mapper.latest(source.customerId(),source.sourceKey());
if(row!=null && changed(row,source)) throw conflict("原来源已变化,请重新核实预约货物资料");
return row==null?null:view(row);
}
private static boolean changed(AppointmentCargoVerification row,AppointmentSourceService.Source source) {
return !Objects.equals(row.getSourceVersion(),source.sourceVersion()) || !Objects.equals(row.getDestinationKey(),source.destinationKey())
|| !Objects.equals(row.getSourcePackages(),source.packages());
}
private Verification replay(AppointmentCargoVerification row,String hash) {
if(!Objects.equals(row.getRequestHash(),hash)) throw conflict("同一保存请求的内容或操作人不一致,请核对核实记录");
return view(row);
}
private Verification view(AppointmentCargoVerification row) {
try {
return new Verification(row.getVersion(),row.getSourceVersion(),row.getDestinationKey(),row.getSourcePackages(),
row.getPackageCount(),json.readValue(row.getShipmentReferencesJson(),new TypeReference<List<String>>(){}),row.getRemark(),
row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC));
} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实资料不可读",error);}
}
private String hash(String customer,String source,String operator,Save request) {
try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((customer+"\n"+source+"\n"+operator+"\n"+encode(request)).getBytes(StandardCharsets.UTF_8)));}
catch(java.security.NoSuchAlgorithmException error) {throw new IllegalStateException(error);}
}
private String encode(Object value) {
try {return json.writeValueAsString(value);} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实资料无法保存",error);}
}
private static void validate(Save request,String operator) {
if(request==null || clean(operator)==null || clean(request.idempotencyKey())==null || request.idempotencyKey().length()>64
|| clean(request.destinationKey())==null || request.destinationKey().length()>160 || request.expectedVersion()<0
|| request.packageCount()!=null && request.packageCount()<=0 || request.shipmentReferences()==null || request.shipmentReferences().size()>100
|| request.shipmentReferences().stream().anyMatch(ref->clean(ref)==null || ref.length()>128)
|| request.remark()!=null && request.remark().length()>1000 || request.expectedSourceVersion()!=null && request.expectedSourceVersion().length()>160)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请核对货物箱数、货件号及保存版本");
}
private static String clean(String value) {return value==null || value.isBlank()?null:value.trim();}
private static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}