fix(lastleg): restore dispatch returns from immutable pick source and expose customer receipts

This commit is contained in:
2026-09-30 12:09:19 +08:00
parent c7f880300c
commit 4a14001a09
11 changed files with 293 additions and 9 deletions
@@ -5,20 +5,24 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
import com.baiyue.lastleg.delivery.service.DeliveryDocumentService;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/delivery-orders")
public class MerchantDeliveryController {
@Resource private DeliveryTransferService service;
@Resource private DeliveryDocumentService documents;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')")
@@ -42,9 +46,29 @@ public class MerchantDeliveryController {
return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo));
}
@GetMapping("/{deliveryNo}/documents/receipt")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')")
public ApiResponse<DeliveryDocumentDtos.MerchantReceipts> receipts(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String deliveryNo) {
return ApiResponse.success(documents.merchantReceipts(customer(user), deliveryNo));
}
@GetMapping("/{deliveryNo}/documents/receipt/files/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')")
public ResponseEntity<byte[]> receiptDownload(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @PathVariable long fileId) {
DeliveryDocumentService.Download downloaded = documents.merchantReceiptDownload(customer(user), deliveryNo, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(downloaded.file().fileName(), StandardCharsets.UTF_8).build().toString())
.body(downloaded.content());
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
if (!"MERCHANT".equals(user.appCode()) || user.tenantId() <= 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
@@ -16,6 +16,7 @@ public final class DeliveryDocumentDtos {
@NotNull @Size(max=20) List<@NotNull @Min(1) Long> fileIds) { }
public record File(Long fileId, String fileName, String contentType, long sizeBytes,
LocalDateTime uploadedAt) { }
public record MerchantReceipts(String deliveryNo, List<File> files) { }
public record Category(DeliveryDocumentCategory category, String status, int fileCount, List<File> files) { }
public record CategorySummary(DeliveryDocumentCategory category, String status, int fileCount) { }
public record Summary(int uploadedCategoryCount, int totalCategoryCount, List<CategorySummary> categories) { }
@@ -37,6 +37,17 @@ public class DeliveryDocumentService {
public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); }
public MerchantReceipts merchantReceipts(String customerId, String deliveryNo) {
Detail snapshot = detail(customerId, deliveryNo);
return new MerchantReceipts(snapshot.deliveryNo(), snapshot.categories().stream()
.filter(category -> category.category() == DeliveryDocumentCategory.RECEIPT)
.flatMap(category -> category.files().stream()).toList());
}
public Download merchantReceiptDownload(String customerId, String deliveryNo, long fileId) {
return download(customerId, deliveryNo, DeliveryDocumentCategory.RECEIPT, fileId, null);
}
public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category,
String operatorId, MultipartFile file) {
DeliveryOrder order = require(customerId, deliveryNo);
@@ -82,8 +82,11 @@ public class DispatchPdaWorkflowService {
public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId,
String operatorId, String operatorName,
DispatchPdaRequests.Pick request) {
DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = order.getCustomerId();
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = found.getCustomerId();
// Transfer and picking serialize on the same dispatch before taking task/inventory locks.
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null) throw conflict("Dispatch PDA task is not available for picking");
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse");
@@ -47,12 +47,24 @@ public interface InventoryCommandMapper {
"AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)")
int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger " +
"WHERE customer_id=#{customerId} AND position_id=#{positionId} AND outbound_order_no=#{outboundNo} " +
"AND sku_code=#{skuCode} AND business_type='SHIP' AND delta_packages<0")
@Select("SELECT COALESCE(-SUM(l.delta_packages),0) FROM ll_inventory_ledger l " +
"JOIN ll_outbound_order o ON o.customer_id=l.customer_id AND o.outbound_no=#{outboundNo} " +
"AND o.overseas_warehouse_id=l.overseas_warehouse_id AND o.status='OUTBOUNDED' " +
"WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId} " +
"AND l.sku_code=#{skuCode} AND l.business_type='SHIP' AND l.delta_packages<0 " +
"AND ((o.outbound_type='DISPATCH_DELIVERY' AND l.business_order_no=o.dispatch_no " +
"AND l.outbound_order_no=o.dispatch_no AND EXISTS (" +
"SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " +
"ON p.customer_id=t.customer_id AND p.task_id=t.id " +
"WHERE t.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " +
"AND t.warehouse_id=o.overseas_warehouse_id AND p.inventory_position_id=l.position_id " +
"AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':'," +
"p.dispatch_item_id,':',p.idempotency_key,':SHIP'))) " +
"OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no))")
long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId,
@Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode);
@Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " +
"WHERE customer_id=#{customerId} AND source_event_id=#{sourceIdentity} " +
"AND sku_code=#{skuCode} AND business_type='RETURN_STOCK_IN' AND delta_packages>0")
@@ -3,13 +3,42 @@ info:
title: 派送单三类资料
version: 1.0.0
description: |
所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。
平台资料入口的customerId必须为真实派送所属客户;客户只读入口范围只取Token,只公开当前派送单已保存的RECEIPT签收文件,不提供上传、修改、历史版本、BOL或装车照片下载。
GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。
RECEIPT 签收文件只有在 PUT 保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。allowedActions 不再返回人工 COMPLETE_DELIVERY。
START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。
资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。
security: [{ bearerAuth: [] }]
paths:
/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
get:
summary: 查询本企业派送单签收资料
description: 权限merchant:warehouse:delivery:list。仅当前已绑定RECEIPT文件,尚无资料返回files空数组;其他客户派送单统一不存在,不接受customerId或历史版本。
responses:
'200':
description: 本企业签收资料或统一业务错误
content:
application/json:
schema:
allOf:
- { $ref: '#/components/schemas/Envelope' }
- type: object
properties: { data: { $ref: '#/components/schemas/MerchantReceipts' } }
/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt/files/{fileId}/download:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
- { in: path, name: fileId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
get:
summary: 下载本企业派送单当前签收文件
description: 权限merchant:warehouse:delivery:list。服务端校验客户、派送单、当前RECEIPT绑定及File对象归属;其他客户、其他派送单、非签收类别、仅上传未保存或已解绑文件返回不存在。文件经服务端代理,附件下载、no-store、nosniff;不返回对象存储地址。
responses:
'200':
description: 原始文件字节;业务失败为统一JSON错误
content:
application/octet-stream: { schema: { type: string, format: binary } }
application/json: { schema: { $ref: '#/components/schemas/Envelope' } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
@@ -103,6 +132,12 @@ components:
CustomerId: { in: query, name: customerId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
Category: { in: path, name: category, required: true, schema: { $ref: '#/components/schemas/CategoryCode' } }
schemas:
MerchantReceipts:
type: object
required: [deliveryNo, files]
properties:
deliveryNo: { type: string }
files: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/File' } }
CategoryCode:
type: string
enum: [LOADING_PHOTO, BOL, RECEIPT]