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 shipmentReferences, String remark, String operatorName, Instant verifiedAt) { } public record Context(AppointmentSourceService.Source source, Verification verification, long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, List effectiveShipmentReferences, Long availablePackages, boolean sourceChanged, List allowedActions, List 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 inspectBatch(List refs) { var result=new ArrayList(); for(int start=0;startr.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,AppointmentCargoVerification>(); var allocations=new HashMap,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 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.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> originalShipmentReferences(List 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>(){}),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);} }