183 lines
14 KiB
Java
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);}
|
|
}
|