package com.baiyue.lastleg.appointmentplanning; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.core.api.PageResult; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.validation.constraints.*; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.Instant; import java.util.Set; @Service public class AppointmentFollowupService { private final AppointmentSourceService sources; private final AppointmentFollowupMapper mapper; private final ObjectMapper json; private final AppointmentContainerService containers; public AppointmentFollowupService(AppointmentSourceService sources, AppointmentFollowupMapper mapper, ObjectMapper json, AppointmentContainerService containers) { this.sources = sources; this.mapper = mapper; this.json = json; this.containers = containers; } public record Save(@NotBlank @Size(max=32) String stage, @Min(0) int expectedVersion, @NotBlank @Size(max=64) String idempotencyKey, @Size(max=200) String contactedParty, @NotBlank @Size(max=1000) String result, @Size(max=500) String nextAction, LocalDate followupDate) { } public record Record(String stage, int version, String contactedParty, String result, String nextAction, LocalDate followupDate, String operatorName, Instant createdAt) { } public record History(PageResult history, java.util.Map versions) { } private static final Set STAGES = Set.of("DRAYAGE_CONTACT", "TERMINAL_APPOINTMENT", "WAREHOUSE_RECEIVING", "FBA_RECEIVING"); public History history(String sourceKey, String customerId, int page, int size) { requireCargoKey(sourceKey); return history(sourceKey,customerId,page,size,()->sources.resolve(sourceKey,customerId,null)); } public History containerHistory(String containerId,String warehouseId,int page,int size) { return history(AppointmentContainerService.subjectKey(containerId,warehouseId),"0",page,size,()->containers.context(containerId,warehouseId)); } private History history(String sourceKey,String customerId,int page,int size,Runnable verifySource) { if (page<1 || size<1 || size>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"分页参数无效"); long total = mapper.count(sourceKey,customerId); if (total == 0) verifySource.run(); var records = mapper.page(sourceKey,customerId,size,(long)(page-1)*size).stream().map(AppointmentFollowupService::view).toList(); var versions = new java.util.LinkedHashMap(); STAGES.stream().sorted().forEach(stage -> versions.put(stage, mapper.version(sourceKey,customerId,stage))); return new History(new PageResult<>(records,page,size,total,(total+size-1)/size),versions); } @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public Record save(String sourceKey, String customerId, Save request, String operatorId, String operatorName) { requireCargoKey(sourceKey); if(!"FBA_RECEIVING".equals(request.stage())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"提柜及接仓事项请进入整柜跟进,避免按货物重复联系"); return append(sourceKey,customerId,request,operatorId,operatorName,()->{ var source=sources.resolve(sourceKey,customerId,null); return new Subject(source.orderId(),source.canFollowup()); }); } @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public Record saveContainer(String containerId,String warehouseId,Save request,String operatorId,String operatorName) { String key=AppointmentContainerService.subjectKey(containerId,warehouseId); boolean allowed=warehouseId==null ? Set.of("DRAYAGE_CONTACT","TERMINAL_APPOINTMENT").contains(request.stage()) : "WAREHOUSE_RECEIVING".equals(request.stage()); if(!allowed) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择对应柜次或接仓点的跟进事项"); return append(key,"0",request,operatorId,operatorName,()->{ var context=containers.context(containerId,warehouseId); return new Subject(null,context.canFollowup()); }); } private record Subject(Long orderId,boolean canFollowup) { } private Record append(String sourceKey,String customerId,Save request,String operatorId,String operatorName, java.util.function.Supplier resolve) { if (!STAGES.contains(request.stage())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效跟进事项"); if (request.followupDate()!=null && (request.nextAction()==null || request.nextAction().isBlank())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"设置跟进日期时请填写下一步安排"); } final String hash; try { hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((customerId+"\n"+operatorId+"\n"+json.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); } catch (JsonProcessingException | NoSuchAlgorithmException error) { throw new IllegalStateException("无法保存跟进请求",error); } var existing = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey()); if (existing!=null) { if (!hash.equals(existing.getRequestHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"重复请求内容不一致,请核对跟进记录"); return view(existing); } var source = resolve.get(); if (!source.canFollowup()) throw new ResponseStatusException(HttpStatus.CONFLICT,"当前来源仅可查看,请先核对承运状态"); if (mapper.version(sourceKey,customerId,request.stage())!=request.expectedVersion()) throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新,请重新读取后提交"); var value = new AppointmentFollowup(); value.setId(IdWorker.getId()); value.setSourceOrderId(source.orderId()); value.setSourceKey(sourceKey); value.setCustomerId(customerId); value.setStage(request.stage()); value.setVersion(request.expectedVersion()+1); value.setContactedParty(clean(request.contactedParty())); value.setResult(request.result().trim()); value.setNextAction(clean(request.nextAction())); value.setFollowupDate(request.followupDate()); value.setOperatorId(operatorId); value.setOperatorName(clean(operatorName)); value.setCommandKey(request.idempotencyKey()); value.setRequestHash(hash); // Return the same precision that DATETIME(3) can persist, including the first response. value.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(java.time.temporal.ChronoUnit.MILLIS)); try { mapper.insert(value); } catch (org.springframework.dao.DuplicateKeyException conflict) { var raced = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey()); if (raced!=null && hash.equals(raced.getRequestHash())) return view(raced); throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新或请求内容冲突,请重新读取",conflict); } return view(value); } private static String clean(String value) { return value==null || value.isBlank() ? null : value.trim(); } private static void requireCargoKey(String sourceKey) { if(sourceKey==null || !(sourceKey.startsWith("ORDER:") || sourceKey.startsWith("BOOKING:"))) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效货物来源"); } private static Record view(AppointmentFollowup row) { return new Record(row.getStage(),row.getVersion(),row.getContactedParty(),row.getResult(),row.getNextAction(),row.getFollowupDate(),row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC)); } }