diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java index 4649c137d..f28a3347c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java @@ -47,6 +47,16 @@ public class ProcurementWorkflowController { public ApiResponse dispatch(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Command request) { return ApiResponse.success(service.dispatch(customerId,orderNo,batchId,platform(user,customerId),request)); } + @PutMapping("/{orderNo}/outbounds/{batchId}") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')") + public ApiResponse editOutbound(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody OutboundEdit request) { + return ApiResponse.success(service.editOutbound(customerId,orderNo,batchId,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/outbounds/{batchId}/cancel") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')") + public ApiResponse cancelOutbound(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Command request) { + return ApiResponse.success(service.cancelOutbound(customerId,orderNo,batchId,platform(user,customerId),request)); + } /** 上传签收凭证并完成本批出库。 */ @PostMapping("/{orderNo}/outbounds/{batchId}/complete") @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java index 0e2d0cc89..ec8a42e2a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java @@ -12,6 +12,8 @@ public final class ProcurementWorkflowDtos { @Positive long quantity,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { } public record Outbound(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, @Positive long quantity,@NotBlank @Size(max=255) String trackingNo) { } + public record OutboundEdit(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, + @Positive long quantity,@NotBlank @Size(max=255) String trackingNo) { } public record Completion(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, @NotEmpty @Size(max=20) List<@Positive Long> fileIds) { } public record ReceiptRow(Long id,long quantity,List fileIds,LocalDateTime receivedAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java index 586704ab6..c62ed6103 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java @@ -28,4 +28,7 @@ public interface ProcurementWorkflowMapper { /** 派送和签收均要求批次属于已锁定订单。 */ @Update("UPDATE ll_procurement_workflow_outbound SET status=#{status},file_ids=#{files},completed_at=#{completedAt} WHERE id=#{id} AND order_id=#{orderId}") void updateOutbound(@Param("id") long id,@Param("orderId") long orderId,@Param("status") String status,@Param("files") String files,@Param("completedAt") java.time.LocalDateTime completedAt); + /** 修改尚未开始派送的批次。 */ + @Update("UPDATE ll_procurement_workflow_outbound SET quantity=#{quantity},tracking_no=#{tracking} WHERE id=#{id} AND order_id=#{orderId} AND status='IN_TRANSIT'") + void editOutbound(@Param("id") long id,@Param("orderId") long orderId,@Param("quantity") long quantity,@Param("tracking") String tracking); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java index 0e4c8842a..3fc42b2db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java @@ -31,6 +31,7 @@ public class ProcurementWorkflowRepository { public void outbound(long id,long orderId,String no,long quantity,String tracking,String operator) { facts.outbound(id,orderId,no,quantity,tracking,operator); } /** 更新出库批次。 */ public void outbound(long id,long orderId,String status,String files,java.time.LocalDateTime completedAt) { facts.updateOutbound(id,orderId,status,files,completedAt); } + public void editOutbound(long id,long orderId,long quantity,String tracking) { facts.editOutbound(id,orderId,quantity,tracking); } /** 文件仅按当前订单及客户返回,不能引用其他采购单文件。 */ public List files(OverseasProcurementOrder order) { return files.selectList(new LambdaQueryWrapper().eq(OverseasProcurementFile::getOrderId,order.getId()).eq(OverseasProcurementFile::getCustomerId,order.getCustomerId())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java index bf4db1291..9445c1027 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java @@ -91,6 +91,31 @@ public class ProcurementWorkflowService { return snapshot(order); } + /** 运输中批次允许调整数量和物流单号,开始派送后立即冻结。 */ + @Transactional + public Workflow editOutbound(String customer,String no,long batchId,String operator,OutboundEdit request) { + var order=order(customer,no,true);String action="EDIT_OUTBOUND:"+batchId; + if(replay(order,request.idempotencyKey(),action,request)) return snapshot(order); + version(order,request.version());var batch=batch(order,batchId); + if(!"IN_TRANSIT".equals(batch.status())) throw conflict("开始派送后不能修改出库单"); + var current=snapshot(order); long available=current.availableQuantity()+batch.quantity(); + if(request.quantity()<=0 || request.quantity()>available) throw conflict("出库数量超过采购可用库存"); + if(request.trackingNo()==null || request.trackingNo().isBlank() || request.trackingNo().length()>255) throw bad("请填写物流跟踪号"); + repository.editOutbound(batchId,order.getId(),request.quantity(),request.trackingNo().trim()); + finish(order,operator,request.idempotencyKey(),action,request); return snapshot(order); + } + + /** 运输中批次允许取消;已开始派送的批次不可取消。 */ + @Transactional + public Workflow cancelOutbound(String customer,String no,long batchId,String operator,Command request) { + var order=order(customer,no,true);String action="CANCEL_OUTBOUND:"+batchId; + if(replay(order,request.idempotencyKey(),action,request)) return snapshot(order); + version(order,request.version());var batch=batch(order,batchId); + if(!"IN_TRANSIT".equals(batch.status())) throw conflict("开始派送后不能取消出库单"); + repository.outbound(batchId,order.getId(),"CANCELLED","[]",null); refreshStatus(order); + finish(order,operator,request.idempotencyKey(),action,request); return snapshot(order); + } + /** 签收凭证关联到本批次;同事务完成批次并扣除采购库存,重试不重复扣减。 */ @Transactional public Workflow complete(String customer,String no,long batchId,String operator,Completion request) { @@ -124,7 +149,7 @@ public class ProcurementWorkflowService { var outbounds=repository.outbounds(order.getId()).stream().map(r->new OutboundRow(number(r,"id"),text(r,"outbound_no"),number(r,"quantity"),text(r,"tracking_no"),text(r,"status"),ids(r.get("file_ids")),date(r.get("created_at")),date(r.get("completed_at")))).toList(); long received=receipts.stream().mapToLong(ReceiptRow::quantity).sum(); long completed=outbounds.stream().filter(b->"COMPLETED".equals(b.status())).mapToLong(OutboundRow::quantity).sum(); - long reserved=outbounds.stream().filter(b->!"COMPLETED".equals(b.status())).mapToLong(OutboundRow::quantity).sum(); + long reserved=outbounds.stream().filter(b->!Set.of("COMPLETED","CANCELLED").contains(b.status())).mapToLong(OutboundRow::quantity).sum(); Set visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds())); var files=visible.isEmpty()?List.of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList(); return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files); diff --git a/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml b/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml index 653e54b37..80417b5d3 100644 --- a/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml @@ -101,6 +101,36 @@ paths: description: 未认证 '403': description: 身份或操作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}: + put: + summary: 修改尚未开始派送的出库单 + description: 仅 IN_TRANSIT 批次可修改数量和物流跟踪号;开始派送后拒绝修改。 + parameters: + - $ref: '#/components/parameters/OrderNo' + - $ref: '#/components/parameters/CustomerId' + - $ref: '#/components/parameters/BatchId' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/OutboundCommand' } + responses: + '200': { $ref: '#/components/responses/WorkflowResponse' } + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}/cancel: + post: + summary: 取消尚未开始派送的出库单 + description: 仅 IN_TRANSIT 批次可取消;取消后释放采购库存占用,开始派送后拒绝取消。 + parameters: + - $ref: '#/components/parameters/OrderNo' + - $ref: '#/components/parameters/CustomerId' + - $ref: '#/components/parameters/BatchId' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Command' } + responses: + '200': { $ref: '#/components/responses/WorkflowResponse' } /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}/complete: post: summary: 上传凭证并完成本批出库 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java index 5eb526cbf..d25776b6f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java @@ -17,7 +17,8 @@ class OverseasProcurementOpenApiContractTest { } String platform = "/api/platform/v1/last-leg/overseas-procurements/{orderNo}"; for (String suffix : new String[]{"/workflow:", "/receipts:", "/request-confirmation:", - "/outbounds:", "/outbounds/{batchId}/dispatch:", "/outbounds/{batchId}/complete:"}) { + "/outbounds:", "/outbounds/{batchId}:", "/outbounds/{batchId}/cancel:", + "/outbounds/{batchId}/dispatch:", "/outbounds/{batchId}/complete:"}) { assertTrue(yaml.contains(platform + suffix), suffix); } String merchant = "/api/merchant/v1/last-leg/overseas-procurements/{orderNo}"; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowAuthorizationTest.java index ea02f1f13..b9ecc1068 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowAuthorizationTest.java @@ -35,6 +35,8 @@ class ProcurementWorkflowAuthorizationTest { controller.read(user,"9","CG-1");verify(service).read("9","CG-1"); assertThatThrownBy(()->controller.receive(user,"9","CG-1",new Receipt(0,"r",1,List.of(1L)))).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(()->controller.outbound(user,"9","CG-1",new Outbound(0,"o",1,"TN"))).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.editOutbound(user,"9","CG-1",1,new OutboundEdit(0,"edit",1,"TN"))).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.cancelOutbound(user,"9","CG-1",1,new Command(0,"cancel"))).isInstanceOf(AccessDeniedException.class); } @Test void receiverCanSubmitConfirmationButCannotShip() { var controller=context.getBean(ProcurementWorkflowController.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java index 54ce44d4a..003c170f2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java @@ -126,7 +126,7 @@ class ProcurementWorkflowPersistenceTest { assertThat(service.read("9","CG-1").stockQuantity()).isEqualTo(10); } - @Test void outboundDoesNotRequireFeeDetailsOrApproval() { + @Test void outboundCanBeEditedOrCancelledBeforeDispatchButNotAfter() { tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L)))); tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask"))); tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok"))); @@ -134,6 +134,21 @@ class ProcurementWorkflowPersistenceTest { var result=tx.execute(s->service.outbound("9","CG-1","7",request)); assertThat(result.reservedQuantity()).isEqualTo(3); assertThat(result.outbounds()).hasSize(1); + long batch=result.outbounds().get(0).id(); + var edited=tx.execute(s->service.editOutbound("9","CG-1",batch,"7",new OutboundEdit(4,"edit",4,"TRACK-EDIT"))); + assertThat(edited.outbounds().get(0).quantity()).isEqualTo(4); + assertThat(edited.outbounds().get(0).trackingNo()).isEqualTo("TRACK-EDIT"); + var cancelled=tx.execute(s->service.cancelOutbound("9","CG-1",batch,"7",new Command(5,"cancel"))); + assertThat(cancelled.outbounds().get(0).status()).isEqualTo("CANCELLED"); + assertThat(cancelled.reservedQuantity()).isZero(); + assertThat(cancelled.availableQuantity()).isEqualTo(10); + var next=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(6,"out2",3,"TRACK-2"))); + long activeBatch=next.outbounds().stream().filter(row->"IN_TRANSIT".equals(row.status())).findFirst().orElseThrow().id(); + tx.execute(s->service.dispatch("9","CG-1",activeBatch,"7",new Command(7,"dispatch"))); + assertThatThrownBy(()->tx.execute(s->service.editOutbound("9","CG-1",activeBatch,"7",new OutboundEdit(8,"edit-late",2,"TRACK-LATE")))) + .hasMessageContaining("开始派送后"); + assertThatThrownBy(()->tx.execute(s->service.cancelOutbound("9","CG-1",activeBatch,"7",new Command(8,"cancel-late")))) + .hasMessageContaining("开始派送后"); } private void file(long id,long orderId,String customer,String type) {