发布采购、拼车、预约及匹配端侧修复至灰度 #26

Merged
admin merged 45 commits from test into main 2026-10-12 10:19:14 +08:00
11 changed files with 255 additions and 14 deletions
Showing only changes of commit 43494cbfa3 - Show all commits
@@ -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<Void> 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<PageResult<FbaAppointmentCancellationHistory>> 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<PageResult<FbaAppointmentCancellationSummary>> 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) {
@@ -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) { }
@@ -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) { }
}
@@ -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) { }
@@ -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<Cancellation> cancellations(@Param("number") String number,@Param("size") int size,@Param("offset") long offset);
}
@@ -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<FbaAppointmentCancellationHistory> 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<FbaAppointmentCancellationSummary> 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<FbaAppointmentCancellationHistory> 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) {
@@ -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
@@ -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());
}
@@ -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());
}
@@ -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"));
@@ -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));
}
}