功能提交

This commit is contained in:
2026-08-19 15:52:47 +08:00
parent 8bf775f885
commit ce5144d27e
21 changed files with 243 additions and 30 deletions
+2
View File
@@ -61,6 +61,8 @@
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购表的 `inbound_quantity - outbound_quantity` 只表示采购业务单据余量,不得写入或冒充 `available_packages`。审核/下单只能停留在采购单据(或明确的 `procurement_packages` 桶);只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
## 修改与验证
@@ -185,6 +185,7 @@ public class DeliveryTransferService {
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) {
String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo);
order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now);
order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY");
tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId());
history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND");
@@ -9,6 +9,7 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -103,6 +104,7 @@ public class DispatchController {
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchPdaRequests.Depart request) {
requirePdaOperate(user);
String scopedTenant = scope(user, customerId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request));
@@ -121,4 +123,12 @@ public class DispatchController {
}
return String.valueOf(user.tenantId());
}
private void requirePdaOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
DispatchPdaController.OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -15,6 +15,7 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda")
public class DispatchPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@Resource private DispatchPdaWorkflowService service;
@PostMapping("/push")
@@ -22,6 +23,7 @@ public class DispatchPdaController {
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Push request) {
requireOperate(user);
return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request));
}
@GetMapping
@@ -35,6 +37,7 @@ public class DispatchPdaController {
@PathVariable String dispatchNo, @PathVariable long itemId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Pick request) {
requireOperate(user);
return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request));
}
@PostMapping("/complete")
@@ -43,6 +46,7 @@ public class DispatchPdaController {
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
requireOperate(user);
return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
@@ -56,4 +60,10 @@ public class DispatchPdaController {
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
private void requireOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -35,8 +35,6 @@ import java.util.stream.Collectors;
public class DispatchService {
public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_DEPARTURE, "PICKING", "READY_TO_DEPART",
"PARTIAL_READY_TO_DEPART", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED", CANCELLED);
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@@ -185,11 +183,8 @@ public class DispatchService {
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
if (StringUtils.hasText(request.status())) {
String status = request.status().trim().toUpperCase();
if (!STATUSES.contains(status) || CANCELLED.equals(status)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Use cancel endpoint or a valid dispatch status");
}
dispatch.setStatus(status);
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Dispatch status is PDA-managed and cannot be changed by the edit endpoint");
}
dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch);
@@ -42,6 +42,7 @@ public record LastlegOrderSummary(
String relabelStatus,
String palletMergeFlag,
String currentStatus,
String subOrderStatus,
String privateRemark,
String customerRemark,
String warehouseRemark,
@@ -50,6 +50,7 @@ public class LastlegOrder {
private String relabelStatus;
private String palletMergeFlag;
private String currentStatus;
private String subOrderStatus;
private String privateRemark;
private String customerRemark;
private String warehouseRemark;
@@ -23,6 +23,7 @@ import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pda")
public class InboundPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@Resource private InboundPdaWorkflowService service;
@PostMapping("/push")
@@ -30,6 +31,7 @@ public class InboundPdaController {
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.Push request) {
requireOperate(user);
return ApiResponse.success(service.push(scope(user, customerId), planNo, request));
}
@@ -46,6 +48,7 @@ public class InboundPdaController {
@PathVariable long allocationId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
requireOperate(user);
return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request));
}
@@ -55,6 +58,7 @@ public class InboundPdaController {
@PathVariable long allocationId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
requireOperate(user);
return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request));
}
@@ -71,6 +75,7 @@ public class InboundPdaController {
@PathVariable long allocationId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.ConfirmMerge request) {
requireOperate(user);
return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request));
}
@@ -78,6 +83,7 @@ public class InboundPdaController {
public ApiResponse<PdaResponses.Task> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId) {
requireOperate(user);
return ApiResponse.success(service.complete(scope(user, customerId), planNo));
}
@@ -91,4 +97,11 @@ public class InboundPdaController {
}
return String.valueOf(user.tenantId());
}
private void requireOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -13,6 +13,8 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -35,6 +37,7 @@ public class InboundStockReceiptService {
@Resource private InboundStockReceiptMapper receipts;
@Resource private InventoryCommandService inventory;
@Resource private DropshipOrderService dropshipOrders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Transactional
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
@@ -59,6 +62,7 @@ public class InboundStockReceiptService {
.filter(Objects::nonNull).collect(Collectors.toSet());
if (receivedAllocationIds.equals(expectedAllocationIds)) {
activateDropship(customerId, task.getId(), orderById, existing);
completeOrders(customerId, plan, orderById, receivedAt);
return;
}
throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying");
@@ -81,6 +85,29 @@ public class InboundStockReceiptService {
}
}
activateDropship(customerId, task.getId(), orderById, created);
completeOrders(customerId, plan, orderById, receivedAt);
}
private void completeOrders(String customerId, InboundPlan plan, Map<Long, LastlegOrder> orderById,
LocalDateTime completedAt) {
for (LastlegOrder snapshot : orderById.values()) {
LastlegOrder order = orders.lockById(customerId, snapshot.getId());
if (order == null) throw conflict("Inbound tail-leg order disappeared while completing stock-in");
if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) continue;
String before = order.getCurrentStatus();
order.setCurrentStatus("INBOUND_COMPLETED");
order.setSubOrderStatus("INBOUND_PLAN:COMPLETED");
order.setUpdatedAt(completedAt);
orders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setOrderId(order.getId());
history.setFromStatus(before);
history.setToStatus("INBOUND_COMPLETED");
history.setEventId("PDA_INBOUND_COMPLETE:" + plan.getPlanNo());
history.setRemark("PDA inbound completed and inventory stocked in");
history.setCreatedAt(completedAt);
orderHistories.insert(history);
}
}
private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
@@ -21,12 +21,15 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/platform/v1/last-leg/inventory")
@Tag(name = "Tail-leg inbound-order package inventory commands")
public class InventoryCommandController {
public static final String MANAGE_PERMISSION = "lastleg:inventory:manage";
public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust";
@Resource private InventoryCommandService service;
@PostMapping("/hold")
@Operation(summary = "Move available inbound-order packages to hold")
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.hold(scope(user, request.customerId()), operator(user), request));
}
@@ -34,6 +37,7 @@ public class InventoryCommandController {
@Operation(summary = "Release held inbound-order packages")
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request));
}
@@ -41,6 +45,7 @@ public class InventoryCommandController {
@Operation(summary = "Allocate available inbound-order packages to an outbound order")
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.allocate(scope(user, request.customerId()), operator(user), request));
}
@@ -48,6 +53,7 @@ public class InventoryCommandController {
@Operation(summary = "Cancel an outbound allocation")
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.unallocate(scope(user, request.customerId()), operator(user), request));
}
@@ -55,6 +61,7 @@ public class InventoryCommandController {
@Operation(summary = "Confirm picked inbound-order packages")
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.pick(scope(user, request.customerId()), operator(user), request));
}
@@ -62,6 +69,7 @@ public class InventoryCommandController {
@Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty")
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.ship(scope(user, request.customerId()), operator(user), request));
}
@@ -69,6 +77,7 @@ public class InventoryCommandController {
@Operation(summary = "Adjust inbound-order package inventory after a physical count")
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Adjustment request) {
requirePermission(user, ADJUST_PERMISSION);
return ApiResponse.success(service.adjust(scope(user, request.customerId()), operator(user), request));
}
@@ -87,4 +96,11 @@ public class InventoryCommandController {
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private void requirePermission(AuthenticatedUser user, String permission) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(permission)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required");
}
}
}
@@ -82,7 +82,8 @@ public class RelabelApplicationService {
order.setPackageCount(total);
order.setPickedPackageCount(0);
order.setShippedPackageCount(0);
order.setCurrentStatus(PENDING_PROCESSING);
order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING);
order.setRelabelOrderNo(relabelNo);
order.setRelabelStatus(PENDING_PROCESSING);
order.setCustomerRemark(trim(request.remark()));
@@ -117,7 +118,7 @@ public class RelabelApplicationService {
for (RelabelRequests.Item source : request.items()) insertItem(customerId, applicationId, source, now);
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
insertOrderHistory(order.getId(), null, PENDING_PROCESSING, "Relabel application submitted", now);
insertOrderHistory(order.getId(), null, order.getCurrentStatus(), "Relabel application submitted", now);
return detail(customerId, relabelNo);
}
@@ -174,15 +175,23 @@ public class RelabelApplicationService {
LastlegOrder order = orders.selectById(application.getLastlegOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing");
String orderBefore = order.getCurrentStatus();
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
// A return-relabel tail order joins the normal inbound queue after labels are finished.
String orderStatus = "COMPLETED".equals(next) && "RETURN_RELABEL".equals(order.getOrderType())
? "PENDING_ASSIGNMENT" : next;
String orderStatus = switch (next) {
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
? "PENDING_ASSIGNMENT" : "COMPLETED";
case "REJECTED", "CANCELLED" -> "CANCELLED";
default -> mainStatus;
};
order.setCurrentStatus(orderStatus);
order.setSubOrderStatus("RELABEL:" + next);
order.setRelabelStatus(next);
order.setUpdatedAt(now);
orders.updateById(order);
insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now);
insertOrderHistory(order.getId(), orderBefore, orderStatus, request.remark(), now);
if (!Objects.equals(orderBefore, orderStatus)) {
insertOrderHistory(order.getId(), orderBefore, orderStatus, request.remark(), now);
}
return detail(customerId, relabelNo);
}
@@ -126,7 +126,7 @@ public class LastlegOrderService {
List<LastlegOrder> orders = orderMapper.selectList(filter(customerId, query)
.orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000"));
List<String> lines = new ArrayList<>();
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,当前状态,备注(仅自己),备注(客户),备注(仓库)");
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)");
for (LastlegOrder order : orders) {
lines.add(String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()),
@@ -140,7 +140,7 @@ public class LastlegOrderService {
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()),
csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getPrivateRemark()),
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))));
}
return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8);
@@ -189,7 +189,7 @@ public class LastlegOrderService {
- Objects.requireNonNullElse(o.getShippedPackageCount(), 0)), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(),
o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(),
o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(),
o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt());
}
@@ -15,6 +15,7 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/warehouse-transfers")
public class WarehouseTransferController {
public static final String MANAGE_PERMISSION = "lastleg:warehouse-transfer:manage";
@Resource private WarehouseTransferService service;
@PostMapping
@@ -41,6 +42,7 @@ public class WarehouseTransferController {
public ApiResponse<WarehouseTransferResponses.Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Allocate request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.allocate(scope(user, customerId), transferNo, operator(user), request));
}
@@ -48,6 +50,7 @@ public class WarehouseTransferController {
public ApiResponse<WarehouseTransferResponses.Detail> outbound(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Outbound request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.outbound(scope(user, customerId), transferNo, operator(user), request));
}
@@ -55,6 +58,7 @@ public class WarehouseTransferController {
public ApiResponse<WarehouseTransferResponses.Detail> receive(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Receipt request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.receive(scope(user, customerId), transferNo, operator(user), request));
}
@@ -65,6 +69,13 @@ public class WarehouseTransferController {
}
}
private void requirePermission(AuthenticatedUser user, String permission) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(permission)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required");
}
}
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
@@ -225,12 +225,14 @@ public class WarehouseTransferService {
LocalDateTime now = LocalDateTime.now();
String before = order.getCurrentStatus();
transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer);
String orderStatus = APPROVED.equals(next)
? (Boolean.TRUE.equals(transfer.getNeedRelabel()) ? RelabelApplicationService.PENDING_PROCESSING : PENDING_ASSIGNMENT)
: "CANCELLED";
order.setCurrentStatus(orderStatus); order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(order.getId(), before, orderStatus, transferNo,
"Warehouse transfer reviewed: " + next, now);
String orderStatus = APPROVED.equals(next) ? PENDING_ASSIGNMENT : "CANCELLED";
order.setCurrentStatus(orderStatus);
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next);
order.setUpdatedAt(now); orders.updateById(order);
if (!java.util.Objects.equals(before, orderStatus)) {
insertOrderHistory(order.getId(), before, orderStatus, transferNo,
"Warehouse transfer reviewed: " + next, now);
}
return detail(customerId, transferNo);
}
@@ -304,7 +306,8 @@ public class WarehouseTransferService {
transfer.setStatus(ALLOCATED); transfer.setAllocationIdempotencyKey(key);
transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus("READY_TO_OUTBOUND"); order.setUpdatedAt(now);
String before = order.getCurrentStatus(); order.setCurrentStatus("WAIT_OUTBOUND");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:ALLOCATED"); order.setUpdatedAt(now);
orders.updateById(order);
insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo,
"Warehouse transfer inventory allocated", now);
@@ -354,7 +357,8 @@ public class WarehouseTransferService {
transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT"); order.setUpdatedAt(now);
String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now);
orders.updateById(order);
insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo,
"Warehouse transfer left source warehouse", now);
@@ -415,7 +419,8 @@ public class WarehouseTransferService {
|| "RECEIVED".equals(value.getStatus()));
transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? RECEIVED : PARTIALLY_RECEIVED);
String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? "COMPLETED" : "IN_TRANSIT");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + (complete ? RECEIVED : PARTIALLY_RECEIVED));
order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo,
"Warehouse transfer inventory received at destination", now);
@@ -435,7 +440,8 @@ public class WarehouseTransferService {
order.setContainerNo(transferNo);
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setCurrentStatus(PENDING_REVIEW); order.setCustomerRemark(trim(request.remark()));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW);
order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE);
order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now);
orders.insert(order);
@@ -494,6 +500,7 @@ public class WarehouseTransferService {
transfer.setRelabelApplicationId(applicationId); transfer.setRelabelNo(relabelNo); transfer.setUpdatedAt(now);
transfers.updateById(transfer);
order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(RelabelApplicationService.PENDING_PROCESSING);
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + RelabelApplicationService.PENDING_PROCESSING);
order.setUpdatedAt(now); orders.updateById(order);
}
@@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
relabel_status VARCHAR(32) COMMENT '换标状态',
pallet_merge_flag VARCHAR(32) COMMENT '合托标记',
current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态',
sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总',
private_remark VARCHAR(1000) COMMENT '备注(仅自己)',
customer_remark VARCHAR(1000) COMMENT '备注(客户)',
warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)',
@@ -21,6 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.List;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -85,6 +86,24 @@ class DispatchServiceTest {
verify(candidates, never()).available(any(), any(), any(), any());
}
@Test
void editCannotBypassPdaManagedDispatchStatus() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
dispatch.setStatus(DispatchService.PENDING_DEPARTURE);
when(orders.findByDispatchNo("100", "PC1")).thenReturn(dispatch);
when(orders.lockById("100", 1L)).thenReturn(dispatch);
DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null,
LocalDateTime.now(), null, null, null, null, "COMPLETED");
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.update("100", "PC1", request));
assertEquals(400, exception.getStatusCode().value());
assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus());
verify(orders, never()).updateById(any(DispatchOrder.class));
}
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1",
"LIP1-P001", destination, destination, destination, destination, 12,
@@ -13,6 +13,8 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -43,6 +45,7 @@ class InboundStockReceiptServiceTest {
@Mock InboundStockReceiptMapper receipts;
@Mock InventoryCommandService inventory;
@Mock DropshipOrderService dropshipOrders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
private InboundStockReceiptService service;
@BeforeEach
@@ -52,6 +55,7 @@ class InboundStockReceiptServiceTest {
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
}
@@ -74,6 +78,9 @@ class InboundStockReceiptServiceTest {
verify(receipts).insert(receipt.capture());
assertEquals(5L, receipt.getValue().getReceivedPackages());
assertEquals(10L, receipt.getValue().getInventoryPositionId());
assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus());
assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus());
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
@@ -177,6 +184,8 @@ class InboundStockReceiptServiceTest {
LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100");
order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setOverseasWarehouseId("OW-1");
order.setOverseasWarehouseName("Warehouse"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8");
order.setCurrentStatus("INBOUND_PLANNED");
org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order);
LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity);
return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L));
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.inventory.command.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InventoryCommandControllerTest {
@Mock InventoryCommandService service;
private InventoryCommandController controller;
@BeforeEach
void setUp() {
controller = new InventoryCommandController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void inventoryMutationRequiresFormPermission() {
var request = move();
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> controller.hold(user(Set.of()), request));
assertEquals(403, exception.getStatusCode().value());
verify(service, never()).hold(any(), any(), any());
}
@Test
void permittedBackOfficeUserCanRunInventoryFormCommand() {
var request = move();
when(service.hold("100", "7", request)).thenReturn(response());
controller.hold(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request);
verify(service).hold("100", "7", request);
}
@Test
void physicalAdjustmentRequiresDedicatedPermission() {
var request = new InventoryCommandRequests.Adjustment("100", 1L, 2, "COUNT", "ORD-1", "adj-1", null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> controller.adjust(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request));
assertEquals(403, exception.getStatusCode().value());
verify(service, never()).adjust(any(), any(), any());
}
private InventoryCommandRequests.Move move() {
return new InventoryCommandRequests.Move("100", 1L, 2, "ORD-1", null, null, "hold-1", null);
}
private AuthenticatedUser user(Set<String> permissions) {
return new AuthenticatedUser(7L, 100L, 100L, 8L, "operator", "MERCHANT", "WEB", permissions);
}
private InventoryCommandResponse response() {
return new InventoryCommandResponse(1L, "HOLD", 2L, 3L, 0, 2, 0, 0, false, false);
}
}
@@ -88,7 +88,8 @@ class RelabelApplicationServiceTest {
verify(orderHistories).insert(tailHistory.capture());
assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType());
assertEquals(RelabelApplicationService.PENDING_PROCESSING, tailOrder.getValue().getCurrentStatus());
assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
assertEquals("LMO-RELABEL-1", tailOrder.getValue().getOrderNo());
assertEquals(tailOrder.getValue().getInboundCode(), tailOrder.getValue().getSourceInboundId());
@@ -98,7 +99,7 @@ class RelabelApplicationServiceTest {
assertEquals(6, application.getValue().getNotifiedPackageCount());
assertEquals("SKU-A", relabelItem.getValue().getSkuCode());
assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus());
assertEquals("PENDING_PROCESSING", tailHistory.getValue().getToStatus());
assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus());
assertEquals("FBR-1", result.application().relabelNo());
}
@@ -161,7 +162,8 @@ class RelabelApplicationServiceTest {
verify(applications).updateById(application);
ArgumentCaptor<LastlegOrder> updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).updateById(updatedOrder.capture());
assertEquals("PROCESSING", updatedOrder.getValue().getCurrentStatus());
assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus());
verify(histories).insert(any(RelabelStatusHistory.class));
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
}
@@ -114,7 +114,8 @@ class WarehouseTransferServiceTest {
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).insert(tailOrder.capture());
assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus());
assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo());
assertNull(tailOrder.getValue().getTransferWarehouseId());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
@@ -193,7 +194,7 @@ class WarehouseTransferServiceTest {
assertEquals(701L, captured.getValue().getPalletId());
assertEquals(5, captured.getValue().getPackageCount());
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
assertEquals("READY_TO_OUTBOUND", result.orderStatus());
assertEquals("WAIT_OUTBOUND", result.orderStatus());
verifyNoInteractions(inventory);
}