diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 8d71f8b85..ef92711ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -7,6 +7,9 @@ import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse; import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.CancelFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentCancellationHistory; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentCancellationSummary; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; @@ -167,12 +170,32 @@ public class InboundPlanController { @PreAuthorize("hasAuthority('platform:appointment:delete')") @Operation(summary = "删除未关联排车的预约", description = "Required authority: platform:appointment:delete") public ApiResponse deleteFbaAppointment(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String appointmentNo, @RequestParam int version) { + @PathVariable String appointmentNo, @Valid @RequestBody CancelFbaAppointmentRequest request) { requireAppointmentPlatform(user); - appointmentService.delete(String.valueOf(user.userId()), appointmentNo, version); + appointmentService.delete(String.valueOf(user.userId()), appointmentNo, request); return ApiResponse.success(null); } + @GetMapping("/fba-appointments/{appointmentNo}/cancellations") + @PreAuthorize("hasAnyAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "','" + OverseasPermissions.APPOINTMENT_LIST + "')") + @Operation(summary = "分页读取预约取消历史", description = "平台专用;沿用预约详情只读权限,不返回内部身份或原始命令JSON") + public ApiResponse> fbaAppointmentCancellations( + @AuthenticationPrincipal AuthenticatedUser user,@PathVariable String appointmentNo, + @RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size) { + requireAppointmentPlatform(user); + return ApiResponse.success(appointmentService.cancellationHistory(appointmentNo,page,size)); + } + + @GetMapping("/fba-appointments/cancellations") + @PreAuthorize("hasAnyAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "','" + OverseasPermissions.APPOINTMENT_LIST + "')") + @Operation(summary = "分页读取预约取消记录摘要", description = "平台专用;沿用预约详情只读权限,仅取消命令摘要,不逐行查询") + public ApiResponse> fbaAppointmentCancellationRecords( + @AuthenticationPrincipal AuthenticatedUser user,@RequestParam(defaultValue="1") int page, + @RequestParam(defaultValue="20") int size) { + requireAppointmentPlatform(user); + return ApiResponse.success(appointmentService.cancellations(page,size)); + } + private void requireAppointmentPlatform(AuthenticatedUser user) { if (user == null || user.userId() == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/CancelFbaAppointmentRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/CancelFbaAppointmentRequest.java new file mode 100644 index 000000000..765709c0c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/CancelFbaAppointmentRequest.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.*; + +public record CancelFbaAppointmentRequest(@NotNull @Min(0) Integer version, + @NotBlank @Size(max=1000) String cancelReason) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentCancellationHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentCancellationHistory.java new file mode 100644 index 000000000..51aff258f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentCancellationHistory.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** Business-only cancellation history; never exposes command JSON or internal identities. */ +public record FbaAppointmentCancellationHistory(String appointmentNo, Integer version, String cancelReason, + LocalDateTime cancelledAt, Snapshot snapshot) { + public record Snapshot(String fbaWarehouseCode, String appointmentStatus, String appointmentRemark, + LocalDate plannedDate, LocalDateTime plannedAt, LocalDateTime plannedEndAt, + String confirmationNo, LocalDateTime appointmentAt, LocalDateTime appointmentEndAt, + String timezone, int selectedCount) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentCancellationSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentCancellationSummary.java new file mode 100644 index 000000000..183a89749 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentCancellationSummary.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.time.LocalDateTime; + +public record FbaAppointmentCancellationSummary(String appointmentNo,Integer version,String cancelReason, + LocalDateTime cancelledAt,String fbaWarehouseCode,int selectedCount) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/FbaAppointmentCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/FbaAppointmentCommandMapper.java index 7335dd6a7..94f6a69b4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/FbaAppointmentCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/FbaAppointmentCommandMapper.java @@ -11,4 +11,9 @@ public interface FbaAppointmentCommandMapper { int begin(@Param("key") String key,@Param("hash") String hash,@Param("operation") String operation,@Param("operator") String operator,@Param("createdAt") java.time.LocalDateTime createdAt); @Update("UPDATE ll_fba_appointment_command SET appointment_no=#{appointmentNo},response_json=#{response} WHERE command_key=#{key} AND response_json IS NULL") int finish(@Param("key") String key,@Param("appointmentNo") String appointmentNo,@Param("response") String response); + record Cancellation(String responseJson,java.time.LocalDateTime createdAt) { } + @Select("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE operation=CONCAT('CANCEL:',appointment_no) AND response_json IS NOT NULL AND (#{number} IS NULL OR appointment_no=#{number})") + long cancellationCount(@Param("number") String number); + @Select("SELECT response_json AS responseJson,created_at AS createdAt FROM ll_fba_appointment_command WHERE operation=CONCAT('CANCEL:',appointment_no) AND response_json IS NOT NULL AND (#{number} IS NULL OR appointment_no=#{number}) ORDER BY created_at DESC,command_key DESC LIMIT #{size} OFFSET #{offset}") + java.util.List cancellations(@Param("number") String number,@Param("size") int size,@Param("offset") long offset); } 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 fa694aaaf..ed3327e66 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 @@ -120,12 +120,15 @@ public class InboundFbaAppointmentService { }); } @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) - public void delete(String operatorId,String appointmentNo,int version) { - command("CANCEL:"+appointmentNo,operatorId,"CANCEL:"+appointmentNo+":"+version,version,()->{ + public void delete(String operatorId,String appointmentNo,CancelFbaAppointmentRequest request) { + if(request==null || request.version()==null || request.version()<0 || clean(request.cancelReason())==null || request.cancelReason().length()>1000) throw bad("请填写取消原因和有效版本"); + int version=request.version(); + var cancellation=new CancelFbaAppointmentRequest(version,request.cancelReason().trim()); + command("CANCEL:"+appointmentNo,operatorId,"CANCEL:"+appointmentNo+":"+version,cancellation,()->{ var appointment=find(appointmentNo,true);if(appointment==null) throw missing(); if(!Objects.equals(appointment.getVersion(),version)) throw conflict("预约已更新,请重新读取后操作"); if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("预约货物已关联排车,不允许取消,请先处理关联排车"); - appointment.setAppointmentStatus("CANCELLED");appointment.setAppointmentRemark("操作员取消预约安排"); + appointment.setAppointmentStatus("CANCELLED"); appointment.setVersion(version+1);appointment.setUpdatedAt(now());appointmentMapper.updateById(appointment); var rows=items(appointment.getId());rows.forEach(item->{item.setActive(false);itemMapper.updateById(item);}); return response(appointment,rows); @@ -196,13 +199,47 @@ public class InboundFbaAppointmentService { try {commands.begin(key,hash,operation,operator,now());} catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash);} var response=action.get(); - try {if(commands.finish(key,response.appointmentNo(),json.writeValueAsString(response))!=1)throw conflict("预约命令结果未能保存");} + try {if(commands.finish(key,response.appointmentNo(),commandSnapshot(response,request))!=1)throw conflict("预约命令结果未能保存");} catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("无法保存预约命令结果",error);} return response; } + private String commandSnapshot(FbaAppointmentResponse response,Object request) throws com.fasterxml.jackson.core.JsonProcessingException { + var node=json.valueToTree(response); + if(request instanceof CancelFbaAppointmentRequest cancellation) ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("cancelReason",cancellation.cancelReason()); + return json.writeValueAsString(node); + } + private FbaAppointmentResponse readSnapshot(String value) throws com.fasterxml.jackson.core.JsonProcessingException { + var node=(com.fasterxml.jackson.databind.node.ObjectNode)json.readTree(value); + node.remove("cancelReason"); + return json.treeToValue(node,FbaAppointmentResponse.class); + } + @Transactional(readOnly=true) + public PageResult cancellationHistory(String appointmentNo,int page,int size) { + if(page<1 || size<1 || size>100) throw bad("分页参数无效"); + var appointment=find(appointmentNo,false);if(appointment==null) throw missing(); + return readCancellationHistory(appointment.getAppointmentNo(),page,size); + } + @Transactional(readOnly=true) + public PageResult cancellations(int page,int size) { + if(page<1 || size<1 || size>100) throw bad("分页参数无效"); + var history=readCancellationHistory(null,page,size); + return new PageResult<>(history.records().stream().map(row->new FbaAppointmentCancellationSummary(row.appointmentNo(),row.version(),row.cancelReason(),row.cancelledAt(),row.snapshot().fbaWarehouseCode(),row.snapshot().selectedCount())).toList(),page,size,history.total(),history.pages()); + } + private PageResult readCancellationHistory(String number,int page,int size) { + long total=commands.cancellationCount(number); + var records=commands.cancellations(number,size,(long)(page-1)*size).stream().map(row->{ + try { + var snapshot=readSnapshot(row.responseJson()); + var reason=json.readTree(row.responseJson()).path("cancelReason"); + return new FbaAppointmentCancellationHistory(snapshot.appointmentNo(),snapshot.version(),reason.isTextual()?clean(reason.asText()):null,row.createdAt(), + new FbaAppointmentCancellationHistory.Snapshot(snapshot.fbaWarehouseCode(),snapshot.appointmentStatus(),snapshot.appointmentRemark(),snapshot.plannedDate(),snapshot.plannedAt(),snapshot.plannedEndAt(),snapshot.confirmationNo(),snapshot.appointmentAt(),snapshot.appointmentEndAt(),snapshot.timezone(),snapshot.selectedCount())); + } catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("取消历史不可读",error);} + }).toList(); + return new PageResult<>(records,page,size,total,(total+size-1)/size); + } private FbaAppointmentResponse replay(FbaAppointmentCommandMapper.Command value,String hash) { if(value==null || !hash.equals(value.requestHash()) || value.responseJson()==null)throw conflict("重复请求内容不一致或结果尚未就绪,请重新核对"); - try {return json.readValue(value.responseJson(),FbaAppointmentResponse.class);} + try {return readSnapshot(value.responseJson());} catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("预约命令记录不可读",error);} } private InboundFbaAppointment find(String number,boolean lock) { 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 bce37a011..053dbf9a8 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 @@ -34,9 +34,17 @@ paths: schema: {type: string} delete: summary: 取消未关联有效排车的预约并释放占用 - description: '平台专用,权限platform:appointment:delete。校验版本后保留主单、确认事实及明细历史,设置CANCELLED并释放货源占用;不删除客户货物或回退库存。已有关联有效排车时拒绝。取消命令按系统单号、版本和操作者幂等。' - parameters: - - {name: version, in: query, required: true, schema: {type: integer, minimum: 0}} + description: '平台专用,权限platform:appointment:delete。校验版本后保留主单、确认事实及明细历史,设置CANCELLED并释放货源占用;不删除客户货物或回退库存。已有关联有效排车时拒绝。取消原因必填,独立保存于取消命令历史,不覆盖原业务备注;按系统单号、版本、操作者及原因校验幂等,异内容拒绝。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [version, cancelReason] + properties: + version: {type: integer, minimum: 0} + cancelReason: {type: string, minLength: 1, maxLength: 1000, description: 实际取消原因,不能仅含空白} responses: '200': {description: 业务响应,成功code为字符串200;版本冲突或已关联排车返回业务错误} '403': {description: 无删除权限} @@ -63,6 +71,93 @@ paths: responses: '200': {$ref: '#/components/responses/AppointmentResult'} '403': {description: 无写权限} + /api/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations: + get: + summary: 分页发现预约快捷取消记录 + description: '仅平台;权限同详情:platform:appointment:list或platform:overseas-warehouse:inbound-order:list。仅取消命令摘要,不扩展普通编辑历史。服务端分页,不逐行读取详情;按取消时间倒序。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + responses: + '200': + description: code为字符串200;data含records、page、size、total、pages + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + properties: + page: {type: integer} + size: {type: integer} + total: {type: integer, format: int64} + pages: {type: integer, format: int64} + records: + type: array + items: + type: object + properties: + appointmentNo: {type: string} + version: {type: integer} + cancelReason: {type: string, nullable: true} + cancelledAt: {type: string, description: UTC取消时间} + fbaWarehouseCode: {type: string} + selectedCount: {type: integer} + '400': {description: 分页参数无效} + '403': {description: 非平台身份或无读取权限} + /api/platform/v1/last-leg/inbound-plans/fba-appointments/{appointmentNo}/cancellations: + get: + summary: 分页读取此预约的快捷取消命令历史 + description: '仅平台;权限同预约详情:platform:appointment:list或platform:overseas-warehouse:inbound-order:list。按预约业务号限定,最新取消在前。不包含普通编辑记录,不返回原始JSON、命令键或内部账号ID;历史缺原因返回null,不回填。' + parameters: + - {name: appointmentNo, in: path, required: true, schema: {type: string}} + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + responses: + '200': + description: 业务响应,code为字符串200 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + properties: + page: {type: integer} + size: {type: integer} + total: {type: integer, format: int64} + pages: {type: integer, format: int64} + records: + type: array + items: + type: object + properties: + appointmentNo: {type: string} + version: {type: integer, description: 取消后版本} + cancelReason: {type: string, nullable: true} + cancelledAt: {type: string, description: UTC取消命令时间,yyyy-MM-ddTHH:mm:ss} + snapshot: + type: object + properties: + fbaWarehouseCode: {type: string} + appointmentStatus: {type: string} + appointmentRemark: {type: string, nullable: true, description: 原业务备注} + plannedDate: {type: string, format: date, nullable: true} + plannedAt: {type: string, nullable: true} + plannedEndAt: {type: string, nullable: true} + confirmationNo: {type: string, nullable: true} + appointmentAt: {type: string, nullable: true} + appointmentEndAt: {type: string, nullable: true} + timezone: {type: string, nullable: true} + selectedCount: {type: integer} + '400': {description: 分页参数无效} + '403': {description: 非平台身份或无详情读取权限} + '404': {description: 预约不存在} /api/platform/v1/last-leg/inbound-plans/fba-appointments/calendar: get: operationId: fbaAppointmentCalendar diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index 09869395d..c34060f95 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -105,6 +105,10 @@ class InboundPlanControllerTest { assertThatThrownBy(() -> controller.fbaAppointmentCalendar(customer, java.time.LocalDate.of(2026, 10, 1), java.time.LocalDate.of(2026, 11, 1))) .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(() -> controller.fbaAppointmentCancellations(customer,"APT-1",1,20)) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(() -> controller.fbaAppointmentCancellationRecords(customer,1,20)) + .isInstanceOf(ResponseStatusException.class); verify(appointmentService, never()).detail(org.mockito.ArgumentMatchers.any()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java index 18eb8cc43..c9d23feaa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -30,6 +30,12 @@ class InboundPlanPermissionContractTest { } assertEquals("hasAnyAuthority('" + LIST + "','platform:appointment:list')", methods.get("fbaAppointmentDetail").getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAnyAuthority('" + LIST + "','platform:appointment:list')", + methods.get("fbaAppointmentCancellations").getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAnyAuthority('" + LIST + "','platform:appointment:list')", + methods.get("fbaAppointmentCancellationRecords").getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:appointment:delete')", + methods.get("deleteFbaAppointment").getAnnotation(PreAuthorize.class).value()); assertEquals("hasAuthority('platform:appointment:list')", methods.get("fbaAppointmentCalendar").getAnnotation(PreAuthorize.class).value()); } 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 d07023399..96da4fcd1 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 @@ -1,5 +1,7 @@ package com.baiyue.lastleg.inboundplan.service; +import org.springframework.web.server.ResponseStatusException; + import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; import com.baiyue.lastleg.appointmentplanning.*; @@ -102,7 +104,7 @@ class FbaAppointmentPersistenceTest { @Test void cancellationReleasesGeneratedClaimsWhileReplayKeepsItsOriginalSnapshot() { try(var context=new AnnotationConfigApplicationContext(Config.class)) { var service=context.getBean(InboundFbaAppointmentService.class);var first=service.createFromSources("operator",request("first")); - service.delete("operator",first.appointmentNo(),0); + service.delete("operator",first.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段")); var second=service.createFromSources("operator",request("second"));assertNotEquals(first.appointmentNo(),second.appointmentNo()); assertEquals("CANCELLED",service.detail(first.appointmentNo()).appointmentStatus()); assertEquals(first,service.createFromSources("operator",request("first"))); @@ -110,6 +112,35 @@ class FbaAppointmentPersistenceTest { assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class)); } } + @Test void cancellationHistoryIsPersistedPagedScopedAndDoesNotExposeIdentities() throws Exception { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var service=context.getBean(InboundFbaAppointmentService.class); + var original=service.createFromSources("internal-operator",request("history-first")); + var reason=new CancelFbaAppointmentRequest(0,"仓库临时关闭"); + service.delete("internal-operator",original.appointmentNo(),reason); + service.delete("internal-operator",original.appointmentNo(),reason); + var page=service.cancellationHistory(original.appointmentNo(),1,1); + assertEquals(1,service.cancellations(1,1).total()); + assertEquals(original.appointmentNo(),service.cancellations(1,1).records().get(0).appointmentNo()); + assertTrue(service.cancellations(2,1).records().isEmpty()); + assertEquals(1,page.total());assertEquals(1,page.records().size()); + assertEquals("仓库临时关闭",page.records().get(0).cancelReason()); + assertEquals(original.appointmentRemark(),page.records().get(0).snapshot().appointmentRemark()); + assertEquals("CANCELLED",page.records().get(0).snapshot().appointmentStatus()); + var json=new ObjectMapper().findAndRegisterModules().writeValueAsString(page); + assertFalse(json.contains("internal-operator"));assertFalse(json.contains("responseJson"));assertFalse(json.contains("sourceKey")); + assertTrue(service.cancellationHistory(original.appointmentNo(),2,1).records().isEmpty()); + var second=service.createFromSources("internal-operator",request("history-second")); + assertEquals(0,service.cancellationHistory(second.appointmentNo(),1,20).total()); + assertThrows(ResponseStatusException.class,()->service.cancellationHistory("AP-NOT-FOUND",1,20)); + var db=new JdbcTemplate(context.getBean(DataSource.class)); + var mapper=new ObjectMapper().findAndRegisterModules(); + var value=(com.fasterxml.jackson.databind.node.ObjectNode)mapper.readTree(db.queryForObject("SELECT response_json FROM ll_fba_appointment_command WHERE operation=?",String.class,"CANCEL:"+original.appointmentNo())); + value.remove("cancelReason"); + db.update("UPDATE ll_fba_appointment_command SET response_json=? WHERE operation=?",mapper.writeValueAsString(value),"CANCEL:"+original.appointmentNo()); + assertNull(service.cancellationHistory(original.appointmentNo(),1,20).records().get(0).cancelReason()); + } + } @Test void unknownQuantityOnReviewDoesNotSilentlyKeepThePreviousKnownQuantity() { try(var context=new AnnotationConfigApplicationContext(Config.class)) { var service=context.getBean(InboundFbaAppointmentService.class);var saved=service.createFromSources("operator",request("create")); 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 de9930461..06fbbe015 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 @@ -146,17 +146,17 @@ class InboundFbaAppointmentServiceTest { @Test void staleUpdateAndStaleCancellationDoNotWrite() { var result=create("original"); assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(pending(null),2,"stale-update"))); - assertThrows(ResponseStatusException.class,()->service.delete("operator",result.appointmentNo(),2)); + assertThrows(ResponseStatusException.class,()->service.delete("operator",result.appointmentNo(),new CancelFbaAppointmentRequest(2,"收货方取消时段"))); verify(headers,never()).update(any(InboundFbaAppointment.class),any());verify(headers,never()).updateById(any(InboundFbaAppointment.class)); } @Test void activeDispatchBlocksChangingOrCancellingItsAppointment() { var result=create("original");when(query.countActiveDispatches(result.id())).thenReturn(1L); assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(pending("change"),0,"blocked-update"))); - assertThrows(ResponseStatusException.class,()->service.delete("operator",result.appointmentNo(),0));assertTrue(storedItems.stream().allMatch(InboundFbaAppointmentItem::getActive)); + assertThrows(ResponseStatusException.class,()->service.delete("operator",result.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段")));assertTrue(storedItems.stream().allMatch(InboundFbaAppointmentItem::getActive)); } @Test void cancellationPreservesConfirmedFactsAndHistoryButCannotRestoreOverAnotherActiveClaim() { var result=service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1")); - service.delete("operator",result.appointmentNo(),0); + service.delete("operator",result.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段")); assertEquals("CANCELLED",storedHeaders.get(0).getAppointmentStatus());assertEquals(at,storedHeaders.get(0).getAppointmentAt());assertFalse(storedItems.get(0).getActive()); assertEquals(result,service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1"))); when(query.countActiveSources(anyList(),anyList(),eq(result.id()))).thenReturn(1L); @@ -172,4 +172,19 @@ class InboundFbaAppointmentServiceTest { var one=create("first");when(headers.selectList(any())).thenReturn(List.copyOf(storedHeaders));clearInvocations(items); var calendar=service.calendar(from,from.plusDays(30));assertEquals(one.items(),calendar.get(0).items());verify(items,times(1)).selectList(any()); } + @Test void cancellationRequiresReasonAndKeepsOriginalRemarkAndCommandIdentity() { + var original=service.createFromSources("operator",request("cancel-test",pending("保留业务备注"),"ORDER:1")); + for(String reason:Arrays.asList(null," ","x".repeat(1001))) { + assertThrows(ResponseStatusException.class,()->service.delete("operator",original.appointmentNo(),new CancelFbaAppointmentRequest(0,reason))); + } + var cancel=new CancelFbaAppointmentRequest(0,"收货方取消时段"); + service.delete("operator",original.appointmentNo(),cancel); + service.delete("operator",original.appointmentNo(),cancel); + assertEquals("保留业务备注",service.detail(original.appointmentNo()).appointmentRemark()); + assertEquals(1,service.detail(original.appointmentNo()).version()); + assertThrows(ResponseStatusException.class,()->service.delete("operator",original.appointmentNo(),new CancelFbaAppointmentRequest(0,"不同原因"))); + assertThrows(ResponseStatusException.class,()->service.delete("another-operator",original.appointmentNo(),cancel)); + assertThrows(ResponseStatusException.class,()->service.cancellationHistory(original.appointmentNo(),0,20)); + assertThrows(ResponseStatusException.class,()->service.cancellationHistory(original.appointmentNo(),1,101)); + } }