fix: require dedicated appointment cancellation command

This commit is contained in:
2026-10-11 14:14:51 +08:00
parent 43494cbfa3
commit 29b562ebf5
4 changed files with 32 additions and 4 deletions
@@ -95,14 +95,15 @@ public class InboundFbaAppointmentService {
}
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FbaAppointmentResponse update(String operatorId,String appointmentNo,UpdateFbaAppointmentRequest request) {
if ("CANCELLED".equals(status(request.details().appointmentStatus()))) throw bad("取消预约请使用取消操作并填写实际取消原因");
return command("UPDATE:"+appointmentNo,operatorId,request.idempotencyKey(),request,()->{
var appointment=find(appointmentNo,true);if(appointment==null) throw missing();
if(!Objects.equals(appointment.getVersion(),request.version())) throw conflict("预约已更新,请重新读取后操作");
if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("已关联有效排车,请先核对并处理运输安排后再改约");
var current=items(appointment.getId());
String status=status(request.details().appointmentStatus());
if(Set.of("FAILED","CANCELLED").contains(status)) {
if(clean(request.details().appointmentRemark())==null) throw bad("取消或未约成时请填写原因");
if("FAILED".equals(status)) {
if(clean(request.details().appointmentRemark())==null) throw bad("未约成时请填写原因");
// Keep the last planned and confirmed times as historical facts.
appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(request.details().appointmentRemark().trim());
current.forEach(item->{item.setActive(false);itemMapper.updateById(item);});
@@ -56,7 +56,7 @@ paths:
'403': {description: 无读取权限}
put:
summary: 按版本更新预约
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台限定,版本及持久化幂等校验;已有有效排车时先处理运输安排。取消/失败必须写原因并保留原确认时间。恢复时重新核对来源、目的仓和占用;不能直接将SUCCESS退回PENDING。'
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台限定,版本及持久化幂等校验;已有有效排车时先处理运输安排。未约成必须写原因并保留原确认时间。禁止通过更新设置CANCELLED,取消必须调用DELETE并通过独立取消权限、实际原因和历史规则。恢复时重新核对来源、目的仓和占用;不能直接将SUCCESS退回PENDING。'
requestBody:
required: true
content:
@@ -220,7 +220,7 @@ components:
confirmationNo: {type: string, maxLength: 128, nullable: true, description: 仅SUCCESS登记收货方的外部约号;同一FBA仓不得重复}
appointmentAt: {type: string, nullable: true, description: 对方确认开始当地时间;SUCCESS必填,PENDING不能当作计划填写}
appointmentEndAt: {type: string, nullable: true, description: 明确结束时间时必须晚于开始时间;未知留空,不推算}
appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED, CANCELLED]}
appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED]}
appointmentRemark: {type: string, maxLength: 1000, nullable: true}
startOffset: {type: string, maxLength: 6, nullable: true, description: 当地重叠时刻的明确时差;必须符合目的仓IANA时区}
endOffset: {type: string, maxLength: 6, nullable: true}
@@ -112,6 +112,20 @@ class FbaAppointmentPersistenceTest {
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class));
}
}
@Test void restoringCancelledAppointmentRechecksClaimsAndPreservesCancellationHistory() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);
var original=service.createFromSources("operator",request("restore-original"));
service.delete("operator",original.appointmentNo(),new CancelFbaAppointmentRequest(0,"仓库取消时段"));
var history=service.cancellationHistory(original.appointmentNo(),1,20).records();
var restored=service.update("operator",original.appointmentNo(),new UpdateFbaAppointmentRequest(request("unused").details(),1,"restore-command"));
assertEquals("PENDING",restored.appointmentStatus());
assertEquals(history,service.cancellationHistory(original.appointmentNo(),1,20).records());
assertEquals("CANCELLED",history.get(0).snapshot().appointmentStatus());
assertEquals("仓库取消时段",history.get(0).cancelReason());
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("overlap-restored")));
}
}
@Test void cancellationHistoryIsPersistedPagedScopedAndDoesNotExposeIdentities() throws Exception {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);
@@ -154,6 +154,19 @@ class InboundFbaAppointmentServiceTest {
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(pending("change"),0,"blocked-update")));
assertThrows(ResponseStatusException.class,()->service.delete("operator",result.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段")));assertTrue(storedItems.stream().allMatch(InboundFbaAppointmentItem::getActive));
}
@Test void ordinaryUpdateCannotCancelOrUseTheExistingRemarkAsCancellationReason() {
var original=service.createFromSources("operator",request("cancel-bypass",pending("原业务备注"),"ORDER:1"));
var details=new FbaAppointmentDetails(null,null,null,null,null,null,"CANCELLED","原业务备注",null,null);
clearInvocations(commands,headers,items);
var error=assertThrows(ResponseStatusException.class,()->service.update("operator",original.appointmentNo(),new UpdateFbaAppointmentRequest(details,0,"bypass")));
assertEquals(400,error.getStatusCode().value());
assertEquals("PENDING",storedHeaders.get(0).getAppointmentStatus());
assertEquals("原业务备注",storedHeaders.get(0).getAppointmentRemark());
assertEquals(0,storedHeaders.get(0).getVersion());
assertTrue(storedItems.stream().allMatch(InboundFbaAppointmentItem::getActive));
verify(commands,never()).begin(anyString(),anyString(),anyString(),anyString(),any());
verify(headers,never()).update(any(InboundFbaAppointment.class),any());
}
@Test void cancellationPreservesConfirmedFactsAndHistoryButCannotRestoreOverAnotherActiveClaim() {
var result=service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1"));
service.delete("operator",result.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段"));