diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index ed3327e66..5e5a52ebc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -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);}); diff --git a/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml b/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml index 053dbf9a8..58283d672 100644 --- a/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml @@ -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} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index 96da4fcd1..df073ba1f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java index 06fbbe015..572a140cf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -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,"收货方取消时段"));