2026-10-09 22:04:49 +08:00
|
|
|
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;
|
|
|
|
|
public AppointmentFollowupService(AppointmentSourceService sources, AppointmentFollowupMapper mapper, ObjectMapper json) {
|
|
|
|
|
this.sources = sources; this.mapper = mapper; this.json = json;
|
|
|
|
|
}
|
|
|
|
|
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<Record> history, java.util.Map<String,Integer> versions) { }
|
|
|
|
|
private static final Set<String> STAGES = Set.of("DRAYAGE_CONTACT", "TERMINAL_APPOINTMENT", "WAREHOUSE_RECEIVING", "FBA_RECEIVING");
|
|
|
|
|
|
|
|
|
|
public History history(String sourceKey, String customerId, int page, int size) {
|
|
|
|
|
if (page<1 || size<1 || size>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"分页参数无效");
|
|
|
|
|
long total = mapper.count(sourceKey,customerId);
|
|
|
|
|
if (total == 0) sources.resolve(sourceKey,customerId,null);
|
|
|
|
|
var records = mapper.page(sourceKey,customerId,size,(long)(page-1)*size).stream().map(AppointmentFollowupService::view).toList();
|
|
|
|
|
var versions = new java.util.LinkedHashMap<String,Integer>();
|
|
|
|
|
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) {
|
|
|
|
|
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 = sources.resolve(sourceKey,customerId,null);
|
|
|
|
|
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(source.sourceKey()); value.setCustomerId(source.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());
|
2026-10-09 22:31:51 +08:00
|
|
|
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));
|
2026-10-09 22:04:49 +08:00
|
|
|
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 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));
|
|
|
|
|
}
|
|
|
|
|
}
|